initial improved Script Quick Launch dialog

This commit is contained in:
Lee Chagolla-Christensen
2025-12-10 19:11:24 -08:00
committed by dragonmacher
parent 41e7ac82ed
commit f4f99b562f
6 changed files with 253 additions and 401 deletions
@@ -285,7 +285,17 @@ class GhidraScriptActionManager {
private void chooseScript(ActionContext actioncontext1) { private void chooseScript(ActionContext actioncontext1) {
List<ScriptInfo> scriptInfos = provider.getScriptInfos(); List<ScriptInfo> 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(); dialog.show();
ScriptInfo chosenInfo = dialog.getUserChoice(); ScriptInfo chosenInfo = dialog.getUserChoice();
@@ -67,6 +67,8 @@ public class GhidraScriptComponentProvider extends ComponentProviderAdapter {
private static final double TOP_PREFERRED_RESIZE_WEIGHT = .80; private static final double TOP_PREFERRED_RESIZE_WEIGHT = .80;
private static final String DESCRIPTION_DIVIDER_LOCATION = "DESCRIPTION_DIVIDER_LOCATION"; private static final String DESCRIPTION_DIVIDER_LOCATION = "DESCRIPTION_DIVIDER_LOCATION";
private static final String FILTER_TEXT = "FILTER_TEXT"; 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<ResourceFile, GhidraScriptEditorComponentProvider> editorMap = new HashMap<>(); private Map<ResourceFile, GhidraScriptEditorComponentProvider> editorMap = new HashMap<>();
private final GhidraScriptMgrPlugin plugin; private final GhidraScriptMgrPlugin plugin;
@@ -88,6 +90,7 @@ public class GhidraScriptComponentProvider extends ComponentProviderAdapter {
private String[] previousCategory; private String[] previousCategory;
private ResourceFile lastRunScript; private ResourceFile lastRunScript;
private LinkedList<String> recentScripts = new LinkedList<>();
private WeakSet<RunScriptTask> runningScriptTaskSet = private WeakSet<RunScriptTask> runningScriptTaskSet =
WeakDataStructureFactory.createCopyOnReadWeakSet(); WeakDataStructureFactory.createCopyOnReadWeakSet();
private TaskListener cleanupTaskSetListener = new TaskListener() { private TaskListener cleanupTaskSetListener = new TaskListener() {
@@ -311,6 +314,12 @@ public class GhidraScriptComponentProvider extends ComponentProviderAdapter {
String filterText = saveState.getString(FILTER_TEXT, ""); String filterText = saveState.getString(FILTER_TEXT, "");
tableFilterPanel.setFilterText(filterText); 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(); String filterText = tableFilterPanel.getFilterText();
saveState.putString(FILTER_TEXT, filterText); saveState.putString(FILTER_TEXT, filterText);
String[] scripts = recentScripts.toArray(new String[0]);
saveState.putStrings(RECENT_SCRIPTS, scripts);
} }
void dispose() { void dispose() {
@@ -364,6 +376,10 @@ public class GhidraScriptComponentProvider extends ComponentProviderAdapter {
return editorMap; return editorMap;
} }
LinkedList<String> getRecentScripts() {
return recentScripts;
}
void assignKeyBinding() { void assignKeyBinding() {
ResourceFile script = getSelectedScript(); ResourceFile script = getSelectedScript();
ScriptAction action = actionManager.createAction(script); ScriptAction action = actionManager.createAction(script);
@@ -664,6 +680,17 @@ public class GhidraScriptComponentProvider extends ComponentProviderAdapter {
void runScript(ResourceFile scriptFile, TaskListener listener) { void runScript(ResourceFile scriptFile, TaskListener listener) {
lastRunScript = scriptFile; 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); GhidraScript script = doGetScriptInstance(scriptFile);
if (script != null) { if (script != null) {
doRunScript(script, listener); doRunScript(script, listener);
@@ -16,17 +16,28 @@
package ghidra.app.plugin.core.script; 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");
/** private String displayName;
* Called when the user makes a selection.
*/
public void editingStopped();
/** private ScriptGroup(String displayName) {
* Called when the user cancels the script selection process. this.displayName = displayName;
*/ }
public void editingCancelled();
public String getDisplayName() {
return displayName;
}
public static ScriptGroup getGroupByDisplayName(String name) {
for (ScriptGroup group : values()) {
if (group.getDisplayName().equals(name)) {
return group;
}
}
return null;
}
} }
@@ -15,102 +15,82 @@
*/ */
package ghidra.app.plugin.core.script; package ghidra.app.plugin.core.script;
import java.awt.BorderLayout; import java.awt.*;
import java.util.LinkedList;
import java.util.List; import java.util.List;
import javax.swing.JComponent; import javax.swing.*;
import javax.swing.JPanel;
import javax.swing.event.DocumentEvent;
import javax.swing.event.DocumentListener;
import docking.DialogComponentProvider; 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.app.script.ScriptInfo;
import ghidra.framework.plugintool.PluginTool; import ghidra.framework.plugintool.PluginTool;
import ghidra.util.HelpLocation; import ghidra.util.HelpLocation;
import ghidra.util.HTMLUtilities;
import ghidra.util.Swing; 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 { public class ScriptSelectionDialog extends DialogComponentProvider {
private ScriptSelectionEditor editor;
private PluginTool tool; private PluginTool tool;
private List<ScriptInfo> scriptInfos; private List<ScriptInfo> scriptInfos;
private LinkedList<String> recentScripts;
private String initialScript;
private ScriptInfo userChoice; private ScriptInfo userChoice;
private SearchList<ScriptInfo> searchList;
ScriptSelectionDialog(GhidraScriptMgrPlugin plugin, List<ScriptInfo> scriptInfos) { ScriptSelectionDialog(GhidraScriptMgrPlugin plugin, List<ScriptInfo> scriptInfos,
LinkedList<String> recentScripts, String initialScript) {
super("Run Script", true, true, true, false); super("Run Script", true, true, true, false);
this.tool = plugin.getTool(); this.tool = plugin.getTool();
this.scriptInfos = scriptInfos; this.scriptInfos = scriptInfos;
this.recentScripts = recentScripts;
this.initialScript = initialScript;
init(); addWorkPanel(buildMainPanel());
addOKButton();
addCancelButton();
setHelpLocation(new HelpLocation(plugin.getName(), "Script Quick Launch")); setHelpLocation(new HelpLocation(plugin.getName(), "Script Quick Launch"));
} }
private void init() { private JComponent buildMainPanel() {
buildEditor(); JPanel panel = new JPanel(new BorderLayout());
addOKButton(); ScriptsModel model = new ScriptsModel(scriptInfos, recentScripts);
addCancelButton(); searchList = new SearchList<>(model, (script, category) -> scriptChosen(script));
} searchList.setItemRenderer(new ScriptRenderer());
searchList.setDisplayNameFunction((script, category) -> script.getName());
private void buildEditor() { // Pre-select the initial script if provided
removeWorkPanel(); if (initialScript != null && !initialScript.isEmpty()) {
Swing.runLater(() -> {
editor = new ScriptSelectionEditor(scriptInfos); for (ScriptInfo info : scriptInfos) {
if (info.getName().equals(initialScript)) {
editor.setConsumeEnterKeyPress(false); // we want to handle Enter key presses searchList.setSelectedItem(info);
break;
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 panel.add(searchList, BorderLayout.CENTER);
public void insertUpdate(DocumentEvent e) { panel.setPreferredSize(new Dimension(600, 400));
textUpdated();
return panel;
} }
@Override private void scriptChosen(ScriptInfo script) {
public void removeUpdate(DocumentEvent e) { if (script != null) {
textUpdated(); userChoice = script;
close();
} }
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;
} }
public void show() { public void show() {
@@ -123,38 +103,94 @@ public class ScriptSelectionDialog extends DialogComponentProvider {
@Override @Override
protected void dialogShown() { protected void dialogShown() {
Swing.runLater(() -> editor.requestFocus()); Swing.runLater(() -> searchList.getFilterField().requestFocus());
} }
// overridden to set the user choice to null
@Override @Override
protected void cancelCallback() { protected void cancelCallback() {
userChoice = null; userChoice = null;
super.cancelCallback(); super.cancelCallback();
} }
// overridden to perform validation and to get the user's choice
@Override @Override
protected void okCallback() { protected void okCallback() {
ScriptInfo selectedScript = searchList.getSelectedItem();
if (!editor.validateUserSelection()) { if (selectedScript == null) {
setStatusText("Invalid script name"); setStatusText("Please select a script");
return; return;
} }
userChoice = editor.getEditorValue(); userChoice = selectedScript;
clearStatusText(); clearStatusText();
close(); 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 // Inner Classes
@Override //=================================================================================================
public void close() {
buildEditor(); /**
setStatusText(""); * Custom renderer for script entries in the search list.
super.close(); */
private class ScriptRenderer extends GListCellRenderer<SearchListEntry<ScriptInfo>> {
{
setHTMLRenderingEnabled(true);
} }
@Override
public Component getListCellRendererComponent(JList<? extends SearchListEntry<ScriptInfo>> list,
SearchListEntry<ScriptInfo> 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("<html>");
// Script name
html.append("<b>").append(HTMLUtilities.escapeHTML(script.getName())).append("</b>");
// Keybinding if available
KeyStroke keyBinding = script.getKeyBinding();
if (keyBinding != null) {
html.append(" <font color=\"")
.append(Palette.GRAY.toHexString())
.append("\"><i>(")
.append(keyBinding.toString())
.append(")</i></font>");
}
// Description on next line
String description = script.getDescription();
if (description != null && !description.isEmpty()) {
html.append("<br><font color=\"")
.append(Palette.GRAY.toHexString())
.append("\">")
.append(HTMLUtilities.escapeHTML(truncateDescription(description)))
.append("</font>");
}
html.append("</html>");
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;
}
}
} }
@@ -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<ScriptInfo> selectionField;
private TreeMap<String, ScriptInfo> scriptMap = new TreeMap<>();
// we use a simple listener data structure, since this widget is transient and nothing more
// advanced should be needed
private List<ScriptEditorListener> listeners = new ArrayList<>();
ScriptSelectionEditor(List<ScriptInfo> scriptInfos) {
scriptInfos.forEach(i -> scriptMap.put(i.getName(), i));
init();
}
private void init() {
List<ScriptInfo> sortedInfos = new ArrayList<>(scriptMap.values());
DataToStringConverter<ScriptInfo> 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<ScriptInfo> {
public ScriptTextFieldModel(List<ScriptInfo> data,
DataToStringConverter<ScriptInfo> searchConverter,
DataToStringConverter<ScriptInfo> descriptionConverter) {
super(data, searchConverter, descriptionConverter);
}
@Override
public List<SearchMode> getSupportedSearchModes() {
return List.of(SearchMode.WILDCARD, SearchMode.CONTAINS, SearchMode.STARTS_WITH);
}
}
private class ScriptSelectionTextField extends DropDownSelectionTextField<ScriptInfo> {
public ScriptSelectionTextField(DropDownTextFieldDataModel<ScriptInfo> 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<ScriptInfo> {
@Override
public String getString(ScriptInfo info) {
StringBuilder buffy = new StringBuilder("<html><P>");
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("<P>");
buffy.append("<FONT COLOR=\"" +
Palette.GRAY.toHexString() + "\"><I>&nbsp;");
buffy.append(keyBinding.toString());
buffy.append("</I></FONT>");
buffy.append("<P><P>");
}
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("(?<!\n)\n", "");
stripped = stripped.replaceAll("\n", "\n\n");
return HTMLUtilities.lineWrapWithHTMLLineBreaks(stripped, 50);
}
}
}
@@ -0,0 +1,71 @@
/* ###
* 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 docking.widgets.searchlist.DefaultSearchListModel;
import ghidra.app.script.ScriptInfo;
/**
* Model for the script selection search list that organizes scripts into
* "Recent Scripts" and "All Scripts" categories.
*/
public class ScriptsModel extends DefaultSearchListModel<ScriptInfo> {
private List<ScriptInfo> allScripts;
private LinkedList<String> recentScriptNames;
public ScriptsModel(List<ScriptInfo> allScripts, LinkedList<String> recentScriptNames) {
this.allScripts = allScripts;
this.recentScriptNames = recentScriptNames != null ? recentScriptNames : new LinkedList<>();
populateModel();
}
private void populateModel() {
// Create map for quick lookup
Map<String, ScriptInfo> scriptMap = new HashMap<>();
for (ScriptInfo script : allScripts) {
scriptMap.put(script.getName(), script);
}
// Add recent scripts first (in MRU order)
List<ScriptInfo> recentScripts = new ArrayList<>();
Set<String> 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<ScriptInfo> 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);
}
}