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
@@ -0,0 +1,58 @@
package io.github.gabrielbbaldez.stacktale.idea;

import java.nio.file.InvalidPathException;
import java.nio.file.Path;

/**
* The settings rules shared by the plugin's persisted state and its settings page: the
* defaults that reproduce the behaviour from before the settings existed, the bounds a
* stored value is held to, and how a configured log path becomes a filesystem path.
*
* No IntelliJ API on purpose, so the rules are unit-testable in :core.
*/
public final class StSettings {

/** Empty means auto-detect — what the plugin did before the path could be set. */
public static final String DEFAULT_LOG_PATH = "";

/** The poll the tool window has always used. */
public static final int DEFAULT_POLL_SECONDS = 3;

public static final int MIN_POLL_SECONDS = 1;
public static final int MAX_POLL_SECONDS = 3600;

private StSettings() {
}

/** Blank collapses to {@link #DEFAULT_LOG_PATH}; surrounding whitespace is a typo, not a path. */
public static String normalizeLogPath(String raw) {
return raw == null ? DEFAULT_LOG_PATH : raw.trim();
}

/**
* Holds a stored interval inside the range the spinner offers. The settings file is
* hand-editable, and a 0 that reached the poll would turn it into a busy loop.
*/
public static int clampPollSeconds(int raw) {
return Math.min(MAX_POLL_SECONDS, Math.max(MIN_POLL_SECONDS, raw));
}

/**
* Resolves a configured log path, a relative one against the project root. Returns null
* when nothing is configured (auto-detect) or when the text is not a usable path —
* whether the file exists is the caller's question, not this one.
*/
public static Path resolveLogPath(String configured, String projectBasePath) {
String path = normalizeLogPath(configured);
if (path.isEmpty()) return null;

try {
Path candidate = Path.of(path);
if (candidate.isAbsolute()) return candidate.normalize();
if (projectBasePath == null) return null;
return Path.of(projectBasePath).resolve(candidate).normalize();
} catch (InvalidPathException e) {
return null;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package io.github.gabrielbbaldez.stacktale.idea;

import org.junit.jupiter.api.Test;

import java.nio.file.Path;

import static org.assertj.core.api.Assertions.assertThat;

class StSettingsTest {

private static final String BASE = Path.of("home", "dev", "shop-api").toAbsolutePath().toString();

@Test
void defaultsReproduceTheBehaviourFromBeforeTheSettingsExisted() {
assertThat(StSettings.DEFAULT_LOG_PATH).isEmpty();
assertThat(StSettings.DEFAULT_POLL_SECONDS).isEqualTo(3); // the 3000 ms poll
}

@Test
void blankOrMissingLogPathMeansAutoDetect() {
assertThat(StSettings.normalizeLogPath(null)).isEqualTo(StSettings.DEFAULT_LOG_PATH);
assertThat(StSettings.normalizeLogPath("")).isEqualTo(StSettings.DEFAULT_LOG_PATH);
assertThat(StSettings.normalizeLogPath(" ")).isEqualTo(StSettings.DEFAULT_LOG_PATH);

assertThat(StSettings.resolveLogPath("", BASE)).isNull();
assertThat(StSettings.resolveLogPath(" ", BASE)).isNull();
}

@Test
void trimsWhitespaceAroundAPastedLogPath() {
assertThat(StSettings.normalizeLogPath(" build/errors-ai.log\n")).isEqualTo("build/errors-ai.log");
assertThat(StSettings.resolveLogPath(" build/errors-ai.log ", BASE))
.isEqualTo(Path.of(BASE, "build", "errors-ai.log"));
}

@Test
void resolvesARelativeLogPathAgainstTheProjectRoot() {
assertThat(StSettings.resolveLogPath("build/errors-ai.log", BASE))
.isEqualTo(Path.of(BASE, "build", "errors-ai.log"));
assertThat(StSettings.resolveLogPath("./api/build/errors-ai.log", BASE))
.isEqualTo(Path.of(BASE, "api", "build", "errors-ai.log"));
}

@Test
void keepsAnAbsoluteLogPathAsGiven() {
Path absolute = Path.of("var", "log", "errors-ai.log").toAbsolutePath();

assertThat(StSettings.resolveLogPath(absolute.toString(), BASE)).isEqualTo(absolute);
}

@Test
void cannotResolveARelativePathForAProjectWithNoRoot() {
assertThat(StSettings.resolveLogPath("build/errors-ai.log", null)).isNull();
}

@Test
void rejectsTextTheFilesystemCannotRepresentInsteadOfThrowing() {
String nulInTheMiddle = "errors" + (char) 0 + "ai.log"; // rejected on every platform

assertThat(StSettings.resolveLogPath(nulInTheMiddle, BASE)).isNull();
}

@Test
void clampsAPollIntervalOutsideTheSpinnerRange() {
assertThat(StSettings.clampPollSeconds(0)).isEqualTo(StSettings.MIN_POLL_SECONDS);
assertThat(StSettings.clampPollSeconds(-30)).isEqualTo(StSettings.MIN_POLL_SECONDS);
assertThat(StSettings.clampPollSeconds(Integer.MAX_VALUE)).isEqualTo(StSettings.MAX_POLL_SECONDS);
assertThat(StSettings.clampPollSeconds(StSettings.DEFAULT_POLL_SECONDS))
.isEqualTo(StSettings.DEFAULT_POLL_SECONDS);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package io.github.gabrielbbaldez.stacktale.idea;

import com.intellij.openapi.options.Configurable;
import com.intellij.openapi.project.Project;
import com.intellij.ui.JBIntSpinner;
import com.intellij.ui.components.JBLabel;
import com.intellij.ui.components.JBTextField;
import com.intellij.util.ui.FormBuilder;
import org.jetbrains.annotations.Nls;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

import javax.swing.JComponent;
import javax.swing.JPanel;

/** Settings → Tools → Stacktale: point the tool window at a log file and tune the poll. */
public final class StacktaleConfigurable implements Configurable {

private final Project project;

private JBTextField logPathField;
private JBIntSpinner pollSpinner;
private JPanel panel;

public StacktaleConfigurable(@NotNull Project project) {
this.project = project;
}

@Override
public @Nls(capitalization = Nls.Capitalization.Title) String getDisplayName() {
return "Stacktale";
}

@Override
public @Nullable JComponent createComponent() {
logPathField = new JBTextField();
pollSpinner = new JBIntSpinner(
StSettings.DEFAULT_POLL_SECONDS,
StSettings.MIN_POLL_SECONDS,
StSettings.MAX_POLL_SECONDS);

panel = FormBuilder.createFormBuilder()
.addLabeledComponent(new JBLabel("Log file:"), logPathField, 1, false)
.addTooltip("Absolute, or relative to the project root. "
+ "Leave empty to auto-detect errors-ai.log.")
.addLabeledComponent(new JBLabel("Poll interval (seconds):"), pollSpinner, 1, false)
.addComponentFillVertically(new JPanel(), 0)
.getPanel();

reset();
return panel;
}

@Override
public boolean isModified() {
StacktaleSettings settings = StacktaleSettings.getInstance(project);
return !settings.logPath().equals(logPath()) || settings.pollSeconds() != pollSeconds();
}

@Override
public void apply() {
StacktaleSettings settings = StacktaleSettings.getInstance(project);
settings.setLogPath(logPath());
settings.setPollSeconds(pollSeconds());

// The next tick would pick both up on its own; re-reading now means a changed path
// shows its reports as soon as the dialog closes.
StacktaleReportService.getInstance(project).refreshNow();
}

@Override
public void reset() {
StacktaleSettings settings = StacktaleSettings.getInstance(project);
logPathField.setText(settings.logPath());
pollSpinner.setNumber(settings.pollSeconds());
}

@Override
public void disposeUIResources() {
panel = null;
logPathField = null;
pollSpinner = null;
}

private String logPath() {
return StSettings.normalizeLogPath(logPathField.getText());
}

/** The spinner already holds the value in range, so the settings class has nothing to clamp. */
private int pollSeconds() {
return pollSpinner.getNumber();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ public void mouseClicked(MouseEvent e) {

private ActionToolbar buildToolbar() {
DefaultActionGroup group = new DefaultActionGroup();
group.add(new AnAction("Refresh", "Re-read errors-ai.log", AllIcons.Actions.Refresh) {
group.add(new AnAction("Refresh", "Re-read the report log", AllIcons.Actions.Refresh) {
@Override
public void actionPerformed(@NotNull AnActionEvent e) {
reportService.refreshNow();
Expand All @@ -99,8 +99,8 @@ 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.");
detail.setText(noLogMessage());
detail.setCaretPosition(0);
return;
}

Expand All @@ -119,6 +119,17 @@ private void reportsChanged(@Nullable Path log, @NotNull List<StReport> reports)
}
}

/** Auto-detect and a configured path come up empty for different reasons; say which one. */
private String noLogMessage() {
String configured = StacktaleSettings.getInstance(project).logPath();
if (configured.isEmpty()) {
return "No errors-ai.log found in this project yet.\n\n"
+ "Add the stacktale library and trigger an error — reports will appear here.";
}
return "No file at the log path set for this project:\n\n" + configured + "\n\n"
+ "Change it in Settings → Tools → Stacktale, or clear it to auto-detect errors-ai.log.";
}

private int indexOfId(String id) {
for (int i = 0; i < model.size(); i++) {
if (model.get(i).id().equals(id)) return i;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,11 @@
* Project-level source of Stacktale reports.
*
* Owns the single errors-ai.log poll used by both the tool window and status-bar widget.
* Which file it reads and how often come from {@link StacktaleSettings}.
*/
@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);
}
Expand Down Expand Up @@ -74,18 +73,27 @@ private void poll() {
refresh(false);

if (!disposed && !project.isDisposed()) {
alarm.addRequest(this::poll, POLL_MILLIS);
// Read the interval here rather than capturing it once: every tick arms the next
// one, so a changed setting applies on the following tick instead of only after
// the project is reopened.
alarm.addRequest(this::poll, settings().pollMillis());
}
}

private synchronized void refresh(boolean force) {
if (disposed || project.isDisposed()) return;

Path log = findLog();
StacktaleSettings settings = settings();

// An empty setting keeps the auto-detect, so detection is the fallback rather than
// something a configured path replaces; a set path is read as given, even when it is
// not there yet, so the tool window never quietly shows a different file's reports.
boolean autoDetecting = settings.logPath().isEmpty();
Path log = autoDetecting ? findLog() : regularFile(settings.configuredLog());

// 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 && autoDetecting && DumbService.getInstance(project).isDumb()) return;

if (log == null) {
boolean changed = currentLog != null
Expand Down Expand Up @@ -115,6 +123,10 @@ private synchronized void refresh(boolean force) {
notifyListeners();
}

private @NotNull StacktaleSettings settings() {
return StacktaleSettings.getInstance(project);
}

private void notifyListeners() {
Path log = currentLog;
List<StReport> reports = currentReports;
Expand All @@ -128,6 +140,10 @@ private void notifyListeners() {
});
}

private static @Nullable Path regularFile(@Nullable Path path) {
return path != null && Files.isRegularFile(path) ? path : null;
}

/** Prefer ./errors-ai.log; otherwise use an indexed file in the project. */
private @Nullable Path findLog() {
String base = project.getBasePath();
Expand Down
Loading