diff --git a/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/script/GhidraScriptActionManager.java b/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/script/GhidraScriptActionManager.java index d7eff4e9ac..d1aea472a7 100644 --- a/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/script/GhidraScriptActionManager.java +++ b/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/script/GhidraScriptActionManager.java @@ -285,7 +285,17 @@ class GhidraScriptActionManager { private void chooseScript(ActionContext actioncontext1) { List scriptInfos = provider.getScriptInfos(); - ScriptSelectionDialog dialog = new ScriptSelectionDialog(plugin, scriptInfos); + + // Get the last run script name to pre-populate the dialog + String initialScript = null; + ResourceFile lastRunScript = provider.getLastRunScript(); + if (lastRunScript != null) { + initialScript = lastRunScript.getName(); + } + + ScriptSelectionDialog dialog = + new ScriptSelectionDialog(plugin, scriptInfos, provider.getRecentScripts(), + initialScript); dialog.show(); ScriptInfo chosenInfo = dialog.getUserChoice(); diff --git a/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/script/GhidraScriptComponentProvider.java b/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/script/GhidraScriptComponentProvider.java index b11b61e6a6..cfe7703272 100644 --- a/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/script/GhidraScriptComponentProvider.java +++ b/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/script/GhidraScriptComponentProvider.java @@ -67,6 +67,8 @@ public class GhidraScriptComponentProvider extends ComponentProviderAdapter { private static final double TOP_PREFERRED_RESIZE_WEIGHT = .80; private static final String DESCRIPTION_DIVIDER_LOCATION = "DESCRIPTION_DIVIDER_LOCATION"; private static final String FILTER_TEXT = "FILTER_TEXT"; + private static final String RECENT_SCRIPTS = "RECENT_SCRIPTS"; + private static final int MAX_RECENT_SCRIPTS = 10; private Map editorMap = new HashMap<>(); private final GhidraScriptMgrPlugin plugin; @@ -88,6 +90,7 @@ public class GhidraScriptComponentProvider extends ComponentProviderAdapter { private String[] previousCategory; private ResourceFile lastRunScript; + private LinkedList recentScripts = new LinkedList<>(); private WeakSet runningScriptTaskSet = WeakDataStructureFactory.createCopyOnReadWeakSet(); private TaskListener cleanupTaskSetListener = new TaskListener() { @@ -311,6 +314,12 @@ public class GhidraScriptComponentProvider extends ComponentProviderAdapter { String filterText = saveState.getString(FILTER_TEXT, ""); tableFilterPanel.setFilterText(filterText); + + String[] scripts = saveState.getStrings(RECENT_SCRIPTS, new String[0]); + recentScripts.clear(); + for (String script : scripts) { + recentScripts.add(script); + } } /** @@ -332,6 +341,9 @@ public class GhidraScriptComponentProvider extends ComponentProviderAdapter { String filterText = tableFilterPanel.getFilterText(); saveState.putString(FILTER_TEXT, filterText); + + String[] scripts = recentScripts.toArray(new String[0]); + saveState.putStrings(RECENT_SCRIPTS, scripts); } void dispose() { @@ -364,6 +376,10 @@ public class GhidraScriptComponentProvider extends ComponentProviderAdapter { return editorMap; } + LinkedList getRecentScripts() { + return recentScripts; + } + void assignKeyBinding() { ResourceFile script = getSelectedScript(); ScriptAction action = actionManager.createAction(script); @@ -664,6 +680,17 @@ public class GhidraScriptComponentProvider extends ComponentProviderAdapter { void runScript(ResourceFile scriptFile, TaskListener listener) { lastRunScript = scriptFile; + + // Update recent scripts list + String scriptName = scriptFile.getName(); + recentScripts.remove(scriptName); // Remove if already exists + recentScripts.addFirst(scriptName); // Add to front (most recent) + + // Trim to max size + while (recentScripts.size() > MAX_RECENT_SCRIPTS) { + recentScripts.removeLast(); + } + GhidraScript script = doGetScriptInstance(scriptFile); if (script != null) { doRunScript(script, listener); diff --git a/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/script/ScriptEditorListener.java b/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/script/ScriptGroup.java similarity index 54% rename from Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/script/ScriptEditorListener.java rename to Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/script/ScriptGroup.java index ad218a0359..3f888fba87 100644 --- a/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/script/ScriptEditorListener.java +++ b/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/script/ScriptGroup.java @@ -4,9 +4,9 @@ * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at - * + * * http://www.apache.org/licenses/LICENSE-2.0 - * + * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -16,17 +16,28 @@ package ghidra.app.plugin.core.script; /** - * A simple listener to know when users have chosen a script in the {@link ScriptSelectionDialog} + * Categories for organizing scripts in the Script Quick Launch dialog. */ -public interface ScriptEditorListener { +public enum ScriptGroup { + RECENT_SCRIPTS("Recent Scripts"), + ALL_SCRIPTS("All Scripts"); - /** - * Called when the user makes a selection. - */ - public void editingStopped(); + private String displayName; - /** - * Called when the user cancels the script selection process. - */ - public void editingCancelled(); + private ScriptGroup(String displayName) { + this.displayName = displayName; + } + + public String getDisplayName() { + return displayName; + } + + public static ScriptGroup getGroupByDisplayName(String name) { + for (ScriptGroup group : values()) { + if (group.getDisplayName().equals(name)) { + return group; + } + } + return null; + } } diff --git a/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/script/ScriptSelectionDialog.java b/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/script/ScriptSelectionDialog.java index db13ddb849..3515ddd00f 100644 --- a/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/script/ScriptSelectionDialog.java +++ b/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/script/ScriptSelectionDialog.java @@ -4,9 +4,9 @@ * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at - * + * * http://www.apache.org/licenses/LICENSE-2.0 - * + * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -15,102 +15,82 @@ */ package ghidra.app.plugin.core.script; -import java.awt.BorderLayout; +import java.awt.*; +import java.util.LinkedList; import java.util.List; -import javax.swing.JComponent; -import javax.swing.JPanel; -import javax.swing.event.DocumentEvent; -import javax.swing.event.DocumentListener; +import javax.swing.*; import docking.DialogComponentProvider; +import docking.widgets.list.GListCellRenderer; +import docking.widgets.searchlist.SearchList; +import docking.widgets.searchlist.SearchListEntry; +import generic.theme.GThemeDefaults.Colors.Palette; import ghidra.app.script.ScriptInfo; import ghidra.framework.plugintool.PluginTool; import ghidra.util.HelpLocation; +import ghidra.util.HTMLUtilities; import ghidra.util.Swing; /** - * A dialog that prompts the user to select a script. + * A dialog that prompts the user to select a script from a searchable list + * organized into "Recent Scripts" and "All Scripts" categories. */ public class ScriptSelectionDialog extends DialogComponentProvider { - private ScriptSelectionEditor editor; private PluginTool tool; private List scriptInfos; + private LinkedList recentScripts; + private String initialScript; private ScriptInfo userChoice; + private SearchList searchList; - ScriptSelectionDialog(GhidraScriptMgrPlugin plugin, List scriptInfos) { + ScriptSelectionDialog(GhidraScriptMgrPlugin plugin, List scriptInfos, + LinkedList recentScripts, String initialScript) { super("Run Script", true, true, true, false); this.tool = plugin.getTool(); this.scriptInfos = scriptInfos; + this.recentScripts = recentScripts; + this.initialScript = initialScript; - init(); + addWorkPanel(buildMainPanel()); + addOKButton(); + addCancelButton(); setHelpLocation(new HelpLocation(plugin.getName(), "Script Quick Launch")); } - private void init() { - buildEditor(); + private JComponent buildMainPanel() { + JPanel panel = new JPanel(new BorderLayout()); - addOKButton(); - addCancelButton(); + ScriptsModel model = new ScriptsModel(scriptInfos, recentScripts); + searchList = new SearchList<>(model, (script, category) -> scriptChosen(script)); + searchList.setItemRenderer(new ScriptRenderer()); + searchList.setDisplayNameFunction((script, category) -> script.getName()); + + // Pre-select the initial script if provided + if (initialScript != null && !initialScript.isEmpty()) { + Swing.runLater(() -> { + for (ScriptInfo info : scriptInfos) { + if (info.getName().equals(initialScript)) { + searchList.setSelectedItem(info); + break; + } + } + }); + } + + panel.add(searchList, BorderLayout.CENTER); + panel.setPreferredSize(new Dimension(600, 400)); + + return panel; } - private void buildEditor() { - removeWorkPanel(); - - editor = new ScriptSelectionEditor(scriptInfos); - - editor.setConsumeEnterKeyPress(false); // we want to handle Enter key presses - - editor.addEditorListener(new ScriptEditorListener() { - @Override - public void editingCancelled() { - if (isVisible()) { - cancelCallback(); - } - } - - @Override - public void editingStopped() { - if (isVisible()) { - okCallback(); - } - } - }); - editor.addDocumentListener(new DocumentListener() { - - @Override - public void changedUpdate(DocumentEvent e) { - textUpdated(); - } - - @Override - public void insertUpdate(DocumentEvent e) { - textUpdated(); - } - - @Override - public void removeUpdate(DocumentEvent e) { - textUpdated(); - } - - private void textUpdated() { - clearStatusText(); - } - - }); - - JComponent mainPanel = createEditorPanel(); - addWorkPanel(mainPanel); - - rootPanel.validate(); - } - - private JComponent createEditorPanel() { - JPanel mainPanel = new JPanel(new BorderLayout()); - mainPanel.add(editor.getEditorComponent(), BorderLayout.NORTH); - return mainPanel; + private void scriptChosen(ScriptInfo script) { + if (script != null) { + userChoice = script; + close(); + } } public void show() { @@ -123,38 +103,94 @@ public class ScriptSelectionDialog extends DialogComponentProvider { @Override protected void dialogShown() { - Swing.runLater(() -> editor.requestFocus()); + Swing.runLater(() -> searchList.getFilterField().requestFocus()); } - // overridden to set the user choice to null @Override protected void cancelCallback() { userChoice = null; super.cancelCallback(); } - // overridden to perform validation and to get the user's choice @Override protected void okCallback() { + ScriptInfo selectedScript = searchList.getSelectedItem(); - if (!editor.validateUserSelection()) { - setStatusText("Invalid script name"); + if (selectedScript == null) { + setStatusText("Please select a script"); return; } - userChoice = editor.getEditorValue(); - + userChoice = selectedScript; clearStatusText(); close(); } - // overridden to re-create the editor each time we are closed so that the editor's windows - // are properly parented for each new dialog - @Override - public void close() { - buildEditor(); - setStatusText(""); - super.close(); - } +//================================================================================================= +// Inner Classes +//================================================================================================= + /** + * Custom renderer for script entries in the search list. + */ + private class ScriptRenderer extends GListCellRenderer> { + { + setHTMLRenderingEnabled(true); + } + + @Override + public Component getListCellRendererComponent(JList> list, + SearchListEntry entry, int index, boolean isSelected, boolean cellHasFocus) { + + super.getListCellRendererComponent(list, entry, index, isSelected, cellHasFocus); + + if (entry == null) { + return this; + } + + ScriptInfo script = entry.value(); + + // Build display text + StringBuilder html = new StringBuilder(""); + + // Script name + html.append("").append(HTMLUtilities.escapeHTML(script.getName())).append(""); + + // Keybinding if available + KeyStroke keyBinding = script.getKeyBinding(); + if (keyBinding != null) { + html.append(" (") + .append(keyBinding.toString()) + .append(")"); + } + + // Description on next line + String description = script.getDescription(); + if (description != null && !description.isEmpty()) { + html.append("
") + .append(HTMLUtilities.escapeHTML(truncateDescription(description))) + .append(""); + } + + html.append(""); + + setText(html.toString()); + setIcon(script.getToolBarImage(false)); + + return this; + } + + private String truncateDescription(String description) { + // Remove newlines and limit length for display + String clean = description.replaceAll("\\s+", " ").trim(); + if (clean.length() > 100) { + return clean.substring(0, 97) + "..."; + } + return clean; + } + } } diff --git a/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/script/ScriptSelectionEditor.java b/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/script/ScriptSelectionEditor.java deleted file mode 100644 index f00d5880b1..0000000000 --- a/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/script/ScriptSelectionEditor.java +++ /dev/null @@ -1,303 +0,0 @@ -/* ### - * IP: GHIDRA - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package ghidra.app.plugin.core.script; - -import java.util.*; - -import javax.swing.*; -import javax.swing.event.*; - -import org.apache.commons.lang3.StringUtils; - -import docking.widgets.*; -import generic.theme.GThemeDefaults.Colors.Palette; -import ghidra.app.script.ScriptInfo; -import ghidra.util.HTMLUtilities; - -/** - * A widget that allows the user to choose an existing script by typing its name or picking it - * from a list. - */ -public class ScriptSelectionEditor { - - private JPanel editorPanel; - private DropDownSelectionTextField selectionField; - private TreeMap scriptMap = new TreeMap<>(); - - // we use a simple listener data structure, since this widget is transient and nothing more - // advanced should be needed - private List listeners = new ArrayList<>(); - - ScriptSelectionEditor(List scriptInfos) { - - scriptInfos.forEach(i -> scriptMap.put(i.getName(), i)); - - init(); - } - - private void init() { - - List sortedInfos = new ArrayList<>(scriptMap.values()); - - DataToStringConverter stringConverter = info -> info.getName(); - ScriptInfoDescriptionConverter descriptionConverter = new ScriptInfoDescriptionConverter(); - ScriptTextFieldModel model = new ScriptTextFieldModel(sortedInfos, stringConverter, - descriptionConverter); - - selectionField = new ScriptSelectionTextField(model); - - // propagate Enter and Cancel presses to the client - selectionField.addCellEditorListener(new CellEditorListener() { - - @Override - public void editingStopped(ChangeEvent e) { - fireEditingStopped(); - } - - @Override - public void editingCanceled(ChangeEvent e) { - fireEditingCancelled(); - } - }); - - selectionField.setBorder(UIManager.getBorder("Table.focusCellHighlightBorder")); - - editorPanel = new JPanel(); - editorPanel.setLayout(new BoxLayout(editorPanel, BoxLayout.X_AXIS)); - editorPanel.add(selectionField); - } - - /** - * Adds a listener to know when the user has chosen a script info or cancelled editing. - * @param l the listener - */ - public void addEditorListener(ScriptEditorListener l) { - listeners.remove(l); - listeners.add(l); - } - - /** - * Removes the given listener. - * @param l the listener - */ - public void removeEditorListener(ScriptEditorListener l) { - listeners.remove(l); - } - - /** - * Adds a document listener to the text field editing component of this editor so that users - * can be notified when the text contents of the editor change. You may verify whether the - * text changes represent a valid DataType by calling {@link #validateUserSelection()}. - * @param listener the listener to add. - * @see #validateUserSelection() - */ - public void addDocumentListener(DocumentListener listener) { - selectionField.getDocument().addDocumentListener(listener); - } - - /** - * Removes a previously added document listener. - * @param listener the listener to remove. - */ - public void removeDocumentListener(DocumentListener listener) { - selectionField.getDocument().removeDocumentListener(listener); - } - - /** - * Sets whether this editor should consumer Enter key presses - * @see DropDownSelectionTextField#setConsumeEnterKeyPress(boolean) - * - * @param consume true to consume - */ - public void setConsumeEnterKeyPress(boolean consume) { - selectionField.setConsumeEnterKeyPress(consume); - } - - /** - * Returns the component that allows the user to edit. - * @return the component that allows the user to edit. - */ - public JComponent getEditorComponent() { - return editorPanel; - } - - /** - * Focuses this editors text field. - */ - public void requestFocus() { - selectionField.requestFocus(); - } - - /** - * Returns the text value of the editor's text field. - * @return the text value of the editor's text field. - */ - public String getEditorText() { - return selectionField.getText(); - } - - /** - * Returns the currently chosen script info or null. - * @return the currently chosen script info or null. - */ - public ScriptInfo getEditorValue() { - return selectionField.getSelectedValue(); - } - - /** - * Returns true if the value of this editor is valid. Clients can use this to verify that the - * user text is a valid script selection. - * @return true if the valid of this editor is valid. - */ - public boolean validateUserSelection() { - - // if it is not a known type, the prompt user to create new one - if (!containsValidScript()) { - return parseTextEntry(); - } - - return true; - } - - private boolean containsValidScript() { - // look for the case where the user made a selection from the matching window, but - // then changed the text field text. - ScriptInfo selectedInfo = selectionField.getSelectedValue(); - if (selectedInfo != null && - selectionField.getText().equals(selectedInfo.getName())) { - return true; - } - return false; - } - - private boolean parseTextEntry() { - - if (StringUtils.isBlank(selectionField.getText())) { - return false; - } - - String text = selectionField.getText(); - ScriptInfo info = scriptMap.get(text); - if (info != null) { - selectionField.setSelectedValue(info); - return true; - } - return false; - } - - private void fireEditingStopped() { - listeners.forEach(l -> l.editingStopped()); - } - - private void fireEditingCancelled() { - listeners.forEach(l -> l.editingCancelled()); - } - -//================================================================================================= -// Inner Classes -//================================================================================================= - - private class ScriptTextFieldModel extends DefaultDropDownSelectionDataModel { - - public ScriptTextFieldModel(List data, - DataToStringConverter searchConverter, - DataToStringConverter descriptionConverter) { - super(data, searchConverter, descriptionConverter); - } - - @Override - public List getSupportedSearchModes() { - return List.of(SearchMode.WILDCARD, SearchMode.CONTAINS, SearchMode.STARTS_WITH); - } - - } - - private class ScriptSelectionTextField extends DropDownSelectionTextField { - - public ScriptSelectionTextField(DropDownTextFieldDataModel dataModel) { - super(dataModel); - } - - @Override - protected boolean shouldReplaceTextFieldTextWithSelectedItem(String textFieldText, - ScriptInfo selectedItem) { - - // This is called when the user presses Enter with a list item selected. By - // default, the text field will not replace the text field text if the given item - // does not match the text. This is to allow users to enter custom text. We do - // not want custom text, as the user must pick an existing script. Thus, we always - // allow the replace. - return true; - } - } - - private class ScriptInfoDescriptionConverter implements DataToStringConverter { - - @Override - public String getString(ScriptInfo info) { - StringBuilder buffy = new StringBuilder("

"); - - KeyStroke keyBinding = info.getKeyBinding(); - if (keyBinding != null) { - // show the keybinding at the top softly so the user can quickly see it without - // it interfering with the overall description - buffy.append("

"); - buffy.append(" "); - buffy.append(keyBinding.toString()); - buffy.append(""); - buffy.append("

"); - } - - String description = info.getDescription(); - String formatted = formatDescription(description); - buffy.append(formatted); - - return buffy.toString(); - } - - private String formatDescription(String description) { - // - // We are going to wrap lines at 50 columns so that they fit the tooltip window. We - // will also try to keep the original structure of manually separated lines by - // preserving empty lines included in the original description. Removing all newlines - // except for the blank lines allows the line wrapping utility to create the best - // output. - // - - // split into lines and remove all leading/trailing whitespace - String[] lines = description.split("\n"); - for (int i = 0; i < lines.length; i++) { - String line = lines[i]; - lines[i] = line.trim(); - } - - // restore the newline characters; this will allow us to detect consecutive newlines - StringBuilder bufffy = new StringBuilder(); - for (String line : lines) { - bufffy.append(line).append("\n"); - } - - // Remove all newlines, except for consecutive newlines, which represent blank lines. - // Then, for any remaining newline, add back the extra blank line. - String trimmed = bufffy.toString(); - String stripped = trimmed.replaceAll("(? { + + private List allScripts; + private LinkedList recentScriptNames; + + public ScriptsModel(List allScripts, LinkedList recentScriptNames) { + this.allScripts = allScripts; + this.recentScriptNames = recentScriptNames != null ? recentScriptNames : new LinkedList<>(); + populateModel(); + } + + private void populateModel() { + // Create map for quick lookup + Map scriptMap = new HashMap<>(); + for (ScriptInfo script : allScripts) { + scriptMap.put(script.getName(), script); + } + + // Add recent scripts first (in MRU order) + List recentScripts = new ArrayList<>(); + Set addedScripts = new HashSet<>(); + for (String recentName : recentScriptNames) { + ScriptInfo script = scriptMap.get(recentName); + if (script != null) { + recentScripts.add(script); + addedScripts.add(recentName); + } + } + + if (!recentScripts.isEmpty()) { + add(ScriptGroup.RECENT_SCRIPTS.getDisplayName(), recentScripts); + } + + // Add all other scripts (alphabetically sorted) + List otherScripts = new ArrayList<>(); + for (ScriptInfo script : allScripts) { + if (!addedScripts.contains(script.getName())) { + otherScripts.add(script); + } + } + otherScripts.sort(Comparator.comparing(ScriptInfo::getName)); + + add(ScriptGroup.ALL_SCRIPTS.getDisplayName(), otherScripts); + } +}