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