Skip to content
Open
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
51 changes: 47 additions & 4 deletions src/commandwindow/CommandWindow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -462,7 +462,17 @@ export default class CommandWindow implements vscode.Pseudoterminal {
// Don't actually move the cursor, but do move the index we think the cursor is at.
this._justTypedLastInColumn = false;
} else {
if (this._getAbsoluteIndexOnLine(this._cursorIndex) % this._terminalDimensions.columns === 0) {
// Check if the character before cursor is a newline (explicit line break)
const charBeforeCursor = this._currentPromptLine.charAt(this._getAbsoluteIndexOnLine(this._cursorIndex) - 1);
if (charBeforeCursor === '\n') {
// Moving left across an explicit newline - need to go up and find position on previous line
const textBeforeNewline = this._currentPromptLine.substring(0, this._getAbsoluteIndexOnLine(this._cursorIndex) - 1);
const previousNewlineIndex = textBeforeNewline.lastIndexOf('\n');
const positionOnPreviousLine = previousNewlineIndex === -1
? textBeforeNewline.length
: textBeforeNewline.length - previousNewlineIndex - 1;
this._writeEmitter.fire(ACTION_KEYS.UP + ACTION_KEYS.MOVE_TO_POSITION_IN_LINE((positionOnPreviousLine % this._terminalDimensions.columns) + 1));
} else if (this._getAbsoluteIndexOnLine(this._cursorIndex) % this._terminalDimensions.columns === 0) {
this._writeEmitter.fire(ACTION_KEYS.UP + ACTION_KEYS.MOVE_TO_POSITION_IN_LINE(this._terminalDimensions.columns));
} else {
this._writeEmitter.fire(ACTION_KEYS.LEFT);
Expand All @@ -477,7 +487,12 @@ export default class CommandWindow implements vscode.Pseudoterminal {
if (this._justTypedLastInColumn) {
// Not possible
} else {
if (this._getAbsoluteIndexOnLine(this._cursorIndex) % this._terminalDimensions.columns === (this._terminalDimensions.columns - 1)) {
// Check if the character at cursor is a newline (explicit line break)
const charAtCursor = this._currentPromptLine.charAt(this._getAbsoluteIndexOnLine(this._cursorIndex));
if (charAtCursor === '\n') {
// Moving right across an explicit newline - go down to start of next line
this._writeEmitter.fire(ACTION_KEYS.DOWN + ACTION_KEYS.MOVE_TO_POSITION_IN_LINE(1));
} else if (this._getAbsoluteIndexOnLine(this._cursorIndex) % this._terminalDimensions.columns === (this._terminalDimensions.columns - 1)) {
this._writeEmitter.fire(ACTION_KEYS.DOWN + ACTION_KEYS.MOVE_TO_POSITION_IN_LINE(0));
} else {
this._writeEmitter.fire(ACTION_KEYS.RIGHT);
Expand Down Expand Up @@ -566,7 +581,9 @@ export default class CommandWindow implements vscode.Pseudoterminal {
}

private _eraseExistingPromptLine (): void {
const numberOfLinesBehind = Math.floor(this._getAbsoluteIndexOnLine(this._cursorIndex) / this._terminalDimensions.columns);
const textUpToCursor = this._currentPromptLine.substring(0, this._getAbsoluteIndexOnLine(this._cursorIndex));
const numberOfExplicitNewlines = (textUpToCursor.match(/\r?\n/g) ?? []).length;
const numberOfLinesBehind = Math.floor(this._getAbsoluteIndexOnLine(this._cursorIndex) / this._terminalDimensions.columns) + numberOfExplicitNewlines;
if (numberOfLinesBehind !== 0) {
this._writeEmitter.fire(ACTION_KEYS.UP.repeat(numberOfLinesBehind))
}
Expand Down Expand Up @@ -667,7 +684,13 @@ export default class CommandWindow implements vscode.Pseudoterminal {
} else if (lineNumberCursorShouldBeOn < lineOfInputCursorIsCurrentlyOn) {
this._writeEmitter.fire(ACTION_KEYS.UP.repeat(lineOfInputCursorIsCurrentlyOn - lineNumberCursorShouldBeOn));
}
this._writeEmitter.fire(ACTION_KEYS.MOVE_TO_POSITION_IN_LINE((this._getAbsoluteIndexOnLine(this._cursorIndex) % this._terminalDimensions.columns) + 1));
// Calculate column position accounting for explicit newlines
const textUpToCursor = this._currentPromptLine.substring(0, this._getAbsoluteIndexOnLine(this._cursorIndex));
const lastNewlineIndex = textUpToCursor.lastIndexOf('\n');
const positionOnCurrentLine = lastNewlineIndex === -1
? this._getAbsoluteIndexOnLine(this._cursorIndex)
: textUpToCursor.length - lastNewlineIndex - 1;
this._writeEmitter.fire(ACTION_KEYS.MOVE_TO_POSITION_IN_LINE((positionOnCurrentLine % this._terminalDimensions.columns) + 1));
}

setDimensions (dimensions: vscode.TerminalDimensions): void {
Expand Down Expand Up @@ -877,6 +900,26 @@ export default class CommandWindow implements vscode.Pseudoterminal {
this._justTypedLastInColumn = this._getAbsoluteIndexOnLine(this._cursorIndex) % this._terminalDimensions.columns === 0;
}

/**
* Get cursor position information for testing purposes.
* Returns the logical line number (0-based) and column position (0-based) within that line.
* For multi-line commands with explicit newlines, the line is determined by counting newlines.
*/
getCursorPosition (): { line: number, column: number } {
const textUpToCursor = this._currentPromptLine.substring(0, this._getAbsoluteIndexOnLine(this._cursorIndex));
const lastNewlineIndex = textUpToCursor.lastIndexOf('\n');

// Count newlines to determine line number
const line = (textUpToCursor.match(/\n/g) ?? []).length;

// Calculate column position within the current line
const column = lastNewlineIndex === -1
? this._cursorIndex // No newlines, so cursor is on first line
: textUpToCursor.length - lastNewlineIndex - 1 - this._currentPrompt.length;

return { line, column };
}

onDidWrite: vscode.Event<string>;
onDidOverrideDimensions?: vscode.Event<vscode.TerminalDimensions | undefined> | undefined;
onDidClose?: vscode.Event<number> | undefined;
Expand Down
8 changes: 8 additions & 0 deletions src/commandwindow/TerminalService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,14 @@ export default class TerminalService {
getCommandWindow (): CommandWindow {
return this._commandWindow;
}

/**
* Get cursor position information for testing purposes.
* Returns the logical line number (0-based) and column position (0-based) within that line.
*/
getCursorPosition (): { line: number, column: number } {
return this._commandWindow.getCursorPosition();
}
}

/**
Expand Down
1 change: 1 addition & 0 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ export async function activate (context: vscode.ExtensionContext): Promise<void>
context.subscriptions.push(vscode.commands.registerCommand('matlab.runSelection', async () => await executionCommandProvider.handleRunSelection()))
context.subscriptions.push(vscode.commands.registerCommand('matlab.interrupt', () => executionCommandProvider.handleInterrupt()))
context.subscriptions.push(vscode.commands.registerCommand('matlab.openCommandWindow', async () => await terminalService.openTerminalOrBringToFront()))
context.subscriptions.push(vscode.commands.registerCommand('matlab.getCursorPosition', () => terminalService.getCursorPosition()))
context.subscriptions.push(vscode.commands.registerCommand('matlab.addFolderToPath', async (uri: vscode.Uri) => await executionCommandProvider.handleAddFolderToPath(uri)))
context.subscriptions.push(vscode.commands.registerCommand('matlab.addFolderAndSubfoldersToPath', async (uri: vscode.Uri) => await executionCommandProvider.handleAddFolderAndSubfoldersToPath(uri)))
context.subscriptions.push(vscode.commands.registerCommand('matlab.changeDirectory', async (uri: vscode.Uri) => await executionCommandProvider.handleChangeDirectory(uri)))
Expand Down
43 changes: 43 additions & 0 deletions src/test/tools/tester/TerminalTester.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,13 @@ export class TerminalTester {
return await this.vs.poll(this.doesTerminalContain.bind(this, expected), true, `Assertion on terminal content: ${message}`)
}

/**
* Assert the MATLAB terminal does not contain some content
*/
public async assertNotContains (expected: string, message: string): Promise<void> {
return await this.vs.poll(this.doesTerminalNotContain.bind(this, expected), true, `Assertion on terminal content: ${message}`)
}

/**
* Checks if the MATLAB terminal contains some content (no polling)
*/
Expand All @@ -56,8 +63,44 @@ export class TerminalTester {
return content.includes(expected)
}

/**
* Checks if the MATLAB terminal does not contain some content (no polling)
*/
private async doesTerminalNotContain (expected: string): Promise<boolean> {
const content = await this.getTerminalContent()
return !content.includes(expected)
}

public async type (text: string): Promise<void> {
const container = await this.terminal.findElement(vet.By.className('xterm-helper-textarea'));
return await container.sendKeys(text)
}

/**
* Get the current cursor position in the MATLAB terminal
* @returns The cursor position as { line: number, column: number } (both 0-based)
*/
public async getCursorPosition (): Promise<{ line: number, column: number }> {
const workbench = new vet.Workbench()
const position = await workbench.executeCommand('matlab.getCursorPosition')
return position as { line: number, column: number }
}

/**
* Assert that the cursor is at the expected position
* @param expectedLine Expected line number (0-based)
* @param expectedColumn Expected column number (0-based)
* @param message Message to display if assertion fails
*/
public async assertCursorPosition (expectedLine: number, expectedColumn: number, message: string): Promise<void> {
return await this.vs.poll(
async () => await this.getCursorPosition(),
{ line: expectedLine, column: expectedColumn },
`Assertion on cursor position: ${message}`,
5000,
async (result) => {
console.log(`Expected cursor at line ${expectedLine}, column ${expectedColumn}, but got line ${result.line}, column ${result.column}`)
}
)
}
}
116 changes: 116 additions & 0 deletions src/test/ui/terminal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,4 +90,120 @@ suite('Terminal UI Tests', () => {
await vs.terminal.assertContains('a = 123;', 'Up arrow after typing "a" should recall matching command')
await vs.terminal.type(Key.ESCAPE)
});

test('Test multi-line command history cycling', async () => {
// Execute a multi-line command by pasting (simulates copy-paste of multi-line text)
await vs.terminal.type('x = [1 2\n 3 4]')
await vs.terminal.type(Key.RETURN)

// Execute another command to move forward in history
await vs.terminal.executeCommand('y = 5;')
await vs.terminal.executeCommand('clc')

// Recall the multi-line command with up arrow
await vs.terminal.type(Key.ARROW_UP)
await vs.terminal.type(Key.ARROW_UP)
await vs.terminal.assertContains('x = [1 2', 'Up arrow should recall first line of multi-line command')
await vs.terminal.assertContains('3 4]', 'Up arrow should recall second line of multi-line command')

// Cycle away from the multi-line command
await vs.terminal.type(Key.ARROW_DOWN)
await vs.terminal.assertNotContains('x = [1 2', 'First line should not stick after cycling away with down arrow')
await vs.terminal.assertNotContains('3 4]', 'Second line should not stick after cycling away with down arrow')
await vs.terminal.type(Key.ESCAPE)
});

test('Test multi-line command cursor position', async () => {
// Execute a multi-line command
await vs.terminal.type('a = 1\nb = 2')
await vs.terminal.type(Key.RETURN)
await vs.terminal.executeCommand('clc')

// Recall the multi-line command
await vs.terminal.type(Key.ARROW_UP)

// Cursor should be at the end of the command - verify position of cursor
// Should be on line 1 (second line), at column 5 (after "b = 2")
await vs.terminal.assertCursorPosition(1, 5, 'Cursor should be at end of multi-line command')
await vs.terminal.type(Key.ESCAPE)
});

test('Test multi-line command left arrow navigation to upper lines', async () => {
// Execute a multi-line command
await vs.terminal.type('x = 10\ny = 20\nz = 30')
await vs.terminal.type(Key.RETURN)
await vs.terminal.executeCommand('clc')

// Recall the multi-line command
await vs.terminal.type(Key.ARROW_UP)

// Move left to navigate from last line to first line
// Start at end: "z = 30|"
for (let i = 0; i < 6; i++) {
await vs.terminal.type(Key.ARROW_LEFT)
}
// Now at: "z = 30" -> should cross newline to second line
await vs.terminal.type(Key.ARROW_LEFT)

// Verify we're on second line at the end
// Should be on line 1 (second line), at column 6 (after "y = 20")
await vs.terminal.assertCursorPosition(1, 6, 'Cursor should be at end of second line after navigating left from third line')
await vs.terminal.type(Key.ESCAPE)
});

test('Test multi-line command right arrow navigation to lower lines', async () => {
// Execute a multi-line command
await vs.terminal.type('p = 1\nq = 2')
await vs.terminal.type(Key.RETURN)
await vs.terminal.executeCommand('clc')

// Recall the multi-line command and navigate to start
await vs.terminal.type(Key.ARROW_UP)
await vs.terminal.type(Key.HOME)

// Now at start of first line: "|p = 1"
// Move right to end of first line
for (let i = 0; i < 5; i++) {
await vs.terminal.type(Key.ARROW_RIGHT)
}

// Now at: "p = 1|" -> next right should cross newline to second line
await vs.terminal.type(Key.ARROW_RIGHT)

// Verify we're on second line at the beginning
// Should be on line 1 (second line), at column 0 (start of "q = 2")
await vs.terminal.assertCursorPosition(1, 0, 'Cursor should be at start of second line after navigating right from first line')
await vs.terminal.type(Key.ESCAPE)
});

test('Test multi-line command bidirectional navigation', async () => {
// Execute a three-line command
await vs.terminal.type('line1\nline2\nline3')
await vs.terminal.type(Key.RETURN)
await vs.terminal.executeCommand('clc')

// Recall and navigate: end -> line2 -> line1 -> line2 -> line3
await vs.terminal.type(Key.ARROW_UP)

// Navigate to middle of second line using left arrows
for (let i = 0; i < 8; i++) { // "line3" (5 chars) + newline + "li" (2 chars) = 8 left arrows
await vs.terminal.type(Key.ARROW_LEFT)
}

// Verify position on line2
// Should be on line 1 (second line), at column 2 (after "li")
await vs.terminal.assertCursorPosition(1, 2, 'Cursor should be at position 2 on line 1 after navigating left')

// Navigate back right to line3
await vs.terminal.type(Key.ARROW_RIGHT) // move past 'n'
await vs.terminal.type(Key.ARROW_RIGHT) // 'e'
await vs.terminal.type(Key.ARROW_RIGHT) // '2'
await vs.terminal.type(Key.ARROW_RIGHT) // cross newline to line3

// Verify we're back on line3
// Should be on line 2 (third line), at column 0 (start of "line3")
await vs.terminal.assertCursorPosition(2, 0, 'Cursor should be at start of line 2 after navigating right back')

await vs.terminal.type(Key.ESCAPE)
});
});