SettingsDialog.java
package org.egothor.methodatlas.gui.dialog;
import org.egothor.methodatlas.ai.AiProvider;
import org.egothor.methodatlas.gui.model.AiProfile;
import org.egothor.methodatlas.gui.model.AppSettings;
import org.egothor.methodatlas.gui.service.AnalysisService;
import org.egothor.methodatlas.gui.service.SettingsManager;
import javax.swing.*;
import javax.swing.border.EmptyBorder;
import javax.swing.border.TitledBorder;
import java.awt.*;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Modal settings dialog covering AI profile management, plugin selection,
* and UI preferences.
*
* <p>The AI section uses a master-detail layout: a profile list on the left
* lets the user switch between named provider configurations, while the form
* on the right edits the selected profile's parameters. Multiple profiles
* can coexist, allowing quick switching between, for example, a fast local
* model and a precise cloud model.</p>
*
* <p>Changes are written to the model and persisted via {@link SettingsManager}
* only when the user confirms with the <em>Save</em> button. Closing or
* clicking <em>Cancel</em> discards all changes.</p>
*/
@SuppressWarnings("PMD.NonSerializableClass")
public final class SettingsDialog extends JDialog {
// ── Provider info ─────────────────────────────────────────────────────
private static final String[] PROVIDER_NAMES = {
"AUTO (Ollama → API key fallback)",
"OLLAMA (local Ollama instance)",
"OPENAI (ChatGPT API)",
"ANTHROPIC (Claude API)",
"AZURE_OPENAI (Azure OpenAI Service)",
"GROQ (Groq LPU cloud)",
"XAI (Grok / xAI)",
"GITHUB_MODELS (GitHub free tier)",
"MISTRAL (Mistral AI)",
"OPENROUTER (multi-model gateway)"
};
private static final AiProvider[] PROVIDERS = {
AiProvider.AUTO, AiProvider.OLLAMA, AiProvider.OPENAI, AiProvider.ANTHROPIC,
AiProvider.AZURE_OPENAI, AiProvider.GROQ, AiProvider.XAI,
AiProvider.GITHUB_MODELS, AiProvider.MISTRAL, AiProvider.OPENROUTER
};
private static final String[] DEFAULT_MODELS = {
"qwen2.5-coder:7b", // AUTO
"qwen2.5-coder:7b", // OLLAMA
"gpt-4o", // OPENAI
"claude-sonnet-4-6", // ANTHROPIC
"gpt-4o", // AZURE_OPENAI
"llama-3.3-70b-versatile", // GROQ
"grok-2-latest", // XAI
"gpt-4o", // GITHUB_MODELS
"mistral-large-latest", // MISTRAL
"openai/gpt-4o" // OPENROUTER
};
private static final String[] THEME_CLASSES = {
"com.formdev.flatlaf.FlatIntelliJLaf",
"com.formdev.flatlaf.FlatDarkLaf",
"com.formdev.flatlaf.FlatLightLaf",
"com.formdev.flatlaf.FlatDarculaLaf"
};
private static final String[] THEME_NAMES = {
"IntelliJ Light", "Flat Dark", "Flat Light", "Darcula"
};
@java.io.Serial
private static final long serialVersionUID = 1L;
/** Minimum number of profiles that must exist (cannot delete below this). */
private static final int MIN_PROFILE_COUNT = 1;
// ── Profile management ────────────────────────────────────────────────
private List<AiProfile> workingProfiles;
private int currentProfileIndex = -1;
private final DefaultListModel<String> profileListModel = new DefaultListModel<>();
private final JList<String> profileList = new JList<>(profileListModel);
// ── AI form components ────────────────────────────────────────────────
private final JCheckBox aiEnabledBox = new JCheckBox("Enable AI enrichment");
private final JComboBox<String> providerCombo = new JComboBox<>(PROVIDER_NAMES);
private final JTextField modelField = new JTextField(24);
private final JPasswordField apiKeyField = new JPasswordField(24);
private final JTextField baseUrlField = new JTextField(24);
private final JTextField apiVersionField = new JTextField(14);
private final JSpinner timeoutSpinner = new JSpinner(new SpinnerNumberModel(90, 5, 600, 5));
private final JSpinner retriesSpinner = new JSpinner(new SpinnerNumberModel(1, 0, 10, 1));
private final JCheckBox confidenceBox = new JCheckBox("Request confidence scores");
// ── Plugin components ─────────────────────────────────────────────────
/** Maps plugin ID → enable/disable checkbox; populated at construction time. */
private final Map<String, JCheckBox> pluginBoxes = new LinkedHashMap<>();
/** Maps plugin ID → file-mask text field; populated at construction time. */
private final Map<String, JTextField> pluginSuffixFields = new LinkedHashMap<>();
// ── Appearance ────────────────────────────────────────────────────────
private final JComboBox<String> themeCombo = new JComboBox<>(THEME_NAMES);
// ── Audit ─────────────────────────────────────────────────────────────
private final JTextField operatorNameField = new JTextField(24);
// ── State ─────────────────────────────────────────────────────────────
private final AppSettings settings;
private boolean confirmed;
/**
* Constructs the settings dialog, populates all fields from the
* current settings, and centres the dialog over {@code owner}.
*
* <p>The dialog is modal. Changes are applied to {@code settings} and
* persisted to disk only when the user clicks <em>Save</em>. Clicking
* <em>Cancel</em> or closing the window leaves {@code settings}
* unchanged. After {@code setVisible(true)} returns, check
* {@link #isConfirmed()} to determine which action the user took.</p>
*
* <p>Must be called on the Swing Event Dispatch Thread.</p>
*
* @param owner parent frame used for modal blocking and positioning;
* must not be {@code null}
* @param settings settings object whose fields are pre-populated into
* the form and updated on save; must not be {@code null}
*/
public SettingsDialog(Frame owner, AppSettings settings) {
super(owner, "Settings — MethodAtlas", true);
this.settings = settings;
setDefaultCloseOperation(DISPOSE_ON_CLOSE);
buildUi();
populate();
pack();
setMinimumSize(new Dimension(640, 0));
setLocationRelativeTo(owner);
}
// ── Construction ──────────────────────────────────────────────────────
private void buildUi() {
JPanel centre = new JPanel();
centre.setLayout(new BoxLayout(centre, BoxLayout.Y_AXIS));
centre.setBorder(new EmptyBorder(12, 12, 8, 12));
centre.add(buildAiSection());
centre.add(Box.createVerticalStrut(6));
centre.add(buildPluginsSection());
centre.add(Box.createVerticalStrut(6));
centre.add(buildThemeSection());
centre.add(Box.createVerticalStrut(6));
centre.add(buildAuditSection());
centre.add(Box.createVerticalStrut(6));
centre.add(buildConfigPathSection());
JPanel buttons = buildButtonRow();
JPanel outer = new JPanel(new BorderLayout(0, 8));
outer.setBorder(new EmptyBorder(0, 0, 12, 0));
outer.add(new JScrollPane(centre,
ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED,
ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER), BorderLayout.CENTER);
outer.add(buttons, BorderLayout.SOUTH);
setContentPane(outer);
// Auto-fill default model when provider changes
providerCombo.addActionListener(e -> {
int idx = providerCombo.getSelectedIndex();
if (idx >= 0 && idx < DEFAULT_MODELS.length) {
String current = modelField.getText().trim();
boolean isDefault = false;
for (String dm : DEFAULT_MODELS) {
if (dm.equals(current)) { isDefault = true; break; }
}
if (current.isEmpty() || isDefault) {
modelField.setText(DEFAULT_MODELS[idx]);
}
}
});
aiEnabledBox.addActionListener(e -> updateAiControlsEnabled());
}
private JPanel buildAiSection() {
JPanel panel = new JPanel(new BorderLayout(8, 4));
panel.setBorder(titledBorder("AI Profiles"));
panel.setAlignmentX(LEFT_ALIGNMENT);
// ── Profile list (left) ────────────────────────────────────────────
profileList.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
profileList.setVisibleRowCount(7);
profileList.addListSelectionListener(e -> {
if (e.getValueIsAdjusting()) { return; }
onProfileListSelectionChanged();
});
JButton newBtn = new JButton("New");
JButton deleteBtn = new JButton("Delete");
JButton renameBtn = new JButton("Rename");
newBtn.setMargin(new Insets(2, 6, 2, 6));
deleteBtn.setMargin(new Insets(2, 6, 2, 6));
renameBtn.setMargin(new Insets(2, 6, 2, 6));
newBtn.addActionListener(e -> onNewProfile());
deleteBtn.addActionListener(e -> onDeleteProfile());
renameBtn.addActionListener(e -> onRenameProfile());
JPanel listButtons = new JPanel(new FlowLayout(FlowLayout.LEFT, 2, 0));
listButtons.add(newBtn);
listButtons.add(deleteBtn);
listButtons.add(renameBtn);
JScrollPane listScroll = new JScrollPane(profileList);
listScroll.setPreferredSize(new Dimension(150, 0));
JPanel listPanel = new JPanel(new BorderLayout(0, 2));
listPanel.add(listScroll, BorderLayout.CENTER);
listPanel.add(listButtons, BorderLayout.SOUTH);
// ── Profile form (right) ───────────────────────────────────────────
JPanel formPanel = new JPanel(new BorderLayout(0, 4));
formPanel.add(aiEnabledBox, BorderLayout.NORTH);
JPanel grid = new JPanel(new GridBagLayout());
GridBagConstraints lc = labelConstraints();
GridBagConstraints fc = fieldConstraints();
int row = 0;
addRow(grid, "Provider:", providerCombo, lc, fc, row++);
addRow(grid, "Model:", modelField, lc, fc, row++);
addRow(grid, "API Key:", apiKeyField, lc, fc, row++);
addRow(grid, "Base URL (optional):", baseUrlField, lc, fc, row++);
addRow(grid, "Azure API Version:", apiVersionField, lc, fc, row++);
addRow(grid, "Timeout (seconds):", timeoutSpinner, lc, fc, row++);
addRow(grid, "Max retries:", retriesSpinner, lc, fc, row++);
GridBagConstraints cc = new GridBagConstraints();
cc.gridx = 1; cc.gridy = row; cc.anchor = GridBagConstraints.WEST;
grid.add(confidenceBox, cc);
formPanel.add(grid, BorderLayout.CENTER);
panel.add(listPanel, BorderLayout.WEST);
panel.add(formPanel, BorderLayout.CENTER);
return panel;
}
@SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
private JPanel buildPluginsSection() {
JPanel panel = new JPanel(new BorderLayout(0, 4));
panel.setBorder(titledBorder("Discovery Plugins"));
panel.setAlignmentX(LEFT_ALIGNMENT);
List<String> available = AnalysisService.availablePluginIds();
if (available.isEmpty()) {
panel.add(new JLabel(" No discovery plugins detected on classpath."), BorderLayout.CENTER);
return panel;
}
JPanel grid = new JPanel(new GridBagLayout());
// Header
GridBagConstraints hc = new GridBagConstraints();
hc.gridy = 0; hc.anchor = GridBagConstraints.WEST;
hc.insets = new Insets(0, 2, 4, 8);
hc.gridx = 0; grid.add(new JLabel("Enable"), hc);
hc.gridx = 1; grid.add(new JLabel("Plugin"), hc);
hc.gridx = 2;
hc.insets = new Insets(0, 2, 4, 0);
hc.weightx = 1.0; hc.fill = GridBagConstraints.HORIZONTAL;
grid.add(new JLabel("File masks (comma-separated; blank = plugin built-in default)"), hc);
GridBagConstraints checkC = new GridBagConstraints();
checkC.anchor = GridBagConstraints.CENTER;
checkC.insets = new Insets(1, 2, 1, 8);
GridBagConstraints idC = new GridBagConstraints();
idC.anchor = GridBagConstraints.WEST;
idC.insets = new Insets(1, 0, 1, 8);
GridBagConstraints fieldC = new GridBagConstraints();
fieldC.anchor = GridBagConstraints.WEST;
fieldC.fill = GridBagConstraints.HORIZONTAL;
fieldC.weightx = 1.0;
fieldC.insets = new Insets(1, 0, 1, 0);
int row = 1;
for (String id : available) {
JCheckBox box = new JCheckBox();
box.setToolTipText("Include plugin '" + id + "' in scans");
pluginBoxes.put(id, box);
JTextField suffixField = new JTextField(20);
suffixField.putClientProperty("JTextField.placeholderText", "plugin default");
suffixField.setToolTipText(
"File masks for plugin '" + id + "' (comma-separated, e.g. Test.java, IT.java)");
pluginSuffixFields.put(id, suffixField);
checkC.gridx = 0; checkC.gridy = row;
grid.add(box, checkC);
idC.gridx = 1; idC.gridy = row;
grid.add(new JLabel(id), idC);
fieldC.gridx = 2; fieldC.gridy = row;
grid.add(suffixField, fieldC);
row++;
}
panel.add(grid, BorderLayout.CENTER);
return panel;
}
private JPanel buildThemeSection() {
JPanel panel = new JPanel(new FlowLayout(FlowLayout.LEFT, 4, 0));
panel.setBorder(titledBorder("Appearance"));
panel.setAlignmentX(LEFT_ALIGNMENT);
panel.add(new JLabel("Theme:"));
panel.add(themeCombo);
panel.add(new JLabel(" (takes effect on next launch)"));
return panel;
}
private JPanel buildAuditSection() {
JPanel panel = new JPanel(new BorderLayout(4, 0));
panel.setBorder(titledBorder("Audit"));
panel.setAlignmentX(LEFT_ALIGNMENT);
operatorNameField.setToolTipText(
"Written into the 'note' field of every override YAML entry and evidence CSV row");
operatorNameField.putClientProperty("JTextField.placeholderText",
"e.g. Jane Smith or jsmith@example.com");
panel.add(new JLabel("Operator name (reviewer identity): "), BorderLayout.WEST);
panel.add(operatorNameField, BorderLayout.CENTER);
return panel;
}
private JPanel buildConfigPathSection() {
JPanel panel = new JPanel(new BorderLayout(4, 0));
panel.setBorder(titledBorder("Configuration File"));
panel.setAlignmentX(LEFT_ALIGNMENT);
String path = SettingsManager.getSettingsFile().toString();
JTextField pathField = new JTextField(path);
pathField.setEditable(false);
pathField.setFont(pathField.getFont().deriveFont(Font.PLAIN, 11f));
pathField.setForeground(UIManager.getColor("Label.disabledForeground"));
JButton openDirButton = new JButton("Open folder");
openDirButton.addActionListener(e -> openContainingFolder(path));
panel.add(pathField, BorderLayout.CENTER);
panel.add(openDirButton, BorderLayout.EAST);
return panel;
}
private JPanel buildButtonRow() {
JButton resetBtn = new JButton("Reset to Defaults");
JButton saveBtn = new JButton("Save");
JButton cancelBtn = new JButton("Cancel");
resetBtn.setToolTipText("Restore all settings to their built-in defaults");
resetBtn.addActionListener(e -> onReset());
saveBtn.addActionListener(e -> onSave());
cancelBtn.addActionListener(e -> dispose());
getRootPane().setDefaultButton(saveBtn);
JPanel buttons = new JPanel(new BorderLayout());
buttons.setBorder(new EmptyBorder(0, 12, 0, 12));
JPanel left = new JPanel(new FlowLayout(FlowLayout.LEFT, 4, 0));
left.add(resetBtn);
JPanel right = new JPanel(new FlowLayout(FlowLayout.RIGHT, 4, 0));
right.add(cancelBtn);
right.add(saveBtn);
buttons.add(left, BorderLayout.WEST);
buttons.add(right, BorderLayout.EAST);
return buttons;
}
// ── Populate / Save / Reset ───────────────────────────────────────────
@SuppressWarnings("PMD.NPathComplexity")
private void populate() {
// Deep-copy the profiles so edits don't affect the live settings object
workingProfiles = new ArrayList<>();
for (AiProfile p : settings.getProfiles()) {
workingProfiles.add(copyProfile(p));
}
if (workingProfiles.isEmpty()) {
workingProfiles.add(new AiProfile());
}
profileListModel.clear();
currentProfileIndex = -1;
for (AiProfile p : workingProfiles) {
profileListModel.addElement(p.getName());
}
// Select the active profile, or fall back to first
String activeName = settings.getActiveProfileName();
int activeIdx = 0;
for (int i = 0; i < workingProfiles.size(); i++) {
if (workingProfiles.get(i).getName().equals(activeName)) {
activeIdx = i;
break;
}
}
profileList.setSelectedIndex(activeIdx); // triggers listener → populates form
operatorNameField.setText(settings.getOperatorName());
String themeClass = settings.getThemeClass();
for (int i = 0; i < THEME_CLASSES.length; i++) {
if (THEME_CLASSES[i].equals(themeClass)) {
themeCombo.setSelectedIndex(i);
break;
}
}
List<String> enabled = settings.getEnabledPlugins();
for (Map.Entry<String, JCheckBox> entry : pluginBoxes.entrySet()) {
entry.getValue().setSelected(enabled.isEmpty() || enabled.contains(entry.getKey()));
}
Map<String, List<String>> pluginSuffixes = settings.getPluginSuffixes();
for (Map.Entry<String, JTextField> entry : pluginSuffixFields.entrySet()) {
List<String> masks = pluginSuffixes.get(entry.getKey());
entry.getValue().setText(masks != null && !masks.isEmpty()
? String.join(", ", masks) : "");
}
}
private void onSave() {
commitFormToCurrentProfile();
settings.setProfiles(workingProfiles);
int selIdx = profileList.getSelectedIndex();
if (selIdx >= 0 && selIdx < workingProfiles.size()) {
settings.setActiveProfileName(workingProfiles.get(selIdx).getName());
}
settings.setOperatorName(operatorNameField.getText().trim());
int themeIdx = themeCombo.getSelectedIndex();
if (themeIdx >= 0) { settings.setThemeClass(THEME_CLASSES[themeIdx]); }
List<String> enabledPlugins = new ArrayList<>();
boolean allChecked = pluginBoxes.values().stream().allMatch(JCheckBox::isSelected);
if (!allChecked) {
pluginBoxes.forEach((id, box) -> { if (box.isSelected()) { enabledPlugins.add(id); } });
}
settings.setEnabledPlugins(enabledPlugins);
Map<String, List<String>> pluginSuffixes = new LinkedHashMap<>();
pluginSuffixFields.forEach((id, field) -> {
String text = field.getText().trim();
if (!text.isEmpty()) {
List<String> masks = new ArrayList<>();
for (String token : text.split(",")) {
String m = token.trim();
if (!m.isEmpty()) { masks.add(m); }
}
if (!masks.isEmpty()) {
pluginSuffixes.put(id, masks);
}
}
});
settings.setPluginSuffixes(pluginSuffixes);
SettingsManager.save(settings);
confirmed = true;
dispose();
}
private void onReset() {
int choice = JOptionPane.showConfirmDialog(this,
"Reset all settings to built-in defaults?\nThe settings file will be overwritten on Save.",
"Reset to Defaults", JOptionPane.OK_CANCEL_OPTION, JOptionPane.WARNING_MESSAGE);
if (choice != JOptionPane.OK_OPTION) { return; }
AppSettings defaults = new AppSettings();
workingProfiles = new ArrayList<>();
for (AiProfile p : defaults.getProfiles()) {
workingProfiles.add(copyProfile(p));
}
profileListModel.clear();
currentProfileIndex = -1;
for (AiProfile p : workingProfiles) {
profileListModel.addElement(p.getName());
}
profileList.setSelectedIndex(0);
for (int i = 0; i < THEME_CLASSES.length; i++) {
if (THEME_CLASSES[i].equals(defaults.getThemeClass())) {
themeCombo.setSelectedIndex(i);
break;
}
}
operatorNameField.setText("");
pluginBoxes.values().forEach(b -> b.setSelected(true));
pluginSuffixFields.values().forEach(f -> f.setText(""));
}
/**
* Returns whether the user confirmed the dialog by clicking
* <em>Save</em>.
*
* <p>This method returns {@code false} both before the dialog is shown
* and when it was dismissed via <em>Cancel</em> or the window close
* button.</p>
*
* @return {@code true} if and only if the user clicked <em>Save</em>
*/
public boolean isConfirmed() { return confirmed; }
// ── Profile management ────────────────────────────────────────────────
private void onProfileListSelectionChanged() {
int newIdx = profileList.getSelectedIndex();
if (newIdx == currentProfileIndex || newIdx < 0) { return; }
// Commit the form to the profile we are leaving
if (currentProfileIndex >= 0 && currentProfileIndex < workingProfiles.size()) {
commitFormToProfile(workingProfiles.get(currentProfileIndex));
}
currentProfileIndex = newIdx;
populateProfileForm(workingProfiles.get(newIdx));
updateAiControlsEnabled();
}
private void onNewProfile() {
commitFormToCurrentProfile();
String baseName = "Profile";
int n = 2;
String name = baseName;
while (profileNameExists(name)) {
name = baseName + " " + n++;
}
String input = JOptionPane.showInputDialog(this, "Profile name:", name);
if (input == null || input.isBlank()) { return; }
input = input.trim();
if (profileNameExists(input)) {
JOptionPane.showMessageDialog(this, "A profile with that name already exists.",
"Duplicate Name", JOptionPane.WARNING_MESSAGE);
return;
}
AiProfile newProfile = new AiProfile();
newProfile.setName(input);
workingProfiles.add(newProfile);
profileListModel.addElement(input);
profileList.setSelectedIndex(workingProfiles.size() - 1);
}
private void onDeleteProfile() {
int idx = profileList.getSelectedIndex();
if (idx < 0) { return; }
if (workingProfiles.size() <= MIN_PROFILE_COUNT) {
JOptionPane.showMessageDialog(this, "At least one profile must exist.",
"Cannot Delete", JOptionPane.WARNING_MESSAGE);
return;
}
workingProfiles.remove(idx);
profileListModel.remove(idx);
currentProfileIndex = -1;
profileList.setSelectedIndex(Math.min(idx, workingProfiles.size() - 1));
}
private void onRenameProfile() {
int idx = profileList.getSelectedIndex();
if (idx < 0) { return; }
AiProfile profile = workingProfiles.get(idx);
String input = JOptionPane.showInputDialog(this, "New name:", profile.getName());
if (input == null || input.isBlank()) { return; }
input = input.trim();
if (input.equals(profile.getName())) { return; }
if (profileNameExists(input)) {
JOptionPane.showMessageDialog(this, "A profile with that name already exists.",
"Duplicate Name", JOptionPane.WARNING_MESSAGE);
return;
}
profile.setName(input);
profileListModel.set(idx, input);
}
private boolean profileNameExists(String name) {
return workingProfiles.stream().anyMatch(p -> p.getName().equals(name));
}
// ── Profile form helpers ──────────────────────────────────────────────
private void populateProfileForm(AiProfile profile) {
aiEnabledBox.setSelected(profile.isEnabled());
String provName = profile.getProvider();
for (int i = 0; i < PROVIDERS.length; i++) {
if (PROVIDERS[i].name().equals(provName)) {
providerCombo.setSelectedIndex(i);
break;
}
}
modelField.setText(profile.getModel());
apiKeyField.setText(profile.getApiKey());
baseUrlField.setText(profile.getBaseUrl());
apiVersionField.setText(profile.getApiVersion());
timeoutSpinner.setValue(profile.getTimeoutSeconds());
retriesSpinner.setValue(profile.getMaxRetries());
confidenceBox.setSelected(profile.isConfidence());
}
private void commitFormToProfile(AiProfile profile) {
profile.setEnabled(aiEnabledBox.isSelected());
int idx = providerCombo.getSelectedIndex();
profile.setProvider(idx >= 0 ? PROVIDERS[idx].name() : "AUTO");
profile.setModel(modelField.getText().trim());
profile.setApiKey(new String(apiKeyField.getPassword()));
profile.setBaseUrl(baseUrlField.getText().trim());
profile.setApiVersion(apiVersionField.getText().trim());
profile.setTimeoutSeconds((int) timeoutSpinner.getValue());
profile.setMaxRetries((int) retriesSpinner.getValue());
profile.setConfidence(confidenceBox.isSelected());
}
private void commitFormToCurrentProfile() {
if (currentProfileIndex >= 0 && currentProfileIndex < workingProfiles.size()) {
commitFormToProfile(workingProfiles.get(currentProfileIndex));
}
}
private static AiProfile copyProfile(AiProfile src) {
AiProfile copy = new AiProfile();
copy.setName(src.getName());
copy.setEnabled(src.isEnabled());
copy.setProvider(src.getProvider());
copy.setModel(src.getModel());
copy.setApiKey(src.getApiKey());
copy.setBaseUrl(src.getBaseUrl());
copy.setApiVersion(src.getApiVersion());
copy.setTimeoutSeconds(src.getTimeoutSeconds());
copy.setMaxRetries(src.getMaxRetries());
copy.setConfidence(src.isConfidence());
return copy;
}
// ── Helpers ───────────────────────────────────────────────────────────
private void updateAiControlsEnabled() {
boolean on = aiEnabledBox.isSelected();
providerCombo.setEnabled(on);
modelField.setEnabled(on);
apiKeyField.setEnabled(on);
baseUrlField.setEnabled(on);
apiVersionField.setEnabled(on);
timeoutSpinner.setEnabled(on);
retriesSpinner.setEnabled(on);
confidenceBox.setEnabled(on);
}
private static void openContainingFolder(String filePath) {
try {
java.nio.file.Path dir = java.nio.file.Path.of(filePath).getParent();
if (dir != null && java.nio.file.Files.isDirectory(dir)) {
Desktop.getDesktop().open(dir.toFile());
}
} catch (Exception ex) {
// best-effort open; Desktop.open not supported on all platforms — nothing to do
}
}
private static void addRow(JPanel grid, String labelText, JComponent field,
GridBagConstraints lc, GridBagConstraints fc, int row) {
lc.gridy = row;
fc.gridy = row;
grid.add(new JLabel(labelText), lc);
grid.add(field, fc);
}
private static GridBagConstraints labelConstraints() {
GridBagConstraints c = new GridBagConstraints();
c.gridx = 0; c.anchor = GridBagConstraints.WEST;
c.insets = new Insets(3, 0, 3, 8);
return c;
}
private static GridBagConstraints fieldConstraints() {
GridBagConstraints c = new GridBagConstraints();
c.gridx = 1; c.anchor = GridBagConstraints.WEST; c.fill = GridBagConstraints.HORIZONTAL;
c.weightx = 1.0; c.insets = new Insets(3, 0, 3, 0);
return c;
}
private static TitledBorder titledBorder(String title) {
return BorderFactory.createTitledBorder(
BorderFactory.createEtchedBorder(), title,
TitledBorder.LEFT, TitledBorder.TOP);
}
}