Compare commits

..

2 Commits

Author SHA1 Message Date
van Elst, Marcus 3b3e997d13 Merge fix #79: GuiPromptEditorTab Pfad-Übergabe 2026-05-04 11:46:59 +02:00
van Elst, Marcus ddfbf9b8cb #79: GuiPromptEditorTab erhaelt Konfigurationsaenderungen via notifyConfigurationChanged
Einfuehren von GuiPromptEditorPortFactory als funktionalem Interface,
damit GuiConfigurationEditorWorkspace bei jedem Laden oder Speichern
einer Konfiguration einen passenden Port fuer den Prompt-Tab erzeugen
kann. GuiPromptEditorTab.notifyConfigurationChanged() aktualisiert Port,
Pfad und maxTitleLength und setzt Dirty-State sowie Tab-Titel zurueck.
BootstrapRunner uebergibt die Factory an GuiStartupContext. Damit werden
alle vier Symptome aus #79 behoben: leerer Tab, gesperrte Textarea,
fehlgeschlagenes Speichern und fehlender Dirty-State-Indikator.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 11:38:42 +02:00
6 changed files with 181 additions and 17 deletions
@@ -415,11 +415,12 @@ public final class GuiConfigurationEditorWorkspace {
private final GuiHistoricalDocumentContextPort historicalDocumentContextPort;
/**
* Bridge-Port zum Prompt-Editor-Use-Case. Wird vom {@link GuiPromptEditorTab} genutzt,
* um den Prompt-Inhalt zu laden, zu speichern und eine Standard-Prompt-Datei anzulegen.
* Fabrik, die für einen gegebenen Prompt-Dateipfad einen {@link GuiPromptEditorPort}
* erzeugt. Wird verwendet, wenn eine neue Konfiguration geladen oder gespeichert wird,
* um den {@link GuiPromptEditorTab} mit einem aktualisierten Port zu versorgen.
* Supplied by Bootstrap via the startup context.
*/
private final GuiPromptEditorPort promptEditorPort;
private final GuiPromptEditorPortFactory promptEditorPortFactory;
/**
* Second main tab of the window that drives the live processing-run view. Created
@@ -510,7 +511,7 @@ public final class GuiConfigurationEditorWorkspace {
this.manualFileRenamePort = effectiveContext.manualFileRenamePort();
this.manualFileCopyPort = effectiveContext.manualFileCopyPort();
this.historicalDocumentContextPort = effectiveContext.historicalDocumentContextPort();
this.promptEditorPort = effectiveContext.promptEditorPort();
this.promptEditorPortFactory = effectiveContext.promptEditorPortFactory();
this.batchRunTab = new GuiBatchRunTab(
() -> this.batchRunLauncher,
() -> this.miniRunLauncher,
@@ -541,7 +542,7 @@ public final class GuiConfigurationEditorWorkspace {
maxTitleLength = 60;
}
this.promptEditorTab = new GuiPromptEditorTab(
this.promptEditorPort, configuredPromptPath, maxTitleLength);
effectiveContext.promptEditorPort(), configuredPromptPath, maxTitleLength);
configureRoot();
configureHeader(effectiveContext.startupNotice());
@@ -1145,6 +1146,8 @@ public final class GuiConfigurationEditorWorkspace {
refreshHeader();
// Statuszeile nach erfolgreichem Speichern aktualisieren (Konfigurationspfad kann neu sein)
statusBarStateListener.accept(this.editorState);
// Prompt-Tab über neuen Prompt-Pfad informieren (kann sich durch Speichern geändert haben)
notifyPromptTabConfigChanged(this.editorState);
if (result.hasApiKeyPreservationNote()) {
LOG.info("GUI-Editor: API-Key fuer Provider '{}' wurde beibehalten (Feld war leer, "
@@ -1242,6 +1245,61 @@ public final class GuiConfigurationEditorWorkspace {
runEditorValidation();
// Statuszeile über den neuen Zustand informieren
statusBarStateListener.accept(newState);
// Prompt-Tab mit neuem Pfad und Port versorgen
notifyPromptTabConfigChanged(newState);
}
/**
* Benachrichtigt den Prompt-Editor-Tab über eine geänderte Konfiguration.
* <p>
* Liest {@code prompt.template.file} und {@code max.title.length} aus dem neuen
* Zustand, erzeugt über die Factory einen passenden Port und übergibt beides an den
* Tab. Ist der Prompt-Pfad leer, wird ein No-Op-Port verwendet.
* <p>
* Muss auf dem JavaFX Application Thread aufgerufen werden.
*
* @param state der neue Editor-Zustand; darf nicht {@code null} sein
*/
private void notifyPromptTabConfigChanged(de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState state) {
String promptPath = state.values().promptTemplateFile();
int maxTitle;
try {
maxTitle = Integer.parseInt(state.values().maxTitleLength().trim());
} catch (NumberFormatException e) {
maxTitle = 60;
}
GuiPromptEditorPort newPort = (promptPath != null && !promptPath.isBlank())
? promptEditorPortFactory.create(promptPath)
: noOpPromptEditorPort();
promptEditorTab.notifyConfigurationChanged(
newPort,
promptPath != null ? promptPath : "",
maxTitle);
}
private static GuiPromptEditorPort noOpPromptEditorPort() {
return new GuiPromptEditorPort() {
@Override
public de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingResult loadCurrentPrompt() {
return new de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingFailure(
"NO_PATH", "Kein Prompt-Pfad konfiguriert.");
}
@Override
public de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult save(String content) {
return new de.gecheckt.pdf.umbenenner.application.port.out.PromptSaveResult.WriteFailed(
"Kein Prompt-Pfad konfiguriert.", null);
}
@Override
public de.gecheckt.pdf.umbenenner.application.validation.technicaltest.CorrectionOutcome
createDefaultPromptIfMissing(
de.gecheckt.pdf.umbenenner.application.validation.technicaltest
.CorrectionSuggestion.CreatePromptFile suggestion) {
return new de.gecheckt.pdf.umbenenner.application.validation.technicaltest
.CorrectionOutcome.NotAttempted(suggestion, "Kein Prompt-Pfad konfiguriert.");
}
};
}
private void configureRoot() {
@@ -0,0 +1,22 @@
package de.gecheckt.pdf.umbenenner.adapter.in.gui;
/**
* Fabrik, die für einen gegebenen Prompt-Dateipfad einen {@link GuiPromptEditorPort} erzeugt.
* <p>
* Wird vom {@link GuiConfigurationEditorWorkspace} genutzt, um nach einem Konfigurations-Laden
* oder -Speichern einen neuen Port für den {@link GuiPromptEditorTab} zu erstellen, ohne dass
* der GUI-Adapter direkt von Bootstrap-internen Klassen abhängen muss.
* <p>
* Alle Implementierungen liegen in {@code pdf-umbenenner-bootstrap}.
*/
@FunctionalInterface
public interface GuiPromptEditorPortFactory {
/**
* Erzeugt einen {@link GuiPromptEditorPort} für den angegebenen Prompt-Dateipfad.
*
* @param promptFilePath konfigurierter Pfad zur Prompt-Datei; darf nicht {@code null} sein
* @return vollständig verdrahteter Port; nie {@code null}
*/
GuiPromptEditorPort create(String promptFilePath);
}
@@ -58,11 +58,11 @@ public class GuiPromptEditorTab {
private static final String TAB_TITLE = "Prompt";
private static final String TAB_TITLE_DIRTY = "Prompt *";
private final GuiPromptEditorPort promptEditorPort;
private GuiPromptEditorPort promptEditorPort;
/** Konfigurierter Prompt-Dateipfad wird für CreatePromptFile-Vorschläge benötigt. */
private final String configuredPromptPath;
private String configuredPromptPath;
/** Konfigurierte maximale Titellänge für den Default-Prompt-Inhalt. */
private final int maxTitleLength;
private int maxTitleLength;
// Thread-Strategie (injizierbar für Tests ohne JavaFX-Runtime)
/** Erzeugt Worker-Threads für blockierende Operationen. */
@@ -125,6 +125,35 @@ public class GuiPromptEditorTab {
return dirty;
}
/**
* Aktualisiert den Tab auf eine neue Konfiguration.
* <p>
* Setzt Port, Prompt-Dateipfad und maximale Titellänge auf die neuen Werte.
* Der bisherige Lade-Baseline wird verworfen und der Dirty-State zurückgesetzt.
* Ist der Tab zum Zeitpunkt des Aufrufs sichtbar, wird ein erneutes Laden sofort
* ausgelöst; andernfalls erfolgt das Laden beim nächsten Öffnen des Tabs.
* <p>
* Muss auf dem JavaFX Application Thread aufgerufen werden.
*
* @param newPort neuer Port für Prompt-Operationen; darf nicht {@code null} sein
* @param newPromptPath neuer konfigurierter Prompt-Dateipfad; darf nicht {@code null} sein
* @param newMaxTitleLength neue konfigurierte maximale Titellänge
*/
public void notifyConfigurationChanged(GuiPromptEditorPort newPort,
String newPromptPath,
int newMaxTitleLength) {
this.promptEditorPort = Objects.requireNonNull(newPort, "newPort must not be null");
this.configuredPromptPath = Objects.requireNonNull(newPromptPath, "newPromptPath must not be null");
this.maxTitleLength = newMaxTitleLength;
this.loadedContent = null;
this.dirty = false;
this.tab.setText(TAB_TITLE);
this.saveButton.setDisable(true);
if (tab.isSelected()) {
loadPromptAsync();
}
}
/**
* Zeigt einen Bestätigungsdialog, wenn ungespeicherte Änderungen vorhanden sind.
* Gibt {@code true} zurück, wenn die Änderungen verworfen werden dürfen.
@@ -76,7 +76,8 @@ public record GuiStartupContext(
GuiHistoryOverviewPort historyOverviewPort,
GuiHistoryDetailsPort historyDetailsPort,
GuiHistoryResetDocumentStatusPort historyResetDocumentStatusPort,
GuiDeleteDocumentHistoryPort deleteDocumentHistoryPort) {
GuiDeleteDocumentHistoryPort deleteDocumentHistoryPort,
GuiPromptEditorPortFactory promptEditorPortFactory) {
/**
* Creates a fully wired startup context.
@@ -107,6 +108,8 @@ public record GuiStartupContext(
* bar; {@code null} defaults to {@code "dev"}
* @param promptEditorPort bridge zum Prompt-Editor-Use-Case; darf nicht
* {@code null} sein
* @param promptEditorPortFactory Fabrik für Prompt-Editor-Ports bei Konfigurationswechsel;
* darf nicht {@code null} sein
*/
public GuiStartupContext {
initialState = Objects.requireNonNull(initialState, "initialState must not be null");
@@ -150,6 +153,8 @@ public record GuiStartupContext(
"historyResetDocumentStatusPort must not be null");
deleteDocumentHistoryPort = Objects.requireNonNull(deleteDocumentHistoryPort,
"deleteDocumentHistoryPort must not be null");
promptEditorPortFactory = Objects.requireNonNull(promptEditorPortFactory,
"promptEditorPortFactory must not be null");
}
/**
@@ -193,7 +198,7 @@ public record GuiStartupContext(
rejectingManualFileCopyPort(),
noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort(),
noOpHistoryOverviewPort(), noOpHistoryDetailsPort(),
noOpHistoryResetPort(), noOpDeleteHistoryPort());
noOpHistoryResetPort(), noOpDeleteHistoryPort(), noOpPromptEditorPortFactory());
}
/**
@@ -231,7 +236,7 @@ public record GuiStartupContext(
rejectingManualFileCopyPort(),
noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort(),
noOpHistoryOverviewPort(), noOpHistoryDetailsPort(),
noOpHistoryResetPort(), noOpDeleteHistoryPort());
noOpHistoryResetPort(), noOpDeleteHistoryPort(), noOpPromptEditorPortFactory());
}
/**
@@ -269,7 +274,7 @@ public record GuiStartupContext(
rejectingManualFileRenamePort(), rejectingManualFileCopyPort(),
noOpHistoricalDocumentContextPort(), "dev", noOpPromptEditorPort(),
noOpHistoryOverviewPort(), noOpHistoryDetailsPort(),
noOpHistoryResetPort(), noOpDeleteHistoryPort());
noOpHistoryResetPort(), noOpDeleteHistoryPort(), noOpPromptEditorPortFactory());
}
private static GuiBatchRunLauncher rejectingBatchRunLauncher() {
@@ -389,7 +394,12 @@ public record GuiStartupContext(
noOpHistoryOverviewPort(),
noOpHistoryDetailsPort(),
noOpHistoryResetPort(),
noOpDeleteHistoryPort());
noOpDeleteHistoryPort(),
noOpPromptEditorPortFactory());
}
private static GuiPromptEditorPortFactory noOpPromptEditorPortFactory() {
return path -> noOpPromptEditorPort();
}
private static GuiPromptEditorPort noOpPromptEditorPort() {
@@ -252,6 +252,47 @@ class GuiPromptEditorTabSmokeTest {
assertFalse(dirtyRef.get(), "Dirty-State muss false sein wenn Datei nicht gefunden wurde");
}
@Test
void notifyConfigurationChanged_shouldResetDirtyStateAndTitle() throws Exception {
CountDownLatch latch = new CountDownLatch(1);
AtomicReference<Throwable> fxError = new AtomicReference<>();
AtomicBoolean dirtyRef = new AtomicBoolean(true);
AtomicReference<String> titleRef = new AtomicReference<>();
Platform.runLater(() -> {
try {
SyncPromptEditorPort port = new SyncPromptEditorPort();
GuiPromptEditorTab editorTab = buildSyncTab(port);
// Laden und anschliessend Inhalt aendern, um Dirty-State zu erzeugen
editorTab.loadPromptAsync();
editorTab.resetToDefault();
// Vorbedingung: Dirty-State muss aktiv sein
assertTrue(editorTab.hasDirtyContent(),
"Vorbedingung: Dirty-State muss nach resetToDefault aktiv sein");
// Konfiguration wechseln Dirty-State und Titel sollen zurueckgesetzt werden
editorTab.notifyConfigurationChanged(new SyncPromptEditorPort(), "/new/prompt.txt", 80);
dirtyRef.set(editorTab.hasDirtyContent());
titleRef.set(editorTab.tab().getText());
} catch (Throwable t) {
fxError.set(t);
} finally {
latch.countDown();
}
});
assertTrue(latch.await(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS));
if (fxError.get() != null) {
throw new AssertionError("FX-Thread hat eine Ausnahme geworfen", fxError.get());
}
assertFalse(dirtyRef.get(),
"Dirty-State muss nach notifyConfigurationChanged false sein");
assertFalse(titleRef.get().contains("*"),
"Tab-Titel darf nach notifyConfigurationChanged keinen Asterisk enthalten; Titel war: "
+ titleRef.get());
}
@Test
void tabTitle_shouldContainAsterisk_afterEditWithLoadedBaseline() throws Exception {
CountDownLatch latch = new CountDownLatch(1);
@@ -849,7 +849,8 @@ public class BootstrapRunner {
historyOverviewPort,
historyDetailsPort,
historyResetPort,
deleteHistoryPort);
deleteHistoryPort,
this::buildGuiPromptEditorPort);
}
Path configPath = Paths.get(configPathOverride.get());
@@ -879,7 +880,8 @@ public class BootstrapRunner {
historyOverviewPort,
historyDetailsPort,
historyResetPort,
deleteHistoryPort);
deleteHistoryPort,
this::buildGuiPromptEditorPort);
}
LOG.info("GUI startup: configuration file confirmed at: {}", configPath.toAbsolutePath());
@@ -892,7 +894,8 @@ public class BootstrapRunner {
technicalTestOrchestrator, correctionExecutionService, batchRunLauncher,
miniRunLauncher, resetPort, manualRenamePort, manualCopyPort,
historicalDocumentContextPort, applicationVersion, promptEditorPort,
historyOverviewPort, historyDetailsPort, historyResetPort, deleteHistoryPort);
historyOverviewPort, historyDetailsPort, historyResetPort, deleteHistoryPort,
this::buildGuiPromptEditorPort);
} catch (GuiConfigurationLoadException e) {
LOG.error("GUI startup: configuration could not be loaded, starting without it: {}",
e.getMessage(), e);
@@ -918,7 +921,8 @@ public class BootstrapRunner {
historyOverviewPort,
historyDetailsPort,
historyResetPort,
deleteHistoryPort);
deleteHistoryPort,
this::buildGuiPromptEditorPort);
}
}