Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
091ec5c
Spec the settings UI reachable from a title-bar gear
jbachorik Jul 25, 2026
2b25a73
Revise the settings spec after adversarial review
jbachorik Jul 25, 2026
208a4c0
Plan the settings UI implementation
jbachorik Jul 25, 2026
1d4021f
Drop the delegating applyTerminalFontSize from the plan
jbachorik Jul 25, 2026
4b210f4
Add interface and terminal font size to the workspace UI state
jbachorik Jul 25, 2026
679c61b
Persist the font sizes in the workspace UI state
jbachorik Jul 25, 2026
4c8b281
Route font-size writes through the single state writer
jbachorik Jul 25, 2026
9b0dcc9
Carry the terminal font size in the generated ghostty config
jbachorik Jul 25, 2026
d1ae7c1
Generate a font-scaled copy of app.css at runtime
jbachorik Jul 25, 2026
8d1ae92
Fix UiFontScale regex to keep declarations with mid-declaration comments
jbachorik Jul 25, 2026
93ddf09
Apply the interface font size through the theme manager
jbachorik Jul 25, 2026
1755fe2
Let the user config be written, atomically and off the FX thread
jbachorik Jul 25, 2026
88976ed
Serialize UserConfig saves through a single-threaded executor
jbachorik Jul 25, 2026
c7addb9
Add the settings modal
jbachorik Jul 25, 2026
115b073
Open settings from the title-bar gear and command-comma
jbachorik Jul 25, 2026
ec6b2cd
Add settings checks to the manual checklist
jbachorik Jul 25, 2026
b7b1530
Fix final review findings: FX-thread I/O, theme-radio drift, modal st…
jbachorik Jul 25, 2026
a2ad378
Fix terminal-config staleness and ThemeManager's ungenerated-size window
jbachorik Jul 26, 2026
c74b175
Persist the interface size the user chose, not a read-back of applied…
jbachorik Jul 26, 2026
d6743e9
Documentation and naming cleanup: clarify Javadoc, soften overclaims,…
jbachorik Jul 26, 2026
de27b01
Fix five pre-merge review findings in the Settings UI
jbachorik Jul 26, 2026
e8e5137
Fix worktrees directory save retry and Path.of() hardening in Setting…
jbachorik Jul 26, 2026
3a9a8ff
Style the settings directory row, and add a Settings diag driver
jbachorik Jul 26, 2026
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
241 changes: 240 additions & 1 deletion app/src/main/java/app/drydock/DrydockApplication.java

Large diffs are not rendered by default.

14 changes: 14 additions & 0 deletions app/src/main/java/app/drydock/app/RepositoryManager.java
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,20 @@ public void updateTheme(UiTheme theme) {
stateStore.update(state -> state.withUi(state.ui().withTheme(theme)));
}

/**
* Persists the interface font size immediately, like the theme: a
* discrete user action in the settings modal, cheap to save, and losing
* it to a crash would be a visible regression on the next launch.
*/
public void updateUiFontSize(double uiFontSize) {
stateStore.update(state -> state.withUi(state.ui().withUiFontSize(uiFontSize)));
}

/** Persists the terminal font size immediately, for the same reason as {@link #updateUiFontSize}. */
public void updateTerminalFontSize(double terminalFontSize) {
stateStore.update(state -> state.withUi(state.ui().withTerminalFontSize(terminalFontSize)));
}

private static String defaultDisplayName(Path root) {
Path fileName = root.getFileName();
return fileName == null ? root.toString() : fileName.toString();
Expand Down
200 changes: 199 additions & 1 deletion app/src/main/java/app/drydock/config/UserConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import app.drydock.state.json.JsonValue;
import app.drydock.state.json.JsonValue.JsonObject;
import app.drydock.state.json.JsonValue.JsonString;
import app.drydock.state.json.JsonWriter;

import java.io.IOException;
import java.lang.System.Logger;
Expand All @@ -13,8 +14,15 @@
import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicReference;

/**
* User-editable settings read from {@code ~/.drydock/config.json}, separate
Expand Down Expand Up @@ -51,10 +59,31 @@ public static UserConfig load() {
* "file loading" must never block the JavaFX application thread) --
* {@link #load()} does a synchronous stat + read, so callers on the FX
* thread (e.g. the create-worktree modal) must use this instead.
*
* <p>Routed through {@link #SAVE_EXECUTOR} rather than an independent
* virtual thread so a load is FIFO-ordered against every {@link
* #saveAsync} queued before it: an unordered read could otherwise land
* before a just-committed save's write and hand back the stale value
* (e.g. close-then-reopen the settings modal right after editing the
* worktrees directory).</p>
*/
public static CompletableFuture<UserConfig> loadAsync() {
CompletableFuture<UserConfig> future = new CompletableFuture<>();
Thread.ofVirtual().start(() -> future.complete(load()));
SAVE_EXECUTOR.execute(() -> {
try {
future.complete(load());
} catch (Throwable t) {
// load() already catches everything it knows how to handle
// and falls back to empty(); anything reaching here is
// unexpected. Completing exceptionally (instead of letting
// it silently kill the thread) matters because the caller
// -- the settings modal -- disables its controls until this
// future completes one way or the other; an uncompleted
// future would leave them stuck at "Loading…" forever.
future.completeExceptionally(t);
throw t;
}
});
return future;
}

Expand All @@ -79,4 +108,173 @@ static UserConfig load(Path configFile) {
return empty();
}
}

