1 /*
2  * TerminalPreferencesPane.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 java.util.List;
18 
19 import com.google.gwt.user.client.Command;
20 import com.google.gwt.user.client.ui.HasHorizontalAlignment;
21 import com.google.gwt.user.client.ui.HorizontalPanel;
22 import com.google.gwt.user.client.ui.Panel;
23 import org.rstudio.core.client.BrowseCap;
24 import org.rstudio.core.client.ElementIds;
25 import org.rstudio.core.client.JsArrayUtil;
26 import org.rstudio.core.client.StringUtil;
27 import org.rstudio.core.client.dom.DomUtils;
28 import org.rstudio.core.client.prefs.RestartRequirement;
29 import org.rstudio.core.client.resources.ImageResource2x;
30 import org.rstudio.core.client.theme.DialogTabLayoutPanel;
31 import org.rstudio.core.client.theme.VerticalTabPanel;
32 import org.rstudio.core.client.widget.FileChooserTextBox;
33 import org.rstudio.core.client.widget.FormLabel;
34 import org.rstudio.core.client.widget.SelectWidget;
35 import org.rstudio.core.client.widget.TextBoxWithButton;
36 import org.rstudio.studio.client.common.GlobalDisplay;
37 import org.rstudio.studio.client.common.HelpLink;
38 import org.rstudio.studio.client.server.Server;
39 import org.rstudio.studio.client.server.ServerError;
40 import org.rstudio.studio.client.server.ServerRequestCallback;
41 import org.rstudio.studio.client.workbench.model.Session;
42 import org.rstudio.studio.client.workbench.prefs.model.UserPrefs;
43 import org.rstudio.studio.client.workbench.prefs.model.UserPrefsAccessor;
44 import org.rstudio.studio.client.workbench.views.terminal.TerminalShellInfo;
45 
46 import com.google.gwt.core.client.JsArray;
47 import com.google.gwt.core.client.Scheduler;
48 import com.google.gwt.dom.client.Style.Unit;
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.Label;
52 import com.google.gwt.user.client.ui.TextBox;
53 import com.google.inject.Inject;
54 
55 public class TerminalPreferencesPane extends PreferencesPane
56 {
57 
58    @Inject
TerminalPreferencesPane(UserPrefs prefs, PreferencesDialogResources res, Session session, final GlobalDisplay globalDisplay, final Server server)59    public TerminalPreferencesPane(UserPrefs prefs,
60                                   PreferencesDialogResources res,
61                                   Session session,
62                                   final GlobalDisplay globalDisplay,
63                                   final Server server)
64    {
65       prefs_ = prefs;
66       res_ = res;
67       session_ = session;
68       server_ = server;
69 
70       VerticalTabPanel general = new VerticalTabPanel(ElementIds.TERMINAL_GENERAL_PREFS);
71       VerticalTabPanel closing = new VerticalTabPanel(ElementIds.TERMINAL_CLOSING_PREFS);
72 
73       Label shellLabel = headerLabel("Shell");
74       shellLabel.getElement().getStyle().setMarginTop(8, Unit.PX);
75       general.add(shellLabel);
76 
77       initialDirectory_ = new SelectWidget(
78             "Initial directory:",
79             new String[]
80                   {
81                         "Project directory",
82                         "Current directory",
83                         "Home directory"
84                   },
85             new String[]
86                   {
87                         UserPrefs.TERMINAL_INITIAL_DIRECTORY_PROJECT,
88                         UserPrefs.TERMINAL_INITIAL_DIRECTORY_CURRENT,
89                         UserPrefs.TERMINAL_INITIAL_DIRECTORY_HOME
90                   },
91             false, true, false);
92       spaced(initialDirectory_);
93       general.add(initialDirectory_);
94 
95       terminalShell_ = new SelectWidget("New terminals open with:");
96       spaced(terminalShell_);
97       general.add(terminalShell_);
98       terminalShell_.setEnabled(false);
99       terminalShell_.addChangeHandler(event -> manageCustomShellControlVisibility());
100       terminalShell_.addChangeHandler(event -> managePythonIntegrationControlVisibility());
101 
102       // custom shell exe path chooser
103       Command onShellExePathChosen = new Command()
104       {
105          @Override
106          public void execute()
107          {
108             managePythonIntegrationControlVisibility();
109 
110             if (BrowseCap.isWindowsDesktop())
111             {
112                String shellExePath = customShellChooser_.getText();
113                if (!shellExePath.endsWith(".exe"))
114                {
115                   String message = "The program '" + shellExePath + "'" +
116                      " is unlikely to be a valid shell executable.";
117 
118                   globalDisplay.showMessage(
119                         GlobalDisplay.MSG_WARNING,
120                         "Invalid Shell Executable",
121                         message);
122                }
123             }
124          }
125       };
126 
127       String textboxWidth = "250px";
128       customShellPathLabel_ = new FormLabel("Custom shell binary:");
129       customShellChooser_ = new FileChooserTextBox(customShellPathLabel_,
130                                                    "(Not Found)",
131                                                    ElementIds.TextBoxButtonId.TERMINAL,
132                                                    false,
133                                                    null,
134                                                    onShellExePathChosen);
135       addTextBoxChooser(general, textboxWidth, customShellPathLabel_, customShellChooser_);
136       customShellChooser_.setEnabled(false);
137 
138       customShellOptions_ = new TextBox();
139       DomUtils.disableSpellcheck(customShellOptions_);
140       customShellOptions_.setWidth(textboxWidth);
141       customShellOptions_.setEnabled(false);
142       customShellOptionsLabel_ = new FormLabel("Custom shell command-line options:", customShellOptions_);
143       general.add(spacedBefore(customShellOptionsLabel_));
144       general.add(spaced(customShellOptions_));
145 
146 
147       chkPythonIntegration_ = checkboxPref(
148             "Enable Python integration",
149             prefs_.terminalPythonIntegration());
150 
151       chkPythonIntegration_.setTitle(
152             "When enabled, the active version of Python will be placed on the PATH for new terminal sessions. " +
153             "Only bash and zsh are supported.");
154 
155       general.add(chkPythonIntegration_);
156 
157       Label perfLabel = headerLabel("Connection");
158       perfLabel.getElement().getStyle().setMarginTop(8, Unit.PX);
159       general.add(perfLabel);
160 
161       boolean showPerfLabel = false;
162       if (haveLocalEchoPref())
163       {
164          CheckBox chkTerminalLocalEcho = checkboxPref("Local terminal echo",
165                prefs_.terminalLocalEcho(),
166                "Local echo is more responsive but may get out of sync with some line-editing modes or custom shells.");
167          general.add(chkTerminalLocalEcho);
168          showPerfLabel = true;
169       }
170       if (haveWebsocketPref())
171       {
172          CheckBox chkTerminalWebsocket = checkboxPref("Connect with WebSockets",
173                prefs_.terminalWebsockets(),
174                "WebSockets are generally more responsive; try turning off if terminal won't connect.");
175          general.add(chkTerminalWebsocket);
176          showPerfLabel = true;
177       }
178 
179       perfLabel.setVisible(showPerfLabel);
180 
181       Label displayLabel = headerLabel("Display");
182       displayLabel.getElement().getStyle().setMarginTop(8, Unit.PX);
183       general.add(displayLabel);
184       chkHardwareAcceleration_ = new CheckBox("Hardware acceleration");
185       general.add(lessSpaced(chkHardwareAcceleration_));
186       chkAudibleBell_ = new CheckBox("Audible bell");
187       general.add(lessSpaced(chkAudibleBell_));
188       chkWebLinks_ = new CheckBox("Clickable web links");
189       general.add(chkWebLinks_);
190 
191       HelpLink helpLink = new HelpLink("Using the RStudio terminal", "rstudio_terminal", false);
192       nudgeRight(helpLink);
193       helpLink.addStyleName(res_.styles().newSection());
194       general.add(helpLink);
195 
196       Label miscLabel = headerLabel("Miscellaneous");
197       miscLabel.getElement().getStyle().setMarginTop(8, Unit.PX);
198       closing.add(miscLabel);
199       miscLabel.setVisible(true);
200 
201       autoClosePref_ = new SelectWidget(
202             "When shell exits:",
203             new String[]
204                   {
205                         "Close the pane",
206                         "Don't close the pane",
207                         "Close pane if shell exits cleanly"
208                   },
209             new String[]
210                   {
211                         UserPrefs.TERMINAL_CLOSE_BEHAVIOR_ALWAYS,
212                         UserPrefs.TERMINAL_CLOSE_BEHAVIOR_NEVER,
213                         UserPrefs.TERMINAL_CLOSE_BEHAVIOR_CLEAN
214                   },
215             false, true, false);
216       spaced(autoClosePref_);
217       closing.add(autoClosePref_);
218 
219       if (haveCaptureEnvPref())
220       {
221          CheckBox chkCaptureEnv = checkboxPref("Save and restore environment variables",
222                prefs_.terminalTrackEnvironment(),
223                "Terminal occasionally runs a hidden command to capture state of environment variables.");
224          closing.add(chkCaptureEnv);
225       }
226 
227       if (haveBusyDetectionPref())
228       {
229          Label shutdownLabel = headerLabel("Process Termination");
230          shutdownLabel.getElement().getStyle().setMarginTop(8, Unit.PX);
231          closing.add(shutdownLabel);
232          shutdownLabel.setVisible(true);
233 
234          busyMode_ = new SelectWidget("Ask before killing processes:");
235          spaced(busyMode_);
236          closing.add(busyMode_);
237          busyMode_.setEnabled(false);
238          busyMode_.addChangeHandler(event -> manageBusyModeControlVisibility());
239          busyExclusionList_ = new TextBox();
240          DomUtils.disableSpellcheck(busyExclusionList_);
241          busyExclusionList_.setWidth(textboxWidth);
242          busyExclusionListLabel_ = new FormLabel("Don't ask before killing:", busyExclusionList_);
243          closing.add(busyExclusionListLabel_);
244          closing.add(busyExclusionList_);
245          busyExclusionList_.setEnabled(false);
246       }
247 
248       DialogTabLayoutPanel tabPanel = new DialogTabLayoutPanel("Terminal");
249       tabPanel.setSize("435px", "533px");
250       tabPanel.add(general, "General", general.getBasePanelId());
251       tabPanel.add(closing, "Closing", closing.getBasePanelId());
252       tabPanel.selectTab(0);
253       add(tabPanel);
254    }
255 
256    @Override
getIcon()257    public ImageResource getIcon()
258    {
259       return new ImageResource2x(res_.iconTerminal2x());
260    }
261 
262    @Override
getName()263    public String getName()
264    {
265       return "Terminal";
266    }
267 
268    @Override
initialize(UserPrefs prefs)269    protected void initialize(UserPrefs prefs)
270    {
271       Scheduler.get().scheduleDeferred(() -> server_.getTerminalShells(
272             new ServerRequestCallback<JsArray<TerminalShellInfo>>()
273       {
274          @Override
275          public void onResponseReceived(JsArray<TerminalShellInfo> shells)
276          {
277             String currentShell = BrowseCap.isWindowsDesktop() ?
278                prefs.windowsTerminalShell().getValue() :
279                prefs.posixTerminalShell().getValue();
280             int currentShellIndex = 0;
281 
282             TerminalPreferencesPane.this.terminalShell_.getListBox().clear();
283 
284             boolean hasCustom = false;
285 
286             for (int i = 0; i < shells.length(); i++)
287             {
288                TerminalShellInfo info = shells.get(i);
289                if (StringUtil.equals(info.getShellType(), UserPrefs.WINDOWS_TERMINAL_SHELL_CUSTOM))
290                   hasCustom = true;
291                TerminalPreferencesPane.this.terminalShell_.addChoice(
292                      info.getShellName(), info.getShellType());
293                if (info.getShellType() == currentShell)
294                   currentShellIndex = i;
295             }
296             if (TerminalPreferencesPane.this.terminalShell_.getListBox().getItemCount() > 0)
297             {
298                TerminalPreferencesPane.this.terminalShell_.setEnabled((true));
299                TerminalPreferencesPane.this.terminalShell_.getListBox().setSelectedIndex(currentShellIndex);
300             }
301 
302             if (hasCustom)
303             {
304                customShellChooser_.setText(prefs.customShellCommand().getValue());
305                customShellChooser_.setEnabled(true);
306                customShellOptions_.setText(prefs.customShellOptions().getValue());
307                customShellOptions_.setEnabled(true);
308             }
309             manageCustomShellControlVisibility();
310          }
311 
312          @Override
313          public void onError(ServerError error) { }
314       }));
315 
316       if (busyMode_ != null)
317       {
318          busyMode_.getListBox().clear();
319          busyMode_.addChoice("Always", UserPrefs.BUSY_DETECTION_ALWAYS);
320          busyMode_.addChoice("Never", UserPrefs.BUSY_DETECTION_NEVER);
321          busyMode_.addChoice("Always except for list", UserPrefs.BUSY_DETECTION_LIST);
322          busyMode_.setEnabled(true);
323 
324          prefs_.busyDetection().getValue();
325          for (int i = 0; i < busyMode_.getListBox().getItemCount(); i++)
326          {
327             if (busyMode_.getListBox().getValue(i) == prefs_.busyDetection().getValue())
328             {
329                busyMode_.getListBox().setSelectedIndex(i);
330             }
331          }
332 
333          List<String> exclusionArray = JsArrayUtil.fromJsArrayString(
334                prefs_.busyExclusionList().getValue());
335 
336          StringBuilder exclusionList = new StringBuilder();
337          for (String entry: exclusionArray)
338          {
339             if (entry.trim().isEmpty())
340             {
341                continue;
342             }
343             if (exclusionList.length() > 0)
344             {
345                exclusionList.append(" ");
346             }
347             exclusionList.append(entry.trim());
348          }
349 
350          busyExclusionList_.setText(exclusionList.toString());
351          busyExclusionList_.setEnabled(true);
352 
353          manageBusyModeControlVisibility();
354       }
355 
356       chkAudibleBell_.setValue(prefs_.terminalBellStyle().getValue() == UserPrefsAccessor.TERMINAL_BELL_STYLE_SOUND);
357       chkWebLinks_.setValue(prefs_.terminalWeblinks().getValue());
358       chkHardwareAcceleration_.setValue(prefs_.terminalRenderer().getValue() == UserPrefsAccessor.TERMINAL_RENDERER_CANVAS);
359 
360       if (!initialDirectory_.setValue(prefs.terminalInitialDirectory().getValue()))
361          initialDirectory_.getListBox().setSelectedIndex(0);
362 
363       if (!autoClosePref_.setValue(prefs.terminalCloseBehavior().getValue()))
364          autoClosePref_.getListBox().setSelectedIndex(0);
365    }
366 
367    @Override
onApply(UserPrefs rPrefs)368    public RestartRequirement onApply(UserPrefs rPrefs)
369    {
370       RestartRequirement restartRequirement = super.onApply(rPrefs);
371 
372       if (haveBusyDetectionPref())
373       {
374          prefs_.busyExclusionList().setGlobalValue(StringUtil.split(busyExclusionList_.getText(), " "));
375          prefs_.busyDetection().setGlobalValue(selectedBusyMode());
376       }
377 
378       if (BrowseCap.isWindowsDesktop())
379          prefs_.windowsTerminalShell().setGlobalValue(selectedShellType());
380       else
381          prefs_.posixTerminalShell().setGlobalValue(selectedShellType());
382 
383       prefs_.customShellCommand().setGlobalValue(customShellChooser_.getText());
384       prefs_.customShellOptions().setGlobalValue(customShellOptions_.getText());
385 
386       prefs_.terminalBellStyle().setGlobalValue(chkAudibleBell_.getValue() ?
387             UserPrefsAccessor.TERMINAL_BELL_STYLE_SOUND : UserPrefsAccessor.TERMINAL_BELL_STYLE_NONE);
388       prefs_.terminalRenderer().setGlobalValue(chkHardwareAcceleration_.getValue() ?
389             UserPrefsAccessor.TERMINAL_RENDERER_CANVAS : UserPrefsAccessor.TERMINAL_RENDERER_DOM);
390       prefs_.terminalWeblinks().setGlobalValue(chkWebLinks_.getValue());
391 
392       prefs_.terminalInitialDirectory().setGlobalValue(initialDirectory_.getValue());
393       prefs_.terminalCloseBehavior().setGlobalValue(autoClosePref_.getValue());
394 
395       return restartRequirement;
396    }
397 
haveLocalEchoPref()398    private boolean haveLocalEchoPref()
399    {
400       return !BrowseCap.isWindowsDesktop();
401    }
402 
haveBusyDetectionPref()403    private boolean haveBusyDetectionPref()
404    {
405       return !BrowseCap.isWindowsDesktop();
406    }
407 
haveWebsocketPref()408    private boolean haveWebsocketPref()
409    {
410       return session_.getSessionInfo().getAllowTerminalWebsockets();
411    }
412 
haveCaptureEnvPref()413    private boolean haveCaptureEnvPref()
414    {
415       return !BrowseCap.isWindowsDesktop();
416    }
417 
selectedShellType()418    private String selectedShellType()
419    {
420       return terminalShell_.getListBox().getSelectedValue();
421    }
422 
manageCustomShellControlVisibility()423    private void manageCustomShellControlVisibility()
424    {
425       boolean customEnabled = (selectedShellType() == UserPrefs.WINDOWS_TERMINAL_SHELL_CUSTOM);
426       customShellPathLabel_.setVisible(customEnabled);
427       customShellChooser_.setVisible(customEnabled);
428       customShellOptionsLabel_.setVisible(customEnabled);
429       customShellOptions_.setVisible(customEnabled);
430    }
431 
pythonIntegrationSupported()432    private boolean pythonIntegrationSupported()
433    {
434       String shell = terminalShell_.getValue();
435       if (StringUtil.equals(shell, "bash") ||
436           StringUtil.equals(shell, "zsh"))
437       {
438          return true;
439       }
440 
441       if (StringUtil.equals(shell, "custom"))
442       {
443          String shellPath = customShellChooser_.getText();
444          if (shellPath.endsWith("bash") ||
445              shellPath.endsWith("zsh") ||
446              shellPath.endsWith("bash.exe") ||
447              shellPath.endsWith("zsh.exe"))
448          {
449             return true;
450          }
451       }
452 
453       return false;
454    }
455 
managePythonIntegrationControlVisibility()456    private void managePythonIntegrationControlVisibility()
457    {
458       if (pythonIntegrationSupported())
459       {
460          chkPythonIntegration_.setEnabled(true);
461          chkPythonIntegration_.setVisible(true);
462       }
463       else
464       {
465          chkPythonIntegration_.setEnabled(false);
466          chkPythonIntegration_.setVisible(false);
467       }
468    }
469 
selectedBusyMode()470    private String selectedBusyMode()
471    {
472       int idx = busyMode_.getListBox().getSelectedIndex();
473       return busyMode_.getListBox().getValue(idx);
474    }
475 
manageBusyModeControlVisibility()476    private void manageBusyModeControlVisibility()
477    {
478       boolean exclusionListEnabled = selectedBusyMode() == UserPrefs.BUSY_DETECTION_LIST;
479       busyExclusionListLabel_.setVisible(exclusionListEnabled);
480       busyExclusionList_.setVisible(exclusionListEnabled);
481    }
482 
addTextBoxChooser(Panel panel, String textWidth, FormLabel captionLabel, TextBoxWithButton chooser)483    private void addTextBoxChooser(Panel panel, String textWidth, FormLabel captionLabel, TextBoxWithButton chooser)
484    {
485       HorizontalPanel captionPanel = new HorizontalPanel();
486       captionPanel.setWidth(textWidth);
487       nudgeRight(captionPanel);
488 
489       captionPanel.add(captionLabel);
490       captionPanel.setCellHorizontalAlignment(captionLabel, HasHorizontalAlignment.ALIGN_LEFT);
491 
492       panel.add(tight(captionPanel));
493 
494       chooser.setTextWidth(textWidth);
495       nudgeRight(chooser);
496       textBoxWithChooser(chooser);
497       spaced(chooser);
498       panel.add(chooser);
499    }
500 
501    private final SelectWidget terminalShell_;
502    private final FormLabel customShellPathLabel_;
503    private final TextBoxWithButton customShellChooser_;
504    private final FormLabel customShellOptionsLabel_;
505    private final TextBox customShellOptions_;
506    private final SelectWidget initialDirectory_;
507 
508    private final CheckBox chkHardwareAcceleration_;
509    private final CheckBox chkAudibleBell_;
510    private final CheckBox chkWebLinks_;
511    private final CheckBox chkPythonIntegration_;
512 
513    private SelectWidget autoClosePref_;
514    private SelectWidget busyMode_;
515    private FormLabel busyExclusionListLabel_;
516    private TextBox busyExclusionList_;
517 
518    // Injected ----
519    private final UserPrefs prefs_;
520    private final PreferencesDialogResources res_;
521    private final Session session_;
522    private final Server server_;
523  }
524