diff --git a/packages/core/src/electron-browser/messaging/electron-local-ws-connection-source.ts b/packages/core/src/electron-browser/messaging/electron-local-ws-connection-source.ts index 82c19a40aa598..20d4f88dca089 100644 --- a/packages/core/src/electron-browser/messaging/electron-local-ws-connection-source.ts +++ b/packages/core/src/electron-browser/messaging/electron-local-ws-connection-source.ts @@ -18,14 +18,16 @@ import { injectable } from 'inversify'; import { Endpoint } from '../../browser/endpoint'; import { WebSocketConnectionSource } from '../../browser/messaging/ws-connection-source'; +export const LOCAL_PORT_PARAM = 'localPort'; export function getLocalPort(): string | undefined { const params = new URLSearchParams(location.search); - return params.get('localPort') ?? undefined; + return params.get(LOCAL_PORT_PARAM) ?? undefined; } +export const CURRENT_PORT_PARAM = 'port'; export function getCurrentPort(): string | undefined { const params = new URLSearchParams(location.search); - return params.get('port') ?? undefined; + return params.get(CURRENT_PORT_PARAM) ?? undefined; } @injectable() diff --git a/packages/dev-container/src/electron-browser/container-connection-contribution.ts b/packages/dev-container/src/electron-browser/container-connection-contribution.ts index f9aa6ad9477e6..e57340f07f282 100644 --- a/packages/dev-container/src/electron-browser/container-connection-contribution.ts +++ b/packages/dev-container/src/electron-browser/container-connection-contribution.ts @@ -18,12 +18,12 @@ import { inject, injectable } from '@theia/core/shared/inversify'; import { AbstractRemoteRegistryContribution, RemoteRegistry } from '@theia/remote/lib/electron-browser/remote-registry-contribution'; import { DevContainerFile, LastContainerInfo, RemoteContainerConnectionProvider } from '../electron-common/remote-container-connection-provider'; import { RemotePreferences } from '@theia/remote/lib/electron-browser/remote-preferences'; -import { WorkspaceStorageService } from '@theia/workspace/lib/browser/workspace-storage-service'; import { Command, MaybePromise, QuickInputService, URI } from '@theia/core'; import { WorkspaceInput, WorkspaceOpenHandlerContribution, WorkspaceService } from '@theia/workspace/lib/browser/workspace-service'; import { ContainerOutputProvider } from './container-output-provider'; import { WorkspaceServer } from '@theia/workspace/lib/common'; import { DEV_CONTAINER_PATH_QUERY, DEV_CONTAINER_WORKSPACE_SCHEME } from '../electron-common/dev-container-workspaces'; +import { LocalStorageService, StorageService } from '@theia/core/lib/browser'; export namespace RemoteContainerCommands { export const REOPEN_IN_CONTAINER = Command.toLocalizedCommand({ @@ -43,8 +43,8 @@ export class ContainerConnectionContribution extends AbstractRemoteRegistryContr @inject(RemotePreferences) protected readonly remotePreferences: RemotePreferences; - @inject(WorkspaceStorageService) - protected readonly workspaceStorageService: WorkspaceStorageService; + @inject(LocalStorageService) + protected readonly storageService: StorageService; @inject(WorkspaceService) protected readonly workspaceService: WorkspaceService; @@ -105,7 +105,7 @@ export class ContainerConnectionContribution extends AbstractRemoteRegistryContr async doOpenInContainer(devcontainerFile: DevContainerFile, workspacePath?: string): Promise { const lastContainerInfoKey = `${LAST_USED_CONTAINER}:${devcontainerFile.path}`; - const lastContainerInfo = await this.workspaceStorageService.getData(lastContainerInfoKey); + const lastContainerInfo = await this.storageService.getData(lastContainerInfoKey); this.containerOutputProvider.openChannel(); @@ -116,7 +116,7 @@ export class ContainerConnectionContribution extends AbstractRemoteRegistryContr workspacePath: workspacePath }); - this.workspaceStorageService.setData(lastContainerInfoKey, { + this.storageService.setData(lastContainerInfoKey, { id: connectionResult.containerId, lastUsed: Date.now() }); diff --git a/packages/filesystem/src/browser/file-dialog/file-dialog-service.ts b/packages/filesystem/src/browser/file-dialog/file-dialog-service.ts index afc42899c64db..2f52c492a5ac6 100644 --- a/packages/filesystem/src/browser/file-dialog/file-dialog-service.ts +++ b/packages/filesystem/src/browser/file-dialog/file-dialog-service.ts @@ -61,7 +61,7 @@ export class DefaultFileDialogService implements FileDialogService { const value = await dialog.open(); if (value) { if (!Array.isArray(value)) { - return value.uri; + return props.fileScheme ? value.uri.withScheme(props.fileScheme) : value.uri; } return value.map(node => node.uri); } diff --git a/packages/filesystem/src/browser/file-dialog/file-dialog.ts b/packages/filesystem/src/browser/file-dialog/file-dialog.ts index ab9ece4eb044b..e06813baf5110 100644 --- a/packages/filesystem/src/browser/file-dialog/file-dialog.ts +++ b/packages/filesystem/src/browser/file-dialog/file-dialog.ts @@ -58,6 +58,10 @@ export const FILENAME_TEXTFIELD_CLASS = 'theia-FileNameTextField'; export const CONTROL_PANEL_CLASS = 'theia-ControlPanel'; export const TOOLBAR_ITEM_TRANSFORM_TIMEOUT = 100; +export interface AdditionalButtonDefinition { + label: string; + onClick: (resolve: (v: T | undefined) => void, reject: (v: unknown) => void) => void; +} export class FileDialogProps extends DialogProps { /** @@ -78,6 +82,16 @@ export class FileDialogProps extends DialogProps { */ modal?: boolean; + /** + * scheme of the fileUri. Defaults to `file`. + */ + fileScheme?: string; + + /** + * Additional buttons to show beside the close and accept buttons. + */ + additionalButtons?: AdditionalButtonDefinition[]; + } @injectable() @@ -180,6 +194,15 @@ export abstract class FileDialog extends AbstractDialog { this.hiddenFilesToggleRenderer = this.hiddenFilesToggleFactory(this.widget.model.tree); this.contentNode.appendChild(this.hiddenFilesToggleRenderer.host); + this.props.additionalButtons?.forEach(({ label, onClick }) => { + const button = this.appendButton(label, false); + button.onclick = () => { + if (this.resolve && this.reject) { + onClick(this.resolve, this.reject); + } + }; + }); + if (this.props.filters) { this.treeFiltersRenderer = this.treeFiltersFactory({ suppliedFilters: this.props.filters, fileDialogTree: this.widget.model.tree }); const filters = Object.keys(this.props.filters); diff --git a/packages/filesystem/src/electron-browser/file-dialog/electron-file-dialog-service.ts b/packages/filesystem/src/electron-browser/file-dialog/electron-file-dialog-service.ts index a5cd9a7a4ad63..6f4faed9cc09e 100644 --- a/packages/filesystem/src/electron-browser/file-dialog/electron-file-dialog-service.ts +++ b/packages/filesystem/src/electron-browser/file-dialog/electron-file-dialog-service.ts @@ -49,7 +49,13 @@ export class ElectronFileDialogService extends DefaultFileDialogService { return undefined; } - const uris = filePaths.map(path => FileUri.create(path)); + const uris = filePaths.map(path => { + let uri = FileUri.create(path); + if (props.fileScheme) { + uri = uri.withScheme(props.fileScheme); + } + return uri; + }); const canAccess = await this.canRead(uris); const result = canAccess ? uris.length === 1 ? uris[0] : uris : undefined; return result; @@ -70,6 +76,9 @@ export class ElectronFileDialogService extends DefaultFileDialogService { } const uri = FileUri.create(filePath); + if (props.fileScheme) { + uri.withScheme(props.fileScheme); + } const exists = await this.fileService.exists(uri); if (!exists) { return uri; diff --git a/packages/remote/package.json b/packages/remote/package.json index 28d53ca6a1e92..65311933bf858 100644 --- a/packages/remote/package.json +++ b/packages/remote/package.json @@ -6,6 +6,7 @@ "@theia/core": "1.59.0", "@theia/filesystem": "1.59.0", "@theia/userstorage": "1.59.0", + "@theia/workspace": "1.59.0", "archiver": "^5.3.1", "decompress": "^4.2.1", "decompress-tar": "^4.0.0", diff --git a/packages/remote/src/electron-browser/local-backend-services.ts b/packages/remote/src/electron-browser/local-backend-services.ts index 61697304e2fcf..6f79bb6681ca0 100644 --- a/packages/remote/src/electron-browser/local-backend-services.ts +++ b/packages/remote/src/electron-browser/local-backend-services.ts @@ -14,13 +14,15 @@ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** -import { RpcProxy } from '@theia/core'; +import { RpcProxy, URI } from '@theia/core'; import { inject, injectable } from '@theia/core/shared/inversify'; import { RemoteFileSystemProvider, RemoteFileSystemServer } from '@theia/filesystem/lib/common/remote-file-system-provider'; +import { FileService, FileServiceContribution } from '@theia/filesystem/lib/browser/file-service'; export const LocalEnvVariablesServer = Symbol('LocalEnviromentVariableServer'); export const LocalRemoteFileSytemServer = Symbol('LocalRemoteFileSytemServer'); +export const LOCAL_FILE_SCHEME = 'localfile'; /** * provide file access to local files while connected to a remote workspace or dev container. */ @@ -28,4 +30,37 @@ export const LocalRemoteFileSytemServer = Symbol('LocalRemoteFileSytemServer'); export class LocalRemoteFileSystemProvider extends RemoteFileSystemProvider { @inject(LocalRemoteFileSytemServer) protected override readonly server: RpcProxy; + +} + +@injectable() +export class LocalRemoteFileSystemContribution implements FileServiceContribution { + @inject(LocalRemoteFileSystemProvider) + protected readonly provider: LocalRemoteFileSystemProvider; + + registerFileSystemProviders(service: FileService): void { + service.onWillActivateFileSystemProvider(event => { + if (event.scheme === LOCAL_FILE_SCHEME) { + service.registerProvider(LOCAL_FILE_SCHEME, new Proxy(this.provider, { + get(target, prop): unknown { + const member = target[prop as keyof LocalRemoteFileSystemProvider]; + + if (typeof member === 'function') { + return (...args: unknown[]) => { + const mappedArgs = args.map(arg => { + if (arg instanceof URI && arg.scheme === LOCAL_FILE_SCHEME) { + return arg.withScheme('file'); + } + return arg; + }); + return member.apply(target, mappedArgs); + }; + } + return member; + } + })); + } + }); + } + } diff --git a/packages/remote/src/electron-browser/remote-electron-file-dialog-service.ts b/packages/remote/src/electron-browser/remote-electron-file-dialog-service.ts index 7ab21738f50ac..60a0c59ebfdd3 100644 --- a/packages/remote/src/electron-browser/remote-electron-file-dialog-service.ts +++ b/packages/remote/src/electron-browser/remote-electron-file-dialog-service.ts @@ -14,13 +14,14 @@ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** -import { MaybeArray, URI } from '@theia/core'; +import { MaybeArray, URI, nls } from '@theia/core'; import { inject, injectable } from '@theia/core/shared/inversify'; -import { OpenFileDialogProps, SaveFileDialogProps } from '@theia/filesystem/lib/browser/file-dialog'; +import { AdditionalButtonDefinition, OpenFileDialogProps, SaveFileDialogProps } from '@theia/filesystem/lib/browser/file-dialog'; import { FileStat } from '@theia/filesystem/lib/common/files'; import { DefaultFileDialogService } from '@theia/filesystem/lib/browser/file-dialog/file-dialog-service'; import { ElectronFileDialogService } from '@theia/filesystem/lib/electron-browser/file-dialog/electron-file-dialog-service'; import { RemoteService } from './remote-service'; +import { LOCAL_FILE_SCHEME } from './local-backend-services'; @injectable() export class RemoteElectronFileDialogService extends ElectronFileDialogService { @@ -31,6 +32,7 @@ export class RemoteElectronFileDialogService extends ElectronFileDialogService { override showOpenDialog(props: OpenFileDialogProps, folder?: FileStat | undefined): Promise; override showOpenDialog(props: OpenFileDialogProps, folder?: FileStat): Promise | undefined> | Promise { if (this.remoteService.isConnected()) { + this.addLocalFilesButton(props); return DefaultFileDialogService.prototype.showOpenDialog.call(this, props, folder); } else { return super.showOpenDialog(props, folder); @@ -44,4 +46,21 @@ export class RemoteElectronFileDialogService extends ElectronFileDialogService { return super.showSaveDialog(props, folder); } } + + protected addLocalFilesButton(props: OpenFileDialogProps): void { + const localFilesButton: AdditionalButtonDefinition<{ uri: URI }> = { + label: nls.localizeByDefault('Show Local'), + onClick: async resolve => { + const localFile = await super.showOpenDialog({ ...props, fileScheme: LOCAL_FILE_SCHEME }); + if (localFile) { + resolve({ uri: localFile }); + } + } + }; + if (props.additionalButtons) { + props.additionalButtons.push(localFilesButton); + } else { + props.additionalButtons = [localFilesButton]; + } + } } diff --git a/packages/remote/src/electron-browser/remote-frontend-module.ts b/packages/remote/src/electron-browser/remote-frontend-module.ts index 59d80fddd5e9e..a14529cac98d9 100644 --- a/packages/remote/src/electron-browser/remote-frontend-module.ts +++ b/packages/remote/src/electron-browser/remote-frontend-module.ts @@ -35,8 +35,11 @@ import '../../src/electron-browser/style/port-forwarding-widget.css'; import { UserStorageContribution } from '@theia/userstorage/lib/browser/user-storage-contribution'; import { RemoteUserStorageContribution } from './remote-user-storage-provider'; import { remoteFileSystemPath, RemoteFileSystemProxyFactory, RemoteFileSystemServer } from '@theia/filesystem/lib/common/remote-file-system-provider'; -import { LocalEnvVariablesServer, LocalRemoteFileSystemProvider, LocalRemoteFileSytemServer } from './local-backend-services'; +import { LocalEnvVariablesServer, LocalRemoteFileSystemContribution, LocalRemoteFileSystemProvider, LocalRemoteFileSytemServer } from './local-backend-services'; import { envVariablesPath, EnvVariablesServer } from '@theia/core/lib/common/env-variables'; +import { WorkspaceHandlingContribution, WorkspaceOpenHandlerContribution } from '@theia/workspace/lib/browser'; +import { RemoteLocalWorkspaceContribution } from './remote-local-workspace-contribution'; +import { FileServiceContribution } from '@theia/filesystem/lib/browser/file-service'; export default new ContainerModule((bind, _, __, rebind) => { bind(RemoteFrontendContribution).toSelf().inSingletonScope(); @@ -81,5 +84,13 @@ export default new ContainerModule((bind, _, __, rebind) => { bind(LocalRemoteFileSystemProvider).toSelf().inSingletonScope(); rebind(UserStorageContribution).to(RemoteUserStorageContribution); + if (isRemote) { + bind(RemoteLocalWorkspaceContribution).toSelf().inSingletonScope(); + bind(WorkspaceOpenHandlerContribution).toService(RemoteLocalWorkspaceContribution); + bind(WorkspaceHandlingContribution).toService(RemoteLocalWorkspaceContribution); + bind(LocalRemoteFileSystemContribution).toSelf().inSingletonScope(); + bind(FileServiceContribution).toService(LocalRemoteFileSystemContribution); + } + }); diff --git a/packages/remote/src/electron-browser/remote-local-workspace-contribution.ts b/packages/remote/src/electron-browser/remote-local-workspace-contribution.ts new file mode 100644 index 0000000000000..9fe45f4d12036 --- /dev/null +++ b/packages/remote/src/electron-browser/remote-local-workspace-contribution.ts @@ -0,0 +1,88 @@ +// ***************************************************************************** +// Copyright (C) 2024 TypeFox and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { ILogger, MaybePromise, URI } from '@theia/core'; +import { inject, injectable } from '@theia/core/shared/inversify'; +import { WorkspaceHandlingContribution, WorkspaceInput, WorkspaceOpenHandlerContribution, WorkspacePreferences } from '@theia/workspace/lib/browser'; +import { LOCAL_FILE_SCHEME } from './local-backend-services'; +import { CURRENT_PORT_PARAM, LOCAL_PORT_PARAM, getCurrentPort, getLocalPort } from '@theia/core/lib/electron-browser/messaging/electron-local-ws-connection-source'; +import { RemoteStatusService } from '../electron-common/remote-status-service'; +import { WindowService } from '@theia/core/lib/browser/window/window-service'; + +@injectable() +export class RemoteLocalWorkspaceContribution implements WorkspaceOpenHandlerContribution, WorkspaceHandlingContribution { + + @inject(RemoteStatusService) + protected readonly remoteStatusService: RemoteStatusService; + + @inject(WindowService) + protected readonly windowService: WindowService; + + @inject(ILogger) + protected logger: ILogger; + + @inject(WorkspacePreferences) + protected preferences: WorkspacePreferences; + + canHandle(uri: URI): boolean { + return uri.scheme === LOCAL_FILE_SCHEME; + } + + async modifyRecentWorksapces(workspaces: string[]): Promise { + return workspaces.map(workspace => { + const uri = new URI(workspace); + if (uri.scheme === 'file') { + return uri.withScheme(LOCAL_FILE_SCHEME).toString(); + } + // possible check as well if a remote/dev-container worksace is from the connected remote and therefore change it to the 'file' scheme + return workspace; + }); + } + + openWorkspace(uri: URI, options?: WorkspaceInput | undefined): MaybePromise { + const workspacePath = uri.path.toString(); + + if (this.preferences['workspace.preserveWindow'] || (options && options.preserveWindow)) { + this.reloadWindow(workspacePath); + } else { + try { + this.openNewWindow(workspacePath); + } catch (error) { + this.logger.error(error.toString()).then(() => this.reloadWindow(workspacePath)); + } + } + } + + protected reloadWindow(workspacePath: string): void { + const currentPort = getCurrentPort(); + this.remoteStatusService.connectionClosed(parseInt(currentPort ?? '0')); + const searchParams = this.getModifiedUrl().searchParams; + this.windowService.reload({ hash: encodeURI(workspacePath), search: Object.fromEntries(searchParams) }); + } + + protected openNewWindow(workspacePath: string): void { + const url = this.getModifiedUrl(); + url.hash = encodeURI(workspacePath); + this.windowService.openNewWindow(url.toString()); + } + + protected getModifiedUrl(): URL { + const url = new URL(window.location.href); + url.searchParams.set(CURRENT_PORT_PARAM, getLocalPort() ?? ''); + url.searchParams.delete(LOCAL_PORT_PARAM); + return url; + } +} diff --git a/packages/remote/tsconfig.json b/packages/remote/tsconfig.json index 6ab2143003036..6c44dde9348cc 100644 --- a/packages/remote/tsconfig.json +++ b/packages/remote/tsconfig.json @@ -17,6 +17,9 @@ }, { "path": "../userstorage" + }, + { + "path": "../workspace" } ] } diff --git a/packages/workspace/src/browser/workspace-frontend-module.ts b/packages/workspace/src/browser/workspace-frontend-module.ts index 9f7e83fd52a94..78ff6710800b2 100644 --- a/packages/workspace/src/browser/workspace-frontend-module.ts +++ b/packages/workspace/src/browser/workspace-frontend-module.ts @@ -16,7 +16,7 @@ import { ContainerModule, interfaces } from '@theia/core/shared/inversify'; import { CommandContribution, MenuContribution, bindContributionProvider } from '@theia/core/lib/common'; -import { WebSocketConnectionProvider, FrontendApplicationContribution, KeybindingContribution } from '@theia/core/lib/browser'; +import { FrontendApplicationContribution, KeybindingContribution, ServiceConnectionProvider } from '@theia/core/lib/browser'; import { OpenFileDialogFactory, SaveFileDialogFactory, @@ -32,7 +32,7 @@ import { LabelProviderContribution } from '@theia/core/lib/browser/label-provide import { VariableContribution } from '@theia/variable-resolver/lib/browser'; import { WorkspaceServer, workspacePath, UntitledWorkspaceService, WorkspaceFileService } from '../common'; import { WorkspaceFrontendContribution } from './workspace-frontend-contribution'; -import { WorkspaceOpenHandlerContribution, WorkspaceService } from './workspace-service'; +import { WorkspaceHandlingContribution, WorkspaceOpenHandlerContribution, WorkspaceService } from './workspace-service'; import { WorkspaceCommandContribution, FileMenuContribution, EditMenuContribution } from './workspace-commands'; import { WorkspaceVariableContribution } from './workspace-variable-contribution'; import { WorkspaceStorageService } from './workspace-storage-service'; @@ -60,15 +60,15 @@ export default new ContainerModule((bind: interfaces.Bind, unbind: interfaces.Un bindWorkspacePreferences(bind); bindWorkspaceTrustPreferences(bind); bindContributionProvider(bind, WorkspaceOpenHandlerContribution); + bindContributionProvider(bind, WorkspaceHandlingContribution); bind(WorkspaceService).toSelf().inSingletonScope(); bind(FrontendApplicationContribution).toService(WorkspaceService); bind(CanonicalUriService).toSelf().inSingletonScope(); - bind(WorkspaceServer).toDynamicValue(ctx => { - const provider = ctx.container.get(WebSocketConnectionProvider); - return provider.createProxy(workspacePath); - }).inSingletonScope(); + bind(WorkspaceServer).toDynamicValue(ctx => + ServiceConnectionProvider.createLocalProxy(ctx.container, workspacePath) + ).inSingletonScope(); bind(WorkspaceFrontendContribution).toSelf().inSingletonScope(); for (const identifier of [FrontendApplicationContribution, CommandContribution, KeybindingContribution, MenuContribution]) { diff --git a/packages/workspace/src/browser/workspace-service.ts b/packages/workspace/src/browser/workspace-service.ts index 188f5bdedcfd8..056fc1b48fa87 100644 --- a/packages/workspace/src/browser/workspace-service.ts +++ b/packages/workspace/src/browser/workspace-service.ts @@ -44,6 +44,11 @@ export interface WorkspaceOpenHandlerContribution { getWorkspaceLabel?(uri: URI): MaybePromise; } +export const WorkspaceHandlingContribution = Symbol('WorkspaceHandlingContribution'); +export interface WorkspaceHandlingContribution { + modifyRecentWorksapces?(workspaces: string[]): MaybePromise; +} + /** * The workspace service. */ @@ -103,6 +108,9 @@ export class WorkspaceService implements FrontendApplicationContribution, Worksp @inject(ContributionProvider) @named(WorkspaceOpenHandlerContribution) protected readonly openHandlerContribution: ContributionProvider; + @inject(ContributionProvider) @named(WorkspaceHandlingContribution) + protected readonly workspaceHandlingContribution: ContributionProvider; + protected _ready = new Deferred(); get ready(): Promise { return this._ready.promise; @@ -331,7 +339,15 @@ export class WorkspaceService implements FrontendApplicationContribution, Worksp } async recentWorkspaces(): Promise { - return this.server.getRecentWorkspaces(); + let recentWorkspaces = await this.server.getRecentWorkspaces(); + + for (const handler of this.workspaceHandlingContribution.getContributions()) { + if (handler.modifyRecentWorksapces) { + recentWorkspaces = await handler.modifyRecentWorksapces(recentWorkspaces); + } + } + + return recentWorkspaces; } async removeRecentWorkspace(uri: string): Promise { @@ -371,7 +387,7 @@ export class WorkspaceService implements FrontendApplicationContribution, Worksp throw new Error(`Could not find a handler to open the workspace with uri ${uri.toString()}.`); } - async canHandle(uri: URI): Promise { + canHandle(uri: URI): boolean { return uri.scheme === 'file'; }