terminal: trim command when matching command for smart execution and when smart execu...
[idea/community.git] / plugins / terminal / src / org / jetbrains / plugins / terminal / TerminalShellCommandHandlerHelper.java
1 // Copyright 2000-2020 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file.
2 package org.jetbrains.plugins.terminal;
3
4 import com.google.common.base.Ascii;
5 import com.intellij.execution.Executor;
6 import com.intellij.execution.ExecutorRegistry;
7 import com.intellij.execution.executors.DefaultRunExecutor;
8 import com.intellij.ide.util.PropertiesComponent;
9 import com.intellij.notification.*;
10 import com.intellij.openapi.actionSystem.ActionManager;
11 import com.intellij.openapi.actionSystem.AnAction;
12 import com.intellij.openapi.actionSystem.KeyboardShortcut;
13 import com.intellij.openapi.application.ApplicationManager;
14 import com.intellij.openapi.application.Experiments;
15 import com.intellij.openapi.application.ReadAction;
16 import com.intellij.openapi.diagnostic.Logger;
17 import com.intellij.openapi.keymap.KeymapUtil;
18 import com.intellij.openapi.options.ShowSettingsUtil;
19 import com.intellij.openapi.project.Project;
20 import com.intellij.openapi.util.Disposer;
21 import com.intellij.openapi.util.text.StringUtil;
22 import com.intellij.openapi.wm.ToolWindowId;
23 import com.intellij.terminal.TerminalShellCommandHandler;
24 import com.intellij.util.Alarm;
25 import com.jediterm.terminal.StyledTextConsumerAdapter;
26 import com.jediterm.terminal.SubstringFinder;
27 import com.jediterm.terminal.TextStyle;
28 import com.jediterm.terminal.TtyConnector;
29 import com.jediterm.terminal.model.CharBuffer;
30 import com.jediterm.terminal.model.TerminalModelListener;
31 import org.jetbrains.annotations.NonNls;
32 import org.jetbrains.annotations.NotNull;
33 import org.jetbrains.annotations.Nullable;
34 import org.jetbrains.plugins.terminal.arrangement.TerminalWorkingDirectoryManager;
35 import org.jetbrains.plugins.terminal.shellCommandRunner.TerminalDebugSmartCommandAction;
36 import org.jetbrains.plugins.terminal.shellCommandRunner.TerminalExecutorAction;
37 import org.jetbrains.plugins.terminal.shellCommandRunner.TerminalRunSmartCommandAction;
38
39 import javax.swing.*;
40 import javax.swing.event.HyperlinkEvent;
41 import java.awt.event.KeyEvent;
42 import java.io.IOException;
43 import java.util.Arrays;
44 import java.util.Objects;
45 import java.util.concurrent.atomic.AtomicBoolean;
46
47 public final class TerminalShellCommandHandlerHelper {
48   private static final Logger LOG = Logger.getInstance(TerminalShellCommandHandler.class);
49   @NonNls private static final String TERMINAL_CUSTOM_COMMANDS_GOT_IT = "TERMINAL_CUSTOM_COMMANDS_GOT_IT";
50   @NonNls private static final String GOT_IT = "got_it";
51   @NonNls private static final String FEATURE_ID = "terminal.shell.command.handling";
52
53   private static Experiments ourExperiments;
54   private static final NotificationGroup ourToolWindowGroup =
55     NotificationGroup.toolWindowGroup("Terminal", TerminalToolWindowFactory.TOOL_WINDOW_ID);
56   private final ShellTerminalWidget myWidget;
57   private final Alarm myAlarm;
58   private volatile String myWorkingDirectory;
59   private volatile Boolean myHasRunningCommands;
60   private PropertiesComponent myPropertiesComponent;
61   private final SingletonNotificationManager mySingletonNotificationManager =
62     new SingletonNotificationManager(ourToolWindowGroup, NotificationType.INFORMATION, null);
63   private final AtomicBoolean myKeyPressed = new AtomicBoolean(false);
64
65   TerminalShellCommandHandlerHelper(@NotNull ShellTerminalWidget widget) {
66     myWidget = widget;
67     myAlarm = new Alarm(Alarm.ThreadToUse.POOLED_THREAD, widget);
68
69     ApplicationManager.getApplication().getMessageBus().connect(myWidget).subscribe(
70       TerminalCommandHandlerCustomizer.Companion.getTERMINAL_COMMAND_HANDLER_TOPIC(), () -> scheduleCommandHighlighting());
71
72     TerminalModelListener listener = () -> {
73       if (myKeyPressed.compareAndSet(true, false)) {
74         scheduleCommandHighlighting();
75       }
76     };
77     widget.getTerminalTextBuffer().addModelListener(listener);
78     Disposer.register(myWidget, () -> widget.getTerminalTextBuffer().removeModelListener(listener));
79   }
80
81   public void processKeyPressed() {
82     if (isFeatureEnabled()) {
83       myKeyPressed.set(true);
84       scheduleCommandHighlighting();
85     }
86   }
87
88   private void scheduleCommandHighlighting() {
89     myAlarm.cancelAllRequests();
90     myAlarm.addRequest(() -> { highlightMatchedCommand(myWidget.getProject()); }, 0);
91   }
92
93   public static boolean isFeatureEnabled() {
94     Experiments experiments = ourExperiments;
95     if (experiments == null) {
96       experiments = ReadAction.compute(() -> {
97         return ApplicationManager.getApplication().isDisposed() ? null : Experiments.getInstance();
98       });
99       ourExperiments = experiments;
100     }
101     return experiments != null && experiments.isFeatureEnabled(FEATURE_ID);
102   }
103
104   private void highlightMatchedCommand(@NotNull Project project) {
105     if (!isEnabledForProject()) {
106       myWidget.getTerminalPanel().setFindResult(null);
107       return;
108     }
109
110     //highlight matched command
111     String command = myWidget.getTypedShellCommand().trim();
112     SubstringFinder.FindResult result =
113       TerminalShellCommandHandler.Companion.matches(project, getWorkingDirectory(), !hasRunningCommands(), command)
114       ? searchMatchedCommand(command) : null;
115     myWidget.getTerminalPanel().setFindResult(result);
116
117     //show notification
118     if (getPropertiesComponent().getBoolean(TERMINAL_CUSTOM_COMMANDS_GOT_IT, false)) {
119       return;
120     }
121
122     if (result != null) {
123       String title = TerminalBundle.message("smart_command_execution.notification.title");
124       String content = TerminalBundle.message("smart_command_execution.notification.text",
125                                               KeymapUtil.getFirstKeyboardShortcutText(getRunAction()),
126                                               KeymapUtil.getFirstKeyboardShortcutText(getDebugAction()),
127                                               ShowSettingsUtil.getSettingsMenuName(),
128                                               GOT_IT);
129       NotificationListener.Adapter listener = new NotificationListener.Adapter() {
130         @Override
131         protected void hyperlinkActivated(@NotNull Notification notification, @NotNull HyperlinkEvent e) {
132           if (GOT_IT.equals(e.getDescription())) {
133             getPropertiesComponent().setValue(TERMINAL_CUSTOM_COMMANDS_GOT_IT, true, false);
134           }
135         }
136       };
137       mySingletonNotificationManager.notify(title, content, project, listener);
138     }
139   }
140
141   private boolean isEnabledForProject() {
142     return getPropertiesComponent().getBoolean(TerminalCommandHandlerCustomizer.TERMINAL_CUSTOM_COMMAND_EXECUTION, true);
143   }
144
145   @NotNull
146   private PropertiesComponent getPropertiesComponent() {
147     PropertiesComponent propertiesComponent = myPropertiesComponent;
148     if (propertiesComponent == null) {
149       propertiesComponent = ReadAction.compute(() -> PropertiesComponent.getInstance());
150       myPropertiesComponent = propertiesComponent;
151     }
152     return propertiesComponent;
153   }
154
155   @Nullable
156   private String getWorkingDirectory() {
157     String workingDirectory = myWorkingDirectory;
158     if (workingDirectory == null) {
159       workingDirectory = StringUtil.notNullize(TerminalWorkingDirectoryManager.getWorkingDirectory(myWidget, null));
160       myWorkingDirectory = workingDirectory;
161     }
162     return StringUtil.nullize(workingDirectory);
163   }
164
165   private boolean hasRunningCommands() {
166     Boolean hasRunningCommands = myHasRunningCommands;
167     if (hasRunningCommands == null) {
168       hasRunningCommands = myWidget.hasRunningCommands();
169       myHasRunningCommands = hasRunningCommands;
170     }
171     return hasRunningCommands;
172   }
173
174   private @Nullable SubstringFinder.FindResult searchMatchedCommand(@NotNull String pattern) {
175     if (pattern.length() == 0) {
176       return null;
177     }
178
179     return myWidget.processTerminalBuffer(textBuffer -> {
180       int cursorLine = myWidget.getLineNumberAtCursor();
181       if (cursorLine < 0 || cursorLine >= textBuffer.getHeight()) {
182         return null;
183       }
184       String lineText = textBuffer.getLine(cursorLine).getText();
185       int patternStartInd = lineText.lastIndexOf(pattern);
186       if (patternStartInd < 0) {
187         return null;
188       }
189       SubstringFinder finder = new SubstringFinder(pattern, true) {
190         @Override
191         public boolean accept(@NotNull FindResult.FindItem item) {
192           return item.getStart().x >= patternStartInd;
193         }
194       };
195       textBuffer.processScreenLines(cursorLine, 1, new StyledTextConsumerAdapter() {
196         @Override
197         public void consume(int x, int y, @NotNull TextStyle style, @NotNull CharBuffer characters, int startRow) {
198           for (int i = 0; i < characters.length(); i++) {
199             finder.nextChar(x, y - startRow, characters, i);
200           }
201         }
202       });
203       return finder.getResult();
204     });
205   }
206
207   public boolean processEnterKeyPressed(@NotNull KeyEvent keyPressed) {
208     if (!isFeatureEnabled() || !isEnabledForProject()) {
209       onShellCommandExecuted();
210       return false;
211     }
212     String command = myWidget.getTypedShellCommand().trim();
213     if (LOG.isDebugEnabled()) {
214       LOG.debug("typed shell command to execute: " + command);
215     }
216     myAlarm.cancelAllRequests();
217
218     Project project = myWidget.getProject();
219     String workingDirectory = getWorkingDirectory();
220     boolean localSession = !hasRunningCommands();
221     if (!TerminalShellCommandHandler.Companion.matches(project, workingDirectory, localSession, command)) {
222       onShellCommandExecuted();
223       return false;
224     }
225
226     TerminalShellCommandHandler handler = TerminalShellCommandHandler.Companion.getEP().getExtensionList().stream()
227       .filter(it -> it.matches(project, workingDirectory, localSession, command))
228       .findFirst()
229       .orElseThrow(() -> new RuntimeException("Cannot find matching command handler."));
230
231     Executor executor = matchedExecutor(keyPressed);
232     if (executor == null) {
233       onShellCommandExecuted();
234       TerminalUsageTriggerCollector.Companion.triggerSmartCommand(project, workingDirectory, localSession, command, handler, false);
235       return false;
236     }
237
238     TerminalUsageTriggerCollector.Companion.triggerSmartCommand(project, workingDirectory, localSession, command, handler, true);
239     TerminalShellCommandHandler.Companion.executeShellCommandHandler(myWidget.getProject(), getWorkingDirectory(),
240                                                                      !hasRunningCommands(), command, executor);
241     clearTypedCommand(command);
242     return true;
243   }
244
245   private void onShellCommandExecuted() {
246     myWorkingDirectory = null;
247     myHasRunningCommands = null;
248   }
249
250   private void clearTypedCommand(@NotNull String command) {
251     TtyConnector connector = myWidget.getTtyConnector();
252     byte[] array = new byte[command.length()];
253     Arrays.fill(array, Ascii.BS);
254     try {
255       connector.write(array);
256     }
257     catch (IOException e) {
258       LOG.info("Cannot clear shell command " + command, e);
259     }
260   }
261
262   @Nullable
263   static Executor matchedExecutor(@NotNull KeyEvent e) {
264     if (matchedRunAction(e) != null) {
265       return DefaultRunExecutor.getRunExecutorInstance();
266     } else if (matchedDebugAction(e) != null) {
267       return ExecutorRegistry.getInstance().getExecutorById(ToolWindowId.DEBUG);
268     } else {
269       return null;
270     }
271   }
272
273   private static TerminalExecutorAction matchedRunAction(@NotNull KeyEvent e) {
274     final KeyboardShortcut eventShortcut = new KeyboardShortcut(KeyStroke.getKeyStrokeForEvent(e), null);
275     AnAction action = getRunAction();
276     return action instanceof TerminalRunSmartCommandAction
277            && Arrays.stream(action.getShortcutSet().getShortcuts()).anyMatch(sc -> sc.isKeyboard() && sc.startsWith(eventShortcut))
278            ? ((TerminalRunSmartCommandAction)action)
279            : null;
280   }
281
282   private static TerminalExecutorAction matchedDebugAction(@NotNull KeyEvent e) {
283     final KeyboardShortcut eventShortcut = new KeyboardShortcut(KeyStroke.getKeyStrokeForEvent(e), null);
284     AnAction action = getDebugAction();
285     return action instanceof TerminalDebugSmartCommandAction
286            && Arrays.stream(action.getShortcutSet().getShortcuts()).anyMatch(sc -> sc.isKeyboard() && sc.startsWith(eventShortcut))
287            ? ((TerminalDebugSmartCommandAction)action)
288            : null;
289   }
290
291   @NotNull
292   private static AnAction getRunAction() {
293     return Objects.requireNonNull(ActionManager.getInstance().getAction("Terminal.SmartCommandExecution.Run"));
294   }
295
296   @NotNull
297   private static AnAction getDebugAction() {
298     return Objects.requireNonNull(ActionManager.getInstance().getAction("Terminal.SmartCommandExecution.Debug"));
299   }
300 }