Skip to content

Commit d4db844

Browse files
authoredFeb 28, 2020
Refactor model into a common API class (#45)
* enable es2019 features and use flatMap * Remove knowledge of the perforce command line / parsing from the model class and move it in to a dedicated module * Changes `exec` to `cross-spawn` which properly escapes arguments * Fixes: An issue where quotes or shell characters in "submit default changelist" command would be interpreted as shell characters * Improve testing, change StubPerforceService to StubModel * TODO - do the same for other calls in PerforceCommands, PerforceService and Utils * TODO - flatMap is compiled to a flatMap - so probably need to update the vscode minimum version
1 parent 29d7be9 commit d4db844

27 files changed

+3152
-1836
lines changed
 

‎.gitignore

+2-1
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@ out
22
node_modules
33
*.vsix
44
.vscode-test
5-
dist
5+
dist
6+
/reports

‎.vscode/launch.json

+1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"outFiles": [
1414
"${workspaceFolder}/dist/**/*.js"
1515
],
16+
"skipFiles": ["${workspaceFolder}/node_modules/**/*.js"],
1617
"preLaunchTask": "npm: watch"
1718
},
1819
{

‎.vscodeignore

+2-1
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,5 @@ src/**
1010
tsconfig.json
1111
node_modules
1212
out/
13-
webpack.config.js
13+
webpack.config.js
14+
reports/

‎CHANGELOG.md

+6-1
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
# Change log
22

3+
## [n.n.n] - 2020-mm-dd (TBD)
4+
* Fix an issue where user input in changelist descriptions could be interpreted by the shell
5+
* The `perforce.maxBuffer` setting has been removed, because the internal method of running perforce commands has changed, and no longer uses this buffer
6+
* Internally, there has been a large amount of code refactoring to make it easier to implement and test upcoming features. As usual, please [raise an issue](https://github.com/mjcrouch/vscode-perforce/issues) on GitHub if there are any problems!
7+
38
## [3.5.2] - 2020-02-13
49
* Fix "p4" status icon appearing on all workspaces, even without a perforce client
510
* Fix gutter decorations not being applied after a file was added to the depot (#42)
611
* Prevent `edit on file save` and `edit on file modified` from continually trying to open the same file when auto save was enabled (#39)
7-
* Fix the extension saying "file opened for edit" event if it wasn't
12+
* Fix the extension saying "file opened for edit" even if it wasn't
813

914
## [3.5.1] - 2020-02-10
1015
* Gutter decorations for a moved file now show the diff against the file it was moved from, if known (#29)

‎README.md

+14-20
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
# vscode-perforce
22

3+
[![VS Code marketplace button](https://vsmarketplacebadge.apphb.com/installs/mjcrouch.perforce.svg)](https://marketplace.visualstudio.com/items/mjcrouch.perforce)
34
[![GitHub issues](https://img.shields.io/github/issues/mjcrouch/vscode-perforce.svg)](https://github.com/mjcrouch/vscode-perforce/issues)
4-
[![Dependency Status](https://img.shields.io/david/mjcrouch/vscode-perforce.svg)](https://david-dm.org/mjcrouch/vscode-perforce)
5+
[![GitHub license button](https://img.shields.io/github/license/mjcrouch/vscode-perforce.svg)](https://github.com/mjcrouch/vscode-perforce/blob/master/LICENSE.txt)
6+
[![Build Status](https://dev.azure.com/mjcrouch/vscode-perforce/_apis/build/status/mjcrouch.vscode-perforce?branchName=master)](https://dev.azure.com/mjcrouch/vscode-perforce/_build/latest?definitionId=1&branchName=master)
7+
[![Test Status](https://img.shields.io/azure-devops/tests/mjcrouch/vscode-perforce/1/master)](https://dev.azure.com/mjcrouch/vscode-perforce/_build/latest?definitionId=1&branchName=master)
8+
[![Dependency Status](https://img.shields.io/david/mjcrouch/vscode-perforce.svg)](https://david-dm.org/mjcrouch/vscode-perforce)
59
[![Dev Dependency Status](https://img.shields.io/david/dev/mjcrouch/vscode-perforce.svg)](https://david-dm.org/mjcrouch/vscode-perforce?type=dev)
6-
[![GitHub license button](https://img.shields.io/github/license/mjcrouch/vscode-perforce.svg)](https://github.com/mjcrouch/vscode-perforce/blob/master/LICENSE.txt)
7-
[![VS Code marketplace button](https://vsmarketplacebadge.apphb.com/installs/mjcrouch.perforce.svg)](https://marketplace.visualstudio.com/items/mjcrouch.perforce)
810

911
Perforce integration for Visual Studio Code
1012

@@ -40,7 +42,6 @@ If you install this extension, please uninstall or disable `slevesque.perforce`
4042
| 
4143
|`perforce.dir` |`string` |Overrides any PWD setting (current working directory) and replaces it with the specified directory
4244
|`perforce.command` |`string` |Configure a path to p4 or an alternate command if needed
43-
|`perforce.maxBuffer` |`number` |Specify the largest amount of data allowed for commands, including file comparison. Default is 1048576 (1MB)
4445
|`perforce.realpath` |`boolean` |**Experimental** Try to resolve real file path before executing command
4546
| 
4647
|`perforce.activationMode` |`string` |Controls when to activate the extension (`always`,`autodetect`,`off`)
@@ -64,9 +65,9 @@ You must properly configure a perforce depot area before the extension activates
6465

6566
You can specify how you want the extension to activate by setting the parameter `perforce.activationMode`
6667

67-
* `always` - Always try to activate the extension (old behavior)
68-
* `autodetect` - Only activate when detecting a valid depot or `.p4config` file (default)
69-
* `off` - Don't try to activate
68+
* `autodetect` (default) - The extension will only activate if it detects a valid perforce client that contains the workspace root, or a `.p4config` file in the workspace. If one is not detected, perforce commands will not be registered with VSCode, but you will be able to view the perforce output log to see why the extension did not activate
69+
* `always` - Always try to activate the extension, even if a valid client was not found. This may be useful if you want to use perforce commands on files outside of the workspace, **and** you either have perforce set up properly with .p4config files for that area, or you have manually specified a user / client / port etc in your vscode configuration. Otherwise, you should probably avoid this setting
70+
* `off` - Don't try to activate the extension. No perforce log output will be produced
7071

7172
The following can be set in VSCode user or workspace settings to properly detect the perforce depot
7273
```json
@@ -87,7 +88,7 @@ More detail in [Perforce Documentation](https://www.perforce.com/perforce/r17.1/
8788

8889
## Multi-root support
8990

90-
You can now specify the following settings per workspace:
91+
You can specify the following settings per workspace:
9192
* `perforce.client`
9293
* `perforce.user`
9394
* `perforce.port`
@@ -99,7 +100,7 @@ See [Multi-root Workspaces - Settings](https://code.visualstudio.com/docs/editor
99100

100101
## Status bar icons
101102

102-
* ![check](images/check.png) opened in add or edit
103+
* ![check](images/check.png) opened for add or edit
103104
* ![file-text](images/file-text.png) not opened on this client
104105
* ![circle-slash](images/circle-slash.png) not under client's root
105106

@@ -117,18 +118,11 @@ Explore and leave your comments on [GitHub](https://github.com/mjcrouch/vscode-p
117118
#### **Q:** Something is not working
118119
**A:** Here are a few steps you should try first:
119120
1. Look at the logs with `Perforce: Show Output`
120-
1. Search the [existing issue on GitHub](https://github.com/mjcrouch/vscode-perforce/issues?utf8=✓&q=is%3Aissue)
121+
1. Search for the [existing issue on GitHub](https://github.com/mjcrouch/vscode-perforce/issues?utf8=✓&q=is%3Aissue)
121122
1. If you can't find your problem, [create an issue](https://github.com/mjcrouch/vscode-perforce/issues/new), and please include the logs when possible
122-
123-
124-
#### **Q:** Operations on a large files fail
125-
**A:** Increase `perforce.maxBuffer` in your user settings.
126-
[more...](https://github.com/stef-levesque/vscode-perforce/issues/116)
127-
128-
129-
#### **Q:** There is a lot of duplicated changelists showing up in the `Source Control` viewlet
130-
**A:** Please provide your Perforce Output logs in [issue #62](https://github.com/stef-levesque/vscode-perforce/issues/62)
131-
123+
124+
#### **Q:** Does it work with Remote-SSH?
125+
**A:** Yes - you will need to install the extension on the remote instance of VSCode, using the normal extensions view
132126

133127
#### **Q:** I'm using this old thing called *Source Depot*...
134128
**A:** I don't think you exist, since Microsoft has migrated to git. Compatibility mode has been removed.

‎azure-pipelines.yml

+9-1
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,19 @@ steps:
4646
env:
4747
DISPLAY: ':99.0'
4848

49+
- task: PublishTestResults@2
50+
inputs:
51+
testResultsFormat: 'JUnit'
52+
testResultsFiles: '**/junit-unit.xml'
53+
testRunTitle: $(Agent.JobName) - Unit Tests
54+
displayName: Publish unit test results
55+
4956
- task: PublishTestResults@2
5057
inputs:
5158
testResultsFormat: 'JUnit'
5259
testResultsFiles: '**/junit-integration.xml'
53-
testRunTitle: 'All Tests'
60+
testRunTitle: $(Agent.JobName) - All Tests
61+
displayName: Publish integration test results
5462

5563
- bash: |
5664
echo ">>> Install vsce"

‎package-lock.json

+198-24
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json

+11-13
Original file line numberDiff line numberDiff line change
@@ -172,12 +172,6 @@
172172
"default": 32,
173173
"description": "Specify the maximum number of file should be processed in a single command line (doesn't include changelist operations). Default is 32"
174174
},
175-
"perforce.maxBuffer": {
176-
"type": "number",
177-
"minimum": 1024,
178-
"default": 1048576,
179-
"description": "Specify the largest amount of data allowed for commands, including file comparison. Default is 204800 (200KB)"
180-
},
181175
"perforce.realpath": {
182176
"type": "boolean",
183177
"default": false,
@@ -746,31 +740,35 @@
746740
}
747741
]
748742
},
749-
"dependencies": {
750-
"ini": "^1.3.4",
751-
"micromatch": "^4.0.2",
752-
"parse-gitignore": "^0.4.0"
753-
},
743+
"dependencies": {},
754744
"devDependencies": {
745+
"@arrows/composition": "^1.2.2",
755746
"@types/chai": "^4.2.7",
756747
"@types/chai-as-promised": "^7.1.2",
748+
"@types/cross-spawn": "^6.0.1",
757749
"@types/glob": "^7.1.1",
758750
"@types/ini": "^1.3.30",
759751
"@types/micromatch": "^4.0.1",
760-
"@types/mocha": "^5.2.7",
752+
"@types/mocha": "^7.0.1",
761753
"@types/node": "^12.12.25",
762754
"@types/sinon": "^7.5.1",
763755
"@types/sinon-chai": "^3.2.3",
764756
"@typescript-eslint/eslint-plugin": "^2.16.0",
765757
"@typescript-eslint/parser": "^2.16.0",
766758
"chai": "^4.2.0",
767759
"chai-as-promised": "^7.1.1",
760+
"cross-spawn": "^7.0.1",
761+
"cypress-multi-reporters": "^1.2.4",
768762
"eslint": "^6.8.0",
769763
"eslint-config-prettier": "^6.9.0",
770764
"eslint-loader": "^3.0.3",
771765
"eslint-plugin-prettier": "^3.1.2",
772766
"glob": "^7.1.6",
773-
"mocha": "^7.0.0",
767+
"ini": "^1.3.4",
768+
"micromatch": "^4.0.2",
769+
"mocha": "^7.1.0",
770+
"mocha-junit-reporter": "^1.23.3",
771+
"parse-gitignore": "^0.4.0",
774772
"prettier": "1.19.1",
775773
"sinon": "^8.1.0",
776774
"sinon-chai": "^3.4.0",

‎src/ContentProvider.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ export class PerforceContentProvider {
5555
}
5656

5757
const allArgs = Utils.decodeUriQuery(uri.query ?? "");
58-
const args = (allArgs["p4args"] as string) ?? "-q";
58+
const args = ((allArgs["p4args"] as string) ?? "-q").split(" ");
5959
const command = (allArgs["command"] as string) ?? "print";
6060

6161
const [resource, file] = this.getResourceAndFileForUri(uri, allArgs);

‎src/Display.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ export namespace Display {
8686
}
8787

8888
if (!doc.isUntitled) {
89-
const args = '"' + Utils.expansePath(doc.uri.fsPath) + '"';
89+
const args = [Utils.expansePath(doc.uri.fsPath)];
9090
PerforceService.execute(
9191
doc.uri,
9292
"opened",

‎src/PerforceCommands.ts

+39-59
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
import * as Path from "path";
1717

1818
import { PerforceService } from "./PerforceService";
19+
import * as p4 from "./api/PerforceApi";
1920
import { Display } from "./Display";
2021
import { Utils } from "./Utils";
2122

@@ -55,7 +56,7 @@ export namespace PerforceCommands {
5556
}
5657

5758
export function add(fileUri: Uri, directoryOverride?: string) {
58-
const args = '"' + Utils.expansePath(fileUri.fsPath) + '"';
59+
const args = [Utils.expansePath(fileUri.fsPath)];
5960
PerforceService.execute(
6061
fileUri,
6162
"add",
@@ -92,7 +93,7 @@ export namespace PerforceCommands {
9293

9394
export function edit(fileUri: Uri, directoryOverride?: string): Promise<boolean> {
9495
return new Promise(resolve => {
95-
const args = '"' + Utils.expansePath(fileUri.fsPath) + '"';
96+
const args = [Utils.expansePath(fileUri.fsPath)];
9697
PerforceService.execute(
9798
fileUri,
9899
"edit",
@@ -125,7 +126,7 @@ export namespace PerforceCommands {
125126
}
126127

127128
export function p4delete(fileUri: Uri) {
128-
const args = '"' + Utils.expansePath(fileUri.fsPath) + '"';
129+
const args = [Utils.expansePath(fileUri.fsPath)];
129130
PerforceService.execute(
130131
fileUri,
131132
"delete",
@@ -155,7 +156,7 @@ export namespace PerforceCommands {
155156
? Path.dirname(fileUri.fsPath)
156157
: undefined;
157158

158-
const args = '"' + Utils.expansePath(fileUri.fsPath) + '"';
159+
const args = [Utils.expansePath(fileUri.fsPath)];
159160
PerforceService.execute(
160161
fileUri,
161162
"revert",
@@ -218,7 +219,7 @@ export namespace PerforceCommands {
218219

219220
const doc = editor.document;
220221

221-
const args = '-s "' + Utils.expansePath(doc.uri.fsPath) + '"';
222+
const args = ["-s", Utils.expansePath(doc.uri.fsPath)];
222223
PerforceService.execute(
223224
doc.uri,
224225
"filelog",
@@ -273,12 +274,12 @@ export namespace PerforceCommands {
273274
const cl = conf.get("annotate.changelist");
274275
const usr = conf.get("annotate.user");
275276
const swarmHost = conf.get("swarmHost");
276-
let args = "-q";
277+
const args = ["-q"];
277278
if (cl) {
278-
args += "c";
279+
args.push("-c");
279280
}
280281
if (usr) {
281-
args += "u";
282+
args.push("-u");
282283
}
283284

284285
const decorationType = window.createTextEditorDecorationType({
@@ -422,7 +423,7 @@ export namespace PerforceCommands {
422423
}
423424

424425
const resource = Uri.file(file);
425-
const args = '"' + file + '"';
426+
const args = [file];
426427
PerforceService.execute(
427428
resource,
428429
"where",
@@ -460,62 +461,41 @@ export namespace PerforceCommands {
460461
}
461462
}
462463

463-
export function logout() {
464+
export async function logout() {
464465
const resource = guessWorkspaceUri();
465-
PerforceService.execute(resource, "logout", (err, stdout, stderr) => {
466-
if (err) {
467-
Display.showError(err.message);
468-
return false;
469-
} else if (stderr) {
470-
Display.showError(stderr.toString());
471-
return false;
472-
} else {
473-
Display.showMessage("Logout successful");
474-
Display.updateEditor();
475-
return true;
476-
}
477-
});
466+
try {
467+
await p4.logout(resource, {});
468+
Display.showMessage("Logout successful");
469+
Display.updateEditor();
470+
return true;
471+
} catch {}
472+
return false;
478473
}
479474

480-
export function login() {
475+
export async function login() {
481476
const resource = guessWorkspaceUri();
482-
PerforceService.execute(
483-
resource,
484-
"login",
485-
(err, stdout, stderr) => {
486-
if (err || stderr) {
487-
window
488-
.showInputBox({ prompt: "Enter password", password: true })
489-
.then(passwd => {
490-
PerforceService.execute(
491-
resource,
492-
"login",
493-
(err, stdout, stderr) => {
494-
if (err) {
495-
Display.showError(err.message);
496-
return false;
497-
} else if (stderr) {
498-
Display.showError(stderr.toString());
499-
return false;
500-
} else {
501-
Display.showMessage("Login successful");
502-
Display.updateEditor();
503-
return true;
504-
}
505-
},
506-
undefined,
507-
undefined,
508-
passwd
509-
);
510-
});
511-
} else {
477+
478+
let loggedIn = await p4.isLoggedIn(resource);
479+
if (!loggedIn) {
480+
const password = await window.showInputBox({
481+
prompt: "Enter password",
482+
password: true
483+
});
484+
if (password) {
485+
try {
486+
await p4.login(resource, { password });
487+
512488
Display.showMessage("Login successful");
513489
Display.updateEditor();
514-
return true;
515-
}
516-
},
517-
"-s"
518-
);
490+
loggedIn = true;
491+
} catch {}
492+
}
493+
} else {
494+
Display.showMessage("Login successful");
495+
Display.updateEditor();
496+
loggedIn = true;
497+
}
498+
return loggedIn;
519499
}
520500

521501
export function menuFunctions() {

‎src/PerforceService.ts

+109-48
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { Display } from "./Display";
55
import { PerforceSCMProvider } from "./ScmProvider";
66

77
import * as CP from "child_process";
8+
import * as spawn from "cross-spawn";
89
import { CommandLimiter } from "./CommandLimiter";
910

1011
// eslint-disable-next-line @typescript-eslint/interface-name-prefix
@@ -87,28 +88,8 @@ export namespace PerforceService {
8788
return path;
8889
}
8990

90-
export function getPerforceCmdPath(resource: Uri): string {
91+
function getPerforceCmdPath(): string {
9192
let p4Path = workspace.getConfiguration("perforce").get("command", "none");
92-
const p4User = workspace
93-
.getConfiguration("perforce", resource)
94-
.get("user", "none");
95-
const p4Client = workspace
96-
.getConfiguration("perforce", resource)
97-
.get("client", "none");
98-
const p4Port = workspace
99-
.getConfiguration("perforce", resource)
100-
.get("port", "none");
101-
const p4Pass = workspace
102-
.getConfiguration("perforce", resource)
103-
.get("password", "none");
104-
const p4Dir = workspace.getConfiguration("perforce", resource).get("dir", "none");
105-
106-
const buildCmd = (value: string | number | undefined, arg: string): string => {
107-
if (!value || value === "none") {
108-
return "";
109-
}
110-
return ` ${arg} ${value}`;
111-
};
11293

11394
if (p4Path === "none") {
11495
const isWindows = process.platform.startsWith("win");
@@ -122,31 +103,56 @@ export namespace PerforceService {
122103
uncPath = replaceable.join("\\\\");
123104
}
124105

125-
uncPath = `"${uncPath}"`;
126106
return uncPath;
127107
};
128108

129109
p4Path = toUNC(p4Path);
130110
}
111+
return p4Path;
112+
}
131113

132-
p4Path += buildCmd(p4User, "-u");
133-
p4Path += buildCmd(p4Client, "-c");
134-
p4Path += buildCmd(p4Port, "-p");
135-
p4Path += buildCmd(p4Pass, "-P");
136-
p4Path += buildCmd(p4Dir, "-d");
114+
function getPerforceCmdParams(resource: Uri): string[] {
115+
const p4User = workspace
116+
.getConfiguration("perforce", resource)
117+
.get("user", "none");
118+
const p4Client = workspace
119+
.getConfiguration("perforce", resource)
120+
.get("client", "none");
121+
const p4Port = workspace
122+
.getConfiguration("perforce", resource)
123+
.get("port", "none");
124+
const p4Pass = workspace
125+
.getConfiguration("perforce", resource)
126+
.get("password", "none");
127+
const p4Dir = workspace.getConfiguration("perforce", resource).get("dir", "none");
128+
129+
const ret: string[] = [];
130+
131+
const buildCmd = (value: string | number | undefined, arg: string): string[] => {
132+
if (!value || value === "none") {
133+
return [];
134+
}
135+
return [arg, value.toString()];
136+
};
137+
138+
ret.push(...buildCmd(p4User, "-u"));
139+
ret.push(...buildCmd(p4Client, "-c"));
140+
ret.push(...buildCmd(p4Port, "-p"));
141+
ret.push(...buildCmd(p4Pass, "-P"));
142+
ret.push(...buildCmd(p4Dir, "-d"));
137143

138144
// later args override earlier args
139145
const wksFolder = workspace.getWorkspaceFolder(resource);
140146
const config = wksFolder ? getConfig(wksFolder.uri.fsPath) : null;
141147
if (config) {
142-
p4Path += buildCmd(config.p4User, "-u");
143-
p4Path += buildCmd(config.p4Client, "-c");
144-
p4Path += buildCmd(config.p4Port, "-p");
145-
p4Path += buildCmd(config.p4Pass, "-P");
146-
p4Path += buildCmd(config.p4Dir, "-d");
148+
ret.push(...buildCmd(config.p4User, "-u"));
149+
ret.push(...buildCmd(config.p4Client, "-c"));
150+
ret.push(...buildCmd(config.p4Port, "-p"));
151+
ret.push(...buildCmd(config.p4Pass, "-P"));
152+
ret.push(...buildCmd(config.p4Dir, "-d"));
147153
}
148154

149-
return p4Path;
155+
return ret;
150156
}
151157

152158
let id = 0;
@@ -155,7 +161,7 @@ export namespace PerforceService {
155161
resource: Uri,
156162
command: string,
157163
responseCallback: (err: Error | null, stdout: string, stderr: string) => void,
158-
args?: string,
164+
args?: string[],
159165
directoryOverride?: string | null,
160166
input?: string
161167
): void {
@@ -183,7 +189,7 @@ export namespace PerforceService {
183189
export function executeAsPromise(
184190
resource: Uri,
185191
command: string,
186-
args?: string,
192+
args?: string[],
187193
directoryOverride?: string,
188194
input?: string
189195
): Promise<string> {
@@ -211,41 +217,96 @@ export namespace PerforceService {
211217
resource: Uri,
212218
command: string,
213219
responseCallback: (err: Error | null, stdout: string, stderr: string) => void,
214-
args?: string,
220+
args?: string[],
215221
directoryOverride?: string | null,
216222
input?: string
217223
): void {
218224
const wksFolder = workspace.getWorkspaceFolder(resource);
219225
const config = wksFolder ? getConfig(wksFolder.uri.fsPath) : null;
220226
const wksPath = wksFolder ? wksFolder.uri.fsPath : "";
221-
let cmdLine = getPerforceCmdPath(resource);
222-
const maxBuffer = workspace
223-
.getConfiguration("perforce")
224-
.get("maxBuffer", 200 * 1024);
227+
const cmd = getPerforceCmdPath();
225228

229+
const allArgs: string[] = getPerforceCmdParams(resource);
226230
if (directoryOverride !== null && directoryOverride !== undefined) {
227-
cmdLine += " -d " + directoryOverride;
231+
allArgs.push("-d", directoryOverride);
228232
}
229-
cmdLine += " " + command;
233+
allArgs.push(command);
230234

231235
if (args !== undefined) {
232236
if (config && config.stripLocalDir) {
233-
args = args.replace(config.localDir, "");
237+
args = args.map(arg => arg.replace(config.localDir, ""));
234238
}
235239

236-
cmdLine += " " + args;
240+
allArgs.push(...args);
237241
}
238242

239-
Display.channel.appendLine(cmdLine);
240-
const cmdArgs = { cwd: config ? config.localDir : wksPath, maxBuffer: maxBuffer };
241-
const child = CP.exec(cmdLine, cmdArgs, responseCallback);
243+
const spawnArgs: CP.SpawnOptions = { cwd: config ? config.localDir : wksPath };
244+
spawnPerforceCommand(cmd, allArgs, spawnArgs, responseCallback, input);
245+
}
246+
247+
function spawnPerforceCommand(
248+
cmd: string,
249+
allArgs: string[],
250+
spawnArgs: CP.SpawnOptions,
251+
responseCallback: (err: Error | null, stdout: string, stderr: string) => void,
252+
input?: string
253+
) {
254+
logExecutedCommand(cmd, allArgs);
255+
const child = spawn(cmd, allArgs, spawnArgs);
256+
257+
let called = false;
258+
child.on("error", (err: Error) => {
259+
if (!called) {
260+
called = true;
261+
responseCallback(err, "", "");
262+
}
263+
});
242264

243265
if (input !== undefined) {
244266
if (!child.stdin) {
245267
throw new Error("Child does not have standard input");
246268
}
247269
child.stdin.end(input, "utf8");
248270
}
271+
272+
getResults(child).then((value: string[]) => {
273+
if (!called) {
274+
responseCallback(null, value[0] ?? "", value[1] ?? "");
275+
}
276+
});
277+
}
278+
279+
function logExecutedCommand(cmd: string, args: string[]) {
280+
// not necessarily using these escaped values, because cross-spawn does its own escaping,
281+
// but no sensible way of logging the unescaped array for a user. The output command line
282+
// should at least be copy-pastable and work
283+
const escapedArgs = args.map(arg => `'${arg.replace(/'/g, `'\\''`)}'`);
284+
const loggedCommand = [cmd].concat(escapedArgs);
285+
Display.channel.appendLine(loggedCommand.join(" "));
286+
}
287+
288+
async function getResults(child: CP.ChildProcess): Promise<string[]> {
289+
return Promise.all([readStdOut(child), readStdErr(child)]);
290+
}
291+
292+
async function readStdOut(child: CP.ChildProcess) {
293+
let output: string = "";
294+
if (child.stdout) {
295+
for await (const data of child.stdout) {
296+
output += data.toString();
297+
}
298+
}
299+
return output;
300+
}
301+
302+
async function readStdErr(child: CP.ChildProcess) {
303+
let output: string = "";
304+
if (child.stderr) {
305+
for await (const data of child.stderr) {
306+
output += data.toString();
307+
}
308+
}
309+
return output;
249310
}
250311

251312
export function handleCommonServiceResponse(
@@ -290,7 +351,7 @@ export namespace PerforceService {
290351

291352
export function getConfigFilename(resource: Uri): Promise<string> {
292353
return new Promise((resolve, reject) => {
293-
PerforceService.executeAsPromise(resource, "set", "-q")
354+
PerforceService.executeAsPromise(resource, "set", ["-q"])
294355
.then(stdout => {
295356
let configIndex = stdout.indexOf("P4CONFIG=");
296357
if (configIndex === -1) {

‎src/ScmProvider.ts

+2-3
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,8 @@ export class PerforceSCMProvider {
5959
}
6060
public get count(): number {
6161
const countBadge = this._workspaceConfig.countBadge;
62-
const resources: Resource[] = this._model.ResourceGroups.reduce(
63-
(a, b) => a.concat(b.resourceStates as Resource[]),
64-
[] as Resource[]
62+
const resources: Resource[] = this._model.ResourceGroups.flatMap(
63+
g => g.resourceStates as Resource[]
6564
);
6665

6766
// Don't count MOVE_DELETE as we already count MOVE_ADD

‎src/Utils.ts

+15-74
Original file line numberDiff line numberDiff line change
@@ -97,81 +97,14 @@ export namespace Utils {
9797
return allArgs;
9898
}
9999

100-
export function processInfo(output: string): Map<string, string> {
101-
const map = new Map<string, string>();
102-
const lines = output.trim().split("\n");
103-
104-
for (let i = 0, n = lines.length; i < n; ++i) {
105-
// Property Name: Property Value
106-
const matches = new RegExp(/([^:]+): (.+)/).exec(lines[i]);
107-
108-
if (matches) {
109-
map.set(matches[1], matches[2]);
110-
}
111-
}
112-
113-
return map;
114-
}
115-
116-
export function isLoggedIn(resource: Uri): Promise<boolean> {
117-
return new Promise((resolve, reject) => {
118-
PerforceService.execute(
119-
resource,
120-
"login",
121-
(err, stdout, stderr) => {
122-
err && Display.showError(err.toString());
123-
stderr && Display.showError(stderr.toString());
124-
if (err) {
125-
reject(err);
126-
} else if (stderr) {
127-
reject(stderr);
128-
} else {
129-
resolve(true);
130-
}
131-
},
132-
"-s"
133-
);
134-
});
135-
}
136-
137-
export function getSimpleOutput(resource: Uri, command: string): Promise<string> {
138-
return new Promise((resolve, reject) => {
139-
PerforceService.execute(resource, command, (err, stdout, stderr) => {
140-
err && Display.showError(err.toString());
141-
stderr && Display.showError(stderr.toString());
142-
if (err) {
143-
reject(err);
144-
} else if (stderr) {
145-
reject(stderr);
146-
} else {
147-
resolve(stdout);
148-
}
149-
});
150-
});
151-
}
152-
153-
export function getOutputs(
154-
resource: Uri,
155-
command: string
156-
): Promise<[string, string]> {
157-
return new Promise((resolve, reject) => {
158-
PerforceService.execute(resource, command, (err, stdout, stderr) => {
159-
err && Display.showError(err.toString());
160-
if (err) {
161-
reject(err);
162-
}
163-
resolve([stdout, stderr]);
164-
});
165-
});
166-
}
167-
168100
export interface CommandParams {
169101
file?: Uri | string;
170102
revision?: string;
171-
prefixArgs?: string;
103+
prefixArgs?: string[];
172104
gOpts?: string;
173105
input?: string;
174106
hideStdErr?: boolean; // just from the status bar - not from the log output
107+
stdErrIsOk?: boolean;
175108
}
176109

177110
// Get a string containing the output of the command
@@ -181,8 +114,16 @@ export namespace Utils {
181114
params: CommandParams
182115
): Promise<string> {
183116
return new Promise((resolve, reject) => {
184-
const { file, revision, prefixArgs, gOpts, input, hideStdErr } = params;
185-
let args = prefixArgs ?? "";
117+
const {
118+
file,
119+
revision,
120+
prefixArgs,
121+
gOpts,
122+
input,
123+
hideStdErr,
124+
stdErrIsOk
125+
} = params;
126+
const args = prefixArgs ?? [];
186127

187128
if (gOpts !== undefined) {
188129
command = gOpts + " " + command;
@@ -194,7 +135,7 @@ export namespace Utils {
194135
let path = typeof file === "string" ? file : file.fsPath;
195136
path = expansePath(path);
196137

197-
args += ' "' + path + revisionString + '"';
138+
args.push(path + revisionString);
198139
}
199140

200141
PerforceService.execute(
@@ -209,7 +150,7 @@ export namespace Utils {
209150
}
210151
if (err) {
211152
reject(err);
212-
} else if (stderr) {
153+
} else if (stderr && !stdErrIsOk) {
213154
reject(stderr);
214155
} else {
215156
resolve(stdout);
@@ -227,7 +168,7 @@ export namespace Utils {
227168
command: string,
228169
file: Uri,
229170
revision?: string,
230-
prefixArgs?: string,
171+
prefixArgs?: string[],
231172
gOpts?: string,
232173
input?: string
233174
): Promise<string> {

‎src/api/CommandUtils.ts

+226
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
import { Utils } from "../Utils";
2+
import { FileSpec, isFileSpec, PerforceFile } from "./CommonTypes";
3+
import * as vscode from "vscode";
4+
5+
/**
6+
* Predicate used for filtering out undefined or null values from an array,
7+
* and resulting in an array of type T
8+
* @param obj a single element
9+
* @returns the truthiness of the value, and narrows the type to T
10+
*/
11+
export function isTruthy<T>(obj: T | undefined | null): obj is T {
12+
return !!obj;
13+
}
14+
15+
/**
16+
* Extract a section of an array between two matching predicates
17+
* @param allLines The array to extract from
18+
* @param startingWith Matches the first line of the section (exclusive)
19+
* @param endingWith Matches the last line of the section - if not found, returns all elements after the start index
20+
*/
21+
export function extractSection<T>(
22+
allLines: T[],
23+
startingWith: (line: T) => boolean,
24+
endingWith: (line: T) => boolean
25+
) {
26+
const startIndex = allLines.findIndex(startingWith);
27+
if (startIndex >= 0) {
28+
const endIndex = allLines.findIndex(endingWith);
29+
return allLines.slice(startIndex + 1, endIndex >= 0 ? endIndex : undefined);
30+
}
31+
}
32+
33+
/**
34+
* Divides an array into sections that start with the matching line
35+
*
36+
* @param lines the array to divide
37+
* @param sectionMatcher a predicate that matches the first line of a section
38+
* @returns An array of string arrays. Each array is a section **starting** with the matching line.
39+
* If no matching line is present, the returned array is empty
40+
*/
41+
export function sectionArrayBy<T>(lines: T[], sectionMatcher: (line: T) => boolean) {
42+
const sections: T[][] = [];
43+
44+
let nextMatch = lines.findIndex(sectionMatcher);
45+
let prevMatch = -1;
46+
while (nextMatch > prevMatch) {
47+
prevMatch = nextMatch;
48+
nextMatch = lines.slice(prevMatch + 1).findIndex(sectionMatcher) + prevMatch + 1;
49+
sections.push(
50+
lines.slice(prevMatch, nextMatch > prevMatch ? nextMatch : undefined)
51+
);
52+
}
53+
54+
return sections;
55+
}
56+
57+
function arraySplitter<T>(chunkSize: number) {
58+
return (arr: T[]): T[][] => {
59+
const ret: T[][] = [];
60+
for (let i = 0; i < arr.length; i += chunkSize) {
61+
ret.push(arr.slice(i, i + chunkSize));
62+
}
63+
return ret;
64+
};
65+
}
66+
67+
export const splitIntoChunks = <T>(arr: T[]) => arraySplitter<T>(32)(arr);
68+
69+
export function applyToEach<T, R>(fn: (input: T) => R) {
70+
return (input: T[]) => input.map(i => fn(i));
71+
}
72+
73+
export function concatIfOutputIsDefined<T, R>(...fns: ((arg: T) => R | undefined)[]) {
74+
return (arg: T) =>
75+
fns.reduce((all, fn) => {
76+
const val = fn(arg);
77+
return val !== undefined ? all.concat([val]) : all;
78+
}, [] as R[]);
79+
}
80+
81+
export type CmdlineArgs = (string | undefined)[];
82+
83+
function makeFlag(flag: string, value: string | boolean | undefined): CmdlineArgs {
84+
if (typeof value === "string") {
85+
return value ? ["-" + flag, value] : [];
86+
}
87+
return value ? ["-" + flag] : [];
88+
}
89+
90+
export function makeFlags(
91+
pairs: [string, string | boolean | undefined][],
92+
lastArgs?: (string | undefined)[]
93+
): CmdlineArgs {
94+
return pairs.flatMap(pair => makeFlag(pair[0], pair[1])).concat(...(lastArgs ?? []));
95+
}
96+
97+
type FlagValue = string | boolean | PerforceFile | PerforceFile[] | string[] | undefined;
98+
type FlagDefinition<T> = {
99+
[key in keyof T]: FlagValue;
100+
};
101+
102+
function lastArgAsStrings(
103+
lastArg: FlagValue,
104+
lastArgIsFormattedArray?: boolean
105+
): (string | undefined)[] | undefined {
106+
if (typeof lastArg === "boolean") {
107+
return undefined;
108+
}
109+
if (typeof lastArg === "string") {
110+
return [lastArg];
111+
}
112+
if (isFileSpec(lastArg)) {
113+
return [
114+
Utils.expansePath(lastArg.fsPath) + (lastArg.suffix ? lastArg.suffix : "")
115+
];
116+
}
117+
if (lastArgIsFormattedArray) {
118+
return lastArg as string[];
119+
}
120+
return pathsToArgs(lastArg);
121+
}
122+
123+
/**
124+
* Create a function that maps an object of type P into an array of command arguments
125+
* @param flagNames A set of tuples - flag name to output (e.g. "c" produces "-c") and key from the object to use.
126+
* For example, given an object `{chnum: "1", delete: true}`, the parameter `[["c", "chnum"], ["d", "delete"]]` would map this object to `["-c", "1", "-d"]`
127+
* @param lastArg The field on the object that contains the final argument(s), that do not require a command line switch. Typically a list of paths to append to the end of the command. (must not be a boolean field)
128+
* @param lastArgIsFormattedArray If the last argument is a string array, disable putting quotes around the strings
129+
* @param fixedPrefix A fixed string to always put first in the perforce command
130+
*/
131+
export function flagMapper<P extends FlagDefinition<P>>(
132+
flagNames: [string, keyof P][],
133+
lastArg?: keyof P,
134+
lastArgIsFormattedArray?: boolean,
135+
fixedPrefix?: string
136+
) {
137+
return (options: P): CmdlineArgs => {
138+
return [fixedPrefix].concat(
139+
makeFlags(
140+
flagNames.map(fn => {
141+
return [fn[0], options[fn[1]] as string | boolean | undefined];
142+
}),
143+
lastArg
144+
? lastArgAsStrings(
145+
options[lastArg] as FlagValue,
146+
lastArgIsFormattedArray
147+
)
148+
: undefined
149+
)
150+
);
151+
};
152+
}
153+
154+
const joinDefinedArgs = (args: CmdlineArgs) => args?.filter(isTruthy);
155+
156+
function pathsToArgs(arr?: (string | FileSpec)[]) {
157+
return (
158+
arr?.map(path => {
159+
if (isFileSpec(path)) {
160+
return Utils.expansePath(path.fsPath) + (path.suffix ? path.suffix : "");
161+
} else if (path) {
162+
return path;
163+
}
164+
}) ?? []
165+
);
166+
}
167+
168+
export const fixedParams = (ps: Utils.CommandParams) => () => ps;
169+
170+
const runPerforceCommand = Utils.runCommand;
171+
172+
/**
173+
* merge n objects of the same type, where the left hand value has precedence
174+
* @param args the objects to merge
175+
*/
176+
function mergeWithoutOverriding<T>(...args: T[]): T {
177+
return args.reduce((all, cur) => {
178+
return { ...cur, ...all };
179+
});
180+
}
181+
182+
/**
183+
* merge n object of the same type, where the right hand value has precedence
184+
* @param args The objects to merge
185+
*/
186+
export function mergeAll<T>(...args: T[]): T {
187+
return args.reduce((all, cur) => {
188+
return { ...all, ...cur };
189+
});
190+
}
191+
192+
/**
193+
* Returns a function that, when called with options of type T, runs a defined perforce command
194+
* @param command The name of the perforce command to run
195+
* @param fn A function that maps from the input options of type T to a set of arguments to pass into the command
196+
* @param otherParams An optional function that maps from the input options to the additional options to pass in to runCommand (not command line options!)
197+
*/
198+
export function makeSimpleCommand<T>(
199+
command: string,
200+
fn: (opts: T) => CmdlineArgs,
201+
otherParams?: (opts: T) => Exclude<Utils.CommandParams, { prefixArgs: string }>
202+
) {
203+
return (resource: vscode.Uri, options: T) =>
204+
runPerforceCommand(
205+
resource,
206+
command,
207+
mergeWithoutOverriding(
208+
{
209+
prefixArgs: joinDefinedArgs(fn(options))
210+
},
211+
otherParams?.(options) ?? {}
212+
)
213+
);
214+
}
215+
216+
/**
217+
* Create a function that awaits the result of the first async function, and passes it to the mapper function
218+
* @param fn The async function to await
219+
* @param mapper The function that accepts the result of the async function
220+
*/
221+
export function asyncOuputHandler<T extends any[], M, O>(
222+
fn: (...args: T) => Promise<M>,
223+
mapper: (arg: M) => O
224+
) {
225+
return async (...args: T) => mapper(await fn(...args));
226+
}

‎src/api/CommonTypes.ts

+45
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
export type FixedJob = { id: string; description: string[] };
2+
3+
export type ChangeInfo = {
4+
chnum: string;
5+
description: string;
6+
date: string;
7+
user: string;
8+
client: string;
9+
status?: string;
10+
};
11+
12+
export type ChangeSpec = {
13+
description?: string;
14+
files?: ChangeSpecFile[];
15+
change?: string;
16+
rawFields: RawField[];
17+
};
18+
19+
export type RawField = {
20+
name: string;
21+
value: string[];
22+
};
23+
24+
export type ChangeSpecFile = {
25+
depotPath: string;
26+
action: string;
27+
};
28+
29+
export type FstatInfo = {
30+
depotFile: string;
31+
[key: string]: string;
32+
};
33+
34+
export type FileSpec = {
35+
/** The filesystem path - without escaping special characters */
36+
fsPath: string;
37+
/** Optional suffix, e.g. #1, @=2 */
38+
suffix?: string;
39+
};
40+
41+
export type PerforceFile = FileSpec | string;
42+
43+
export function isFileSpec(obj: any): obj is FileSpec {
44+
return obj && obj.fsPath;
45+
}

‎src/api/PerforceApi.ts

+542
Large diffs are not rendered by default.

‎src/scm/Model.ts

+219-608
Large diffs are not rendered by default.

‎src/test/helpers/StubPerforceModel.ts

+271
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
import * as sinon from "sinon";
2+
import * as vscode from "vscode";
3+
import * as p4 from "../../api/PerforceApi";
4+
5+
import { ChangeInfo, ChangeSpec, FixedJob, FstatInfo } from "../../api/CommonTypes";
6+
import { Status } from "../../scm/Status";
7+
import { PerforceService } from "../../PerforceService";
8+
import { getStatusText } from "./testUtils";
9+
10+
type PerforceResponseCallback = (
11+
err: Error | null,
12+
stdout: string,
13+
stderr: string
14+
) => void;
15+
16+
export interface StubJob {
17+
name: string;
18+
description: string[];
19+
}
20+
21+
export interface StubChangelist {
22+
chnum: string;
23+
description: string;
24+
submitted?: boolean;
25+
files: StubFile[];
26+
shelvedFiles?: StubFile[];
27+
jobs?: StubJob[];
28+
}
29+
30+
export interface StubFile {
31+
localFile: vscode.Uri;
32+
depotPath: string;
33+
depotRevision: number;
34+
operation: Status;
35+
fileType?: string;
36+
resolveFromDepotPath?: string;
37+
resolveEndFromRev?: number;
38+
}
39+
40+
export function stubExecute() {
41+
return sinon.stub(PerforceService, "execute").callsFake(executeStub);
42+
}
43+
44+
function executeStub(
45+
_resource: vscode.Uri,
46+
command: string,
47+
responseCallback: PerforceResponseCallback,
48+
_args?: string[],
49+
_directoryOverride?: string | null,
50+
_input?: string
51+
) {
52+
setImmediate(() => {
53+
responseCallback(null, command, "");
54+
});
55+
}
56+
57+
function makeDefaultInfo(resource: vscode.Uri) {
58+
const ret = new Map<string, string>();
59+
ret.set("User name", "user");
60+
ret.set("Client name", "cli");
61+
ret.set("Client root", resource.fsPath);
62+
ret.set("Current directory", resource.fsPath);
63+
return Promise.resolve(ret);
64+
}
65+
66+
export class StubPerforceModel {
67+
public changelists: StubChangelist[];
68+
69+
public isLoggedIn: sinon.SinonStub<any>;
70+
public deleteChangelist: sinon.SinonStub<any>;
71+
public fixJob: sinon.SinonStub<any>;
72+
public getChangeSpec: sinon.SinonStub<any>;
73+
public getChangelists: sinon.SinonStub<any>;
74+
public getFixedJobs: sinon.SinonStub<any>;
75+
public getFstatInfo: sinon.SinonStub<any>;
76+
public getInfo: sinon.SinonStub<any>;
77+
public getOpenedFiles: sinon.SinonStub<any>;
78+
public getShelvedFiles: sinon.SinonStub<any>;
79+
public haveFile: sinon.SinonStub<any>;
80+
public reopenFiles: sinon.SinonStub<any>;
81+
public revert: sinon.SinonStub<any>;
82+
public shelve: sinon.SinonStub<any>;
83+
public submitChangelist: sinon.SinonStub<any>;
84+
public sync: sinon.SinonStub<any>;
85+
public unshelve: sinon.SinonStub<any>;
86+
public inputChangeSpec: sinon.SinonStub<any>;
87+
88+
constructor() {
89+
this.changelists = [];
90+
91+
this.isLoggedIn = sinon.stub(p4, "isLoggedIn").resolves(true);
92+
this.deleteChangelist = sinon
93+
.stub(p4, "deleteChangelist")
94+
.resolves("changelist deleted");
95+
this.fixJob = sinon.stub(p4, "fixJob").resolves("job fixed");
96+
this.getChangeSpec = sinon
97+
.stub(p4, "getChangeSpec")
98+
.callsFake(this.resolveChangeSpec.bind(this));
99+
this.getChangelists = sinon
100+
.stub(p4, "getChangelists")
101+
.callsFake(this.resolveChangelists.bind(this));
102+
this.getFixedJobs = sinon
103+
.stub(p4, "getFixedJobs")
104+
.callsFake(this.resolveFixedJobs.bind(this));
105+
this.getFstatInfo = sinon
106+
.stub(p4, "getFstatInfo")
107+
.callsFake(this.fstatFiles.bind(this));
108+
this.getInfo = sinon.stub(p4, "getInfo").callsFake(makeDefaultInfo);
109+
this.getOpenedFiles = sinon
110+
.stub(p4, "getOpenedFiles")
111+
.callsFake(this.resolveOpenFiles.bind(this));
112+
this.getShelvedFiles = sinon
113+
.stub(p4, "getShelvedFiles")
114+
.callsFake(this.resolveShelvedFiles.bind(this));
115+
this.haveFile = sinon.stub(p4, "haveFile").resolves(true);
116+
this.reopenFiles = sinon.stub(p4, "reopenFiles").resolves("reopened");
117+
this.revert = sinon.stub(p4, "revert").resolves("reverted");
118+
this.shelve = sinon.stub(p4, "shelve").resolves("shelved");
119+
this.submitChangelist = sinon.stub(p4, "submitChangelist").resolves("submitted");
120+
this.sync = sinon.stub(p4, "sync").resolves("synced");
121+
this.unshelve = sinon.stub(p4, "unshelve").resolves("unshelved");
122+
this.inputChangeSpec = sinon
123+
.stub(p4, "inputChangeSpec")
124+
.resolves({ chnum: "99", rawOutput: "Change 99 created" });
125+
}
126+
127+
resolveOpenFiles(
128+
_resource: vscode.Uri,
129+
options: p4.OpenedFileOptions
130+
): Promise<string[]> {
131+
return Promise.resolve(
132+
this.changelists
133+
.filter(cl => (options.chnum ? cl.chnum === options.chnum : true))
134+
.flatMap(cl => cl.files.map(file => file.depotPath))
135+
);
136+
}
137+
138+
resolveChangelists(): Promise<ChangeInfo[]> {
139+
// Note - doesn't take account of options! (TODO if required)
140+
return Promise.resolve(
141+
this.changelists
142+
.filter(cl => !cl.submitted && cl.chnum !== "default")
143+
.map<ChangeInfo>(cl => {
144+
return {
145+
chnum: cl.chnum,
146+
date: "01/01/2020",
147+
client: "cli",
148+
user: "user",
149+
description: cl.description,
150+
status: "*pending*"
151+
};
152+
})
153+
);
154+
}
155+
156+
resolveFixedJobs(
157+
_resource: vscode.Uri,
158+
options: p4.GetFixedJobsOptions
159+
): Promise<FixedJob[]> {
160+
const cl = this.changelists.find(cl => cl.chnum === options.chnum);
161+
if (!cl) {
162+
return Promise.reject("Changelist does not exist");
163+
}
164+
return Promise.resolve(
165+
cl.jobs?.map<FixedJob>(job => {
166+
return { description: job.description, id: job.name };
167+
}) ?? []
168+
);
169+
}
170+
171+
resolveShelvedFiles(
172+
_resource: vscode.Uri,
173+
options: p4.GetShelvedOptions
174+
): Promise<p4.ShelvedChangeInfo[]> {
175+
return Promise.resolve(
176+
this.changelists
177+
.filter(cl => options.chnums.includes(cl.chnum))
178+
.map(cl => {
179+
return {
180+
chnum: parseInt(cl.chnum),
181+
paths: cl.shelvedFiles?.map(s => s.depotPath)
182+
};
183+
})
184+
.filter((cl): cl is p4.ShelvedChangeInfo => cl.paths !== undefined)
185+
);
186+
}
187+
188+
/*private withoutUndefined<T>(obj: { [key: string]: T }) {
189+
return Object.entries(obj).reduce((all, cur) => {
190+
if (cur[1] !== undefined && cur[1] !== null) {
191+
all[cur[0]] = cur[1];
192+
}
193+
return all;
194+
}, {} as { [key: string]: T });
195+
}*/
196+
197+
fstatFile(
198+
depotPath: string,
199+
chnum?: string,
200+
shelved?: boolean
201+
): FstatInfo | undefined {
202+
const cl = this.changelists.find(c =>
203+
chnum ? c.chnum === chnum : c.files.some(file => file.depotPath === depotPath)
204+
);
205+
const file = shelved
206+
? cl?.shelvedFiles?.find(file => file.depotPath === depotPath)
207+
: cl?.files.find(file => file.depotPath === depotPath);
208+
209+
if (file) {
210+
return {
211+
depotFile: depotPath,
212+
clientFile: file.localFile.fsPath,
213+
isMapped: "true",
214+
haveRev: file.depotRevision.toString(),
215+
headType: file.fileType ?? "text",
216+
action: getStatusText(file.operation),
217+
workRev: file.depotRevision?.toString(),
218+
change: cl?.chnum,
219+
resolveFromFile0: file.resolveFromDepotPath,
220+
resolveEndFromRev0: file.resolveEndFromRev?.toString()
221+
} as FstatInfo;
222+
}
223+
}
224+
225+
fstatFiles(
226+
_resource: vscode.Uri,
227+
options: p4.FstatOptions
228+
): Promise<(FstatInfo | undefined)[]> {
229+
const files = options.depotPaths.map(path =>
230+
this.fstatFile(path, options.chnum, options.limitToShelved)
231+
);
232+
return Promise.resolve(files);
233+
//return Promise.reject("implement me");
234+
}
235+
236+
resolveChangeSpec(
237+
_resource: vscode.Uri,
238+
options: p4.ChangeSpecOptions
239+
): Promise<ChangeSpec> {
240+
if (options.existingChangelist) {
241+
const cl = this.changelists.find(
242+
cl => cl.chnum === options.existingChangelist
243+
);
244+
if (!cl) {
245+
return Promise.reject("No such changelist " + options.existingChangelist);
246+
}
247+
return Promise.resolve<ChangeSpec>({
248+
change: options.existingChangelist,
249+
description: cl.description,
250+
files: cl.files.map(file => {
251+
return {
252+
action: getStatusText(file.operation),
253+
depotPath: file.depotPath
254+
};
255+
}),
256+
rawFields: [{ name: "A field", value: ["don't know"] }]
257+
});
258+
}
259+
const cl = this.changelists.find(cl => cl.chnum === "default");
260+
return Promise.resolve<ChangeSpec>({
261+
description: "<Enter description>",
262+
files: cl?.files.map(file => {
263+
return {
264+
action: getStatusText(file.operation),
265+
depotPath: file.depotPath
266+
};
267+
}),
268+
rawFields: [{ name: "A field", value: ["don't know"] }]
269+
});
270+
}
271+
}

‎src/test/helpers/testUtils.ts

+128
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import * as path from "path";
2+
import * as vscode from "vscode";
3+
import { Utils } from "../../Utils";
4+
import { Status } from "../../scm/Status";
5+
import { StubFile } from "./StubPerforceModel";
6+
7+
function stdout(out: string): [string, string] {
8+
return [out, ""];
9+
}
10+
11+
function stderr(err: string): [string, string] {
12+
return ["", err];
13+
}
14+
15+
export function returnStdOut(out: string) {
16+
return () => stdout(out);
17+
}
18+
19+
export function returnStdErr(err: string) {
20+
return () => stderr(err);
21+
}
22+
23+
export function getStatusText(status: Status): string {
24+
switch (status) {
25+
case Status.ADD:
26+
return "add";
27+
case Status.ARCHIVE:
28+
return "archive";
29+
case Status.BRANCH:
30+
return "branch";
31+
case Status.DELETE:
32+
return "delete";
33+
case Status.EDIT:
34+
return "edit";
35+
case Status.IMPORT:
36+
return "import";
37+
case Status.INTEGRATE:
38+
return "integrate";
39+
case Status.LOCK:
40+
return "lock";
41+
case Status.MOVE_ADD:
42+
return "move/add";
43+
case Status.MOVE_DELETE:
44+
return "move/delete";
45+
case Status.PURGE:
46+
return "purge";
47+
case Status.UNKNOWN:
48+
return "???";
49+
}
50+
}
51+
52+
export function getWorkspaceUri() {
53+
if (!vscode.workspace.workspaceFolders) {
54+
throw new Error("No workspace folders open");
55+
}
56+
return vscode.workspace.workspaceFolders[0].uri;
57+
}
58+
59+
export function getLocalFile(workspace: vscode.Uri, ...relativePath: string[]) {
60+
return vscode.Uri.file(path.resolve(workspace.fsPath, ...relativePath));
61+
}
62+
63+
/**
64+
* Matches against a perforce URI, containing a local file's path
65+
* @param file
66+
*/
67+
export function perforceLocalUriMatcher(file: StubFile) {
68+
if (!file.localFile) {
69+
throw new Error("Can't make a local file matcher without a local file");
70+
}
71+
return Utils.makePerforceDocUri(file.localFile, "print", "-q", {
72+
workspace: getWorkspaceUri().fsPath
73+
}).with({ fragment: file.depotRevision.toString() });
74+
}
75+
76+
/**
77+
* Matches against a perforce URI, using the depot path for a file
78+
* @param file
79+
*/
80+
export function perforceDepotUriMatcher(file: StubFile) {
81+
return Utils.makePerforceDocUri(
82+
vscode.Uri.parse("perforce:" + file.depotPath),
83+
"print",
84+
"-q",
85+
{ depot: true, workspace: getWorkspaceUri().fsPath }
86+
).with({ fragment: file.depotRevision.toString() });
87+
}
88+
89+
/**
90+
* Matches against a perforce URI, using the resolveBaseFile0 depot path
91+
* @param file
92+
*/
93+
export function perforceFromFileUriMatcher(file: StubFile) {
94+
return Utils.makePerforceDocUri(
95+
vscode.Uri.parse("perforce:" + file.resolveFromDepotPath),
96+
"print",
97+
"-q",
98+
{ depot: true, workspace: getWorkspaceUri().fsPath }
99+
).with({ fragment: file.resolveEndFromRev?.toString() });
100+
}
101+
102+
/**
103+
* Matches against a perforce URI, using the depot path for the file AND containing a fragment for the shelved changelist number
104+
* @param file
105+
* @param chnum
106+
*/
107+
export function perforceShelvedUriMatcher(file: StubFile, chnum: string) {
108+
return Utils.makePerforceDocUri(
109+
vscode.Uri.parse("perforce:" + file.depotPath).with({
110+
fragment: "@=" + chnum
111+
}),
112+
"print",
113+
"-q",
114+
{ depot: true, workspace: getWorkspaceUri().fsPath }
115+
);
116+
}
117+
118+
export function perforceLocalShelvedUriMatcher(file: StubFile, chnum: string) {
119+
if (!file.localFile) {
120+
throw new Error("Can't make a local file matcher without a local file");
121+
}
122+
return Utils.makePerforceDocUri(
123+
file.localFile.with({ fragment: "@=" + chnum }),
124+
"print",
125+
"-q",
126+
{ workspace: getWorkspaceUri().fsPath }
127+
);
128+
}

‎src/test/suite/StubPerforceService.ts

-624
This file was deleted.

‎src/test/suite/index.ts

+6
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ export function run(): Promise<void> {
88
ui: "bdd"
99
});
1010
mocha.useColors(true);
11+
mocha.reporter("cypress-multi-reporters", {
12+
reporterEnabled: "mocha-junit-reporter, spec",
13+
mochaJunitReporterReporterOptions: {
14+
mochaFile: "./reports/junit-integration.xml"
15+
}
16+
});
1117

1218
const testsRoot = path.resolve(__dirname, "..");
1319

‎src/test/suite/model.test.ts

+588-352
Large diffs are not rendered by default.

‎src/test/suite/perforceApi.test.ts

+707
Large diffs are not rendered by default.

‎src/test/suite/perforceCommands.test.ts

+3-4
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,13 @@ import * as sinonChai from "sinon-chai";
66
import * as vscode from "vscode";
77

88
import * as sinon from "sinon";
9-
import { StubPerforceService, getLocalFile } from "./StubPerforceService";
9+
import { stubExecute } from "../helpers/StubPerforceModel";
1010
import p4Commands from "../helpers/p4Commands";
1111
import { PerforceCommands } from "../../PerforceCommands";
1212
import { Utils } from "../../Utils";
1313
import { PerforceContentProvider } from "../../ContentProvider";
1414
import { Display } from "../../Display";
15+
import { getLocalFile } from "../helpers/testUtils";
1516

1617
chai.use(sinonChai);
1718
chai.use(p4Commands);
@@ -22,7 +23,6 @@ describe("Perforce Command Module (integration)", () => {
2223
throw new Error("No workspace folders open");
2324
}
2425
const workspaceUri = vscode.workspace.workspaceFolders[0].uri;
25-
let stubService: StubPerforceService;
2626
let execCommand: sinon.SinonSpy<[string, ...any[]], Thenable<unknown>>;
2727
const subscriptions: vscode.Disposable[] = [];
2828

@@ -37,8 +37,7 @@ describe("Perforce Command Module (integration)", () => {
3737

3838
beforeEach(() => {
3939
Display.initialize(subscriptions);
40-
stubService = new StubPerforceService();
41-
stubService.stubExecute();
40+
stubExecute();
4241
execCommand = sinon.spy(vscode.commands, "executeCommand");
4342
});
4443
afterEach(() => {

‎src/test/suite/unit/index.ts

+6
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ export function run(): Promise<void> {
77
const mocha = new Mocha({
88
ui: "bdd"
99
});
10+
mocha.reporter("cypress-multi-reporters", {
11+
reporterEnabled: "mocha-junit-reporter, spec",
12+
mochaJunitReporterReporterOptions: {
13+
mochaFile: "./reports/junit-unit.xml"
14+
}
15+
});
1016
mocha.useColors(true);
1117

1218
const testsRoot = __dirname;

‎tsconfig.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"module": "commonjs",
44
"target": "es6",
55
"lib": [
6-
"es6"
6+
"es2019"
77
],
88
"outDir": "out",
99
"sourceMap": true,

0 commit comments

Comments
 (0)
Please sign in to comment.