diff --git a/src/main/java/com/example/workutils/biz/apidoc/ApiDocWindow.java b/src/main/java/com/example/workutils/biz/apidoc/ApiDocWindow.java index 55cb36f..6f98856 100644 --- a/src/main/java/com/example/workutils/biz/apidoc/ApiDocWindow.java +++ b/src/main/java/com/example/workutils/biz/apidoc/ApiDocWindow.java @@ -10,7 +10,6 @@ import com.example.workutils.biz.apidoc.parser.JavaSourceParser; import com.example.workutils.system.config.WorkUtilsProperties; import com.example.workutils.system.support.FileOperationHelper; import com.example.workutils.system.support.HistoryPersistence; -import com.example.workutils.system.support.SystemOpenHelper; import com.example.workutils.system.ui.StyledButton; import com.example.workutils.system.ui.Theme; import com.example.workutils.system.support.AppExecutors; @@ -21,6 +20,9 @@ import com.example.workutils.system.ui.async.OperationStatusPane; import com.example.workutils.system.ui.history.ExportHistoryPane; import com.example.workutils.system.ui.history.HistoryFileCommands; import com.example.workutils.system.ui.history.HistoryItem; +import com.example.workutils.system.ui.history.ResultFileActions; +import com.example.workutils.system.ui.history.SystemFileActionGateway; +import com.example.workutils.system.ui.notification.FxNotificationCenter; import javafx.application.Platform; import javafx.collections.FXCollections; import javafx.collections.ObservableList; @@ -58,6 +60,7 @@ public class ApiDocWindow { private final ApiBaseClassManager apiBaseClassManager; private final RunApiDocExportUseCase exportUseCase; private final AsyncOperationController exportOperation; + private final HistoryFileCommands historyFileCommands; // 模式选择 private ToggleGroup modeGroup; @@ -107,6 +110,11 @@ public class ApiDocWindow { historyItems.setAll(historyRows.stream() .map(ApiDocWindow::toHistoryItem).toList())); this.stage = new Stage(); + this.historyFileCommands = new ResultFileActions( + new SystemFileActionGateway(), + new FxNotificationCenter(stage), + message -> log("[INFO] " + message)) + .commands(); buildUI(); loadProjects(); loadHistory(); @@ -285,7 +293,7 @@ public class ApiDocWindow { ExportHistoryPane historyPane = new ExportHistoryPane( "导出格式", "文件数", historyItems, - historyFileCommands(), fsBody()); + historyFileCommands, fsBody()); historyPane.setPrefHeight(210); historyWrapper.getChildren().add(historyPane); tabHistory.setContent(historyWrapper); @@ -760,13 +768,13 @@ public class ApiDocWindow { + "-fx-background-radius: " + Theme.RADIUS_SM + "; -fx-padding: 2 6;")); openBtn.setOnAction(e -> { - openFile(currentFileName); + historyFileCommands.openFile(currentFileName); }); dirBtn.setOnAction(e -> { - openFolder(currentFileName); + historyFileCommands.openFolder(currentFileName); }); copyBtn.setOnAction(e -> { - copyFilePath(currentFileName); + historyFileCommands.copyPath(currentFileName); }); delBtn.setOnAction(e -> { if (currentRow != null) { @@ -793,63 +801,10 @@ public class ApiDocWindow { } } - private void copyFilePath(String path) { - if (path == null) return; - javafx.scene.input.Clipboard.getSystemClipboard() - .setContent(new javafx.scene.input.ClipboardContent() {{ - putString(path); - }}); - log("[INFO] 已复制路径: " + path); - } }; return cell; } - private HistoryFileCommands historyFileCommands() { - return new HistoryFileCommands( - this::openFile, - this::openWithNotepad, - this::openFolder, - path -> { - javafx.scene.input.ClipboardContent content = - new javafx.scene.input.ClipboardContent(); - content.putString(path); - javafx.scene.input.Clipboard.getSystemClipboard().setContent(content); - log("[INFO] 已复制路径: " + path); - }); - } - - // ==================== 文件打开工具方法 ==================== - - private void openFile(String path) { - if (path == null) return; - try { - File f = new File(path); - if (!f.exists()) return; - SystemOpenHelper.openFile(f.toPath()); - } catch (Exception ignored) { - } - } - - private void openWithNotepad(String path) { - if (path == null) return; - try { - SystemOpenHelper.editFile(path); - } catch (Exception e) { - openFile(path); - } - } - - private void openFolder(String path) { - if (path == null) return; - try { - File f = new File(path).getParentFile(); - if (f == null || !f.exists()) return; - SystemOpenHelper.revealInFileManager(path); - } catch (Exception ignored) { - } - } - // ==================== 数据行 ==================== static HistoryItem toHistoryItem(HistoryRow row) { diff --git a/src/main/java/com/example/workutils/biz/mybatisexport/MyBatisExportWindow.java b/src/main/java/com/example/workutils/biz/mybatisexport/MyBatisExportWindow.java index a2ca64d..6a6311c 100644 --- a/src/main/java/com/example/workutils/biz/mybatisexport/MyBatisExportWindow.java +++ b/src/main/java/com/example/workutils/biz/mybatisexport/MyBatisExportWindow.java @@ -6,7 +6,6 @@ import com.example.workutils.biz.mybatisexport.service.MyBatisExportService; import com.example.workutils.system.config.WorkUtilsProperties; import com.example.workutils.system.support.AppExecutors; import com.example.workutils.system.support.HistoryPersistence; -import com.example.workutils.system.support.SystemOpenHelper; import com.example.workutils.system.ui.StyledButton; import com.example.workutils.system.ui.Theme; import com.example.workutils.system.ui.async.AsyncOperationController; @@ -15,6 +14,9 @@ import com.example.workutils.system.ui.async.OperationStatusPane; import com.example.workutils.system.ui.history.ExportHistoryPane; import com.example.workutils.system.ui.history.HistoryFileCommands; import com.example.workutils.system.ui.history.HistoryItem; +import com.example.workutils.system.ui.history.ResultFileActions; +import com.example.workutils.system.ui.history.SystemFileActionGateway; +import com.example.workutils.system.ui.notification.FxNotificationCenter; import javafx.application.Platform; import javafx.collections.FXCollections; import javafx.collections.ObservableList; @@ -26,7 +28,6 @@ import javafx.scene.layout.*; import javafx.scene.Parent; import javafx.stage.Stage; -import java.io.File; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Date; @@ -46,6 +47,7 @@ public class MyBatisExportWindow { private final WorkUtilsProperties properties; private final RunSqlExportUseCase runExportUseCase; private final AsyncOperationController exportOperation; + private final HistoryFileCommands historyFileCommands; // 过滤 private ComboBox filterModeCmb; @@ -81,6 +83,11 @@ public class MyBatisExportWindow { historyItems.setAll(historyRows.stream() .map(MyBatisExportWindow::toHistoryItem).toList())); this.stage = new Stage(); + this.historyFileCommands = new ResultFileActions( + new SystemFileActionGateway(), + new FxNotificationCenter(stage), + message -> logArea.appendText("[INFO] " + message + "\n")) + .commands(); buildUI(); loadHistory(); } @@ -174,25 +181,12 @@ public class MyBatisExportWindow { tableTitle.setTextFill(Theme.TEXT_PRIMARY); ExportHistoryPane historyPane = new ExportHistoryPane( - "导出数", historyItems, historyFileCommands(), fsBody()); + "导出数", historyItems, historyFileCommands, fsBody()); VBox.setVgrow(historyPane, Priority.ALWAYS); root.getChildren().addAll(filterRow, progressRow, logTitle, logArea, tableTitle, historyPane); stage.setScene(new Scene(root)); } - private HistoryFileCommands historyFileCommands() { - return new HistoryFileCommands( - this::openFile, - this::openWithNotepad, - this::openFolder, - path -> { - javafx.scene.input.ClipboardContent content = - new javafx.scene.input.ClipboardContent(); - content.putString(path); - javafx.scene.input.Clipboard.getSystemClipboard().setContent(content); - }); - } - // ==================== 历史持久化 ==================== /** @@ -275,37 +269,6 @@ public class MyBatisExportWindow { saveHistory(); } - // ==================== 文件打开工具方法 ==================== - - private void openFile(String path) { - if (path == null) return; - try { - File f = new File(path); - if (!f.exists()) return; - SystemOpenHelper.openFile(f.toPath()); - } catch (Exception ignored) { - } - } - - private void openWithNotepad(String path) { - if (path == null) return; - try { - SystemOpenHelper.editFile(path); - } catch (Exception e) { - openFile(path); - } - } - - private void openFolder(String path) { - if (path == null) return; - try { - File f = new File(path).getParentFile(); - if (f == null || !f.exists()) return; - SystemOpenHelper.revealInFileManager(path); - } catch (Exception ignored) { - } - } - // ==================== 数据行 ==================== static HistoryItem toHistoryItem(HistoryRow row) { diff --git a/src/main/java/com/example/workutils/biz/scanner/ScannerWindow.java b/src/main/java/com/example/workutils/biz/scanner/ScannerWindow.java index 710c5f0..3577cbe 100644 --- a/src/main/java/com/example/workutils/biz/scanner/ScannerWindow.java +++ b/src/main/java/com/example/workutils/biz/scanner/ScannerWindow.java @@ -8,7 +8,6 @@ import com.example.workutils.system.support.AppExecutors; import com.example.workutils.system.support.FileOperationHelper; import com.example.workutils.system.support.HistoryPersistence; import com.example.workutils.system.support.OutputHelper; -import com.example.workutils.system.support.SystemOpenHelper; import com.example.workutils.system.ui.StyledButton; import com.example.workutils.system.ui.Theme; import com.example.workutils.system.ui.async.AsyncOperationController; @@ -17,6 +16,9 @@ import com.example.workutils.system.ui.async.OperationStatusPane; import com.example.workutils.system.ui.history.ExportHistoryPane; import com.example.workutils.system.ui.history.HistoryFileCommands; import com.example.workutils.system.ui.history.HistoryItem; +import com.example.workutils.system.ui.history.ResultFileActions; +import com.example.workutils.system.ui.history.SystemFileActionGateway; +import com.example.workutils.system.ui.notification.FxNotificationCenter; import javafx.application.Platform; import javafx.collections.FXCollections; import javafx.collections.ObservableList; @@ -28,7 +30,6 @@ import javafx.scene.layout.*; import javafx.scene.Parent; import javafx.stage.Stage; -import java.io.File; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Date; @@ -49,6 +50,7 @@ public class ScannerWindow { private final OutputHelper outputHelper; private final RunScanUseCase runScanUseCase; private final AsyncOperationController scanOperation; + private final HistoryFileCommands historyFileCommands; // UI private CheckBox regexCb; @@ -79,6 +81,11 @@ public class ScannerWindow { historyItems.setAll(historyRows.stream() .map(ScannerWindow::toHistoryItem).toList())); this.stage = new Stage(); + this.historyFileCommands = new ResultFileActions( + new SystemFileActionGateway(), + new FxNotificationCenter(stage), + message -> logArea.appendText("[INFO] " + message + "\n")) + .commands(); buildUI(); loadHistory(); } @@ -158,7 +165,7 @@ public class ScannerWindow { tableTitle.setTextFill(Theme.TEXT_PRIMARY); ExportHistoryPane historyPane = new ExportHistoryPane( - "匹配数", historyItems, historyFileCommands(), fsBody()); + "匹配数", historyItems, historyFileCommands, fsBody()); VBox.setVgrow(historyPane, Priority.ALWAYS); // === 日志区 === @@ -172,20 +179,6 @@ public class ScannerWindow { stage.setScene(new Scene(root)); } - private HistoryFileCommands historyFileCommands() { - return new HistoryFileCommands( - this::openFile, - this::openWithNotepad, - this::openFolder, - path -> { - javafx.scene.input.ClipboardContent content = - new javafx.scene.input.ClipboardContent(); - content.putString(path); - javafx.scene.input.Clipboard.getSystemClipboard().setContent(content); - logArea.appendText("[INFO] 已复制路径: " + path + "\n"); - }); - } - // ==================== 历史持久化 ==================== /** @@ -253,54 +246,6 @@ public class ScannerWindow { logArea.appendText("已导出: " + result.fileName() + "\n"); } - // ==================== 文件打开工具方法 ==================== - - private void openFile(String path) { - if (path == null) { - logArea.appendText("[WARN] 路径为空\n"); - return; - } - try { - File f = new File(path); - if (!f.exists()) { - logArea.appendText("[WARN] 文件不存在: " + path + "\n"); - return; - } - // Delegate platform-specific open behavior to SystemOpenHelper. - SystemOpenHelper.openFile(f.toPath()); - logArea.appendText("[OK] 已打开: " + path + "\n"); - } catch (Exception e) { - logArea.appendText("[ERROR] 打开失败: " + (e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName()) + "\n"); - } - } - - private void openWithNotepad(String path) { - try { - SystemOpenHelper.editFile(path); - logArea.appendText("[OK] 用系统编辑器打开: " + path + "\n"); - } catch (Exception e) { - openFile(path); // fallback - } - } - - private void openFolder(String path) { - if (path == null) { - logArea.appendText("[WARN] 路径为空\n"); - return; - } - try { - File f = new File(path).getParentFile(); - if (f == null || !f.exists()) { - logArea.appendText("[WARN] 目录不存在: " + path + "\n"); - return; - } - SystemOpenHelper.revealInFileManager(path); - logArea.appendText("[OK] 已打开目录: " + f.getAbsolutePath() + "\n"); - } catch (Exception e) { - logArea.appendText("[ERROR] 打开目录失败: " + (e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName()) + "\n"); - } - } - // ==================== 数据行 ==================== static HistoryItem toHistoryItem(HistoryRow row) { diff --git a/src/main/java/com/example/workutils/system/ui/history/FileActionGateway.java b/src/main/java/com/example/workutils/system/ui/history/FileActionGateway.java new file mode 100644 index 0000000..6642209 --- /dev/null +++ b/src/main/java/com/example/workutils/system/ui/history/FileActionGateway.java @@ -0,0 +1,15 @@ +package com.example.workutils.system.ui.history; + +import java.io.IOException; +import java.nio.file.Path; + +public interface FileActionGateway { + + void open(Path path) throws IOException; + + void edit(Path path) throws IOException; + + void reveal(Path path) throws IOException; + + void copyPath(String path); +} diff --git a/src/main/java/com/example/workutils/system/ui/history/ResultFileActions.java b/src/main/java/com/example/workutils/system/ui/history/ResultFileActions.java new file mode 100644 index 0000000..12673ab --- /dev/null +++ b/src/main/java/com/example/workutils/system/ui/history/ResultFileActions.java @@ -0,0 +1,90 @@ +package com.example.workutils.system.ui.history; + +import com.example.workutils.system.ui.notification.NotificationCenter; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.util.Objects; +import java.util.function.Consumer; + +public final class ResultFileActions { + + private final FileActionGateway gateway; + private final NotificationCenter notifications; + private final Consumer successFeedback; + + public ResultFileActions( + FileActionGateway gateway, + NotificationCenter notifications, + Consumer successFeedback) { + this.gateway = Objects.requireNonNull(gateway, "gateway"); + this.notifications = Objects.requireNonNull(notifications, "notifications"); + this.successFeedback = Objects.requireNonNull(successFeedback, "successFeedback"); + } + + public HistoryFileCommands commands() { + return new HistoryFileCommands( + path -> performExisting(path, "打开文件", gateway::open), + path -> performExisting(path, "编辑文件", gateway::edit), + path -> performExisting(path, "打开目录", gateway::reveal), + this::copyPath); + } + + private void performExisting(String value, String action, PathAction operation) { + Path path = parsePath(value); + if (path == null) { + return; + } + if (!Files.isRegularFile(path)) { + notifications.warning("文件操作失败", "文件不存在"); + return; + } + try { + operation.accept(path); + } catch (IOException | RuntimeException error) { + notifyFailure(action, error); + } + } + + private void copyPath(String value) { + Path path = parsePath(value); + if (path == null) { + return; + } + String normalized = path.toString(); + try { + gateway.copyPath(normalized); + successFeedback.accept(Files.exists(path) + ? "已复制路径: " + normalized + : "已复制路径(文件已不存在): " + normalized); + } catch (RuntimeException error) { + notifyFailure("复制路径", error); + } + } + + private Path parsePath(String value) { + if (value == null || value.isBlank()) { + notifications.warning("文件操作失败", "文件路径不能为空"); + return null; + } + try { + return Path.of(value.trim()).toAbsolutePath().normalize(); + } catch (InvalidPathException error) { + notifyFailure("解析路径", error); + return null; + } + } + + private void notifyFailure(String action, Exception error) { + String detail = error.getMessage(); + notifications.error("文件操作失败", action + "失败" + + (detail == null || detail.isBlank() ? "" : ": " + detail)); + } + + @FunctionalInterface + private interface PathAction { + void accept(Path path) throws IOException; + } +} diff --git a/src/main/java/com/example/workutils/system/ui/history/SystemFileActionGateway.java b/src/main/java/com/example/workutils/system/ui/history/SystemFileActionGateway.java new file mode 100644 index 0000000..31e719a --- /dev/null +++ b/src/main/java/com/example/workutils/system/ui/history/SystemFileActionGateway.java @@ -0,0 +1,33 @@ +package com.example.workutils.system.ui.history; + +import com.example.workutils.system.support.SystemOpenHelper; +import javafx.scene.input.Clipboard; +import javafx.scene.input.ClipboardContent; + +import java.io.IOException; +import java.nio.file.Path; + +public final class SystemFileActionGateway implements FileActionGateway { + + @Override + public void open(Path path) throws IOException { + SystemOpenHelper.openFile(path); + } + + @Override + public void edit(Path path) throws IOException { + SystemOpenHelper.editFile(path); + } + + @Override + public void reveal(Path path) throws IOException { + SystemOpenHelper.revealInFileManager(path); + } + + @Override + public void copyPath(String path) { + ClipboardContent content = new ClipboardContent(); + content.putString(path); + Clipboard.getSystemClipboard().setContent(content); + } +} diff --git a/src/test/java/com/example/workutils/system/ui/history/ResultFileActionsTest.java b/src/test/java/com/example/workutils/system/ui/history/ResultFileActionsTest.java new file mode 100644 index 0000000..b4cd97d --- /dev/null +++ b/src/test/java/com/example/workutils/system/ui/history/ResultFileActionsTest.java @@ -0,0 +1,135 @@ +package com.example.workutils.system.ui.history; + +import com.example.workutils.system.ui.notification.NotificationCenter; +import com.example.workutils.system.ui.notification.NotificationMessage; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ResultFileActionsTest { + + @TempDir + Path tempDir; + + @Test + void routesExistingFileActionsThroughGateway() throws Exception { + Path file = Files.createFile(tempDir.resolve("result.csv")); + RecordingGateway gateway = new RecordingGateway(); + RecordingNotifications notifications = new RecordingNotifications(); + ResultFileActions actions = new ResultFileActions( + gateway, notifications, message -> { }); + + HistoryFileCommands commands = actions.commands(); + commands.openFile(file.toString()); + commands.openInEditor(file.toString()); + commands.openFolder(file.toString()); + + assertEquals(List.of( + "open:" + file.toAbsolutePath().normalize(), + "edit:" + file.toAbsolutePath().normalize(), + "reveal:" + file.toAbsolutePath().normalize()), gateway.calls); + assertTrue(notifications.messages.isEmpty()); + } + + @Test + void copiesNormalizedMissingPathAndReportsThatItNoLongerExists() { + Path missing = tempDir.resolve("missing.csv"); + RecordingGateway gateway = new RecordingGateway(); + RecordingNotifications notifications = new RecordingNotifications(); + List feedback = new ArrayList<>(); + ResultFileActions actions = new ResultFileActions( + gateway, notifications, feedback::add); + + actions.commands().copyPath(missing.toString()); + + String normalized = missing.toAbsolutePath().normalize().toString(); + assertEquals(List.of("copy:" + normalized), gateway.calls); + assertEquals(List.of("已复制路径(文件已不存在): " + normalized), feedback); + } + + @Test + void rejectsBlankAndMissingOpenTargetsWithoutCallingGateway() { + RecordingGateway gateway = new RecordingGateway(); + RecordingNotifications notifications = new RecordingNotifications(); + ResultFileActions actions = new ResultFileActions( + gateway, notifications, message -> { }); + + actions.commands().openFile(" "); + actions.commands().openFolder(tempDir.resolve("gone.csv").toString()); + + assertTrue(gateway.calls.isEmpty()); + assertEquals(2, notifications.messages.size()); + assertEquals("文件路径不能为空", notifications.messages.get(0).message()); + assertEquals("文件不存在", notifications.messages.get(1).message()); + } + + @Test + void translatesPlatformFailureIntoUserFacingError() throws Exception { + Path file = Files.createFile(tempDir.resolve("result.sql")); + RecordingGateway gateway = new RecordingGateway(); + gateway.failure = new IllegalStateException("desktop unavailable"); + RecordingNotifications notifications = new RecordingNotifications(); + ResultFileActions actions = new ResultFileActions( + gateway, notifications, message -> { }); + + actions.commands().openFile(file.toString()); + + assertEquals(1, notifications.messages.size()); + NotificationMessage message = notifications.messages.get(0); + assertEquals("文件操作失败", message.title()); + assertTrue(message.message().contains("desktop unavailable")); + } + + private static final class RecordingGateway implements FileActionGateway { + private final List calls = new ArrayList<>(); + private RuntimeException failure; + + @Override + public void open(Path path) { + record("open:" + path); + } + + @Override + public void edit(Path path) { + record("edit:" + path); + } + + @Override + public void reveal(Path path) { + record("reveal:" + path); + } + + @Override + public void copyPath(String path) { + record("copy:" + path); + } + + private void record(String call) { + if (failure != null) { + throw failure; + } + calls.add(call); + } + } + + private static final class RecordingNotifications implements NotificationCenter { + private final List messages = new ArrayList<>(); + + @Override + public void show(NotificationMessage message) { + messages.add(message); + } + + @Override + public boolean confirm(String title, String header, String message) { + throw new UnsupportedOperationException(); + } + } +} diff --git a/src/test/java/com/example/workutils/system/ui/history/WindowHistoryCommandsDependencyTest.java b/src/test/java/com/example/workutils/system/ui/history/WindowHistoryCommandsDependencyTest.java new file mode 100644 index 0000000..835b85e --- /dev/null +++ b/src/test/java/com/example/workutils/system/ui/history/WindowHistoryCommandsDependencyTest.java @@ -0,0 +1,30 @@ +package com.example.workutils.system.ui.history; + +import com.example.workutils.biz.apidoc.ApiDocWindow; +import com.example.workutils.biz.mybatisexport.MyBatisExportWindow; +import com.example.workutils.biz.scanner.ScannerWindow; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class WindowHistoryCommandsDependencyTest { + + @ParameterizedTest + @ValueSource(classes = { + ApiDocWindow.class, + ScannerWindow.class, + MyBatisExportWindow.class + }) + void targetWindowOwnsOneSharedHistoryCommandBundle(Class windowType) { + long commandFields = Arrays.stream(windowType.getDeclaredFields()) + .filter(field -> field.getType() == HistoryFileCommands.class) + .count(); + + assertEquals(1, commandFields, + () -> windowType.getSimpleName() + + " must own one shared HistoryFileCommands instance"); + } +}