Repository: microsoft/vscode (TypeScript)
Date: 2022-12-07 08:02:23+00:00
Task type: Bug Fix

Issue:
Unexpected filenames proposed when saving untitled files
<!-- ⚠️⚠️ Do Not Delete This! bug_report_template ⚠️⚠️ -->
<!-- Please read our Rules of Conduct: https://opensource.microsoft.com/codeofconduct/ -->
<!-- 🕮 Read our guide about submitting issues: https://github.com/microsoft/vscode/wiki/Submitting-Bugs-and-Suggestions -->
<!-- 🔎 Search existing issues to avoid creating duplicates. -->
<!-- 🧪 Test using the latest Insiders build to see if your issue has already been fixed: https://code.visualstudio.com/insiders/ -->
<!-- 💡 Instead of creating your report here, use 'Report Issue' from the 'Help' menu in VS Code to pre-fill useful information. -->
<!-- 🔧 Launch with `code --disable-extensions` to check. -->
Does this issue occur when all extensions are disabled?: Yes/No

<!-- 🪓 If you answered No above, use 'Help: Start Extension Bisect' from Command Palette to try to identify the cause. -->
<!-- 📣 Issues caused by an extension need to be reported directly to the extension publisher. The 'Help > Report Issue' dialog can assist with this. -->
- VS Code Version: 1.67.2
- OS Version: Windows 10

TL/DR: When saving untitled documents that have name with extension, the proposed file name is not the same and can be completely different:
`FooBar.component.yml` -> `.condarc.txt` (I do not have a file with that name ANYWHERE on my PC. I have `.condarc` somewhere though)
`FooBar.component.yaml` -> `FooBar.component.yaml.yml`

Steps to Reproduce:

Issue 1:

1. Create extension command that executes `vscode.commands.executeCommand('vscode.openWith',         vscode.Uri.parse("untitled:FooBar.component.yaml"), "default" );`
2. Debug the extension and activate the command to get a new untitled file tab
3. Try to save the file. The proposed filename is `FooBar.component.yaml.yml` despite the original Untitled file name already ending with `.yaml` which is a valid extension for YAML files

Issue 2:

1. Create extension command that executes `vscode.commands.executeCommand('vscode.openWith',         vscode.Uri.parse("untitled:FooBar.component.yml"), "default" );`
2. Debug the extension and activate the command to get a new untitled file tab
3. Try to save the file. The proposed filename is `.condarc.txt`, which is unexpected and very surprising. I do not have a file with that name **anywhere** on my PC. I have `.condarc` somewhere though. Note that the VSCode-added extension is `.txt` here despite VSCode detecting the file format as YAML based on contents.

I expect that when saving the file VSCode proposes my given filename and extension to the user.
I expect that when saving the file VSCode does not propose completely unrelated file names like `.condarc.txt`.



Issue discussion:
Thanks for creating this issue! It looks like you may be using an old version of VS Code, the latest stable release is 1.67.2. Please try upgrading to the latest version and checking whether this issue remains.

Happy Coding!
Here is how to open an untitled file with associated file path:

```ts
const doc = await vscode.workspace.openTextDocument(vscode.Uri.file('/Users/bpasero/Desktop/test-ts/src/FooBar.component.yaml').with({ scheme: 'untitled' }));
vscode.window.showTextDocument(doc);
```

As for being able to suggest an extension, here is the related issue: https://github.com/microsoft/vscode/issues/117985
There are certain cases where the proposed solution might not work.

The solution requires an absolute file path, but it's not always available. For example, with VSCode Web the workspace might not contain any directories.

@bpasero I've tested your suggestion and it does not seem to work.

The following code still proposes the file name with double extension: `pipeline.component.yaml.yml` 

```ts
        vscode.commands.executeCommand(
          "vscode.openWith",
          vscode.Uri.file("/Users/bpasero/Desktop/test-ts/src/pipeline.component.yaml").with({ scheme: 'untitled' }),
          "cloudPipelines.pipelineEditor"
        );
```

