Implement new structure
This commit is contained in:
@ -1,369 +1,82 @@
|
||||
import * as vscode from "vscode";
|
||||
import { CoderApi, VSCodeApi } from "../../typings/api";
|
||||
import { createCSSRule } from "vs/base/browser/dom";
|
||||
import { Emitter, Event } from "vs/base/common/event";
|
||||
import { IDisposable } from "vs/base/common/lifecycle";
|
||||
import { URI } from "vs/base/common/uri";
|
||||
import { generateUuid } from "vs/base/common/uuid";
|
||||
import { localize } from "vs/nls";
|
||||
import { SyncActionDescriptor } from "vs/platform/actions/common/actions";
|
||||
import { CommandsRegistry, ICommandService } from "vs/platform/commands/common/commands";
|
||||
import { IConfigurationService } from "vs/platform/configuration/common/configuration";
|
||||
import { IContextMenuService } from "vs/platform/contextview/browser/contextView";
|
||||
import { FileDeleteOptions, FileOpenOptions, FileOverwriteOptions, FileSystemProviderCapabilities, FileType, FileWriteOptions, IFileChange, IFileService, IFileSystemProvider, IStat, IWatchOptions } from "vs/platform/files/common/files";
|
||||
import { IInstantiationService, ServiceIdentifier } from "vs/platform/instantiation/common/instantiation";
|
||||
import { ServiceCollection } from "vs/platform/instantiation/common/serviceCollection";
|
||||
import { INotificationService } from "vs/platform/notification/common/notification";
|
||||
import { Registry } from "vs/platform/registry/common/platform";
|
||||
import { IStatusbarEntry, IStatusbarEntryAccessor, IStatusbarService, StatusbarAlignment } from "vs/workbench/services/statusbar/common/statusbar";
|
||||
import { IStorageService } from "vs/platform/storage/common/storage";
|
||||
import { ITelemetryService } from "vs/platform/telemetry/common/telemetry";
|
||||
import { IThemeService } from "vs/platform/theme/common/themeService";
|
||||
import { IWorkspaceContextService } from "vs/platform/workspace/common/workspace";
|
||||
import * as extHostTypes from "vs/workbench/api/common/extHostTypes";
|
||||
import { CustomTreeView, CustomTreeViewPane } from "vs/workbench/browser/parts/views/customView";
|
||||
import { ViewContainerViewlet } from "vs/workbench/browser/parts/views/viewsViewlet";
|
||||
import { Extensions as ViewletExtensions, ShowViewletAction, ViewletDescriptor, ViewletRegistry } from "vs/workbench/browser/viewlet";
|
||||
import { Extensions as ActionExtensions, IWorkbenchActionRegistry } from "vs/workbench/common/actions";
|
||||
import { Extensions as ViewsExtensions, ITreeItem, ITreeViewDataProvider, ITreeViewDescriptor, IViewContainersRegistry, IViewsRegistry, TreeItemCollapsibleState } from "vs/workbench/common/views";
|
||||
import { IEditorGroupsService } from "vs/workbench/services/editor/common/editorGroupsService";
|
||||
import { IEditorService } from "vs/workbench/services/editor/common/editorService";
|
||||
import { IExtensionService } from "vs/workbench/services/extensions/common/extensions";
|
||||
import { IWorkbenchLayoutService } from "vs/workbench/services/layout/browser/layoutService";
|
||||
import { IViewletService } from "vs/workbench/services/viewlet/browser/viewlet";
|
||||
import { Application, ApplicationsResponse, CreateSessionResponse, FilesResponse, RecentResponse } from "../common/api"
|
||||
import { ApiEndpoint, HttpCode, HttpError } from "../common/http"
|
||||
|
||||
export interface AuthBody {
|
||||
password: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side implementation of VS Code's API.
|
||||
* TODO: Views aren't quite working.
|
||||
* TODO: Implement menu items for views (for item actions).
|
||||
* TODO: File system provider doesn't work.
|
||||
* Set authenticated status.
|
||||
*/
|
||||
export const vscodeApi = (serviceCollection: ServiceCollection): VSCodeApi => {
|
||||
const getService = <T>(id: ServiceIdentifier<T>): T => serviceCollection.get<T>(id) as T;
|
||||
const commandService = getService(ICommandService);
|
||||
const notificationService = getService(INotificationService);
|
||||
const fileService = getService(IFileService);
|
||||
const viewsRegistry = Registry.as<IViewsRegistry>(ViewsExtensions.ViewsRegistry);
|
||||
const statusbarService = getService(IStatusbarService);
|
||||
|
||||
// It would be nice to just export what VS Code creates but it looks to me
|
||||
// that it assumes it's running in the extension host and wouldn't work here.
|
||||
// It is probably possible to create an extension host that runs in the
|
||||
// browser's main thread, but I'm not sure how much jank that would require.
|
||||
// We could have a web worker host but we want DOM access.
|
||||
return {
|
||||
EventEmitter: <any>Emitter, // It can take T so T | undefined should work.
|
||||
FileSystemError: extHostTypes.FileSystemError,
|
||||
FileType,
|
||||
StatusBarAlignment: extHostTypes.StatusBarAlignment,
|
||||
ThemeColor: extHostTypes.ThemeColor,
|
||||
TreeItemCollapsibleState: extHostTypes.TreeItemCollapsibleState,
|
||||
Uri: URI,
|
||||
commands: {
|
||||
executeCommand: <T = any>(commandId: string, ...args: any[]): Promise<T | undefined> => {
|
||||
return commandService.executeCommand(commandId, ...args);
|
||||
},
|
||||
registerCommand: (id: string, command: (...args: any[]) => any): IDisposable => {
|
||||
return CommandsRegistry.registerCommand(id, command);
|
||||
},
|
||||
},
|
||||
window: {
|
||||
createStatusBarItem(alignmentOrOptions?: extHostTypes.StatusBarAlignment | vscode.window.StatusBarItemOptions, priority?: number): StatusBarEntry {
|
||||
return new StatusBarEntry(statusbarService, alignmentOrOptions, priority);
|
||||
},
|
||||
registerTreeDataProvider: <T>(id: string, dataProvider: vscode.TreeDataProvider<T>): IDisposable => {
|
||||
const tree = new TreeViewDataProvider(dataProvider);
|
||||
const view = viewsRegistry.getView(id);
|
||||
(view as ITreeViewDescriptor).treeView.dataProvider = tree;
|
||||
return {
|
||||
dispose: () => tree.dispose(),
|
||||
};
|
||||
},
|
||||
showErrorMessage: async (message: string): Promise<string | undefined> => {
|
||||
notificationService.error(message);
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
workspace: {
|
||||
registerFileSystemProvider: (scheme: string, provider: vscode.FileSystemProvider): IDisposable => {
|
||||
return fileService.registerProvider(scheme, new FileSystemProvider(provider));
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
export function setAuthed(authed: boolean): void {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
;(window as any).setAuthed(authed)
|
||||
}
|
||||
|
||||
/**
|
||||
* Coder API. This should only provide functionality that can't be made
|
||||
* available through the VS Code API.
|
||||
* Try making a request. Throw an error if the request is anything except OK.
|
||||
* Also set authed to false if the request returns unauthorized.
|
||||
*/
|
||||
export const coderApi = (serviceCollection: ServiceCollection): CoderApi => {
|
||||
const getService = <T>(id: ServiceIdentifier<T>): T => serviceCollection.get<T>(id) as T;
|
||||
return {
|
||||
registerView: (viewId, viewName, containerId, containerName, icon): void => {
|
||||
const cssClass = `extensionViewlet-${containerId}`;
|
||||
const id = `workbench.view.extension.${containerId}`;
|
||||
class CustomViewlet extends ViewContainerViewlet {
|
||||
public constructor(
|
||||
@IConfigurationService configurationService: IConfigurationService,
|
||||
@IWorkbenchLayoutService layoutService: IWorkbenchLayoutService,
|
||||
@ITelemetryService telemetryService: ITelemetryService,
|
||||
@IWorkspaceContextService contextService: IWorkspaceContextService,
|
||||
@IStorageService storageService: IStorageService,
|
||||
@IEditorService _editorService: IEditorService,
|
||||
@IInstantiationService instantiationService: IInstantiationService,
|
||||
@IThemeService themeService: IThemeService,
|
||||
@IContextMenuService contextMenuService: IContextMenuService,
|
||||
@IExtensionService extensionService: IExtensionService,
|
||||
) {
|
||||
super(id, `${id}.state`, true, configurationService, layoutService, telemetryService, storageService, instantiationService, themeService, contextMenuService, extensionService, contextService);
|
||||
}
|
||||
}
|
||||
|
||||
Registry.as<ViewletRegistry>(ViewletExtensions.Viewlets).registerViewlet(
|
||||
ViewletDescriptor.create(CustomViewlet as any, id, containerName, cssClass, undefined, URI.parse(icon)),
|
||||
);
|
||||
|
||||
Registry.as<IWorkbenchActionRegistry>(ActionExtensions.WorkbenchActions).registerWorkbenchAction(
|
||||
SyncActionDescriptor.create(OpenCustomViewletAction as any, id, localize("showViewlet", "Show {0}", containerName)),
|
||||
"View: Show {0}",
|
||||
localize("view", "View"),
|
||||
);
|
||||
|
||||
// Generate CSS to show the icon in the activity bar.
|
||||
const iconClass = `.monaco-workbench .activitybar .monaco-action-bar .action-label.${cssClass}`;
|
||||
createCSSRule(iconClass, `-webkit-mask: url('${icon}') no-repeat 50% 50%`);
|
||||
|
||||
const container = Registry.as<IViewContainersRegistry>(ViewsExtensions.ViewContainersRegistry).registerViewContainer(containerId);
|
||||
Registry.as<IViewsRegistry>(ViewsExtensions.ViewsRegistry).registerViews([{
|
||||
id: viewId,
|
||||
name: viewName,
|
||||
ctorDescriptor: { ctor: CustomTreeViewPane },
|
||||
treeView: getService(IInstantiationService).createInstance(CustomTreeView as any, viewId, container),
|
||||
}] as ITreeViewDescriptor[], container);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
class OpenCustomViewletAction extends ShowViewletAction {
|
||||
public constructor(
|
||||
id: string, label: string,
|
||||
@IViewletService viewletService: IViewletService,
|
||||
@IEditorGroupsService editorGroupService: IEditorGroupsService,
|
||||
@IWorkbenchLayoutService layoutService: IWorkbenchLayoutService,
|
||||
) {
|
||||
super(id, label, id, viewletService, editorGroupService, layoutService);
|
||||
}
|
||||
const tryRequest = async (endpoint: string, options?: RequestInit): Promise<Response> => {
|
||||
const response = await fetch("/api" + endpoint + "/", options)
|
||||
if (response.status === HttpCode.Unauthorized) {
|
||||
setAuthed(false)
|
||||
}
|
||||
if (response.status !== HttpCode.Ok) {
|
||||
const text = await response.text()
|
||||
throw new HttpError(text || response.statusText || "unknown error", response.status)
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
class FileSystemProvider implements IFileSystemProvider {
|
||||
private readonly _onDidChange = new Emitter<IFileChange[]>();
|
||||
|
||||
public readonly onDidChangeFile: Event<IFileChange[]> = this._onDidChange.event;
|
||||
|
||||
public readonly capabilities: FileSystemProviderCapabilities;
|
||||
public readonly onDidChangeCapabilities: Event<void> = Event.None;
|
||||
|
||||
public constructor(private readonly provider: vscode.FileSystemProvider) {
|
||||
this.capabilities = FileSystemProviderCapabilities.Readonly;
|
||||
}
|
||||
|
||||
public watch(resource: URI, opts: IWatchOptions): IDisposable {
|
||||
return this.provider.watch(resource, opts);
|
||||
}
|
||||
|
||||
public async stat(resource: URI): Promise<IStat> {
|
||||
return this.provider.stat(resource);
|
||||
}
|
||||
|
||||
public async readFile(resource: URI): Promise<Uint8Array> {
|
||||
return this.provider.readFile(resource);
|
||||
}
|
||||
|
||||
public async writeFile(resource: URI, content: Uint8Array, opts: FileWriteOptions): Promise<void> {
|
||||
return this.provider.writeFile(resource, content, opts);
|
||||
}
|
||||
|
||||
public async delete(resource: URI, opts: FileDeleteOptions): Promise<void> {
|
||||
return this.provider.delete(resource, opts);
|
||||
}
|
||||
|
||||
public mkdir(_resource: URI): Promise<void> {
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
|
||||
public async readdir(resource: URI): Promise<[string, FileType][]> {
|
||||
return this.provider.readDirectory(resource);
|
||||
}
|
||||
|
||||
public async rename(resource: URI, target: URI, opts: FileOverwriteOptions): Promise<void> {
|
||||
return this.provider.rename(resource, target, opts);
|
||||
}
|
||||
|
||||
public async copy(resource: URI, target: URI, opts: FileOverwriteOptions): Promise<void> {
|
||||
return this.provider.copy!(resource, target, opts);
|
||||
}
|
||||
|
||||
public open(_resource: URI, _opts: FileOpenOptions): Promise<number> {
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
|
||||
public close(_fd: number): Promise<void> {
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
|
||||
public read(_fd: number, _pos: number, _data: Uint8Array, _offset: number, _length: number): Promise<number> {
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
|
||||
public write(_fd: number, _pos: number, _data: Uint8Array, _offset: number, _length: number): Promise<number> {
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
/**
|
||||
* Try authenticating.
|
||||
*/
|
||||
export const authenticate = async (body?: AuthBody): Promise<void> => {
|
||||
let formBody: URLSearchParams | undefined
|
||||
if (body) {
|
||||
formBody = new URLSearchParams()
|
||||
formBody.append("password", body.password)
|
||||
}
|
||||
const response = await tryRequest(ApiEndpoint.login, {
|
||||
method: "POST",
|
||||
body: formBody,
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
|
||||
},
|
||||
})
|
||||
const json = await response.json()
|
||||
if (json && json.success) {
|
||||
setAuthed(true)
|
||||
}
|
||||
}
|
||||
|
||||
class TreeViewDataProvider<T> implements ITreeViewDataProvider {
|
||||
private readonly root = Symbol("root");
|
||||
private readonly values = new Map<string, T>();
|
||||
private readonly children = new Map<T | Symbol, ITreeItem[]>();
|
||||
|
||||
public constructor(private readonly provider: vscode.TreeDataProvider<T>) {}
|
||||
|
||||
public async getChildren(item?: ITreeItem): Promise<ITreeItem[]> {
|
||||
const value = item && this.itemToValue(item);
|
||||
const children = await Promise.all(
|
||||
(await this.provider.getChildren(value) || [])
|
||||
.map(async (childValue) => {
|
||||
const treeItem = await this.provider.getTreeItem(childValue);
|
||||
const handle = this.createHandle(treeItem);
|
||||
this.values.set(handle, childValue);
|
||||
return {
|
||||
handle,
|
||||
collapsibleState: TreeItemCollapsibleState.Collapsed,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
this.clear(value || this.root, item);
|
||||
this.children.set(value || this.root, children);
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
throw new Error("not implemented");
|
||||
}
|
||||
|
||||
private itemToValue(item: ITreeItem): T {
|
||||
if (!this.values.has(item.handle)) {
|
||||
throw new Error(`No element found with handle ${item.handle}`);
|
||||
}
|
||||
return this.values.get(item.handle)!;
|
||||
}
|
||||
|
||||
private clear(value: T | Symbol, item?: ITreeItem): void {
|
||||
if (this.children.has(value)) {
|
||||
this.children.get(value)!.map((c) => this.clear(this.itemToValue(c), c));
|
||||
this.children.delete(value);
|
||||
}
|
||||
if (item) {
|
||||
this.values.delete(item.handle);
|
||||
}
|
||||
}
|
||||
|
||||
private createHandle(item: vscode.TreeItem): string {
|
||||
return item.id
|
||||
? `coder-tree-item-id/${item.id}`
|
||||
: `coder-tree-item-uuid/${generateUuid()}`;
|
||||
}
|
||||
export const getFiles = async (): Promise<FilesResponse> => {
|
||||
const response = await tryRequest(ApiEndpoint.files)
|
||||
return response.json()
|
||||
}
|
||||
|
||||
interface IStatusBarEntry extends IStatusbarEntry {
|
||||
alignment: StatusbarAlignment;
|
||||
priority?: number;
|
||||
export const getRecent = async (): Promise<RecentResponse> => {
|
||||
const response = await tryRequest(ApiEndpoint.recent)
|
||||
return response.json()
|
||||
}
|
||||
|
||||
class StatusBarEntry implements vscode.StatusBarItem {
|
||||
private static ID = 0;
|
||||
|
||||
private _id: number;
|
||||
private entry: IStatusBarEntry;
|
||||
private visible?: boolean;
|
||||
private disposed?: boolean;
|
||||
private statusId: string;
|
||||
private statusName: string;
|
||||
private accessor?: IStatusbarEntryAccessor;
|
||||
private timeout: any;
|
||||
|
||||
constructor(private readonly statusbarService: IStatusbarService, alignmentOrOptions?: extHostTypes.StatusBarAlignment | vscode.window.StatusBarItemOptions, priority?: number) {
|
||||
this._id = StatusBarEntry.ID--;
|
||||
if (alignmentOrOptions && typeof alignmentOrOptions !== "number") {
|
||||
this.statusId = alignmentOrOptions.id;
|
||||
this.statusName = alignmentOrOptions.name;
|
||||
this.entry = {
|
||||
alignment: alignmentOrOptions.alignment === extHostTypes.StatusBarAlignment.Right
|
||||
? StatusbarAlignment.RIGHT : StatusbarAlignment.LEFT,
|
||||
priority,
|
||||
text: "",
|
||||
};
|
||||
} else {
|
||||
this.statusId = "web-api";
|
||||
this.statusName = "Web API";
|
||||
this.entry = {
|
||||
alignment: alignmentOrOptions === extHostTypes.StatusBarAlignment.Right
|
||||
? StatusbarAlignment.RIGHT : StatusbarAlignment.LEFT,
|
||||
priority,
|
||||
text: "",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public get alignment(): extHostTypes.StatusBarAlignment {
|
||||
return this.entry.alignment === StatusbarAlignment.RIGHT
|
||||
? extHostTypes.StatusBarAlignment.Right : extHostTypes.StatusBarAlignment.Left;
|
||||
}
|
||||
|
||||
public get id(): number { return this._id; }
|
||||
public get priority(): number | undefined { return this.entry.priority; }
|
||||
public get text(): string { return this.entry.text; }
|
||||
public get tooltip(): string | undefined { return this.entry.tooltip; }
|
||||
public get color(): string | extHostTypes.ThemeColor | undefined { return this.entry.color; }
|
||||
public get command(): string | undefined { return this.entry.command; }
|
||||
|
||||
public set text(text: string) { this.update({ text }); }
|
||||
public set tooltip(tooltip: string | undefined) { this.update({ tooltip }); }
|
||||
public set color(color: string | extHostTypes.ThemeColor | undefined) { this.update({ color }); }
|
||||
public set command(command: string | undefined) { this.update({ command }); }
|
||||
|
||||
public show(): void {
|
||||
this.visible = true;
|
||||
this.update();
|
||||
}
|
||||
|
||||
public hide(): void {
|
||||
clearTimeout(this.timeout);
|
||||
this.visible = false;
|
||||
if (this.accessor) {
|
||||
this.accessor.dispose();
|
||||
this.accessor = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private update(values?: Partial<IStatusBarEntry>): void {
|
||||
this.entry = { ...this.entry, ...values };
|
||||
if (this.disposed || !this.visible) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(this.timeout);
|
||||
this.timeout = setTimeout(() => {
|
||||
if (!this.accessor) {
|
||||
this.accessor = this.statusbarService.addEntry(this.entry, this.statusId, this.statusName, this.entry.alignment, this.priority);
|
||||
} else {
|
||||
this.accessor.update(this.entry);
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this.hide();
|
||||
this.disposed = true;
|
||||
}
|
||||
export const getApplications = async (): Promise<ApplicationsResponse> => {
|
||||
const response = await tryRequest(ApiEndpoint.applications)
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export const getSession = async (app: Application): Promise<CreateSessionResponse> => {
|
||||
const response = await tryRequest(ApiEndpoint.session, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(app),
|
||||
})
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export const killSession = async (app: Application): Promise<Response> => {
|
||||
return tryRequest(ApiEndpoint.session, {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify(app),
|
||||
})
|
||||
}
|
||||
|
18
src/browser/app.css
Normal file
18
src/browser/app.css
Normal file
@ -0,0 +1,18 @@
|
||||
html,
|
||||
body,
|
||||
#root,
|
||||
iframe {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
iframe {
|
||||
border: none;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #272727;
|
||||
margin: 0;
|
||||
font-family: 'IBM Plex Sans', sans-serif;
|
||||
overflow: hidden;
|
||||
}
|
37
src/browser/app.tsx
Normal file
37
src/browser/app.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import * as React from "react"
|
||||
import { Application } from "../common/api"
|
||||
import { Route, Switch } from "react-router-dom"
|
||||
import { HttpError } from "../common/http"
|
||||
import { Modal } from "./components/modal"
|
||||
import { getOptions } from "../common/util"
|
||||
|
||||
const App: React.FunctionComponent = () => {
|
||||
const [authed, setAuthed] = React.useState<boolean>(false)
|
||||
const [app, setApp] = React.useState<Application>()
|
||||
const [error, setError] = React.useState<HttpError | Error | string>()
|
||||
|
||||
React.useEffect(() => {
|
||||
getOptions()
|
||||
}, [])
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
;(window as any).setAuthed = setAuthed
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Switch>
|
||||
<Route path="/vscode" render={(): React.ReactElement => <iframe id="iframe" src="/vscode-embed"></iframe>} />
|
||||
<Route
|
||||
path="/"
|
||||
render={(): React.ReactElement => (
|
||||
<Modal app={app} setApp={setApp} authed={authed} error={error} setError={setError} />
|
||||
)}
|
||||
/>
|
||||
</Switch>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
@ -1,133 +0,0 @@
|
||||
import { Emitter } from "vs/base/common/event";
|
||||
import { URI } from "vs/base/common/uri";
|
||||
import { localize } from "vs/nls";
|
||||
import { Extensions, IConfigurationRegistry } from "vs/platform/configuration/common/configurationRegistry";
|
||||
import { registerSingleton } from "vs/platform/instantiation/common/extensions";
|
||||
import { ServiceCollection } from "vs/platform/instantiation/common/serviceCollection";
|
||||
import { ILocalizationsService } from "vs/platform/localizations/common/localizations";
|
||||
import { INotificationService, Severity } from "vs/platform/notification/common/notification";
|
||||
import { Registry } from "vs/platform/registry/common/platform";
|
||||
import { PersistentConnectionEventType } from "vs/platform/remote/common/remoteAgentConnection";
|
||||
import { ITelemetryService } from "vs/platform/telemetry/common/telemetry";
|
||||
import { coderApi, vscodeApi } from "vs/server/src/browser/api";
|
||||
import { INodeProxyService, NodeProxyChannelClient } from "vs/server/src/common/nodeProxy";
|
||||
import { TelemetryChannelClient } from "vs/server/src/common/telemetry";
|
||||
import { split } from "vs/server/src/common/util";
|
||||
import "vs/workbench/contrib/localizations/browser/localizations.contribution";
|
||||
import { LocalizationsService } from "vs/workbench/services/localizations/electron-browser/localizationsService";
|
||||
import { IRemoteAgentService } from "vs/workbench/services/remote/common/remoteAgentService";
|
||||
|
||||
class TelemetryService extends TelemetryChannelClient {
|
||||
public constructor(
|
||||
@IRemoteAgentService remoteAgentService: IRemoteAgentService,
|
||||
) {
|
||||
super(remoteAgentService.getConnection()!.getChannel("telemetry"));
|
||||
}
|
||||
}
|
||||
|
||||
const TELEMETRY_SECTION_ID = "telemetry";
|
||||
|
||||
Registry.as<IConfigurationRegistry>(Extensions.Configuration).registerConfiguration({
|
||||
"id": TELEMETRY_SECTION_ID,
|
||||
"order": 110,
|
||||
"type": "object",
|
||||
"title": localize("telemetryConfigurationTitle", "Telemetry"),
|
||||
"properties": {
|
||||
"telemetry.enableTelemetry": {
|
||||
"type": "boolean",
|
||||
"description": localize("telemetry.enableTelemetry", "Enable usage data and errors to be sent to a Microsoft online service."),
|
||||
"default": true,
|
||||
"tags": ["usesOnlineServices"]
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
class NodeProxyService extends NodeProxyChannelClient implements INodeProxyService {
|
||||
private readonly _onClose = new Emitter<void>();
|
||||
public readonly onClose = this._onClose.event;
|
||||
private readonly _onDown = new Emitter<void>();
|
||||
public readonly onDown = this._onDown.event;
|
||||
private readonly _onUp = new Emitter<void>();
|
||||
public readonly onUp = this._onUp.event;
|
||||
|
||||
public constructor(
|
||||
@IRemoteAgentService remoteAgentService: IRemoteAgentService,
|
||||
) {
|
||||
super(remoteAgentService.getConnection()!.getChannel("nodeProxy"));
|
||||
remoteAgentService.getConnection()!.onDidStateChange((state) => {
|
||||
switch (state.type) {
|
||||
case PersistentConnectionEventType.ConnectionGain:
|
||||
return this._onUp.fire();
|
||||
case PersistentConnectionEventType.ConnectionLost:
|
||||
return this._onDown.fire();
|
||||
case PersistentConnectionEventType.ReconnectionPermanentFailure:
|
||||
return this._onClose.fire();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
registerSingleton(ILocalizationsService, LocalizationsService);
|
||||
registerSingleton(INodeProxyService, NodeProxyService);
|
||||
registerSingleton(ITelemetryService, TelemetryService);
|
||||
|
||||
/**
|
||||
* This is called by vs/workbench/browser/web.main.ts after the workbench has
|
||||
* been initialized so we can initialize our own client-side code.
|
||||
*/
|
||||
export const initialize = async (services: ServiceCollection): Promise<void> => {
|
||||
const target = window as any;
|
||||
target.ide = coderApi(services);
|
||||
target.vscode = vscodeApi(services);
|
||||
|
||||
const event = new CustomEvent("ide-ready");
|
||||
(event as any).ide = target.ide;
|
||||
(event as any).vscode = target.vscode;
|
||||
window.dispatchEvent(event);
|
||||
|
||||
if (!window.isSecureContext) {
|
||||
(services.get(INotificationService) as INotificationService).notify({
|
||||
severity: Severity.Warning,
|
||||
message: "code-server is being accessed over an insecure domain. Some functionality may not work as expected.",
|
||||
actions: {
|
||||
primary: [{
|
||||
id: "understand",
|
||||
label: "I understand",
|
||||
tooltip: "",
|
||||
class: undefined,
|
||||
enabled: true,
|
||||
checked: true,
|
||||
dispose: () => undefined,
|
||||
run: () => {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}],
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export interface Query {
|
||||
[key: string]: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the URL modified with the specified query variables. It's pretty
|
||||
* stupid so it probably doesn't cover any edge cases. Undefined values will
|
||||
* unset existing values. Doesn't allow duplicates.
|
||||
*/
|
||||
export const withQuery = (url: string, replace: Query): string => {
|
||||
const uri = URI.parse(url);
|
||||
const query = { ...replace };
|
||||
uri.query.split("&").forEach((kv) => {
|
||||
const [key, value] = split(kv, "=");
|
||||
if (!(key in query)) {
|
||||
query[key] = value;
|
||||
}
|
||||
});
|
||||
return uri.with({
|
||||
query: Object.keys(query)
|
||||
.filter((k) => typeof query[k] !== "undefined")
|
||||
.map((k) => `${k}=${query[k]}`).join("&"),
|
||||
}).toString(true);
|
||||
};
|
27
src/browser/components/animate.tsx
Normal file
27
src/browser/components/animate.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import * as React from "react"
|
||||
|
||||
export interface DelayProps {
|
||||
readonly show: boolean
|
||||
readonly delay: number
|
||||
}
|
||||
|
||||
export const Animate: React.FunctionComponent<DelayProps> = (props) => {
|
||||
const [timer, setTimer] = React.useState<NodeJS.Timeout>()
|
||||
const [mount, setMount] = React.useState<boolean>(false)
|
||||
const [visible, setVisible] = React.useState<boolean>(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
if (!props.show) {
|
||||
setVisible(false)
|
||||
setTimer(setTimeout(() => setMount(false), props.delay))
|
||||
} else {
|
||||
setTimer(setTimeout(() => setVisible(true), props.delay))
|
||||
setMount(true)
|
||||
}
|
||||
}, [props])
|
||||
|
||||
return mount ? <div className={`animate -${visible ? "show" : "hide"}`}>{props.children}</div> : null
|
||||
}
|
28
src/browser/components/error.css
Normal file
28
src/browser/components/error.css
Normal file
@ -0,0 +1,28 @@
|
||||
.field-error {
|
||||
color: red;
|
||||
}
|
||||
|
||||
.request-error {
|
||||
align-items: center;
|
||||
color: rgba(0, 0, 0, 0.37);
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
font-weight: 700;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.request-error > .close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #b6b6b6;
|
||||
cursor: pointer;
|
||||
margin-top: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.request-error + .request-error {
|
||||
border-top: 1px solid #b6b6b6;
|
||||
}
|
48
src/browser/components/error.tsx
Normal file
48
src/browser/components/error.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import * as React from "react"
|
||||
import { HttpError } from "../../common/http"
|
||||
|
||||
export interface ErrorProps {
|
||||
error: HttpError | Error | string
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* An error to be displayed in a section where a request has failed.
|
||||
*/
|
||||
export const RequestError: React.FunctionComponent<ErrorProps> = (props) => {
|
||||
return (
|
||||
<div className="request-error">
|
||||
<div className="error">{typeof props.error === "string" ? props.error : props.error.message}</div>
|
||||
{props.onClose ? (
|
||||
<button className="close" onClick={props.onClose}>
|
||||
Go Back
|
||||
</button>
|
||||
) : (
|
||||
undefined
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a more human/natural/useful message for some error codes resulting
|
||||
* from a form submission.
|
||||
*/
|
||||
const humanizeFormError = (error: HttpError | Error | string): string => {
|
||||
if (typeof error === "string") {
|
||||
return error
|
||||
}
|
||||
switch ((error as HttpError).code) {
|
||||
case 401:
|
||||
return "Wrong password"
|
||||
default:
|
||||
return error.message
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An error to be displayed underneath a field.
|
||||
*/
|
||||
export const FieldError: React.FunctionComponent<ErrorProps> = (props) => {
|
||||
return <div className="field-error">{humanizeFormError(props.error)}</div>
|
||||
}
|
108
src/browser/components/list.css
Normal file
108
src/browser/components/list.css
Normal file
@ -0,0 +1,108 @@
|
||||
.app-list {
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
margin: 0 -10px; /* To counter app padding. */
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.app-loader {
|
||||
align-items: center;
|
||||
color: #b6b6b6;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.app-loader > .loader {
|
||||
color: #b6b6b6;
|
||||
}
|
||||
|
||||
.app-row {
|
||||
color: #b6b6b6;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
font-size: 1em;
|
||||
line-height: 1em;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.app-row > .launch,
|
||||
.app-row > .kill {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font-size: 1em;
|
||||
line-height: 1em;
|
||||
margin: 1px 0;
|
||||
padding: 3px 10px;
|
||||
}
|
||||
|
||||
.app-row > .launch {
|
||||
border-radius: 50px;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.app-row > .launch:hover,
|
||||
.app-row > .kill:hover {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.app-row .icon {
|
||||
height: 1em;
|
||||
margin-right: 5px;
|
||||
width: 1em;
|
||||
}
|
||||
|
||||
.app-row .icon.-missing {
|
||||
background-color: #eee;
|
||||
color: #b6b6b6;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.app-row .icon.-missing::after {
|
||||
content: "?";
|
||||
font-size: 0.7em;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.app-row.-selected {
|
||||
background-color: #bcc6fa;
|
||||
}
|
||||
|
||||
.app-loader > .opening {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.app-loader > .app-row {
|
||||
color: #000;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.app-loader > .cancel {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #b6b6b6;
|
||||
cursor: pointer;
|
||||
margin-top: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.app-list + .app-list {
|
||||
border-top: 1px solid #b6b6b6;
|
||||
margin-top: 1em;
|
||||
padding-top: 1em;
|
||||
}
|
||||
|
||||
.app-list > .header {
|
||||
color: #b6b6b6;
|
||||
font-size: 1em;
|
||||
margin-bottom: 1em;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.app-list > .loader {
|
||||
color: #b6b6b6;
|
||||
}
|
169
src/browser/components/list.tsx
Normal file
169
src/browser/components/list.tsx
Normal file
@ -0,0 +1,169 @@
|
||||
import * as React from "react"
|
||||
import { Application, isExecutableApplication, isRunningApplication } from "../../common/api"
|
||||
import { HttpError } from "../../common/http"
|
||||
import { getSession, killSession } from "../api"
|
||||
import { RequestError } from "../components/error"
|
||||
|
||||
export const AppDetails: React.FunctionComponent<Application> = (props) => {
|
||||
return (
|
||||
<>
|
||||
{props.icon ? (
|
||||
<img className="icon" src={`data:image/png;base64,${props.icon}`}></img>
|
||||
) : (
|
||||
<div className="icon -missing"></div>
|
||||
)}
|
||||
<div className="name">{props.name}</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export interface AppRowProps {
|
||||
readonly app: Application
|
||||
onKilled(app: Application): void
|
||||
open(app: Application): void
|
||||
}
|
||||
|
||||
export const AppRow: React.FunctionComponent<AppRowProps> = (props) => {
|
||||
const [killing, setKilling] = React.useState<boolean>(false)
|
||||
const [error, setError] = React.useState<HttpError>()
|
||||
|
||||
function kill(): void {
|
||||
if (isRunningApplication(props.app)) {
|
||||
setKilling(true)
|
||||
killSession(props.app)
|
||||
.then(() => {
|
||||
setKilling(false)
|
||||
props.onKilled(props.app)
|
||||
})
|
||||
.catch((error) => {
|
||||
setError(error)
|
||||
setKilling(false)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-row">
|
||||
<button className="launch" onClick={(): void => props.open(props.app)}>
|
||||
<AppDetails {...props.app} />
|
||||
</button>
|
||||
{isRunningApplication(props.app) && !killing ? (
|
||||
<button className="kill" onClick={(): void => kill()}>
|
||||
{error ? error.message : killing ? "..." : "kill"}
|
||||
</button>
|
||||
) : (
|
||||
undefined
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export interface AppListProps {
|
||||
readonly header: string
|
||||
readonly apps?: ReadonlyArray<Application>
|
||||
open(app: Application): void
|
||||
onKilled(app: Application): void
|
||||
}
|
||||
|
||||
export const AppList: React.FunctionComponent<AppListProps> = (props) => {
|
||||
return (
|
||||
<div className="app-list">
|
||||
<h2 className="header">{props.header}</h2>
|
||||
{props.apps && props.apps.length > 0 ? (
|
||||
props.apps.map((app, i) => <AppRow key={i} app={app} {...props} />)
|
||||
) : props.apps ? (
|
||||
<RequestError error={`No ${props.header.toLowerCase()} found`} />
|
||||
) : (
|
||||
<div className="loader">loading...</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export interface ApplicationSection {
|
||||
readonly apps?: ReadonlyArray<Application>
|
||||
readonly header: string
|
||||
}
|
||||
|
||||
export interface AppLoaderProps {
|
||||
readonly app?: Application
|
||||
setApp(app?: Application): void
|
||||
getApps(): Promise<ReadonlyArray<ApplicationSection>>
|
||||
}
|
||||
|
||||
/**
|
||||
* Display provided applications or sessions and allow opening them.
|
||||
*/
|
||||
export const AppLoader: React.FunctionComponent<AppLoaderProps> = (props) => {
|
||||
const [apps, setApps] = React.useState<ReadonlyArray<ApplicationSection>>()
|
||||
const [error, setError] = React.useState<HttpError>()
|
||||
|
||||
const refresh = (): void => {
|
||||
props
|
||||
.getApps()
|
||||
.then(setApps)
|
||||
.catch((e) => setError(e.message))
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
refresh()
|
||||
}, [props])
|
||||
|
||||
function open(app: Application): void {
|
||||
props.setApp(app)
|
||||
if (!isRunningApplication(app) && isExecutableApplication(app)) {
|
||||
getSession(app)
|
||||
.then((session) => {
|
||||
props.setApp({ ...app, ...session })
|
||||
})
|
||||
.catch(setError)
|
||||
}
|
||||
}
|
||||
|
||||
if (error) {
|
||||
props.setApp(undefined)
|
||||
return (
|
||||
<RequestError
|
||||
error={error}
|
||||
onClose={(): void => {
|
||||
setError(undefined)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (props.app && !props.app.loaded) {
|
||||
return (
|
||||
<div className="app-loader">
|
||||
<div className="opening">Opening</div>
|
||||
<div className="app-row">
|
||||
<AppDetails {...props.app} />
|
||||
</div>
|
||||
<button
|
||||
className="cancel"
|
||||
onClick={(): void => {
|
||||
props.setApp(undefined)
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!apps) {
|
||||
return (
|
||||
<div className="app-loader">
|
||||
<div className="loader">loading</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{apps.map((section, i) => (
|
||||
<AppList key={i} open={open} onKilled={refresh} {...section} />
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
147
src/browser/components/modal.css
Normal file
147
src/browser/components/modal.css
Normal file
@ -0,0 +1,147 @@
|
||||
.modal-bar {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
left: 0;
|
||||
padding: 20px;
|
||||
position: fixed;
|
||||
pointer-events: none;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
.animate > .modal-bar {
|
||||
transform: translateY(-100%);
|
||||
transition: transform 200ms;
|
||||
}
|
||||
|
||||
.animate.-show > .modal-bar {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.modal-bar > .bar {
|
||||
background-color: #fcfcfc;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
|
||||
box-sizing: border-box;
|
||||
color: #101010;
|
||||
display: flex;
|
||||
font-size: 0.8em;
|
||||
max-width: 400px;
|
||||
padding: 20px;
|
||||
pointer-events: initial;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.modal-bar > .bar > .content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
padding-right: 20px;
|
||||
}
|
||||
|
||||
.modal-bar > .bar > .open {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal-bar > .bar > .close {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
color: #b6b6b6;
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
right: 1px;
|
||||
top: 1px;
|
||||
}
|
||||
|
||||
.modal-bar > .bar > .open > .button {
|
||||
background-color: transparent;
|
||||
border-radius: 5px;
|
||||
border: 1px solid #101010;
|
||||
color: #101010;
|
||||
cursor: pointer;
|
||||
padding: 1em;
|
||||
}
|
||||
|
||||
.modal-bar > .bar > .open > .button:hover {
|
||||
background-color: #bcc6fa;
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
align-items: center;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
left: 0;
|
||||
padding: 20px;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
z-index: 9999999;
|
||||
}
|
||||
|
||||
.modal-container > .modal {
|
||||
background: #fcfcfc;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
height: 100%;
|
||||
max-height: 400px;
|
||||
max-width: 664px;
|
||||
padding: 20px 0;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.modal-container > .modal > .sidebar {
|
||||
border-right: 1.5px solid rgba(0, 0, 0, 0.37);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.modal-container > .modal > .sidebar > .links {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.modal-container > .modal > .sidebar > .links > .link {
|
||||
color: rgba(0, 0, 0, 0.37);
|
||||
font-size: 1.4em;
|
||||
height: 31px;
|
||||
margin-bottom: 20px;
|
||||
padding: 0 35px;
|
||||
text-decoration: none;
|
||||
transition: 150ms color ease, 150ms height ease, 150ms margin-bottom ease;
|
||||
}
|
||||
|
||||
.modal-container > .modal > .sidebar > .footer > .close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #b6b6b6;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.modal-container > .modal > .sidebar > .footer > .close:hover {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.modal-container > .modal > .links > .link[aria-current="page"] {
|
||||
color: rgba(0, 0, 0, 1);
|
||||
}
|
||||
|
||||
.modal-container > .modal > .content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
overflow: auto;
|
||||
padding: 0 20px;
|
||||
}
|
192
src/browser/components/modal.tsx
Normal file
192
src/browser/components/modal.tsx
Normal file
@ -0,0 +1,192 @@
|
||||
import { logger } from "@coder/logger"
|
||||
import * as React from "react"
|
||||
import { NavLink, Route, RouteComponentProps, Switch } from "react-router-dom"
|
||||
import { Application, isExecutableApplication } from "../../common/api"
|
||||
import { HttpError } from "../../common/http"
|
||||
import { RequestError } from "../components/error"
|
||||
import { Browse } from "../pages/browse"
|
||||
import { Home } from "../pages/home"
|
||||
import { Login } from "../pages/login"
|
||||
import { Open } from "../pages/open"
|
||||
import { Recent } from "../pages/recent"
|
||||
import { Animate } from "./animate"
|
||||
|
||||
export interface ModalProps {
|
||||
app?: Application
|
||||
authed: boolean
|
||||
error?: HttpError | Error | string
|
||||
setApp(app?: Application): void
|
||||
setError(error?: HttpError | Error | string): void
|
||||
}
|
||||
|
||||
export const Modal: React.FunctionComponent<ModalProps> = (props) => {
|
||||
const [showModal, setShowModal] = React.useState<boolean>(false)
|
||||
const [showBar, setShowBar] = React.useState<boolean>(true)
|
||||
|
||||
const setApp = (app: Application): void => {
|
||||
setShowModal(false)
|
||||
props.setApp(app)
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
// Show the bar when hovering around the top area for a while.
|
||||
let timeout: NodeJS.Timeout | undefined
|
||||
const hover = (clientY: number): void => {
|
||||
if (clientY > 30 && timeout) {
|
||||
clearTimeout(timeout)
|
||||
timeout = undefined
|
||||
} else if (clientY <= 30 && !timeout) {
|
||||
timeout = setTimeout(() => setShowBar(true), 1000)
|
||||
}
|
||||
}
|
||||
|
||||
const iframe =
|
||||
props.app && !isExecutableApplication(props.app) && (document.getElementById("iframe") as HTMLIFrameElement)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const postIframeMessage = (message: any): void => {
|
||||
if (iframe && iframe.contentWindow) {
|
||||
iframe.contentWindow.postMessage(message, window.location.origin)
|
||||
} else {
|
||||
logger.warn("Tried to post message to missing iframe")
|
||||
}
|
||||
}
|
||||
|
||||
const onHover = (event: MouseEvent | MessageEvent): void => {
|
||||
hover((event as MessageEvent).data ? (event as MessageEvent).data.clientY : (event as MouseEvent).clientY)
|
||||
}
|
||||
|
||||
const onIframeLoaded = (): void => {
|
||||
if (props.app) {
|
||||
setApp({ ...props.app, loaded: true })
|
||||
}
|
||||
}
|
||||
|
||||
// No need to track the mouse if we don't have a hidden bar.
|
||||
const hasHiddenBar = !props.error && !showModal && props.app && !showBar
|
||||
|
||||
if (props.app && !isExecutableApplication(props.app)) {
|
||||
// Once the iframe reports it has loaded, tell it to bind mousemove and
|
||||
// start listening for that instead.
|
||||
if (!props.app.loaded) {
|
||||
window.addEventListener("message", onIframeLoaded)
|
||||
} else if (hasHiddenBar) {
|
||||
postIframeMessage({ bind: "mousemove", prop: "clientY" })
|
||||
window.removeEventListener("message", onIframeLoaded)
|
||||
window.addEventListener("message", onHover)
|
||||
}
|
||||
} else if (hasHiddenBar) {
|
||||
document.addEventListener("mousemove", onHover)
|
||||
}
|
||||
|
||||
return (): void => {
|
||||
document.removeEventListener("mousemove", onHover)
|
||||
window.removeEventListener("message", onHover)
|
||||
window.removeEventListener("message", onIframeLoaded)
|
||||
if (props.app && !isExecutableApplication(props.app)) {
|
||||
postIframeMessage({ unbind: "mousemove" })
|
||||
}
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
}, [showBar, props.error, showModal, props.app])
|
||||
|
||||
return props.error || showModal || !props.app || !props.app.loaded ? (
|
||||
<div className="modal-container">
|
||||
<div className="modal">
|
||||
{props.authed && (!props.app || props.app.loaded) ? (
|
||||
<aside className="sidebar">
|
||||
<nav className="links">
|
||||
{!props.authed ? (
|
||||
<NavLink className="link" to="/login">
|
||||
Login
|
||||
</NavLink>
|
||||
) : (
|
||||
undefined
|
||||
)}
|
||||
{props.authed ? (
|
||||
<NavLink className="link" exact to="/recent/">
|
||||
Recent
|
||||
</NavLink>
|
||||
) : (
|
||||
undefined
|
||||
)}
|
||||
{props.authed ? (
|
||||
<NavLink className="link" exact to="/open/">
|
||||
Open
|
||||
</NavLink>
|
||||
) : (
|
||||
undefined
|
||||
)}
|
||||
{props.authed ? (
|
||||
<NavLink className="link" exact to="/browse/">
|
||||
Browse
|
||||
</NavLink>
|
||||
) : (
|
||||
undefined
|
||||
)}
|
||||
</nav>
|
||||
<div className="footer">
|
||||
{props.app && props.app.loaded && !props.error ? (
|
||||
<button className="close" onClick={(): void => setShowModal(false)}>
|
||||
Close
|
||||
</button>
|
||||
) : (
|
||||
undefined
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
) : (
|
||||
undefined
|
||||
)}
|
||||
{props.error ? (
|
||||
<RequestError
|
||||
error={props.error}
|
||||
onClose={(): void => {
|
||||
props.setApp(undefined)
|
||||
props.setError(undefined)
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="content">
|
||||
<Switch>
|
||||
<Route path="/login" component={Login} />
|
||||
<Route
|
||||
path="/recent"
|
||||
render={(p: RouteComponentProps): React.ReactElement => (
|
||||
<Recent app={props.app} setApp={setApp} {...p} />
|
||||
)}
|
||||
/>
|
||||
<Route path="/browse" component={Browse} />
|
||||
<Route
|
||||
path="/open"
|
||||
render={(p: RouteComponentProps): React.ReactElement => <Open app={props.app} setApp={setApp} {...p} />}
|
||||
/>
|
||||
<Route path="/" component={Home} />
|
||||
</Switch>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Animate show={showBar} delay={200}>
|
||||
<div className="modal-bar">
|
||||
<div className="bar">
|
||||
<div className="content">
|
||||
<div className="help">
|
||||
Hover at the top {/*or press <strong>Ctrl+Shift+G</strong>*/} to display this menu.
|
||||
</div>
|
||||
</div>
|
||||
<div className="open">
|
||||
<button className="button" onClick={(): void => setShowModal(true)}>
|
||||
Open Modal
|
||||
</button>
|
||||
</div>
|
||||
<button className="close" onClick={(): void => setShowBar(false)}>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Animate>
|
||||
)
|
||||
}
|
@ -1,46 +0,0 @@
|
||||
import { Emitter } from "vs/base/common/event";
|
||||
import { createDecorator } from "vs/platform/instantiation/common/instantiation";
|
||||
import { ExtHostNodeProxyShape, MainContext, MainThreadNodeProxyShape } from "vs/workbench/api/common/extHost.protocol";
|
||||
import { IExtHostRpcService } from "vs/workbench/api/common/extHostRpcService";
|
||||
|
||||
export class ExtHostNodeProxy implements ExtHostNodeProxyShape {
|
||||
_serviceBrand: any;
|
||||
|
||||
private readonly _onMessage = new Emitter<string>();
|
||||
public readonly onMessage = this._onMessage.event;
|
||||
private readonly _onClose = new Emitter<void>();
|
||||
public readonly onClose = this._onClose.event;
|
||||
private readonly _onDown = new Emitter<void>();
|
||||
public readonly onDown = this._onDown.event;
|
||||
private readonly _onUp = new Emitter<void>();
|
||||
public readonly onUp = this._onUp.event;
|
||||
|
||||
private readonly proxy: MainThreadNodeProxyShape;
|
||||
|
||||
constructor(@IExtHostRpcService rpc: IExtHostRpcService) {
|
||||
this.proxy = rpc.getProxy(MainContext.MainThreadNodeProxy);
|
||||
}
|
||||
|
||||
public $onMessage(message: string): void {
|
||||
this._onMessage.fire(message);
|
||||
}
|
||||
|
||||
public $onClose(): void {
|
||||
this._onClose.fire();
|
||||
}
|
||||
|
||||
public $onUp(): void {
|
||||
this._onUp.fire();
|
||||
}
|
||||
|
||||
public $onDown(): void {
|
||||
this._onDown.fire();
|
||||
}
|
||||
|
||||
public send(message: string): void {
|
||||
this.proxy.$send(message);
|
||||
}
|
||||
}
|
||||
|
||||
export interface IExtHostNodeProxy extends ExtHostNodeProxy { }
|
||||
export const IExtHostNodeProxy = createDecorator<IExtHostNodeProxy>("IExtHostNodeProxy");
|
19
src/browser/index.html
Normal file
19
src/browser/index.html
Normal file
@ -0,0 +1,19 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no">
|
||||
<!-- <meta http-equiv="Content-Security-Policy" content="font-src 'self'; connect-src 'self'; default-src ws: wss:; style-src 'self'; script-src 'self' 'unsafe-inline'; manifest-src 'self'; img-src 'self' data:;"> -->
|
||||
<title>code-server</title>
|
||||
<link rel="icon" href="/static-{{COMMIT}}/src/browser/media/favicon.ico" type="image/x-icon" />
|
||||
<link rel="manifest" href="/static-{{COMMIT}}/src/browser/media/manifest.json" crossorigin="use-credentials">
|
||||
<link rel="apple-touch-icon" href="/static-{{COMMIT}}/src/browser/media/code-server.png" />
|
||||
<link href="https://fonts.googleapis.com/css?family=IBM+Plex+Sans&display=swap" rel="stylesheet" />
|
||||
<link href="/static-{{COMMIT}}/dist/index.css" rel="stylesheet">
|
||||
<meta id="coder-options" data-settings="{{OPTIONS}}">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root">{{COMPONENT}}</div>
|
||||
<script src="/static-{{COMMIT}}/dist/index.js"></script>
|
||||
</body>
|
||||
</html>
|
18
src/browser/index.tsx
Normal file
18
src/browser/index.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import * as React from "react"
|
||||
import * as ReactDOM from "react-dom"
|
||||
import App from "./app"
|
||||
import { BrowserRouter } from "react-router-dom"
|
||||
|
||||
import "./app.css"
|
||||
import "./pages/home.css"
|
||||
import "./pages/login.css"
|
||||
import "./components/error.css"
|
||||
import "./components/list.css"
|
||||
import "./components/modal.css"
|
||||
|
||||
ReactDOM.hydrate(
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>,
|
||||
document.getElementById("root")
|
||||
)
|
@ -1,31 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'self' 'unsafe-inline'; script-src 'unsafe-inline'; manifest-src 'self'; img-src 'self';">
|
||||
<title>Authenticate: code-server</title>
|
||||
<link rel="icon" href="./static/out/vs/server/src/media/favicon.ico" type="image/x-icon" />
|
||||
<link rel="manifest" href="./manifest.json" crossorigin="use-credentials">
|
||||
<link rel="apple-touch-icon" href="./static/out/vs/server/src/media/code-server.png" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<link href="./static/out/vs/server/src/media/login.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<form class="login-form" method="post">
|
||||
<h4 class="title">code-server</h4>
|
||||
<h2 class="subtitle">
|
||||
Enter server password
|
||||
</h2>
|
||||
<div class="field">
|
||||
<!-- The onfocus code places the cursor at the end of the value. -->
|
||||
<input name="password" type="password" class="input" value=""
|
||||
required autofocus
|
||||
onfocus="const value=this.value;this.value='';this.value=value;">
|
||||
</div>
|
||||
<button class="button" type="submit">
|
||||
<span class="label">Enter IDE</span>
|
||||
</button>
|
||||
<div class="error-display" style="display:none">{{ERROR}}</div>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
@ -1,37 +0,0 @@
|
||||
import { IDisposable } from "vs/base/common/lifecycle";
|
||||
import { INodeProxyService } from "vs/server/src/common/nodeProxy";
|
||||
import { ExtHostContext, IExtHostContext, MainContext, MainThreadNodeProxyShape } from "vs/workbench/api/common/extHost.protocol";
|
||||
import { extHostNamedCustomer } from "vs/workbench/api/common/extHostCustomers";
|
||||
|
||||
@extHostNamedCustomer(MainContext.MainThreadNodeProxy)
|
||||
export class MainThreadNodeProxy implements MainThreadNodeProxyShape {
|
||||
private disposed = false;
|
||||
private disposables = <IDisposable[]>[];
|
||||
|
||||
constructor(
|
||||
extHostContext: IExtHostContext,
|
||||
@INodeProxyService private readonly proxyService: INodeProxyService,
|
||||
) {
|
||||
if (!extHostContext.remoteAuthority) { // HACK: A terrible way to detect if running in the worker.
|
||||
const proxy = extHostContext.getProxy(ExtHostContext.ExtHostNodeProxy);
|
||||
this.disposables = [
|
||||
this.proxyService.onMessage((message: string) => proxy.$onMessage(message)),
|
||||
this.proxyService.onClose(() => proxy.$onClose()),
|
||||
this.proxyService.onDown(() => proxy.$onDown()),
|
||||
this.proxyService.onUp(() => proxy.$onUp()),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$send(message: string): void {
|
||||
if (!this.disposed) {
|
||||
this.proxyService.send(message);
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.disposables.forEach((d) => d.dispose());
|
||||
this.disposables = [];
|
||||
this.disposed = true;
|
||||
}
|
||||
}
|
BIN
src/browser/media/code-server.png
Normal file
BIN
src/browser/media/code-server.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 39 KiB |
BIN
src/browser/media/favicon.ico
Normal file
BIN
src/browser/media/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.0 KiB |
13
src/browser/media/manifest.json
Normal file
13
src/browser/media/manifest.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "code-server",
|
||||
"short_name": "code-server",
|
||||
"start_url": "../../../..",
|
||||
"display": "fullscreen",
|
||||
"background-color": "#fff",
|
||||
"description": "Run editors on a remote server.",
|
||||
"icons": [{
|
||||
"src": "./code-server.png",
|
||||
"sizes": "384x384",
|
||||
"type": "image/png"
|
||||
}]
|
||||
}
|
34
src/browser/pages/browse.tsx
Normal file
34
src/browser/pages/browse.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import * as React from "react"
|
||||
import { RouteComponentProps } from "react-router"
|
||||
import { FilesResponse } from "../../common/api"
|
||||
import { HttpError } from "../../common/http"
|
||||
import { getFiles } from "../api"
|
||||
import { RequestError } from "../components/error"
|
||||
|
||||
/**
|
||||
* File browser.
|
||||
*/
|
||||
export const Browse: React.FunctionComponent<RouteComponentProps> = (props) => {
|
||||
const [response, setResponse] = React.useState<FilesResponse>()
|
||||
const [error, setError] = React.useState<HttpError>()
|
||||
|
||||
React.useEffect(() => {
|
||||
getFiles()
|
||||
.then(setResponse)
|
||||
.catch((e) => setError(e.message))
|
||||
}, [props])
|
||||
|
||||
return (
|
||||
<>
|
||||
{error || (response && response.files.length === 0) ? (
|
||||
<RequestError error={error || "Empty directory"} />
|
||||
) : (
|
||||
<ul>
|
||||
{((response && response.files) || []).map((f, i) => (
|
||||
<li key={i}>{f.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
8
src/browser/pages/home.css
Normal file
8
src/browser/pages/home.css
Normal file
@ -0,0 +1,8 @@
|
||||
.orientation-guide {
|
||||
align-items: center;
|
||||
color: #b6b6b6;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
22
src/browser/pages/home.tsx
Normal file
22
src/browser/pages/home.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import * as React from "react"
|
||||
import { RouteComponentProps } from "react-router"
|
||||
import { authenticate } from "../api"
|
||||
|
||||
export const Home: React.FunctionComponent<RouteComponentProps> = (props) => {
|
||||
React.useEffect(() => {
|
||||
authenticate()
|
||||
.then(() => {
|
||||
// TEMP: Always redirect to VS Code.
|
||||
props.history.push("./vscode/")
|
||||
})
|
||||
.catch(() => {
|
||||
props.history.push("./login/")
|
||||
})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="orientation-guide">
|
||||
<div className="welcome">Welcome to code-server.</div>
|
||||
</div>
|
||||
)
|
||||
}
|
35
src/browser/pages/login.css
Normal file
35
src/browser/pages/login.css
Normal file
@ -0,0 +1,35 @@
|
||||
.login-form {
|
||||
align-items: center;
|
||||
color: rgba(0, 0, 0, 0.37);
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
font-weight: 700;
|
||||
justify-content: center;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.login-form > .field {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.login-form > .field-error {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.login-form > .field > .input {
|
||||
border: 1px solid #b6b6b6;
|
||||
box-sizing: border-box;
|
||||
padding: 10px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.login-form > .field > .submit {
|
||||
background-color: transparent;
|
||||
border: 1px solid #b6b6b6;
|
||||
box-sizing: border-box;
|
||||
margin-left: -1px;
|
||||
padding: 10px 20px;
|
||||
}
|
55
src/browser/pages/login.tsx
Normal file
55
src/browser/pages/login.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
import * as React from "react"
|
||||
import { RouteComponentProps } from "react-router"
|
||||
import { HttpError } from "../../common/http"
|
||||
import { authenticate } from "../api"
|
||||
import { FieldError } from "../components/error"
|
||||
|
||||
/**
|
||||
* Login page. Will redirect on success.
|
||||
*/
|
||||
export const Login: React.FunctionComponent<RouteComponentProps> = (props) => {
|
||||
const [password, setPassword] = React.useState<string>("")
|
||||
const [error, setError] = React.useState<HttpError>()
|
||||
|
||||
function redirect(): void {
|
||||
// TEMP: Always redirect to VS Code.
|
||||
console.log("is authed")
|
||||
props.history.push("../vscode/")
|
||||
// const params = new URLSearchParams(window.location.search)
|
||||
// props.history.push(params.get("to") || "/")
|
||||
}
|
||||
|
||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>): Promise<void> {
|
||||
event.preventDefault()
|
||||
authenticate({ password })
|
||||
.then(redirect)
|
||||
.catch(setError)
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
authenticate()
|
||||
.then(redirect)
|
||||
.catch(() => {
|
||||
// Do nothing; we're already at the login page.
|
||||
})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<form className="login-form" onSubmit={handleSubmit}>
|
||||
<div className="field">
|
||||
<input
|
||||
autoFocus
|
||||
className="input"
|
||||
type="password"
|
||||
placeholder="password"
|
||||
autoComplete="current-password"
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>): void => setPassword(event.target.value)}
|
||||
/>
|
||||
<button className="submit" type="submit">
|
||||
Log In
|
||||
</button>
|
||||
</div>
|
||||
{error ? <FieldError error={error} /> : undefined}
|
||||
</form>
|
||||
)
|
||||
}
|
29
src/browser/pages/open.tsx
Normal file
29
src/browser/pages/open.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import * as React from "react"
|
||||
import { Application } from "../../common/api"
|
||||
import { getApplications } from "../api"
|
||||
import { ApplicationSection, AppLoader } from "../components/list"
|
||||
|
||||
export interface OpenProps {
|
||||
app?: Application
|
||||
setApp(app: Application): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Display recently used applications.
|
||||
*/
|
||||
export const Open: React.FunctionComponent<OpenProps> = (props) => {
|
||||
return (
|
||||
<AppLoader
|
||||
getApps={async (): Promise<ReadonlyArray<ApplicationSection>> => {
|
||||
const response = await getApplications()
|
||||
return [
|
||||
{
|
||||
header: "Applications",
|
||||
apps: response && response.applications,
|
||||
},
|
||||
]
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
33
src/browser/pages/recent.tsx
Normal file
33
src/browser/pages/recent.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import * as React from "react"
|
||||
import { Application } from "../../common/api"
|
||||
import { getRecent } from "../api"
|
||||
import { ApplicationSection, AppLoader } from "../components/list"
|
||||
|
||||
export interface RecentProps {
|
||||
app?: Application
|
||||
setApp(app: Application): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Display recently used applications.
|
||||
*/
|
||||
export const Recent: React.FunctionComponent<RecentProps> = (props) => {
|
||||
return (
|
||||
<AppLoader
|
||||
getApps={async (): Promise<ReadonlyArray<ApplicationSection>> => {
|
||||
const response = await getRecent()
|
||||
return [
|
||||
{
|
||||
header: "Running Applications",
|
||||
apps: response && response.running,
|
||||
},
|
||||
{
|
||||
header: "Recent Applications",
|
||||
apps: response && response.recent,
|
||||
},
|
||||
]
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
208
src/browser/socket.ts
Normal file
208
src/browser/socket.ts
Normal file
@ -0,0 +1,208 @@
|
||||
import { field, logger, Logger } from "@coder/logger"
|
||||
import { Emitter } from "../common/emitter"
|
||||
import { generateUuid } from "../common/util"
|
||||
|
||||
const decoder = new TextDecoder("utf8")
|
||||
export const decode = (buffer: string | ArrayBuffer): string => {
|
||||
return typeof buffer !== "string" ? decoder.decode(buffer) : buffer
|
||||
}
|
||||
|
||||
/**
|
||||
* A web socket that reconnects itself when it closes. Sending messages while
|
||||
* disconnected will throw an error.
|
||||
*/
|
||||
export class ReconnectingSocket {
|
||||
protected readonly _onMessage = new Emitter<string | ArrayBuffer>()
|
||||
public readonly onMessage = this._onMessage.event
|
||||
protected readonly _onDisconnect = new Emitter<number | undefined>()
|
||||
public readonly onDisconnect = this._onDisconnect.event
|
||||
protected readonly _onClose = new Emitter<number | undefined>()
|
||||
public readonly onClose = this._onClose.event
|
||||
protected readonly _onConnect = new Emitter<void>()
|
||||
public readonly onConnect = this._onConnect.event
|
||||
|
||||
// This helps distinguish messages between sockets.
|
||||
private readonly logger: Logger
|
||||
|
||||
private socket?: WebSocket
|
||||
private connecting?: Promise<void>
|
||||
private closed = false
|
||||
private readonly openTimeout = 10000
|
||||
|
||||
// Every time the socket fails to connect, the retry will be increasingly
|
||||
// delayed up to a maximum.
|
||||
private readonly retryBaseDelay = 1000
|
||||
private readonly retryMaxDelay = 10000
|
||||
private retryDelay?: number
|
||||
private readonly retryDelayFactor = 1.5
|
||||
|
||||
// The socket must be connected for this amount of time before resetting the
|
||||
// retry delay. This prevents rapid retries when the socket does connect but
|
||||
// is closed shortly after.
|
||||
private resetRetryTimeout?: NodeJS.Timeout
|
||||
private readonly resetRetryDelay = 10000
|
||||
|
||||
private _binaryType: typeof WebSocket.prototype.binaryType = "arraybuffer"
|
||||
|
||||
public constructor(private customPath?: string, public readonly id: string = generateUuid(4)) {
|
||||
// On Firefox the socket seems to somehow persist a page reload so the close
|
||||
// event runs and we see "attempting to reconnect".
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("beforeunload", () => this.close())
|
||||
}
|
||||
this.logger = logger.named(this.id)
|
||||
}
|
||||
|
||||
public set binaryType(b: typeof WebSocket.prototype.binaryType) {
|
||||
this._binaryType = b
|
||||
if (this.socket) {
|
||||
this.socket.binaryType = b
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Permanently close the connection. Will not attempt to reconnect. Will
|
||||
* remove event listeners.
|
||||
*/
|
||||
public close(code?: number): void {
|
||||
if (this.closed) {
|
||||
return
|
||||
}
|
||||
|
||||
if (code) {
|
||||
this.logger.info(`closing with code ${code}`)
|
||||
}
|
||||
|
||||
if (this.resetRetryTimeout) {
|
||||
clearTimeout(this.resetRetryTimeout)
|
||||
}
|
||||
|
||||
this.closed = true
|
||||
|
||||
if (this.socket) {
|
||||
this.socket.close()
|
||||
} else {
|
||||
this._onClose.emit(code)
|
||||
}
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this._onMessage.dispose()
|
||||
this._onDisconnect.dispose()
|
||||
this._onClose.dispose()
|
||||
this._onConnect.dispose()
|
||||
this.logger.debug("disposed handlers")
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message on the socket. Logs an error if currently disconnected.
|
||||
*/
|
||||
public send(message: string | ArrayBuffer): void {
|
||||
this.logger.trace(() => ["sending message", field("message", decode(message))])
|
||||
if (!this.socket) {
|
||||
return logger.error("tried to send message on closed socket")
|
||||
}
|
||||
this.socket.send(message)
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the socket. Can also be called to wait until the connection is
|
||||
* established in the case of disconnections. Multiple calls will be handled
|
||||
* correctly.
|
||||
*/
|
||||
public async connect(): Promise<void> {
|
||||
if (!this.connecting) {
|
||||
this.connecting = new Promise((resolve, reject) => {
|
||||
const tryConnect = (): void => {
|
||||
if (this.closed) {
|
||||
return reject(new Error("disconnected")) // Don't keep trying if we've closed permanently.
|
||||
}
|
||||
if (typeof this.retryDelay === "undefined") {
|
||||
this.retryDelay = 0
|
||||
} else {
|
||||
this.retryDelay = this.retryDelay * this.retryDelayFactor || this.retryBaseDelay
|
||||
if (this.retryDelay > this.retryMaxDelay) {
|
||||
this.retryDelay = this.retryMaxDelay
|
||||
}
|
||||
}
|
||||
this._connect()
|
||||
.then((socket) => {
|
||||
this.logger.info("connected")
|
||||
this.socket = socket
|
||||
this.socket.binaryType = this._binaryType
|
||||
if (this.resetRetryTimeout) {
|
||||
clearTimeout(this.resetRetryTimeout)
|
||||
}
|
||||
this.resetRetryTimeout = setTimeout(() => (this.retryDelay = undefined), this.resetRetryDelay)
|
||||
this.connecting = undefined
|
||||
this._onConnect.emit()
|
||||
resolve()
|
||||
})
|
||||
.catch((error) => {
|
||||
this.logger.error(`failed to connect: ${error.message}`)
|
||||
tryConnect()
|
||||
})
|
||||
}
|
||||
tryConnect()
|
||||
})
|
||||
}
|
||||
return this.connecting
|
||||
}
|
||||
|
||||
private async _connect(): Promise<WebSocket> {
|
||||
const socket = await new Promise<WebSocket>((resolve, _reject) => {
|
||||
if (this.retryDelay) {
|
||||
this.logger.info(`retrying in ${this.retryDelay}ms...`)
|
||||
}
|
||||
setTimeout(() => {
|
||||
this.logger.info("connecting...")
|
||||
const socket = new WebSocket(
|
||||
`${location.protocol === "https:" ? "wss" : "ws"}://${location.host}${this.customPath || location.pathname}${
|
||||
location.search ? `?${location.search}` : ""
|
||||
}`
|
||||
)
|
||||
|
||||
const reject = (): void => {
|
||||
_reject(new Error("socket closed"))
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
||||
socket.removeEventListener("open", open)
|
||||
socket.removeEventListener("close", reject)
|
||||
_reject(new Error("timeout"))
|
||||
}, this.openTimeout)
|
||||
|
||||
const open = (): void => {
|
||||
clearTimeout(timeout)
|
||||
socket.removeEventListener("close", reject)
|
||||
resolve(socket)
|
||||
}
|
||||
|
||||
socket.addEventListener("open", open)
|
||||
socket.addEventListener("close", reject)
|
||||
}, this.retryDelay)
|
||||
})
|
||||
|
||||
socket.addEventListener("message", (event) => {
|
||||
this.logger.trace(() => ["got message", field("message", decode(event.data))])
|
||||
this._onMessage.emit(event.data)
|
||||
})
|
||||
socket.addEventListener("close", (event) => {
|
||||
this.socket = undefined
|
||||
if (!this.closed) {
|
||||
this._onDisconnect.emit(event.code)
|
||||
// It might be closed in the event handler.
|
||||
if (!this.closed) {
|
||||
this.logger.info("connection closed; attempting to reconnect")
|
||||
this.connect()
|
||||
}
|
||||
} else {
|
||||
this._onClose.emit(event.code)
|
||||
this.logger.info("connection closed permanently")
|
||||
}
|
||||
})
|
||||
|
||||
return socket
|
||||
}
|
||||
}
|
@ -1,92 +0,0 @@
|
||||
<!-- Copyright (C) Microsoft Corporation. All rights reserved. -->
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
|
||||
<!-- Disable pinch zooming -->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no">
|
||||
|
||||
<!-- Workbench Configuration -->
|
||||
<meta id="vscode-workbench-web-configuration" data-settings="{{WORKBENCH_WEB_CONFIGURATION}}">
|
||||
|
||||
<!-- Workarounds/Hacks (remote user data uri) -->
|
||||
<meta id="vscode-remote-user-data-uri" data-settings="{{REMOTE_USER_DATA_URI}}">
|
||||
<!-- NOTE@coder: Added the commit for use in caching, the product for the
|
||||
extensions gallery URL, and nls for language support. -->
|
||||
<meta id="vscode-remote-commit" data-settings="{{COMMIT}}">
|
||||
<meta id="vscode-remote-product-configuration" data-settings="{{PRODUCT_CONFIGURATION}}">
|
||||
<meta id="vscode-remote-nls-configuration" data-settings="{{NLS_CONFIGURATION}}">
|
||||
|
||||
<!-- Workbench Icon/Manifest/CSS -->
|
||||
<link rel="icon" href="./static-{{COMMIT}}/out/vs/server/src/media/favicon.ico" type="image/x-icon" />
|
||||
<link rel="manifest" href="./manifest.json" crossorigin="use-credentials">
|
||||
<link data-name="vs/workbench/workbench.web.api" rel="stylesheet" href="./static-{{COMMIT}}/out/vs/workbench/workbench.web.api.css">
|
||||
<link rel="apple-touch-icon" href="./static-{{COMMIT}}/out/vs/server/src/media/code-server.png" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
|
||||
<!-- Prefetch to avoid waterfall -->
|
||||
<link rel="prefetch" href="./static-{{COMMIT}}/node_modules/semver-umd/lib/semver-umd.js">
|
||||
</head>
|
||||
|
||||
<body aria-label="">
|
||||
</body>
|
||||
|
||||
<!-- Startup (do not modify order of script tags!) -->
|
||||
<!-- NOTE:coder: Modified to work against the current path and use the commit for caching. -->
|
||||
<script>
|
||||
// NOTE: Changes to inline scripts require update of content security policy
|
||||
const basePath = window.location.pathname.replace(/\/+$/, '');
|
||||
const base = window.location.origin + basePath;
|
||||
const el = document.getElementById('vscode-remote-commit');
|
||||
const commit = el ? el.getAttribute('data-settings') : "";
|
||||
const staticBase = base + '/static-' + commit;
|
||||
let nlsConfig;
|
||||
try {
|
||||
nlsConfig = JSON.parse(document.getElementById('vscode-remote-nls-configuration').getAttribute('data-settings'));
|
||||
if (nlsConfig._resolvedLanguagePackCoreLocation) {
|
||||
const bundles = Object.create(null);
|
||||
nlsConfig.loadBundle = (bundle, language, cb) => {
|
||||
let result = bundles[bundle];
|
||||
if (result) {
|
||||
return cb(undefined, result);
|
||||
}
|
||||
// FIXME: Only works if path separators are /.
|
||||
const path = nlsConfig._resolvedLanguagePackCoreLocation
|
||||
+ '/' + bundle.replace(/\//g, '!') + '.nls.json';
|
||||
fetch(`${base}/resource/?path=${encodeURIComponent(path)}`)
|
||||
.then((response) => response.json())
|
||||
.then((json) => {
|
||||
bundles[bundle] = json;
|
||||
cb(undefined, json);
|
||||
})
|
||||
.catch(cb);
|
||||
};
|
||||
}
|
||||
} catch (error) { /* Probably fine. */ }
|
||||
self.require = {
|
||||
baseUrl: `${staticBase}/out`,
|
||||
paths: {
|
||||
'vscode-textmate': `${staticBase}/node_modules/vscode-textmate/release/main`,
|
||||
'onigasm-umd': `${staticBase}/node_modules/onigasm-umd/release/main`,
|
||||
'xterm': `${staticBase}/node_modules/xterm/lib/xterm.js`,
|
||||
'xterm-addon-search': `${staticBase}/node_modules/xterm-addon-search/lib/xterm-addon-search.js`,
|
||||
'xterm-addon-web-links': `${staticBase}/node_modules/xterm-addon-web-links/lib/xterm-addon-web-links.js`,
|
||||
'xterm-addon-webgl': `${staticBase}/node_modules/xterm-addon-webgl/lib/xterm-addon-webgl.js`,
|
||||
'semver-umd': `${staticBase}/node_modules/semver-umd/lib/semver-umd.js`,
|
||||
},
|
||||
'vs/nls': nlsConfig,
|
||||
};
|
||||
</script>
|
||||
<script src="./static-{{COMMIT}}/out/vs/loader.js"></script>
|
||||
<script src="./static-{{COMMIT}}/out/vs/workbench/workbench.web.api.nls.js"></script>
|
||||
<script src="./static-{{COMMIT}}/out/vs/workbench/workbench.web.api.js"></script>
|
||||
<!-- TODO@coder: This errors with multiple anonymous define calls (one is
|
||||
workbench.js and one is semver-umd.js). For now use the same method found in
|
||||
workbench-dev.html. Appears related to the timing of the script load events. -->
|
||||
<!-- <script src="./static-{{COMMIT}}/out/vs/workbench/workbench.js"></script> -->
|
||||
<script>
|
||||
// NOTE: Changes to inline scripts require update of content security policy
|
||||
require(['vs/code/browser/workbench/workbench'], function() {});
|
||||
</script>
|
||||
</html>
|
@ -1,53 +0,0 @@
|
||||
<!-- Copyright (C) Microsoft Corporation. All rights reserved. -->
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
|
||||
<!-- Disable pinch zooming -->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no">
|
||||
|
||||
<!-- Workbench Configuration -->
|
||||
<meta id="vscode-workbench-web-configuration" data-settings="{{WORKBENCH_WEB_CONFIGURATION}}">
|
||||
|
||||
<!-- Workarounds/Hacks (remote user data uri) -->
|
||||
<meta id="vscode-remote-user-data-uri" data-settings="{{REMOTE_USER_DATA_URI}}">
|
||||
<!-- NOTE@coder: Added the commit for use in caching, the product for the
|
||||
extensions gallery URL, and nls for language support. -->
|
||||
<meta id="vscode-remote-commit" data-settings="{{COMMIT}}">
|
||||
<meta id="vscode-remote-product-configuration" data-settings="{{PRODUCT_CONFIGURATION}}">
|
||||
<meta id="vscode-remote-nls-configuration" data-settings="{{NLS_CONFIGURATION}}">
|
||||
|
||||
<!-- Workbench Icon/Manifest/CSS -->
|
||||
<link rel="icon" href="./static/out/vs/server/src/media/favicon.ico" type="image/x-icon" />
|
||||
<link rel="manifest" href="./manifest.json" crossorigin="use-credentials">
|
||||
</head>
|
||||
|
||||
<body aria-label="">
|
||||
</body>
|
||||
|
||||
<!-- Startup (do not modify order of script tags!) -->
|
||||
<script>
|
||||
const basePath = window.location.pathname.replace(/\/+$/, '');
|
||||
const base = window.location.origin + basePath;
|
||||
const el = document.getElementById('vscode-remote-commit');
|
||||
const commit = el ? el.getAttribute('data-settings') : "";
|
||||
const staticBase = base + '/static-' + commit;
|
||||
self.require = {
|
||||
baseUrl: `${staticBase}/out`,
|
||||
paths: {
|
||||
'vscode-textmate': `${staticBase}/node_modules/vscode-textmate/release/main`,
|
||||
'onigasm-umd': `${staticBase}/node_modules/onigasm-umd/release/main`,
|
||||
'xterm': `${staticBase}/node_modules/xterm/lib/xterm.js`,
|
||||
'xterm-addon-search': `${staticBase}/node_modules/xterm-addon-search/lib/xterm-addon-search.js`,
|
||||
'xterm-addon-web-links': `${staticBase}/node_modules/xterm-addon-web-links/lib/xterm-addon-web-links.js`,
|
||||
'xterm-addon-webgl': `${staticBase}/node_modules/xterm-addon-webgl/lib/xterm-addon-webgl.js`,
|
||||
'semver-umd': `${staticBase}/node_modules/semver-umd/lib/semver-umd.js`,
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<script src="./static/out/vs/loader.js"></script>
|
||||
<script>
|
||||
require(['vs/code/browser/workbench/workbench'], function() {});
|
||||
</script>
|
||||
</html>
|
@ -1,57 +0,0 @@
|
||||
import { URI } from "vs/base/common/uri";
|
||||
import { IExtensionDescription } from "vs/platform/extensions/common/extensions";
|
||||
import { ILogService } from "vs/platform/log/common/log";
|
||||
import { Client } from "vs/server/node_modules/@coder/node-browser/out/client/client";
|
||||
import { fromTar } from "vs/server/node_modules/@coder/requirefs/out/requirefs";
|
||||
import { ExtensionActivationTimesBuilder } from "vs/workbench/api/common/extHostExtensionActivator";
|
||||
import { IExtHostNodeProxy } from "./extHostNodeProxy";
|
||||
|
||||
export const loadCommonJSModule = async <T>(
|
||||
module: IExtensionDescription,
|
||||
activationTimesBuilder: ExtensionActivationTimesBuilder,
|
||||
nodeProxy: IExtHostNodeProxy,
|
||||
logService: ILogService,
|
||||
vscode: any,
|
||||
): Promise<T> => {
|
||||
const fetchUri = URI.from({
|
||||
scheme: self.location.protocol.replace(":", ""),
|
||||
authority: self.location.host,
|
||||
path: `${self.location.pathname.replace(/\/static.*\/out\/vs\/workbench\/services\/extensions\/worker\/extensionHostWorkerMain.js$/, "")}/tar`,
|
||||
query: `path=${encodeURIComponent(module.extensionLocation.path)}`,
|
||||
});
|
||||
const response = await fetch(fetchUri.toString(true));
|
||||
if (response.status !== 200) {
|
||||
throw new Error(`Failed to download extension "${module.extensionLocation.path}"`);
|
||||
}
|
||||
const client = new Client(nodeProxy, { logger: logService });
|
||||
const init = await client.handshake();
|
||||
const buffer = new Uint8Array(await response.arrayBuffer());
|
||||
const rfs = fromTar(buffer);
|
||||
(<any>self).global = self;
|
||||
rfs.provide("vscode", vscode);
|
||||
Object.keys(client.modules).forEach((key) => {
|
||||
const mod = (client.modules as any)[key];
|
||||
if (key === "process") {
|
||||
(<any>self).process = mod;
|
||||
(<any>self).process.env = init.env;
|
||||
return;
|
||||
}
|
||||
|
||||
rfs.provide(key, mod);
|
||||
switch (key) {
|
||||
case "buffer":
|
||||
(<any>self).Buffer = mod.Buffer;
|
||||
break;
|
||||
case "timers":
|
||||
(<any>self).setImmediate = mod.setImmediate;
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
activationTimesBuilder.codeLoadingStart();
|
||||
return rfs.require(".");
|
||||
} finally {
|
||||
activationTimesBuilder.codeLoadingStop();
|
||||
}
|
||||
};
|
Reference in New Issue
Block a user