Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions src/commands/playground/newPlayground.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import type * as vscode from 'vscode';
import { PLAYGROUND_LANGUAGE_ID } from '../../documentdb/playground/constants';
import { createPlaygroundFileName } from './newPlayground';

function playgroundDocument(fileName: string): vscode.TextDocument {
return {
fileName,
languageId: PLAYGROUND_LANGUAGE_ID,
uri: {
fsPath: fileName,
path: fileName,
},
} as vscode.TextDocument;
}

function javascriptDocument(fileName: string): vscode.TextDocument {
return {
fileName,
languageId: 'javascript',
uri: {
fsPath: fileName,
path: fileName,
},
} as vscode.TextDocument;
}

describe('createPlaygroundFileName', () => {
it('uses cluster and collection context when provided', () => {
const fileName = createPlaygroundFileName([], {
clusterDisplayName: 'my-cluster',
databaseOrCollectionName: 'users',
});

expect(fileName).toBe('my-cluster_users.documentdb.js');
});

it('sanitizes invalid filename characters and whitespace', () => {
const fileName = createPlaygroundFileName([], {
clusterDisplayName: 'prod/eu:1',
databaseOrCollectionName: 'orders 2026?',
});

expect(fileName).toBe('prod-eu-1_orders-2026.documentdb.js');
});

it('adds a numeric suffix when the contextual filename is already open', () => {
const fileName = createPlaygroundFileName(
[
playgroundDocument('C:\\workspace\\my-cluster_users.documentdb.js'),
playgroundDocument('C:\\workspace\\my-cluster_users-2.documentdb.js'),
javascriptDocument('C:\\workspace\\my-cluster_users-3.documentdb.js'),
],
{
clusterDisplayName: 'my-cluster',
databaseOrCollectionName: 'users',
},
);

// Dedup checks ALL open documents (including non-playground) because VS Code rejects
// applyEdit on an untitled URI that already exists, regardless of language.
expect(fileName).toBe('my-cluster_users-4.documentdb.js');
});

it('falls back to generic numbering when context is missing', () => {
const fileName = createPlaygroundFileName([
playgroundDocument('C:\\workspace\\playground-1.documentdb.js'),
playgroundDocument('C:\\workspace\\my-cluster_users.documentdb.js'),
]);

expect(fileName).toBe('playground-2.documentdb.js');
});

it('skips taken slots in the generic fallback (counter cannot collide)', () => {
const fileName = createPlaygroundFileName([
playgroundDocument('C:\\workspace\\playground-1.documentdb.js'),
playgroundDocument('C:\\workspace\\playground-3.documentdb.js'),
]);

// playground-2 is free even though two playgrounds are open — old logic would have
// produced playground-3 (a collision).
expect(fileName).toBe('playground-2.documentdb.js');
});

it('falls back to generic numbering when context sanitizes to empty', () => {
const fileName = createPlaygroundFileName([playgroundDocument('C:\\workspace\\playground-1.documentdb.js')], {
clusterDisplayName: '///',
databaseOrCollectionName: '***',
});

expect(fileName).toBe('playground-2.documentdb.js');
});
});
103 changes: 91 additions & 12 deletions src/commands/playground/newPlayground.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,17 @@ import * as path from 'path';
import * as vscode from 'vscode';
import { modifierKey } from '../../constants';
import { PlaygroundService } from '../../documentdb/playground/PlaygroundService';
import { PLAYGROUND_FILE_EXTENSION, PLAYGROUND_LANGUAGE_ID } from '../../documentdb/playground/constants';
import { PLAYGROUND_FILE_EXTENSION } from '../../documentdb/playground/constants';
import { type PlaygroundConnection } from '../../documentdb/playground/types';
import { type CollectionItem } from '../../tree/documentdb/CollectionItem';
import { type DatabaseItem } from '../../tree/documentdb/DatabaseItem';
import { escapeJsString } from '../../utils/escapeJsString';

interface PlaygroundFileNameContext {
readonly clusterDisplayName: string;
readonly databaseOrCollectionName: string;
}

