diff --git a/packages/core/src/browser/shell/tab-bars.ts b/packages/core/src/browser/shell/tab-bars.ts index f0237776da929..6d3faade9659c 100644 --- a/packages/core/src/browser/shell/tab-bars.ts +++ b/packages/core/src/browser/shell/tab-bars.ts @@ -31,7 +31,7 @@ import { IconThemeService } from '../icon-theme-service'; import { BreadcrumbsRenderer, BreadcrumbsRendererFactory } from '../breadcrumbs/breadcrumbs-renderer'; import { NavigatableWidget } from '../navigatable-types'; import { IDragEvent } from '@phosphor/dragdrop'; -import { PINNED_CLASS } from '../widgets/widget'; +import { LOCKED_CLASS, PINNED_CLASS } from '../widgets/widget'; import { CorePreferences } from '../core-preferences'; import { HoverService } from '../hover-service'; @@ -178,7 +178,8 @@ export class TabBarRenderer extends TabBar.Renderer { { className: 'theia-tab-icon-label' }, this.renderIcon(data, isInSidePanel), this.renderLabel(data, isInSidePanel), - this.renderBadge(data, isInSidePanel) + this.renderBadge(data, isInSidePanel), + this.renderLock(data, isInSidePanel) ), h.div({ className: 'p-TabBar-tabCloseIcon action-label', @@ -275,6 +276,12 @@ export class TabBarRenderer extends TabBar.Renderer { : h.div({ className: 'theia-badge-decorator-horizontal' }, `${limitedBadge}`); } + renderLock(data: SideBarRenderData, isInSidePanel?: boolean): VirtualElement { + return !isInSidePanel && data.title.className.includes(LOCKED_CLASS) + ? h.div({ className: 'p-TabBar-tabLock' }) + : h.div({}); + } + protected readonly decorations = new Map, WidgetDecoration.Data[]>(); protected resetDecorations(title?: Title): void { diff --git a/packages/core/src/browser/style/tabs.css b/packages/core/src/browser/style/tabs.css index cb35d7e7f5990..51150ef36507d 100644 --- a/packages/core/src/browser/style/tabs.css +++ b/packages/core/src/browser/style/tabs.css @@ -173,6 +173,18 @@ background: none; } +.p-TabBar-tabLock:after { + content: "\ebe7"; + opacity: 0.75; + margin-left: 4px; + color: inherit; + font-family: codicon; + font-size: 16px; + font-weight: normal; + display: inline-block; + vertical-align: top; +} + /* file icons */ .p-TabBar[data-orientation='horizontal'] .p-TabBar-tabIcon.file-icon, .p-TabBar-tab.p-mod-drag-image .p-TabBar-tabIcon.file-icon { diff --git a/packages/core/src/browser/widgets/widget.ts b/packages/core/src/browser/widgets/widget.ts index 5274d0f2386a6..09e920a7cf032 100644 --- a/packages/core/src/browser/widgets/widget.ts +++ b/packages/core/src/browser/widgets/widget.ts @@ -53,6 +53,7 @@ export const CODICON_LOADING_CLASSES = codiconArray('loading'); export const SELECTED_CLASS = 'theia-mod-selected'; export const FOCUS_CLASS = 'theia-mod-focus'; export const PINNED_CLASS = 'theia-mod-pinned'; +export const LOCKED_CLASS = 'theia-mod-locked'; export const DEFAULT_SCROLL_OPTIONS: PerfectScrollbar.Options = { suppressScrollX: true, minScrollbarLength: 35, @@ -371,6 +372,12 @@ export function pin(title: Title): void { } } +export function lock(title: Title): void { + if (!title.className.includes(LOCKED_CLASS)) { + title.className += ` ${LOCKED_CLASS}`; + } +} + export function togglePinned(title?: Title): void { if (title) { if (isPinned(title)) { diff --git a/packages/core/src/common/resource.ts b/packages/core/src/common/resource.ts index 607697993bea7..4f0586a2e8935 100644 --- a/packages/core/src/common/resource.ts +++ b/packages/core/src/common/resource.ts @@ -55,6 +55,7 @@ export interface Resource extends Disposable { * Undefined if a resource did not read content yet. */ readonly encoding?: string | undefined; + readonly isReadonly?: boolean; /** * Reads latest content of this resource. * diff --git a/packages/editor/src/browser/editor-widget-factory.ts b/packages/editor/src/browser/editor-widget-factory.ts index da30689b14bf3..f29daafa4643e 100644 --- a/packages/editor/src/browser/editor-widget-factory.ts +++ b/packages/editor/src/browser/editor-widget-factory.ts @@ -16,7 +16,7 @@ import { injectable, inject } from '@theia/core/shared/inversify'; import URI from '@theia/core/lib/common/uri'; -import { SelectionService } from '@theia/core/lib/common'; +import { nls, SelectionService } from '@theia/core/lib/common'; import { NavigatableWidgetOptions, WidgetFactory, LabelProvider } from '@theia/core/lib/browser'; import { EditorWidget } from './editor-widget'; import { TextEditorProvider } from './editor'; @@ -72,6 +72,10 @@ export class EditorWidgetFactory implements WidgetFactory { private setLabels(editor: EditorWidget, uri: URI): void { editor.title.caption = uri.path.fsPath(); + if (editor.editor.isReadonly) { + // nls-todo: 'Read Only' be available with newer VSCode API + editor.title.caption += ` • ${nls.localize('theia/editor/readOnly', 'Read Only')}`; + } const icon = this.labelProvider.getIcon(uri); editor.title.label = this.labelProvider.getName(uri); editor.title.iconClass = icon + ' file-icon'; diff --git a/packages/editor/src/browser/editor-widget.ts b/packages/editor/src/browser/editor-widget.ts index 324e2728f59d0..669dff4b97f44 100644 --- a/packages/editor/src/browser/editor-widget.ts +++ b/packages/editor/src/browser/editor-widget.ts @@ -15,7 +15,7 @@ // ***************************************************************************** import { Disposable, SelectionService, Event, UNTITLED_SCHEME } from '@theia/core/lib/common'; -import { Widget, BaseWidget, Message, Saveable, SaveableSource, Navigatable, StatefulWidget } from '@theia/core/lib/browser'; +import { Widget, BaseWidget, Message, Saveable, SaveableSource, Navigatable, StatefulWidget, lock } from '@theia/core/lib/browser'; import URI from '@theia/core/lib/common/uri'; import { TextEditor } from './editor'; @@ -27,6 +27,9 @@ export class EditorWidget extends BaseWidget implements SaveableSource, Navigata ) { super(editor); this.addClass('theia-editor'); + if (editor.isReadonly) { + lock(this.title); + } this.toDispose.push(this.editor); this.toDispose.push(this.editor.onSelectionChanged(() => this.setSelection())); this.toDispose.push(this.editor.onFocusChanged(() => this.setSelection())); diff --git a/packages/editor/src/browser/editor.ts b/packages/editor/src/browser/editor.ts index 95997a05bd2e1..ce0a2ce198257 100644 --- a/packages/editor/src/browser/editor.ts +++ b/packages/editor/src/browser/editor.ts @@ -196,6 +196,7 @@ export interface TextEditor extends Disposable, TextEditorSelection, Navigatable readonly node: HTMLElement; readonly uri: URI; + readonly isReadonly: boolean; readonly document: TextEditorDocument; readonly onDocumentContentChanged: Event; diff --git a/packages/filesystem/src/browser/file-resource.ts b/packages/filesystem/src/browser/file-resource.ts index b38fda0fb8e55..2db117ac45113 100644 --- a/packages/filesystem/src/browser/file-resource.ts +++ b/packages/filesystem/src/browser/file-resource.ts @@ -40,6 +40,7 @@ export namespace FileResourceVersion { } export interface FileResourceOptions { + isReadonly: boolean shouldOverwrite: () => Promise shouldOpenAsText: (error: string) => Promise } @@ -60,6 +61,9 @@ export class FileResource implements Resource { get encoding(): string | undefined { return this._version?.encoding; } + get isReadonly(): boolean { + return this.options.isReadonly || this.fileService.hasCapability(this.uri, FileSystemProviderCapabilities.Readonly); + } constructor( readonly uri: URI, @@ -184,15 +188,7 @@ export class FileResource implements Resource { } } - saveContents(content: string, options?: ResourceSaveOptions): Promise { - return this.doWrite(content, options); - } - - saveStream(content: Readable, options?: ResourceSaveOptions): Promise { - return this.doWrite(content, options); - } - - protected async doWrite(content: string | Readable, options?: ResourceSaveOptions): Promise { + protected doWrite = async (content: string | Readable, options?: ResourceSaveOptions): Promise => { const version = options?.version || this._version; const current = FileResourceVersion.is(version) ? version : undefined; const etag = current?.etag; @@ -218,14 +214,22 @@ export class FileResource implements Resource { } throw e; } - } + }; + saveStream?: Resource['saveStream']; + saveContents?: Resource['saveContents']; saveContentChanges?: Resource['saveContentChanges']; protected updateSavingContentChanges(): void { - if (this.fileService.hasCapability(this.uri, FileSystemProviderCapabilities.Update)) { - this.saveContentChanges = this.doSaveContentChanges; - } else { + if (this.isReadonly) { delete this.saveContentChanges; + delete this.saveContents; + delete this.saveStream; + } else { + this.saveContents = this.doWrite; + this.saveStream = this.doWrite; + if (this.fileService.hasCapability(this.uri, FileSystemProviderCapabilities.Update)) { + this.saveContentChanges = this.doSaveContentChanges; + } } } protected doSaveContentChanges: Resource['saveContentChanges'] = async (changes, options) => { @@ -317,6 +321,7 @@ export class FileResourceResolver implements ResourceResolver { throw new Error('The given uri is a directory: ' + this.labelProvider.getLongName(uri)); } return new FileResource(uri, this.fileService, { + isReadonly: stat?.isReadonly ?? false, shouldOverwrite: () => this.shouldOverwrite(uri), shouldOpenAsText: error => this.shouldOpenAsText(uri, error) }); diff --git a/packages/filesystem/src/browser/file-service.ts b/packages/filesystem/src/browser/file-service.ts index ffc0100cdf93c..c9c59cbfc78b1 100644 --- a/packages/filesystem/src/browser/file-service.ts +++ b/packages/filesystem/src/browser/file-service.ts @@ -426,6 +426,16 @@ export class FileService { return !!(provider && (provider.capabilities & capability)); } + /** + * List the schemes and capabilities for registered file system providers + */ + listCapabilities(): { scheme: string; capabilities: FileSystemProviderCapabilities }[] { + return Array.from(this.providers.entries()).map(([scheme, provider]) => ({ + scheme, + capabilities: provider.capabilities + })); + } + protected async withProvider(resource: URI): Promise { // Assert path is absolute if (!resource.path.isAbsolute) { diff --git a/packages/filesystem/src/common/files.ts b/packages/filesystem/src/common/files.ts index 2dfed8108bb9c..94d1dc781ead0 100644 --- a/packages/filesystem/src/common/files.ts +++ b/packages/filesystem/src/common/files.ts @@ -234,6 +234,11 @@ export interface FileStat extends BaseStat { */ isSymbolicLink: boolean; + /** + * The resource is read only. + */ + isReadonly: boolean; + /** * The children of the file stat or undefined if none. */ @@ -277,6 +282,7 @@ export namespace FileStat { isFile: (stat.type & FileType.File) !== 0, isDirectory: (stat.type & FileType.Directory) !== 0, isSymbolicLink: (stat.type & FileType.SymbolicLink) !== 0, + isReadonly: !!stat.permissions && (stat.permissions & FilePermission.Readonly) !== 0, mtime: stat.mtime, ctime: stat.ctime, size: stat.size, @@ -485,6 +491,14 @@ export enum FileType { SymbolicLink = 64 } +export enum FilePermission { + + /** + * File is readonly. + */ + Readonly = 1 +} + export interface Stat { type: FileType; @@ -499,6 +513,8 @@ export interface Stat { ctime: number; size: number; + + permissions?: FilePermission; } export interface WatchOptions { diff --git a/packages/monaco/src/browser/monaco-editor.ts b/packages/monaco/src/browser/monaco-editor.ts index 5c28658e28b70..96a7d0a5f53c6 100644 --- a/packages/monaco/src/browser/monaco-editor.ts +++ b/packages/monaco/src/browser/monaco-editor.ts @@ -220,6 +220,10 @@ export class MonacoEditor extends MonacoEditorServices implements TextEditor { return this.onDocumentContentChangedEmitter.event; } + get isReadonly(): boolean { + return this.document.isReadonly(); + } + get cursor(): Position { const { lineNumber, column } = this.editor.getPosition()!; return this.m2p.asPosition(lineNumber, column); diff --git a/packages/plugin-ext/src/common/plugin-api-rpc.ts b/packages/plugin-ext/src/common/plugin-api-rpc.ts index a77d09cee6151..3c6d9524bcef7 100644 --- a/packages/plugin-ext/src/common/plugin-api-rpc.ts +++ b/packages/plugin-ext/src/common/plugin-api-rpc.ts @@ -1829,6 +1829,7 @@ export interface DebugMain { } export interface FileSystemExt { + $acceptProviderInfos(scheme: string, capabilities?: files.FileSystemProviderCapabilities): void; $stat(handle: number, resource: UriComponents): Promise; $readdir(handle: number, resource: UriComponents): Promise<[string, files.FileType][]>; $readFile(handle: number, resource: UriComponents): Promise; diff --git a/packages/plugin-ext/src/main/browser/file-system-main-impl.ts b/packages/plugin-ext/src/main/browser/file-system-main-impl.ts index cebfd8d7e95f9..126cb3f8d06bc 100644 --- a/packages/plugin-ext/src/main/browser/file-system-main-impl.ts +++ b/packages/plugin-ext/src/main/browser/file-system-main-impl.ts @@ -27,7 +27,7 @@ import { URI } from '@theia/core/shared/vscode-uri'; import { interfaces } from '@theia/core/shared/inversify'; import CoreURI from '@theia/core/lib/common/uri'; import { BinaryBuffer } from '@theia/core/lib/common/buffer'; -import { Disposable } from '@theia/core/lib/common/disposable'; +import { Disposable, DisposableCollection } from '@theia/core/lib/common/disposable'; import { Event, Emitter } from '@theia/core/lib/common/event'; import { MAIN_RPC_CONTEXT, FileSystemMain, FileSystemExt, IFileChangeDto } from '../../common/plugin-api-rpc'; import { RPCProtocol } from '../../common/rpc-protocol'; @@ -46,15 +46,24 @@ export class FileSystemMainImpl implements FileSystemMain, Disposable { private readonly _proxy: FileSystemExt; private readonly _fileProvider = new Map(); private readonly _fileService: FileService; + private readonly _disposables = new DisposableCollection(); constructor(rpc: RPCProtocol, container: interfaces.Container) { this._proxy = rpc.getProxy(MAIN_RPC_CONTEXT.FILE_SYSTEM_EXT); this._fileService = container.get(FileService); + + for (const { scheme, capabilities } of this._fileService.listCapabilities()) { + this._proxy.$acceptProviderInfos(scheme, capabilities); + } + + this._disposables.push(this._fileService.onDidChangeFileSystemProviderRegistrations(e => this._proxy.$acceptProviderInfos(e.scheme, e.provider?.capabilities))); + this._disposables.push(this._fileService.onDidChangeFileSystemProviderCapabilities(e => this._proxy.$acceptProviderInfos(e.scheme, e.provider.capabilities))); + this._disposables.push(Disposable.create(() => this._fileProvider.forEach(value => value.dispose()))); + this._disposables.push(Disposable.create(() => this._fileProvider.clear())); } dispose(): void { - this._fileProvider.forEach(value => value.dispose()); - this._fileProvider.clear(); + this._disposables.dispose(); } $registerFileSystemProvider(handle: number, scheme: string, capabilities: FileSystemProviderCapabilities): void { diff --git a/packages/plugin-ext/src/plugin/file-system-ext-impl.ts b/packages/plugin-ext/src/plugin/file-system-ext-impl.ts index 9d771c00322a4..a335a1f03e794 100644 --- a/packages/plugin-ext/src/plugin/file-system-ext-impl.ts +++ b/packages/plugin-ext/src/plugin/file-system-ext-impl.ts @@ -137,7 +137,7 @@ class FsLinkProvider { class ConsumerFileSystem implements vscode.FileSystem { - constructor(private _proxy: FileSystemMain) { } + constructor(private _proxy: FileSystemMain, private _capabilities: Map) { } stat(uri: vscode.Uri): Promise { return this._proxy.$stat(uri).catch(ConsumerFileSystem._handleError); @@ -148,7 +148,7 @@ class ConsumerFileSystem implements vscode.FileSystem { createDirectory(uri: vscode.Uri): Promise { return this._proxy.$mkdir(uri).catch(ConsumerFileSystem._handleError); } - async readFile(uri: vscode.Uri): Promise { + readFile(uri: vscode.Uri): Promise { return this._proxy.$readFile(uri).then(buff => buff.buffer).catch(ConsumerFileSystem._handleError); } writeFile(uri: vscode.Uri, content: Uint8Array): Promise { @@ -163,6 +163,13 @@ class ConsumerFileSystem implements vscode.FileSystem { copy(source: vscode.Uri, destination: vscode.Uri, options?: { overwrite?: boolean }): Promise { return this._proxy.$copy(source, destination, { ...{ overwrite: false }, ...options }).catch(ConsumerFileSystem._handleError); } + isWritableFileSystem(scheme: string): boolean | undefined { + const capabilities = this._capabilities.get(scheme); + if (typeof capabilities === 'number') { + return (capabilities & files.FileSystemProviderCapabilities.Readonly) === 0; + } + return undefined; + } private static _handleError(err: any): never { // generic error if (!(err instanceof Error)) { @@ -193,6 +200,7 @@ export class FileSystemExtImpl implements FileSystemExt { private readonly _proxy: FileSystemMain; private readonly _linkProvider = new FsLinkProvider(); private readonly _fsProvider = new Map(); + private readonly _capabilities = new Map(); private readonly _usedSchemes = new Set(); private readonly _watches = new Map(); @@ -203,7 +211,7 @@ export class FileSystemExtImpl implements FileSystemExt { constructor(rpc: RPCProtocol, private _extHostLanguageFeatures: LanguagesExtImpl) { this._proxy = rpc.getProxy(PLUGIN_RPC_CONTEXT.FILE_SYSTEM_MAIN); - this.fileSystem = new ConsumerFileSystem(this._proxy); + this.fileSystem = new ConsumerFileSystem(this._proxy, this._capabilities); // register used schemes Object.keys(Schemas).forEach(scheme => this._usedSchemes.add(scheme)); @@ -295,8 +303,16 @@ export class FileSystemExtImpl implements FileSystemExt { } private static _asIStat(stat: vscode.FileStat): files.Stat { - const { type, ctime, mtime, size } = stat; - return { type, ctime, mtime, size }; + const { type, ctime, mtime, size, permissions } = stat; + return { type, ctime, mtime, size, permissions }; + } + + $acceptProviderInfos(scheme: string, capabilities?: files.FileSystemProviderCapabilities): void { + if (typeof capabilities === 'number') { + this._capabilities.set(scheme, capabilities); + } else { + this._capabilities.delete(scheme); + } } $stat(handle: number, resource: UriComponents): Promise { diff --git a/packages/plugin-ext/src/plugin/plugin-context.ts b/packages/plugin-ext/src/plugin/plugin-context.ts index 36f99bf58ecf2..7d0abb638a8a4 100644 --- a/packages/plugin-ext/src/plugin/plugin-context.ts +++ b/packages/plugin-ext/src/plugin/plugin-context.ts @@ -201,6 +201,7 @@ import { CustomEditorsExtImpl } from './custom-editors'; import { WebviewViewsExtImpl } from './webview-views'; import { PluginPackage } from '../common'; import { Endpoint } from '@theia/core/lib/browser/endpoint'; +import { FilePermission } from '@theia/filesystem/lib/common/files'; export function createAPIFactory( rpc: RPCProtocol, @@ -599,8 +600,8 @@ export function createAPIFactory( registerTextDocumentContentProvider(scheme: string, provider: theia.TextDocumentContentProvider): theia.Disposable { return workspaceExt.registerTextDocumentContentProvider(scheme, provider); }, - registerFileSystemProvider(scheme: string, provider: theia.FileSystemProvider): theia.Disposable { - return fileSystemExt.registerFileSystemProvider(scheme, provider); + registerFileSystemProvider(scheme: string, provider: theia.FileSystemProvider, options?: { isCaseSensitive?: boolean, isReadonly?: boolean }): theia.Disposable { + return fileSystemExt.registerFileSystemProvider(scheme, provider, options); }, getWorkspaceFolder(uri: theia.Uri): theia.WorkspaceFolder | undefined { return workspaceExt.getWorkspaceFolder(uri); @@ -1065,6 +1066,7 @@ export function createAPIFactory( WorkspaceEdit, SymbolInformation, FileType, + FilePermission, FileChangeType, ShellQuoting, ShellExecution, diff --git a/packages/plugin/src/theia.d.ts b/packages/plugin/src/theia.d.ts index 869976cc8b36e..7db1c07ddf209 100644 --- a/packages/plugin/src/theia.d.ts +++ b/packages/plugin/src/theia.d.ts @@ -5961,6 +5961,18 @@ export module '@theia/plugin' { SymbolicLink = 64 } + export enum FilePermission { + /** + * The file is readonly. + * + * *Note:* All `FileStat` from a `FileSystemProvider` that is registered with + * the option `isReadonly: true` will be implicitly handled as if `FilePermission.Readonly` + * is set. As a consequence, it is not possible to have a readonly file system provider + * registered where some `FileStat` are not readonly. + */ + Readonly = 1 + } + /** * The `FileStat`-type represents metadata about a file */ @@ -5992,6 +6004,12 @@ export module '@theia/plugin' { * example. */ size: number; + /** + * The permissions of the file, e.g. whether the file is readonly. + * + * *Note:* This value might be a bitmask, e.g. `FilePermission.Readonly | FilePermission.Other`. + */ + permissions?: FilePermission; } /** @@ -6301,6 +6319,21 @@ export module '@theia/plugin' { * @param options Defines if existing files should be overwritten. */ copy(source: Uri, target: Uri, options?: { overwrite?: boolean }): Thenable; + + /** + * Check if a given file system supports writing files. + * + * Keep in mind that just because a file system supports writing, that does + * not mean that writes will always succeed. There may be permissions issues + * or other errors that prevent writing a file. + * + * @param scheme The scheme of the filesystem, for example `file` or `git`. + * + * @return `true` if the file system supports writing, `false` if it does not + * support writing (i.e. it is readonly), and `undefined` if the editor does not + * know about the filesystem. + */ + isWritableFileSystem(scheme: string): boolean | undefined; } /** diff --git a/packages/property-view/src/browser/resource-property-view/resource-property-data-service.spec.ts b/packages/property-view/src/browser/resource-property-view/resource-property-data-service.spec.ts index 8d8f64700cd9a..bd38dcf1c6ec6 100644 --- a/packages/property-view/src/browser/resource-property-view/resource-property-data-service.spec.ts +++ b/packages/property-view/src/browser/resource-property-view/resource-property-data-service.spec.ts @@ -39,6 +39,7 @@ const mockFileStat: FileStat = { isFile: false, isDirectory: true, isSymbolicLink: false, + isReadonly: false, resource: new URI('resource'), name: 'name' }; diff --git a/packages/workspace/src/browser/workspace-commands.spec.ts b/packages/workspace/src/browser/workspace-commands.spec.ts index a41e0a0ea4d93..68a369b0bd8bb 100644 --- a/packages/workspace/src/browser/workspace-commands.spec.ts +++ b/packages/workspace/src/browser/workspace-commands.spec.ts @@ -48,6 +48,7 @@ describe('workspace-commands', () => { isFile: true, isDirectory: false, isSymbolicLink: false, + isReadonly: false, resource: new URI('foo/bar'), name: 'bar', }; @@ -56,6 +57,7 @@ describe('workspace-commands', () => { isFile: false, isDirectory: true, isSymbolicLink: false, + isReadonly: false, resource: new URI('foo'), name: 'foo', children: [