diff --git a/core/src/main/java/io/github/gabrielbbaldez/stacktale/idea/StSettings.java b/core/src/main/java/io/github/gabrielbbaldez/stacktale/idea/StSettings.java new file mode 100644 index 0000000..2134d74 --- /dev/null +++ b/core/src/main/java/io/github/gabrielbbaldez/stacktale/idea/StSettings.java @@ -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; + } + } +} diff --git a/core/src/test/java/io/github/gabrielbbaldez/stacktale/idea/StSettingsTest.java b/core/src/test/java/io/github/gabrielbbaldez/stacktale/idea/StSettingsTest.java new file mode 100644 index 0000000..f863296 --- /dev/null +++ b/core/src/test/java/io/github/gabrielbbaldez/stacktale/idea/StSettingsTest.java @@ -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); + } +} diff --git a/plugin/src/main/java/io/github/gabrielbbaldez/stacktale/idea/StacktaleConfigurable.java b/plugin/src/main/java/io/github/gabrielbbaldez/stacktale/idea/StacktaleConfigurable.java new file mode 100644 index 0000000..45638c3 --- /dev/null +++ b/plugin/src/main/java/io/github/gabrielbbaldez/stacktale/idea/StacktaleConfigurable.java @@ -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(); + } +} 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 a1ef2dc..ddbb695 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 @@ -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(); @@ -99,8 +99,8 @@ 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."); + detail.setText(noLogMessage()); + detail.setCaretPosition(0); return; } @@ -119,6 +119,17 @@ private void reportsChanged(@Nullable Path log, @NotNull List 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; 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 6b3b2eb..827ecdd 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 @@ -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 reports); } @@ -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 @@ -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 reports = currentReports; @@ -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(); diff --git a/plugin/src/main/java/io/github/gabrielbbaldez/stacktale/idea/StacktaleSettings.java b/plugin/src/main/java/io/github/gabrielbbaldez/stacktale/idea/StacktaleSettings.java new file mode 100644 index 0000000..08641e5 --- /dev/null +++ b/plugin/src/main/java/io/github/gabrielbbaldez/stacktale/idea/StacktaleSettings.java @@ -0,0 +1,82 @@ +package io.github.gabrielbbaldez.stacktale.idea; + +import com.intellij.openapi.components.PersistentStateComponent; +import com.intellij.openapi.components.Service; +import com.intellij.openapi.components.State; +import com.intellij.openapi.components.Storage; +import com.intellij.openapi.project.Project; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.nio.file.Path; + +/** + * Project-level settings for the Stacktale tool window: which log file to read, and how often + * to re-read it. The defaults are today's behaviour — an empty path auto-detects + * {@code errors-ai.log}, and the interval is the poll the plugin has always used — so a + * project that never opens the settings page notices nothing. + * + *

This is the persisted state; the rules it applies live in {@link StSettings}, where they + * are unit-tested. Values are read on every poll rather than captured once; see + * {@link StacktaleReportService}. + */ +@Service(Service.Level.PROJECT) +@State(name = "StacktaleSettings", storages = @Storage("stacktale.xml")) +public final class StacktaleSettings implements PersistentStateComponent { + + /** The serialized shape: public mutable fields are what the XML serializer works with. */ + public static final class State { + public String logPath = StSettings.DEFAULT_LOG_PATH; + public int pollSeconds = StSettings.DEFAULT_POLL_SECONDS; + } + + private final Project project; + private State state = new State(); + + public StacktaleSettings(@NotNull Project project) { + this.project = project; + } + + static @NotNull StacktaleSettings getInstance(@NotNull Project project) { + return project.getService(StacktaleSettings.class); + } + + @Override + public @NotNull State getState() { + return state; + } + + @Override + public void loadState(@NotNull State loaded) { + this.state = loaded; + } + + /** The configured path as typed, trimmed. Empty means auto-detect. */ + @NotNull String logPath() { + return StSettings.normalizeLogPath(state.logPath); + } + + void setLogPath(@Nullable String logPath) { + state.logPath = StSettings.normalizeLogPath(logPath); + } + + int pollSeconds() { + return StSettings.clampPollSeconds(state.pollSeconds); + } + + void setPollSeconds(int pollSeconds) { + state.pollSeconds = StSettings.clampPollSeconds(pollSeconds); + } + + int pollMillis() { + return pollSeconds() * 1000; + } + + /** + * The configured log file, or null when the path is empty (auto-detect) or unusable. + * Whether the file exists is the caller's question. + */ + @Nullable Path configuredLog() { + return StSettings.resolveLogPath(state.logPath, project.getBasePath()); + } +} diff --git a/plugin/src/main/resources/META-INF/plugin.xml b/plugin/src/main/resources/META-INF/plugin.xml index d2a4fcd..58e1863 100644 --- a/plugin/src/main/resources/META-INF/plugin.xml +++ b/plugin/src/main/resources/META-INF/plugin.xml @@ -24,5 +24,11 @@ + +