Skip to content

Commit 292da58

Browse files
authored
Pin Quarto documents when running code (#14846)
Closes #14736 Quarto documents open as preview editor tabs. Editing the prose in a `.qmd` already pins the tab, but running code did not -- so after running a cell (which, in inline output mode, also spins up a backing R or Python session), clicking another file in the Explorer would silently close the Quarto document and end its session. Running code now pins the document tab, just like editing prose does. The `QuartoExecutionManager` pins the editor for the document at the start of both `executeCells` and `executeInlineCells` (which every run gesture routes through -- the cell toolbar, keyboard shortcuts, and the Quarto extension's inline-execution command). This mirrors the notebook behavior where running a cell pins the editor. ### Release Notes #### New Features - N/A #### Bug Fixes - Running code in a Quarto document now pins its editor tab, so the document (and its runtime session) isn't silently closed when opening another file (#14736) ### Validation Steps @:quarto 1. Enable Quarto inline output. 2. Open a `.qmd` file with a single click in the Explorer (opens as a preview tab, shown in italics). 3. Run a cell in the file. 4. Verify the editor tab is now pinned (no longer italic). 5. Click a different file in the Explorer and confirm the Quarto document stays open and its R/Python session keeps running.
1 parent a580d34 commit 292da58

2 files changed

Lines changed: 123 additions & 0 deletions

File tree

src/vs/workbench/contrib/positronQuarto/browser/quartoExecutionManager.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { ILogService } from '../../../../platform/log/common/log.js';
1616
import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js';
1717
import { IEphemeralStateService } from '../../../../platform/ephemeralState/common/ephemeralState.js';
1818
import { IEditorService } from '../../../services/editor/common/editorService.js';
19+
import { IEditorGroupsService } from '../../../services/editor/common/editorGroupsService.js';
1920
import { ITextModel } from '../../../../editor/common/model.js';
2021
import { IModelService } from '../../../../editor/common/services/model.js';
2122
import { ILanguageFeaturesService } from '../../../../editor/common/services/languageFeatures.js';
@@ -180,6 +181,7 @@ export class QuartoExecutionManager extends Disposable implements IQuartoExecuti
180181
@IQuartoKernelManager private readonly _kernelManager: IQuartoKernelManager,
181182
@IQuartoDocumentModelService private readonly _documentModelService: IQuartoDocumentModelService,
182183
@IEditorService private readonly _editorService: IEditorService,
184+
@IEditorGroupsService private readonly _editorGroupsService: IEditorGroupsService,
183185
@IEphemeralStateService private readonly _ephemeralStateService: IEphemeralStateService,
184186
@IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService,
185187
@IConfigurationService private readonly _configurationService: IConfigurationService,
@@ -218,6 +220,10 @@ export class QuartoExecutionManager extends Disposable implements IQuartoExecuti
218220
return;
219221
}
220222

223+
// Running code pins the document tab so its preview editor (and any
224+
// backing runtime session) isn't silently closed.
225+
this._pinEditorForDocument(documentUri);
226+
221227
this._logService.debug(`[QuartoExecutionManager] Queueing ${cells.length} cells for execution`);
222228

223229
// Filter cells based on eval option when running multiple cells.
@@ -378,6 +384,10 @@ export class QuartoExecutionManager extends Disposable implements IQuartoExecuti
378384
return;
379385
}
380386

387+
// Running code pins the document tab so its preview editor (and any
388+
// backing runtime session) isn't silently closed. See #14736.
389+
this._pinEditorForDocument(documentUri);
390+
381391
this._logService.debug(`[QuartoExecutionManager] Queueing ${codeRanges.length} inline code ranges for execution`);
382392

383393
// First get the text model - this works as long as the document is open in an editor
@@ -2210,6 +2220,22 @@ export class QuartoExecutionManager extends Disposable implements IQuartoExecuti
22102220
});
22112221
}
22122222

