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) {
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();
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 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<ResourceFile, GhidraScriptEditorComponentProvider> editorMap = new HashMap<>();
private final GhidraScriptMgrPlugin plugin;
@@ -88,6 +90,7 @@ public class GhidraScriptComponentProvider extends ComponentProviderAdapter {
private String[] previousCategory;
private ResourceFile lastRunScript;
private LinkedList<String> recentScripts = new LinkedList<>();
private WeakSet<RunScriptTask> 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<String> 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);
@@ -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;
}
}
@@ -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<ScriptInfo> scriptInfos;
private LinkedList<String> recentScripts;
private String initialScript;
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);
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<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);
}
}