/**
* Writes {@code config} to {@code configFile}: temp file in the same
* directory, then an atomic move, so a crash mid-write can never leave a
* truncated config where a valid one was.
*
* <p>Members this build does not know about are read back from the
* existing file and preserved -- the file is hand-editable, so silently
* dropping a key a newer build (or the user) put there would be data
* loss.</p>
*
* <p>Unlike {@link #load()}, a failure here throws: a save is a
* user-visible action, and one that silently did nothing is worse than
* one that reports why.</p>
*
* <p>{@code configFile} is resolved to an absolute path first so it
* always has a parent directory to create the temp file in -- a bare
* relative filename with no parent segment would otherwise force the
* temp file into the JVM's default temp directory, which can be a
* different filesystem and break {@link StandardCopyOption#ATOMIC_MOVE}.
* A path that resolves to a filesystem root (no parent even after that)
* is rejected outright rather than silently falling back.</p>
*/
static void save(UserConfig config, Path configFile) throws IOException {
Path resolvedConfigFile = configFile.toAbsolutePath().normalize();
Path parent = resolvedConfigFile.getParent();
if (parent == null) {
throw new IOException("Cannot save config to a path with no parent directory: " + resolvedConfigFile);
}

JsonObject root = readExistingRootOrEmpty(resolvedConfigFile);
root.members().remove("worktreesDirectory");
config.worktreesDirectory().ifPresent(dir -> root.put("worktreesDirectory", new JsonString(dir.toString())));

Files.createDirectories(parent);
Path temp = Files.createTempFile(parent, "config", ".json.tmp");
try {
Files.writeString(temp, JsonWriter.write(root), StandardCharsets.UTF_8);
Files.move(temp, resolvedConfigFile, StandardCopyOption.REPLACE_EXISTING,
StandardCopyOption.ATOMIC_MOVE);
} finally {
Files.deleteIfExists(temp);
}
}

/** Best-effort read of {@code configFile}'s existing top-level object, for {@link #save} to preserve unknown members. */
private static JsonObject readExistingRootOrEmpty(Path configFile) {
if (Files.exists(configFile)) {
try {
if (JsonParser.parse(Files.readString(configFile, StandardCharsets.UTF_8))
instanceof JsonObject existing) {
return existing;
}
} catch (IOException | JsonParseException e) {
LOG.log(Level.WARNING, "Existing config " + configFile
+ " is unreadable or malformed; replacing it", e);
}
}
return JsonObject.empty();
}

/**
* Runs saves for {@link #saveAsync} one at a time. A plain "one virtual
* thread per call" approach (as an earlier version of this class did)
* lets concurrent saves race: the settings modal commits on both
* field-blur and Browse, which can fire back-to-back, and with
* unordered threads the stale call's {@code Files.move} can land after
* the fresh one's, silently reverting the file. A single-thread executor
* makes writes happen in submission order, same as {@code
* AnnotationStore.saveExecutor}.
*/
private static final ExecutorService SAVE_EXECUTOR =
Executors.newSingleThreadExecutor(runnable -> Thread.ofVirtual().unstarted(runnable));

/**
* Newest-wins pending save, mirroring {@code AnnotationStore.pendingSnapshot}:
* {@link #saveAsync} always writes the *whole* config, so a call that
* arrives while an earlier one is still queued (not yet picked up by
* {@link #SAVE_EXECUTOR}'s single thread) can simply replace it instead
* of enqueuing a second, soon-to-be-overwritten write. Only one task is
* ever queued at a time; {@link #runPendingSave} is what drains it.
*/
private static final AtomicReference<PendingSave> PENDING_SAVE = new AtomicReference<>();

private record PendingSave(UserConfig config, CompletableFuture<Void> future) {
}

