Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<StReport> model = new DefaultListModel<>();
private final JBList<StReport> 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());
Expand All @@ -76,17 +67,15 @@ public void mouseClicked(MouseEvent e) {
setContent(splitter);
setToolbar(buildToolbar().getComponent());

refresh();
schedulePoll();
reportService.addListener(this::reportsChanged, this);
}

private ActionToolbar buildToolbar() {
DefaultActionGroup group = new DefaultActionGroup();
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) {
Expand All @@ -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<StReport> 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<StReport> 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
Expand All @@ -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<VirtualFile> 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());
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<StReport> reports);
}

private final Project project;
private final Alarm alarm;
private final List<Listener> listeners = new CopyOnWriteArrayList<>();

private volatile Path currentLog;
private volatile List<StReport> 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<StReport> 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<VirtualFile> 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();
}
}
Loading
Loading