Browse Source

Adds live share support

Keeps all path manipulation on the host rather than guest
Fixes shared/local path munging for mac/windows
Keeps current user lookup local when a guest
Keeps difftool lookup local when a guest
Avoids fetch date lookup when a guest
Stops clearing node_modules (for now)
main
Eric Amodio 6 years ago
parent
commit
9ccbae3521
18 changed files with 709 additions and 70 deletions
  1. +12
    -11
      README.md
  2. +5
    -0
      package-lock.json
  3. +9
    -2
      package.json
  4. +1
    -0
      src/codelens/codeLensProvider.ts
  5. +2
    -1
      src/constants.ts
  6. +8
    -0
      src/container.ts
  7. +23
    -7
      src/git/git.ts
  8. +80
    -44
      src/git/gitService.ts
  9. +10
    -1
      src/git/gitUri.ts
  10. +1
    -1
      src/git/models/repository.ts
  11. +1
    -1
      src/git/shell.ts
  12. +2
    -1
      src/trackers/documentTracker.ts
  13. +3
    -0
      src/ui/config.ts
  14. +106
    -0
      src/vsls/guest.ts
  15. +276
    -0
      src/vsls/host.ts
  16. +54
    -0
      src/vsls/protocol.ts
  17. +112
    -0
      src/vsls/vsls.ts
  18. +4
    -1
      webpack.config.js

+ 12
- 11
README.md View File

