From 3aac21b2c6896bd7a24995f55290bbd356551614 Mon Sep 17 00:00:00 2001 From: janithcd Date: Sun, 2 Aug 2026 20:54:11 +0530 Subject: [PATCH 1/2] feat: add error count status-bar widget Closes #4 --- .../stacktale/idea/StacktalePanel.java | 65 +------- .../idea/StacktaleReportService.java | 146 ++++++++++++++++++ .../idea/StacktaleStatusBarWidgetFactory.java | 127 +++++++++++++++ plugin/src/main/resources/META-INF/plugin.xml | 4 + 4 files changed, 285 insertions(+), 57 deletions(-) create mode 100644 plugin/src/main/java/io/github/gabrielbbaldez/stacktale/idea/StacktaleReportService.java create mode 100644 plugin/src/main/java/io/github/gabrielbbaldez/stacktale/idea/StacktaleStatusBarWidgetFactory.java 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..04903e6 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 @@ -6,19 +6,15 @@ 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 +25,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; - 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 +66,7 @@ public void mouseClicked(MouseEvent e) { setContent(splitter); setToolbar(buildToolbar().getComponent()); - refresh(); - schedulePoll(); + reportService.addListener(this::reportsChanged); } private ActionToolbar buildToolbar() { @@ -85,8 +74,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 +94,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 +125,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()); @@ -190,7 +143,6 @@ private void copySelected() { StReport report = list.getSelectedValue(); if (report != null) CopyPasteManager.getInstance().setContents(new StringSelection(report.block())); } - private static class ReportCellRenderer extends DefaultListCellRenderer { @Override public Component getListCellRendererComponent(JList l, Object value, int i, boolean selected, boolean focus) { @@ -200,7 +152,6 @@ public Component getListCellRendererComponent(JList l, Object value, int i, b } return this; } - private static String escape(String s) { return s.replace("&", "&").replace("<", "<").replace(">", ">"); } 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..9381184 --- /dev/null +++ b/plugin/src/main/java/io/github/gabrielbbaldez/stacktale/idea/StacktaleReportService.java @@ -0,0 +1,146 @@ +package io.github.gabrielbbaldez.stacktale.idea; + +import com.intellij.openapi.Disposable; +import com.intellij.openapi.application.ReadAction; +import com.intellij.openapi.components.Service; +import com.intellij.openapi.project.Project; +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.SWING_THREAD, this); + alarm.addRequest(this::poll, 0); + } + + static @NotNull StacktaleReportService getInstance(@NotNull Project project) { + return project.getService(StacktaleReportService.class); + } + + void addListener(@NotNull Listener listener) { + listeners.add(listener); + listener.reportsChanged(currentLog, currentReports); + } + + void removeListener(@NotNull Listener listener) { + listeners.remove(listener); + } + + void refreshNow() { + lastContent = null; + refresh(); + } + + private void poll() { + if (disposed || project.isDisposed()) return; + + refresh(); + + if (!disposed && !project.isDisposed()) { + alarm.addRequest(this::poll, POLL_MILLIS); + } + } + + private void refresh() { + Path log = findLog(); + + 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 (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; + + 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; + } + + 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..910455e --- /dev/null +++ b/plugin/src/main/java/io/github/gabrielbbaldez/stacktale/idea/StacktaleStatusBarWidgetFactory.java @@ -0,0 +1,127 @@ +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 final StacktaleReportService reportService; + + private volatile StatusBar statusBar; + private volatile Path currentLog; + private volatile int reportCount; + + private StacktaleStatusBarWidget(@NotNull Project project) { + this.project = project; + this.reportService = StacktaleReportService.getInstance(project); + reportService.addListener(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() { + reportService.removeListener(this); + statusBar = null; + } + } +} 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 @@ + + From 61bacb8d102a34611d0ce8861f383a3a0733ab2f Mon Sep 17 00:00:00 2001 From: janithcd Date: Mon, 3 Aug 2026 12:52:58 +0530 Subject: [PATCH 2/2] fix: address status-bar widget review feedback --- .../stacktale/idea/StacktalePanel.java | 11 ++++- .../idea/StacktaleReportService.java | 43 +++++++++++++------ .../idea/StacktaleStatusBarWidgetFactory.java | 5 +-- .../idea/StacktaleToolWindowFactory.java | 1 + 4 files changed, 40 insertions(+), 20 deletions(-) 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 04903e6..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,6 +1,7 @@ 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; @@ -33,7 +34,7 @@ * 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 { +class StacktalePanel extends SimpleToolWindowPanel implements Disposable { private final Project project; private final ToolWindow toolWindow; @@ -66,7 +67,7 @@ public void mouseClicked(MouseEvent e) { setContent(splitter); setToolbar(buildToolbar().getComponent()); - reportService.addListener(this::reportsChanged); + reportService.addListener(this::reportsChanged, this); } private ActionToolbar buildToolbar() { @@ -143,6 +144,11 @@ private void copySelected() { StReport report = list.getSelectedValue(); 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) { @@ -152,6 +158,7 @@ public Component getListCellRendererComponent(JList l, Object value, int i, b } return this; } + private static String escape(String s) { return s.replace("&", "&").replace("<", "<").replace(">", ">"); } 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 index 9381184..6b3b2eb 100644 --- a/plugin/src/main/java/io/github/gabrielbbaldez/stacktale/idea/StacktaleReportService.java +++ b/plugin/src/main/java/io/github/gabrielbbaldez/stacktale/idea/StacktaleReportService.java @@ -1,9 +1,12 @@ 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; @@ -43,7 +46,7 @@ interface Listener { public StacktaleReportService(@NotNull Project project) { this.project = project; - this.alarm = new Alarm(Alarm.ThreadToUse.SWING_THREAD, this); + this.alarm = new Alarm(Alarm.ThreadToUse.POOLED_THREAD, this); alarm.addRequest(this::poll, 0); } @@ -51,33 +54,39 @@ public StacktaleReportService(@NotNull Project project) { return project.getService(StacktaleReportService.class); } - void addListener(@NotNull Listener listener) { + void addListener( + @NotNull Listener listener, + @NotNull Disposable parent + ) { listeners.add(listener); + Disposer.register(parent, () -> listeners.remove(listener)); listener.reportsChanged(currentLog, currentReports); } - void removeListener(@NotNull Listener listener) { - listeners.remove(listener); - } - void refreshNow() { - lastContent = null; - refresh(); + if (disposed || project.isDisposed()) return; + alarm.addRequest(() -> refresh(true), 0); } private void poll() { if (disposed || project.isDisposed()) return; - refresh(); + refresh(false); if (!disposed && !project.isDisposed()) { alarm.addRequest(this::poll, POLL_MILLIS); } } - private void refresh() { + 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 @@ -98,7 +107,7 @@ private void refresh() { return; } - if (log.equals(currentLog) && content.equals(lastContent)) return; + if (!force && log.equals(currentLog) && content.equals(lastContent)) return; currentLog = log; lastContent = content; @@ -110,9 +119,13 @@ private void notifyListeners() { Path log = currentLog; List reports = currentReports; - for (Listener listener : listeners) { - listener.reportsChanged(log, reports); - } + 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. */ @@ -123,6 +136,8 @@ private void notifyListeners() { if (Files.isRegularFile(candidate)) return candidate; } + if (DumbService.getInstance(project).isDumb()) return null; + Collection found = ReadAction.compute(() -> FilenameIndex.getVirtualFilesByName( "errors-ai.log", 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 index 910455e..97c2bd2 100644 --- a/plugin/src/main/java/io/github/gabrielbbaldez/stacktale/idea/StacktaleStatusBarWidgetFactory.java +++ b/plugin/src/main/java/io/github/gabrielbbaldez/stacktale/idea/StacktaleStatusBarWidgetFactory.java @@ -41,7 +41,6 @@ private static final class StacktaleStatusBarWidget StacktaleReportService.Listener { private final Project project; - private final StacktaleReportService reportService; private volatile StatusBar statusBar; private volatile Path currentLog; @@ -49,8 +48,7 @@ private static final class StacktaleStatusBarWidget private StacktaleStatusBarWidget(@NotNull Project project) { this.project = project; - this.reportService = StacktaleReportService.getInstance(project); - reportService.addListener(this); + StacktaleReportService.getInstance(project).addListener(this, this); } @Override @@ -120,7 +118,6 @@ public void reportsChanged( @Override public void dispose() { - reportService.removeListener(this); 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); } }