Skip to content

Add notebook.workingDirectory setting #8558

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
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
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#
# Copyright (C) 2023-2024 Posit Software, PBC. All rights reserved.
# Copyright (C) 2023-2025 Posit Software, PBC. All rights reserved.
# Licensed under the Elastic License 2.0. See LICENSE.txt for license information.
#

Expand Down Expand Up @@ -249,6 +249,7 @@ class PositronInitializationOptions:
"""Positron-specific language server initialization options."""

notebook_path: Optional[Path] = attrs.field(default=None)
working_directory: Optional[str] = attrs.field(default=None)


class PositronJediLanguageServerProtocol(JediLanguageServerProtocol):
Expand Down Expand Up @@ -285,10 +286,16 @@ def lsp_initialize(self, params: InitializeParams) -> InitializeResult:
server.show_message_log(msg, msg_type=MessageType.Error)
initialization_options = PositronInitializationOptions()

# If a notebook path was provided, set the project path to the notebook's parent.
# See https://github.com/posit-dev/positron/issues/5948.
# Set the project path to the notebook's parent if `working_directory` is not provided.
# See https://github.com/posit-dev/positron/issues/5948 and https://github.com/posit-dev/positron/issues/7988.
working_directory = initialization_options.working_directory
notebook_path = initialization_options.notebook_path
path = notebook_path.parent if notebook_path else self._server.workspace.root_path
if working_directory:
path = working_directory
elif notebook_path:
path = notebook_path.parent
else:
path = self._server.workspace.root_path

# Create the Jedi Project.
# Note that this overwrites a Project already created in the parent class.
Expand Down
5 changes: 3 additions & 2 deletions extensions/positron-python/src/client/positron/lsp.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*---------------------------------------------------------------------------------------------
* Copyright (C) 2023-2024 Posit Software, PBC. All rights reserved.
* Copyright (C) 2023-2025 Posit Software, PBC. All rights reserved.
* Licensed under the Elastic License 2.0. See LICENSE.txt for license information.
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
Expand Down Expand Up @@ -90,7 +90,7 @@ export class PythonLsp implements vscode.Disposable {
return out.promise;
};

const { notebookUri } = this._metadata;
const { notebookUri, workingDirectory } = this._metadata;

