Skip to content

Commit 281dba7

Browse files
jmcpherspetetronicjuliasilge
authored
Add "Set as Working Directory" command (#5476)
This change adds a "Set as Working Directory" affordance to the Explorer. <img width="453" alt="image" src="https://github.com/user-attachments/assets/4ed59ae6-8ef5-4697-81e4-059dfd59b81e"> This command sets the current working directory of the active Console to the folder by emitting the appropriate R or Python code. Addresses #4444. The only complicated/annoying bit is that in Python, the idiomatic way to change the working directory is `os.chdir`, but that doesn't work unless you've done an `import os` at some point. So we also have to check to see if you have `os`, and if you haven't, tack on an `import os` to the command. I did not add an affordance going the other way (from the Console to the Explorer). That one will need more thought because the Explorer can't display folders outside your workspace, whereas the Console can enter any legal working directory. ### QA Notes - If it's helpful for testing, I also added this to the Palette, under `Interpreter: Set as Working Directory`. - Directory names should be properly escaped. --------- Signed-off-by: Jonathan <[email protected]> Co-authored-by: Pete <[email protected]> Co-authored-by: Julia Silge <[email protected]>
1 parent 150ef81 commit 281dba7

File tree

18 files changed

+252
-7
lines changed

18 files changed

+252
-7
lines changed

extensions/jupyter-adapter/src/LanguageRuntimeAdapter.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,19 @@ export class LanguageRuntimeSessionAdapter
265265
this._kernel.replyToPrompt(id, reply);
266266
}
267267

268+
/**
269+
* Set the working directory for the kernel. This is a stub implementation since Jupyter
270+
* doesn't have a concept of a working directory.
271+
*
272+
* @param workingDirectory The working directory to set
273+
* @returns Nothing
274+
* @throws An error message indicating that this method is not implemented
275+
*/
276+
public setWorkingDirectory(workingDirectory: string): Promise<void> {
277+
return Promise.reject(
278+
`Cannot change working directory to ${workingDirectory} (not implemented)`);
279+
}
280+
268281
/**
269282
* Interrupts the kernel.
270283
*/

extensions/positron-javascript/src/session.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,10 @@ export class JavaScriptLanguageRuntimeSession implements positron.LanguageRuntim
148148
throw new Error('Method not implemented.');
149149
}
150150

151+
setWorkingDirectory(dir: string): Thenable<void> {
152+
throw new Error('Method not implemented.');
153+
}
154+
151155
async start(): Promise<positron.LanguageRuntimeInfo> {
152156
this._onDidChangeRuntimeState.fire(positron.RuntimeState.Initializing);
153157
this._onDidChangeRuntimeState.fire(positron.RuntimeState.Starting);

extensions/positron-python/python_files/positron/positron_ipykernel/positron_ipkernel.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -411,7 +411,7 @@ def __init__(self, **kwargs) -> None:
411411
# Create Positron services
412412
self.data_explorer_service = DataExplorerService(_CommTarget.DataExplorer, self.job_queue)
413413
self.plots_service = PlotsService(_CommTarget.Plot, self.session_mode)
414-
self.ui_service = UiService()
414+
self.ui_service = UiService(self)
415415
self.help_service = HelpService()
416416
self.lsp_service = LSPService(self)
417417
self.variables_service = VariablesService(self)

extensions/positron-python/python_files/positron/positron_ipykernel/tests/test_ui.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,25 @@ def test_open_editor(ui_service: UiService, ui_comm: DummyComm) -> None:
123123
]
124124

125125

126+
def test_is_module_loaded(ui_comm: DummyComm) -> None:
127+
"""
128+
Test the `isModuleLoaded` RPC method called from Positron.
129+
"""
130+
module = "fallingStars"
131+
msg = json_rpc_request(
132+
"call_method",
133+
{
134+
"method": "isModuleLoaded",
135+
"params": [module],
136+
},
137+
comm_id="dummy_comm_id",
138+
)
139+
ui_comm.handle_msg(msg)
140+
141+
# Check that the response is sent, with a result of False.
142+
assert ui_comm.messages == [json_rpc_response(False)]
143+
144+
126145
def test_clear_console(ui_service: UiService, ui_comm: DummyComm) -> None:
127146
ui_service.clear_console()
128147