/**
* Creates a new Query Playground file.
*
Expand Down Expand Up @@ -52,12 +57,16 @@ export async function newPlayground(context: IActionContext, node?: DatabaseItem
'',
].join('\n');

await createPlaygroundWithContent(template, {
clusterId: node.cluster.clusterId,
clusterDisplayName: node.cluster.name,
databaseName: node.databaseInfo.name,
viewId: node.cluster.viewId,
});
await createPlaygroundWithContent(
template,
{
clusterId: node.cluster.clusterId,
clusterDisplayName: node.cluster.name,
databaseName: node.databaseInfo.name,
viewId: node.cluster.viewId,
},
isCollectionItem(node) ? node.collectionInfo.name : undefined,
);
}

/**
Expand Down Expand Up @@ -111,16 +120,20 @@ export async function newPlaygroundWithContent(
/**
* Shared logic for creating an untitled playground document and binding its connection.
*/
async function createPlaygroundWithContent(content: string, connection: PlaygroundConnection): Promise<void> {
async function createPlaygroundWithContent(
content: string,
connection: PlaygroundConnection,
collectionName?: string,
): Promise<void> {
const service = PlaygroundService.getInstance();

// Create untitled file with a workspace-relative path so VS Code's hot exit
// can persist the content across restarts. Without a real-looking path,
// untitled documents lose their content on relaunch.
const numberUntitledPlaygrounds = vscode.workspace.textDocuments.filter(
(doc) => doc.languageId === PLAYGROUND_LANGUAGE_ID,
).length;
const fileName = `playground-${numberUntitledPlaygrounds + 1}${PLAYGROUND_FILE_EXTENSION}`;
const fileName = createPlaygroundFileName(vscode.workspace.textDocuments, {
clusterDisplayName: connection.clusterDisplayName,
databaseOrCollectionName: collectionName ?? connection.databaseName,
});
const folderPath = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? os.tmpdir();
const filePath = path.join(folderPath, fileName);
const uri = vscode.Uri.file(filePath).with({ scheme: 'untitled' });
Expand All @@ -137,3 +150,69 @@ async function createPlaygroundWithContent(content: string, connection: Playgrou
function isCollectionItem(node: DatabaseItem | CollectionItem | undefined): node is CollectionItem {
return node !== undefined && 'collectionInfo' in node;
}

export function createPlaygroundFileName(
textDocuments: readonly vscode.TextDocument[],
context?: PlaygroundFileNameContext,
): string {
// Dedup against ALL open documents (not just playgrounds): VS Code rejects
// applyEdit on an untitled URI that already exists, regardless of language.
const existingFileNames = new Set(textDocuments.map(getTextDocumentFileName).filter(Boolean));

const contextualBaseName = context
? [sanitizeFileNamePart(context.clusterDisplayName), sanitizeFileNamePart(context.databaseOrCollectionName)]
.filter(Boolean)
.join('_')
: '';

if (contextualBaseName) {
return findFreeFileName(contextualBaseName, existingFileNames, { startSuffix: 2, suffixFirst: false });
}

return findFreeFileName('playground', existingFileNames, { startSuffix: 1, suffixFirst: true });
}

/**
* Returns the first `${base}${ext}` (or `${base}-${N}${ext}`) name not present in `existing`.
* When `suffixFirst` is true, always starts with a numeric suffix (e.g. `playground-1`).
*/
function findFreeFileName(
base: string,
existing: ReadonlySet<string>,
options: { startSuffix: number; suffixFirst: boolean },
): string {
if (!options.suffixFirst) {
const unsuffixed = `${base}${PLAYGROUND_FILE_EXTENSION}`;
if (!existing.has(unsuffixed)) {
return unsuffixed;
}
}

let suffix = options.startSuffix;
// Loop is bounded by the number of open documents + 1, so termination is guaranteed.
// eslint-disable-next-line no-constant-condition
while (true) {
const candidate = `${base}-${suffix}${PLAYGROUND_FILE_EXTENSION}`;
if (!existing.has(candidate)) {
return candidate;
}
suffix += 1;
}
}

function sanitizeFileNamePart(value: string): string {
return replaceControlCharacters(value)
.replace(/[<>:"/\\|?*]/g, '-')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
}

function replaceControlCharacters(value: string): string {
return [...value].map((char) => (char.charCodeAt(0) < 32 ? '-' : char)).join('');
}

function getTextDocumentFileName(doc: vscode.TextDocument): string {
const fileName = doc.uri.fsPath || doc.fileName || doc.uri.path;
return path.basename(fileName.replace(/\\/g, '/'));
}