From e22de439ee809fe2527b55f15cef05db7b720f49 Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Fri, 17 Apr 2026 12:15:30 +0000 Subject: [PATCH 1/6] fix: enhance cursor movement handling and line wrapping logic in ShellInputHandler --- .../shell/ShellInputHandler.test.ts | 142 ++++++++++++++++-- src/documentdb/shell/ShellInputHandler.ts | 81 +++++++--- 2 files changed, 187 insertions(+), 36 deletions(-) diff --git a/src/documentdb/shell/ShellInputHandler.test.ts b/src/documentdb/shell/ShellInputHandler.test.ts index 010c01cc6..cffc762c4 100644 --- a/src/documentdb/shell/ShellInputHandler.test.ts +++ b/src/documentdb/shell/ShellInputHandler.test.ts @@ -120,9 +120,10 @@ describe('ShellInputHandler', () => { describe('arrow key navigation', () => { it('should move cursor left', () => { handler.handleInput('abc'); - written = ''; handler.handleInput('\x1b[D'); // Left - expect(written).toBe('\x1b[D'); + expect(handler.getCursor()).toBe(2); + // Re-render output contains the buffer with repositioned cursor + expect(written).toContain('abc'); }); it('should not move cursor left past start', () => { @@ -130,15 +131,17 @@ describe('ShellInputHandler', () => { handler.handleInput('\x1b[D'); // Left to start written = ''; handler.handleInput('\x1b[D'); // Try left again + expect(handler.getCursor()).toBe(0); expect(written).toBe(''); // No output — already at start }); it('should move cursor right', () => { handler.handleInput('abc'); handler.handleInput('\x1b[D'); // Left - written = ''; handler.handleInput('\x1b[C'); // Right - expect(written).toBe('\x1b[C'); + expect(handler.getCursor()).toBe(3); + // Re-render output contains the buffer + expect(written).toContain('abc'); }); it('should not move cursor right past end', () => { @@ -152,32 +155,28 @@ describe('ShellInputHandler', () => { describe('Home/End', () => { it('should move cursor to start with Home', () => { handler.handleInput('abc'); - written = ''; handler.handleInput('\x1b[H'); // Home - expect(written).toContain('\x1b[3D'); // Move left 3 + expect(handler.getCursor()).toBe(0); }); it('should move cursor to end with End', () => { handler.handleInput('abc'); handler.handleInput('\x1b[H'); // Home - written = ''; handler.handleInput('\x1b[F'); // End - expect(written).toContain('\x1b[3C'); // Move right 3 + expect(handler.getCursor()).toBe(3); }); it('should handle Ctrl+A (Home)', () => { handler.handleInput('abc'); - written = ''; handler.handleInput('\x01'); // Ctrl+A - expect(written).toContain('\x1b[3D'); + expect(handler.getCursor()).toBe(0); }); it('should handle Ctrl+E (End)', () => { handler.handleInput('abc'); handler.handleInput('\x01'); // Ctrl+A — go home - written = ''; handler.handleInput('\x05'); // Ctrl+E — go end - expect(written).toContain('\x1b[3C'); + expect(handler.getCursor()).toBe(3); }); }); @@ -687,4 +686,123 @@ describe('ShellInputHandler', () => { expect(ghostCalled).toBe(false); }); }); + + describe('line wrapping', () => { + /** + * Helper: parse the ANSI output to extract the "move up" count from + * Step 1 of reRenderLine(). Returns 0 if no CUU sequence is found. + */ + function extractMoveUp(output: string): number { + // \x1b[A at the START of the output = Step 1 move-up + const match = /^\x1b\[(\d+)A/.exec(output); + return match ? Number(match[1]) : 0; + } + + it('should NOT move up when typing stays within a single row', () => { + // prompt=5, cols=80 → 75 chars fit on the first row + handler.setColumns(80); + handler.setPromptWidth(5); + handler.handleInput('a'.repeat(10)); + written = ''; + handler.handleInput('b'); + // Still on row 0, no move-up expected + expect(extractMoveUp(written)).toBe(0); + }); + + it('should NOT incorrectly move up at the exact column boundary (deferred-wrap)', () => { + // prompt=5, cols=80: typing 75 chars fills exactly to column 80 + // The terminal cursor is in deferred-wrap on row 0, NOT row 1. + handler.setColumns(80); + handler.setPromptWidth(5); + handler.handleInput('a'.repeat(75)); // fills row 0 exactly + written = ''; + handler.handleInput('b'); // this is the 76th char, wraps to row 1 + // Step 1 should move up 0 rows (cursor WAS on row 0 from the previous render) + expect(extractMoveUp(written)).toBe(0); + }); + + it('should move up 1 row when cursor is on the second row', () => { + // prompt=5, cols=80: 76 chars = prompt+buffer = 81 cols → wraps to row 1 + handler.setColumns(80); + handler.setPromptWidth(5); + handler.handleInput('a'.repeat(76)); // cursor ends up on row 1 + written = ''; + handler.handleInput('b'); // 77th char, still on row 1 + // Previous render left cursor on row 1, so Step 1 should move up 1 + expect(extractMoveUp(written)).toBe(1); + }); + + it('should handle cursor movement across row boundaries with Left arrow', () => { + // prompt=5, cols=80: 76 chars wraps. + // Char 75 is at (row 0, col 79), char 76 is at (row 1, col 0). + // Moving left from col 0 of row 1 should cross to row 0, col 79. + handler.setColumns(80); + handler.setPromptWidth(5); + handler.handleInput('a'.repeat(76)); // cursor at end (row 1, col 1) + handler.handleInput('\x1b[D'); // Left → cursor at pos 75, (row 1, col 0) + handler.handleInput('\x1b[D'); // Left → cursor at pos 74, (row 0, col 79) + expect(handler.getCursor()).toBe(74); + // Buffer unchanged + expect(handler.getBuffer()).toBe('a'.repeat(76)); + }); + + it('should handle Home across wrapped rows', () => { + handler.setColumns(80); + handler.setPromptWidth(5); + handler.handleInput('a'.repeat(100)); // wraps across 2 rows + handler.handleInput('\x1b[H'); // Home + expect(handler.getCursor()).toBe(0); + }); + + it('should handle End across wrapped rows', () => { + handler.setColumns(80); + handler.setPromptWidth(5); + handler.handleInput('a'.repeat(100)); + handler.handleInput('\x1b[H'); // Home + handler.handleInput('\x1b[F'); // End + expect(handler.getCursor()).toBe(100); + }); + + it('should handle backspace at the wrap boundary', () => { + handler.setColumns(80); + handler.setPromptWidth(5); + handler.handleInput('a'.repeat(76)); // wraps to row 1 + handler.handleInput('\x7f'); // Backspace — remove last char + expect(handler.getBuffer()).toBe('a'.repeat(75)); + expect(handler.getCursor()).toBe(75); + }); + + it('should handle double wrap boundary (160+ cols)', () => { + handler.setColumns(80); + handler.setPromptWidth(5); + handler.handleInput('a'.repeat(155)); // 5+155=160 → exactly fills 2 rows + written = ''; + handler.handleInput('b'); // wraps to row 2 + // Previous render left cursor at deferred-wrap on row 1 + // Step 1 should move up 1 (not 2) + expect(extractMoveUp(written)).toBe(1); + }); + + it('should re-render correctly when pasting text that wraps', () => { + handler.setColumns(80); + handler.setPromptWidth(5); + // Paste 100 chars at once + const pastedText = 'x'.repeat(100); + handler.handleInput(pastedText); + expect(handler.getBuffer()).toBe(pastedText); + expect(handler.getCursor()).toBe(100); + }); + + it('should handle insert in the middle of a wrapped line', () => { + handler.setColumns(80); + handler.setPromptWidth(5); + handler.handleInput('a'.repeat(100)); + // Move cursor to position 50 (middle of first row) + handler.handleInput('\x1b[H'); // Home + for (let i = 0; i < 50; i++) handler.handleInput('\x1b[C'); // Right x50 + expect(handler.getCursor()).toBe(50); + handler.handleInput('X'); // Insert in middle + expect(handler.getBuffer()).toBe('a'.repeat(50) + 'X' + 'a'.repeat(50)); + }); + }); }); diff --git a/src/documentdb/shell/ShellInputHandler.ts b/src/documentdb/shell/ShellInputHandler.ts index 3f2b43f19..6bb231065 100644 --- a/src/documentdb/shell/ShellInputHandler.ts +++ b/src/documentdb/shell/ShellInputHandler.ts @@ -77,6 +77,13 @@ export class ShellInputHandler { /** Terminal width in columns (for wrap-aware re-rendering). */ private _columns: number = 80; + /** + * Tracks the terminal row (relative to the prompt row) where the cursor + * was left after the last {@link reRenderLine} call. Used in Step 1 of + * the next re-render to move up the correct number of rows. + */ + private _lastCursorRow: number = 0; + private readonly _callbacks: ShellInputHandlerCallbacks; constructor(callbacks: ShellInputHandlerCallbacks) { @@ -88,6 +95,9 @@ export class ShellInputHandler { */ setPromptWidth(width: number): void { this._promptWidth = width; + // Always called when the cursor is at a fresh prompt row, so reset + // the tracked cursor row to avoid stale values from the previous line. + this._lastCursorRow = 0; } /** @@ -127,6 +137,7 @@ export class ShellInputHandler { this._historyIndex = -1; this._savedInput = ''; this._multiLineBuffer = []; + this._lastCursorRow = 0; } /** @@ -449,25 +460,29 @@ export class ShellInputHandler { private moveCursorLeft(): void { if (this._cursor > 0) { this._cursor--; - this._callbacks.write('\x1b[D'); + // Use full re-render to correctly handle row-boundary crossings. + // Simple \x1b[D does not wrap to the previous row in xterm.js. + this.reRenderLine(); } } private moveCursorRight(): void { if (this._cursor < this._buffer.length) { this._cursor++; - this._callbacks.write('\x1b[C'); + // Use full re-render to correctly handle row-boundary crossings. + // Simple \x1b[C does not advance to the next row in xterm.js. + this.reRenderLine(); } } private moveCursorTo(position: number): void { const target = Math.max(0, Math.min(position, this._buffer.length)); - if (target < this._cursor) { - this._callbacks.write(`\x1b[${String(this._cursor - target)}D`); - } else if (target > this._cursor) { - this._callbacks.write(`\x1b[${String(target - this._cursor)}C`); + if (target !== this._cursor) { + this._cursor = target; + // Use full re-render to correctly handle row-boundary crossings. + // Simple \x1b[nD / \x1b[nC do not cross row boundaries. + this.reRenderLine(); } - this._cursor = target; } private wordLeft(): void { @@ -607,11 +622,16 @@ export class ShellInputHandler { * Re-render the entire input line with syntax highlighting. * * This replaces the old per-character echo approach. On every buffer mutation: - * 1. Move cursor up to the prompt row if input wraps across multiple rows. + * 1. Move cursor up to the prompt row using the tracked {@link _lastCursorRow}. * 2. Move cursor to the start of the input area (after the prompt). * 3. Write the (optionally colorized) buffer content. * 4. Erase any leftover characters/rows from the previous (longer) buffer. * 5. Reposition the cursor to the correct row and column. + * + * Row calculations use a deferred-wrap–aware formula: when content exactly + * fills a terminal row, the cursor stays on that row (not the next) until + * another character is written. The formula + * `absCol > 0 ? Math.floor((absCol - 1) / cols) : 0` accounts for this. */ private reRenderLine(): void { const bufferWidth = terminalDisplayWidth(this._buffer); @@ -620,11 +640,12 @@ export class ShellInputHandler { let output = ''; - // Step 1: Move cursor up to the prompt row if wrapping occurred - const cursorAbsCol = this._promptWidth + cursorDisplayOffset; - const cursorRow = cols > 0 ? Math.floor(cursorAbsCol / cols) : 0; - if (cursorRow > 0) { - output += `\x1b[${String(cursorRow)}A`; + // Step 1: Move cursor up to the prompt row. + // Uses _lastCursorRow (set at the end of the previous call) instead of + // re-deriving from the new buffer state, which would be wrong because the + // buffer has already been mutated before this method runs. + if (this._lastCursorRow > 0) { + output += `\x1b[${String(this._lastCursorRow)}A`; } // Step 2: Carriage return + move right past the prompt @@ -640,15 +661,22 @@ export class ShellInputHandler { // Step 4: Erase from cursor to end of screen (handles wrapped leftover rows) output += '\x1b[J'; - // Step 5: Reposition cursor to the correct position + // Step 5: Reposition cursor to the correct position. + // After writing the buffer the terminal cursor is at an absolute column + // that may be in "deferred wrap" state (exactly fills a row). We use + // \r to normalize to column 0 of the current physical row, then navigate + // to the target row and column with relative movements. const endAbsCol = this._promptWidth + bufferWidth; const targetAbsCol = this._promptWidth + cursorDisplayOffset; if (cols > 0 && endAbsCol !== targetAbsCol) { - const endRow = Math.floor(endAbsCol / cols); - const targetRow = Math.floor(targetAbsCol / cols); - const endCol = endAbsCol % cols; - const targetCol = targetAbsCol % cols; + // Deferred-wrap–aware row: when absCol is an exact multiple of cols + // the cursor hasn't wrapped yet — it's still on the previous row. + const endRow = endAbsCol > 0 ? Math.floor((endAbsCol - 1) / cols) : 0; + const targetRow = targetAbsCol > 0 ? Math.floor((targetAbsCol - 1) / cols) : 0; + + // \r normalizes to column 0, avoiding deferred-wrap column ambiguity. + output += '\r'; // Move up from end row to target row const rowDiff = endRow - targetRow; @@ -656,19 +684,24 @@ export class ShellInputHandler { output += `\x1b[${String(rowDiff)}A`; } - // Move horizontally to target column - const colDiff = endCol - targetCol; - if (colDiff > 0) { - output += `\x1b[${String(colDiff)}D`; - } else if (colDiff < 0) { - output += `\x1b[${String(-colDiff)}C`; + // Move right to target column (CUF is capped at cols-1 by the terminal, + // which is visually correct for the deferred-wrap edge case). + const targetCol = targetAbsCol > 0 ? ((targetAbsCol - 1) % cols) + 1 : 0; + if (targetCol > 0) { + output += `\x1b[${String(targetCol)}C`; } + + this._lastCursorRow = targetRow; + } else if (cols > 0) { + // Cursor is at end of buffer — already positioned correctly. + this._lastCursorRow = endAbsCol > 0 ? Math.floor((endAbsCol - 1) / cols) : 0; } else { // Fallback for unknown columns: simple cursor-back const tailWidth = terminalDisplayWidth(this._buffer.slice(this._cursor)); if (tailWidth > 0) { output += `\x1b[${String(tailWidth)}D`; } + this._lastCursorRow = 0; } this._callbacks.write(output); From 016ef84ef0f8ff1ffd29e384652ce50d3b014fc8 Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Fri, 17 Apr 2026 13:39:23 +0000 Subject: [PATCH 2/6] feat: implement multi-line paste handling in DocumentDBShellPty with user prompt options --- l10n/bundle.l10n.json | 9 + package.json | 15 ++ src/__mocks__/vscode.js | 5 + .../shell/DocumentDBShellPty.test.ts | 154 ++++++++++++++++-- src/documentdb/shell/DocumentDBShellPty.ts | 147 +++++++++++++++++ .../shell/ShellInputHandler.test.ts | 1 + 6 files changed, 320 insertions(+), 11 deletions(-) diff --git a/l10n/bundle.l10n.json b/l10n/bundle.l10n.json index 91fc714b8..d2cffe02c 100644 --- a/l10n/bundle.l10n.json +++ b/l10n/bundle.l10n.json @@ -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", @@ -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", @@ -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.", @@ -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", @@ -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}", @@ -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", @@ -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}\"...", @@ -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", @@ -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…", diff --git a/package.json b/package.json index 8078738c1..84b112176 100644 --- a/package.json +++ b/package.json @@ -1160,6 +1160,21 @@ "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.", + "default": "ask", + "enum": [ + "ask", + "executeAsOne", + "runLineByLine" + ], + "enumDescriptions": [ + "Show a prompt asking how to process the pasted text.", + "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.", diff --git a/src/__mocks__/vscode.js b/src/__mocks__/vscode.js index 294d17c81..084a7f4bd 100644 --- a/src/__mocks__/vscode.js +++ b/src/__mocks__/vscode.js @@ -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() { diff --git a/src/documentdb/shell/DocumentDBShellPty.test.ts b/src/documentdb/shell/DocumentDBShellPty.test.ts index 0e676bbc6..43d823c94 100644 --- a/src/documentdb/shell/DocumentDBShellPty.test.ts +++ b/src/documentdb/shell/DocumentDBShellPty.test.ts @@ -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); @@ -579,4 +586,129 @@ describe('DocumentDBShellPty', () => { expect(mockEvaluate).toHaveBeenNthCalledWith(2, 'use newdb', expect.any(Number)); }); }); + + describe('multi-line paste dialog', () => { + /** Override the multiLinePasteBehavior setting while preserving other mocked settings. */ + function mockPasteBehavior(behavior: string): 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; + } + // Preserve base settings needed by the PTY + if ((section === undefined || section === '') && _key === 'documentDB.timeout') { + return 120; + } + return defaultValue; + }), + } 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(); + }); + }); }); diff --git a/src/documentdb/shell/DocumentDBShellPty.ts b/src/documentdb/shell/DocumentDBShellPty.ts index 0be726f3d..18dedeaf0 100644 --- a/src/documentdb/shell/DocumentDBShellPty.ts +++ b/src/documentdb/shell/DocumentDBShellPty.ts @@ -226,6 +226,14 @@ export class DocumentDBShellPty implements vscode.Pseudoterminal { } handleInput(data: string): void { + // Detect multi-line paste: a single handleInput call with multiple characters + // containing newlines. Single keystrokes are always length 1 (or short escape + // sequences that never contain \r or \n). + if (data.length > 1 && /[\r\n]/.test(data) && this._inputHandler.isEnabled) { + void this.handleMultiLinePaste(data); + return; + } + // Clear ghost text before processing input (except for Right Arrow and Tab // which are handled by the input handler's escape sequence processing) if (data !== '\x1b[C' && data !== '\x09') { @@ -247,6 +255,145 @@ export class DocumentDBShellPty implements vscode.Pseudoterminal { this._inputHandler.setColumns(dimensions.columns); } + // ─── Private: Multi-line paste handling ────────────────────────────────── + + /** + * Handle pasted text that contains multiple lines. + * + * Depending on the `documentDB.shell.multiLinePasteBehavior` setting, either: + * - Asks the user how to process the text (default) + * - Joins lines into a single expression + * - Runs each line independently (raw shell behavior) + */ + private async handleMultiLinePaste(data: string): Promise { + // Normalize line endings and split + const lines = data.split(/\r\n|\r|\n/).filter((l) => l.length > 0); + + // If only one non-empty line after splitting, process normally + if (lines.length <= 1) { + this.processInputDirectly(data); + return; + } + + const behavior = vscode.workspace + .getConfiguration('documentDB.shell') + .get('multiLinePasteBehavior', 'ask'); + + if (behavior === 'executeAsOne') { + this.processInputDirectly(this.joinPastedLines(lines) + '\r'); + return; + } + + if (behavior === 'runLineByLine') { + this.processInputDirectly(data); + return; + } + + // 'ask' — show QuickPick + // Disable input while the dialog is open to prevent typing + this._inputHandler.setEnabled(false); + + try { + const picked = await vscode.window.showQuickPick( + [ + { + label: l10n.t('Execute as One'), + detail: l10n.t('Lines will be joined into a single expression and executed.'), + id: 'join', + }, + { + label: l10n.t('Run as Is'), + detail: l10n.t('Each line will be run independently.'), + id: 'lineByLine', + }, + { + label: l10n.t('Cancel'), + detail: l10n.t('Discard the pasted input.'), + id: 'cancel', + }, + { + label: '', + kind: vscode.QuickPickItemKind.Separator, + }, + { + label: l10n.t('Configure in Settings'), + detail: l10n.t('Open settings to change the default behavior.'), + id: 'settings', + }, + ], + { + title: l10n.t('How to process your multi-line text?'), + placeHolder: l10n.t('{0} lines detected in pasted text', lines.length), + }, + ); + + if (!picked || !('id' in picked)) { + // Dismissed — do nothing + return; + } + + // Re-enable input before processing the chosen action + this._inputHandler.setEnabled(true); + + switch (picked.id) { + case 'join': + this.processInputDirectly(this.joinPastedLines(lines) + '\r'); + break; + case 'lineByLine': + this.processInputDirectly(data); + break; + case 'settings': + void vscode.commands.executeCommand( + 'workbench.action.openSettings', + 'documentDB.shell.multiLinePasteBehavior', + ); + break; + case 'cancel': + default: + break; + } + } finally { + if (!this._evaluating) { + this._inputHandler.setEnabled(true); + } + } + } + + /** + * Join pasted lines into a single expression. + * Lines that start with `.` (method chaining) are joined directly; + * other continuation lines are joined with a space. + */ + private joinPastedLines(lines: string[]): string { + if (lines.length === 0) { + return ''; + } + + let result = lines[0]; + for (let i = 1; i < lines.length; i++) { + const trimmed = lines[i].trimStart(); + if (trimmed.startsWith('.')) { + // Method chaining — join directly (no space needed) + result += trimmed; + } else { + // Other continuation — join with a space + result += ' ' + trimmed; + } + } + return result; + } + + /** + * Process input through the normal path (clear ghost text, dismiss + * completion, forward to input handler). + */ + private processInputDirectly(data: string): void { + this._ghostText.clear((d) => this._writeEmitter.fire(d)); + this._ghostTextIsHint = false; + this._completionListVisible = false; + this._inputHandler.handleInput(data); + } + // ─── Private: Session initialization ───────────────────────────────────── private async initializeSession(): Promise { diff --git a/src/documentdb/shell/ShellInputHandler.test.ts b/src/documentdb/shell/ShellInputHandler.test.ts index cffc762c4..bbe73ac4e 100644 --- a/src/documentdb/shell/ShellInputHandler.test.ts +++ b/src/documentdb/shell/ShellInputHandler.test.ts @@ -694,6 +694,7 @@ describe('ShellInputHandler', () => { */ function extractMoveUp(output: string): number { // \x1b[A at the START of the output = Step 1 move-up + // eslint-disable-next-line no-control-regex const match = /^\x1b\[(\d+)A/.exec(output); return match ? Number(match[1]) : 0; } From a12d228531fe5cb2adf7dc4baabbb7231e447ea7 Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Fri, 17 Apr 2026 13:49:51 +0000 Subject: [PATCH 3/6] fix: enhance multi-line paste behavior to consider VS Code's built-in warning settings --- package.json | 6 +- .../shell/DocumentDBShellPty.test.ts | 65 ++++++++++++++++++- src/documentdb/shell/DocumentDBShellPty.ts | 19 +++++- 3 files changed, 85 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 84b112176..d0816703e 100644 --- a/package.json +++ b/package.json @@ -1162,15 +1162,17 @@ }, "documentDB.shell.multiLinePasteBehavior": { "type": "string", - "description": "Controls how multi-line text is handled when pasted into the Interactive Shell.", + "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.", + "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." ] diff --git a/src/documentdb/shell/DocumentDBShellPty.test.ts b/src/documentdb/shell/DocumentDBShellPty.test.ts index 43d823c94..96ae41343 100644 --- a/src/documentdb/shell/DocumentDBShellPty.test.ts +++ b/src/documentdb/shell/DocumentDBShellPty.test.ts @@ -588,14 +588,21 @@ describe('DocumentDBShellPty', () => { }); describe('multi-line paste dialog', () => { - /** Override the multiLinePasteBehavior setting while preserving other mocked settings. */ - function mockPasteBehavior(behavior: string): void { + /** + * 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 === '') && _key === 'documentDB.timeout') { return 120; @@ -710,5 +717,59 @@ describe('DocumentDBShellPty', () => { 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(); + }); }); }); diff --git a/src/documentdb/shell/DocumentDBShellPty.ts b/src/documentdb/shell/DocumentDBShellPty.ts index 18dedeaf0..0c0e867af 100644 --- a/src/documentdb/shell/DocumentDBShellPty.ts +++ b/src/documentdb/shell/DocumentDBShellPty.ts @@ -289,7 +289,24 @@ export class DocumentDBShellPty implements vscode.Pseudoterminal { return; } - // 'ask' — show QuickPick + // 'ask' — show QuickPick, but only if VS Code's built-in multi-line paste + // warning is disabled. When their dialog is active ('auto' or 'always'), + // the user already had a chance to cancel or "Paste as one line", so + // showing a second dialog would be redundant. + // 'alwaysAsk' — always show our dialog regardless of VS Code's setting. + if (behavior === 'ask') { + const vscodePasteWarning = vscode.workspace + .getConfiguration('terminal.integrated') + .get('enableMultiLinePasteWarning', 'auto'); + + if (vscodePasteWarning !== 'never') { + // VS Code already prompted — run line by line (the user chose "Paste") + this.processInputDirectly(data); + return; + } + } + + // 'ask' with VS Code dialog disabled, or 'alwaysAsk' — show our own // Disable input while the dialog is open to prevent typing this._inputHandler.setEnabled(false); From 4fa1037f82dd9114a3222016f4ca19e436c7064c Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Fri, 17 Apr 2026 14:20:27 +0000 Subject: [PATCH 4/6] fix: reset _lastCursorRow on terminal resize When the terminal is resized, xterm.js reflows content but _lastCursorRow was not updated, causing reRenderLine() to navigate to the wrong prompt row. Reset it to 0 in setColumns(). --- src/documentdb/shell/ShellInputHandler.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/documentdb/shell/ShellInputHandler.ts b/src/documentdb/shell/ShellInputHandler.ts index 6bb231065..16d554b5b 100644 --- a/src/documentdb/shell/ShellInputHandler.ts +++ b/src/documentdb/shell/ShellInputHandler.ts @@ -105,6 +105,9 @@ export class ShellInputHandler { */ setColumns(columns: number): void { this._columns = columns; + // Reset tracked cursor row — after a resize xterm.js reflows content, + // making the previous _lastCursorRow stale. + this._lastCursorRow = 0; } /** From 4ee502cf5581ddb1604e0b8798865d1c4b68d656 Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Fri, 17 Apr 2026 14:20:34 +0000 Subject: [PATCH 5/6] fix: filter whitespace-only lines in multi-line paste Change .filter((l) => l.length > 0) to .filter((l) => l.trim().length > 0) so that whitespace-only lines are excluded. Previously, a paste like "db.test\n \n.find()" would produce a spurious double space. --- src/documentdb/shell/DocumentDBShellPty.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/documentdb/shell/DocumentDBShellPty.ts b/src/documentdb/shell/DocumentDBShellPty.ts index 0c0e867af..a4d2f6004 100644 --- a/src/documentdb/shell/DocumentDBShellPty.ts +++ b/src/documentdb/shell/DocumentDBShellPty.ts @@ -267,7 +267,7 @@ export class DocumentDBShellPty implements vscode.Pseudoterminal { */ private async handleMultiLinePaste(data: string): Promise { // Normalize line endings and split - const lines = data.split(/\r\n|\r|\n/).filter((l) => l.length > 0); + const lines = data.split(/\r\n|\r|\n/).filter((l) => l.trim().length > 0); // If only one non-empty line after splitting, process normally if (lines.length <= 1) { From a9a27c272f857d76a9c018c3e9b2a2d05c6572c7 Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Fri, 17 Apr 2026 14:20:41 +0000 Subject: [PATCH 6/6] test: preserve colorOutput setting in mockPasteBehavior mockPasteBehavior() now preserves documentDB.shell.display.colorOutput=false alongside documentDB.timeout, preventing brittleness if paste tests ever check rendered ANSI output. --- src/documentdb/shell/DocumentDBShellPty.test.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/documentdb/shell/DocumentDBShellPty.test.ts b/src/documentdb/shell/DocumentDBShellPty.test.ts index 96ae41343..2b4f8f17b 100644 --- a/src/documentdb/shell/DocumentDBShellPty.test.ts +++ b/src/documentdb/shell/DocumentDBShellPty.test.ts @@ -604,8 +604,13 @@ describe('DocumentDBShellPty', () => { return vscodePasteWarning; } // Preserve base settings needed by the PTY - if ((section === undefined || section === '') && _key === 'documentDB.timeout') { - return 120; + if (section === undefined || section === '') { + if (_key === 'documentDB.shell.display.colorOutput') { + return false; + } + if (_key === 'documentDB.timeout') { + return 120; + } } return defaultValue; }),