Skip to content

Commit

Permalink
Handle file system permissions (#11965)
Browse files Browse the repository at this point in the history
  • Loading branch information
msujew authored Dec 12, 2022
1 parent 1446bca commit ac7a9ff
Show file tree
Hide file tree
Showing 18 changed files with 161 additions and 27 deletions.
11 changes: 9 additions & 2 deletions packages/core/src/browser/shell/tab-bars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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<Title<Widget>, WidgetDecoration.Data[]>();

protected resetDecorations(title?: Title<Widget>): void {
Expand Down
12 changes: 12 additions & 0 deletions packages/core/src/browser/style/tabs.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/browser/widgets/widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -371,6 +372,12 @@ export function pin(title: Title<Widget>): void {
}
}

export function lock(title: Title<Widget>): void {
if (!title.className.includes(LOCKED_CLASS)) {
title.className += ` ${LOCKED_CLASS}`;
}
}

export function togglePinned(title?: Title<Widget>): void {
if (title) {
if (isPinned(title)) {
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/common/resource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
6 changes: 5 additions & 1 deletion packages/editor/src/browser/editor-widget-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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';
Expand Down
5 changes: 4 additions & 1 deletion packages/editor/src/browser/editor-widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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()));
Expand Down
1 change: 1 addition & 0 deletions packages/editor/src/browser/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TextDocumentChangeEvent>;

Expand Down
31 changes: 18 additions & 13 deletions packages/filesystem/src/browser/file-resource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export namespace FileResourceVersion {
}

export interface FileResourceOptions {
isReadonly: boolean
shouldOverwrite: () => Promise<boolean>
shouldOpenAsText: (error: string) => Promise<boolean>
}
Expand All @@ -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,
Expand Down Expand Up @@ -184,15 +188,7 @@ export class FileResource implements Resource {
}
}

saveContents(content: string, options?: ResourceSaveOptions): Promise<void> {
return this.doWrite(content, options);
}

saveStream(content: Readable<string>, options?: ResourceSaveOptions): Promise<void> {
return this.doWrite(content, options);
}

protected async doWrite(content: string | Readable<string>, options?: ResourceSaveOptions): Promise<void> {
protected doWrite = async (content: string | Readable<string>, options?: ResourceSaveOptions): Promise<void> => {
const version = options?.version || this._version;
const current = FileResourceVersion.is(version) ? version : undefined;
const etag = current?.etag;
Expand All @@ -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) => {
Expand Down Expand Up @@ -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)
});
Expand Down
10 changes: 10 additions & 0 deletions packages/filesystem/src/browser/file-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<FileSystemProvider> {
// Assert path is absolute
if (!resource.path.isAbsolute) {
Expand Down
16 changes: 16 additions & 0 deletions packages/filesystem/src/common/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -485,6 +491,14 @@ export enum FileType {
SymbolicLink = 64
}

export enum FilePermission {

/**
* File is readonly.
*/
Readonly = 1
}

export interface Stat {
type: FileType;

Expand All @@ -499,6 +513,8 @@ export interface Stat {
ctime: number;

size: number;

permissions?: FilePermission;
}

export interface WatchOptions {
Expand Down
4 changes: 4 additions & 0 deletions packages/monaco/src/browser/monaco-editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions packages/plugin-ext/src/common/plugin-api-rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1835,6 +1835,7 @@ export interface DebugMain {
}

export interface FileSystemExt {
$acceptProviderInfos(scheme: string, capabilities?: files.FileSystemProviderCapabilities): void;
$stat(handle: number, resource: UriComponents): Promise<files.Stat>;
$readdir(handle: number, resource: UriComponents): Promise<[string, files.FileType][]>;
$readFile(handle: number, resource: UriComponents): Promise<BinaryBuffer>;
Expand Down
15 changes: 12 additions & 3 deletions packages/plugin-ext/src/main/browser/file-system-main-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -46,15 +46,24 @@ export class FileSystemMainImpl implements FileSystemMain, Disposable {
private readonly _proxy: FileSystemExt;
private readonly _fileProvider = new Map<number, RemoteFileSystemProvider>();
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 {
Expand Down
26 changes: 21 additions & 5 deletions packages/plugin-ext/src/plugin/file-system-ext-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ class FsLinkProvider {

class ConsumerFileSystem implements vscode.FileSystem {

constructor(private _proxy: FileSystemMain) { }
constructor(private _proxy: FileSystemMain, private _capabilities: Map<string, number>) { }

stat(uri: vscode.Uri): Promise<vscode.FileStat> {
return this._proxy.$stat(uri).catch(ConsumerFileSystem._handleError);
Expand All @@ -148,7 +148,7 @@ class ConsumerFileSystem implements vscode.FileSystem {
createDirectory(uri: vscode.Uri): Promise<void> {
return this._proxy.$mkdir(uri).catch(ConsumerFileSystem._handleError);
}
async readFile(uri: vscode.Uri): Promise<Uint8Array> {
readFile(uri: vscode.Uri): Promise<Uint8Array> {
return this._proxy.$readFile(uri).then(buff => buff.buffer).catch(ConsumerFileSystem._handleError);
}
writeFile(uri: vscode.Uri, content: Uint8Array): Promise<void> {
Expand All @@ -163,6 +163,13 @@ class ConsumerFileSystem implements vscode.FileSystem {
copy(source: vscode.Uri, destination: vscode.Uri, options?: { overwrite?: boolean }): Promise<void> {
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)) {
Expand Down Expand Up @@ -193,6 +200,7 @@ export class FileSystemExtImpl implements FileSystemExt {
private readonly _proxy: FileSystemMain;
private readonly _linkProvider = new FsLinkProvider();
private readonly _fsProvider = new Map<number, vscode.FileSystemProvider>();
private readonly _capabilities = new Map<string, number>();
private readonly _usedSchemes = new Set<string>();
private readonly _watches = new Map<number, IDisposable>();

Expand All @@ -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));
Expand Down Expand Up @@ -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<files.Stat> {
Expand Down
Loading

0 comments on commit ac7a9ff

Please sign in to comment.