/**
* As {@link #save}, off the caller's thread -- the settings modal calls
* this from the FX thread, where a synchronous write is forbidden. The
* returned future completes exceptionally on failure so the caller can
* surface it; it is never swallowed.
*
* <p>Concurrent calls coalesce onto {@link #PENDING_SAVE} rather than
* racing independent threads (see {@link #SAVE_EXECUTOR}'s Javadoc). A
* call that gets superseded before it is written still has its returned
* future completed -- with the outcome of the save that superseded it --
* so a caller awaiting it never hangs.</p>
*
* <p><b>Tests:</b> {@link #PENDING_SAVE} and {@link #SAVE_EXECUTOR} are
* static, i.e. shared by the whole test JVM. A test that calls this must
* call {@link #flushPendingSaves()} (in a {@code finally}, before its
* {@code @TempDir} is removed) so a write cannot leak into, or race, an
* unrelated test.</p>
*/
public static CompletableFuture<Void> saveAsync(UserConfig config) {
CompletableFuture<Void> future = new CompletableFuture<>();
PendingSave superseded = PENDING_SAVE.getAndSet(new PendingSave(config, future));
if (superseded == null) {
SAVE_EXECUTOR.execute(UserConfig::runPendingSave);
} else {
future.whenComplete((ignoredValue, error) -> {
if (error != null) {
superseded.future().completeExceptionally(error);
} else {
superseded.future().complete(null);
}
});
}
return future;
}

private static void runPendingSave() {
PendingSave pending = PENDING_SAVE.getAndSet(null);
if (pending == null) {
// Nothing left to do: a coalesced call already claimed this slot.
return;
}
try {
save(pending.config(), defaultConfigFile());
pending.future().complete(null);
} catch (IOException | RuntimeException e) {
pending.future().completeExceptionally(e);
} catch (Throwable t) {
// Anything beyond IOException/RuntimeException is unexpected,
// but this runs on SAVE_EXECUTOR's single background thread: an
// uncaught throwable here would kill that thread silently and
// every future saveAsync call would queue forever with its
// future never completing (SAVE_EXECUTOR.execute would still
// "succeed" -- ThreadPoolExecutor just replaces the dead worker
// -- but PENDING_SAVE's coalescing means a caller waiting on
// this specific future would hang). Complete it exceptionally
// first so no caller of saveAsync/flushPendingSaves is left
// waiting forever, then rethrow so the failure is still visible.
pending.future().completeExceptionally(t);
throw t;
}
}

/**
* Awaits every {@link #saveAsync} queued so far (AGENTS.md: a service
* writing files from a background thread exposes a flush, so shutdown
* and tests do not race a pending write). Submitting a no-op to {@link
* #SAVE_EXECUTOR} and awaiting it works because the executor is single
* threaded and FIFO: the no-op cannot run until every save task queued
* ahead of it has finished, so awaiting the no-op transitively awaits
* all of them -- same trick as {@code AnnotationStore.flushPendingSaves}.
* Bounded, because a wedged disk must not hang shutdown -- the atomic
* move means the worst case is a stale file, never a corrupt one.
*/
public static void flushPendingSaves() {
try {
SAVE_EXECUTOR.submit(() -> { }).get(10, TimeUnit.SECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} catch (ExecutionException | TimeoutException e) {
// saveAsync's own future already reported the failure to its caller.
}
}
}
41 changes: 34 additions & 7 deletions app/src/main/java/app/drydock/domain/WorkspaceUiState.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
/**
* The subset of workspace UI state relevant to Milestone 4 (plan section
* 10.3): which repository is selected, the sidebar width, and which
* repository nodes are expanded.
* repository nodes are expanded. Also includes cosmetic preferences for
* interface and terminal font sizes.
*
* <p>Plan section 10.3 also lists workspace-state fields that belong to
* later milestones (selected session, open tabs, right-pane width, last
Expand All @@ -21,34 +22,60 @@ public record WorkspaceUiState(
Optional<RepositoryId> selectedRepositoryId,
double sidebarWidth,
Set<RepositoryId> expandedRepositoryIds,
UiTheme theme
UiTheme theme,
double uiFontSize,
double terminalFontSize
) {
/** Design default 288px (handoff README section 2), clamped 220-520 at the SplitPane. */
public static final double DEFAULT_SIDEBAR_WIDTH = 288.0;

/**
* The app.css {@code .root} base size, and therefore the divisor every
* interface-scale factor is computed against (see {@code UiFontScale}).
*/
public static final double DEFAULT_UI_FONT_SIZE = 13.0;

/** Matches the interface default so terminals start visually consistent with the UI. */
public static final double DEFAULT_TERMINAL_FONT_SIZE = 13.0;

public WorkspaceUiState {
Objects.requireNonNull(selectedRepositoryId, "selectedRepositoryId");
expandedRepositoryIds = Set.copyOf(Objects.requireNonNull(expandedRepositoryIds, "expandedRepositoryIds"));
Objects.requireNonNull(theme, "theme");
}