// If this client belongs to a notebook, set the document selector to only include that notebook.
// Otherwise, this is the main client for this language, so set the document selector to include
Expand Down Expand Up @@ -128,6 +128,7 @@ export class PythonLsp implements vscode.Disposable {
if (notebookUri) {
this._clientOptions.initializationOptions.positron = {
notebook_path: notebookUri.fsPath,
working_directory: workingDirectory,
};
}

Expand Down
27 changes: 15 additions & 12 deletions extensions/positron-supervisor/src/KallichoreSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -330,18 +330,21 @@ export class KallichoreSession implements JupyterLanguageRuntimeSession {
this._kernelSpec = kernelSpec;
const varActions = await this.buildEnvVarActions(false);

// Prepare the working directory; use the workspace root if available,
// otherwise the home directory
let workingDir = vscode.workspace.workspaceFolders?.[0].uri.fsPath || os.homedir();

// If we have a notebook URI, use its parent directory as the working
// directory instead. Note that not all notebooks have valid on-disk
// URIs since they may be transient or not yet saved; for these, we fall
// back to the workspace root or home directory.
if (this.metadata.notebookUri?.fsPath) {
const notebookPath = this.metadata.notebookUri.fsPath;
if (fs.existsSync(notebookPath)) {
workingDir = path.dirname(notebookPath);
let workingDir = this.metadata.workingDirectory;

if (!workingDir) {
// Use the workspace root if available, otherwise the home directory
workingDir = vscode.workspace.workspaceFolders?.[0].uri.fsPath || os.homedir();

// If we have a notebook URI, use its parent directory as the working
// directory instead. Note that not all notebooks have valid on-disk
// URIs since they may be transient or not yet saved; for these, we fall
// back to the workspace root or home directory.
if (this.metadata.notebookUri?.fsPath) {
const notebookPath = this.metadata.notebookUri.fsPath;
if (fs.existsSync(notebookPath)) {
workingDir = path.dirname(notebookPath);
}
}
}

Expand Down
3 changes: 3 additions & 0 deletions src/positron-dts/positron.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -540,6 +540,9 @@ declare module 'positron' {

/** The URI of the notebook document associated with the session, if any */
readonly notebookUri?: vscode.Uri;

/** The starting working directory of the session, if any */
readonly workingDirectory?: string;
}

/**
Expand Down
1 change: 1 addition & 0 deletions src/vs/workbench/api/common/configurationExtensionPoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ const IGNORED_JUPYTER_CONFIGURATION_PROPERTIES = new Set([
'jupyter.interactiveWindow.textEditor.executeSelection',
'jupyter.interactiveWindow.textEditor.magicCommandsAsComments',
'jupyter.interactiveWindow.viewColumn',
'jupyter.notebookFileRoot',
]);
// --- End Positron ---
const jsonRegistry = Registry.as<IJSONContributionRegistry>(JSONExtensions.JSONContribution);
Expand Down
12 changes: 12 additions & 0 deletions src/vs/workbench/contrib/notebook/browser/notebook.contribution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ import { IModelService } from '../../../../editor/common/services/model.js';
import { ILanguageSelection, ILanguageService } from '../../../../editor/common/languages/language.js';
import { ITextModelContentProvider, ITextModelService } from '../../../../editor/common/services/resolverService.js';
import * as nls from '../../../../nls.js';
// --- Start Positron ---
// eslint-disable-next-line no-duplicate-imports
import { ConfigurationScope } from '../../../../platform/configuration/common/configurationRegistry.js';
// --- End Positron ---
import { Extensions, IConfigurationPropertySchema, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js';
import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js';
import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js';
Expand Down Expand Up @@ -1311,6 +1315,14 @@ configurationRegistry.registerConfiguration({
type: 'string',
default: '',
tags: ['notebookLayout']
},
// --- Start Positron ---
[NotebookSetting.workingDirectory]: {
markdownDescription: nls.localize('notebook.workingDirectory', "Default working directory for notebook kernels. Supports [variables](https://code.visualstudio.com/docs/reference/variables-reference) like `${workspaceFolder}`. When empty, uses the notebook file's directory. Any change to this setting will apply to future opened notebooks."),
type: 'string',
default: '',
scope: ConfigurationScope.RESOURCE
}
// --- End Positron ---
}
});
3 changes: 3 additions & 0 deletions src/vs/workbench/contrib/notebook/common/notebookCommon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1065,6 +1065,9 @@ export const NotebookSetting = {
outputBackupSizeLimit: 'notebook.backup.sizeLimit',
multiCursor: 'notebook.multiCursor.enabled',
markupFontFamily: 'notebook.markup.fontFamily',
// --- Start Positron ---
workingDirectory: 'notebook.workingDirectory',
// --- End Positron ---
} as const;

export const enum CellStatusbarAlignment {
Expand Down
54 changes: 52 additions & 2 deletions src/vs/workbench/services/runtimeSession/common/runtimeSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ import { INotificationService, Severity } from '../../../../platform/notificatio
import { localize } from '../../../../nls.js';
import { UiClientInstance } from '../../languageRuntime/common/languageRuntimeUiClient.js';
import { IWorkbenchEnvironmentService } from '../../environment/common/environmentService.js';
import { IConfigurationResolverService } from '../../configurationResolver/common/configurationResolver.js';
import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js';
import { NotebookSetting } from '../../../contrib/notebook/common/notebookCommon.js';

/**
* The maximum number of active sessions a user can have running at a time.
Expand Down Expand Up @@ -170,7 +173,9 @@ export class RuntimeSessionService extends Disposable implements IRuntimeSession
@IExtensionService private readonly _extensionService: IExtensionService,
@IStorageService private readonly _storageService: IStorageService,
@IUpdateService private readonly _updateService: IUpdateService,
@IWorkbenchEnvironmentService private readonly _environmentService: IWorkbenchEnvironmentService
@IWorkbenchEnvironmentService private readonly _environmentService: IWorkbenchEnvironmentService,
@IConfigurationResolverService private readonly _configurationResolverService: IConfigurationResolverService,
@IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService
) {

super();
Expand Down Expand Up @@ -387,6 +392,46 @@ export class RuntimeSessionService extends Disposable implements IRuntimeSession
return Array.from(this._activeSessionsBySessionId.values());
}

/**
* Resolves the working directory configuration with variable substitution.
*
* @param notebookUri The URI of the notebook, if any, for resource-scoped configuration
* @returns The resolved working directory or undefined if not configured
*/
private async resolveWorkingDirectory(notebookUri?: URI): Promise<string | undefined> {
// Only resolve/provide a working directory for notebooks.
if (!notebookUri) {
return undefined;
}

// Get the working directory configuration
const configValue = this._configurationService.getValue<string>(
NotebookSetting.workingDirectory, { resource: notebookUri }
);

// If no configuration value is set, return undefined
if (!configValue || configValue.trim() === '') {
return undefined;
}

// Get the workspace folder for variable resolution
const workspaceFolder = this._workspaceContextService.getWorkspaceFolder(notebookUri);

try {
// Resolve variables in the configuration value
const resolvedValue = await this._configurationResolverService.resolveAsync(
workspaceFolder || undefined,
configValue
);

return resolvedValue;
} catch (error) {
// Log the error and return the original value as fallback
this._logService.warn(`Failed to resolve working directory variables in '${configValue}':`, error);
return configValue;
}
}

/**
* Select a session for the provided runtime.
*
Expand Down Expand Up @@ -1535,10 +1580,15 @@ export class RuntimeSessionService extends Disposable implements IRuntimeSession
}

const sessionId = this.generateNewSessionId(runtimeMetadata, sessionMode === LanguageRuntimeSessionMode.Notebook);

// Resolve the working directory configuration
const workingDirectory = await this.resolveWorkingDirectory(notebookUri);

const sessionMetadata: IRuntimeSessionMetadata = {
sessionId,
sessionMode,
notebookUri,
workingDirectory,
createdTimestamp: Date.now(),
startReason: source
};
Expand Down Expand Up @@ -1645,7 +1695,7 @@ export class RuntimeSessionService extends Disposable implements IRuntimeSession
* @param runtime The runtime to get the manager for.
* @returns The session manager that manages the runtime.
*
* Throws an errror if no session manager is found for the runtime.
* Throws an error if no session manager is found for the runtime.
*/
private async getManagerForRuntime(runtime: ILanguageRuntimeMetadata): Promise<ILanguageRuntimeSessionManager> {
// Look for the session manager that manages the runtime.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,9 @@ export interface IRuntimeSessionMetadata {
/** The notebook associated with the session, if any */
readonly notebookUri: URI | undefined;

/** The starting working directory of the session, if any */
readonly workingDirectory?: string;

/**
* A timestamp (in milliseconds since the Epoch) representing the time at
* which the runtime session was created.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import { TestLanguageRuntimeSession, waitForRuntimeState } from './testLanguageR
import { createRuntimeServices, createTestLanguageRuntimeMetadata, startTestLanguageRuntimeSession } from './testRuntimeSessionService.js';
import { TestRuntimeSessionManager } from '../../../../test/common/positronWorkbenchTestServices.js';
import { TestWorkspaceTrustManagementService } from '../../../../test/common/workbenchTestServices.js';
import { IConfigurationResolverService } from '../../../configurationResolver/common/configurationResolver.js';
import { NotebookSetting } from '../../../../contrib/notebook/common/notebookCommon.js';

type IStartSessionTask = (runtime: ILanguageRuntimeMetadata) => Promise<TestLanguageRuntimeSession>;

Expand All @@ -31,6 +33,7 @@ suite('Positron - RuntimeSessionService', () => {
let runtimeSessionService: IRuntimeSessionService;
let configService: TestConfigurationService;
let workspaceTrustManagementService: TestWorkspaceTrustManagementService;
let configurationResolverService: IConfigurationResolverService;
let manager: TestRuntimeSessionManager;
let runtime: ILanguageRuntimeMetadata;
let anotherRuntime: ILanguageRuntimeMetadata;
Expand All @@ -44,6 +47,7 @@ suite('Positron - RuntimeSessionService', () => {
runtimeSessionService = instantiationService.get(IRuntimeSessionService);
configService = instantiationService.get(IConfigurationService) as TestConfigurationService;
workspaceTrustManagementService = instantiationService.get(IWorkspaceTrustManagementService) as TestWorkspaceTrustManagementService;
configurationResolverService = instantiationService.get(IConfigurationResolverService);
manager = TestRuntimeSessionManager.instance;

// Dispose all sessions on teardown.
Expand Down Expand Up @@ -1182,4 +1186,91 @@ suite('Positron - RuntimeSessionService', () => {
assert.strictEqual(session.dynState.sessionName, newName, 'Session name should be updated correctly');
assert.strictEqual(otherSession.dynState.sessionName, runtime.runtimeName, 'Other session name should remain unchanged');
});

suite('Working Directory Configuration', () => {
test('working directory is applied to notebook sessions when configured', async () => {
const workingDir = '/custom/working/directory';
configService.setUserConfiguration(NotebookSetting.workingDirectory, workingDir);

const session = await startNotebook(runtime);

assert.strictEqual(session.metadata.workingDirectory, workingDir, 'Working directory should be set for notebook sessions');
});

test('working directory is default for console sessions even when notebook working directory is configured', async () => {
const workingDir = '/custom/working/directory';
configService.setUserConfiguration(NotebookSetting.workingDirectory, workingDir);

const session = await startConsole(runtime);

assert.strictEqual(session.metadata.workingDirectory, undefined, 'Working directory should be undefined for console sessions');
});

test('working directory is undefined when configuration is empty string', async () => {
configService.setUserConfiguration(NotebookSetting.workingDirectory, '');

const session = await startNotebook(runtime);

assert.strictEqual(session.metadata.workingDirectory, undefined, 'Working directory should be undefined for empty string');
});

test('working directory is undefined when configuration is whitespace only', async () => {
configService.setUserConfiguration(NotebookSetting.workingDirectory, ' ');

const session = await startNotebook(runtime);

assert.strictEqual(session.metadata.workingDirectory, undefined, 'Working directory should be undefined for whitespace only');
});

test('working directory supports variable resolution for notebook sessions', async () => {
const workingDir = '/workspace/folder';
configService.setUserConfiguration(NotebookSetting.workingDirectory, workingDir);

// Create a mock that actually resolves variables
const mockConfigResolver = configurationResolverService as any;
mockConfigResolver.resolveAsync = sinon.stub().resolves('/resolved/workspace/folder');

const session = await startNotebook(runtime);

assert.strictEqual(session.metadata.workingDirectory, '/resolved/workspace/folder', 'Working directory should be resolved');
sinon.assert.calledOnce(mockConfigResolver.resolveAsync);
});

test('working directory falls back to original value when resolution fails for notebook sessions', async () => {
const workingDir = '/workspace/folder';
configService.setUserConfiguration(NotebookSetting.workingDirectory, workingDir);

// Create a mock that throws an error during resolution
const mockConfigResolver = configurationResolverService as any;
mockConfigResolver.resolveAsync = sinon.stub().rejects(new Error('Resolution failed'));

const session = await startNotebook(runtime);

assert.strictEqual(session.metadata.workingDirectory, workingDir, 'Working directory should fall back to original value');
sinon.assert.calledOnce(mockConfigResolver.resolveAsync);
});

test('working directory is resource-scoped for notebook sessions', async () => {
const workingDir = '/notebook/specific/directory';
await configService.setUserConfiguration(NotebookSetting.workingDirectory, workingDir, notebookUri);

const session = await startNotebook(runtime);

assert.strictEqual(session.metadata.workingDirectory, workingDir, 'Working directory should be resource-scoped');
});

test('working directory differs between console and notebook sessions', async () => {
const consoleWorkingDir = '/console/directory';
const notebookWorkingDir = '/notebook/directory';

await configService.setUserConfiguration(NotebookSetting.workingDirectory, consoleWorkingDir);
await configService.setUserConfiguration(NotebookSetting.workingDirectory, notebookWorkingDir, notebookUri);

const consoleSession = await startConsole(runtime);
const notebookSession = await startNotebook(runtime);

assert.strictEqual(consoleSession.metadata.workingDirectory, undefined, 'Console session should not use working directory configuration');
assert.strictEqual(notebookSession.metadata.workingDirectory, notebookWorkingDir, 'Notebook session should use resource-scoped configuration');
});
});
});
Loading
Loading