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
9 changes: 9 additions & 0 deletions l10n/bundle.l10n.json
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@
"{0} file(s) were ignored because they do not match the \"*.json\" pattern.": "{0} file(s) were ignored because they do not match the \"*.json\" pattern.",
"{0} inserted": "{0} inserted",
"{0} item(s) already exist in the destination. Check the Output panel for details.": "{0} item(s) already exist in the destination. Check the Output panel for details.",
"{0} lines detected in pasted text": "{0} lines detected in pasted text",
"{0} more actions": "{0} more actions",
"{0} processed": "{0} processed",
"{0} replaced": "{0} replaced",
Expand Down Expand Up @@ -264,6 +265,7 @@
"Collection: \"{targetCollectionName}\" {annotation}": "Collection: \"{targetCollectionName}\" {annotation}",
"Configure Azure Discovery Filters": "Configure Azure Discovery Filters",
"Configure Azure VM Discovery Filters": "Configure Azure VM Discovery Filters",
"Configure in Settings": "Configure in Settings",
"Configure Subscription Filter": "Configure Subscription Filter",
"Configure Tenant & Subscription Filters": "Configure Tenant & Subscription Filters",
"Configure TLS/SSL Security": "Configure TLS/SSL Security",
Expand Down Expand Up @@ -362,6 +364,7 @@
"detailed execution analysis": "detailed execution analysis",
"Disable TLS/SSL (Not recommended)": "Disable TLS/SSL (Not recommended)",
"Disable TLS/SSL checks when connecting.": "Disable TLS/SSL checks when connecting.",
"Discard the pasted input.": "Discard the pasted input.",
"Discovery plugin error: clusterId \"{0}\" must start with provider ID \"{1}\". Plugin \"{2}\" must prefix clusterId with its provider ID.": "Discovery plugin error: clusterId \"{0}\" must start with provider ID \"{1}\". Plugin \"{2}\" must prefix clusterId with its provider ID.",
"Do not rely on case to distinguish between databases. For example, you cannot use two databases with names like, salesData and SalesData.": "Do not rely on case to distinguish between databases. For example, you cannot use two databases with names like, salesData and SalesData.",
"Do not save credentials.": "Do not save credentials.",
Expand Down Expand Up @@ -394,6 +397,7 @@
"Duplicate key error. {0}": "Duplicate key error. {0}",
"e.g., 12345678-1234-1234-1234-123456789012 or 12345678123412341234123456789012": "e.g., 12345678-1234-1234-1234-123456789012 or 12345678123412341234123456789012",
"e.g., DocumentDB, Environment, Project": "e.g., DocumentDB, Environment, Project",
"Each line will be run independently.": "Each line will be run independently.",
"Edit selected document": "Edit selected document",
"Element with id of {rootId} not found.": "Element with id of {rootId} not found.",
"empty": "empty",
Expand Down Expand Up @@ -440,6 +444,7 @@
"Errors found in file \"{path}\". Please fix these:": "Errors found in file \"{path}\". Please fix these:",
"Examined-to-Returned Ratio": "Examined-to-Returned Ratio",
"Excellent": "Excellent",
"Execute as One": "Execute as One",
"Execute the find query": "Execute the find query",
"Executed in {0}ms": "Executed in {0}ms",
"Executing explain(aggregate) for collection: {collection}, pipeline stages: {stageCount}": "Executing explain(aggregate) for collection: {collection}, pipeline stages: {stageCount}",
Expand Down Expand Up @@ -563,6 +568,7 @@
"Host": "Host",
"How do you want to connect?": "How do you want to connect?",
"How should conflicts be handled during the copy operation?": "How should conflicts be handled during the copy operation?",
"How to process your multi-line text?": "How to process your multi-line text?",
"How would you rate Query Insights?": "How would you rate Query Insights?",
"I have read and agree to the ": "I have read and agree to the ",
"I like it": "I like it",
Expand Down Expand Up @@ -669,6 +675,7 @@
"Level up": "Level up",
"Limit": "Limit",
"Limit Returned Fields": "Limit Returned Fields",
"Lines will be joined into a single expression and executed.": "Lines will be joined into a single expression and executed.",
"Load More...": "Load More...",
"Loaded {0} document(s) from \"{1}\"": "Loaded {0} document(s) from \"{1}\"",
"Loading \"{0}\"...": "Loading \"{0}\"...",
Expand Down Expand Up @@ -770,6 +777,7 @@
"Open in Playground": "Open in Playground",
"Open in Shell": "Open in Shell",
"Open setting: {0}": "Open setting: {0}",
"Open settings to change the default behavior.": "Open settings to change the default behavior.",
"Open the VS Code Marketplace to learn more about \"{0}\"": "Open the VS Code Marketplace to learn more about \"{0}\"",
"Open this query in Collection View": "Open this query in Collection View",
"Open this query in Interactive Shell": "Open this query in Interactive Shell",
Expand Down Expand Up @@ -877,6 +885,7 @@
"Role Assignment {0} failed for {1}": "Role Assignment {0} failed for {1}",
"Run": "Run",
"Run All": "Run All",
"Run as Is": "Run as Is",
"Run the entire file ({0}+Shift+Enter)": "Run the entire file ({0}+Shift+Enter)",
"Run this block ({0}+Enter)": "Run this block ({0}+Enter)",
"Running query…": "Running query…",
Expand Down
17 changes: 17 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -1160,6 +1160,23 @@
"description": "Enable autocompletion in the Interactive Shell (when available). Reserved for future use.",
"default": true
},
"documentDB.shell.multiLinePasteBehavior": {
"type": "string",
"description": "Controls how multi-line text is handled when pasted into the Interactive Shell. When set to 'ask', the prompt is only shown if VS Code's built-in paste warning (terminal.integrated.enableMultiLinePasteWarning) is disabled.",
"default": "ask",
"enum": [
"ask",
"alwaysAsk",
"executeAsOne",
"runLineByLine"
],
"enumDescriptions": [
"Show a prompt asking how to process the pasted text. Skipped when VS Code's own multi-line paste warning is enabled, to avoid a double prompt.",
"Always show the prompt, even if VS Code's own multi-line paste warning is also enabled.",
"Always join pasted lines into a single expression and execute.",
"Always run each pasted line independently."
]
},
"documentDB.collectionView.defaultPageSize": {
"type": "number",
"description": "Default page size for loading data in the collection view.",
Expand Down
5 changes: 5 additions & 0 deletions src/__mocks__/vscode.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ vsCodeMock.l10n = {
}),
};