public static WorkspaceUiState empty() {
return new WorkspaceUiState(Optional.empty(), DEFAULT_SIDEBAR_WIDTH, Set.of(), UiTheme.DARK);
return new WorkspaceUiState(Optional.empty(), DEFAULT_SIDEBAR_WIDTH, Set.of(), UiTheme.DARK,
DEFAULT_UI_FONT_SIZE, DEFAULT_TERMINAL_FONT_SIZE);
}

public WorkspaceUiState withSelectedRepositoryId(Optional<RepositoryId> newSelectedRepositoryId) {
return new WorkspaceUiState(newSelectedRepositoryId, sidebarWidth, expandedRepositoryIds, theme);
return new WorkspaceUiState(newSelectedRepositoryId, sidebarWidth, expandedRepositoryIds, theme,
uiFontSize, terminalFontSize);
}

public WorkspaceUiState withSidebarWidth(double newSidebarWidth) {
return new WorkspaceUiState(selectedRepositoryId, newSidebarWidth, expandedRepositoryIds, theme);
return new WorkspaceUiState(selectedRepositoryId, newSidebarWidth, expandedRepositoryIds, theme,
uiFontSize, terminalFontSize);
}

public WorkspaceUiState withExpandedRepositoryIds(Set<RepositoryId> newExpandedRepositoryIds) {
return new WorkspaceUiState(selectedRepositoryId, sidebarWidth, newExpandedRepositoryIds, theme);
return new WorkspaceUiState(selectedRepositoryId, sidebarWidth, newExpandedRepositoryIds, theme,
uiFontSize, terminalFontSize);
}

public WorkspaceUiState withTheme(UiTheme newTheme) {
return new WorkspaceUiState(selectedRepositoryId, sidebarWidth, expandedRepositoryIds, newTheme);
return new WorkspaceUiState(selectedRepositoryId, sidebarWidth, expandedRepositoryIds, newTheme,
uiFontSize, terminalFontSize);
}

public WorkspaceUiState withUiFontSize(double newUiFontSize) {
return new WorkspaceUiState(selectedRepositoryId, sidebarWidth, expandedRepositoryIds, theme,
newUiFontSize, terminalFontSize);
}

public WorkspaceUiState withTerminalFontSize(double newTerminalFontSize) {
return new WorkspaceUiState(selectedRepositoryId, sidebarWidth, expandedRepositoryIds, theme,
uiFontSize, newTerminalFontSize);
}
}
20 changes: 18 additions & 2 deletions app/src/main/java/app/drydock/state/ApplicationStateCodec.java
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,10 @@
* "ui": {
* "selectedRepositoryId": "<uuid>" | null,
* "sidebarWidth": 260.0,
* "expandedRepositoryIds": ["<uuid>", ...]
* "expandedRepositoryIds": ["<uuid>", ...],
* "theme": "DARK" | "LIGHT",
* "uiFontSize": 13.0,
* "terminalFontSize": 13.0
* }
* }
* }</pre>
Expand Down Expand Up @@ -204,6 +207,8 @@ private static JsonValue uiToJson(WorkspaceUiState ui) {
}
obj.put("expandedRepositoryIds", new JsonArray(expanded));
obj.put("theme", new JsonString(ui.theme().name()));
obj.put("uiFontSize", JsonNumber.of(ui.uiFontSize()));
obj.put("terminalFontSize", JsonNumber.of(ui.terminalFontSize()));
return obj;
}

Expand Down Expand Up @@ -375,7 +380,18 @@ private static WorkspaceUiState uiFromJson(JsonObject obj) {
? UiTheme.fromPersisted(s.value())
: UiTheme.DARK;

return new WorkspaceUiState(selected, sidebarWidth, expanded, theme);
// Cosmetic, so lenient like sidebarWidth: absent or non-numeric
// falls back to the design default. Deliberately NOT clamped here
// -- ThemeManager and TerminalThemes own their ranges at the point
// of application, so a hand-edited value is never silently rewritten.
double uiFontSize = obj.get("uiFontSize") instanceof JsonNumber n
? n.asDouble()
: WorkspaceUiState.DEFAULT_UI_FONT_SIZE;
double terminalFontSize = obj.get("terminalFontSize") instanceof JsonNumber n
? n.asDouble()
: WorkspaceUiState.DEFAULT_TERMINAL_FONT_SIZE;

return new WorkspaceUiState(selected, sidebarWidth, expanded, theme, uiFontSize, terminalFontSize);
}

private static int readSchemaVersion(JsonObject root) {
Expand Down
Loading
Loading