diff --git a/plugin/src/main/java/io/github/gabrielbbaldez/stacktale/idea/StacktalePanel.java b/plugin/src/main/java/io/github/gabrielbbaldez/stacktale/idea/StacktalePanel.java index 171fdc5..a1ef2dc 100644 --- a/plugin/src/main/java/io/github/gabrielbbaldez/stacktale/idea/StacktalePanel.java +++ b/plugin/src/main/java/io/github/gabrielbbaldez/stacktale/idea/StacktalePanel.java @@ -1,24 +1,21 @@ package io.github.gabrielbbaldez.stacktale.idea; import com.intellij.icons.AllIcons; +import com.intellij.openapi.Disposable; import com.intellij.openapi.actionSystem.ActionManager; import com.intellij.openapi.actionSystem.ActionToolbar; import com.intellij.openapi.actionSystem.AnAction; import com.intellij.openapi.actionSystem.AnActionEvent; import com.intellij.openapi.actionSystem.DefaultActionGroup; -import com.intellij.openapi.application.ReadAction; import com.intellij.openapi.ide.CopyPasteManager; import com.intellij.openapi.project.Project; import com.intellij.openapi.ui.SimpleToolWindowPanel; -import com.intellij.openapi.vfs.VirtualFile; import com.intellij.openapi.wm.ToolWindow; -import com.intellij.psi.search.FilenameIndex; -import com.intellij.psi.search.GlobalSearchScope; import com.intellij.ui.JBSplitter; import com.intellij.ui.components.JBList; import com.intellij.ui.components.JBScrollPane; -import com.intellij.util.Alarm; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import javax.swing.DefaultListCellRenderer; import javax.swing.DefaultListModel; @@ -29,34 +26,28 @@ import java.awt.datatransfer.StringSelection; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; import java.nio.file.Path; -import java.util.Collection; import java.util.List; /** * The Stacktale tool window: newest reports on the left, the raw block on the right, - * a toolbar to refresh / jump to the culprit / copy for an AI. Re-reads {@code errors-ai.log} - * on a light poll so new errors show up without a manual refresh. + * and a toolbar to refresh / jump to the culprit / copy for an AI. Reports come from + * the single project-level poll owned by {@link StacktaleReportService}. */ -class StacktalePanel extends SimpleToolWindowPanel { - - private static final int POLL_MILLIS = 3000; +class StacktalePanel extends SimpleToolWindowPanel implements Disposable { private final Project project; private final ToolWindow toolWindow; + private final StacktaleReportService reportService; private final DefaultListModel model = new DefaultListModel<>(); private final JBList list = new JBList<>(model); private final JTextArea detail = new JTextArea(); - private final Alarm alarm; - private String lastContent; StacktalePanel(Project project, ToolWindow toolWindow) { super(true, true); this.project = project; this.toolWindow = toolWindow; - this.alarm = new Alarm(Alarm.ThreadToUse.SWING_THREAD, project); + this.reportService = StacktaleReportService.getInstance(project); list.setCellRenderer(new ReportCellRenderer()); list.addListSelectionListener(e -> showSelected()); @@ -76,8 +67,7 @@ public void mouseClicked(MouseEvent e) { setContent(splitter); setToolbar(buildToolbar().getComponent()); - refresh(); - schedulePoll(); + reportService.addListener(this::reportsChanged, this); } private ActionToolbar buildToolbar() { @@ -85,8 +75,7 @@ private ActionToolbar buildToolbar() { group.add(new AnAction("Refresh", "Re-read errors-ai.log", AllIcons.Actions.Refresh) { @Override public void actionPerformed(@NotNull AnActionEvent e) { - lastContent = null; // force a re-read - refresh(); + reportService.refreshNow(); } }); group.add(new AnAction("Jump to Culprit", "Open the culprit frame in the editor", AllIcons.Actions.EditSource) { @@ -106,36 +95,17 @@ public void actionPerformed(@NotNull AnActionEvent e) { return toolbar; } - private void schedulePoll() { - alarm.addRequest(() -> { - refresh(); - schedulePoll(); - }, POLL_MILLIS); - } - - private void refresh() { - Path log = findLog(); + private void reportsChanged(@Nullable Path log, @NotNull List reports) { if (log == null) { toolWindow.setTitle("Stacktale"); model.clear(); detail.setText("No errors-ai.log found in this project yet.\n\n" + "Add the stacktale library and trigger an error — reports will appear here."); - lastContent = null; return; } toolWindow.setTitle("Stacktale — " + log); - String content; - try { - content = Files.readString(log, StandardCharsets.UTF_8); - } catch (Exception e) { - return; // transient (mid-write, locked) — the next poll retries - } - if (content.equals(lastContent)) return; - lastContent = content; - - List reports = StReportParser.parse(content); StReport previouslySelected = list.getSelectedValue(); model.clear(); for (int i = reports.size() - 1; i >= 0; i--) model.addElement(reports.get(i)); // newest first @@ -156,22 +126,6 @@ private int indexOfId(String id) { return 0; } - /** Prefer the conventional ./errors-ai.log; fall back to any indexed one in the project. */ - private Path findLog() { - String base = project.getBasePath(); - if (base != null) { - Path candidate = Path.of(base, "errors-ai.log"); - if (Files.isRegularFile(candidate)) return candidate; - } - Collection found = ReadAction.compute(() -> - FilenameIndex.getVirtualFilesByName("errors-ai.log", GlobalSearchScope.projectScope(project))); - for (VirtualFile vf : found) { - Path p = Path.of(vf.getPath()); - if (Files.isRegularFile(p)) return p; - } - return null; - } - private void showSelected() { StReport report = list.getSelectedValue(); detail.setText(report == null ? "" : report.block()); @@ -191,6 +145,10 @@ private void copySelected() { if (report != null) CopyPasteManager.getInstance().setContents(new StringSelection(report.block())); } + @Override + public void dispose() { + } + private static class ReportCellRenderer extends DefaultListCellRenderer { @Override public Component getListCellRendererComponent(JList l, Object value, int i, boolean selected, boolean focus) { diff --git a/plugin/src/main/java/io/github/gabrielbbaldez/stacktale/idea/StacktaleReportService.java b/plugin/src/main/java/io/github/gabrielbbaldez/stacktale/idea/StacktaleReportService.java new file mode 100644 index 0000000..6b3b2eb --- /dev/null +++ b/plugin/src/main/java/io/github/gabrielbbaldez/stacktale/idea/StacktaleReportService.java @@ -0,0 +1,161 @@ +package io.github.gabrielbbaldez.stacktale.idea; + +import com.intellij.openapi.Disposable; +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.application.ReadAction; +import com.intellij.openapi.components.Service; +import com.intellij.openapi.project.DumbService; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.util.Disposer; +import com.intellij.openapi.vfs.VirtualFile; +import com.intellij.psi.search.FilenameIndex; +import com.intellij.psi.search.GlobalSearchScope; +import com.intellij.util.Alarm; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collection; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * Project-level source of Stacktale reports. + * + * Owns the single errors-ai.log poll used by both the tool window and status-bar widget. + */ +@Service(Service.Level.PROJECT) +public final class StacktaleReportService implements Disposable { + + private static final int POLL_MILLIS = 3000; + + interface Listener { + void reportsChanged(@Nullable Path log, @NotNull List reports); + } + + private final Project project; + private final Alarm alarm; + private final List listeners = new CopyOnWriteArrayList<>(); + + private volatile Path currentLog; + private volatile List currentReports = List.of(); + private String lastContent; + private volatile boolean disposed; + + public StacktaleReportService(@NotNull Project project) { + this.project = project; + this.alarm = new Alarm(Alarm.ThreadToUse.POOLED_THREAD, this); + alarm.addRequest(this::poll, 0); + } + + static @NotNull StacktaleReportService getInstance(@NotNull Project project) { + return project.getService(StacktaleReportService.class); + } + + void addListener( + @NotNull Listener listener, + @NotNull Disposable parent + ) { + listeners.add(listener); + Disposer.register(parent, () -> listeners.remove(listener)); + listener.reportsChanged(currentLog, currentReports); + } + + void refreshNow() { + if (disposed || project.isDisposed()) return; + alarm.addRequest(() -> refresh(true), 0); + } + + private void poll() { + if (disposed || project.isDisposed()) return; + + refresh(false); + + if (!disposed && !project.isDisposed()) { + alarm.addRequest(this::poll, POLL_MILLIS); + } + } + + private synchronized void refresh(boolean force) { + if (disposed || project.isDisposed()) return; + + Path log = findLog(); + + // A nested log cannot be resolved while project indexes are unavailable. + // Preserve the current state and let the next poll retry after indexing. + if (log == null && DumbService.getInstance(project).isDumb()) return; + + if (log == null) { + boolean changed = currentLog != null + || lastContent != null + || !currentReports.isEmpty(); + + currentLog = null; + currentReports = List.of(); + lastContent = null; + + if (changed) notifyListeners(); + return; + } + + String content; + try { + content = Files.readString(log, StandardCharsets.UTF_8); + } catch (Exception ignored) { + return; + } + + if (!force && log.equals(currentLog) && content.equals(lastContent)) return; + + currentLog = log; + lastContent = content; + currentReports = List.copyOf(StReportParser.parse(content)); + notifyListeners(); + } + + private void notifyListeners() { + Path log = currentLog; + List reports = currentReports; + + ApplicationManager.getApplication().invokeLater(() -> { + if (disposed || project.isDisposed()) return; + + for (Listener listener : listeners) { + listener.reportsChanged(log, reports); + } + }); + } + + /** Prefer ./errors-ai.log; otherwise use an indexed file in the project. */ + private @Nullable Path findLog() { + String base = project.getBasePath(); + if (base != null) { + Path candidate = Path.of(base, "errors-ai.log"); + if (Files.isRegularFile(candidate)) return candidate; + } + + if (DumbService.getInstance(project).isDumb()) return null; + + Collection found = ReadAction.compute(() -> + FilenameIndex.getVirtualFilesByName( + "errors-ai.log", + GlobalSearchScope.projectScope(project) + )); + + for (VirtualFile file : found) { + Path path = Path.of(file.getPath()); + if (Files.isRegularFile(path)) return path; + } + + return null; + } + + @Override + public void dispose() { + disposed = true; + listeners.clear(); + alarm.cancelAllRequests(); + } +} diff --git a/plugin/src/main/java/io/github/gabrielbbaldez/stacktale/idea/StacktaleStatusBarWidgetFactory.java b/plugin/src/main/java/io/github/gabrielbbaldez/stacktale/idea/StacktaleStatusBarWidgetFactory.java new file mode 100644 index 0000000..97c2bd2 --- /dev/null +++ b/plugin/src/main/java/io/github/gabrielbbaldez/stacktale/idea/StacktaleStatusBarWidgetFactory.java @@ -0,0 +1,124 @@ +package io.github.gabrielbbaldez.stacktale.idea; + +import com.intellij.openapi.project.Project; +import com.intellij.openapi.wm.StatusBar; +import com.intellij.openapi.wm.StatusBarWidget; +import com.intellij.openapi.wm.StatusBarWidgetFactory; +import com.intellij.openapi.wm.ToolWindow; +import com.intellij.openapi.wm.ToolWindowManager; +import com.intellij.util.Consumer; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.awt.Component; +import java.awt.event.MouseEvent; +import java.nio.file.Path; +import java.util.List; + +/** Creates the status-bar widget showing the current Stacktale report count. */ +public final class StacktaleStatusBarWidgetFactory implements StatusBarWidgetFactory { + + private static final String WIDGET_ID = "StacktaleErrorCount"; + + @Override + public @NotNull String getId() { + return WIDGET_ID; + } + + @Override + public @NotNull String getDisplayName() { + return "Stacktale error count"; + } + + @Override + public @NotNull StatusBarWidget createWidget(@NotNull Project project) { + return new StacktaleStatusBarWidget(project); + } + + private static final class StacktaleStatusBarWidget + implements StatusBarWidget, + StatusBarWidget.TextPresentation, + StacktaleReportService.Listener { + + private final Project project; + + private volatile StatusBar statusBar; + private volatile Path currentLog; + private volatile int reportCount; + + private StacktaleStatusBarWidget(@NotNull Project project) { + this.project = project; + StacktaleReportService.getInstance(project).addListener(this, this); + } + + @Override + public @NotNull String ID() { + return WIDGET_ID; + } + + @Override + public StatusBarWidget.WidgetPresentation getPresentation() { + return this; + } + + @Override + public void install(@NotNull StatusBar statusBar) { + this.statusBar = statusBar; + statusBar.updateWidget(WIDGET_ID); + } + + @Override + public @NotNull String getText() { + return "stacktale: " + reportCount; + } + + @Override + public float getAlignment() { + return Component.CENTER_ALIGNMENT; + } + + @Override + public @NotNull String getTooltipText() { + if (currentLog == null) { + return "No errors-ai.log found in this project"; + } + + String reportWord = reportCount == 1 ? "report" : "reports"; + return reportCount + " Stacktale error " + reportWord + + " in " + currentLog; + } + + @Override + public @NotNull Consumer getClickConsumer() { + return event -> { + if (project.isDisposed()) return; + + ToolWindow toolWindow = ToolWindowManager.getInstance(project) + .getToolWindow("Stacktale"); + + if (toolWindow != null) { + toolWindow.activate(null); + } + }; + } + + @Override + public void reportsChanged( + @Nullable Path log, + @NotNull List reports + ) { + currentLog = log; + reportCount = reports.size(); + + StatusBar installedStatusBar = statusBar; + if (installedStatusBar != null) { + installedStatusBar.updateWidget(WIDGET_ID); + } + } + + @Override + public void dispose() { + statusBar = null; + } + } +} diff --git a/plugin/src/main/java/io/github/gabrielbbaldez/stacktale/idea/StacktaleToolWindowFactory.java b/plugin/src/main/java/io/github/gabrielbbaldez/stacktale/idea/StacktaleToolWindowFactory.java index 2cb6965..3750535 100644 --- a/plugin/src/main/java/io/github/gabrielbbaldez/stacktale/idea/StacktaleToolWindowFactory.java +++ b/plugin/src/main/java/io/github/gabrielbbaldez/stacktale/idea/StacktaleToolWindowFactory.java @@ -14,6 +14,7 @@ public class StacktaleToolWindowFactory implements ToolWindowFactory { public void createToolWindowContent(@NotNull Project project, @NotNull ToolWindow toolWindow) { StacktalePanel panel = new StacktalePanel(project, toolWindow); Content content = ContentFactory.getInstance().createContent(panel, "", false); + content.setDisposer(panel); toolWindow.getContentManager().addContent(content); } } diff --git a/plugin/src/main/resources/META-INF/plugin.xml b/plugin/src/main/resources/META-INF/plugin.xml index ecdb7a4..d2a4fcd 100644 --- a/plugin/src/main/resources/META-INF/plugin.xml +++ b/plugin/src/main/resources/META-INF/plugin.xml @@ -20,5 +20,9 @@ + +