extensions/positron-python/python_files/positron/positron_ipykernel/ui.py

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import sys
1010
import webbrowser
1111
from pathlib import Path
12-
from typing import Callable, Dict, List, Optional, Union
12+
from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Union
1313
from urllib.parse import urlparse
1414

1515
from comm.base_comm import BaseComm
@@ -29,6 +29,9 @@
2929
)
3030
from .utils import JsonData, JsonRecord, alias_home, is_local_html_file
3131

32+
if TYPE_CHECKING:
33+
from .positron_ipkernel import PositronIPyKernel
34+
3235
logger = logging.getLogger(__name__)
3336

3437
_localhosts = [
@@ -51,7 +54,16 @@ class _InvalidParamsError(Exception):
5154
pass
5255

5356

54-
def _set_console_width(params: List[JsonData]) -> None:
57+
def _is_module_loaded(kernel: "PositronIPyKernel", params: List[JsonData]) -> bool:
58+
if not (isinstance(params, list) and len(params) == 1 and isinstance(params[0], str)):
59+
raise _InvalidParamsError(f"Expected a module name, got: {params}")
60+
# Consider: this is not a perfect check for a couple of reasons:
61+
# 1. The module could be loaded under a different name
62+
# 2. The user may have a variable with the same name as the module
63+
return params[0] in kernel.shell.user_ns.keys()
64+
65+
66+
def _set_console_width(kernel: "PositronIPyKernel", params: List[JsonData]) -> None:
5567
if not (isinstance(params, list) and len(params) == 1 and isinstance(params[0], int)):
5668
raise _InvalidParamsError(f"Expected an integer width, got: {params}")
5769

@@ -79,8 +91,9 @@ def _set_console_width(params: List[JsonData]) -> None:
7991
torch_.set_printoptions(linewidth=width)
8092

8193

82-
_RPC_METHODS: Dict[str, Callable[[List[JsonData]], JsonData]] = {
94+
_RPC_METHODS: Dict[str, Callable[["PositronIPyKernel", List[JsonData]], Optional[JsonData]]] = {
8395
"setConsoleWidth": _set_console_width,
96+
"isModuleLoaded": _is_module_loaded,
8497
}
8598

8699

@@ -90,7 +103,9 @@ class UiService:
90103
Used for communication with the frontend, unscoped to any particular view.
91104
"""
92105

93-
def __init__(self) -> None:
106+
def __init__(self, kernel: "PositronIPyKernel") -> None:
107+
self.kernel = kernel
108+
94109
self._comm: Optional[PositronComm] = None
95110

96111
self._working_directory: Optional[Path] = None
@@ -157,7 +172,7 @@ def _call_method(self, rpc_request: CallMethodParams) -> None:
157172
return logger.warning(f"Invalid frontend RPC request method: {rpc_request.method}")
158173

159174
try:
160-
result = func(rpc_request.params)
175+
result = func(self.kernel, rpc_request.params)
161176
except _InvalidParamsError as exception:
162177
return logger.warning(
163178
f"Invalid frontend RPC request params for method '{rpc_request.method}'. {exception}"

extensions/positron-python/src/client/positron/session.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,34 @@ export class PythonRuntimeSession implements positron.LanguageRuntimeSession, vs
203203
}
204204
}
205205

206+
async setWorkingDirectory(dir: string): Promise<void> {
207+
if (this._kernel) {
208+
// Check to see if the 'os' module is available in the kernel
209+
const loaded = await this._kernel.callMethod('isModuleLoaded', 'os');
210+
let code = '';
211+
if (!loaded) {
212+
code = 'import os; ';
213+
}
214+
// Escape backslashes in the directory path
215+
dir = dir.replace(/\\/g, '\\\\');
216+
217+
// Escape single quotes in the directory path
218+
dir = dir.replace(/'/g, "\\'");
219+
220+
// Set the working directory
221+
code += `os.chdir('${dir}')`;
222+
223+
this._kernel.execute(
224+
code,
225+
createUniqueId(),
226+
positron.RuntimeCodeExecutionMode.Interactive,
227+
positron.RuntimeErrorBehavior.Continue,
228+
);
229+
} else {
230+
throw new Error(`Cannot set working directory to ${dir}; kernel not started`);
231+
}
232+
}
233+
206234
private async _installIpykernel(): Promise<void> {
207235
// Get the installer service
208236
const installer = this.serviceContainer.get<IInstaller>(IInstaller);
@@ -605,6 +633,10 @@ export class PythonRuntimeSession implements positron.LanguageRuntimeSession, vs
605633
}
606634
}
607635

636+
export function createUniqueId(): string {
637+
return Math.floor(Math.random() * 0x100000000).toString(16);
638+
}
639+
608640
export function createJupyterKernelExtra(): undefined {
609641
// TODO: Implement and include startup hooks for the Python runtime.
610642
// return {

extensions/positron-r/src/session.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,29 @@ export class RSession implements positron.LanguageRuntimeSession, vscode.Disposa
230230
}
231231
}
232232

233+
/**
234+
* Sets the working directory for the runtime.
235+
*
236+
* @param dir The working directory to set.
237+
*/
238+
async setWorkingDirectory(dir: string): Promise<void> {
239+
if (this._kernel) {
240+
// Escape any backslashes in the directory path
241+
dir = dir.replace(/\\/g, '\\\\');
242+
243+
// Escape any quotes in the directory path
244+
dir = dir.replace(/"/g, '\\"');
245+
246+
// Tell the kernel to change the working directory
247+
this._kernel.execute(`setwd("${dir}")`,
248+
randomUUID(),
249+
positron.RuntimeCodeExecutionMode.Interactive,
250+
positron.RuntimeErrorBehavior.Continue);
251+
} else {
252+
throw new Error(`Cannot change to ${dir}; kernel not started`);
253+
}
254+
}
255+
233256
async start(): Promise<positron.LanguageRuntimeInfo> {
234257
if (!this._kernel) {
235258
this._kernel = await this.createKernel();

extensions/positron-reticulate/src/extension.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -471,6 +471,10 @@ class ReticulateRuntimeSession implements positron.LanguageRuntimeSession {
471471
return this.pythonSession.replyToPrompt(id, reply);
472472
}
473473

474+
public setWorkingDirectory(dir: string): Thenable<void> {
475+
return this.pythonSession.setWorkingDirectory(dir);
476+
}
477+
474478
public start() {
475479
return this.pythonSession.start();
476480
}

extensions/positron-supervisor/src/KallichoreSession.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -664,6 +664,19 @@ export class KallichoreSession implements JupyterLanguageRuntimeSession {
664664
this.sendCommand(reply);
665665
}
666666

667+
/**
668+
* Set the working directory for the kernel. This is a stub implementation since Jupyter
669+
* doesn't have a concept of a working directory.
670+
*
671+
* @param workingDirectory The working directory to set
672+
* @returns Nothing
673+
* @throws An error message indicating that this method is not implemented
674+
*/
675+
setWorkingDirectory(workingDirectory: string): Promise<void> {
676+
return Promise.reject(
677+
`Cannot change working directory to ${workingDirectory} (not implemented)`);
678+
}
679+
667680
/**
668681
* Restores an existing session from the server.
669682
*

extensions/positron-zed/src/positronZedLanguageRuntime.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1072,6 +1072,16 @@ export class PositronZedRuntimeSession implements positron.LanguageRuntimeSessio
10721072
throw new Error('Method not implemented.');
10731073
}
10741074

1075+
/**
1076+
* Set the working directory for the kernel.
1077+
*
1078+
* @param workingDirectory The working directory to set
1079+
*/
1080+
public setWorkingDirectory(workingDirectory: string): Promise<void> {
1081+
this._ui?.changeDirectory(workingDirectory);
1082+
return Promise.resolve();
1083+
}
1084+
10751085
/**
10761086
* Starts the runtime; returns a Thenable that resolves with information about the runtime.
10771087
* @returns A Thenable that resolves with information about the runtime

0 commit comments

Comments
 (0)