2223+
/**
2224+
* Pin any editor showing the given document.
2225+
*
2226+
* Quarto documents open as preview tabs, which are silently replaced when
2227+
* the user opens another file. Running code signals the user intends to
2228+
* work with the document, not just preview it -- and in inline output mode
2229+
* it creates a backing runtime session -- so we pin the tab to prevent it
2230+
* from being closed out from under a live session. This mirrors the
2231+
* notebook behavior where running a cell pins the editor.
2232+
*/
2233+
private _pinEditorForDocument(documentUri: URI): void {
2234+
for (const { groupId, editor } of this._editorService.findEditors(documentUri)) {
2235+
this._editorGroupsService.getGroup(groupId)?.pinEditor(editor);
2236+
}
2237+
}
2238+
22132239
/**
22142240
* Get the text model for a document URI.
22152241
*/

src/vs/workbench/contrib/positronQuarto/test/browser/quartoExecutionManager.vitest.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { IQuartoKernelManager } from '../../browser/quartoKernelManager.js';
2626
import { IQuartoDocumentModelService } from '../../browser/quartoDocumentModelService.js';
2727
import { QuartoCodeCell } from '../../common/quartoTypes.js';
2828
import { IEditorService } from '../../../../services/editor/common/editorService.js';
29+
import { IEditorGroupsService } from '../../../../services/editor/common/editorGroupsService.js';
2930
import { IEphemeralStateService } from '../../../../../platform/ephemeralState/common/ephemeralState.js';
3031
import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js';
3132
import { CancellationToken } from '../../../../../base/common/cancellation.js';
@@ -80,6 +81,7 @@ describe('QuartoExecutionManager', () => {
8081
let mockKernelManager: MockKernelManager;
8182
let mockDocumentModelService: MockDocumentModelService;
8283
let mockEditorService: MockEditorService;
84+
let mockEditorGroupsService: MockEditorGroupsService;
8385
let mockConsoleService: RecordingConsoleService;
8486
let mockRuntimeSessionService: MockRuntimeSessionService;
8587
let configurationService: TestConfigurationService;
@@ -101,6 +103,7 @@ describe('QuartoExecutionManager', () => {
101103
mockKernelManager = ctx.disposables.add(new MockKernelManager(mockSession));
102104
mockDocumentModelService = new MockDocumentModelService();
103105
mockEditorService = new MockEditorService();
106+
mockEditorGroupsService = new MockEditorGroupsService();
104107
const mockEphemeralStateService = new MockEphemeralStateService();
105108
const mockWorkspaceContextService = new MockWorkspaceContextService();
106109
mockConsoleService = new RecordingConsoleService();
@@ -120,6 +123,7 @@ describe('QuartoExecutionManager', () => {
120123
asKernelManager(mockKernelManager),
121124
asDocumentModelService(mockDocumentModelService),
122125
asEditorService(mockEditorService),
126+
asEditorGroupsService(mockEditorGroupsService),
123127
asEphemeralStateService(mockEphemeralStateService),
124128
asWorkspaceContextService(mockWorkspaceContextService),
125129
configurationService,
@@ -1234,6 +1238,7 @@ describe('QuartoExecutionManager', () => {
12341238
asKernelManager(mockKernelManager),
12351239
asDocumentModelService(mockDocumentModelService),
12361240
asEditorService(trackingEditorService),
1241+
asEditorGroupsService(new MockEditorGroupsService()),
12371242
asEphemeralStateService(new MockEphemeralStateService()),
12381243
asWorkspaceContextService(new MockWorkspaceContextService()),
12391244
configurationService,
@@ -1333,6 +1338,7 @@ describe('QuartoExecutionManager', () => {
13331338
asKernelManager(mockKernelManager),
13341339
asDocumentModelService(localMockDocumentModelService),
13351340
asEditorService(localMockEditorService),
1341+
asEditorGroupsService(new MockEditorGroupsService()),
13361342
asEphemeralStateService(new MockEphemeralStateService()),
13371343
asWorkspaceContextService(new MockWorkspaceContextService()),
13381344
configurationService,
@@ -1506,6 +1512,67 @@ describe('QuartoExecutionManager', () => {
15061512
expect(metadata!['output_pixel_ratio'], 'external-only key should pass through').toBe(2);
15071513
});
15081514
});
1515+
1516+
describe('Editor Pinning', () => {
1517+
it('pins the document tab when a cell is executed (#14736)', async () => {
1518+
const documentUri = URI.file('/test-pin-cell.qmd');
1519+
const cell: QuartoCodeCell = {
1520+
id: 'test-pin-cell',
1521+
index: 0,
1522+
language: 'python',
1523+
startLine: 1,
1524+
endLine: 4,
1525+
codeStartLine: 2,
1526+
codeEndLine: 3,
1527+
label: undefined,
1528+
options: '',
1529+
contentHash: 'pin-cell',
1530+
};
1531+
1532+
const executionPromise = executionManager.executeCell(documentUri, cell);
1533+
const executionId = await mockKernelManager.waitForExecution();
1534+
mockSession.receiveStateMessage({
1535+
parent_id: executionId,
1536+
state: RuntimeOnlineState.Idle,
1537+
});
1538+
await executionPromise;
1539+
1540+
expect(mockEditorGroupsService.pinnedEditors.length).toBe(1);
1541+
});
1542+
1543+
it('pins the document tab when inline cells are executed (#14736)', async () => {
1544+
const documentUri = URI.file('/test-pin-inline.qmd');
1545+
const cell: QuartoCodeCell = {
1546+
id: 'test-pin-inline',
1547+
index: 0,
1548+
language: 'python',
1549+
startLine: 1,
1550+
endLine: 3,
1551+
codeStartLine: 2,
1552+
codeEndLine: 2,
1553+
label: undefined,
1554+
options: '',
1555+
contentHash: 'pin-inline',
1556+
};
1557+
const documentLines = ['```{python}', 'x = 1', '```'];
1558+
const mockModel = new MockQuartoDocumentModel([cell], documentLines);
1559+
mockDocumentModelService.setMockModel(mockModel);
1560+
mockEditorService.getValueInRangeCallback = (range: unknown) => {
1561+
const r = range as { startLineNumber: number; endLineNumber: number };
1562+
return documentLines.slice(r.startLineNumber - 1, r.endLineNumber).join('\n');
1563+
};
1564+
1565+
const executionPromise = executionManager.executeInlineCells(documentUri, [new Range(2, 1, 2, 100)]);
1566+
const executionId = await mockKernelManager.waitForExecution();
1567+
mockSession.receiveStateMessage({
1568+
parent_id: executionId,
1569+
state: RuntimeOnlineState.Idle,
1570+
});
1571+
await executionPromise;
1572+
1573+
expect(mockEditorGroupsService.pinnedEditors.length).toBe(1);
1574+
});
1575+
});
15091576
});
15101577

15111578
// Mock implementations
@@ -1696,6 +1763,7 @@ class MockEditorService {
16961763
findEditors(_resource: unknown): unknown[] {
16971764
const self = this;
16981765
return [{
1766+
groupId: MOCK_EDITOR_GROUP_ID,
16991767
editor: {
17001768
resolve: async () => ({
17011769
textEditorModel: {
@@ -1727,6 +1795,35 @@ function asEditorService(mock: MockEditorService): IEditorService {
17271795
});
17281796
}
17291797

1798+
/** Group id reported by MockEditorService.findEditors, resolved by MockEditorGroupsService.getGroup. */
1799+
const MOCK_EDITOR_GROUP_ID = 1;
1800+
1801+
/**
1802+
* Mock editor groups service that records every editor pinned via its group's
1803+
* pinEditor. Lets tests assert that running a cell pins the document's tab.
1804+
*/
1805+
class MockEditorGroupsService {
1806+
readonly pinnedEditors: unknown[] = [];
1807+
1808+
private readonly _group = {
1809+
pinEditor: (editor: unknown) => {
1810+
this.pinnedEditors.push(editor);
1811+
},
1812+
};
1813+
1814+
getGroup(groupId: number): unknown {
1815+
return groupId === MOCK_EDITOR_GROUP_ID ? this._group : undefined;
1816+
}
1817+
}
1818+
1819+
function asEditorGroupsService(mock: MockEditorGroupsService): IEditorGroupsService {
1820+
return stubInterface<IEditorGroupsService>({
1821+
// Cast: the mock's group only implements pinEditor, which is all the
1822+
// execution manager calls; we narrow at the boundary.
1823+
getGroup: mock.getGroup.bind(mock) as IEditorGroupsService['getGroup'],
1824+
});
1825+
}
1826+
17301827
class MockEphemeralStateService {
17311828
async setItem(_key: string, _value: unknown): Promise<void> {
17321829
// No-op: production calls setItem for queue persistence; tests don't read it back.

0 commit comments

Comments
 (0)