```ts
vscode.Uri.joinPath(vscode.workspace.workspaceFolders?.[0].uri || vscode.Uri.parse(""), "pipeline.component.yaml").with({ scheme: 'untitled' })
```
does not work either.

This bug really breaks the CustomEditor extensions.

The saved file is no longer associated with the custom editor due to the mangled name/extension.

More so, the Custom editor immediately closes upon saving the file.

So the flow becomes:

1. File/New File.../ Pipeline
2. Work on building a pipeline in the custom editor.
3. Save -> VScode prompts for file name suggesting `pipeline.component.yaml`. The user clicks OK
4. VSCode immediately closes the custom editor and reopens the file using the text editor (since the file name no longer matches the extension)
Moving to custom editors team for this case.
I think part of this issue is triggered by language detection.

I have a hypothesis that the extra `.yml` is added/triggered by the YAML language detector.

When the file contains binary data the issue does not seem to be triggered. (I've tested on the modified PawDraw extension modified to propose the `new-XXX.pawdraw.yaml` file name)
I've just verified the hypothesis.

I've modified the CatScratch editor to use the `*.cscratch.yaml` extension and added "New Cat Scratch Document" command based on PawDraw. The issue started betting triggered (proposed `new-37.catscratch.yaml.yml` file name).

So the issue is triggered when the document contains valid YAML data (with includes empty file), but does not trigger when the document contains invalid YAML (e.g. binary data).
This also might be related to the "Duplicate editor tab" bug I've previously opened: https://github.com/microsoft/vscode/issues/150257

I've recorded the video that shows both bugs:

https://user-images.githubusercontent.com/1829149/178197117-3b510db9-a5c3-472c-8775-bb34c8f218f2.mp4

See how with first click on "Scratch" another tab opens (with text editor).

When saving the custom editor tab, the tab closes (due to the bug we're currently discussing: https://github.com/microsoft/vscode/issues/150164).
Repro branch: https://github.com/Ark-kun/microsoft_vscode-extension-samples/commit/9c23def60be33e29a1f24e0c6e0598bd05eca57f
@bpasero added me because Ark-kun mentioned language detection... and I own the ML language detection but this has nothing to do with that from what I can tell... it also is easily reproducible without custom editors as the original issue describes.

I added this to an barebones extension:

```ts
vscode.commands.executeCommand(
		"vscode.openWith",
		vscode.Uri.joinPath(
			vscode.workspace.workspaceFolders?.[0].uri || vscode.Uri.parse(""),
			"pipeline.component.yaml")
			.with({ scheme: 'untitled' }),
		"cloudPipelines.pipelineEditor"
	);
```
> notice the `yaml` at the end instead of `yml`

With `yaml` the behavior I get when I save the file is:
![image](https://user-images.githubusercontent.com/2644648/206000787-e4023e0f-9be8-43e2-a90b-356ca03fed69.png)

Now if I change it to `pipeline.component.yml` I get:
![image](https://user-images.githubusercontent.com/2644648/206000978-f2a262b7-fd8d-4e72-96af-f56242a8a049.png)

I have no idea what `.condarc` is suppose to be.

Trying a different file type `.ps1` (which is a language we ship a grammar for) I notice that this does in fact work as expected:
![image](https://user-images.githubusercontent.com/2644648/206001379-0bbe43dd-255e-4fbe-9094-c23c69492bdd.png)

**So with this said, my suspicion is that something is up with YAML**... I noticed I had the Python extension installed so I disabled it and well well well:
![image](https://user-images.githubusercontent.com/2644648/206002089-c7c981d5-ed63-4e79-9718-a70563bf1e49.png)

So 🐛  one that needs to go to the Python extension: "don't clobber the YAML file association". Looks like there are several instances of that in the extension:
https://github.com/microsoft/vscode-python/blob/main/package.json#L1496-L1523

As for why `"pipeline.component.yaml"` becomes `"pipeline.component.yaml.yml"` I'm not totally sure... but a discovery I made was with this list:
https://github.com/microsoft/vscode/blob/fa0e000e1d24f3b64117da6371200aee801e7243/extensions/yaml/package.json#L38-L44
in the built-in YAML extension (which just provides syntax highlighting). `.yml` is at the top of the list. If I move `.yaml` to the top of this list, suddenly, this works:
![image](https://user-images.githubusercontent.com/2644648/206003106-be2595a1-b16a-464f-b5be-d4b684650478.png)

So 🐛  two is "all possible extensions should be taken into consideration". @bpasero I think you own this? What do you think?

Solution patch:
diff --git a/src/vs/workbench/services/textfile/browser/textFileService.ts b/src/vs/workbench/services/textfile/browser/textFileService.ts
--- a/src/vs/workbench/services/textfile/browser/textFileService.ts
+++ b/src/vs/workbench/services/textfile/browser/textFileService.ts
@@ -10,6 +10,7 @@ import { IRevertOptions, SaveSourceRegistry } from 'vs/workbench/common/editor';
 import { ILifecycleService } from 'vs/workbench/services/lifecycle/common/lifecycle';
 import { IFileService, FileOperationError, FileOperationResult, IFileStatWithMetadata, ICreateFileOptions, IFileStreamContent } from 'vs/platform/files/common/files';
 import { Disposable } from 'vs/base/common/lifecycle';
+import { extname as pathExtname } from 'vs/base/common/path';
 import { IWorkbenchEnvironmentService } from 'vs/workbench/services/environment/common/environmentService';
 import { IUntitledTextEditorService, IUntitledTextEditorModelManager } from 'vs/workbench/services/untitled/common/untitledTextEditorService';
 import { UntitledTextEditorModel } from 'vs/workbench/services/untitled/common/untitledTextEditorModel';
@@ -43,6 +44,7 @@ import { Emitter } from 'vs/base/common/event';
 import { Codicon } from 'vs/base/common/codicons';
 import { listErrorForeground } from 'vs/platform/theme/common/colorRegistry';
 import { withNullAsUndefined } from 'vs/base/common/types';
+import { firstOrDefault } from 'vs/base/common/arrays';
 
 /**
  * The workbench file service implementation implements the raw file service spec and adds additional methods on top.
@@ -576,19 +578,16 @@ export abstract class AbstractTextFileService extends Disposable implements ITex
 				}
 
 				// Untitled without associated file path: use name
-				// of untitled model if it is a valid path name,
-				// otherwise fallback to `basename`.
-				let untitledName = model.name;
-				if (!(await this.pathService.hasValidBasename(joinPath(defaultFilePath, untitledName), untitledName))) {
-					untitledName = basename(resource);
-				}
-
-				// Add language file extension if specified
-				const languageId = model.getLanguageId();
-				if (languageId && languageId !== PLAINTEXT_LANGUAGE_ID) {
-					suggestedFilename = this.suggestFilename(languageId, untitledName);
-				} else {
-					suggestedFilename = untitledName;
+				// of untitled model if it is a valid path name and
+				// figure out the file extension from the mode if any.
+
+				if (await this.pathService.hasValidBasename(joinPath(defaultFilePath, model.name), model.name)) {
+					const languageId = model.getLanguageId();
+					if (languageId && languageId !== PLAINTEXT_LANGUAGE_ID) {
+						suggestedFilename = this.suggestFilename(languageId, model.name);
+					} else {
+						suggestedFilename = model.name;
+					}
 				}
 			}
 		}
@@ -606,18 +605,31 @@ export abstract class AbstractTextFileService extends Disposable implements ITex
 	suggestFilename(languageId: string, untitledName: string) {
 		const languageName = this.languageService.getLanguageName(languageId);
 		if (!languageName) {
-			return untitledName;
+			return untitledName; // unknown language, so we cannot suggest a better name
 		}
 
-		const extension = this.languageService.getExtensions(languageId)[0];
-		if (extension) {
-			if (!untitledName.endsWith(extension)) {
-				return untitledName + extension;
+		const untitledExtension = pathExtname(untitledName);
+
+		const extensions = this.languageService.getExtensions(languageId);
+		if (extensions.includes(untitledExtension)) {
+			return untitledName; // preserve extension if it is compatible with the mode
+		}
+
+		const primaryExtension = firstOrDefault(extensions);
+		if (primaryExtension) {
+			if (untitledExtension) {
+				return `${untitledName.substring(0, untitledName.indexOf(untitledExtension))}${primaryExtension}`;
 			}
+
+			return `${untitledName}${primaryExtension}`;
+		}
+
+		const filenames = this.languageService.getFilenames(languageId);
+		if (filenames.includes(untitledName)) {
+			return untitledName; // preserve name if it is compatible with the mode
 		}
 
-		const filename = this.languageService.getFilenames(languageId)[0];
-		return filename || untitledName;
+		return firstOrDefault(filenames) ?? untitledName;
 	}
 
 	//#endregion


Test patch:
diff --git a/src/vs/workbench/services/textfile/test/browser/textFileService.test.ts b/src/vs/workbench/services/textfile/test/browser/textFileService.test.ts
--- a/src/vs/workbench/services/textfile/test/browser/textFileService.test.ts
+++ b/src/vs/workbench/services/textfile/test/browser/textFileService.test.ts
@@ -160,6 +160,28 @@ suite('Files - TextFileService', () => {
 		registration.dispose();
 	});
 
+	test('Filename Suggestion - Preserve extension if it matchers', () => {
+		const registration = accessor.languageService.registerLanguage({
+			id: 'plumbus2',
+			extensions: ['.shleem', '.gazorpazorp'],
+		});
+
+		const suggested = accessor.textFileService.suggestFilename('plumbus2', 'Untitled-1.gazorpazorp');
+		assert.strictEqual(suggested, 'Untitled-1.gazorpazorp');
+		registration.dispose();
+	});
+
+	test('Filename Suggestion - Rewrite extension according to language', () => {
+		const registration = accessor.languageService.registerLanguage({
+			id: 'plumbus2',
+			extensions: ['.shleem', '.gazorpazorp'],
+		});
+
+		const suggested = accessor.textFileService.suggestFilename('plumbus2', 'Untitled-1.foobar');
+		assert.strictEqual(suggested, 'Untitled-1.shleem');
+		registration.dispose();
+	});
+
 	test('Filename Suggestion - Suggest filename if there are no extensions', () => {
 		const registration = accessor.languageService.registerLanguage({
 			id: 'plumbus2',
@@ -170,4 +192,26 @@ suite('Files - TextFileService', () => {
 		assert.strictEqual(suggested, 'plumbus');
 		registration.dispose();
 	});
+
+	test('Filename Suggestion - Preserve filename if it matches', () => {
+		const registration = accessor.languageService.registerLanguage({
+			id: 'plumbus2',
+			filenames: ['plumbus', 'shleem', 'gazorpazorp']
+		});
+
+		const suggested = accessor.textFileService.suggestFilename('plumbus2', 'gazorpazorp');
+		assert.strictEqual(suggested, 'gazorpazorp');
+		registration.dispose();
+	});
+
+	test('Filename Suggestion - Rewrites filename according to language', () => {
+		const registration = accessor.languageService.registerLanguage({
+			id: 'plumbus2',
+			filenames: ['plumbus', 'shleem', 'gazorpazorp']
+		});
+
+		const suggested = accessor.textFileService.suggestFilename('plumbus2', 'foobar');
+		assert.strictEqual(suggested, 'plumbus');
+		registration.dispose();
+	});
 });