1 /*
2  * PythonPreferencesPaneBase.java
3  *
4  * Copyright (C) 2021 by RStudio, PBC
5  *
6  * Unless you have received this program directly from RStudio pursuant
7  * to the terms of a commercial license agreement with RStudio, then
8  * this program is licensed to you under the terms of version 3 of the
9  * GNU Affero General Public License. This program is distributed WITHOUT
10  * ANY EXPRESS OR IMPLIED WARRANTY, INCLUDING THOSE OF NON-INFRINGEMENT,
11  * MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. Please refer to the
12  * AGPL (http://www.gnu.org/licenses/agpl-3.0.txt) for more details.
13  *
14  */
15 package org.rstudio.studio.client.workbench.prefs.views;
16 
17 import org.rstudio.core.client.CommandWithArg;
18 import org.rstudio.core.client.Debug;
19 import org.rstudio.core.client.ElementIds;
20 import org.rstudio.core.client.StringUtil;
21 import org.rstudio.core.client.files.FileSystemItem;
22 import org.rstudio.core.client.prefs.PreferencesDialogPaneBase;
23 import org.rstudio.core.client.prefs.RestartRequirement;
24 import org.rstudio.core.client.resources.ImageResource2x;
25 import org.rstudio.core.client.widget.HelpButton;
26 import org.rstudio.core.client.widget.InfoBar;
27 import org.rstudio.core.client.widget.ModalDialogBase;
28 import org.rstudio.core.client.widget.OperationWithInput;
29 import org.rstudio.core.client.widget.TextBoxWithButton;
30 import org.rstudio.studio.client.RStudioGinjector;
31 import org.rstudio.studio.client.server.ServerError;
32 import org.rstudio.studio.client.server.ServerRequestCallback;
33 import org.rstudio.studio.client.workbench.model.Session;
34 import org.rstudio.studio.client.workbench.prefs.model.UserPrefs;
35 import org.rstudio.studio.client.workbench.prefs.views.python.PythonInterpreterListEntryUi;
36 import org.rstudio.studio.client.workbench.prefs.views.python.PythonInterpreterSelectionDialog;
37 
38 import com.google.gwt.core.client.GWT;
39 import com.google.gwt.dom.client.Element;
40 import com.google.gwt.event.dom.client.BlurEvent;
41 import com.google.gwt.event.dom.client.ClickEvent;
42 import com.google.gwt.event.dom.client.ClickHandler;
43 import com.google.gwt.event.dom.client.FocusEvent;
44 import com.google.gwt.event.dom.client.KeyCodes;
45 import com.google.gwt.event.dom.client.KeyDownEvent;
46 import com.google.gwt.event.logical.shared.ValueChangeEvent;
47 import com.google.gwt.resources.client.ClientBundle;
48 import com.google.gwt.resources.client.CssResource;
49 import com.google.gwt.resources.client.ImageResource;
50 import com.google.gwt.user.client.ui.CheckBox;
51 import com.google.gwt.user.client.ui.FlowPanel;
52 import com.google.gwt.user.client.ui.SimplePanel;
53 import com.google.inject.Inject;
54 
55 public abstract class PythonPreferencesPaneBase<T> extends PreferencesDialogPaneBase<T>
56 {
PythonPreferencesPaneBase(String width, String placeholderText, boolean isProjectOptions)57    public PythonPreferencesPaneBase(String width,
58                                     String placeholderText,
59                                     boolean isProjectOptions)
60    {
61       RStudioGinjector.INSTANCE.injectMembers(this);
62 
63       add(headerLabel("Python"));
64 
65       mismatchWarningBar_ = new InfoBar(InfoBar.WARNING);
66       mismatchWarningBar_.setText(
67             "The active Python interpreter has been changed by an R startup script.");
68       mismatchWarningBar_.setVisible(false);
69       add(mismatchWarningBar_);
70 
71       tbPythonInterpreter_ = new TextBoxWithButton(
72             "Python interpreter:",
73             null,
74             placeholderText,
75             "Select...",
76             new HelpButton("using_python", "Using Python in RStudio"),
77             ElementIds.TextBoxButtonId.PYTHON_PATH,
78             true,
79             true,
80             new ClickHandler()
81             {
82                @Override
83                public void onClick(ClickEvent event)
84                {
85                   getProgressIndicator().onProgress("Finding interpreters...");
86 
87                   server_.pythonFindInterpreters(new ServerRequestCallback<PythonInterpreters>()
88                   {
89                      @Override
90                      public void onResponseReceived(final PythonInterpreters response)
91                      {
92                         getProgressIndicator().onCompleted();
93 
94                         PythonInterpreterSelectionDialog dialog =
95                               new PythonInterpreterSelectionDialog(
96                                     response.getPythonInterpreters(),
97                                     new OperationWithInput<PythonInterpreter>()
98                                     {
99                                        @Override
100                                        public void execute(PythonInterpreter input)
101                                        {
102                                           String path = input == null ? "" : input.getPath();
103                                           tbPythonInterpreter_.setText(path);
104                                        }
105                                     });
106 
107                         dialog.showModal(true);
108                      }
109 
110                      @Override
111                      public void onError(ServerError error)
112                      {
113                         String message =
114                               "Error finding Python interpreters: " +
115                               error.getUserMessage();
116                         getProgressIndicator().onError(message);
117 
118                         Debug.logError(error);
119                      }
120                   });
121                }
122             });
123 
124       tbPythonInterpreter_.useNativePlaceholder();
125 
126       tbPythonInterpreter_.addValueChangeHandler((ValueChangeEvent<String> event) ->
127       {
128          updateDescription();
129       });
130 
131       tbPythonInterpreter_.getTextBox().addDomHandler((KeyDownEvent event) ->
132       {
133          if (event.getNativeKeyCode() == KeyCodes.KEY_ENTER)
134          {
135             event.stopPropagation();
136             event.preventDefault();
137             tbPythonInterpreter_.blur();
138          }
139          else if (event.getNativeKeyCode() == KeyCodes.KEY_ESCAPE)
140          {
141             event.stopPropagation();
142             event.preventDefault();
143             if (lastValue_ != null)
144                tbPythonInterpreter_.setText(lastValue_);
145             tbPythonInterpreter_.blur();
146          }
147       }, KeyDownEvent.getType());
148 
149       tbPythonInterpreter_.addDomHandler((BlurEvent event) ->
150       {
151          updateDescription();
152       }, BlurEvent.getType());
153 
154       // save the contents of the text box on focus
155       // (we'll restore the value if the user blurs via the Escape key)
156       tbPythonInterpreter_.getTextBox().addFocusHandler((FocusEvent event) ->
157       {
158          lastValue_ = tbPythonInterpreter_.getText();
159       });
160 
161       Element tbEl = tbPythonInterpreter_.getTextBox().getElement();
162       tbEl.addClassName(ModalDialogBase.ALLOW_ENTER_KEY_CLASS);
163       tbEl.addClassName(ModalDialogBase.ALLOW_ESCAPE_KEY_CLASS);
164 
165       tbPythonInterpreter_.setWidth(width);
166       tbPythonInterpreter_.setReadOnly(false);
167       add(spaced(tbPythonInterpreter_));
168 
169       add(interpreterDescription_);
170 
171       if (!isProjectOptions)
172       {
173          cbAutoUseProjectInterpreter_ =
174                new CheckBox("Automatically activate project-local Python environments");
175 
176          initialAutoUseProjectInterpreter_ = prefs_.pythonProjectEnvironmentAutomaticActivate().getGlobalValue();
177          cbAutoUseProjectInterpreter_.setValue(initialAutoUseProjectInterpreter_);
178 
179          cbAutoUseProjectInterpreter_.getElement().setTitle(
180                "When enabled, RStudio will automatically find and activate a " +
181                "Python environment located within the project root directory (if any).");
182 
183          add(lessSpaced(cbAutoUseProjectInterpreter_));
184       }
185    }
186 
187    @Inject
initialize(PythonDialogResources res, PythonServerOperations server, Session session, UserPrefs prefs)188    private void initialize(PythonDialogResources res,
189                            PythonServerOperations server,
190                            Session session,
191                            UserPrefs prefs)
192    {
193       res_ = res;
194       server_ = server;
195       session_ = session;
196       prefs_ = prefs;
197    }
198 
clearDescription()199    protected void clearDescription()
200    {
201       interpreterDescription_.setWidget(new FlowPanel());
202    }
203 
updateDescription()204    protected void updateDescription()
205    {
206       // avoid recursive calls
207       if (updatingDescription_)
208          return;
209 
210       try
211       {
212          updatingDescription_ = true;
213          updateDescriptionImpl();
214       }
215       finally
216       {
217          updatingDescription_ = false;
218       }
219    }
220 
updateDescriptionImpl()221    private void updateDescriptionImpl()
222    {
223       String path = tbPythonInterpreter_.getText().trim();
224 
225       // reset to default when empty
226       if (StringUtil.isNullOrEmpty(path))
227       {
228          tbPythonInterpreter_.setText("");
229          clearDescription();
230          return;
231       }
232 
233       server_.pythonInterpreterInfo(
234             path,
235             new ServerRequestCallback<PythonInterpreter>()
236             {
237                @Override
238                public void onResponseReceived(PythonInterpreter info)
239                {
240                   updateDescriptionImpl(info);
241                }
242 
243                @Override
244                public void onError(ServerError error)
245                {
246                   Debug.logError(error);
247                }
248             });
249    }
250 
updateDescriptionImpl(PythonInterpreter info)251    private void updateDescriptionImpl(PythonInterpreter info)
252    {
253       interpreter_ = info;
254 
255       if (!info.isValid())
256       {
257          String reason = info.getInvalidReason();
258          if (StringUtil.isNullOrEmpty(reason))
259             reason = "The selected Python interpreter does not appear to be valid.";
260 
261          InfoBar bar = new InfoBar(InfoBar.WARNING);
262          bar.setText(reason);
263          interpreterDescription_.setWidget(bar);
264       }
265       else
266       {
267          PythonInterpreterListEntryUi ui = new PythonInterpreterListEntryUi(info);
268          ui.addStyleName(RES.styles().interpreterDescription());
269 
270          String type = info.getType();
271 
272          if (type == null)
273          {
274             type = "[Unknown]";
275          }
276          else if (type == "virtualenv")
277          {
278             type = "Virtual Environment";
279          }
280          else if (type == "conda")
281          {
282             type = "Conda Environment";
283          }
284          else if (type == "system")
285          {
286             type = "System Interpreter";
287          }
288 
289          ui.getPath().setText("[" + type + "]");
290          interpreterDescription_.setWidget(ui);
291       }
292    }
293 
checkForMismatch(PythonInterpreter activeInterpreter)294    protected void checkForMismatch(PythonInterpreter activeInterpreter)
295    {
296       // nothing to do if there isn't an active interpreter
297       if (StringUtil.isNullOrEmpty(activeInterpreter.getPath()))
298       {
299          setMismatchBarVisible(false);
300          return;
301       }
302 
303       // nothing to do if the user hasn't changed the configured Python
304       String requestedPath = tbPythonInterpreter_.getText();
305       boolean isSet = !StringUtil.isNullOrEmpty(requestedPath);
306 
307       if (!isSet)
308       {
309          setMismatchBarVisible(false);
310          return;
311       }
312 
313       // toggle visibility
314       boolean mismatch =
315             !StringUtil.equals(requestedPath, activeInterpreter.getPath());
316 
317       setMismatchBarVisible(mismatch);
318    }
319 
setMismatchBarVisible(boolean visible)320    private void setMismatchBarVisible(boolean visible)
321    {
322       mismatchWarningBar_.setVisible(visible);
323 
324       if (visible)
325       {
326          mismatchWarningBar_.addStyleName(RES.styles().mismatchBar());
327       }
328       else
329       {
330          mismatchWarningBar_.addStyleName(RES.styles().mismatchBar());
331       }
332    }
333 
334    @Override
getIcon()335    public ImageResource getIcon()
336    {
337       return new ImageResource2x(res_.iconPython2x());
338    }
339 
340    @Override
getName()341    public String getName()
342    {
343       return "Python";
344    }
345 
initialize(String pythonPath)346    protected void initialize(String pythonPath)
347    {
348       initialPythonPath_ = pythonPath;
349 
350       if (!StringUtil.isNullOrEmpty(pythonPath))
351       {
352          tbPythonInterpreter_.setText(pythonPath);
353          updateDescription();
354       }
355 
356       server_.pythonActiveInterpreter(new ServerRequestCallback<PythonInterpreter>()
357       {
358          @Override
359          public void onResponseReceived(PythonInterpreter response)
360          {
361             checkForMismatch(response);
362          }
363 
364          @Override
365          public void onError(ServerError error)
366          {
367             Debug.logError(error);
368          }
369       });
370    }
371 
onApply(boolean isProjectPrefs, CommandWithArg<PythonInterpreter> update)372    protected RestartRequirement onApply(boolean isProjectPrefs,
373                                         CommandWithArg<PythonInterpreter> update)
374    {
375       RestartRequirement requirement = new RestartRequirement();
376 
377       // read current Python path
378       String newValue = tbPythonInterpreter_.getText().trim();
379 
380       // for project preferences, use project-relative path to interpreter
381       if (isProjectPrefs)
382       {
383          FileSystemItem projDir = session_.getSessionInfo().getActiveProjectDir();
384          if (projDir.exists() && newValue.startsWith(projDir.getPath()))
385             newValue = newValue.substring(projDir.getLength() + 1);
386       }
387       else
388       {
389          boolean newAutoActivateValue = cbAutoUseProjectInterpreter_.getValue();
390          if (newAutoActivateValue != initialAutoUseProjectInterpreter_)
391          {
392             prefs_.pythonProjectEnvironmentAutomaticActivate().setGlobalValue(newAutoActivateValue);
393             requirement.setSessionRestartRequired(true);
394          }
395       }
396 
397       // check if the interpreter appears to have been set by the user
398       boolean isValidInterpreterSet =
399             interpreter_ != null &&
400             interpreter_.isValid() &&
401             !StringUtil.isNullOrEmpty(newValue);
402 
403       // if an interpreter was set, update to the new value
404       if (isValidInterpreterSet)
405       {
406          update.execute(interpreter_);
407       }
408       else
409       {
410          update.execute(PythonInterpreter.create());
411       }
412 
413       // restart the IDE if the python path has been changed
414       // (we'd prefer to just restart the session but that's not enough
415       // to refresh requisite project preferences, or so it seems)
416       if (!StringUtil.equals(initialPythonPath_, newValue))
417       {
418          if (isProjectPrefs)
419          {
420             requirement.setRestartRequired();
421          }
422          else
423          {
424             requirement.setSessionRestartRequired(true);
425          }
426       }
427 
428       return requirement;
429    }
430 
431 
432    public interface Styles extends CssResource
433    {
overrideLabel()434       String overrideLabel();
interpreterDescription()435       String interpreterDescription();
mismatchBar()436       String mismatchBar();
437    }
438 
439    public interface Resources extends ClientBundle
440    {
441       @Source("PythonPreferencesPane.css")
styles()442       Styles styles();
443    }
444 
445    protected final InfoBar mismatchWarningBar_;
446    protected final TextBoxWithButton tbPythonInterpreter_;
447    protected final SimplePanel interpreterDescription_ = new SimplePanel();
448 
449    protected CheckBox cbAutoUseProjectInterpreter_;
450    protected boolean initialAutoUseProjectInterpreter_;
451 
452    protected String initialPythonPath_;
453    protected PythonInterpreter interpreter_;
454 
455    protected boolean updatingDescription_;
456 
457    protected PythonDialogResources res_;
458    protected PythonServerOperations server_;
459    protected Session session_;
460    protected UserPrefs prefs_;
461 
462    private String lastValue_ = null;
463 
464 
465    protected static Resources RES = GWT.create(Resources.class);
466    static
467    {
468       RES.styles().ensureInjected();
469    }
470 
471 
472 }
473