Skip to content

Commit 4e3aeda

Browse files
authored
feat: settings page for the log path and poll interval (#31)
Add a project-level PersistentStateComponent holding logPath and pollSeconds, and a Configurable for them under Settings | Tools | Stacktale. The poll reads both values when it re-arms rather than capturing them once, so a changed interval takes effect on the next tick instead of only after the project is reopened. An empty path keeps the existing errors-ai.log auto-detect, so the defaults reproduce today's behaviour exactly. The defaults, bounds, and log-path resolution live in a plain-Java StSettings in :core, where the suite covers them: a stored 0 clamping to 1s instead of reaching the poll as a busy loop, a relative path resolving against the project root, and text the filesystem cannot represent being rejected rather than thrown. Closes #5
1 parent 46036e2 commit 4e3aeda

7 files changed

Lines changed: 345 additions & 8 deletions

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package io.github.gabrielbbaldez.stacktale.idea;
2+
3+
import java.nio.file.InvalidPathException;
4+
import java.nio.file.Path;
5+
6+
/**
7+
* The settings rules shared by the plugin's persisted state and its settings page: the
8+
* defaults that reproduce the behaviour from before the settings existed, the bounds a
9+
* stored value is held to, and how a configured log path becomes a filesystem path.
10+
*
11+
* No IntelliJ API on purpose, so the rules are unit-testable in :core.
12+
*/
13+
public final class StSettings {
14+
15+
/** Empty means auto-detect — what the plugin did before the path could be set. */
16+
public static final String DEFAULT_LOG_PATH = "";
17+
18+
/** The poll the tool window has always used. */
19+
public static final int DEFAULT_POLL_SECONDS = 3;
20+
21+
public static final int MIN_POLL_SECONDS = 1;
22+
public static final int MAX_POLL_SECONDS = 3600;
23+
24+
private StSettings() {
25+
}
26+
27+
/** Blank collapses to {@link #DEFAULT_LOG_PATH}; surrounding whitespace is a typo, not a path. */
28+
public static String normalizeLogPath(String raw) {
29+
return raw == null ? DEFAULT_LOG_PATH : raw.trim();
30+
}
31+
32+
/**
33+
* Holds a stored interval inside the range the spinner offers. The settings file is
34+
* hand-editable, and a 0 that reached the poll would turn it into a busy loop.
35+
*/
36+
public static int clampPollSeconds(int raw) {
37+
return Math.min(MAX_POLL_SECONDS, Math.max(MIN_POLL_SECONDS, raw));
38+
}
39+
40+
/**
41+
* Resolves a configured log path, a relative one against the project root. Returns null
42+
* when nothing is configured (auto-detect) or when the text is not a usable path —
43+
* whether the file exists is the caller's question, not this one.
44+
*/
45+
public static Path resolveLogPath(String configured, String projectBasePath) {
46+
String path = normalizeLogPath(configured);
47+
if (path.isEmpty()) return null;
48+
49+
try {
50+
Path candidate = Path.of(path);
51+
if (candidate.isAbsolute()) return candidate.normalize();
52+
if (projectBasePath == null) return null;
53+
return Path.of(projectBasePath).resolve(candidate).normalize();
54+
} catch (InvalidPathException e) {
55+
return null;
56+
}
57+
}
58+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package io.github.gabrielbbaldez.stacktale.idea;
2+
3+
import org.junit.jupiter.api.Test;
4+
5+
import java.nio.file.Path;
6+
7+
import static org.assertj.core.api.Assertions.assertThat;
8+
9+
class StSettingsTest {
10+
11+
private static final String BASE = Path.of("home", "dev", "shop-api").toAbsolutePath().toString();
12+
13+
@Test
14+
void defaultsReproduceTheBehaviourFromBeforeTheSettingsExisted() {
15+
assertThat(StSettings.DEFAULT_LOG_PATH).isEmpty();
16+
assertThat(StSettings.DEFAULT_POLL_SECONDS).isEqualTo(3); // the 3000 ms poll
17+
}
18+
19+
@Test
20+
void blankOrMissingLogPathMeansAutoDetect() {
21+
assertThat(StSettings.normalizeLogPath(null)).isEqualTo(StSettings.DEFAULT_LOG_PATH);
22+
assertThat(StSettings.normalizeLogPath("")).isEqualTo(StSettings.DEFAULT_LOG_PATH);
23+
assertThat(StSettings.normalizeLogPath(" ")).isEqualTo(StSettings.DEFAULT_LOG_PATH);
24+
25+
assertThat(StSettings.resolveLogPath("", BASE)).isNull();
26+
assertThat(StSettings.resolveLogPath(" ", BASE)).isNull();
27+
}
28+
29+
@Test
30+
void trimsWhitespaceAroundAPastedLogPath() {
31+
assertThat(StSettings.normalizeLogPath(" build/errors-ai.log\n")).isEqualTo("build/errors-ai.log");
32+
assertThat(StSettings.resolveLogPath(" build/errors-ai.log ", BASE))
33+
.isEqualTo(Path.of(BASE, "build", "errors-ai.log"));
34+
}
35+
36+
@Test
37+
void resolvesARelativeLogPathAgainstTheProjectRoot() {
38+
assertThat(StSettings.resolveLogPath("build/errors-ai.log", BASE))
39+
.isEqualTo(Path.of(BASE, "build", "errors-ai.log"));
40+
assertThat(StSettings.resolveLogPath("./api/build/errors-ai.log", BASE))
41+
.isEqualTo(Path.of(BASE, "api", "build", "errors-ai.log"));
42+
}
43+
44+
@Test
45+
void keepsAnAbsoluteLogPathAsGiven() {
46+
Path absolute = Path.of("var", "log", "errors-ai.log").toAbsolutePath();
47+
48+
assertThat(StSettings.resolveLogPath(absolute.toString(), BASE)).isEqualTo(absolute);
49+
}
50+
51+
@Test
52+
void cannotResolveARelativePathForAProjectWithNoRoot() {
53+
assertThat(StSettings.resolveLogPath("build/errors-ai.log", null)).isNull();
54+
}
55+
56+
@Test
57+
void rejectsTextTheFilesystemCannotRepresentInsteadOfThrowing() {
58+
String nulInTheMiddle = "errors" + (char) 0 + "ai.log"; // rejected on every platform
59+
60+
assertThat(StSettings.resolveLogPath(nulInTheMiddle, BASE)).isNull();
61+
}
62+
63+
@Test
64+
void clampsAPollIntervalOutsideTheSpinnerRange() {
65+
assertThat(StSettings.clampPollSeconds(0)).isEqualTo(StSettings.MIN_POLL_SECONDS);
66+
assertThat(StSettings.clampPollSeconds(-30)).isEqualTo(StSettings.MIN_POLL_SECONDS);
67+
assertThat(StSettings.clampPollSeconds(Integer.MAX_VALUE)).isEqualTo(StSettings.MAX_POLL_SECONDS);
68+
assertThat(StSettings.clampPollSeconds(StSettings.DEFAULT_POLL_SECONDS))
69+
.isEqualTo(StSettings.DEFAULT_POLL_SECONDS);
70+
}
71+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package io.github.gabrielbbaldez.stacktale.idea;
2+
3+
import com.intellij.openapi.options.Configurable;
4+
import com.intellij.openapi.project.Project;
5+
import com.intellij.ui.JBIntSpinner;
6+
import com.intellij.ui.components.JBLabel;
7+
import com.intellij.ui.components.JBTextField;
8+
import com.intellij.util.ui.FormBuilder;
9+
import org.jetbrains.annotations.Nls;
10+
import org.jetbrains.annotations.NotNull;
11+
import org.jetbrains.annotations.Nullable;
12+
13+
import javax.swing.JComponent;
14+
import javax.swing.JPanel;
15+
16+
/** Settings → Tools → Stacktale: point the tool window at a log file and tune the poll. */
17+
public final class StacktaleConfigurable implements Configurable {
18+
19+
private final Project project;
20+
21+
private JBTextField logPathField;
22+
private JBIntSpinner pollSpinner;
23+
private JPanel panel;
24+
25+
public StacktaleConfigurable(@NotNull Project project) {
26+
this.project = project;
27+
}
28+
29+
@Override
30+
public @Nls(capitalization = Nls.Capitalization.Title) String getDisplayName() {
31+
return "Stacktale";
32+
}
33+
34+
@Override
35+
public @Nullable JComponent createComponent() {
36+
logPathField = new JBTextField();
37+
pollSpinner = new JBIntSpinner(
38+
StSettings.DEFAULT_POLL_SECONDS,
39+
StSettings.MIN_POLL_SECONDS,
40+
StSettings.MAX_POLL_SECONDS);
41+
42+
panel = FormBuilder.createFormBuilder()
43+
.addLabeledComponent(new JBLabel("Log file:"), logPathField, 1, false)
44+
.addTooltip("Absolute, or relative to the project root. "
45+
+ "Leave empty to auto-detect errors-ai.log.")
46+
.addLabeledComponent(new JBLabel("Poll interval (seconds):"), pollSpinner, 1, false)
47+
.addComponentFillVertically(new JPanel(), 0)
48+
.getPanel();
49+
50+
reset();
51+
return panel;
52+
}
53+
54+
@Override
55+
public boolean isModified() {
56+
StacktaleSettings settings = StacktaleSettings.getInstance(project);
57+
return !settings.logPath().equals(logPath()) || settings.pollSeconds() != pollSeconds();
58+
}
59+
60+
@Override
61+
public void apply() {
62+
StacktaleSettings settings = StacktaleSettings.getInstance(project);
63+
settings.setLogPath(logPath());
64+
settings.setPollSeconds(pollSeconds());
65+
66+
// The next tick would pick both up on its own; re-reading now means a changed path
67+
// shows its reports as soon as the dialog closes.
68+
StacktaleReportService.getInstance(project).refreshNow();
69+
}
70+
71+
@Override
72+
public void reset() {
73+
StacktaleSettings settings = StacktaleSettings.getInstance(project);
74+
logPathField.setText(settings.logPath());
75+
pollSpinner.setNumber(settings.pollSeconds());
76+
}
77+
78+
@Override
79+
public void disposeUIResources() {
80+
panel = null;
81+
logPathField = null;
82+
pollSpinner = null;
83+
}
84+
85+
private String logPath() {
86+
return StSettings.normalizeLogPath(logPathField.getText());
87+
}
88+
89+
/** The spinner already holds the value in range, so the settings class has nothing to clamp. */
90+
private int pollSeconds() {
91+
return pollSpinner.getNumber();
92+
}
93+
}

‎plugin/src/main/java/io/github/gabrielbbaldez/stacktale/idea/StacktalePanel.java‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ public void mouseClicked(MouseEvent e) {
7272

7373
private ActionToolbar buildToolbar() {
7474
DefaultActionGroup group = new DefaultActionGroup();
75-
group.add(new AnAction("Refresh", "Re-read errors-ai.log", AllIcons.Actions.Refresh) {
75+
group.add(new AnAction("Refresh", "Re-read the report log", AllIcons.Actions.Refresh) {
7676
@Override
7777
public void actionPerformed(@NotNull AnActionEvent e) {
7878
reportService.refreshNow();
@@ -99,8 +99,8 @@ private void reportsChanged(@Nullable Path log, @NotNull List<StReport> reports)
9999
if (log == null) {
100100
toolWindow.setTitle("Stacktale");
101101
model.clear();
102-
detail.setText("No errors-ai.log found in this project yet.\n\n"
103-
+ "Add the stacktale library and trigger an error — reports will appear here.");
102+
detail.setText(noLogMessage());
103+
detail.setCaretPosition(0);
104104
return;
105105
}
106106

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

122+
/** Auto-detect and a configured path come up empty for different reasons; say which one. */
123+
private String noLogMessage() {
124+
String configured = StacktaleSettings.getInstance(project).logPath();
125+
if (configured.isEmpty()) {
126+
return "No errors-ai.log found in this project yet.\n\n"
127+
+ "Add the stacktale library and trigger an error — reports will appear here.";
128+
}
129+
return "No file at the log path set for this project:\n\n" + configured + "\n\n"
130+
+ "Change it in Settings → Tools → Stacktale, or clear it to auto-detect errors-ai.log.";
131+
}
132+
122133
private int indexOfId(String id) {
123134
for (int i = 0; i < model.size(); i++) {
124135
if (model.get(i).id().equals(id)) return i;

‎plugin/src/main/java/io/github/gabrielbbaldez/stacktale/idea/StacktaleReportService.java‎

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,11 @@
2525
* Project-level source of Stacktale reports.
2626
*
2727
* Owns the single errors-ai.log poll used by both the tool window and status-bar widget.
28+
* Which file it reads and how often come from {@link StacktaleSettings}.
2829
*/
2930
@Service(Service.Level.PROJECT)
3031
public final class StacktaleReportService implements Disposable {
3132

32-
private static final int POLL_MILLIS = 3000;
33-
3433
interface Listener {
3534
void reportsChanged(@Nullable Path log, @NotNull List<StReport> reports);
3635
}
@@ -74,18 +73,27 @@ private void poll() {
7473
refresh(false);
7574

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

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

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

8694
// A nested log cannot be resolved while project indexes are unavailable.
8795
// Preserve the current state and let the next poll retry after indexing.
88-
if (log == null && DumbService.getInstance(project).isDumb()) return;
96+
if (log == null && autoDetecting && DumbService.getInstance(project).isDumb()) return;
8997

9098
if (log == null) {
9199
boolean changed = currentLog != null
@@ -115,6 +123,10 @@ private synchronized void refresh(boolean force) {
115123
notifyListeners();
116124
}
117125

126+
private @NotNull StacktaleSettings settings() {
127+
return StacktaleSettings.getInstance(project);
128+
}
129+
118130
private void notifyListeners() {
119131
Path log = currentLog;
120132
List<StReport> reports = currentReports;
@@ -128,6 +140,10 @@ private void notifyListeners() {
128140
});
129141
}
130142

143+
private static @Nullable Path regularFile(@Nullable Path path) {
144+
return path != null && Files.isRegularFile(path) ? path : null;
145+
}
146+
131147
/** Prefer ./errors-ai.log; otherwise use an indexed file in the project. */
132148
private @Nullable Path findLog() {
133149
String base = project.getBasePath();

0 commit comments

Comments
 (0)