Skip to content
Snippets Groups Projects
Commit a71cc7e1 authored by Paul Slaughter's avatar Paul Slaughter 2️⃣
Browse files

feat: Open MR changes

- Adds command to compare with MR base.
- Also adds VSCode UI contributions for this command.
- When first loaded, opens top 20 changes on Web IDE open.
- Part [of this issue][1].

[1]: gitlab#386256
parent 3f4e82ce
No related branches found
No related tags found
No related merge requests found
Showing
with 569 additions and 16 deletions
import * as vscode from 'vscode';
import { FS_SCHEME, MR_SCHEME } from '../constants';
import compareWithMrBase from './compareWithMrBase';
const DEFAULT_PATH = '/gitlab-ui/src/default.js';
const DEFAULT_TITLE = 'default.js';
describe('commands/compareWithMrBase', () => {
beforeEach(() => {
const document: Partial<vscode.TextDocument> = {
uri: vscode.Uri.from({ scheme: FS_SCHEME, path: DEFAULT_PATH }),
};
const activeTextEditor: Partial<vscode.TextEditor> = {
document: document as vscode.TextDocument,
};
vscode.window.activeTextEditor = activeTextEditor as vscode.TextEditor;
});
it.each`
path | options | expectedPath | expectedTitle | expectedOptions
${'/gitlab-ui/README.md'} | ${{}} | ${'/gitlab-ui/README.md'} | ${'README.md'} | ${{ preview: true }}
${'/gitlab-ui/README.md'} | ${{ preview: false }} | ${'/gitlab-ui/README.md'} | ${'README.md'} | ${{ preview: false }}
${''} | ${{}} | ${DEFAULT_PATH} | ${DEFAULT_TITLE} | ${{ preview: true }}
`(
'with (path=$path, options=$options), executed vscode.diff',
async ({ path, options, expectedPath, expectedTitle, expectedOptions }) => {
const uri = path ? vscode.Uri.from({ scheme: FS_SCHEME, path }) : undefined;
expect(vscode.commands.executeCommand).not.toHaveBeenCalled();
await compareWithMrBase(uri, [], options);
expect(vscode.commands.executeCommand).toHaveBeenCalledWith(
'vscode.diff',
vscode.Uri.from({ scheme: MR_SCHEME, path: expectedPath }),
vscode.Uri.from({ scheme: FS_SCHEME, path: expectedPath }),
expectedTitle,
expectedOptions,
);
},
);
it('without activeTextEditor and called with undefined, does nothing', async () => {
vscode.window.activeTextEditor = undefined;
await compareWithMrBase();
expect(vscode.commands.executeCommand).not.toHaveBeenCalled();
});
});
import { basename } from '@gitlab/utils-path';
import * as vscode from 'vscode';
import { FS_SCHEME, MR_SCHEME } from '../constants';
const getDefaultUri = (): vscode.Uri | undefined => vscode.window.activeTextEditor?.document.uri;
/**
* Command handler that opens a vscode.diff (MR base vs. File System) of the given URI path
*
* **Note:** This commands is contributed to the explorer context menu,
* which determines the type of arguments provided.
*
* @param uriArg - The URI which contains the "path" to use. If not provided, this will default to the active editor's URI.
* @param allUris - All URI's selected by the explorer context (required by explorer context menu commands).
* @param options - Options used when opening the diff.
*/
export default async (uriArg?: vscode.Uri, allUris?: vscode.Uri[], { preview = true } = {}) => {
const uri = uriArg || getDefaultUri();
if (!uri) {
// noop
return;
}
const { path } = uri;
await vscode.commands.executeCommand(
'vscode.diff',
vscode.Uri.from({ scheme: MR_SCHEME, path }),
vscode.Uri.from({ scheme: FS_SCHEME, path }),
basename(path),
{ preview },
);
};
......@@ -7,9 +7,11 @@ import {
SHARE_YOUR_FEEDBACK_COMMAND_ID,
OPEN_REMOTE_WINDOW_COMMAND_ID,
RELOAD_WITH_WARNING_COMMAND_ID,
COMPARE_WITH_MR_BASE_COMMAND_ID,
} from '../constants';
import { registerCommands } from './index';
import checkoutBranch from './checkoutBranch';
import compareWithMrBase from './compareWithMrBase';
import goToGitLab from './goToGitLab';
import goToProject from './goToProject';
import reloadWithWarning from './reloadWithWarning';
......@@ -35,10 +37,11 @@ describe('commands/index', () => {
});
it.each`
commandName | commandFn
${START_REMOTE_COMMAND_ID} | ${startRemote}
${SHARE_YOUR_FEEDBACK_COMMAND_ID} | ${shareYourFeedback}
${RELOAD_WITH_WARNING_COMMAND_ID} | ${reloadWithWarning}
commandName | commandFn
${START_REMOTE_COMMAND_ID} | ${startRemote}
${SHARE_YOUR_FEEDBACK_COMMAND_ID} | ${shareYourFeedback}
${RELOAD_WITH_WARNING_COMMAND_ID} | ${reloadWithWarning}
${COMPARE_WITH_MR_BASE_COMMAND_ID} | ${compareWithMrBase}
`(
'registers $commandName command in the vscode command registry',
({ commandName, commandFn }) => {
......@@ -72,6 +75,7 @@ describe('commands/index', () => {
expect(disposables).toEqual([
{ commandName: CHECKOUT_BRANCH_COMMAND_ID, dispose: expect.any(Function) },
{ commandName: COMPARE_WITH_MR_BASE_COMMAND_ID, dispose: expect.any(Function) },
{ commandName: GO_TO_GITLAB_COMMAND_ID, dispose: expect.any(Function) },
{ commandName: GO_TO_PROJECT_COMMAND_ID, dispose: expect.any(Function) },
{ commandName: OPEN_REMOTE_WINDOW_COMMAND_ID, dispose: expect.any(Function) },
......
......@@ -7,13 +7,15 @@ import {
START_REMOTE_COMMAND_ID,
OPEN_REMOTE_WINDOW_COMMAND_ID,
RELOAD_WITH_WARNING_COMMAND_ID,
COMPARE_WITH_MR_BASE_COMMAND_ID,
} from '../constants';
import { CommandsInitialData } from '../types';
import checkoutBranch from './checkoutBranch';
import compareWithMrBase from './compareWithMrBase';
import goToGitLab from './goToGitLab';
import goToProject from './goToProject';
import shareYourFeedback from './shareYourFeedback';
import startRemote from './startRemote';
import { CommandsInitialData } from '../types';
import openRemoteWindow from './openRemoteWindow';
import reloadWithWarning from './reloadWithWarning';
......@@ -23,6 +25,7 @@ export const registerCommands = (
) => {
disposables.push(
vscode.commands.registerCommand(CHECKOUT_BRANCH_COMMAND_ID, checkoutBranch(dataPromise)),
vscode.commands.registerCommand(COMPARE_WITH_MR_BASE_COMMAND_ID, compareWithMrBase),
vscode.commands.registerCommand(GO_TO_GITLAB_COMMAND_ID, goToGitLab(dataPromise)),
vscode.commands.registerCommand(GO_TO_PROJECT_COMMAND_ID, goToProject(dataPromise)),
vscode.commands.registerCommand(OPEN_REMOTE_WINDOW_COMMAND_ID, openRemoteWindow(dataPromise)),
......
......@@ -3,6 +3,7 @@ export const EXTENSION_ID = 'gitlab-web-ide';
export const FS_SCHEME = 'gitlab-web-ide';
export const SCM_SCHEME = 'gitlab-web-ide-scm';
export const MR_SCHEME = 'gitlab-web-ide-mr';
export const GET_STARTED_WALKTHROUGH_ID = `${FULL_EXTENSION_ID}#getStartedWebIde`;
......@@ -13,6 +14,8 @@ export const COMMIT_COMMAND_TEXT = 'Commit & Push';
export const CHECKOUT_BRANCH_COMMAND_ID = `${EXTENSION_ID}.checkout-branch`;
export const COMPARE_WITH_MR_BASE_COMMAND_ID = `${EXTENSION_ID}.compare-with-mr-base`;
export const GO_TO_GITLAB_COMMAND_ID = `${EXTENSION_ID}.go-to-gitlab`;
export const GO_TO_PROJECT_COMMAND_ID = `${EXTENSION_ID}.go-to-project`;
......@@ -46,6 +49,7 @@ export const BRANCH_STATUS_BAR_ITEM_PRIORITY = 50;
// region: Context
export const WEB_IDE_READY_CONTEXT_ID = 'gitlab-web-ide.is-ready';
export const MERGE_REQUEST_FILE_PATHS_CONTEXT_ID = 'gitlab-web-ide.mergeRequestFilePaths';
// region: Micellaneous -------------------------------------------------------
export const FEEDBACK_ISSUE_URL = 'https://gitlab.com/gitlab-org/gitlab/-/issues/385787';
......@@ -2,7 +2,7 @@
import '../vscode.proposed.codiconDecoration.d';
import * as vscode from 'vscode';
import { basename } from '@gitlab/utils-path';
import { basename, joinPaths } from '@gitlab/utils-path';
import {
initMergeRequestContext,
MSG_FAILED,
......@@ -11,6 +11,15 @@ import {
import { fetchMergeRequestDiffStats } from './mediator';
import { createFakeProgress } from '../test-utils/vscode';
import { MergeRequestFileDecorationProvider } from './vscode/MergeRequestFileDecorationProvider';
import {
COMPARE_WITH_MR_BASE_COMMAND_ID,
FS_SCHEME,
MERGE_REQUEST_FILE_PATHS_CONTEXT_ID,
MR_SCHEME,
} from './constants';
import { GitLabReadonlyFileSystemProvider } from './vscode/GitLabReadonlyFileSystemProvider';
import { FileContentProviderWith404AsEmpty, FileContentProviderWithRepoRoot } from './utils/fs';
import { GitLabFileContentProvider } from './GitLabFileContentProvider';
jest.mock('./mediator');
......@@ -38,6 +47,7 @@ const TEST_OPTIONS: Parameters<typeof initMergeRequestContext>[2] = {
isMergeRequestBranch: true,
mergeRequestUrl: 'https://gitlab.com/gitlab-org/gitlab-web-ide/-/merge_requests/1',
},
isReload: false,
};
const TEST_DIFF_STATS: Awaited<ReturnType<typeof fetchMergeRequestDiffStats>> = [
{
......@@ -57,24 +67,37 @@ const TEST_DIFF_STATS: Awaited<ReturnType<typeof fetchMergeRequestDiffStats>> =
deletions: 0,
},
];
const LARGE_DIFF_STATS: Awaited<ReturnType<typeof fetchMergeRequestDiffStats>> = Array(100)
.fill(1)
.map((_, idx) => ({
path: `file_${idx}.md`,
additions: idx,
deletions: idx,
}));
describe('initMergeRequestContext', () => {
let progress: vscode.Progress<{ increment: number; message: string }>;
let disposables: vscode.Disposable[];
const createMockDisposable = (): vscode.Disposable => ({ dispose: jest.fn() });
const getCallsForCommand = (commandId: string) =>
jest.mocked(vscode.commands.executeCommand).mock.calls.filter(([id]) => id === commandId);
beforeEach(() => {
disposables = [];
progress = createFakeProgress();
jest
.mocked(vscode.window.registerFileDecorationProvider)
.mockImplementation(() => ({ dispose: jest.fn() }));
.mockImplementation(createMockDisposable);
jest
.mocked(vscode.workspace.registerFileSystemProvider)
.mockImplementation(createMockDisposable);
jest.mocked(fetchMergeRequestDiffStats).mockResolvedValue(TEST_DIFF_STATS);
});
describe('default', () => {
beforeEach(async () => {
jest.mocked(fetchMergeRequestDiffStats).mockResolvedValue(TEST_DIFF_STATS);
await initMergeRequestContext(disposables, progress, TEST_OPTIONS);
});
......@@ -111,9 +134,91 @@ describe('initMergeRequestContext', () => {
expect(disposables).toContain(disposable);
});
it('registers file system provider', () => {
const provider = jest.mocked(vscode.workspace.registerFileSystemProvider).mock.calls[0];
const disposable = jest.mocked(vscode.workspace.registerFileSystemProvider).mock.results[0]
.value;
const baseContentProvider = new GitLabFileContentProvider(TEST_OPTIONS.mergeRequest.baseSha);
const decoratedContentProvider = new FileContentProviderWith404AsEmpty(
new FileContentProviderWithRepoRoot(baseContentProvider, TEST_OPTIONS.repoRoot),
);
expect(provider).toEqual([
MR_SCHEME,
new GitLabReadonlyFileSystemProvider(decoratedContentProvider),
{ isReadonly: true },
]);
expect(disposables).toContain(disposable);
});
it('does not show warning message', () => {
expect(vscode.window.showWarningMessage).not.toHaveBeenCalled();
});
it('sets context', () => {
expect(vscode.commands.executeCommand).toHaveBeenCalledWith(
'setContext',
MERGE_REQUEST_FILE_PATHS_CONTEXT_ID,
[
joinPaths('/', TEST_OPTIONS.repoRoot, TEST_DIFF_STATS[0].path),
joinPaths('/', TEST_OPTIONS.repoRoot, TEST_DIFF_STATS[2].path),
],
);
});
it('opens MR changes', () => {
expect(getCallsForCommand(COMPARE_WITH_MR_BASE_COMMAND_ID)).toEqual(
[
joinPaths('/', TEST_OPTIONS.repoRoot, TEST_DIFF_STATS[2].path),
joinPaths('/', TEST_OPTIONS.repoRoot, TEST_DIFF_STATS[0].path),
].map(path => [
COMPARE_WITH_MR_BASE_COMMAND_ID,
vscode.Uri.from({ scheme: FS_SCHEME, path }),
undefined,
{ preview: false },
]),
);
});
});
describe('when called with isReload', () => {
beforeEach(async () => {
await initMergeRequestContext(disposables, progress, { ...TEST_OPTIONS, isReload: true });
});
it('does not open MR changes', () => {
expect(getCallsForCommand(COMPARE_WITH_MR_BASE_COMMAND_ID)).toEqual([]);
});
});
describe('with 100 MR changes', () => {
beforeEach(async () => {
jest.mocked(fetchMergeRequestDiffStats).mockResolvedValue(LARGE_DIFF_STATS);
await initMergeRequestContext(disposables, progress, {
...TEST_OPTIONS,
// why: We need to update `files` or the changes will be considered "deletions"
// and will not be opened.
files: LARGE_DIFF_STATS.map(({ path }) => createTestFile('blob', path)),
});
});
it('only opens top 20 MR changes', () => {
// Note that this is the opposite order of the other opens MR changes test, implying that we're sorting here
const paths = LARGE_DIFF_STATS.slice(LARGE_DIFF_STATS.length - 20).map(({ path }) =>
joinPaths('/', TEST_OPTIONS.repoRoot, path),
);
expect(getCallsForCommand(COMPARE_WITH_MR_BASE_COMMAND_ID)).toEqual(
paths.map(path => [
COMPARE_WITH_MR_BASE_COMMAND_ID,
vscode.Uri.from({ scheme: FS_SCHEME, path }),
undefined,
{ preview: false },
]),
);
});
});
describe('when fetch diff stats fails', () => {
......
import { flow, sortBy, take } from 'lodash';
import { cleanLeadingSeparator, joinPaths, splitParent } from '@gitlab/utils-path';
import { StartCommandResponse } from '@gitlab/vscode-mediator-commands';
import {
FetchMergeRequestDiffStatsResponse,
StartCommandResponse,
} from '@gitlab/vscode-mediator-commands';
import * as vscode from 'vscode';
import {
COMPARE_WITH_MR_BASE_COMMAND_ID,
FS_SCHEME,
MERGE_REQUEST_FILE_PATHS_CONTEXT_ID,
MR_SCHEME,
} from './constants';
import { GitLabFileContentProvider } from './GitLabFileContentProvider';
import { fetchMergeRequestDiffStats } from './mediator';
import { GitLabReadonlyFileSystemProvider } from './vscode/GitLabReadonlyFileSystemProvider';
import { MergeRequestFileDecorationProvider } from './vscode/MergeRequestFileDecorationProvider';
import { FileContentProviderWith404AsEmpty, FileContentProviderWithRepoRoot } from './utils/fs';
// why: There's some non-determinism in this class (specifically with the RateLimiter set up)
jest.mock('./GitLabFileContentProvider', () => ({
// why: Provide custom mock so that equality checks work
GitLabFileContentProvider: function MockGitLabFileContentProvider() {
// intentional noop
},
}));
// why: Export for testing
export const MSG_LOADING_MERGE_REQUEST = 'Loading merge request details...';
export const MSG_FAILED =
'Failed to load merge request details. See the console for more information.';
const MAX_FILES_TO_OPEN = 20;
type InitMergeRequestOptions = {
mergeRequest: NonNullable<StartCommandResponse['mergeRequest']>;
files: StartCommandResponse['files'];
repoRoot: string;
isReload: boolean;
};
const getMergeRequestChanges = async ({
......@@ -64,6 +87,62 @@ const createPathsSet = (diffs: { path: string }[]): ReadonlySet<string> => {
return pathsSet;
};
const initMergeRequestFileDecorator = (
disposables: vscode.Disposable[],
mrChanges: Awaited<ReturnType<typeof getMergeRequestChanges>>,
) => {
const mrPathsSet = createPathsSet(mrChanges);
disposables.push(
vscode.window.registerFileDecorationProvider(
new MergeRequestFileDecorationProvider(mrPathsSet),
),
);
};
const initMergeRequestFileSystem = (
disposables: vscode.Disposable[],
{ mergeRequest, repoRoot }: InitMergeRequestOptions,
) => {
// what: Apply decorators to base GitLabFileContentProvider
const mrContentProvider = flow(
// why: Strip the repoRoot which is passed to the FileSystemProvider
x => new FileContentProviderWithRepoRoot(x, repoRoot),
// why: If we receive a 404, it probably means we are adding a new file, so just treat as empty
x => new FileContentProviderWith404AsEmpty(x),
)(new GitLabFileContentProvider(mergeRequest.baseSha));
disposables.push(
vscode.workspace.registerFileSystemProvider(
MR_SCHEME,
new GitLabReadonlyFileSystemProvider(mrContentProvider),
{
isReadonly: true,
},
),
);
};
const openMergeRequestChanges = async (mrChanges: FetchMergeRequestDiffStatsResponse) => {
const pathsToOpen = take(
sortBy(mrChanges, x => -(x.additions + x.deletions)),
MAX_FILES_TO_OPEN,
)
.map(x => x.path)
.reverse();
await Promise.allSettled(
pathsToOpen.map(async path => {
await vscode.commands.executeCommand(
COMPARE_WITH_MR_BASE_COMMAND_ID,
vscode.Uri.from({ scheme: FS_SCHEME, path }),
undefined,
{ preview: false },
);
}),
);
};
export const initMergeRequestContext = async (
disposables: vscode.Disposable[],
progress: vscode.Progress<{ increment: number; message: string }>,
......@@ -74,13 +153,18 @@ export const initMergeRequestContext = async (
try {
const mrChanges = await getMergeRequestChanges(options);
const mrPathsSet = createPathsSet(mrChanges);
disposables.push(
vscode.window.registerFileDecorationProvider(
new MergeRequestFileDecorationProvider(mrPathsSet),
),
await vscode.commands.executeCommand(
'setContext',
MERGE_REQUEST_FILE_PATHS_CONTEXT_ID,
mrChanges.map(x => x.path),
);
initMergeRequestFileDecorator(disposables, mrChanges);
initMergeRequestFileSystem(disposables, options);
if (!options.isReload) {
await openMergeRequestChanges(mrChanges);
}
} catch (e) {
// eslint-disable-next-line no-console
console.error(e);
......
......@@ -113,6 +113,7 @@ async function initialize(
mergeRequest,
files,
repoRoot,
isReload: options.isReload,
});
}
}
......
import type { IFileContentProvider } from '@gitlab/web-ide-fs';
import { FileContentProviderWith404AsEmpty } from './FileContentProviderWith404AsEmpty';
const TEST_CONTENT = Buffer.from('Hello world!');
const TEST_PATH = 'foo/path';
describe('utils/fs/FileContentProviderWith404AsEmpty', () => {
let base: IFileContentProvider;
let subject: FileContentProviderWith404AsEmpty;
const createSubject = () => new FileContentProviderWith404AsEmpty(base);
beforeEach(() => {
base = {
getContent: jest.fn().mockResolvedValue(TEST_CONTENT),
};
subject = createSubject();
});
it('when base resolves, passes through', async () => {
await expect(subject.getContent(TEST_PATH)).resolves.toBe(TEST_CONTENT);
expect(base.getContent).toHaveBeenCalledWith(TEST_PATH);
});
it('when base errors with 404, returns empty', async () => {
jest.mocked(base.getContent).mockRejectedValue(new Error('something 404 something'));
await expect(subject.getContent(TEST_PATH)).resolves.toEqual(new Uint8Array(0));
});
it('when base errors catastrophically, passes throw', async () => {
const error = new Error('Something really bad');
jest.mocked(base.getContent).mockRejectedValue(error);
await expect(subject.getContent(TEST_PATH)).rejects.toBe(error);
});
});
import type { IFileContentProvider } from '@gitlab/web-ide-fs';
/**
* Decorator for IFileContentProvider that returns 404 responses as empty
*/
export class FileContentProviderWith404AsEmpty implements IFileContentProvider {
private readonly _base: IFileContentProvider;
constructor(base: IFileContentProvider) {
this._base = base;
}
async getContent(path: string): Promise<Uint8Array> {
try {
return this._base.getContent(path);
} catch (e: unknown) {
if (e && e instanceof Error && e.message.match('404')) {
return new Uint8Array(0);
}
throw e;
}
}
}
import type { IFileContentProvider } from '@gitlab/web-ide-fs';
import { FileContentProviderWithRepoRoot } from './FileContentProviderWithRepoRoot';
const TEST_CONTENT = Buffer.from('Hello world!');
const TEST_REPO_ROOT = '/gitlab-ui';
describe('utils/fs/FileContentProviderWithRepoRoot', () => {
let base: IFileContentProvider;
let subject: FileContentProviderWithRepoRoot;
const createSubject = () => new FileContentProviderWithRepoRoot(base, TEST_REPO_ROOT);
beforeEach(() => {
base = {
getContent: jest.fn().mockResolvedValue(TEST_CONTENT),
};
subject = createSubject();
});
it('strips repo root from path and passes through', async () => {
await expect(subject.getContent(`${TEST_REPO_ROOT}/foo/bar.js`)).resolves.toBe(TEST_CONTENT);
expect(base.getContent).toHaveBeenCalledWith('foo/bar.js');
});
});
import type { IFileContentProvider } from '@gitlab/web-ide-fs';
import { stripPathRoot } from '../stripPathRoot';
/**
* Decorator for IFileContentProvider that strips repo root from calls to getContent
*/
export class FileContentProviderWithRepoRoot implements IFileContentProvider {
private readonly _base: IFileContentProvider;
private readonly _repoRoot: string;
constructor(base: IFileContentProvider, repoRoot: string) {
this._base = base;
this._repoRoot = repoRoot;
}
getContent(path: string): Promise<Uint8Array> {
return this._base.getContent(stripPathRoot(path, this._repoRoot));
}
}
export * from './FileContentProviderWith404AsEmpty';
export * from './FileContentProviderWithRepoRoot';
import { stripPathRoot } from './stripPathRoot';
describe('utils/stripPathRoot', () => {
it.each`
path | root | expectation
${''} | ${''} | ${''}
${'/gitlab-ui/src/foo.js'} | ${'gitlab-ui'} | ${'src/foo.js'}
${'/gitlab-ui/src/foo.js'} | ${'/gitlab-ui'} | ${'src/foo.js'}
${'/gitlab-ui/src/foo.js'} | ${'/gitlab-ui/'} | ${'src/foo.js'}
${'gitlab-ui/src/foo.js'} | ${'/gitlab-ui/'} | ${'src/foo.js'}
${'/root/gitlab-ui/src/foo.js'} | ${'/root/gitlab-ui/'} | ${'src/foo.js'}
${'/gitlab-ui/src/foo.js'} | ${'/bar/'} | ${'/gitlab-ui/src/foo.js'}
`('with (path=$path, root=$root), returns $result', ({ path, root, expectation }) => {
const actual = stripPathRoot(path, root);
expect(actual).toBe(expectation);
});
});
import { cleanLeadingSeparator, joinPaths } from '@gitlab/utils-path';
export const stripPathRoot = (path: string, root: string) => {
const cleanPath = cleanLeadingSeparator(path);
const cleanRoot = joinPaths(cleanLeadingSeparator(root), '/');
if (cleanPath.startsWith(cleanRoot)) {
return cleanPath.substring(cleanRoot.length);
}
return path;
};
import * as vscode from 'vscode';
import { IFileContentProvider } from '@gitlab/web-ide-fs';
import { GitLabReadonlyFileSystemProvider } from './GitLabReadonlyFileSystemProvider';
const TEST_CONTENT = Buffer.from('Hello world!');
describe('vscode/GitLabReadonlyFileSystemProvider', () => {
let contentProvider: IFileContentProvider;
let subject: GitLabReadonlyFileSystemProvider;
beforeEach(() => {
contentProvider = {
getContent: jest.fn().mockResolvedValue(TEST_CONTENT),
};
subject = new GitLabReadonlyFileSystemProvider(contentProvider);
});
describe('stat', () => {
it('returns empty stat', () => {
expect(subject.stat()).toEqual({
ctime: 0,
mtime: 0,
size: -1,
type: vscode.FileType.File,
});
});
});
describe('readFile', () => {
it('calls underlying content provider', async () => {
await expect(
subject.readFile(vscode.Uri.parse('file:///test/foo/README.md')),
).resolves.toEqual(TEST_CONTENT);
expect(contentProvider.getContent).toHaveBeenCalledWith('/test/foo/README.md');
});
});
});
/* eslint-disable class-methods-use-this */
import { IFileContentProvider } from '@gitlab/web-ide-fs';
import * as vscode from 'vscode';
const noopDisposable = {
dispose() {
// noop
},
};
/**
* An adapter for our IFileContentProvider to vscode.FileSystemProvider
*/
export class GitLabReadonlyFileSystemProvider implements vscode.FileSystemProvider {
private readonly _fileContentProvider: IFileContentProvider;
constructor(fileContentProvider: IFileContentProvider) {
this._fileContentProvider = fileContentProvider;
}
readFile(uri: vscode.Uri): Uint8Array | Thenable<Uint8Array> {
return this._fileContentProvider.getContent(uri.path);
}
stat(): vscode.FileStat {
return {
ctime: 0,
mtime: 0,
size: -1,
type: vscode.FileType.File,
};
}
get onDidChangeFile() {
return () => noopDisposable;
}
watch(): vscode.Disposable {
return noopDisposable;
}
// region: Unsupported methods - These methods are needed by the interface, but
// not actually supported by the file system, since this file system
// is only needed to provide content for the Source Contrl modules
readDirectory(): [string, vscode.FileType][] | Thenable<[string, vscode.FileType][]> {
throw new Error('Method not implemented.');
}
createDirectory(): void | Thenable<void> {
throw new Error('Method not implemented.');
}
writeFile(): void | Thenable<void> {
throw new Error('Method not implemented.');
}
delete(): void | Thenable<void> {
throw new Error('Method not implemented.');
}
rename(): void | Thenable<void> {
throw new Error('Method not implemented.');
}
}
......@@ -27,6 +27,12 @@
"category": "GitLab Web IDE",
"icon": "$(check)"
},
{
"command": "gitlab-web-ide.compare-with-mr-base",
"title": "Compare with merge request base",
"category": "GitLab Web IDE",
"icon": "$(git-pull-request)"
},
{
"command": "gitlab-web-ide.start-remote",
"title": "Configure a remote connection",
......@@ -54,6 +60,20 @@
}
],
"menus": {
"explorer/context": [
{
"command": "gitlab-web-ide.compare-with-mr-base",
"when": "!explorerResourceIsFolder && resourcePath in gitlab-web-ide.mergeRequestFilePaths",
"group": "3_compare"
}
],
"editor/title": [
{
"command": "gitlab-web-ide.compare-with-mr-base",
"when": "!isInDiffEditor && resourcePath in gitlab-web-ide.mergeRequestFilePaths",
"group": "navigation"
}
],
"scm/title": [
{
"command": "gitlab-web-ide.commit",
......@@ -61,6 +81,11 @@
}
],
"commandPalette": [
{
"command": "gitlab-web-ide.compare-with-mr-base",
"group": "navigation",
"when": "resourcePath in gitlab-web-ide.mergeRequestFilePaths"
},
{
"command": "gitlab-web-ide.go-to-project",
"when": "gitlab-web-ide.is-ready"
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment