MainWindow.java
package org.egothor.methodatlas.gui;
import org.egothor.methodatlas.api.SourcePatcher;
import org.egothor.methodatlas.api.TestDiscoveryConfig;
import org.egothor.methodatlas.gui.dialog.SettingsDialog;
import org.egothor.methodatlas.gui.model.AiProfile;
import org.egothor.methodatlas.gui.model.AnalysisModel;
import org.egothor.methodatlas.gui.model.AppSettings;
import org.egothor.methodatlas.gui.model.MethodEntry;
import org.egothor.methodatlas.gui.panel.ActivityPanel;
import org.egothor.methodatlas.gui.panel.EditorPanel;
import org.egothor.methodatlas.gui.panel.ScanPanel;
import org.egothor.methodatlas.gui.panel.StatusBar;
import org.egothor.methodatlas.gui.panel.TagEditorPanel;
import org.egothor.methodatlas.gui.service.AnalysisService;
import org.egothor.methodatlas.gui.service.AuditWriter;
import org.egothor.methodatlas.gui.service.SettingsManager;
import org.egothor.methodatlas.gui.service.SourceWriteBackSupport;
import javax.swing.*;
import java.awt.*;
import java.awt.event.ComponentAdapter;
import java.awt.event.ComponentEvent;
import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Main application window for MethodAtlas GUI.
*
* <p>Layout overview:</p>
* <pre>
* ┌──────────────────────────────────────────────────────────────┐
* │ Toolbar: Dir · Run · Cancel · Save All · Profile · Settings │
* ├──────────────┬───────────────────────────────────────────────┤
* │ │ Source editor (RSyntaxTextArea) │
* │ Results ├───────────────────────────────────────────────┤
* │ tree │ Tag editor (AI chips + override + apply) │
* ├──────────────┴───────────────────────────────────────────────┤
* │ Activity panel (hidden when idle, collapsible log) │
* ├──────────────────────────────────────────────────────────────┤
* │ Status bar │
* └──────────────────────────────────────────────────────────────┘
* </pre>
*/
@SuppressWarnings("PMD.NonSerializableClass")
public final class MainWindow extends JFrame {
@java.io.Serial
private static final long serialVersionUID = 1L;
// ── State ─────────────────────────────────────────────────────────────
private final AppSettings settings;
private boolean updatingProfileCombo;
private final AnalysisModel model = new AnalysisModel();
private AnalysisService currentService;
private SourceWriteBackSupport writeBackSupport;
// ── Toolbar controls ──────────────────────────────────────────────────
private final JTextField dirField = new JTextField(32);
private final JButton browseButton = new JButton("Browse…");
private final JButton runButton = new JButton("▶ Run Analysis");
private final JButton cancelButton = new JButton("Cancel");
private final JButton saveAllButton = new JButton("💾 Save All Changes");
private final JComboBox<String> profileCombo = new JComboBox<>();
private final JButton settingsButton = new JButton("⚙ Settings");
// ── Split panes ───────────────────────────────────────────────────────
private JSplitPane mainSplit;
private JSplitPane rightSplit;
// ── Panels ────────────────────────────────────────────────────────────
private final ScanPanel scanPanel;
private final EditorPanel editorPanel;
private final TagEditorPanel tagEditorPanel;
private final ActivityPanel activityPanel;
private final StatusBar statusBar;
/**
* Constructs the main window, loads persisted settings, builds all
* panels and the toolbar, and wires event listeners.
*
* <p>Must be called on the Swing Event Dispatch Thread. The window is
* not yet visible after construction; call {@link #setVisible(boolean)
* setVisible(true)} to display it.</p>
*/
public MainWindow() {
super("MethodAtlas");
settings = SettingsManager.load();
setDefaultCloseOperation(DO_NOTHING_ON_CLOSE);
addWindowListener(new WindowAdapter() {
@Override
public void windowClosing(WindowEvent e) {
onClose();
}
});
// Build panels (order matters: editor before tagEditor)
scanPanel = new ScanPanel(model);
editorPanel = new EditorPanel(model);
tagEditorPanel = new TagEditorPanel(model);
activityPanel = new ActivityPanel(model);
statusBar = new StatusBar(model);
buildLayout();
wireToolbar();
wireModelObserver();
restoreWindowState();
applyLastDirectory();
refreshProfileCombo();
// Apply split positions after the window is realized and laid out.
// invokeLater fires after setVisible(true) in MethodAtlasGuiApp's own
// invokeLater, so the split pane has its actual height by then.
SwingUtilities.invokeLater(this::initSplitPositions);
}
// ── Layout ────────────────────────────────────────────────────────────
private void buildLayout() {
// Right pane: editor on top, tag editor on bottom.
// resizeWeight=1.0: all extra vertical space on window resize goes to the
// editor; the tag editor keeps its natural content height.
// Divider position is applied later in initSplitPositions() once the
// window is realized, so setDividerLocation(double) works correctly.
rightSplit = new JSplitPane(JSplitPane.VERTICAL_SPLIT, editorPanel, tagEditorPanel);
rightSplit.setResizeWeight(1.0);
rightSplit.setBorder(null);
// Main split: results tree on left, right pane on right.
// resizeWeight=0.0: all extra horizontal space goes to the right pane.
mainSplit = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, scanPanel, rightSplit);
mainSplit.setResizeWeight(0.0);
mainSplit.setBorder(null);
mainSplit.setDividerLocation(settings.getLeftSplitPosition());
// Keep the scan pane minimum at 10% of the split width so the user
// cannot drag it to zero. The ComponentListener fires on every resize
// so the minimum tracks the actual window width dynamically.
mainSplit.addComponentListener(new ComponentAdapter() {
@Override
public void componentResized(ComponentEvent e) {
scanPanel.setMinimumSize(new Dimension((int) (mainSplit.getWidth() * 0.10), 1));
}
});
// Persist split positions on resize
mainSplit.addPropertyChangeListener(JSplitPane.DIVIDER_LOCATION_PROPERTY,
e -> settings.setLeftSplitPosition((int) e.getNewValue()));
rightSplit.addPropertyChangeListener(JSplitPane.DIVIDER_LOCATION_PROPERTY,
e -> settings.setRightSplitPosition((int) e.getNewValue()));
// South area: activity panel (collapsible, hides when idle) + status bar
JPanel southPanel = new JPanel(new BorderLayout());
southPanel.add(activityPanel, BorderLayout.NORTH);
southPanel.add(statusBar, BorderLayout.SOUTH);
getContentPane().add(buildToolbar(), BorderLayout.NORTH);
getContentPane().add(mainSplit, BorderLayout.CENTER);
getContentPane().add(southPanel, BorderLayout.SOUTH);
}
private JPanel buildToolbar() {
JPanel bar = new JPanel(new FlowLayout(FlowLayout.LEFT, 6, 4));
bar.setBorder(BorderFactory.createMatteBorder(
0, 0, 1, 0, UIManager.getColor("Separator.foreground")));
dirField.setToolTipText("Directory to scan for test sources");
dirField.putClientProperty("JTextField.placeholderText", "Enter or browse to test source directory…");
cancelButton.setEnabled(false);
cancelButton.setToolTipText("Cancel running analysis");
runButton.putClientProperty("JButton.buttonType", "default");
saveAllButton.setEnabled(false);
saveAllButton.setToolTipText(
"Write all staged tag changes to disk (groups all methods per file to prevent line-number drift)");
profileCombo.setToolTipText("Active AI provider profile");
profileCombo.setPrototypeDisplayValue("Default Profile XXXX");
bar.add(new JLabel("Directory:"));
bar.add(dirField);
bar.add(browseButton);
bar.add(runButton);
bar.add(cancelButton);
JSeparator sep = new JSeparator(JSeparator.VERTICAL);
sep.setPreferredSize(new Dimension(1, 22));
bar.add(sep);
bar.add(saveAllButton);
JSeparator sep2 = new JSeparator(JSeparator.VERTICAL);
sep2.setPreferredSize(new Dimension(1, 22));
bar.add(sep2);
bar.add(new JLabel("Profile:"));
bar.add(profileCombo);
JSeparator sep3 = new JSeparator(JSeparator.VERTICAL);
sep3.setPreferredSize(new Dimension(1, 22));
bar.add(sep3);
bar.add(settingsButton);
return bar;
}
// ── Toolbar wiring ────────────────────────────────────────────────────
private void wireToolbar() {
browseButton.addActionListener(e -> browseDirectory());
runButton.addActionListener(e -> startAnalysis());
cancelButton.addActionListener(e -> cancelAnalysis());
saveAllButton.addActionListener(e -> saveAllChanges());
settingsButton.addActionListener(e -> openSettings());
dirField.addActionListener(e -> startAnalysis());
profileCombo.addActionListener(e -> {
if (updatingProfileCombo) { return; }
String selected = (String) profileCombo.getSelectedItem();
if (selected != null) {
settings.setActiveProfileName(selected);
}
});
}
private void browseDirectory() {
JFileChooser chooser = new JFileChooser();
chooser.setFileSelectionMode(JFileChooser.DIRECTORIES_ONLY);
chooser.setDialogTitle("Select test source directory");
String current = dirField.getText().trim();
if (!current.isEmpty() && Files.isDirectory(Path.of(current))) {
chooser.setCurrentDirectory(Path.of(current).toFile());
}
if (chooser.showOpenDialog(this) == JFileChooser.APPROVE_OPTION) {
dirField.setText(chooser.getSelectedFile().getAbsolutePath());
}
}
private void startAnalysis() {
String dir = dirField.getText().trim();
if (dir.isEmpty()) {
JOptionPane.showMessageDialog(this,
"Please enter a directory to scan.", "No Directory", JOptionPane.WARNING_MESSAGE);
return;
}
Path root = Path.of(dir);
if (!Files.isDirectory(root)) {
JOptionPane.showMessageDialog(this,
"The specified path is not a directory:\n" + dir,
"Invalid Directory", JOptionPane.ERROR_MESSAGE);
return;
}
if (currentService != null && !currentService.isDone()) {
currentService.cancel(true);
}
settings.setLastDirectory(dir);
model.clear();
// Rebuild SourcePatcher set against the current settings so the tag
// editor knows which discovered methods can actually be written back.
TestDiscoveryConfig discoveryConfig = new TestDiscoveryConfig(
TagEditorPanel.buildFlatSuffixes(settings),
Set.copyOf(settings.getTestAnnotations()),
Map.of());
writeBackSupport = new SourceWriteBackSupport(discoveryConfig);
tagEditorPanel.setWriteBackSupport(writeBackSupport);
currentService = new AnalysisService(settings, root, model);
currentService.execute();
runButton.setEnabled(false);
cancelButton.setEnabled(true);
}
private void cancelAnalysis() {
if (currentService != null) {
currentService.cancel(true);
}
}
private void openSettings() {
SettingsDialog dlg = new SettingsDialog(this, settings);
dlg.setVisible(true);
if (dlg.isConfirmed()) {
refreshProfileCombo();
}
}
@SuppressWarnings("PMD.UnusedAssignment") // flag read by profileCombo's ActionListener; PMD can't trace cross-listener reads
private void refreshProfileCombo() {
updatingProfileCombo = true;
try {
profileCombo.removeAllItems();
for (AiProfile p : settings.getProfiles()) {
profileCombo.addItem(p.getName());
}
profileCombo.setSelectedItem(settings.getActiveProfileName());
} finally {
updatingProfileCombo = false;
}
}
// ── Save All Changes ──────────────────────────────────────────────────
/**
* Persists every staged {@link MethodEntry} to disk by routing each
* source file through its matching {@link SourcePatcher}. Files in a
* language with no patcher (TypeScript, Go, Python, …) are collected
* as per-file errors so the user gets an explicit message instead of
* a silent skip.
*
* <p>The actual work is delegated to small single-responsibility
* helpers ({@link #groupStagedByFile}, {@link #ensureWriteBackSupport},
* {@link #patchSingleFile}, {@link #writeAuditEvidence},
* {@link #reportSaveResult}) to keep the method's NPath complexity
* within the project PMD threshold.</p>
*/
private void saveAllChanges() {
List<MethodEntry> staged = model.getStagedEntries();
if (staged.isEmpty()) {
return;
}
Map<Path, List<MethodEntry>> byFile = groupStagedByFile(staged);
ensureWriteBackSupport();
List<String> errors = new ArrayList<>();
Set<Path> savedFiles = new LinkedHashSet<>();
List<AuditWriter.SavedEntry> auditEntries = new ArrayList<>();
for (Map.Entry<Path, List<MethodEntry>> fe : byFile.entrySet()) {
patchSingleFile(fe.getKey(), fe.getValue(), errors, savedFiles, auditEntries);
}
editorPanel.reloadIfAmong(savedFiles);
saveAllButton.setEnabled(model.hasStagedChanges());
writeAuditEvidence(auditEntries);
reportSaveResult(staged, savedFiles, errors);
}
/**
* Groups every staged entry by its source-file path. Entries whose
* {@code filePath()} is {@code null} (e.g. unsaved buffers) are
* silently skipped because they cannot be written to disk.
*/
@SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
private static Map<Path, List<MethodEntry>> groupStagedByFile(List<MethodEntry> staged) {
Map<Path, List<MethodEntry>> byFile = new LinkedHashMap<>();
for (MethodEntry entry : staged) {
Path fp = entry.discovered().filePath();
if (fp != null) {
byFile.computeIfAbsent(fp, k -> new ArrayList<>()).add(entry);
}
}
return byFile;
}
/**
* Lazily creates the shared {@link SourceWriteBackSupport} and hands
* it to the tag editor so UI gating and on-disk write-back agree on
* which languages are supported.
*/
private void ensureWriteBackSupport() {
if (writeBackSupport != null) {
return;
}
TestDiscoveryConfig config = new TestDiscoveryConfig(
TagEditorPanel.buildFlatSuffixes(settings),
Set.copyOf(settings.getTestAnnotations()),
Map.of());
writeBackSupport = new SourceWriteBackSupport(config);
tagEditorPanel.setWriteBackSupport(writeBackSupport);
}
/**
* Writes all staged changes for one source file via its
* {@link SourcePatcher}. Records per-file errors in {@code errors},
* appends a {@link AuditWriter.SavedEntry} for each persisted method,
* and adds the file to {@code savedFiles} on success.
*/
private void patchSingleFile(Path filePath, List<MethodEntry> entries,
List<String> errors, Set<Path> savedFiles,
List<AuditWriter.SavedEntry> auditEntries) {
SourcePatcher patcher = writeBackSupport.findPatcher(filePath);
if (patcher == null) {
errors.add(filePath.getFileName()
+ ": source write-back is not supported for this language "
+ "(supported: " + writeBackSupport.supportedLanguagesLabel() + ")");
return;
}
Map<String, List<String>> tagsToApply = new LinkedHashMap<>();
Map<String, String> displayNames = new LinkedHashMap<>();
for (MethodEntry e : entries) {
tagsToApply.put(e.discovered().method(), e.getPendingTags());
String dn = e.getPendingDisplayName();
if (dn != null) {
displayNames.put(e.discovered().method(), dn);
}
}
StringWriter sw = new StringWriter();
try {
patcher.patch(filePath, tagsToApply, displayNames, new PrintWriter(sw));
for (MethodEntry e : entries) {
// Snapshot before clearing so AuditWriter sees the applied values.
auditEntries.add(new AuditWriter.SavedEntry(
e.discovered().fqcn(),
e.discovered().method(),
e.discovered().loc(),
e.getPendingTags(),
e.getPendingDisplayName(),
e.suggestion()));
e.setAppliedTags(e.getPendingTags());
e.clearStagedPatch();
model.notifyEntryChanged(e);
}
savedFiles.add(filePath);
} catch (IOException ex) {
errors.add(filePath.getFileName() + ": " + ex.getMessage());
}
}
/**
* Writes the audit log to the project's {@code .methodatlas/} folder
* (rooted at the currently scanned directory). Audit-write failures
* are surfaced as a warning dialog but do <strong>not</strong> roll
* back the source-file patches that have already been written.
*/
private void writeAuditEvidence(List<AuditWriter.SavedEntry> auditEntries) {
if (auditEntries.isEmpty()) {
return;
}
String dir = dirField.getText().trim();
if (dir.isEmpty()) {
return;
}
try {
AuditWriter.write(Path.of(dir), auditEntries, settings.getOperatorName());
} catch (IOException ex) {
JOptionPane.showMessageDialog(this,
"Source files were saved but audit records could not be written to .methodatlas/:\n"
+ ex.getMessage(),
"Audit Write Warning", JOptionPane.WARNING_MESSAGE);
}
}
/**
* Sets the status-bar message on a clean save, or shows an error
* dialog listing every per-file failure when any error occurred.
*/
private void reportSaveResult(List<MethodEntry> staged,
Set<Path> savedFiles, List<String> errors) {
if (errors.isEmpty()) {
model.setStatusMessage("Saved " + staged.size() + " method(s) across "
+ savedFiles.size() + " file(s)");
return;
}
JOptionPane.showMessageDialog(this,
"Some files could not be saved:\n" + String.join("\n", errors),
"Save Error", JOptionPane.ERROR_MESSAGE);
}
// ── Model observer ────────────────────────────────────────────────────
private void wireModelObserver() {
model.addPropertyChangeListener("status", evt -> {
AnalysisModel.Status s = (AnalysisModel.Status) evt.getNewValue();
boolean done = s == AnalysisModel.Status.DONE
|| s == AnalysisModel.Status.ERROR
|| s == AnalysisModel.Status.IDLE;
runButton.setEnabled(done);
cancelButton.setEnabled(!done);
});
model.addPropertyChangeListener("entries", evt -> {
saveAllButton.setEnabled(model.hasStagedChanges());
});
model.addPropertyChangeListener("cleared", evt -> {
saveAllButton.setEnabled(false);
});
}
// ── Window state ──────────────────────────────────────────────────────
private void initSplitPositions() {
// Seed the scan-pane minimum immediately with the realized window width.
scanPanel.setMinimumSize(new Dimension((int) (mainSplit.getWidth() * 0.10), 1));
int rsp = settings.getRightSplitPosition();
if (rsp > 0) {
rightSplit.setDividerLocation(rsp);
} else {
// Place the divider so the tag editor gets exactly its preferred height
// and the editor takes all remaining vertical space. The window is
// realized at this point (invokeLater fires after setVisible(true)).
int tagPref = tagEditorPanel.getPreferredSize().height;
int available = rightSplit.getHeight();
int pos = available - rightSplit.getDividerSize() - tagPref;
rightSplit.setDividerLocation(Math.max(pos, 100));
}
}
private void restoreWindowState() {
setSize(settings.getWindowWidth(), settings.getWindowHeight());
setLocationRelativeTo(null);
}
private void applyLastDirectory() {
String last = settings.getLastDirectory();
if (last != null && !last.isBlank()) {
dirField.setText(last);
}
}
@SuppressWarnings("PMD.DoNotTerminateVM")
private void onClose() {
if (model.hasStagedChanges()) {
int choice = JOptionPane.showConfirmDialog(this,
"You have staged changes that have not been written to disk.\n"
+ "Save them now before closing?",
"Unsaved Staged Changes",
JOptionPane.YES_NO_CANCEL_OPTION,
JOptionPane.WARNING_MESSAGE);
if (choice == JOptionPane.CANCEL_OPTION || choice == JOptionPane.CLOSED_OPTION) {
return;
}
if (choice == JOptionPane.YES_OPTION) {
saveAllChanges();
}
}
if (currentService != null && !currentService.isDone()) {
currentService.cancel(true);
}
settings.setWindowWidth(getWidth());
settings.setWindowHeight(getHeight());
SettingsManager.save(settings);
dispose();
System.exit(0);
}
}