// QuickPickItemKind enum (not provided by jest-mock-vscode)
if (!vsCodeMock.QuickPickItemKind) {
vsCodeMock.QuickPickItemKind = { Separator: -1, Default: 0 };
}

// CancellationTokenSource mock for AzureWizard
vsCodeMock.CancellationTokenSource = class CancellationTokenSource {
constructor() {
Expand Down
220 changes: 209 additions & 11 deletions src/documentdb/shell/DocumentDBShellPty.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,17 +54,24 @@ describe('DocumentDBShellPty', () => {
terminalName = undefined;

// Mock settings
jest.spyOn(vscode.workspace, 'getConfiguration').mockReturnValue({
get: jest.fn((_key: string, defaultValue?: unknown) => {
if (_key === 'documentDB.shell.display.colorOutput') {
return false; // Disable colors for easier test assertions
}
if (_key === 'documentDB.timeout') {
return 120;
}
return defaultValue;
}),
} as unknown as vscode.WorkspaceConfiguration);
jest.spyOn(vscode.workspace, 'getConfiguration').mockImplementation((section?: string) => {
return {
get: jest.fn((_key: string, defaultValue?: unknown) => {
if (section === undefined || section === '') {
if (_key === 'documentDB.shell.display.colorOutput') {
return false; // Disable colors for easier test assertions
}
if (_key === 'documentDB.timeout') {
return 120;
}
}
if (section === 'documentDB.shell' && _key === 'multiLinePasteBehavior') {
return 'runLineByLine'; // Default to line-by-line in tests for backward compat
}
return defaultValue;
}),
} as unknown as vscode.WorkspaceConfiguration;
});

pty = new DocumentDBShellPty(defaultOptions);

Expand Down Expand Up @@ -579,4 +586,195 @@ describe('DocumentDBShellPty', () => {
expect(mockEvaluate).toHaveBeenNthCalledWith(2, 'use newdb', expect.any(Number));
});
});

describe('multi-line paste dialog', () => {
/**
* Override paste-related settings while preserving other mocked settings.
* @param behavior - value for documentDB.shell.multiLinePasteBehavior
* @param vscodePasteWarning - value for terminal.integrated.enableMultiLinePasteWarning (default: 'never')
*/
function mockPasteBehavior(behavior: string, vscodePasteWarning: string = 'never'): void {
jest.spyOn(vscode.workspace, 'getConfiguration').mockImplementation((section?: string) => {
return {
get: jest.fn((_key: string, defaultValue?: unknown) => {
if (section === 'documentDB.shell' && _key === 'multiLinePasteBehavior') {
return behavior;
}
if (section === 'terminal.integrated' && _key === 'enableMultiLinePasteWarning') {
return vscodePasteWarning;
}
// Preserve base settings needed by the PTY
if (section === undefined || section === '') {
if (_key === 'documentDB.shell.display.colorOutput') {
return false;
}
if (_key === 'documentDB.timeout') {
return 120;
}
}
return defaultValue;
Comment thread
tnaum-ms marked this conversation as resolved.
}),
} as unknown as vscode.WorkspaceConfiguration;
});
}

beforeEach(async () => {
pty.open({ columns: 80, rows: 24 });
await new Promise((resolve) => setTimeout(resolve, 10));
written = '';
});

it('should show QuickPick when behavior is "ask" and multi-line paste detected', async () => {
mockPasteBehavior('ask');

const showQuickPickSpy = jest
.spyOn(vscode.window, 'showQuickPick')
.mockResolvedValue({ label: 'Cancel', detail: '', id: 'cancel' } as never);

pty.handleInput('line1\nline2\n');

await new Promise((resolve) => setTimeout(resolve, 10));
expect(showQuickPickSpy).toHaveBeenCalledTimes(1);

showQuickPickSpy.mockRestore();
});

it('should join and execute when "Execute as One" is chosen', async () => {
mockPasteBehavior('ask');

mockEvaluate.mockResolvedValue({
type: null,
printable: '"result"',
durationMs: 1,
});

const showQuickPickSpy = jest
.spyOn(vscode.window, 'showQuickPick')
.mockResolvedValue({ label: 'Execute as One', detail: '', id: 'join' } as never);

pty.handleInput('db.restaurants\n .find({})\n .limit(5);\n');

await new Promise((resolve) => setTimeout(resolve, 100));

// Lines starting with . should be joined directly (no space)
expect(mockEvaluate).toHaveBeenCalledWith('db.restaurants.find({}).limit(5);', expect.any(Number));

showQuickPickSpy.mockRestore();
});

it('should join continuation lines with space when they do not start with .', async () => {
mockPasteBehavior('executeAsOne');

mockEvaluate.mockResolvedValue({
type: null,
printable: '"result"',
durationMs: 1,
});

pty.handleInput('var x =\n 42;\n');

await new Promise((resolve) => setTimeout(resolve, 50));

expect(mockEvaluate).toHaveBeenCalledWith('var x = 42;', expect.any(Number));
});

it('should run line by line when behavior is "runLineByLine"', async () => {
// Already the default in tests — just verify
mockEvaluate
.mockResolvedValueOnce({ type: null, printable: '"r1"', durationMs: 1 })
.mockResolvedValueOnce({ type: null, printable: '"r2"', durationMs: 1 });

pty.handleInput('show dbs\nuse mydb\n');

await new Promise((resolve) => setTimeout(resolve, 50));

expect(mockEvaluate).toHaveBeenCalledTimes(2);
});

it('should discard input when dialog is cancelled', async () => {
mockPasteBehavior('ask');

const showQuickPickSpy = jest.spyOn(vscode.window, 'showQuickPick').mockResolvedValue(undefined);

pty.handleInput('line1\nline2\n');

await new Promise((resolve) => setTimeout(resolve, 50));

expect(mockEvaluate).not.toHaveBeenCalled();

showQuickPickSpy.mockRestore();
});

it('should not show dialog for single-line paste', async () => {
mockPasteBehavior('ask');

mockEvaluate.mockResolvedValue({ type: null, printable: '"ok"', durationMs: 1 });

const showQuickPickSpy = jest.spyOn(vscode.window, 'showQuickPick');

// Single line with trailing \r — should NOT trigger the dialog
pty.handleInput('show dbs\r');

await new Promise((resolve) => setTimeout(resolve, 50));

expect(showQuickPickSpy).not.toHaveBeenCalled();
expect(mockEvaluate).toHaveBeenCalledWith('show dbs', expect.any(Number));

showQuickPickSpy.mockRestore();
});

it('should skip our dialog and run line-by-line when VS Code paste warning is active', async () => {
// behavior=ask but VS Code's warning is 'auto' (default) → skip our dialog
mockPasteBehavior('ask', 'auto');

mockEvaluate
.mockResolvedValueOnce({ type: null, printable: '"r1"', durationMs: 1 })
.mockResolvedValueOnce({ type: null, printable: '"r2"', durationMs: 1 });

const showQuickPickSpy = jest.spyOn(vscode.window, 'showQuickPick');

pty.handleInput('show dbs\nuse mydb\n');

await new Promise((resolve) => setTimeout(resolve, 50));

// Our QuickPick should NOT have been shown
expect(showQuickPickSpy).not.toHaveBeenCalled();
// Lines should have been run independently
expect(mockEvaluate).toHaveBeenCalledTimes(2);

showQuickPickSpy.mockRestore();
});

it('should show our dialog when VS Code paste warning is disabled', async () => {
// behavior=ask and VS Code's warning is 'never' → show our dialog
mockPasteBehavior('ask', 'never');

const showQuickPickSpy = jest
.spyOn(vscode.window, 'showQuickPick')
.mockResolvedValue({ label: 'Cancel', detail: '', id: 'cancel' } as never);

pty.handleInput('line1\nline2\n');

await new Promise((resolve) => setTimeout(resolve, 10));
expect(showQuickPickSpy).toHaveBeenCalledTimes(1);

showQuickPickSpy.mockRestore();
});

it('should show our dialog even when VS Code paste warning is active if alwaysAsk', async () => {
// behavior=alwaysAsk and VS Code's warning is 'auto' → still show our dialog
mockPasteBehavior('alwaysAsk', 'auto');

const showQuickPickSpy = jest
.spyOn(vscode.window, 'showQuickPick')
.mockResolvedValue({ label: 'Cancel', detail: '', id: 'cancel' } as never);

pty.handleInput('line1\nline2\n');

await new Promise((resolve) => setTimeout(resolve, 10));
expect(showQuickPickSpy).toHaveBeenCalledTimes(1);

showQuickPickSpy.mockRestore();
});
});
});
Loading
Loading