@ -647,17 +647,18 @@ GitLens is highly customizable and provides many configuration settings to allow
### General Settings [#](#general-settings 'General Settings')
| Name | Description |
| ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `gitlens.defaultDateFormat` | Specifies how absolute dates will be formatted by default. See the [Moment.js docs](https://momentjs.com/docs/#/displaying/format/) for valid formats |
| `gitlens.defaultDateStyle` | Specifies how dates will be displayed by default |
| `gitlens.defaultGravatarsStyle` | Specifies the style of the gravatar default (fallback) images<br /><br />`identicon` - a geometric pattern<br />`mm` - a simple, cartoon-style silhouetted outline of a person (does not vary by email hash)<br />`monsterid` - a monster with different colors, faces, etc<br />`retro` - 8-bit arcade-style pixelated faces<br />`robohash` - a robot with different colors, faces, etc<br />`wavatar` - a face with differing features and backgrounds |
| `gitlens.insiders` | Specifies whether to enable experimental features |
| `gitlens.keymap` | Specifies the keymap to use for GitLens shortcut keys<br /><br />`alternate` - adds an alternate set of shortcut keys that start with `Alt` (&#x2325; on macOS)<br />`chorded` - adds a chorded set of shortcut keys that start with `Ctrl+Shift+G` (<code>&#x2325;&#x2318;G</code> on macOS)<br />`none` - no shortcut keys will be added |
| `gitlens.menus` | Specifies which commands will be added to which menus |
| `gitlens.outputLevel` | Specifies how much (if any) output will be sent to the GitLens output channel |
| `gitlens.settings.mode` | Specifies the display mode of the interactive settings editor<br /><br />`simple` - only displays common settings<br />`advanced` - displays all settings |
| `gitlens.showWhatsNewAfterUpgrades` | Specifies whether to show What's New after upgrading to new feature releases |
| Name | Description |
| ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `gitlens.defaultDateFormat` | Specifies how absolute dates will be formatted by default. See the [Moment.js docs](https://momentjs.com/docs/#/displaying/format/) for valid formats |
| `gitlens.defaultDateStyle` | Specifies how dates will be displayed by default |
| `gitlens.defaultGravatarsStyle` | Specifies the style of the gravatar default (fallback) images<br /><br />`identicon` - a geometric pattern<br />`mm` - a simple, cartoon-style silhouetted outline of a person (does not vary by email hash)<br />`monsterid` - a monster with different colors, faces, etc<br />`retro` - 8-bit arcade-style pixelated faces<br />`robohash` - a robot with different colors, faces, etc<br />`wavatar` - a face with differing features and backgrounds |
| `gitlens.insiders` | Specifies whether to enable experimental features |
| `gitlens.keymap` | Specifies the keymap to use for GitLens shortcut keys<br /><br />`alternate` - adds an alternate set of shortcut keys that start with `Alt` (&#x2325; on macOS)<br />`chorded` - adds a chorded set of shortcut keys that start with `Ctrl+Shift+G` (<code>&#x2325;&#x2318;G</code> on macOS)<br />`none` - no shortcut keys will be added |
| `gitlens.liveshare.allowGuestAccess` | Specifies whether to allow guest access to GitLens features when using Visual Studio Live Share |
| `gitlens.menus` | Specifies which commands will be added to which menus |
| `gitlens.outputLevel` | Specifies how much (if any) output will be sent to the GitLens output channel |
| `gitlens.settings.mode` | Specifies the display mode of the interactive settings editor<br /><br />`simple` - only displays common settings<br />`advanced` - displays all settings |
| `gitlens.showWhatsNewAfterUpgrades` | Specifies whether to show What's New after upgrading to new feature releases |
### Repositories View Settings [#](#repositories-view-settings 'Repositories View Settings')

+ 5
- 0
package-lock.json View File

@ -11022,6 +11022,11 @@
"vinyl-source-stream": "^1.1.0"
}
},
"vsls": {
"version": "0.3.967",
"resolved": "https://registry.npmjs.org/vsls/-/vsls-0.3.967.tgz",
"integrity": "sha512-FFaRZz4RBo/QmUHvQophkzMzrTrsV8g169jUPEaL7UWak3FdwGdGvm2DlSZIZl36MzLNb/43BPq6WDzoKDwR4g=="
},
"vso-node-api": {
"version": "6.1.2-preview",
"resolved": "https://registry.npmjs.org/vso-node-api/-/vso-node-api-6.1.2-preview.tgz",

+ 9
- 2
package.json View File

@ -573,6 +573,12 @@
"markdownDescription": "Specifies the keymap to use for GitLens shortcut keys",
"scope": "window"
},
"gitlens.liveshare.allowGuestAccess": {
"type": "boolean",
"default": true,
"description": "Specifies whether to allow guest access to GitLens features when using Visual Studio Live Share",
"scope": "window"
},
"gitlens.menus": {
"anyOf": [
{
@ -4382,7 +4388,7 @@
"scripts": {
"build": "webpack --env.development",
"bundle": "webpack --env.production",
"clean": "git clean -Xdf -e !.cache-images",
"clean": "git clean -Xdf -e !.cache-images -e !node_modules -e !node_modules/**/*",
"lint": "tslint --project tsconfig.json && tslint --project ui.tsconfig.json",
"pack": "vsce package",
"pretty": "prettier --config .prettierrc --loglevel warn --write \"./**/*.{ts,md,json}\" && tslint --project tsconfig.json --fix && tslint --project ui.tsconfig.json --fix",
@ -4401,7 +4407,8 @@
"date-fns": "1.29.0",
"iconv-lite": "0.4.24",
"lodash-es": "4.17.11",
"tslib": "1.9.3"
"tslib": "1.9.3",
"vsls": "0.3.967"
},
"devDependencies": {
"@types/clipboardy": "1.1.0",

+ 1
- 0
src/codelens/codeLensProvider.ts View File

@ -86,6 +86,7 @@ export class GitCodeLensProvider implements CodeLensProvider {
static selector: DocumentSelector = [
{ scheme: DocumentSchemes.File },
{ scheme: DocumentSchemes.Vsls },
{ scheme: DocumentSchemes.Git },
{ scheme: DocumentSchemes.GitLens }
];

+ 2
- 1
src/constants.ts View File

@ -52,7 +52,8 @@ export enum DocumentSchemes {
File = 'file',
Git = 'git',
GitLens = 'gitlens',
Output = 'output'
Output = 'output',
Vsls = 'vsls'
}
export function getEditorIfActive(document: TextDocument): TextEditor | undefined {

+ 8
- 0
src/container.ts View File

@ -17,6 +17,7 @@ import { LineHistoryView } from './views/lineHistoryView';
import { RepositoriesView } from './views/repositoriesView';
import { SearchView } from './views/searchView';
import { ViewCommands } from './views/viewCommands';
import { VslsController } from './vsls/vsls';
import { SettingsEditor } from './webviews/settingsEditor';
import { WelcomeEditor } from './webviews/welcomeEditor';
@ -27,6 +28,8 @@ export class Container {
context.subscriptions.push((this._lineTracker = new GitLineTracker()));
context.subscriptions.push((this._tracker = new GitDocumentTracker()));
context.subscriptions.push((this._vsls = new VslsController()));
context.subscriptions.push((this._git = new GitService()));
// Since there is a bit of a chicken & egg problem with the DocumentTracker and the GitService, initialize the tracker once the GitService is loaded
@ -221,6 +224,11 @@ export class Container {
return this._viewCommands;
}
private static _vsls: VslsController;
static get vsls() {
return this._vsls;
}
private static _welcomeEditor: WelcomeEditor;
static get welcomeEditor() {
return this._welcomeEditor;

+ 23
- 7
src/git/git.ts View File

@ -2,6 +2,7 @@
import * as iconv from 'iconv-lite';
import * as paths from 'path';
import { GlyphChars } from '../constants';
import { Container } from '../container';
import { Logger } from '../logger';
import { Objects, Strings } from '../system';
import { findGitPath, GitLocation } from './locator';
@ -76,10 +77,12 @@ export enum GitErrorHandling {
Throw = 'throw'
}
interface GitCommandOptions extends RunOptions {
export interface GitCommandOptions extends RunOptions {
configs?: string[];
readonly correlationKey?: string;
errors?: GitErrorHandling;
// Specifies that this command should always be executed locally if possible
local?: boolean;
}
// A map of running git commands -- avoids running duplicate overlaping commands
@ -88,7 +91,20 @@ const pendingCommands: Map> = new Map();
const emptyArray: any = [];
const emptyObj = {};
async function git<TOut extends string | Buffer>(options: GitCommandOptions, ...args: any[]): Promise<TOut> {
export async function git<TOut extends string | Buffer>(options: GitCommandOptions, ...args: any[]): Promise<TOut> {
if (Container.vsls.isMaybeGuest) {
if (options.local !== true) {
const guest = await Container.vsls.guest();
if (guest !== undefined) {
return guest.git<TOut>(options, ...args);
}
}
else {
// Since we will have a live share path here, just blank it out
options.cwd = '';
}
}
const start = process.hrtime();
const { configs, correlationKey, errors: errorHandling, ...opts } = options;
@ -426,7 +442,7 @@ export class Git {
}
static check_mailmap(repoPath: string, author: string) {
return git<string>({ cwd: repoPath }, 'check-mailmap', author);
return git<string>({ cwd: repoPath, local: true }, 'check-mailmap', author);
}
static checkout(repoPath: string, ref: string, fileName?: string) {
@ -440,9 +456,9 @@ export class Git {
return git<string>({ cwd: repoPath }, ...params);
}
static async config_get(key: string, repoPath?: string) {
static async config_get(key: string, repoPath?: string, options: { local?: boolean } = {}) {
const data = await git<string>(
{ cwd: repoPath || '', errors: GitErrorHandling.Ignore },
{ cwd: repoPath || '', errors: GitErrorHandling.Ignore, local: options.local },
'config',
'--get',
key
@ -450,9 +466,9 @@ export class Git {
return data === '' ? undefined : data.trim();
}
static async config_getRegex(pattern: string, repoPath?: string) {
static async config_getRegex(pattern: string, repoPath?: string, options: { local?: boolean } = {}) {
const data = await git<string>(
{ cwd: repoPath || '', errors: GitErrorHandling.Ignore },
{ cwd: repoPath || '', errors: GitErrorHandling.Ignore, local: options.local },
'config',
'--get-regex',
pattern

+ 80
- 44
src/git/gitService.ts View File

@ -26,6 +26,7 @@ import { LogCorrelationContext, Logger } from '../logger';
import { Messages } from '../messages';
import { gate, Iterables, log, Objects, Strings, TernarySearchTree, Versions } from '../system';
import { CachedBlame, CachedDiff, CachedLog, GitDocumentState, TrackedDocument } from '../trackers/gitDocumentTracker';
import { vslsUriPrefixRegex } from '../vsls/vsls';
import {
CommitFormatting,
Git,
@ -178,19 +179,36 @@ export class GitService implements Disposable {
}
for (const f of e.added) {
if (f.uri.scheme !== DocumentSchemes.File) continue;
// Search for and add all repositories (nested and/or submodules)
const repositories = await this.repositorySearch(f);
for (const r of repositories) {
this._repositoryTree.set(r.path, r);
const { scheme } = f.uri;
if (scheme !== DocumentSchemes.File && scheme !== DocumentSchemes.Vsls) continue;
if (scheme === DocumentSchemes.Vsls) {
if (Container.vsls.isMaybeGuest) {
const guest = await Container.vsls.guest();
if (guest !== undefined) {
const repositories = await guest.getRepositoriesInFolder(
f,
this.onAnyRepositoryChanged.bind(this)
);
for (const r of repositories) {
this._repositoryTree.set(r.path, r);
}
}
}
}
else {
// Search for and add all repositories (nested and/or submodules)
const repositories = await this.repositorySearch(f);
for (const r of repositories) {
this._repositoryTree.set(r.path, r);
}
}
}
for (const f of e.removed) {
if (f.uri.scheme !== DocumentSchemes.File) continue;
const { fsPath, scheme } = f.uri;
if (scheme !== DocumentSchemes.File && scheme !== DocumentSchemes.Vsls) continue;
const fsPath = f.uri.fsPath;
const repos = this._repositoryTree.findSuperstr(fsPath);
const reposToDelete =
repos !== undefined
@ -230,21 +248,17 @@ export class GitService implements Disposable {
}
private async repositorySearch(folder: WorkspaceFolder): Promise<Repository[]> {
const folderUri = folder.uri;
const { uri } = folder;
const depth = configuration.get<number>(configuration.name('advanced')('repositorySearchDepth').value, uri);
const depth = configuration.get<number>(
configuration.name('advanced')('repositorySearchDepth').value,
folderUri
);
Logger.log(`Searching for repositories (depth=${depth}) in '${folderUri.fsPath}' ...`);
Logger.log(`Searching for repositories (depth=${depth}) in '${uri.fsPath}' ...`);
const start = process.hrtime();
const repositories: Repository[] = [];
const anyRepoChangedFn = this.onAnyRepositoryChanged.bind(this);
const rootPath = await this.getRepoPathCore(folderUri.fsPath, true);
const rootPath = await this.getRepoPathCore(uri.fsPath, true);
if (rootPath !== undefined) {
Logger.log(`Repository found in '${rootPath}'`);
repositories.push(new Repository(folder, rootPath, true, anyRepoChangedFn, this._suspended));
@ -252,7 +266,7 @@ export class GitService implements Disposable {
if (depth <= 0) {
Logger.log(
`Completed repository search (depth=${depth}) in '${folderUri.fsPath}' ${
`Completed repository search (depth=${depth}) in '${uri.fsPath}' ${
GlyphChars.Dot
} ${Strings.getDurationMilliseconds(start)} ms`
);
@ -262,8 +276,8 @@ export class GitService implements Disposable {
// Get any specified excludes -- this is a total hack, but works for some simple cases and something is better than nothing :)
let excludes = {
...workspace.getConfiguration('files', folderUri).get<{ [key: string]: boolean }>('exclude', {}),
...workspace.getConfiguration('search', folderUri).get<{ [key: string]: boolean }>('exclude', {})
...workspace.getConfiguration('files', uri).get<{ [key: string]: boolean }>('exclude', {}),
...workspace.getConfiguration('search', uri).get<{ [key: string]: boolean }>('exclude', {})
};
const excludedPaths = [
@ -284,18 +298,16 @@ export class GitService implements Disposable {
let repoPaths;
try {
repoPaths = await this.repositorySearchCore(folderUri.fsPath, depth, excludes);
repoPaths = await this.repositorySearchCore(uri.fsPath, depth, excludes);
}
catch (ex) {
if (RepoSearchWarnings.doesNotExist.test(ex.message || '')) {
Logger.log(
`Repository search (depth=${depth}) in '${folderUri.fsPath}' FAILED${
ex.message ? `(${ex.message})` : ''
}`
`Repository search (depth=${depth}) in '${uri.fsPath}' FAILED${ex.message ? `(${ex.message})` : ''}`
);
}
else {
Logger.error(ex, `Repository search (depth=${depth}) in '${folderUri.fsPath}' FAILED`);
Logger.error(ex, `Repository search (depth=${depth}) in '${uri.fsPath}' FAILED`);
}
return repositories;
@ -314,7 +326,7 @@ export class GitService implements Disposable {
}
Logger.log(
`Completed repository search (depth=${depth}) in '${folderUri.fsPath}' ${
`Completed repository search (depth=${depth}) in '${uri.fsPath}' ${
GlyphChars.Dot
} ${Strings.getDurationMilliseconds(start)} ms`
);
@ -555,11 +567,18 @@ export class GitService implements Disposable {
);
}
private async fileExists(
async fileExists(
repoPath: string,
fileName: string,
options: { ensureCase: boolean } = { ensureCase: false }
): Promise<boolean> {
if (Container.vsls.isMaybeGuest) {
const guest = await Container.vsls.guest();
if (guest !== undefined) {
return guest.fileExists(repoPath, fileName, options);
}
}
const path = paths.resolve(repoPath, fileName);
const exists = await new Promise<boolean>((resolve, reject) => fs.exists(path, resolve));
if (!options.ensureCase || !exists) return exists;
@ -1075,7 +1094,7 @@ export class GitService implements Disposable {
// If we found the repo, but no user data was found just return
if (user === null) return undefined;
const data = await Git.config_getRegex('user.(name|email)', repoPath);
const data = await Git.config_getRegex('user.(name|email)', repoPath, { local: true });
if (!data) {
// If we found no user data, mark it so we won't bother trying again
this._userMapCache.set(repoPath, null);
@ -1615,7 +1634,7 @@ export class GitService implements Disposable {
let repo = await this.getRepository(filePathOrUri, { ...options, skipCacheUpdate: true });
if (repo !== undefined) return repo.path;
const rp = await this.getRepoPathCore(
let rp = await this.getRepoPathCore(
typeof filePathOrUri === 'string' ? filePathOrUri : filePathOrUri.fsPath,
false
);
@ -1625,12 +1644,19 @@ export class GitService implements Disposable {
if (this._repositoryTree.get(rp) !== undefined) return rp;
// If this new repo is inside one of our known roots and we we don't already know about, add it
const root = this._repositoryTree.findSubstr(rp);
let folder = root === undefined ? workspace.getWorkspaceFolder(GitUri.file(rp)) : root.folder;
const root = this.findRepositoryForPath(this._repositoryTree, rp);
if (folder === undefined) {
const parts = rp.split('/');
folder = { uri: GitUri.file(rp), name: parts[parts.length - 1], index: this._repositoryTree.count() };
let folder;
if (root !== undefined) {
rp = root.path;
folder = root.folder;
}
else {
folder = workspace.getWorkspaceFolder(GitUri.file(rp));
if (folder === undefined) {
const parts = rp.split('/');
folder = { uri: GitUri.file(rp), name: parts[parts.length - 1], index: this._repositoryTree.count() };
}
}
Logger.log(cc, `Repository found in '${rp}'`);
@ -1730,7 +1756,7 @@ export class GitService implements Disposable {
}
}
const repo = repositoryTree.findSubstr(path);
const repo = this.findRepositoryForPath(repositoryTree, path);
if (repo === undefined) return undefined;
// Make sure the file is tracked in this repo before returning -- it could be from a submodule
@ -1738,6 +1764,18 @@ export class GitService implements Disposable {
return repo;
}
private findRepositoryForPath(repositoryTree: TernarySearchTree<Repository>, path: string): Repository | undefined {
let repo = repositoryTree.findSubstr(path);
// If we can't find the repo and we are a guest, check if we are a "root" workspace
if (repo === undefined && Container.vsls.isMaybeGuest) {
if (!vslsUriPrefixRegex.test(path)) {
const vslsPath = Strings.normalizePath(`/~0${path}`);
repo = repositoryTree.findSubstr(vslsPath);
}
}
return repo;
}
async getRepositoryCount(): Promise<number> {
const repositoryTree = await this.getRepositoryTree();
return repositoryTree.count();
@ -1834,15 +1872,13 @@ export class GitService implements Disposable {
isTrackable(scheme: string): boolean;
isTrackable(uri: Uri): boolean;
isTrackable(schemeOruri: string | Uri): boolean {
let scheme: string;
if (typeof schemeOruri === 'string') {
scheme = schemeOruri;
}
else {
scheme = schemeOruri.scheme;
}
return scheme === DocumentSchemes.File || scheme === DocumentSchemes.Git || scheme === DocumentSchemes.GitLens;
const scheme = typeof schemeOruri === 'string' ? schemeOruri : schemeOruri.scheme;
return (
scheme === DocumentSchemes.File ||
scheme === DocumentSchemes.Vsls ||
scheme === DocumentSchemes.Git ||
scheme === DocumentSchemes.GitLens
);
}
async isTracked(
@ -1930,7 +1966,7 @@ export class GitService implements Disposable {
@log()
async getDiffTool(repoPath?: string) {
return (await Git.config_get('diff.guitool', repoPath)) || (await Git.config_get('diff.tool', repoPath));
return (await Git.config_get('diff.guitool', repoPath, { local: true })) || (await Git.config_get('diff.tool', repoPath, { local: true }));
}
@log()

+ 10
- 1
src/git/gitUri.ts View File

@ -54,6 +54,10 @@ export class GitUri extends ((Uri as any) as UriEx) {
data.path = Strings.normalizePath(
`/${data.repoPath}/${uri.path.replace(stripRepoRevisionFromPathRegex, '$1')}`
);
// Make sure we aren't starting with //
if (data.path[1] === '/') {
data.path = data.path.substr(1);
}
super({
scheme: uri.scheme,
@ -192,7 +196,12 @@ export class GitUri extends ((Uri as any) as UriEx) {
}
static file(path: string) {
return Uri.file(path);
const uri = Uri.file(path);
if (Container.vsls.isMaybeGuest) {
return uri.with({ scheme: DocumentSchemes.Vsls });
}
return uri;
}
static fromCommit(commit: GitCommit, previous: boolean = false) {

+ 1
- 1
src/git/models/repository.ts View File

@ -261,7 +261,7 @@ export class Repository implements Disposable {
async getLastFetched(): Promise<number> {
const hasRemotes = await this.hasRemotes();
if (!hasRemotes) return 0;
if (!hasRemotes || Container.vsls.isMaybeGuest) return 0;
return new Promise<number>((resolve, reject) =>
fs.stat(paths.join(this.path, '.git/FETCH_HEAD'), (err, stat) => resolve(err ? 0 : stat.mtime.getTime()))

+ 1
- 1
src/git/shell.ts View File

@ -102,7 +102,7 @@ export class RunError extends Error {
}
export interface RunOptions {
readonly cwd?: string;
cwd?: string;
readonly env?: Object;
readonly encoding?: BufferEncoding | 'buffer';
/**

+ 2
- 1
src/trackers/documentTracker.ts View File

@ -127,7 +127,8 @@ export class DocumentTracker implements Disposable {
}
private onTextDocumentChanged(e: TextDocumentChangeEvent) {
if (e.document.uri.scheme !== DocumentSchemes.File) return;
const { scheme } = e.document.uri;
if (scheme !== DocumentSchemes.File && scheme !== DocumentSchemes.Vsls) return;
let doc = this._documentMap.get(e.document);
if (doc === undefined) {

+ 3
- 0
src/ui/config.ts View File

@ -53,6 +53,9 @@ export interface Config {
};
insiders: boolean;
keymap: KeyMap;
liveshare: {
allowGuestAccess: boolean;
};
menus: boolean | MenuConfig;
mode: {
active: string;

+ 106
- 0
src/vsls/guest.ts View File

@ -0,0 +1,106 @@
'use strict';
import { CancellationToken, Disposable, window, WorkspaceFolder } from 'vscode';
import { LiveShare, SharedServiceProxy } from 'vsls';
import { CommandContext, setCommandContext } from '../constants';
import { GitCommandOptions, Repository, RepositoryChange } from '../git/git';
import { Logger } from '../logger';
import { debug, log } from '../system';
import { VslsHostService } from './host';
import {
GitCommandRequestType,
RepositoriesInFolderRequestType,
RepositoryProxy,
RequestType,
WorkspaceFileExistsRequestType
} from './protocol';
export class VslsGuestService implements Disposable {
@log()
static async connect(api: LiveShare) {
const cc = Logger.getCorrelationContext();
try {
const service = await api.getSharedService(VslsHostService.ServiceId);
if (service == null) {
throw new Error('Failed to connect to host service');
}
return new VslsGuestService(api, service);
}
catch (ex) {
Logger.error(ex, cc);
return undefined;
}
}
constructor(
private readonly _api: LiveShare,
private readonly _service: SharedServiceProxy
) {
_service.onDidChangeIsServiceAvailable(this.onAvailabilityChanged.bind(this));
this.onAvailabilityChanged(_service.isServiceAvailable);
}
dispose() {}
@log()
private async onAvailabilityChanged(available: boolean) {
if (available) {
setCommandContext(CommandContext.Enabled, true);
return;
}
setCommandContext(CommandContext.Enabled, false);
void window.showWarningMessage(
`GitLens features will be unavailable. Unable to connect to the host GitLens service. The host may have disabled GitLens guest access or may not have GitLens installed.`
);
}
@log()
async git<TOut extends string | Buffer>(options: GitCommandOptions, ...args: any[]) {
const response = await this.sendRequest(GitCommandRequestType, { options: options, args: args });
if (response.isBuffer) {
return new Buffer(response.data, 'binary') as TOut;
}
return response.data as TOut;
}
@log()
async getRepositoriesInFolder(
folder: WorkspaceFolder,
onAnyRepositoryChanged: (repo: Repository, reason: RepositoryChange) => void
): Promise<Repository[]> {
const response = await this.sendRequest(RepositoriesInFolderRequestType, {
folderUri: folder.uri.toString(true)
});
return response.repositories.map(
(r: RepositoryProxy) => new Repository(folder, r.path, r.root, onAnyRepositoryChanged, false, r.closed)
);
}
@log()
async fileExists(
repoPath: string,
fileName: string,
options: { ensureCase: boolean } = { ensureCase: false }
): Promise<boolean> {
const response = await this.sendRequest(WorkspaceFileExistsRequestType, {
fileName: fileName,
repoPath: repoPath,
options: options
});
return response.exists;
}
@debug()
private sendRequest<TRequest, TResponse>(
requestType: RequestType<TRequest, TResponse>,
request: TRequest,
cancellation?: CancellationToken
): Promise<TResponse> {
return this._service.request(requestType.name, [request]);
}
}

+ 276
- 0
src/vsls/host.ts View File

@ -0,0 +1,276 @@
'use strict';
import { CancellationToken, Disposable, Uri, workspace, WorkspaceFoldersChangeEvent } from 'vscode';
import { LiveShare, SharedService } from 'vsls';
import { Container } from '../container';
import { git } from '../git/git';
import { GitUri } from '../git/gitUri';
import { Logger } from '../logger';
import { debug, Iterables, log, Strings } from '../system';
import {
GitCommandRequest,
GitCommandRequestType,
GitCommandResponse,
RepositoriesInFolderRequest,
RepositoriesInFolderRequestType,
RepositoriesInFolderResponse,
RequestType,
WorkspaceFileExistsRequest,
WorkspaceFileExistsRequestType,
WorkspaceFileExistsResponse
} from './protocol';
import { vslsUriRootRegex } from './vsls';
const leadingSlashRegex = /^[\/|\\]/;
export class VslsHostService implements Disposable {
static ServiceId = 'proxy';
@log()
static async share(api: LiveShare) {
const service = await api.shareService(this.ServiceId);
if (service == null) {
throw new Error('Failed to share host service');
}
return new VslsHostService(api, service);
}
private readonly _disposable: Disposable;
private _localPathsRegex: RegExp | undefined;
private _localToSharedPaths = new Map<string, string>();
private _sharedPathsRegex: RegExp | undefined;
private _sharedToLocalPaths = new Map<string, string>();
constructor(
private readonly _api: LiveShare,
private readonly _service: SharedService
) {
_service.onDidChangeIsServiceAvailable(this.onAvailabilityChanged.bind(this));
this._disposable = Disposable.from(workspace.onDidChangeWorkspaceFolders(this.onWorkspaceFoldersChanged, this));
this.onRequest(GitCommandRequestType, this.onGitCommandRequest.bind(this));
this.onRequest(RepositoriesInFolderRequestType, this.onRepositoriesInFolderRequest.bind(this));
this.onRequest(WorkspaceFileExistsRequestType, this.onWorkspaceFileExistsRequest.bind(this));
this.onWorkspaceFoldersChanged();
}
dispose() {
this._disposable.dispose();
void this._api.unshareService(VslsHostService.ServiceId);
}
private onRequest<TRequest, TResponse>(
requestType: RequestType<TRequest, TResponse>,
handler: (request: TRequest, cancellation: CancellationToken) => Promise<TResponse>
) {
this._service.onRequest(requestType.name, (args: any[], cancellation: CancellationToken) =>
handler(args[0], cancellation)
);
}
@log()
private onAvailabilityChanged(available: boolean) {
// TODO
}
@debug()
private async onWorkspaceFoldersChanged(e?: WorkspaceFoldersChangeEvent) {
if (workspace.workspaceFolders === undefined || workspace.workspaceFolders.length === 0) return;
const cc = Logger.getCorrelationContext();
this._localToSharedPaths.clear();
this._sharedToLocalPaths.clear();
let localPath;
let sharedPath;
for (const f of workspace.workspaceFolders) {
localPath = Strings.normalizePath(f.uri.fsPath);
sharedPath = Strings.normalizePath(this.convertLocalUriToShared(f.uri).fsPath);
Logger.debug(cc, `shared='${sharedPath}' \u2194 local='${localPath}'`);
this._localToSharedPaths.set(localPath, sharedPath);
this._sharedToLocalPaths.set(sharedPath, localPath);
}
let localPaths = Iterables.join(this._sharedToLocalPaths.values(), '|');
localPaths = localPaths.replace(/(\/|\\)/g, '[\\\\/|\\\\]');
this._localPathsRegex = new RegExp(`(${localPaths})`, 'gi');
let sharedPaths = Iterables.join(this._localToSharedPaths.values(), '|');
sharedPaths = sharedPaths.replace(/(\/|\\)/g, '[\\\\/|\\\\]');
this._sharedPathsRegex = new RegExp(`^(${sharedPaths})`, 'i');
}
@log()
private async onGitCommandRequest(
request: GitCommandRequest,
cancellation: CancellationToken
): Promise<GitCommandResponse> {
const { options, args } = request;
let isRootWorkspace = false;
if (options.cwd !== undefined && options.cwd.length > 0 && this._sharedToLocalPaths !== undefined) {
// This is all so ugly, but basically we are converting shared paths to local paths
if (this._sharedPathsRegex !== undefined && this._sharedPathsRegex.test(options.cwd)) {
options.cwd = Strings.normalizePath(options.cwd).replace(this._sharedPathsRegex, (match, shared) => {
if (!isRootWorkspace) {
isRootWorkspace = shared === '/~0';
}
const local = this._sharedToLocalPaths.get(shared);
return local != null ? local : shared;
});
}
else if (leadingSlashRegex.test(options.cwd)) {
const localCwd = this._sharedToLocalPaths.get('/~0');
if (localCwd !== undefined) {
isRootWorkspace = true;
options.cwd = GitUri.resolve(options.cwd, localCwd);
}
}
}
let files = false;
let i = -1;
for (const arg of args) {
i++;
if (arg === '--') {
files = true;
continue;
}
if (!files) continue;
if (typeof arg === 'string') {
// If we are the "root" workspace, then we need to remove the leading slash off the path (otherwise it will not be treated as a relative path)
if (isRootWorkspace && leadingSlashRegex.test(arg[0])) {
args.splice(i, 1, arg.substr(1));
}
if (this._sharedPathsRegex !== undefined && this._sharedPathsRegex.test(arg)) {
args.splice(
i,
1,
Strings.normalizePath(arg).replace(this._sharedPathsRegex, (match, shared) => {
const local = this._sharedToLocalPaths.get(shared);
return local != null ? local : shared;
})
);
}
}
}
let data = await git(options, ...args);
if (typeof data === 'string') {
// And then we convert local paths to shared paths
if (this._localPathsRegex !== undefined && data.length > 0) {
data = data.replace(this._localPathsRegex, (match, local) => {
const shared = this._localToSharedPaths.get(local);
return shared != null ? shared : local;
});
}
return { data: data };
}
return { data: data.toString('binary'), isBuffer: true };
}
@log()
private async onRepositoriesInFolderRequest(
request: RepositoriesInFolderRequest,
cancellation: CancellationToken
): Promise<RepositoriesInFolderResponse> {
const uri = this.convertSharedUriToLocal(Uri.parse(request.folderUri));
const normalized = Strings.normalizePath(uri.fsPath, { stripTrailingSlash: true }).toLowerCase();
const repos = [
...Iterables.filterMap(await Container.git.getRepositories(), r => {
if (!r.normalizedPath.startsWith(normalized)) return undefined;
const vslsUri = this.convertLocalUriToShared(r.folder.uri);
return {
folderUri: vslsUri.toString(true),
path: vslsUri.path,
root: r.root,
closed: r.closed
};
})
];
return {
repositories: repos
};
}
@log()
private async onWorkspaceFileExistsRequest(
request: WorkspaceFileExistsRequest,
cancellation: CancellationToken
): Promise<WorkspaceFileExistsResponse> {
let { repoPath } = request;
if (this._sharedPathsRegex !== undefined && this._sharedPathsRegex.test(repoPath)) {
repoPath = Strings.normalizePath(repoPath).replace(this._sharedPathsRegex, (match, shared) => {
const local = this._sharedToLocalPaths!.get(shared);
return local != null ? local : shared;
});
}
// TODO: Lock this to be only in the contained workspaces
return { exists: await Container.git.fileExists(repoPath, request.fileName, request.options) };
}
@debug({
exit: result => `returned ${result.toString(true)}`
})
private convertLocalUriToShared(localUri: Uri) {
const cc = Logger.getCorrelationContext();
let sharedUri = this._api.convertLocalUriToShared(localUri);
Logger.debug(
cc,
`LiveShare.convertLocalUriToShared(${localUri.toString(true)}) returned ${sharedUri.toString(true)}`
);
const localPath = localUri.path;
let sharedPath = sharedUri.path;
if (sharedUri.authority.length > 0) {
sharedPath = `/${sharedUri.authority}${sharedPath}`;
}
if (new RegExp(`${localPath}$`, 'i').test(sharedPath)) {
if (sharedPath.length === localPath.length) {
const folder = workspace.getWorkspaceFolder(localUri)!;
sharedUri = sharedUri.with({ path: `/~${folder.index}` });
}
else {
sharedUri = sharedUri.with({ path: sharedPath.substr(0, sharedPath.length - localPath.length) });
}
}
else if (!sharedPath.startsWith('/~')) {
const folder = workspace.getWorkspaceFolder(localUri)!;
sharedUri = sharedUri.with({ path: `/~${folder.index}${sharedPath}` });
}
return sharedUri;
}
private convertSharedUriToLocal(sharedUri: Uri) {
if (vslsUriRootRegex.test(sharedUri.path)) {
sharedUri = sharedUri.with({ path: `${sharedUri.path}/` });
}
const localUri = this._api.convertSharedUriToLocal(sharedUri);
const localPath = localUri.path;
const sharedPath = sharedUri.path;
if (localPath.endsWith(sharedPath)) {
return localUri.with({ path: localPath.substr(0, localPath.length - sharedPath.length) });
}
return localUri;
}
}

+ 54
- 0
src/vsls/protocol.ts View File

@ -0,0 +1,54 @@
'use strict';
import { GitCommandOptions } from '../git/git';
export class RequestType<TRequest, TResponse> {
constructor(
public readonly name: string
) {}
}
export interface GitCommandRequest {
options: GitCommandOptions;
args: any[];
}
export interface GitCommandResponse {
data: string;
isBuffer?: boolean;
}
export const GitCommandRequestType = new RequestType<GitCommandRequest, GitCommandResponse>('git');
export interface RepositoryProxy {
folderUri: string;
path: string;
root: boolean;
closed: boolean;
}
export interface RepositoriesInFolderRequest {
folderUri: string;
}
export interface RepositoriesInFolderResponse {
repositories: RepositoryProxy[];
}
export const RepositoriesInFolderRequestType = new RequestType<
RepositoriesInFolderRequest,
RepositoriesInFolderResponse
>('repositories/inFolder');
export interface WorkspaceFileExistsRequest {
fileName: string;
repoPath: string;
options: { ensureCase: boolean };
}
export interface WorkspaceFileExistsResponse {
exists: boolean;
}
export const WorkspaceFileExistsRequestType = new RequestType<WorkspaceFileExistsRequest, WorkspaceFileExistsResponse>(
'workspace/fileExists'
);

+ 112
- 0
src/vsls/vsls.ts View File

@ -0,0 +1,112 @@
'use strict';
import { Disposable, workspace } from 'vscode';
import { getApi, LiveShare, Role, SessionChangeEvent } from 'vsls';
import { CommandContext, DocumentSchemes, setCommandContext } from '../constants';
import { Container } from '../container';
import { Logger } from './../logger';
import { VslsGuestService } from './guest';
import { VslsHostService } from './host';
export const vslsUriPrefixRegex = /^[\/|\\]~\d+?(?:[\/|\\]|$)/;
export const vslsUriRootRegex = /^[\/|\\]~\d+?$/;
export class VslsController implements Disposable {
private _disposable: Disposable | undefined;
private _guest: VslsGuestService | undefined;
private _host: VslsHostService | undefined;
private _onReady: (() => void) | undefined;
private _waitForReady: Promise<void> | undefined;
constructor() {
void this.initialize();
}
dispose() {
this._disposable && this._disposable.dispose();
if (this._host !== undefined) {
this._host.dispose();
}
if (this._guest !== undefined) {
this._guest.dispose();
}
}
private async initialize() {
try {
// If we have a vsls: workspace open, we might be a guest, so wait until live share transitions into a mode
if (
workspace.workspaceFolders !== undefined &&
workspace.workspaceFolders.some(f => f.uri.scheme === DocumentSchemes.Vsls)
) {
this._waitForReady = new Promise(resolve => (this._onReady = resolve));
}
const api = await getApi();
if (api == null) {
// Tear it down if we can't talk to live share
if (this._onReady !== undefined) {
this._onReady();
this._waitForReady = undefined;
}
return;
}
this._disposable = Disposable.from(
api.onDidChangeSession(e => this.onLiveShareSessionChanged(api, e), this)
);
}
catch (ex) {
debugger;
Logger.error(ex);
return;
}
}
get isMaybeGuest() {
return this._guest !== undefined || this._waitForReady !== undefined;
}
async guest() {
if (this._waitForReady !== undefined) {
await this._waitForReady;
this._waitForReady = undefined;
}
return this._guest;
}
host() {
return this._host;
}
private async onLiveShareSessionChanged(api: LiveShare, e: SessionChangeEvent) {
if (this._host !== undefined) {
this._host.dispose();
}
if (this._guest !== undefined) {
this._guest.dispose();
}
switch (e.session.role) {
case Role.Host:
if (Container.config.liveshare.allowGuestAccess) {
this._host = await VslsHostService.share(api);
}
break;
case Role.Guest:
this._guest = await VslsGuestService.connect(api);
break;
default:
break;
}
if (this._onReady !== undefined) {
this._onReady();
this._onReady = undefined;
}
}
}

+ 4
- 1
webpack.config.js View File

@ -99,7 +99,10 @@ function getExtensionConfig(env) {
use: 'ts-loader',
exclude: /node_modules|\.d\.ts$/
}
]
],
// Removes `Critical dependency: the request of a dependency is an expression` from `./node_modules/vsls/vscode.js`
exprContextRegExp: /^$/,
exprContextCritical: false
},
resolve: {
extensions: ['.ts', '.tsx', '.js', '.jsx']

Loading…
Cancel
Save