diff --git a/docs/audit/plugin-refactor-next.md b/docs/audit/plugin-refactor-next.md new file mode 100644 index 0000000..8cdaaca --- /dev/null +++ b/docs/audit/plugin-refactor-next.md @@ -0,0 +1,58 @@ +# 插件 UI 与代码重构后续事项 + +更新时间:2026-07-30 + +## 已完成 + +- 建立统一异步状态、任务控制器、线程池、Loading 按钮绑定和状态面板。 +- Scanner、MyBatis SQL 导出接入统一异步流程。 +- 提取通用空状态、导出历史和历史文件操作组件。 +- API 文档导出拆分请求、状态、用例和历史逻辑;本地调试代理改为显式选项且默认关闭。 +- Git 同步完成 URL 脱敏、搜索过滤、请求与用例拆分。 +- 文本对比提取 `DiffEngine`,耗时操作移出 UI 线程。 +- MyBatis 逆向工程完成数据源脱敏、查询与生成独立状态、不可变请求及重置配置。 + +## 明日待办(按优先级) + +### P0:生成输出目录与结果反馈 + +- 提取 `OutputLayoutPolicy`,由项目根目录统一推导 Model、XML Mapper 和 Mapper 接口目录。 +- 生成前展示将创建、覆盖和跳过的文件摘要。 +- 生成完成后展示文件清单,并提供打开目录、复制路径操作。 +- 明确目录不可写、文件冲突和部分生成失败时的恢复策略。 +- 为路径推导、覆盖策略和生成结果编写单元测试。 + +### P1:手机 UI 自动化拆分 + +- 将 `MobileUiAutomationWindow` 拆为运行用例、录制用例、环境与设备三个页面。 +- 引入独立的 `RunState`、`RecordState` 和对应 ViewModel。 +- 将截图轮询封装为可关闭的 `ScreenshotStream`,绑定到页面生命周期。 +- 分离运行、停止、录制和生成操作,禁止依赖按钮文字判断状态。 +- 优先补状态迁移和生命周期测试,再迁移 UI。 + +### P2:系统设置结构化 + +- 将设置按“常规、项目与数据源、插件、外观”分类。 +- 用带校验的表单替代主要依赖外部编辑器修改配置文件。 +- 敏感字段统一显示/隐藏,并确保列表和摘要只接触脱敏 DTO。 +- 增加保存成功、校验失败和恢复默认的明确反馈。 + +### P3:公共基础能力收尾 + +- 评估并提取统一 `NotificationCenter`,避免各窗口自行弹窗。 +- 为 Scanner、SQL 导出补充结构化进度 DTO;仅在底层支持真实中断时显示停止操作。 +- 统一生成/导出插件的空状态、结果摘要、打开目录和复制路径交互。 +- 补充键盘访问、焦点顺序、屏幕阅读器语义和颜色对比专项检查。 + +### 暂缓:Git 评审 + +- 按当前决定暂不重构 Git 评审。 +- 后续如恢复,应单独立项,不与手机 UI 自动化或其他高风险窗口同时修改。 + +## 继续实施约束 + +- 每次只推进一个优先级阶段,先写请求、状态和用例测试,再迁移 UI。 +- Window 不直接承载 Git、SQL、HTTP、进程命令或文件遍历。 +- 每个操作使用独立状态,禁止用共享 `running` 锁住无关按钮。 +- 跨线程数据使用不可变 DTO;敏感信息不得进入 UI 状态或日志。 +- 每阶段完成后运行全量测试、编译、`git diff --check` 和只读 UI 验收。 diff --git a/docs/superpowers/plans/2026-07-30-api-doc-refactor-phase-3.md b/docs/superpowers/plans/2026-07-30-api-doc-refactor-phase-3.md new file mode 100644 index 0000000..0b1ea56 --- /dev/null +++ b/docs/superpowers/plans/2026-07-30-api-doc-refactor-phase-3.md @@ -0,0 +1,92 @@ +# API Doc Refactor Phase 3 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Give API document export one immutable request, one async operation owner, an explicit debug-proxy option, and the shared history UI. + +**Architecture:** Capture all JavaFX form values into `ApiDocExportRequest` on the UI thread, then execute from `AsyncOperationController`. Inject the debug endpoint into `ApiDocGenerator`; only the export use case may decide whether to start the local proxy. Adapt existing history rows to `HistoryItem` without changing persistence. + +**Tech Stack:** Java 17, JavaFX, JUnit 5, Maven. + +## Global Constraints + +- Work only on `codex/plugin-ui-refactor` in the existing isolated worktree. +- Preserve generated HTML structure and history-file format. +- Do not introduce `new Thread`. +- File and directory choosers must not enter loading state. +- Local debug proxy is a visible option and defaults to disabled. +- Write and observe a failing test before production behavior. + +--- + +### Task 1: Immutable API export request + +**Files:** +- Create: `src/main/java/com/example/workutils/biz/apidoc/application/ApiDocExportRequest.java` +- Test: `src/test/java/com/example/workutils/biz/apidoc/application/ApiDocExportRequestTest.java` + +**Interfaces:** +- Produces: `Mode { PATH, FQN, BATCH }`. +- Produces validated factories `path`, `fqn`, and `batch`. +- Carries debug-proxy opt-in and batch filters. + +- [ ] Test mode-specific validation and immutable project-path copying. +- [ ] Observe focused test failure. +- [ ] Implement the minimal request record. +- [ ] Confirm focused test passes. + +### Task 2: Explicit debug endpoint injection + +**Files:** +- Modify: `src/main/java/com/example/workutils/biz/apidoc/generator/ApiDocGenerator.java` +- Test: `src/test/java/com/example/workutils/biz/apidoc/generator/ApiDocGeneratorProxyTest.java` + +**Interfaces:** +- Produces constructor `ApiDocGenerator(WorkUtilsProperties, Supplier)`. +- Existing constructor supplies an empty endpoint and never starts a proxy. + +- [ ] Test that generated HTML receives the supplied endpoint and the default constructor does not instantiate the proxy. +- [ ] Observe focused test failure. +- [ ] Inject endpoint supplier and remove `DubboDebugProxy.getInstance()` from HTML rendering. +- [ ] Confirm focused test passes. + +### Task 3: Single async export operation + +**Files:** +- Create: `src/main/java/com/example/workutils/biz/apidoc/application/RunApiDocExportUseCase.java` +- Modify: `src/main/java/com/example/workutils/biz/apidoc/ApiDocWindow.java` +- Test: `src/test/java/com/example/workutils/biz/apidoc/application/RunApiDocExportUseCaseTest.java` + +**Interfaces:** +- Consumes `ApiDocExportRequest`. +- Produces `Result(Path output, int moduleCount, int classCount, int methodCount)`. +- Reports progress through `OperationContext`. + +- [ ] Test proxy-disabled execution does not resolve a local endpoint and proxy-enabled execution resolves it once. +- [ ] Observe focused test failure. +- [ ] Move export orchestration behind the use case and connect it to `AsyncOperationController`. +- [ ] Bind the button through `LoadingButtonBinder` and status through `OperationStatusPane`. +- [ ] Confirm focused and existing tests pass. + +### Task 4: Shared history UI + +**Files:** +- Modify: `src/main/java/com/example/workutils/biz/apidoc/ApiDocWindow.java` +- Test: `src/test/java/com/example/workutils/biz/apidoc/ApiDocHistoryMappingTest.java` + +**Interfaces:** +- Produces package-visible `toHistoryItem(HistoryRow)`. +- Consumes `ExportHistoryPane` and `HistoryFileCommands`. + +- [ ] Test history mapping. +- [ ] Observe focused test failure. +- [ ] Replace duplicated table and action cell. +- [ ] Confirm focused test passes. + +### Task 5: Verification + +- [ ] Run all tests. +- [ ] Compile with JDK 17. +- [ ] Run `git diff --check`. +- [ ] Confirm `ApiDocWindow` contains no `new Thread`, `buildActionCell`, or direct export-button state mutations. +- [ ] Inspect PATH, FQN, and BATCH panels without invoking export or file choosers. diff --git a/docs/superpowers/plans/2026-07-30-diff-engine-phase-5.md b/docs/superpowers/plans/2026-07-30-diff-engine-phase-5.md new file mode 100644 index 0000000..6b07d57 --- /dev/null +++ b/docs/superpowers/plans/2026-07-30-diff-engine-phase-5.md @@ -0,0 +1,79 @@ +# Diff Engine Phase 5 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make CLI and JavaFX text comparison share one UI-independent line and character diff engine, then run expensive comparisons off the JavaFX thread. + +**Architecture:** `DiffEngine` returns immutable line and character segment records without HTML or JavaFX dependencies. `DiffPlugin` delegates CLI comparison to it. `DiffWindow` renders those records and owns only presentation/navigation, with `AsyncOperationController` owning background execution. + +**Tech Stack:** Java 17, JavaFX, JUnit 5, Maven. + +## Global Constraints + +- Preserve current unified text output and HTML colors/navigation. +- `DiffEngine` must not import JavaFX, HTML generators, filesystem, or plugin classes. +- CLI and UI must use the same line-diff implementation. +- Capture text on the UI thread before background execution. +- Test first. + +--- + +### Task 1: Immutable line diff engine + +**Files:** +- Create: `src/main/java/com/example/workutils/biz/diff/domain/DiffEngine.java` +- Test: `src/test/java/com/example/workutils/biz/diff/domain/DiffEngineLineTest.java` + +**Interfaces:** +- Produces `DiffResult compareLines(List left, List right)`. +- Produces immutable `LineDiff`, `LineType`, and counts. + +- [ ] Test unchanged, added, removed, replacement, and input-list mutation. +- [ ] Observe focused test failure. +- [ ] Implement LCS line comparison. +- [ ] Confirm focused test passes. + +### Task 2: Structured character diff + +**Files:** +- Modify: `src/main/java/com/example/workutils/biz/diff/domain/DiffEngine.java` +- Test: `src/test/java/com/example/workutils/biz/diff/domain/DiffEngineCharacterTest.java` + +**Interfaces:** +- Produces `CharacterDiff compareCharacters(String left, String right)`. +- Produces immutable `CharacterSegment(Type, text)` lists for both sides. +- Produces `similarity(String left, String right)`. + +- [ ] Test insertion, deletion, replacement, equality, HTML-like text, and similarity. +- [ ] Observe focused test failure. +- [ ] Implement character LCS and merged segments. +- [ ] Confirm focused test passes. + +### Task 3: Migrate CLI and renderer + +**Files:** +- Modify: `src/main/java/com/example/workutils/biz/diff/DiffPlugin.java` +- Modify: `src/main/java/com/example/workutils/biz/diff/DiffWindow.java` + +- [ ] Replace `DiffPlugin` nested algorithm types with `DiffEngine`. +- [ ] Replace Window line/character computation with engine records. +- [ ] Delete duplicate LCS methods from Plugin and Window. +- [ ] Run focused engine and existing tests. + +### Task 4: Background comparison state + +**Files:** +- Modify: `src/main/java/com/example/workutils/biz/diff/DiffWindow.java` + +- [ ] Add `AsyncOperationController`. +- [ ] Bind compare button through `LoadingButtonBinder`. +- [ ] Add `OperationStatusPane`. +- [ ] Capture both text values before background execution. +- [ ] Apply HTML and counters only on success. + +### Task 5: Verification + +- [ ] Run all tests and compile with JDK 17. +- [ ] Run `git diff --check`. +- [ ] Confirm no LCS matrix implementation remains in `DiffPlugin` or `DiffWindow`. +- [ ] Inspect empty, equal, and different short-text UI states without saving files. diff --git a/docs/superpowers/plans/2026-07-30-git-sync-refactor-phase-4.md b/docs/superpowers/plans/2026-07-30-git-sync-refactor-phase-4.md new file mode 100644 index 0000000..0f31c48 --- /dev/null +++ b/docs/superpowers/plans/2026-07-30-git-sync-refactor-phase-4.md @@ -0,0 +1,90 @@ +# Git Sync Refactor Phase 4 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Stop exposing credential-bearing Git URLs, make large project lists searchable, and give repository synchronization one structured async state owner. + +**Architecture:** Convert raw remote URLs to safe display values before they enter table rows. Add a filtered view over immutable project presentation rows. Wrap the existing blocking synchronization API behind a use case that translates callbacks into immutable progress and result records consumed by `AsyncOperationController`. + +**Tech Stack:** Java 17, JavaFX, JUnit 5, Maven. + +## Global Constraints + +- Preserve Git command behavior and damaged-repository confirmation flow. +- Never display username, password, or token-bearing URL text in the project table. +- Do not add raw `new Thread` to `GitSyncWindow`. +- Do not execute synchronization during UI verification. +- Test first for every new production behavior. + +--- + +### Task 1: Remote URL safe display + +**Files:** +- Create: `src/main/java/com/example/workutils/biz/git/presentation/RemoteUrlDisplay.java` +- Test: `src/test/java/com/example/workutils/biz/git/presentation/RemoteUrlDisplayTest.java` +- Modify: `src/main/java/com/example/workutils/biz/git/GitSyncWindow.java` + +**Interfaces:** +- Produces `RemoteUrlDisplay.safe(String rawUrl)`. +- HTTPS credentials and SSH usernames are removed; host and repository path remain. + +- [ ] Test HTTPS credentials, SSH SCP syntax, plain host/path, and blank values. +- [ ] Observe focused test failure. +- [ ] Implement safe display. +- [ ] Store only safe text in `ProjectRow`. +- [ ] Confirm focused test passes. + +### Task 2: Project search and status filter + +**Files:** +- Create: `src/main/java/com/example/workutils/biz/git/presentation/ProjectFilter.java` +- Test: `src/test/java/com/example/workutils/biz/git/presentation/ProjectFilterTest.java` +- Modify: `src/main/java/com/example/workutils/biz/git/GitSyncWindow.java` + +**Interfaces:** +- Produces pure `matches(name, path, status, query, requiredStatus)`. + +- [ ] Test case-insensitive name/path matching and status filtering. +- [ ] Observe focused test failure. +- [ ] Add search field, status combo, filtered list, and visible count. +- [ ] Confirm focused test passes. + +### Task 3: Structured synchronization use case + +**Files:** +- Create: `src/main/java/com/example/workutils/biz/git/application/GitSyncRequest.java` +- Create: `src/main/java/com/example/workutils/biz/git/application/GitSyncProgress.java` +- Create: `src/main/java/com/example/workutils/biz/git/application/RunGitSyncUseCase.java` +- Test: `src/test/java/com/example/workutils/biz/git/application/RunGitSyncUseCaseTest.java` + +**Interfaces:** +- Consumes `Gateway.sync(branch, Listener)`. +- Produces immutable `Result(total, success, damaged, failed, elapsedSeconds, damagedRepos)`. +- Reports progress through `OperationContext`. + +- [ ] Test request validation, progress translation, result construction, and missing completion failure. +- [ ] Observe focused test failure. +- [ ] Implement request, progress, gateway, listener, and result. +- [ ] Confirm focused test passes. + +### Task 4: Migrate main sync action + +**Files:** +- Modify: `src/main/java/com/example/workutils/biz/git/GitSyncWindow.java` + +**Interfaces:** +- Consumes `AsyncOperationController`. +- Uses `LoadingButtonBinder` and `OperationStatusPane`. + +- [ ] Replace manual button/progress state and window-level worker thread. +- [ ] Keep cancellation wired to both operation state and `plugin.cancelSync()`. +- [ ] Render structured completion summary and damaged rows. +- [ ] Confirm `GitSyncWindow` has no `new Thread` in synchronization flow. + +### Task 5: Verification + +- [ ] Run all tests and JDK 17 compilation. +- [ ] Run `git diff --check`. +- [ ] Inspect project list search/filter, masked remote values, and idle sync state. +- [ ] Do not start synchronization or damaged-repository actions. diff --git a/docs/superpowers/plans/2026-07-30-mybatis-generator-phase-6.md b/docs/superpowers/plans/2026-07-30-mybatis-generator-phase-6.md new file mode 100644 index 0000000..0a3b224 --- /dev/null +++ b/docs/superpowers/plans/2026-07-30-mybatis-generator-phase-6.md @@ -0,0 +1,78 @@ +# MyBatis Generator Phase 6 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Remove sensitive database details from the page, prevent database query/generation from freezing JavaFX, and replace the embedded-page “cancel” action with reset. + +**Architecture:** Convert raw JDBC configuration into a safe `DatasourceSummary` before rendering. Capture query and generation inputs into immutable requests and execute two independent use cases through separate `AsyncOperationController` instances. Keep existing service callbacks as infrastructure adapters. + +**Tech Stack:** Java 17, JavaFX, JUnit 5, Maven. + +## Global Constraints + +- Never expose database password, username, JDBC query parameters, or raw URL in page labels. +- Query and generate actions must have independent state. +- Preserve database access and generated artifact behavior. +- Directory choosers remain synchronous and do not show loading. +- UI verification must not query a database or generate files. + +--- + +### Task 1: Safe datasource summary + +**Files:** +- Create: `src/main/java/com/example/workutils/biz/mybatisgenerator/presentation/DatasourceSummary.java` +- Test: `src/test/java/com/example/workutils/biz/mybatisgenerator/presentation/DatasourceSummaryTest.java` +- Modify: `src/main/java/com/example/workutils/biz/mybatisgenerator/MyBatisGeneratorWindow.java` + +**Interfaces:** +- Produces `DatasourceSummary.from(alias, jdbcUrl)`. +- Exposes alias, database type, masked host, and database name only. + +- [ ] Test MySQL, OceanBase, query parameters, malformed URL, and blank alias. +- [ ] Observe focused test failure. +- [ ] Implement safe parsing and display text. +- [ ] Replace raw URL/user labels. +- [ ] Confirm focused test passes. + +### Task 2: Immutable operation requests + +**Files:** +- Create: `src/main/java/com/example/workutils/biz/mybatisgenerator/application/QueryTablesRequest.java` +- Create: `src/main/java/com/example/workutils/biz/mybatisgenerator/application/GenerateArtifactsRequest.java` +- Test: `src/test/java/com/example/workutils/biz/mybatisgenerator/application/GeneratorRequestTest.java` + +- [ ] Test trimming, required pattern/table selection, directory defaults, and immutable table copying. +- [ ] Observe focused test failure. +- [ ] Implement both records. +- [ ] Confirm focused test passes. + +### Task 3: Independent use cases and loading + +**Files:** +- Create: `src/main/java/com/example/workutils/biz/mybatisgenerator/application/QueryTablesUseCase.java` +- Create: `src/main/java/com/example/workutils/biz/mybatisgenerator/application/GenerateArtifactsUseCase.java` +- Modify: `src/main/java/com/example/workutils/biz/mybatisgenerator/MyBatisGeneratorWindow.java` +- Test: `src/test/java/com/example/workutils/biz/mybatisgenerator/application/GeneratorUseCaseTest.java` + +- [ ] Test gateway delegation and result immutability. +- [ ] Observe focused test failure. +- [ ] Add two use cases and two async controllers. +- [ ] Bind query/generate buttons independently and add operation status panes. +- [ ] Confirm focused tests pass. + +### Task 4: Reset semantics + +**Files:** +- Modify: `src/main/java/com/example/workutils/biz/mybatisgenerator/MyBatisGeneratorWindow.java` + +- [ ] Rename bottom “取消” to “重置配置”. +- [ ] Reset pattern, table selection, and directory defaults without closing the page. +- [ ] Keep window close behavior only on actual window close. + +### Task 5: Verification + +- [ ] Run all tests and compile with JDK 17. +- [ ] Run `git diff --check`. +- [ ] Confirm Window no longer directly calls query/generate on the UI thread. +- [ ] Inspect datasource summary, idle query/generate states, and reset label without database or filesystem side effects. diff --git a/docs/superpowers/plans/2026-07-30-plugin-ui-refactor-phase-1.md b/docs/superpowers/plans/2026-07-30-plugin-ui-refactor-phase-1.md new file mode 100644 index 0000000..886cd73 --- /dev/null +++ b/docs/superpowers/plans/2026-07-30-plugin-ui-refactor-phase-1.md @@ -0,0 +1,245 @@ +# Plugin UI Refactor Phase 1 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Introduce one testable asynchronous operation model and migrate Scanner and MyBatis SQL Export to consistent loading, cancellation, progress, error recovery, and history UI behavior. + +**Architecture:** Add JavaFX-neutral operation state in `system/ui/async`, with a JavaFX adapter that binds buttons and status controls. Plugin windows submit immutable requests to operation controllers; services publish structured progress. Existing business services remain intact in this phase. + +**Tech Stack:** Java 17, JavaFX 17, Spring Boot 2.7, JUnit 5, Maven. + +## Global Constraints + +- Preserve all existing command-line plugin behavior. +- Do not add raw `new Thread` calls. +- Do not put Git, SQL, HTTP, process, or file traversal logic in Window classes. +- Use enum state instead of boolean combinations. +- Give unrelated user actions independent operation state. +- Keep production files below 350 lines where practical; do not increase existing Window line counts. +- Do not modify the user's existing checkout; work only in the isolated worktree. + +--- + +### Task 1: Operation State Machine + +**Files:** +- Create: `src/main/java/com/example/workutils/system/ui/async/OperationState.java` +- Create: `src/main/java/com/example/workutils/system/ui/async/OperationSnapshot.java` +- Create: `src/main/java/com/example/workutils/system/ui/async/OperationStateMachine.java` +- Test: `src/test/java/com/example/workutils/system/ui/async/OperationStateMachineTest.java` + +**Interfaces:** +- Produces: `OperationState`, immutable `OperationSnapshot`, and `OperationStateMachine`. +- `OperationStateMachine.start(String)`, `progress(String,double)`, `requestCancel()`, `succeed(String)`, `fail(String)`, and `reset()`. +- Invalid transitions throw `IllegalStateException`. + +- [ ] **Step 1: Write failing state-transition tests** + +```java +@Test +void runningOperationCanProgressAndSucceed() { + OperationStateMachine machine = new OperationStateMachine(); + machine.start("扫描中"); + machine.progress("项目 1/3", 1.0 / 3.0); + machine.succeed("扫描完成"); + + assertEquals(OperationState.SUCCEEDED, machine.snapshot().state()); + assertEquals("扫描完成", machine.snapshot().message()); +} + +@Test +void idleOperationCannotBeCancelled() { + OperationStateMachine machine = new OperationStateMachine(); + assertThrows(IllegalStateException.class, machine::requestCancel); +} +``` + +- [ ] **Step 2: Run tests and verify RED** + +Run: `.\mvnw.cmd -q -Dtest=OperationStateMachineTest test` + +Expected: compilation failure because the production types do not exist. + +- [ ] **Step 3: Implement the minimal state machine** + +Implement explicit legal transitions: + +```text +IDLE -> RUNNING +RUNNING -> CANCELLING | SUCCEEDED | FAILED +CANCELLING -> SUCCEEDED | FAILED +SUCCEEDED | FAILED -> IDLE +``` + +- [ ] **Step 4: Run tests and verify GREEN** + +Run: `.\mvnw.cmd -q -Dtest=OperationStateMachineTest test` + +Expected: all tests pass. + +### Task 2: Asynchronous Operation Controller + +**Files:** +- Create: `src/main/java/com/example/workutils/system/ui/async/OperationTask.java` +- Create: `src/main/java/com/example/workutils/system/ui/async/OperationContext.java` +- Create: `src/main/java/com/example/workutils/system/ui/async/AsyncOperationController.java` +- Create: `src/main/java/com/example/workutils/system/support/AppExecutors.java` +- Test: `src/test/java/com/example/workutils/system/ui/async/AsyncOperationControllerTest.java` + +**Interfaces:** +- Consumes: `OperationStateMachine`. +- Produces: one controller with `start(OperationTask, Consumer)`, `cancel()`, `snapshotProperty()`, and `runningProperty()`. +- `OperationContext` supplies `report(message, progress)` and `isCancellationRequested()`. + +- [ ] **Step 1: Write failing success, failure, and cancellation tests** + +Use a single-thread test executor and `CountDownLatch`. Assert observable controller state, not mock calls. + +- [ ] **Step 2: Run tests and verify RED** + +Run: `.\mvnw.cmd -q -Dtest=AsyncOperationControllerTest test` + +Expected: compilation failure because the controller does not exist. + +- [ ] **Step 3: Implement the minimal controller** + +Use the injected `ExecutorService`; marshal observable property changes through an injected `Consumer` so tests can use `Runnable::run` and production can use `Platform::runLater`. + +- [ ] **Step 4: Run tests and verify GREEN** + +Run: `.\mvnw.cmd -q -Dtest=AsyncOperationControllerTest test` + +Expected: success, failure, and cancellation tests pass. + +### Task 3: Loading and Status Components + +**Files:** +- Create: `src/main/java/com/example/workutils/system/ui/async/LoadingButtonBinder.java` +- Create: `src/main/java/com/example/workutils/system/ui/async/OperationStatusPane.java` +- Modify: `src/main/resources/css/light-theme.css` +- Test: `src/test/java/com/example/workutils/system/ui/async/LoadingButtonTextTest.java` + +**Interfaces:** +- Consumes: `AsyncOperationController`. +- Produces: consistent primary-button loading text, conditional cancel button, progress indicator, status message, and error/success style classes. + +- [ ] **Step 1: Write a failing pure text-mapping test** + +Test `LoadingButtonBinder.textFor(snapshot, idleText)` with literal expectations: + +```java +assertEquals("扫描中…", textFor(running("扫描中"), "开始扫描")); +assertEquals("正在停止…", textFor(cancelling(), "开始扫描")); +assertEquals("开始扫描", textFor(idle(), "开始扫描")); +``` + +- [ ] **Step 2: Run tests and verify RED** + +Run: `.\mvnw.cmd -q -Dtest=LoadingButtonTextTest test` + +- [ ] **Step 3: Implement mapper, binder, and status pane** + +Use a real JavaFX `ProgressIndicator` only in the binder. Keep text selection pure and independently testable. + +- [ ] **Step 4: Run tests and compile** + +Run: `.\mvnw.cmd -q -Dtest=LoadingButtonTextTest test` + +Run: `.\mvnw.cmd -q -DskipTests compile` + +### Task 4: Scanner Migration + +**Files:** +- Create: `src/main/java/com/example/workutils/biz/scanner/application/ScanRequest.java` +- Create: `src/main/java/com/example/workutils/biz/scanner/application/ScanProgress.java` +- Create: `src/main/java/com/example/workutils/biz/scanner/application/RunScanUseCase.java` +- Modify: `src/main/java/com/example/workutils/biz/scanner/ScannerWindow.java` +- Test: `src/test/java/com/example/workutils/biz/scanner/application/ScanRequestTest.java` + +**Interfaces:** +- Consumes: `ScannerService`, `AsyncOperationController`. +- Produces: immutable validated `ScanRequest` and structured `ScanProgress`. + +- [ ] **Step 1: Write failing ScanRequest validation tests** + +Test blank keyword rejection, invalid regular expression rejection, and trimmed keyword preservation. + +- [ ] **Step 2: Run tests and verify RED** + +Run: `.\mvnw.cmd -q -Dtest=ScanRequestTest test` + +- [ ] **Step 3: Implement request and use case** + +The use case adapts existing `ScannerService` callbacks/results without changing scan behavior. + +- [ ] **Step 4: Migrate ScannerWindow** + +Replace raw thread creation and manual `scanBtn.setDisable` with `AsyncOperationController`. Add `OperationStatusPane`; display the cancel button only while running. Keep history behavior unchanged. + +- [ ] **Step 5: Run focused and full tests** + +Run: `.\mvnw.cmd -q -Dtest=ScanRequestTest test` + +Run: `.\mvnw.cmd -q test` + +### Task 5: MyBatis SQL Export Migration + +**Files:** +- Create: `src/main/java/com/example/workutils/biz/mybatisexport/application/SqlExportRequest.java` +- Create: `src/main/java/com/example/workutils/biz/mybatisexport/application/SqlExportProgress.java` +- Create: `src/main/java/com/example/workutils/biz/mybatisexport/application/RunSqlExportUseCase.java` +- Modify: `src/main/java/com/example/workutils/biz/mybatisexport/MyBatisExportWindow.java` +- Test: `src/test/java/com/example/workutils/biz/mybatisexport/application/SqlExportRequestTest.java` + +**Interfaces:** +- Consumes: `MyBatisExportService`, `AsyncOperationController`. +- Produces: immutable validated request and structured progress. + +- [ ] **Step 1: Write failing filter validation tests** + +Test that ALL requires no value, TABLE requires a nonblank table, and STATEMENT requires a nonblank statement id. + +- [ ] **Step 2: Run tests and verify RED** + +Run: `.\mvnw.cmd -q -Dtest=SqlExportRequestTest test` + +- [ ] **Step 3: Implement request and use case** + +Adapt current export service without changing CSV format or history persistence. + +- [ ] **Step 4: Migrate MyBatisExportWindow** + +Replace raw thread and manual button toggles with the shared controller. Show progress, current project, and conditional cancel action. + +- [ ] **Step 5: Run focused and full tests** + +Run: `.\mvnw.cmd -q -Dtest=SqlExportRequestTest test` + +Run: `.\mvnw.cmd -q test` + +### Task 6: Phase Verification + +**Files:** +- Modify: `docs/audit/plugin-ui-code-audit.md` only if implementation differs from the approved design. + +**Interfaces:** +- Consumes: all previous tasks. +- Produces: a compiling, tested first phase ready for UI inspection. + +- [ ] **Step 1: Run all tests** + +Run: `.\mvnw.cmd -q test` + +- [ ] **Step 2: Compile production code** + +Run: `.\mvnw.cmd -q -DskipTests compile` + +- [ ] **Step 3: Inspect changes** + +Run: `git diff --check` + +Run: `git status --short` + +- [ ] **Step 4: Launch and visually verify** + +Verify Scanner and MyBatis SQL Export in IDLE, RUNNING, CANCELLING, FAILED, and SUCCEEDED states without triggering destructive operations. diff --git a/docs/superpowers/plans/2026-07-30-shared-result-ui-phase-2.md b/docs/superpowers/plans/2026-07-30-shared-result-ui-phase-2.md new file mode 100644 index 0000000..c8b43e3 --- /dev/null +++ b/docs/superpowers/plans/2026-07-30-shared-result-ui-phase-2.md @@ -0,0 +1,108 @@ +# Shared Result UI Phase 2 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Remove duplicated export-history and empty-result UI code from Scanner and MyBatis SQL Export while preserving their behavior. + +**Architecture:** Add a small immutable adapter model between plugin-specific history rows and a reusable JavaFX history pane. Keep file-opening side effects behind injected callbacks so the shared pane only renders data and emits commands. Add a reusable empty-state component for later plugin migrations. + +**Tech Stack:** Java 17, JavaFX, JUnit 5, Maven. + +## Global Constraints + +- Work only in `C:\tmp\workutils-plugin-ui-refactor` on branch `codex/plugin-ui-refactor`. +- Do not add raw `new Thread`. +- Shared UI components must not read files or launch external programs. +- Plugin-specific persistence formats must remain unchanged. +- Write and observe a failing test before each production behavior. + +--- + +### Task 1: Immutable history presentation model + +**Files:** +- Create: `src/main/java/com/example/workutils/system/ui/history/HistoryItem.java` +- Test: `src/test/java/com/example/workutils/system/ui/history/HistoryItemTest.java` + +**Interfaces:** +- Produces: `HistoryItem(String index, String query, String filePath, String count, String timestamp)`. +- Produces: `fileDisplay(int maxLength)` for bounded single-line display. + +- [ ] Write tests proving short paths remain unchanged, long paths are ellipsized while retaining the filename, and constructor values cannot be null. +- [ ] Run `.\mvnw.cmd -q -Dtest=HistoryItemTest test` and observe compilation failure because `HistoryItem` is absent. +- [ ] Implement the immutable record and its display method. +- [ ] Run the focused test and confirm it passes. + +### Task 2: Reusable empty state + +**Files:** +- Create: `src/main/java/com/example/workutils/system/ui/EmptyStatePane.java` +- Modify: `src/main/resources/css/light-theme.css` +- Test: `src/test/java/com/example/workutils/system/ui/EmptyStateContentTest.java` + +**Interfaces:** +- Produces: `EmptyStatePane.Content(String title, String description, String actionLabel)`. +- Produces: `EmptyStatePane(Content content, Runnable action, int fontSize)`. + +- [ ] Write pure content-validation tests proving title is required and a blank action label is allowed only when no action is rendered. +- [ ] Run the focused test and observe failure because the component is absent. +- [ ] Implement the content model, pane, optional action button, and theme classes. +- [ ] Run the focused test and confirm it passes. + +### Task 3: Reusable export history pane + +**Files:** +- Create: `src/main/java/com/example/workutils/system/ui/history/HistoryFileCommands.java` +- Create: `src/main/java/com/example/workutils/system/ui/history/ExportHistoryPane.java` +- Test: `src/test/java/com/example/workutils/system/ui/history/HistoryFileCommandsTest.java` + +**Interfaces:** +- Consumes: `ObservableList`. +- Consumes: `HistoryFileCommands` callbacks for open file, open editor, open folder, and copy path. +- Produces: `ExportHistoryPane(String countColumnTitle, ObservableList items, HistoryFileCommands commands, int fontSize)`. + +- [ ] Write tests proving `HistoryFileCommands` rejects missing callbacks and routes each command to the supplied consumer. +- [ ] Run the focused test and observe failure because the type is absent. +- [ ] Implement callback validation and the shared table, action cell, tooltip, context menu, and empty-state placeholder. +- [ ] Run the focused test and confirm it passes. + +### Task 4: Migrate Scanner + +**Files:** +- Modify: `src/main/java/com/example/workutils/biz/scanner/ScannerWindow.java` +- Test: `src/test/java/com/example/workutils/biz/scanner/ScannerHistoryMappingTest.java` + +**Interfaces:** +- Consumes: `HistoryItem`, `ExportHistoryPane`, `HistoryFileCommands`. +- Produces: package-visible `toHistoryItem(HistoryRow row)` for deterministic presentation mapping. + +- [ ] Write a mapping test for index, query, path, match count, and timestamp. +- [ ] Run the focused test and observe failure because the mapping method is absent. +- [ ] Replace duplicated table/action-cell construction with `ExportHistoryPane`. +- [ ] Run scanner tests and confirm they pass. + +### Task 5: Migrate MyBatis SQL Export + +**Files:** +- Modify: `src/main/java/com/example/workutils/biz/mybatisexport/MyBatisExportWindow.java` +- Test: `src/test/java/com/example/workutils/biz/mybatisexport/MyBatisExportHistoryMappingTest.java` + +**Interfaces:** +- Consumes: `HistoryItem`, `ExportHistoryPane`, `HistoryFileCommands`. +- Produces: package-visible `toHistoryItem(HistoryRow row)`. + +- [ ] Write a mapping test for index, query, path, export count, and timestamp. +- [ ] Run the focused test and observe failure because the mapping method is absent. +- [ ] Replace duplicated table/action-cell construction with `ExportHistoryPane`. +- [ ] Run export tests and confirm they pass. + +### Task 6: Verification + +**Files:** +- Modify only files required by failures found during verification. + +- [ ] Run `.\mvnw.cmd -q test`. +- [ ] Run `.\mvnw.cmd -q -DskipTests compile`. +- [ ] Run `git diff --check`. +- [ ] Confirm Scanner and MyBatis SQL Export no longer declare `buildActionCell`. +- [ ] Inspect both pages without triggering scan, export, file-open, or delete side effects. 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 ed08f37..067d1bb 100644 --- a/src/main/java/com/example/workutils/biz/apidoc/ApiDocWindow.java +++ b/src/main/java/com/example/workutils/biz/apidoc/ApiDocWindow.java @@ -1,7 +1,10 @@ package com.example.workutils.biz.apidoc; +import com.example.workutils.biz.apidoc.application.ApiDocExportRequest; +import com.example.workutils.biz.apidoc.application.RunApiDocExportUseCase; import com.example.workutils.biz.apidoc.config.ApiBaseClassManager; import com.example.workutils.biz.apidoc.generator.ApiDocGenerator; +import com.example.workutils.biz.apidoc.generator.DubboDebugProxy; import com.example.workutils.biz.apidoc.model.ApiDocModel; import com.example.workutils.biz.apidoc.parser.JavaSourceParser; import com.example.workutils.system.config.WorkUtilsProperties; @@ -10,6 +13,14 @@ 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; +import com.example.workutils.system.ui.async.AsyncOperationController; +import com.example.workutils.system.ui.async.LoadingButtonBinder; +import com.example.workutils.system.ui.async.OperationContext; +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 javafx.application.Platform; import javafx.collections.FXCollections; import javafx.collections.ObservableList; @@ -17,7 +28,6 @@ import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.Scene; import javafx.scene.control.*; -import javafx.scene.control.cell.PropertyValueFactory; import javafx.scene.layout.*; import javafx.scene.Parent; import javafx.stage.DirectoryChooser; @@ -46,6 +56,8 @@ public class ApiDocWindow { private final WorkUtilsProperties properties; private final FileOperationHelper fileOperationHelper; private final ApiBaseClassManager apiBaseClassManager; + private final RunApiDocExportUseCase exportUseCase; + private final AsyncOperationController exportOperation; // 模式选择 private ToggleGroup modeGroup; @@ -67,13 +79,12 @@ public class ApiDocWindow { // 操作区 private Button exportBtn; - private ProgressBar progressBar; - private Label progressLabel; + private CheckBox debugProxyCb; private TextArea logArea; // 历史记录 - private TableView historyTable; private final ObservableList historyRows = FXCollections.observableArrayList(); + private final ObservableList historyItems = FXCollections.observableArrayList(); private int historyCounter = 0; private final HistoryPersistence historyPersistence; private static final SimpleDateFormat SDF = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); @@ -85,7 +96,16 @@ public class ApiDocWindow { this.properties = properties; this.closeLatch = closeLatch; this.apiBaseClassManager = apiBaseClassManager; + this.exportUseCase = new RunApiDocExportUseCase( + () -> DubboDebugProxy.getInstance().getInvokeUrl(), + this::executeExport); + this.exportOperation = new AsyncOperationController<>( + AppExecutors.io(), Platform::runLater); this.historyPersistence = new HistoryPersistence("apidoc_history.txt"); + historyRows.addListener( + (javafx.collections.ListChangeListener) change -> + historyItems.setAll(historyRows.stream() + .map(ApiDocWindow::toHistoryItem).toList())); this.stage = new Stage(); buildUI(); loadProjects(); @@ -220,56 +240,30 @@ public class ApiDocWindow { actionRow.setAlignment(Pos.CENTER_LEFT); exportBtn = StyledButton.primary("导出文档", Theme.bold(fsBtn())); exportBtn.setOnAction(e -> startExport()); + LoadingButtonBinder.bind(exportBtn, exportOperation, "导出文档"); + debugProxyCb = new CheckBox("启用接口调试(启动本地代理)"); + debugProxyCb.setSelected(false); + debugProxyCb.setFont(Theme.base(fsBody() - 1)); + debugProxyCb.setTooltip(new Tooltip( + "仅生成支持“发送调试请求”的文档时启用;普通导出无需启动代理。")); + Region actionSpacer = new Region(); + HBox.setHgrow(actionSpacer, Priority.ALWAYS); + actionRow.getChildren().addAll(debugProxyCb, actionSpacer, exportBtn); + OperationStatusPane operationStatus = new OperationStatusPane( + exportOperation, fsBody() - 1, false); - progressBar = new ProgressBar(0); - progressBar.setPrefWidth(280); - progressBar.setPrefHeight(16); - progressBar.setVisible(false); - - progressLabel = new Label(""); - progressLabel.setFont(Theme.base(fsBody() - 2)); - progressLabel.setTextFill(Theme.TEXT_SECONDARY); - - actionRow.getChildren().addAll(exportBtn, progressBar, progressLabel); - - configCard.getChildren().addAll(modeLabel, modeRow, inputStack, actionRow); + configCard.getChildren().addAll( + modeLabel, modeRow, inputStack, actionRow, operationStatus); // ===================== 历史记录 ===================== Label histTitle = new Label("导出历史"); histTitle.setFont(Theme.heading(fsSection() + 1)); histTitle.setTextFill(Theme.TEXT_PRIMARY); - historyTable = new TableView<>(); - historyTable.setItems(historyRows); - historyTable.setColumnResizePolicy(TableView.UNCONSTRAINED_RESIZE_POLICY); - historyTable.setPlaceholder(new Label("暂无导出记录")); - historyTable.setPrefHeight(180); - historyTable.setStyle("-fx-background-color: white;" - + "-fx-border-color: " + Theme.toWeb(Theme.BORDER) + ";" - + "-fx-border-width: 1; -fx-border-radius: " + Theme.RADIUS_SM + ";"); - - TableColumn colIdx = new TableColumn<>("序号"); - colIdx.setCellValueFactory(new PropertyValueFactory<>("index")); - colIdx.setPrefWidth(50); - - TableColumn colMode = new TableColumn<>("导出格式"); - colMode.setCellValueFactory(new PropertyValueFactory<>("mode")); - colMode.setPrefWidth(90); - - TableColumn colFile = new TableColumn<>("导出文件"); - colFile.setCellValueFactory(new PropertyValueFactory<>("fileDisplay")); - - TableColumn colTime = new TableColumn<>("时间"); - colTime.setCellValueFactory(new PropertyValueFactory<>("timestamp")); - colTime.setPrefWidth(140); - - TableColumn colAction = new TableColumn<>("操作"); - colAction.setCellValueFactory(new PropertyValueFactory<>("actionLabel")); - colAction.setCellFactory(col -> buildActionCell()); - colAction.setPrefWidth(170); - colAction.setResizable(false); - - historyTable.getColumns().addAll(colAction, colIdx, colMode, colFile, colTime); + ExportHistoryPane historyPane = new ExportHistoryPane( + "导出格式", "文件数", historyItems, + historyFileCommands(), fsBody()); + historyPane.setPrefHeight(210); // ===================== 日志 ===================== logArea = new TextArea(); @@ -279,7 +273,7 @@ public class ApiDocWindow { logArea.setPrefRowCount(5); VBox.setVgrow(logArea, Priority.ALWAYS); - root.getChildren().addAll(configCard, histTitle, historyTable, logArea); + root.getChildren().addAll(configCard, histTitle, historyPane, logArea); scrollRoot.setContent(root); stage.setScene(new Scene(scrollRoot)); } @@ -313,48 +307,74 @@ public class ApiDocWindow { } private void startExport() { - new Thread(() -> { - try { - if (rbCustomPath.isSelected()) { - String path = pathField.getText().trim(); - if (path.isEmpty()) { - log("请输入路径"); - return; - } - Path tp = Paths.get(path); - JavaSourceParser parser = new JavaSourceParser(apiBaseClassManager); - if (parser.isJavaFile(tp)) { - exportSingle(path, tp, parser); - } else if (parser.isDirectory(tp)) { - exportDir(path, tp, parser); - } else { - log("路径不存在或无效: " + path); - } - } else if (rbFqn.isSelected()) { - exportByFqn(); - } else { - exportBatch(); - } - } catch (Exception e) { - Platform.runLater(() -> { - progressBar.setVisible(false); - log("[ERROR] " + e.getMessage()); - exportBtn.setDisable(false); - }); + try { + ApiDocExportRequest request = buildRequest(); + exportOperation.start( + "正在解析源码", + context -> exportUseCase.execute(request, context), + ignored -> log("导出任务完成"), + error -> log("[ERROR] " + error.getMessage())); + } catch (IllegalArgumentException error) { + log("[WARN] " + error.getMessage()); + } catch (IOException error) { + log("[ERROR] 无法读取项目配置: " + error.getMessage()); + } + } + + private ApiDocExportRequest buildRequest() throws IOException { + boolean debugEnabled = debugProxyCb.isSelected(); + if (rbCustomPath.isSelected()) { + return ApiDocExportRequest.path(pathField.getText(), debugEnabled); + } + if (rbFqn.isSelected()) { + return ApiDocExportRequest.fqn(fqnField.getText(), debugEnabled); + } + List selectedPaths = allProjectsCb.isSelected() + ? fileOperationHelper.getProjectPaths() + : projectCmb.getValue() == null + ? List.of() + : List.of(projectCmb.getValue()); + return ApiDocExportRequest.batch( + selectedPaths, + onlyInterfaceCb.isSelected(), + facadeNameCb.isSelected(), + apiPackageOnlyCb.isSelected(), + debugEnabled); + } + + private void executeExport( + ApiDocExportRequest request, + String debugEndpoint, + OperationContext context) throws Exception { + context.report("正在解析源码", -1); + if (request.mode() == ApiDocExportRequest.Mode.PATH) { + Path path = Paths.get(request.input()); + JavaSourceParser parser = new JavaSourceParser(apiBaseClassManager); + if (parser.isJavaFile(path)) { + exportSingle(request.input(), path, parser, debugEndpoint); + } else if (parser.isDirectory(path)) { + exportDir(request.input(), path, parser, debugEndpoint); + } else { + throw new IllegalArgumentException( + "路径不存在或无效: " + request.input()); } - }, "ApiDoc-Worker").start(); + } else if (request.mode() == ApiDocExportRequest.Mode.FQN) { + exportByFqn(request.input(), debugEndpoint); + } else { + exportBatch(request, debugEndpoint); + } } // ==================== 单文件导出 ==================== - private void exportSingle(String inputPath, Path filePath, JavaSourceParser parser) throws Exception { + private void exportSingle(String inputPath, Path filePath, + JavaSourceParser parser, String debugEndpoint) throws Exception { String projName = extractProjName(inputPath); log("文件: " + filePath.getFileName() + " 项目: " + projName); ApiDocModel.ClassInfo ci = parser.parseFile(filePath, projName, true); if (ci == null || ci.methods == null || ci.methods.isEmpty()) { log("[WARN] 未找到有效的类/接口或公开方法"); - enableBtn(); return; } log("解析: " + ci.type + " " + ci.className + ", " + ci.methods.size() + " 个方法"); @@ -371,12 +391,13 @@ public class ApiDocWindow { doc.projectName = projName; doc.projectPath = inputPath; doc.classes = Collections.singletonList(ci); - doExport(projName, Collections.singletonList(doc)); + doExport(projName, Collections.singletonList(doc), debugEndpoint); } // ==================== 目录导出 ==================== - private void exportDir(String inputPath, Path dirPath, JavaSourceParser parser) throws Exception { + private void exportDir(String inputPath, Path dirPath, + JavaSourceParser parser, String debugEndpoint) throws Exception { String projName = extractProjName(inputPath); log("目录: " + dirPath + " 项目: " + projName); @@ -399,14 +420,12 @@ public class ApiDocWindow { doc.projectName = projName; doc.projectPath = inputPath; doc.classes = classes; - doExport(projName, Collections.singletonList(doc)); + doExport(projName, Collections.singletonList(doc), debugEndpoint); } // ==================== 全限定名导出 ==================== - private void exportByFqn() throws Exception { - String fqn = fqnField.getText().trim(); - if (fqn.isEmpty()) { log("请输入全限定类名"); return; } + private void exportByFqn(String fqn, String debugEndpoint) throws Exception { List projectPaths = fileOperationHelper.getProjectPaths(); if (projectPaths.isEmpty()) { log("[WARN] 未配置项目路径"); return; } @@ -448,7 +467,6 @@ public class ApiDocWindow { ApiDocModel.ClassInfo ci = parser.parseFile(foundFile, foundProjName, true); if (ci == null || ci.methods == null || ci.methods.isEmpty()) { log("[WARN] 未找到有效方法"); - enableBtn(); return; } log("解析: " + ci.className + ", " + ci.methods.size() + " 个方法"); @@ -464,24 +482,17 @@ public class ApiDocWindow { doc.projectName = foundProjName; doc.projectPath = foundFile.toString(); doc.classes = Collections.singletonList(ci); - doExport(foundProjName, Collections.singletonList(doc)); + doExport(foundProjName, Collections.singletonList(doc), debugEndpoint); } // ==================== 批量导出 ==================== - private void exportBatch() throws Exception { - List selectedPaths = new ArrayList<>(); - if (allProjectsCb.isSelected()) { - selectedPaths.addAll(fileOperationHelper.getProjectPaths()); - } else { - String sel = projectCmb.getValue(); - if (sel == null || sel.isEmpty()) { log("请选择项目"); return; } - selectedPaths.add(sel); - } - - boolean onlyInterface = onlyInterfaceCb.isSelected(); - boolean apiPackageOnly = apiPackageOnlyCb.isSelected(); - boolean facadeName = facadeNameCb.isSelected(); + private void exportBatch(ApiDocExportRequest request, + String debugEndpoint) throws Exception { + List selectedPaths = request.projectPaths(); + boolean onlyInterface = request.onlyInterfaces(); + boolean apiPackageOnly = request.apiPackagesOnly(); + boolean facadeName = request.facadeNamesOnly(); log("开始批量导出,共 " + selectedPaths.size() + " 个项目" + (onlyInterface ? ",仅接口" : "") + (facadeName ? ",Facade结尾" : "") @@ -552,13 +563,14 @@ public class ApiDocWindow { } log("总计: " + allDocs.size() + " 个模块, " + totalClasses + " 个类"); - doExport("批量导出", allDocs); + doExport("批量导出", allDocs, debugEndpoint); } // ==================== 导出输出 ==================== - private void doExport(String name, List docs) throws Exception { - ApiDocGenerator gen = new ApiDocGenerator(properties); + private void doExport(String name, List docs, + String debugEndpoint) throws Exception { + ApiDocGenerator gen = new ApiDocGenerator(properties, () -> debugEndpoint); ApiDocModel.ExportSelection sel = new ApiDocModel.ExportSelection(); sel.scope = ApiDocModel.ExportScope.CUSTOM; sel.projectName = name; @@ -568,7 +580,6 @@ public class ApiDocWindow { Platform.runLater(() -> { log("HTML 已导出: " + filePath); addHistory("HTML", filePath); - enableBtn(); }); } @@ -584,13 +595,6 @@ public class ApiDocWindow { Platform.runLater(() -> logArea.appendText(msg + "\n")); } - private void enableBtn() { - Platform.runLater(() -> { - progressBar.setVisible(false); - exportBtn.setDisable(false); - }); - } - // ==================== 历史持久化 ==================== private void loadHistory() { @@ -619,72 +623,18 @@ public class ApiDocWindow { historyPersistence.save(lines); } - // ==================== 操作列 ==================== - - private TableCell buildActionCell() { - TableCell cell = new TableCell() { - private final Button openBtn = new Button("打开"); - private final Button dirBtn = new Button("目录"); - private final HBox btnBox = new HBox(4, openBtn, dirBtn); - { setAlignment(Pos.CENTER); btnBox.setAlignment(Pos.CENTER); } - private final ContextMenu menu = new ContextMenu(); - private String currentFileName; - - { - openBtn.setFont(Theme.base(fsBody() - 2)); - openBtn.setStyle("-fx-background-color: " + Theme.toWeb(Theme.PRIMARY) + ";" - + "-fx-text-fill: white; -fx-cursor: hand;" - + "-fx-background-radius: " + Theme.RADIUS_SM + "; -fx-padding: 3 8;"); - - dirBtn.setFont(Theme.base(fsBody() - 2)); - dirBtn.setStyle("-fx-background-color: " + Theme.toWeb(Theme.BORDER) + ";" - + "-fx-text-fill: " + Theme.toWeb(Theme.TEXT_PRIMARY) + "; -fx-cursor: hand;" - + "-fx-background-radius: " + Theme.RADIUS_SM + "; -fx-padding: 3 6;"); - dirBtn.setTooltip(new Tooltip("打开文件所在目录")); - - MenuItem defaultOpen = new MenuItem("系统默认程序打开"); - defaultOpen.setOnAction(e -> openFile(currentFileName)); - MenuItem openNotepad = new MenuItem("用系统编辑器打开"); - openNotepad.setOnAction(e -> openWithNotepad(currentFileName)); - MenuItem openFolder = new MenuItem("打开所在文件夹"); - openFolder.setOnAction(e -> openFolder(currentFileName)); - MenuItem copyPath = new MenuItem("复制文件路径"); - copyPath.setOnAction(e -> copyFilePath(currentFileName)); - menu.getItems().addAll(defaultOpen, openNotepad, openFolder, copyPath); - - openBtn.setOnAction(e -> openFile(currentFileName)); - dirBtn.setOnAction(e -> openFolder(currentFileName)); - btnBox.setOnContextMenuRequested(e -> { - menu.show(btnBox, e.getScreenX(), e.getScreenY()); + 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); }); - } - - @Override - protected void updateItem(String item, boolean empty) { - super.updateItem(item, empty); - if (empty) { - setGraphic(null); - currentFileName = null; - } else { - int idx = getIndex(); - if (idx >= 0 && getTableView() != null) { - HistoryRow row = getTableView().getItems().get(idx); - currentFileName = row != null ? row.getFileName() : null; - } - setGraphic(btnBox); - } - } - - 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; } // ==================== 文件打开工具方法 ==================== @@ -720,6 +670,15 @@ public class ApiDocWindow { // ==================== 数据行 ==================== + static HistoryItem toHistoryItem(HistoryRow row) { + return new HistoryItem( + row.getIndex(), + row.getMode(), + row.getFileName(), + "1", + row.getTimestamp()); + } + public static class HistoryRow { private final String index; private final String mode; diff --git a/src/main/java/com/example/workutils/biz/apidoc/application/ApiDocExportRequest.java b/src/main/java/com/example/workutils/biz/apidoc/application/ApiDocExportRequest.java new file mode 100644 index 0000000..04c5f42 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/apidoc/application/ApiDocExportRequest.java @@ -0,0 +1,58 @@ +package com.example.workutils.biz.apidoc.application; + +import java.util.List; +import java.util.Objects; + +public record ApiDocExportRequest( + Mode mode, + String input, + List projectPaths, + boolean onlyInterfaces, + boolean facadeNamesOnly, + boolean apiPackagesOnly, + boolean debugProxyEnabled) { + + public enum Mode { + PATH, FQN, BATCH + } + + public ApiDocExportRequest { + Objects.requireNonNull(mode, "mode"); + input = Objects.requireNonNull(input, "input").trim(); + projectPaths = List.copyOf( + Objects.requireNonNull(projectPaths, "projectPaths")); + if (mode != Mode.BATCH && input.isEmpty()) { + throw new IllegalArgumentException("input must not be blank"); + } + if (mode == Mode.BATCH && projectPaths.isEmpty()) { + throw new IllegalArgumentException( + "at least one project path is required"); + } + } + + public static ApiDocExportRequest path( + String path, boolean debugProxyEnabled) { + return new ApiDocExportRequest( + Mode.PATH, path, List.of(), + false, false, false, debugProxyEnabled); + } + + public static ApiDocExportRequest fqn( + String className, boolean debugProxyEnabled) { + return new ApiDocExportRequest( + Mode.FQN, className, List.of(), + false, false, false, debugProxyEnabled); + } + + public static ApiDocExportRequest batch( + List projectPaths, + boolean onlyInterfaces, + boolean facadeNamesOnly, + boolean apiPackagesOnly, + boolean debugProxyEnabled) { + return new ApiDocExportRequest( + Mode.BATCH, "", projectPaths, + onlyInterfaces, facadeNamesOnly, apiPackagesOnly, + debugProxyEnabled); + } +} diff --git a/src/main/java/com/example/workutils/biz/apidoc/application/RunApiDocExportUseCase.java b/src/main/java/com/example/workutils/biz/apidoc/application/RunApiDocExportUseCase.java new file mode 100644 index 0000000..0694ccc --- /dev/null +++ b/src/main/java/com/example/workutils/biz/apidoc/application/RunApiDocExportUseCase.java @@ -0,0 +1,42 @@ +package com.example.workutils.biz.apidoc.application; + +import com.example.workutils.system.ui.async.OperationContext; + +import java.util.Objects; +import java.util.function.Supplier; + +public final class RunApiDocExportUseCase { + + @FunctionalInterface + public interface Exporter { + void export( + ApiDocExportRequest request, + String debugEndpoint, + OperationContext context) throws Exception; + } + + public record Result(boolean completed) {} + + private final Supplier debugEndpointSupplier; + private final Exporter exporter; + + public RunApiDocExportUseCase( + Supplier debugEndpointSupplier, + Exporter exporter) { + this.debugEndpointSupplier = Objects.requireNonNull( + debugEndpointSupplier, "debugEndpointSupplier"); + this.exporter = Objects.requireNonNull(exporter, "exporter"); + } + + public Result execute( + ApiDocExportRequest request, + OperationContext context) throws Exception { + Objects.requireNonNull(request, "request"); + Objects.requireNonNull(context, "context"); + String endpoint = request.debugProxyEnabled() + ? Objects.requireNonNullElse(debugEndpointSupplier.get(), "") + : ""; + exporter.export(request, endpoint, context); + return new Result(true); + } +} diff --git a/src/main/java/com/example/workutils/biz/apidoc/generator/ApiDocGenerator.java b/src/main/java/com/example/workutils/biz/apidoc/generator/ApiDocGenerator.java index 0df3231..6be6538 100644 --- a/src/main/java/com/example/workutils/biz/apidoc/generator/ApiDocGenerator.java +++ b/src/main/java/com/example/workutils/biz/apidoc/generator/ApiDocGenerator.java @@ -17,6 +17,8 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.Objects; +import java.util.function.Supplier; /** * API 文档生成器 —— 输出 HTML / Markdown 格式 @@ -26,11 +28,19 @@ public class ApiDocGenerator { private final WorkUtilsProperties properties; private final HtmlResourceLoader resourceLoader; + private final Supplier debugEndpointSupplier; private static final DateTimeFormatter TS = DateTimeFormatter.ofPattern("yyyyMMddHHmmss"); public ApiDocGenerator(WorkUtilsProperties properties) { + this(properties, () -> ""); + } + + public ApiDocGenerator(WorkUtilsProperties properties, + Supplier debugEndpointSupplier) { this.properties = properties; this.resourceLoader = new HtmlResourceLoader(); + this.debugEndpointSupplier = Objects.requireNonNull( + debugEndpointSupplier, "debugEndpointSupplier"); } /** @@ -61,8 +71,7 @@ public class ApiDocGenerator { String template = resourceLoader.loadTemplate(); - // 懒初始化本地代理(仅第一次触发) - String proxyUrl = DubboDebugProxy.getInstance().getInvokeUrl(); + String proxyUrl = resolveDebugEndpoint(); return template .replace("{{CSS}}", resourceLoader.loadCss()) @@ -76,6 +85,10 @@ public class ApiDocGenerator { .replace("{{CONTENT}}", buildContent(docs)); } + String resolveDebugEndpoint() { + return Objects.requireNonNullElse(debugEndpointSupplier.get(), ""); + } + // ---------- 统计栏 ---------- private String buildStatsBar(int moduleCount, int classCount, int methodCount) { diff --git a/src/main/java/com/example/workutils/biz/diff/DiffPlugin.java b/src/main/java/com/example/workutils/biz/diff/DiffPlugin.java index 64d22da..36e638f 100644 --- a/src/main/java/com/example/workutils/biz/diff/DiffPlugin.java +++ b/src/main/java/com/example/workutils/biz/diff/DiffPlugin.java @@ -11,7 +11,6 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import java.nio.file.Path; -import java.util.*; import java.util.concurrent.CountDownLatch; /** @@ -85,71 +84,4 @@ public class DiffPlugin implements Plugin { return properties.getTodayExportPath(); } - // ==================== LCS 差异算法 ==================== - - enum DiffType { UNCHANGED, ADDED, REMOVED } - - static class DiffLine { - final DiffType type; - final String content; - DiffLine(DiffType type, String content) { this.type = type; this.content = content; } - } - - static class DiffResult { - final List lines; - final int added, removed, unchanged; - - DiffResult(List lines) { - this.lines = lines; - int a = 0, r = 0, u = 0; - for (DiffLine dl : lines) { - switch (dl.type) { - case ADDED: a++; break; - case REMOVED: r++; break; - default: u++; break; - } - } - this.added = a; - this.removed = r; - this.unchanged = u; - } - - boolean hasDifferences() { return added > 0 || removed > 0; } - } - - /** - * 基于 LCS (Longest Common Subsequence) 计算行级差异 - */ - DiffResult computeDiff(List a, List b) { - int m = a.size(), n = b.size(); - int[][] dp = new int[m + 1][n + 1]; - - for (int i = 1; i <= m; i++) { - for (int j = 1; j <= n; j++) { - if (a.get(i - 1).equals(b.get(j - 1))) { - dp[i][j] = dp[i - 1][j - 1] + 1; - } else { - dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]); - } - } - } - - List result = new ArrayList<>(); - int i = m, j = n; - while (i > 0 || j > 0) { - if (i > 0 && j > 0 && a.get(i - 1).equals(b.get(j - 1))) { - result.add(new DiffLine(DiffType.UNCHANGED, a.get(i - 1))); - i--; j--; - } else if (j > 0 && (i == 0 || dp[i][j - 1] >= dp[i - 1][j])) { - result.add(new DiffLine(DiffType.ADDED, b.get(j - 1))); - j--; - } else { - result.add(new DiffLine(DiffType.REMOVED, a.get(i - 1))); - i--; - } - } - Collections.reverse(result); - return new DiffResult(result); - } - } diff --git a/src/main/java/com/example/workutils/biz/diff/DiffWindow.java b/src/main/java/com/example/workutils/biz/diff/DiffWindow.java index b8b0218..e29efbe 100644 --- a/src/main/java/com/example/workutils/biz/diff/DiffWindow.java +++ b/src/main/java/com/example/workutils/biz/diff/DiffWindow.java @@ -1,8 +1,13 @@ package com.example.workutils.biz.diff; +import com.example.workutils.biz.diff.domain.DiffEngine; import com.example.workutils.system.config.WorkUtilsProperties; +import com.example.workutils.system.support.AppExecutors; import com.example.workutils.system.ui.StyledButton; import com.example.workutils.system.ui.Theme; +import com.example.workutils.system.ui.async.AsyncOperationController; +import com.example.workutils.system.ui.async.LoadingButtonBinder; +import com.example.workutils.system.ui.async.OperationStatusPane; import javafx.application.Platform; import javafx.geometry.Insets; import javafx.geometry.Pos; @@ -49,6 +54,9 @@ public class DiffWindow { private static final double CHAR_SIMILARITY_THRESHOLD = 0.3; private final DiffPlugin plugin; + private final DiffEngine diffEngine = new DiffEngine(); + private final AsyncOperationController compareOperation = + new AsyncOperationController<>(AppExecutors.io(), Platform::runLater); private final Stage stage; private final WorkUtilsProperties properties; private TextArea leftArea, rightArea; @@ -143,10 +151,14 @@ public class DiffWindow { Button swapBtn = StyledButton.secondary("⇄ 交换左右", Theme.base(btnFontSize())); Button clearAllBtn = StyledButton.secondary("清空全部", Theme.base(btnFontSize())); compareBtn.setOnAction(e -> doCompare()); + LoadingButtonBinder.bind(compareBtn, compareOperation, "▶ 开始对比"); swapBtn.setOnAction(e -> swapText()); clearAllBtn.setOnAction(e -> clearAll()); - HBox actionBar = new HBox(Theme.S2, clearAllBtn, compareBtn, swapBtn); + OperationStatusPane operationStatus = new OperationStatusPane( + compareOperation, bodyFontSize() - 1, false); + HBox actionBar = new HBox( + Theme.S2, clearAllBtn, compareBtn, swapBtn, operationStatus); actionBar.setAlignment(Pos.CENTER); actionBar.setPadding(new Insets(Theme.S2, 0, Theme.S2, 0)); @@ -272,20 +284,35 @@ public class DiffWindow { return; } - List lines1 = Arrays.asList(text1.split("\n", -1)); - List lines2 = Arrays.asList(text2.split("\n", -1)); - DiffPlugin.DiffResult lineDiff = plugin.computeDiff(lines1, lines2); + compareOperation.start( + "正在比较文本", + context -> { + List lines1 = Arrays.asList(text1.split("\n", -1)); + List lines2 = Arrays.asList(text2.split("\n", -1)); + DiffEngine.DiffResult lineDiff = + diffEngine.compareLines(lines1, lines2); + context.report("正在生成差异结果", 0.75); + DiffRender render = + buildDiffHtmlWithPlain(lines1, lines2, lineDiff); + return new DiffOutcome(lineDiff, render); + }, + this::applyComparison, + error -> setStatus("对比失败: " + error.getMessage(), true)); + } - // 构建字符级差异 HTML + 纯文本结果 - DiffRender render = buildDiffHtmlWithPlain(lines1, lines2, lineDiff); - lastPlainResult = render.plainText; + private void applyComparison(DiffOutcome outcome) { + DiffRender render = outcome.render(); + DiffEngine.DiffResult lineDiff = outcome.result(); + lastPlainResult = render.plainText(); + diffAnchorIds.clear(); + diffAnchorIds.addAll(render.anchorIds()); currentDiffIdx = -1; lastHighlightId = null; // 重置高亮状态 - webEngine.loadContent(render.html); - setStatus("对比完成 — 新增 " + lineDiff.added - + " | 删除 " + lineDiff.removed - + " | 相同 " + lineDiff.unchanged, false); + webEngine.loadContent(render.html()); + setStatus("对比完成 — 新增 " + lineDiff.added() + + " | 删除 " + lineDiff.removed() + + " | 相同 " + lineDiff.unchanged(), false); // 更新差异计数器 Label counter = (Label) webView.getUserData(); @@ -428,33 +455,47 @@ public class DiffWindow { /** * 单次对比的所有渲染输出 */ - private static class DiffRender { - final String html; - final String plainText; - DiffRender(String html, String plainText) { this.html = html; this.plainText = plainText; } + private record DiffOutcome( + DiffEngine.DiffResult result, + DiffRender render) {} + + private record DiffRender( + String html, + String plainText, + List anchorIds) { + + private DiffRender { + anchorIds = List.copyOf(anchorIds); + } } /** * 构建 HTML(带字符级颜色标记)和纯文本结果 */ private DiffRender buildDiffHtmlWithPlain(List lines1, List lines2, - DiffPlugin.DiffResult lineDiff) { + DiffEngine.DiffResult lineDiff) { // 先把行级结果转为可迭代列表,标记每行的原始索引(行号从 1 开始) List items = new ArrayList<>(); int i1 = 0, i2 = 0; - for (DiffPlugin.DiffLine dl : lineDiff.lines) { - switch (dl.type) { + for (DiffEngine.LineDiff dl : lineDiff.lines()) { + switch (dl.type()) { case UNCHANGED: - items.add(new LineItem(DiffPlugin.DiffType.UNCHANGED, dl.content, i1 + 1, i2 + 1)); + items.add(new LineItem( + DiffEngine.LineType.UNCHANGED, + dl.content(), i1 + 1, i2 + 1)); i1++; i2++; break; case REMOVED: - items.add(new LineItem(DiffPlugin.DiffType.REMOVED, lines1.get(i1), i1 + 1, -1)); + items.add(new LineItem( + DiffEngine.LineType.REMOVED, + lines1.get(i1), i1 + 1, -1)); i1++; break; case ADDED: - items.add(new LineItem(DiffPlugin.DiffType.ADDED, lines2.get(i2), -1, i2 + 1)); + items.add(new LineItem( + DiffEngine.LineType.ADDED, + lines2.get(i2), -1, i2 + 1)); i2++; break; } @@ -499,11 +540,11 @@ public class DiffWindow { // 纯文本结果 StringBuilder plain = new StringBuilder(); // 首行:统计信息 - plain.append("【对比统计】新增 ").append(lineDiff.added) - .append(" 行 | 删除 ").append(lineDiff.removed) - .append(" 行 | 相同 ").append(lineDiff.unchanged).append(" 行\n"); + plain.append("【对比统计】新增 ").append(lineDiff.added()) + .append(" 行 | 删除 ").append(lineDiff.removed()) + .append(" 行 | 相同 ").append(lineDiff.unchanged()).append(" 行\n"); plain.append("────────────────────────────────────────────────────────────\n"); - diffAnchorIds.clear(); // 每次对比重置 + List anchorIds = new ArrayList<>(); int diffSeq = 0; // 差异行序号 // 计算内容列最大长度(用于对齐) @@ -538,7 +579,7 @@ public class DiffWindow { case REMOVED: diffId = "diff-" + (diffSeq++); - diffAnchorIds.add(diffId); + anchorIds.add(diffId); html.append(""); html.append("").append(item.leftLineNo).append(""); html.append("").append(htmlEscape(item.content)).append(""); @@ -550,7 +591,7 @@ public class DiffWindow { case ADDED: diffId = "diff-" + (diffSeq++); - diffAnchorIds.add(diffId); + anchorIds.add(diffId); html.append(""); html.append("").append(item.rightLineNo).append(""); html.append(""); @@ -562,7 +603,7 @@ public class DiffWindow { case MODIFIED: diffId = "diff-" + (diffSeq++); - diffAnchorIds.add(diffId); + anchorIds.add(diffId); CharDiffResult cdr = computeCharDiff(item.content, item.modifiedContent); html.append(""); html.append("").append(item.leftLineNo).append(""); @@ -581,7 +622,8 @@ public class DiffWindow { html.append(""); - return new DiffRender(html.toString(), plain.toString()); + return new DiffRender( + html.toString(), plain.toString(), anchorIds); } /** 扩展的差异类型 */ @@ -595,7 +637,8 @@ public class DiffWindow { final int leftLineNo; // 原文行号(1-based),无则 -1 final int rightLineNo; // 对比文行号(1-based),无则 -1 - LineItem(DiffPlugin.DiffType type, String content, int leftLineNo, int rightLineNo) { + LineItem(DiffEngine.LineType type, String content, + int leftLineNo, int rightLineNo) { this.type = convert(type); this.content = content; this.modifiedContent = null; @@ -611,7 +654,7 @@ public class DiffWindow { this.rightLineNo = rightLineNo; } - private static LineType convert(DiffPlugin.DiffType t) { + private static LineType convert(DiffEngine.LineType t) { switch (t) { case UNCHANGED: return LineType.UNCHANGED; case REMOVED: return LineType.REMOVED; @@ -687,32 +730,7 @@ public class DiffWindow { /** 计算两行文本的字符级相似度 (0~1),基于 LCS */ private double charSimilarity(String a, String b) { - if (a.isEmpty() && b.isEmpty()) return 1.0; - int maxLen = Math.max(a.length(), b.length()); - if (maxLen == 0) return 1.0; - int lcsLen = lcsLength(a, b); - return (double) lcsLen / maxLen; - } - - /** LCS 长度(带缓存优化) */ - private int lcsLength(String a, String b) { - int m = a.length(), n = b.length(); - if (m == 0 || n == 0) return 0; - // 用滚动数组优化空间 - int[] prev = new int[n + 1]; - for (int i = 1; i <= m; i++) { - int[] curr = new int[n + 1]; - char ca = a.charAt(i - 1); - for (int j = 1; j <= n; j++) { - if (ca == b.charAt(j - 1)) { - curr[j] = prev[j - 1] + 1; - } else { - curr[j] = Math.max(prev[j], curr[j - 1]); - } - } - prev = curr; - } - return prev[n]; + return diffEngine.similarity(a, b); } /** @@ -727,113 +745,32 @@ public class DiffWindow { } } - /** 字符级片段类型 */ - private enum CharSegType { EQ, DEL, INS } - - private static class CharSegment { - final CharSegType type; - final String text; - CharSegment(CharSegType type, String text) { this.type = type; this.text = text; } - } - /** * 计算两行文本的字符级差异,返回带 标记的 HTML */ private CharDiffResult computeCharDiff(String left, String right) { - if (left.equals(right)) { - return new CharDiffResult(htmlEscape(left), htmlEscape(right)); - } - - // 计算 LCS 回溯路径 - int m = left.length(), n = right.length(); - int[][] dp = new int[m + 1][n + 1]; - for (int i = 1; i <= m; i++) { - char ca = left.charAt(i - 1); - for (int j = 1; j <= n; j++) { - if (ca == right.charAt(j - 1)) { - dp[i][j] = dp[i - 1][j - 1] + 1; - } else { - dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]); - } - } - } - - // 回溯生成左右两侧的字符片段 - List leftSegs = new ArrayList<>(); - List rightSegs = new ArrayList<>(); - int i = m, j = n; - while (i > 0 || j > 0) { - if (i > 0 && j > 0 && left.charAt(i - 1) == right.charAt(j - 1)) { - // EQ: 相同字符 - prepend(leftSegs, new CharSegment(CharSegType.EQ, String.valueOf(left.charAt(i - 1)))); - prepend(rightSegs, new CharSegment(CharSegType.EQ, String.valueOf(right.charAt(j - 1)))); - i--; j--; - } else if (j > 0 && (i == 0 || dp[i][j - 1] >= dp[i - 1][j])) { - // INS: 右侧新增 - prepend(rightSegs, new CharSegment(CharSegType.INS, String.valueOf(right.charAt(j - 1)))); - j--; - } else { - // DEL: 左侧删除 - prepend(leftSegs, new CharSegment(CharSegType.DEL, String.valueOf(left.charAt(i - 1)))); - i--; - } - } - - // 补齐左侧缺失的 EQ/INS(右侧删除的变为左侧 DEL,右侧新增的左侧为空) - // 合并生成完整左侧序列 - List fullLeft = buildFullSide(leftSegs, rightSegs, CharSegType.DEL); - List fullRight = buildFullSideForRight(leftSegs, rightSegs); - - // 合并连续同类型片段 - fullLeft = mergeSegments(fullLeft); - fullRight = mergeSegments(fullRight); - - String leftHtml = segmentsToHtml(fullLeft, CharSegType.DEL, "char-removed"); - String rightHtml = segmentsToHtml(fullRight, CharSegType.INS, "char-added"); - - return new CharDiffResult(leftHtml, rightHtml); - } - - /** - * 合并两侧回溯结果,生成"左列"的完整片段序列 - */ - private List buildFullSide(List left, List right, CharSegType delType) { - // 把右侧非 EQ 片段对应的左侧设为空片段(DEL) - List result = new ArrayList<>(left); - // 左列中如果右侧有 INS 而左侧没有对应 → 不需要处理,因为左侧为空白 - // 实际上我们只需让左侧显示它的片段,右侧 INS 啥也不用对应 - return result; - } - - private List buildFullSideForRight(List left, List right) { - // 右列显示它的片段,左侧 DEL 对应的右侧为空 - return new ArrayList<>(right); - } - - private void prepend(List list, CharSegment seg) { - list.add(0, seg); - } - - /** 合并相邻同类型片段 */ - private List mergeSegments(List segs) { - List merged = new ArrayList<>(); - for (CharSegment seg : segs) { - if (!merged.isEmpty() && merged.get(merged.size() - 1).type == seg.type) { - CharSegment last = merged.remove(merged.size() - 1); - merged.add(new CharSegment(last.type, last.text + seg.text)); - } else { - merged.add(seg); - } - } - return merged; + DiffEngine.CharacterDiff diff = + diffEngine.compareCharacters(left, right); + return new CharDiffResult( + segmentsToHtml( + diff.left(), + DiffEngine.CharacterType.DELETED, + "char-removed"), + segmentsToHtml( + diff.right(), + DiffEngine.CharacterType.INSERTED, + "char-added")); } /** 将片段列表转为 HTML */ - private String segmentsToHtml(List segs, CharSegType highlightType, String cssClass) { + private String segmentsToHtml( + List segments, + DiffEngine.CharacterType highlightType, + String cssClass) { StringBuilder sb = new StringBuilder(); - for (CharSegment seg : segs) { - String escaped = htmlEscape(seg.text); - if (seg.type == highlightType) { + for (DiffEngine.CharacterSegment segment : segments) { + String escaped = htmlEscape(segment.text()); + if (segment.type() == highlightType) { sb.append("").append(escaped).append(""); } else { sb.append(escaped); diff --git a/src/main/java/com/example/workutils/biz/diff/domain/DiffEngine.java b/src/main/java/com/example/workutils/biz/diff/domain/DiffEngine.java new file mode 100644 index 0000000..b7ab9c8 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/diff/domain/DiffEngine.java @@ -0,0 +1,174 @@ +package com.example.workutils.biz.diff.domain; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +public final class DiffEngine { + + public enum LineType { + UNCHANGED, ADDED, REMOVED + } + + public record LineDiff(LineType type, String content) { + public LineDiff { + Objects.requireNonNull(type, "type"); + Objects.requireNonNull(content, "content"); + } + } + + public record DiffResult( + List lines, + int added, + int removed, + int unchanged) { + + public DiffResult { + lines = List.copyOf(lines); + } + + public boolean hasDifferences() { + return added > 0 || removed > 0; + } + } + + public enum CharacterType { + EQUAL, DELETED, INSERTED + } + + public record CharacterSegment(CharacterType type, String text) { + public CharacterSegment { + Objects.requireNonNull(type, "type"); + Objects.requireNonNull(text, "text"); + } + } + + public record CharacterDiff( + List left, + List right) { + + public CharacterDiff { + left = List.copyOf(left); + right = List.copyOf(right); + } + } + + public DiffResult compareLines(List left, List right) { + List a = List.copyOf(Objects.requireNonNull(left, "left")); + List b = List.copyOf(Objects.requireNonNull(right, "right")); + int[][] lengths = lcsMatrix(a, b); + List lines = new ArrayList<>(); + int i = a.size(); + int j = b.size(); + int added = 0; + int removed = 0; + int unchanged = 0; + while (i > 0 || j > 0) { + if (i > 0 && j > 0 && a.get(i - 1).equals(b.get(j - 1))) { + lines.add(new LineDiff(LineType.UNCHANGED, a.get(i - 1))); + unchanged++; + i--; + j--; + } else if (j > 0 + && (i == 0 || lengths[i][j - 1] >= lengths[i - 1][j])) { + lines.add(new LineDiff(LineType.ADDED, b.get(j - 1))); + added++; + j--; + } else { + lines.add(new LineDiff(LineType.REMOVED, a.get(i - 1))); + removed++; + i--; + } + } + Collections.reverse(lines); + return new DiffResult(lines, added, removed, unchanged); + } + + public CharacterDiff compareCharacters(String left, String right) { + Objects.requireNonNull(left, "left"); + Objects.requireNonNull(right, "right"); + int[][] lengths = characterLcsMatrix(left, right); + List leftSegments = new ArrayList<>(); + List rightSegments = new ArrayList<>(); + int i = left.length(); + int j = right.length(); + while (i > 0 || j > 0) { + if (i > 0 && j > 0 + && left.charAt(i - 1) == right.charAt(j - 1)) { + String text = String.valueOf(left.charAt(i - 1)); + leftSegments.add(new CharacterSegment(CharacterType.EQUAL, text)); + rightSegments.add(new CharacterSegment(CharacterType.EQUAL, text)); + i--; + j--; + } else if (j > 0 + && (i == 0 || lengths[i][j - 1] >= lengths[i - 1][j])) { + rightSegments.add(new CharacterSegment( + CharacterType.INSERTED, + String.valueOf(right.charAt(j - 1)))); + j--; + } else { + leftSegments.add(new CharacterSegment( + CharacterType.DELETED, + String.valueOf(left.charAt(i - 1)))); + i--; + } + } + Collections.reverse(leftSegments); + Collections.reverse(rightSegments); + return new CharacterDiff( + mergeCharacterSegments(leftSegments), + mergeCharacterSegments(rightSegments)); + } + + public double similarity(String left, String right) { + Objects.requireNonNull(left, "left"); + Objects.requireNonNull(right, "right"); + int maxLength = Math.max(left.length(), right.length()); + if (maxLength == 0) { + return 1.0; + } + return (double) characterLcsMatrix(left, right) + [left.length()][right.length()] / maxLength; + } + + private int[][] lcsMatrix(List left, List right) { + int[][] lengths = new int[left.size() + 1][right.size() + 1]; + for (int i = 1; i <= left.size(); i++) { + for (int j = 1; j <= right.size(); j++) { + lengths[i][j] = left.get(i - 1).equals(right.get(j - 1)) + ? lengths[i - 1][j - 1] + 1 + : Math.max(lengths[i - 1][j], lengths[i][j - 1]); + } + } + return lengths; + } + + private int[][] characterLcsMatrix(String left, String right) { + int[][] lengths = new int[left.length() + 1][right.length() + 1]; + for (int i = 1; i <= left.length(); i++) { + for (int j = 1; j <= right.length(); j++) { + lengths[i][j] = left.charAt(i - 1) == right.charAt(j - 1) + ? lengths[i - 1][j - 1] + 1 + : Math.max(lengths[i - 1][j], lengths[i][j - 1]); + } + } + return lengths; + } + + private List mergeCharacterSegments( + List segments) { + List merged = new ArrayList<>(); + for (CharacterSegment segment : segments) { + if (!merged.isEmpty() + && merged.get(merged.size() - 1).type() == segment.type()) { + CharacterSegment previous = merged.remove(merged.size() - 1); + merged.add(new CharacterSegment( + segment.type(), previous.text() + segment.text())); + } else { + merged.add(segment); + } + } + return merged; + } +} diff --git a/src/main/java/com/example/workutils/biz/git/GitSyncWindow.java b/src/main/java/com/example/workutils/biz/git/GitSyncWindow.java index c1bfc56..087e865 100644 --- a/src/main/java/com/example/workutils/biz/git/GitSyncWindow.java +++ b/src/main/java/com/example/workutils/biz/git/GitSyncWindow.java @@ -1,12 +1,22 @@ package com.example.workutils.biz.git; +import com.example.workutils.biz.git.application.GitSyncProgress; +import com.example.workutils.biz.git.application.GitSyncRequest; +import com.example.workutils.biz.git.application.RunGitSyncUseCase; +import com.example.workutils.biz.git.presentation.ProjectFilter; +import com.example.workutils.biz.git.presentation.RemoteUrlDisplay; import com.example.workutils.system.config.WorkUtilsProperties; +import com.example.workutils.system.support.AppExecutors; import com.example.workutils.system.support.FileOperationHelper; import com.example.workutils.system.ui.StyledButton; import com.example.workutils.system.ui.Theme; +import com.example.workutils.system.ui.async.AsyncOperationController; +import com.example.workutils.system.ui.async.LoadingButtonBinder; +import com.example.workutils.system.ui.async.OperationStatusPane; import javafx.application.Platform; import javafx.collections.FXCollections; import javafx.collections.ObservableList; +import javafx.collections.transformation.FilteredList; import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.Scene; @@ -20,6 +30,7 @@ import java.io.IOException; import java.util.ArrayList; import java.util.List; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicReference; /** * Git 仓库同步可视化窗口 @@ -33,17 +44,19 @@ public class GitSyncWindow { private final GitSyncPlugin plugin; private final WorkUtilsProperties properties; private final FileOperationHelper fileOperationHelper; + private final RunGitSyncUseCase syncUseCase; + private final AsyncOperationController syncOperation; // Tab 1: 项目列表 private TableView projectTable; private final ObservableList projectRows = FXCollections.observableArrayList(); + private final FilteredList filteredProjectRows = + new FilteredList<>(projectRows, row -> true); // Tab 2: 同步执行 private TextField branchField; private Button syncBtn; private Button stopBtn; - private ProgressBar progressBar; - private Label progressLabel; private TextArea logArea; // Tab 3: 损坏仓库处理 @@ -59,6 +72,9 @@ public class GitSyncWindow { this.fileOperationHelper = fileOperationHelper; this.properties = properties; this.closeLatch = closeLatch; + this.syncUseCase = new RunGitSyncUseCase(this::runPluginSync); + this.syncOperation = new AsyncOperationController<>( + AppExecutors.io(), Platform::runLater); this.stage = new Stage(); buildUI(); } @@ -120,17 +136,34 @@ public class GitSyncWindow { toolbar.setAlignment(Pos.CENTER_LEFT); Button refreshBtn = StyledButton.secondary("刷新列表", Theme.base(13)); refreshBtn.setOnAction(e -> refreshProjectList()); + TextField searchField = new TextField(); + searchField.setPromptText("搜索项目名或路径"); + searchField.setPrefWidth(240); + ComboBox statusFilter = new ComboBox<>(FXCollections.observableArrayList( + ProjectFilter.ALL_STATUSES, "正常", "目录不存在", "非Git仓库")); + statusFilter.setValue(ProjectFilter.ALL_STATUSES); Label countLabel = new Label(); countLabel.setFont(Theme.base(13)); countLabel.setTextFill(Theme.TEXT_SECONDARY); countLabel.textProperty().bind( - javafx.beans.binding.Bindings.size(projectRows).asString("共 %d 个项目")); + javafx.beans.binding.Bindings.size(filteredProjectRows) + .asString("显示 %d 个项目")); - toolbar.getChildren().addAll(refreshBtn, countLabel); + Runnable applyFilter = () -> filteredProjectRows.setPredicate(row -> + ProjectFilter.matches( + row.getName(), row.getPath(), row.getGitStatus(), + searchField.getText(), statusFilter.getValue())); + searchField.textProperty().addListener( + (observable, oldValue, newValue) -> applyFilter.run()); + statusFilter.valueProperty().addListener( + (observable, oldValue, newValue) -> applyFilter.run()); + + toolbar.getChildren().addAll( + refreshBtn, searchField, statusFilter, countLabel); projectTable = new TableView<>(); - projectTable.setItems(projectRows); - projectTable.setColumnResizePolicy(TableView.UNCONSTRAINED_RESIZE_POLICY); + projectTable.setItems(filteredProjectRows); + projectTable.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY); projectTable.setStyle("-fx-background-color: white;" + "-fx-border-color: " + Theme.toWeb(Theme.BORDER) + ";" + "-fx-border-width: 1; -fx-border-radius: " + Theme.RADIUS_SM + ";"); @@ -162,7 +195,7 @@ public class GitSyncWindow { for (FileOperationHelper.ProjectPathInfo p : projects) { String name = fileOperationHelper.extractProjectName(p.getPath()); String gitStatus = checkGitStatus(p.getPath()); - String remote = p.getRemoteBaseUrl(); + String remote = RemoteUrlDisplay.safe(p.getRemoteBaseUrl()); projectRows.add(new ProjectRow(name, p.getPath(), gitStatus, remote)); } } catch (IOException e) { @@ -199,27 +232,20 @@ public class GitSyncWindow { syncBtn = StyledButton.primary("开始同步", Theme.bold(14)); syncBtn.setOnAction(e -> startSync()); + LoadingButtonBinder.bind(syncBtn, syncOperation, "开始同步"); stopBtn = StyledButton.danger("终止", Theme.base(14)); - stopBtn.setDisable(true); stopBtn.setOnAction(e -> { - stopBtn.setDisable(true); - progressLabel.setText("正在终止..."); + syncOperation.cancel(); plugin.cancelSync(); }); + stopBtn.visibleProperty().bind(syncOperation.runningProperty()); + stopBtn.managedProperty().bind(stopBtn.visibleProperty()); branchRow.getChildren().addAll(branchLabel, branchField, syncBtn, stopBtn); - // 进度条 - HBox progressRow = new HBox(Theme.S2); - progressRow.setAlignment(Pos.CENTER_LEFT); - progressBar = new ProgressBar(0); - progressBar.setPrefWidth(400); - progressBar.setPrefHeight(24); - progressLabel = new Label("就绪"); - progressLabel.setFont(Theme.base(13)); - progressLabel.setTextFill(Theme.TEXT_SECONDARY); - progressRow.getChildren().addAll(progressBar, progressLabel); + OperationStatusPane progressRow = new OperationStatusPane( + syncOperation, 13, false); // 日志区域 Label logTitle = new Label("执行日志"); @@ -237,61 +263,80 @@ public class GitSyncWindow { } private void startSync() { - String input = branchField.getText().trim(); - final String branch = input.isEmpty() ? "master" : input; - - syncBtn.setDisable(true); - stopBtn.setDisable(false); + GitSyncRequest request = new GitSyncRequest(branchField.getText()); logArea.clear(); - progressBar.setProgress(0); - progressLabel.setText("正在同步..."); + syncOperation.start( + "正在同步仓库", + context -> syncUseCase.execute( + request, + context, + message -> Platform.runLater( + () -> logArea.appendText(message + "\n"))), + this::completeSync, + error -> logArea.appendText( + "[ERROR] " + error.getMessage() + "\n")); + } - new Thread(() -> { - plugin.syncWithCallback(branch, new GitSyncPlugin.SyncCallback() { - @Override - public void onLog(String message) { - Platform.runLater(() -> { - logArea.appendText(message + "\n"); - }); - } + private void completeSync(RunGitSyncUseCase.Result result) { + logArea.appendText("\n========== 同步完成 ==========\n"); + logArea.appendText(String.format( + "总计: %d | 成功: %d | 损坏: %d | 失败: %d | 耗时: %ds\n", + result.total(), result.success(), result.damaged(), + result.failed(), result.elapsedSeconds())); + if (result.damaged() == 0) { + logArea.appendText("全部同步成功!\n"); + } else { + logArea.appendText("请切换到「损坏仓库处理」Tab 查看详情\n"); + List damaged = plugin.getLastDamagedRepos(); + if (damaged != null) { + refreshDamagedTable(damaged); + } + } + } - @Override - public void onProgress(int current, int total, String repoName, String status, boolean error) { - Platform.runLater(() -> { - progressBar.setProgress((double) current / total); - progressLabel.setText(String.format("%d/%d %s [%s]", current, total, repoName, status)); - if (error) { - logArea.appendText("[" + current + "/" + total + "] " + repoName + " >> " + status + "\n"); - } - }); - } + private void runPluginSync( + String branch, RunGitSyncUseCase.Listener listener) { + AtomicReference summaryRef = + new AtomicReference<>(); + AtomicReference> damagedRef = + new AtomicReference<>(List.of()); + plugin.syncWithCallback(branch, new GitSyncPlugin.SyncCallback() { + @Override + public void onLog(String message) { + listener.onLog(message); + } - @Override - public void onSyncDone(GitSyncPlugin.SyncSummary summary) { - Platform.runLater(() -> { - syncBtn.setDisable(false); - stopBtn.setDisable(true); - progressBar.setProgress(1); - progressLabel.setText("完成"); - logArea.appendText("\n========== 同步完成 ==========\n"); - logArea.appendText(String.format("总计: %d | 成功: %d | 损坏: %d | 失败: %d | 耗时: %ds\n", - summary.total, summary.success, summary.damaged, summary.failed, summary.elapsedSeconds)); - if (summary.damaged == 0) { - logArea.appendText("全部同步成功!\n"); - } else { - logArea.appendText("请切换到「损坏仓库处理」Tab 查看详情\n"); - } - }); - } + @Override + public void onProgress( + int current, int total, String repoName, + String status, boolean error) { + listener.onProgress(new GitSyncProgress( + current, total, repoName, status, error)); + } - @Override - public void onDamagedRepos(List repos) { - Platform.runLater(() -> { - refreshDamagedTable(repos); - }); - } - }); - }, "GitSync-Worker").start(); + @Override + public void onSyncDone(GitSyncPlugin.SyncSummary summary) { + summaryRef.set(summary); + } + + @Override + public void onDamagedRepos(List repos) { + damagedRef.set(List.copyOf(repos)); + } + }); + GitSyncPlugin.SyncSummary summary = summaryRef.get(); + if (summary == null) { + return; + } + List damaged = + damagedRef.get().stream() + .map(repo -> new RunGitSyncUseCase.DamagedRepository( + repo.name, repo.path, repo.branch, + repo.error, repo.remoteUrl)) + .toList(); + listener.onCompleted(new RunGitSyncUseCase.Result( + summary.total, summary.success, summary.damaged, + summary.failed, summary.elapsedSeconds, damaged)); } // ==================== Tab 3: 损坏仓库处理 ==================== diff --git a/src/main/java/com/example/workutils/biz/git/application/GitSyncProgress.java b/src/main/java/com/example/workutils/biz/git/application/GitSyncProgress.java new file mode 100644 index 0000000..c01664e --- /dev/null +++ b/src/main/java/com/example/workutils/biz/git/application/GitSyncProgress.java @@ -0,0 +1,25 @@ +package com.example.workutils.biz.git.application; + +public record GitSyncProgress( + int current, + int total, + String repository, + String status, + boolean error) { + + public GitSyncProgress { + if (current < 0 || total < 0 || current > total) { + throw new IllegalArgumentException("invalid progress"); + } + repository = repository == null ? "" : repository; + status = status == null ? "" : status; + } + + public double fraction() { + return total == 0 ? 0 : (double) current / total; + } + + public String message() { + return current + "/" + total + " " + repository + " [" + status + "]"; + } +} diff --git a/src/main/java/com/example/workutils/biz/git/application/GitSyncRequest.java b/src/main/java/com/example/workutils/biz/git/application/GitSyncRequest.java new file mode 100644 index 0000000..4acdec3 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/git/application/GitSyncRequest.java @@ -0,0 +1,10 @@ +package com.example.workutils.biz.git.application; + +public record GitSyncRequest(String branch) { + + public GitSyncRequest { + branch = branch == null || branch.isBlank() + ? "master" + : branch.trim(); + } +} diff --git a/src/main/java/com/example/workutils/biz/git/application/RunGitSyncUseCase.java b/src/main/java/com/example/workutils/biz/git/application/RunGitSyncUseCase.java new file mode 100644 index 0000000..f29ebd5 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/git/application/RunGitSyncUseCase.java @@ -0,0 +1,80 @@ +package com.example.workutils.biz.git.application; + +import com.example.workutils.system.ui.async.OperationContext; + +import java.util.List; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; + +public final class RunGitSyncUseCase { + + @FunctionalInterface + public interface Gateway { + void sync(String branch, Listener listener); + } + + public interface Listener { + void onLog(String message); + void onProgress(GitSyncProgress progress); + void onCompleted(Result result); + } + + public record DamagedRepository( + String name, + String path, + String branch, + String error, + String remoteUrl) {} + + public record Result( + int total, + int success, + int damaged, + int failed, + long elapsedSeconds, + List damagedRepositories) { + + public Result { + damagedRepositories = List.copyOf(damagedRepositories); + } + } + + private final Gateway gateway; + + public RunGitSyncUseCase(Gateway gateway) { + this.gateway = Objects.requireNonNull(gateway, "gateway"); + } + + public Result execute( + GitSyncRequest request, + OperationContext context, + Consumer logSink) { + Objects.requireNonNull(request, "request"); + Objects.requireNonNull(context, "context"); + Objects.requireNonNull(logSink, "logSink"); + AtomicReference completed = new AtomicReference<>(); + gateway.sync(request.branch(), new Listener() { + @Override + public void onLog(String message) { + logSink.accept(message); + } + + @Override + public void onProgress(GitSyncProgress progress) { + context.report(progress.message(), progress.fraction()); + } + + @Override + public void onCompleted(Result result) { + completed.set(result); + } + }); + Result result = completed.get(); + if (result == null) { + throw new IllegalStateException( + "同步未完成,请检查项目配置或执行日志"); + } + return result; + } +} diff --git a/src/main/java/com/example/workutils/biz/git/presentation/ProjectFilter.java b/src/main/java/com/example/workutils/biz/git/presentation/ProjectFilter.java new file mode 100644 index 0000000..7746f97 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/git/presentation/ProjectFilter.java @@ -0,0 +1,31 @@ +package com.example.workutils.biz.git.presentation; + +import java.util.Locale; + +public final class ProjectFilter { + + public static final String ALL_STATUSES = "全部状态"; + + private ProjectFilter() {} + + public static boolean matches( + String name, + String path, + String status, + String query, + String requiredStatus) { + String normalizedQuery = normalize(query); + boolean textMatches = normalizedQuery.isEmpty() + || normalize(name).contains(normalizedQuery) + || normalize(path).contains(normalizedQuery); + boolean statusMatches = requiredStatus == null + || requiredStatus.isBlank() + || ALL_STATUSES.equals(requiredStatus) + || requiredStatus.equals(status); + return textMatches && statusMatches; + } + + private static String normalize(String value) { + return value == null ? "" : value.trim().toLowerCase(Locale.ROOT); + } +} diff --git a/src/main/java/com/example/workutils/biz/git/presentation/RemoteUrlDisplay.java b/src/main/java/com/example/workutils/biz/git/presentation/RemoteUrlDisplay.java new file mode 100644 index 0000000..f83340f --- /dev/null +++ b/src/main/java/com/example/workutils/biz/git/presentation/RemoteUrlDisplay.java @@ -0,0 +1,37 @@ +package com.example.workutils.biz.git.presentation; + +public final class RemoteUrlDisplay { + + private RemoteUrlDisplay() {} + + public static String safe(String rawUrl) { + if (rawUrl == null || rawUrl.isBlank()) { + return "—"; + } + String value = rawUrl.trim().replace('\\', '/'); + int scheme = value.indexOf("://"); + if (scheme >= 0) { + value = value.substring(scheme + 3); + } + + int firstSlash = value.indexOf('/'); + int at = value.indexOf('@'); + if (at >= 0 && (firstSlash < 0 || at < firstSlash)) { + value = value.substring(at + 1); + } + + int colon = value.indexOf(':'); + firstSlash = value.indexOf('/'); + if (colon > 0 && (firstSlash < 0 || colon < firstSlash)) { + value = value.substring(0, colon) + "/" + value.substring(colon + 1); + } + + while (value.startsWith("/")) { + value = value.substring(1); + } + while (value.endsWith("/")) { + value = value.substring(0, value.length() - 1); + } + return value.isBlank() ? "—" : value; + } +} 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 f4d4d63..a2ca64d 100644 --- a/src/main/java/com/example/workutils/biz/mybatisexport/MyBatisExportWindow.java +++ b/src/main/java/com/example/workutils/biz/mybatisexport/MyBatisExportWindow.java @@ -1,11 +1,20 @@ package com.example.workutils.biz.mybatisexport; +import com.example.workutils.biz.mybatisexport.application.RunSqlExportUseCase; +import com.example.workutils.biz.mybatisexport.application.SqlExportRequest; 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; +import com.example.workutils.system.ui.async.LoadingButtonBinder; +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 javafx.application.Platform; import javafx.collections.FXCollections; import javafx.collections.ObservableList; @@ -13,7 +22,6 @@ import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.Scene; import javafx.scene.control.*; -import javafx.scene.control.cell.PropertyValueFactory; import javafx.scene.layout.*; import javafx.scene.Parent; import javafx.stage.Stage; @@ -36,6 +44,8 @@ public class MyBatisExportWindow { private final CountDownLatch closeLatch; private final MyBatisExportService exportService; private final WorkUtilsProperties properties; + private final RunSqlExportUseCase runExportUseCase; + private final AsyncOperationController exportOperation; // 过滤 private ComboBox filterModeCmb; @@ -44,19 +54,13 @@ public class MyBatisExportWindow { // 操作 private Button exportBtn; - private Button stopBtn; - private ProgressBar progressBar; - private Label progressLabel; // 日志 private TextArea logArea; - // 取消标记 - private volatile boolean exportCancelled = false; - // 历史记录 - private TableView historyTable; private final ObservableList historyRows = FXCollections.observableArrayList(); + private final ObservableList historyItems = FXCollections.observableArrayList(); private static final SimpleDateFormat SDF = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); private int historyCounter = 0; @@ -68,7 +72,14 @@ public class MyBatisExportWindow { this.exportService = exportService; this.properties = properties; this.closeLatch = closeLatch; + this.runExportUseCase = new RunSqlExportUseCase(exportService); + this.exportOperation = new AsyncOperationController<>( + AppExecutors.io(), Platform::runLater); this.historyPersistence = new HistoryPersistence("mybatis_export_history.txt"); + historyRows.addListener( + (javafx.collections.ListChangeListener) change -> + historyItems.setAll(historyRows.stream() + .map(MyBatisExportWindow::toHistoryItem).toList())); this.stage = new Stage(); buildUI(); loadHistory(); @@ -137,28 +148,13 @@ public class MyBatisExportWindow { exportBtn = StyledButton.primary("导出", Theme.bold(fsBtn())); exportBtn.setOnAction(e -> startExport()); + LoadingButtonBinder.bind(exportBtn, exportOperation, "导出"); - stopBtn = StyledButton.danger("终止", Theme.base(fsBtn())); - stopBtn.setDisable(true); - stopBtn.setOnAction(e -> { - stopBtn.setDisable(true); - progressLabel.setText("正在终止..."); - exportCancelled = true; - }); + filterRow.getChildren().addAll( + modeLabel, filterModeCmb, filterValueField, filterHint, exportBtn); - filterRow.getChildren().addAll(modeLabel, filterModeCmb, filterValueField, filterHint, exportBtn, stopBtn); - - // === 进度 === - HBox progressRow = new HBox(Theme.S2); - progressRow.setAlignment(Pos.CENTER_LEFT); - progressBar = new ProgressBar(0); - progressBar.setPrefWidth(400); - progressBar.setPrefHeight(20); - progressBar.setVisible(false); - progressLabel = new Label(""); - progressLabel.setFont(Theme.base(fsBody() - 2)); - progressLabel.setTextFill(Theme.TEXT_SECONDARY); - progressRow.getChildren().addAll(progressBar, progressLabel); + OperationStatusPane progressRow = new OperationStatusPane( + exportOperation, fsBody() - 2); // === 日志区域 === Label logTitle = new Label("执行日志"); @@ -177,114 +173,24 @@ public class MyBatisExportWindow { tableTitle.setFont(Theme.heading(fsSection())); tableTitle.setTextFill(Theme.TEXT_PRIMARY); - // === 历史记录表格 === - historyTable = new TableView<>(); - historyTable.setItems(historyRows); - historyTable.setColumnResizePolicy(TableView.UNCONSTRAINED_RESIZE_POLICY); - historyTable.setPlaceholder(new Label("暂无导出记录")); - historyTable.setStyle("-fx-background-color: white;" - + "-fx-border-color: " + Theme.toWeb(Theme.BORDER) + ";" - + "-fx-border-width: 1; -fx-border-radius: " + Theme.RADIUS_SM + ";"); - - // 列定义 - TableColumn colIndex = new TableColumn<>("序号"); - colIndex.setCellValueFactory(new PropertyValueFactory<>("index")); - - TableColumn colQuery = new TableColumn<>("查询条件"); - colQuery.setCellValueFactory(new PropertyValueFactory<>("queryCondition")); - - TableColumn colFile = new TableColumn<>("生成文件"); - colFile.setCellValueFactory(new PropertyValueFactory<>("fileDisplay")); - - TableColumn colCount = new TableColumn<>("导出数"); - colCount.setCellValueFactory(new PropertyValueFactory<>("recordCount")); - - TableColumn colTime = new TableColumn<>("时间"); - colTime.setCellValueFactory(new PropertyValueFactory<>("timestamp")); - - TableColumn colAction = new TableColumn<>("操作"); - colAction.setCellValueFactory(new PropertyValueFactory<>("actionLabel")); - colAction.setCellFactory(col -> buildActionCell()); - colAction.setPrefWidth(170); - colAction.setResizable(false); - - historyTable.getColumns().addAll(colAction, colIndex, colQuery, colFile, colCount, colTime); - - VBox.setVgrow(historyTable, Priority.ALWAYS); - root.getChildren().addAll(filterRow, progressRow, logTitle, logArea, tableTitle, historyTable); + ExportHistoryPane historyPane = new ExportHistoryPane( + "导出数", historyItems, historyFileCommands(), fsBody()); + VBox.setVgrow(historyPane, Priority.ALWAYS); + root.getChildren().addAll(filterRow, progressRow, logTitle, logArea, tableTitle, historyPane); stage.setScene(new Scene(root)); } - /** - * 构建操作列单元格,包含「打开」按钮和右键菜单 - */ - private TableCell buildActionCell() { - TableCell cell = new TableCell() { - private final Button openBtn = new Button("打开"); - private final Button dirBtn = new Button("目录"); - private final HBox btnBox = new HBox(4, openBtn, dirBtn); - { setAlignment(Pos.CENTER); btnBox.setAlignment(Pos.CENTER); } - private final ContextMenu menu = new ContextMenu(); - private String currentFileName; - - { - openBtn.setFont(Theme.base(fsBody() - 2)); - openBtn.setStyle("-fx-background-color: " + Theme.toWeb(Theme.PRIMARY) + ";" - + "-fx-text-fill: white; -fx-cursor: hand;" - + "-fx-background-radius: " + Theme.RADIUS_SM + "; -fx-padding: 3 8;"); - - dirBtn.setFont(Theme.base(fsBody() - 2)); - dirBtn.setStyle("-fx-background-color: " + Theme.toWeb(Theme.BORDER) + ";" - + "-fx-text-fill: " + Theme.toWeb(Theme.TEXT_PRIMARY) + "; -fx-cursor: hand;" - + "-fx-background-radius: " + Theme.RADIUS_SM + "; -fx-padding: 3 6;"); - dirBtn.setTooltip(new Tooltip("打开文件所在目录")); - - // 右键菜单选项 - MenuItem defaultOpen = new MenuItem("系统默认程序打开"); - defaultOpen.setOnAction(e -> openFile(currentFileName)); - MenuItem openNotepad = new MenuItem("用系统编辑器打开"); - openNotepad.setOnAction(e -> openWithNotepad(currentFileName)); - MenuItem openFolder = new MenuItem("打开所在文件夹"); - openFolder.setOnAction(e -> openFolder(currentFileName)); - MenuItem copyPath = new MenuItem("复制文件路径"); - copyPath.setOnAction(e -> copyFilePath(currentFileName)); - menu.getItems().addAll(defaultOpen, openNotepad, openFolder, copyPath); - - // 左键「打开文件」→ 系统默认程序打开 - openBtn.setOnAction(e -> openFile(currentFileName)); - // 左键「打开目录」→ 打开所在目录 - dirBtn.setOnAction(e -> openFolder(currentFileName)); - // 右键点击 → 弹出菜单 - btnBox.setOnContextMenuRequested(e -> { - menu.show(btnBox, e.getScreenX(), e.getScreenY()); + 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); }); - } - - @Override - protected void updateItem(String item, boolean empty) { - super.updateItem(item, empty); - if (empty) { - setGraphic(null); - currentFileName = null; - } else { - int idx = getIndex(); - if (idx >= 0 && getTableView() != null) { - HistoryRow row = getTableView().getItems().get(idx); - currentFileName = row != null ? row.getFileName() : null; - } - setGraphic(btnBox); - } - } - - private void copyFilePath(String path) { - if (path == null) return; - javafx.scene.input.Clipboard.getSystemClipboard() - .setContent(new javafx.scene.input.ClipboardContent() {{ - putString(path); - }}); - } - }; - return cell; } // ==================== 历史持久化 ==================== @@ -322,103 +228,51 @@ public class MyBatisExportWindow { } private void startExport() { - String mode = filterModeCmb.getValue(); - String val = filterValueField.getText().trim(); - - String filterMode; - String queryDisplay; - if ("全部SQL".equals(mode)) { - filterMode = "all"; - queryDisplay = "全部SQL"; - } else if ("按表名".equals(mode)) { - filterMode = "table"; - queryDisplay = "按表名: " + val; - } else if ("按关键字".equals(mode)) { - filterMode = "keyword"; - queryDisplay = "按关键字: " + val; - } else { - filterMode = "tagid"; - queryDisplay = "按标签ID: " + val; + final SqlExportRequest request; + try { + request = SqlExportRequest.create( + selectedFilterMode(), filterValueField.getText()); + } catch (IllegalArgumentException error) { + logArea.appendText("[WARN] " + error.getMessage() + "\n"); + return; } - String prefix = "SQL " + (val.isEmpty() ? "全部" : val); - final String fQueryDisplay = queryDisplay; - - // 重置状态 - exportCancelled = false; - exportBtn.setDisable(true); - stopBtn.setDisable(false); logArea.clear(); - progressBar.setVisible(true); - progressBar.setProgress(0); - progressLabel.setText("正在导出..."); + exportOperation.start("正在导出", + context -> runExportUseCase.execute( + request, context, + progress -> Platform.runLater(() -> + logArea.appendText(String.format( + "[%d/%d] %s → %d 条\n", + progress.current(), progress.total(), + progress.projectName(), progress.recordCount())))), + result -> completeExport(request, result), + error -> logArea.appendText("[ERROR] " + error.getMessage() + "\n")); + } - new Thread(() -> { - try { - MyBatisExportService.ExportResult result = exportService.exportWithCallback( - prefix, filterMode, val, new MyBatisExportService.ExportCallback() { - @Override - public void onProjectStart(int current, int total, String projectName) { - Platform.runLater(() -> { - double pct = (double)(current - 1) / total; - progressBar.setProgress(pct); - progressLabel.setText(String.format("%d/%d 处理中 %s ...", current, total, projectName)); - logArea.appendText(String.format("[%d/%d] 处理中 %s ...\n", current, total, projectName)); - }); - } + private SqlExportRequest.FilterMode selectedFilterMode() { + return switch (filterModeCmb.getSelectionModel().getSelectedIndex()) { + case 1 -> SqlExportRequest.FilterMode.TABLE; + case 2 -> SqlExportRequest.FilterMode.KEYWORD; + case 3 -> SqlExportRequest.FilterMode.TAG_ID; + default -> SqlExportRequest.FilterMode.ALL; + }; + } - @Override - public void onProjectDone(int current, int total, String projectName, int recordCount) { - Platform.runLater(() -> { - double pct = (double) current / total; - progressBar.setProgress(pct); - progressLabel.setText(String.format("%d/%d %s → %d 条SQL", current, total, projectName, recordCount)); - logArea.appendText(String.format("[%d/%d] %s → %d 条\n", current, total, projectName, recordCount)); - }); - } + private void completeExport(SqlExportRequest request, + MyBatisExportService.ExportResult result) { + int totalRows = result == null ? 0 : result.rows.size(); + logArea.appendText(String.format( + "\n========== 导出完成:%d 条记录 ==========\n", totalRows)); + if (result == null || result.fileName == null || result.fileName.isEmpty()) { + return; + } - @Override - public boolean isCancelled() { - return exportCancelled; - } - }); - - int hc = ++historyCounter; - String timestamp = SDF.format(new Date()); - int totalRows = result != null ? result.rows.size() : 0; - boolean wasCancelled = exportCancelled; - - Platform.runLater(() -> { - if (wasCancelled) { - progressBar.setProgress(1); - progressLabel.setText("已取消"); - logArea.appendText("\n[WARN] 导出已被用户取消\n"); - } else { - progressBar.setProgress(1); - progressLabel.setText("完成 (" + totalRows + " 条)"); - logArea.appendText(String.format("\n========== 导出完成:%d 条记录 ==========\n", totalRows)); - } - progressBar.setVisible(false); - exportBtn.setDisable(false); - stopBtn.setDisable(true); - - if (result != null && result.fileName != null && !result.fileName.isEmpty()) { - historyRows.add(0, new HistoryRow( - String.valueOf(hc), fQueryDisplay, result.fileName, - totalRows, timestamp, "打开")); - saveHistory(); - } - }); - } catch (Exception ex) { - Platform.runLater(() -> { - progressBar.setVisible(false); - progressLabel.setText("导出失败"); - exportBtn.setDisable(false); - stopBtn.setDisable(true); - logArea.appendText("[ERROR] " + ex.getMessage() + "\n"); - }); - } - }, "SQLExport-Worker").start(); + int hc = ++historyCounter; + historyRows.add(0, new HistoryRow( + String.valueOf(hc), request.queryDisplay(), result.fileName, + totalRows, SDF.format(new Date()), "打开")); + saveHistory(); } // ==================== 文件打开工具方法 ==================== @@ -454,6 +308,15 @@ public class MyBatisExportWindow { // ==================== 数据行 ==================== + static HistoryItem toHistoryItem(HistoryRow row) { + return new HistoryItem( + row.getIndex(), + row.getQueryCondition(), + row.getFileName(), + row.getRecordCount(), + row.getTimestamp()); + } + public static class HistoryRow { private final String index; private final String queryCondition; diff --git a/src/main/java/com/example/workutils/biz/mybatisexport/application/RunSqlExportUseCase.java b/src/main/java/com/example/workutils/biz/mybatisexport/application/RunSqlExportUseCase.java new file mode 100644 index 0000000..43b878e --- /dev/null +++ b/src/main/java/com/example/workutils/biz/mybatisexport/application/RunSqlExportUseCase.java @@ -0,0 +1,48 @@ +package com.example.workutils.biz.mybatisexport.application; + +import com.example.workutils.biz.mybatisexport.service.MyBatisExportService; +import com.example.workutils.system.ui.async.OperationContext; + +import java.util.function.Consumer; + +public final class RunSqlExportUseCase { + + private final MyBatisExportService exportService; + + public RunSqlExportUseCase(MyBatisExportService exportService) { + this.exportService = exportService; + } + + public MyBatisExportService.ExportResult execute( + SqlExportRequest request, + OperationContext context, + Consumer progressConsumer) throws Exception { + return exportService.exportWithCallback( + request.fileNamePrefix(), + request.serviceFilterMode(), + request.filterValue(), + new MyBatisExportService.ExportCallback() { + @Override + public void onProjectStart(int current, int total, String projectName) { + context.report(current + "/" + total + " " + projectName, + total == 0 ? 0 : (double) (current - 1) / total); + progressConsumer.accept(new SqlExportProgress( + current, total, projectName, 0)); + } + + @Override + public void onProjectDone(int current, int total, + String projectName, int recordCount) { + context.report(current + "/" + total + " " + projectName, + total == 0 ? 0 : (double) current / total); + progressConsumer.accept(new SqlExportProgress( + current, total, projectName, recordCount)); + } + + @Override + public boolean isCancelled() { + return context.isCancellationRequested(); + } + }); + } +} diff --git a/src/main/java/com/example/workutils/biz/mybatisexport/application/SqlExportProgress.java b/src/main/java/com/example/workutils/biz/mybatisexport/application/SqlExportProgress.java new file mode 100644 index 0000000..3da1ec8 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/mybatisexport/application/SqlExportProgress.java @@ -0,0 +1,8 @@ +package com.example.workutils.biz.mybatisexport.application; + +public record SqlExportProgress(int current, int total, String projectName, int recordCount) { + + public double fraction() { + return total == 0 ? 0 : (double) current / total; + } +} diff --git a/src/main/java/com/example/workutils/biz/mybatisexport/application/SqlExportRequest.java b/src/main/java/com/example/workutils/biz/mybatisexport/application/SqlExportRequest.java new file mode 100644 index 0000000..7b7f710 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/mybatisexport/application/SqlExportRequest.java @@ -0,0 +1,44 @@ +package com.example.workutils.biz.mybatisexport.application; + +public record SqlExportRequest(FilterMode filterMode, String filterValue) { + + public enum FilterMode { + ALL("all", "全部 SQL"), + TABLE("table", "按表名"), + KEYWORD("keyword", "按关键字"), + TAG_ID("tagid", "按标签 ID"); + + private final String serviceValue; + private final String displayName; + + FilterMode(String serviceValue, String displayName) { + this.serviceValue = serviceValue; + this.displayName = displayName; + } + } + + public static SqlExportRequest create(FilterMode mode, String value) { + if (mode == null) { + throw new IllegalArgumentException("请选择过滤方式"); + } + String normalized = value == null ? "" : value.trim(); + if (mode != FilterMode.ALL && normalized.isEmpty()) { + throw new IllegalArgumentException("请输入过滤值"); + } + return new SqlExportRequest(mode, mode == FilterMode.ALL ? "" : normalized); + } + + public String serviceFilterMode() { + return filterMode.serviceValue; + } + + public String queryDisplay() { + return filterMode == FilterMode.ALL + ? filterMode.displayName + : filterMode.displayName + ": " + filterValue; + } + + public String fileNamePrefix() { + return "SQL_" + (filterValue.isEmpty() ? "全部" : filterValue); + } +} diff --git a/src/main/java/com/example/workutils/biz/mybatisgenerator/MyBatisGeneratorWindow.java b/src/main/java/com/example/workutils/biz/mybatisgenerator/MyBatisGeneratorWindow.java index da507ed..554336c 100644 --- a/src/main/java/com/example/workutils/biz/mybatisgenerator/MyBatisGeneratorWindow.java +++ b/src/main/java/com/example/workutils/biz/mybatisgenerator/MyBatisGeneratorWindow.java @@ -1,9 +1,18 @@ package com.example.workutils.biz.mybatisgenerator; +import com.example.workutils.biz.mybatisgenerator.application.GenerateArtifactsRequest; +import com.example.workutils.biz.mybatisgenerator.application.GenerateArtifactsUseCase; +import com.example.workutils.biz.mybatisgenerator.application.QueryTablesRequest; +import com.example.workutils.biz.mybatisgenerator.application.QueryTablesUseCase; +import com.example.workutils.biz.mybatisgenerator.presentation.DatasourceSummary; import com.example.workutils.system.config.WorkUtilsProperties; +import com.example.workutils.system.support.AppExecutors; import com.example.workutils.system.support.FileOperationHelper; import com.example.workutils.system.ui.StyledButton; import com.example.workutils.system.ui.Theme; +import com.example.workutils.system.ui.async.AsyncOperationController; +import com.example.workutils.system.ui.async.LoadingButtonBinder; +import com.example.workutils.system.ui.async.OperationStatusPane; import javafx.application.Platform; import javafx.collections.FXCollections; import javafx.collections.ObservableList; @@ -46,6 +55,10 @@ public class MyBatisGeneratorWindow { private final FileOperationHelper fileOperationHelper; private final GeneratorCallback callback; private final WorkUtilsProperties properties; + private final QueryTablesUseCase queryTablesUseCase; + private final GenerateArtifactsUseCase generateArtifactsUseCase; + private final AsyncOperationController> queryOperation; + private final AsyncOperationController generateOperation; // ---- UI 组件 ---- private TextField patternField; @@ -98,6 +111,17 @@ public class MyBatisGeneratorWindow { this.fileOperationHelper = fileOperationHelper; this.properties = properties; this.closeLatch = closeLatch; + this.queryTablesUseCase = new QueryTablesUseCase(callback::queryTables); + this.generateArtifactsUseCase = new GenerateArtifactsUseCase( + request -> callback.generate( + request.tables(), + request.modelDirectory(), + request.xmlDirectory(), + request.mapperDirectory())); + this.queryOperation = new AsyncOperationController<>( + AppExecutors.io(), Platform::runLater); + this.generateOperation = new AsyncOperationController<>( + AppExecutors.io(), Platform::runLater); this.stage = new Stage(); buildUI(); } @@ -240,8 +264,10 @@ public class MyBatisGeneratorWindow { } private void updateDbInfo() { - dbUrlLabel.setText("数据库: " + callback.getDbUrl()); - dbUserLabel.setText("用户: " + callback.getDbUsername()); + DatasourceSummary summary = DatasourceSummary.from( + callback.getCurrentDatasourceName(), callback.getDbUrl()); + dbUrlLabel.setText(summary.displayText()); + dbUserLabel.setText("连接凭据已隐藏,仅在执行数据库操作时使用"); } // ==================== 左侧:项目路径树 ==================== @@ -368,7 +394,10 @@ public class MyBatisGeneratorWindow { patternField.getStyleClass().add(Theme.CSS_TEXT_FIELD); Button queryBtn = StyledButton.primary("查询表", Font.font("Microsoft YaHei", fsButton())); queryBtn.setOnAction(e -> onQueryTables()); + LoadingButtonBinder.bind(queryBtn, queryOperation, "查询表"); patternBox.getChildren().addAll(patternField, queryBtn); + OperationStatusPane queryStatus = new OperationStatusPane( + queryOperation, fsBody() - 1, false); Label tableLabel = new Label("匹配表 (勾选要生成的表):"); tableLabel.setFont(Font.font("Microsoft YaHei", fsLabel())); @@ -386,7 +415,9 @@ public class MyBatisGeneratorWindow { VBox dirBox = createDirectorySection(); - panel.getChildren().addAll(patternLabel, patternBox, tableLabel, tableListView, selectBox, dirBox); + panel.getChildren().addAll( + patternLabel, patternBox, queryStatus, + tableLabel, tableListView, selectBox, dirBox); VBox.setVgrow(tableListView, Priority.ALWAYS); return panel; } @@ -507,51 +538,57 @@ public class MyBatisGeneratorWindow { } private void onQueryTables() { - String pattern = patternField.getText().trim(); - if (pattern.isEmpty()) { - showAlert("请输入表名通配符"); - return; - } - try { - List tables = callback.queryTables(pattern); - tableItems.clear(); - if (tables.isEmpty()) { - showAlert("未找到匹配通配符 [" + pattern + "] 的表"); - } else { - for (String t : tables) { - tableItems.add(new TableItem(t, true)); - } + QueryTablesRequest request = + new QueryTablesRequest(patternField.getText()); + queryOperation.start( + "正在查询数据库表", + context -> queryTablesUseCase.execute(request), + tables -> applyQueriedTables(request, tables), + error -> showAlert("查询失败: " + error.getMessage())); + } catch (IllegalArgumentException error) { + showAlert(error.getMessage()); + } + } + + private void applyQueriedTables( + QueryTablesRequest request, List tables) { + tableItems.clear(); + if (tables.isEmpty()) { + showAlert("未找到匹配通配符 [" + request.pattern() + "] 的表"); + } else { + for (String table : tables) { + tableItems.add(new TableItem(table, true)); } - } catch (Exception e) { - showAlert("查询失败: " + e.getMessage()); } } private void onGenerate() { - List selectedTables = tableItems.stream() - .filter(TableItem::isSelected) - .map(TableItem::getTableName) - .collect(Collectors.toList()); - - if (selectedTables.isEmpty()) { - showAlert("请至少选择一个表"); - return; - } - - String modelDir = getDirText(modelDirField); - String xmlDir = getDirText(xmlDirField); - String mapperDir = getDirText(mapperDirField); - try { - String result = callback.generate(selectedTables, modelDir, xmlDir, mapperDir); - showInfo("生成结果", result); - } catch (Exception e) { - showAlert("生成失败: " + e.getMessage()); - e.printStackTrace(); + GenerateArtifactsRequest request = new GenerateArtifactsRequest( + tableItems.stream() + .filter(TableItem::isSelected) + .map(TableItem::getTableName) + .collect(Collectors.toList()), + getDirText(modelDirField), + getDirText(xmlDirField), + getDirText(mapperDirField)); + generateOperation.start( + "正在生成代码", + context -> generateArtifactsUseCase.execute(request), + result -> showInfo("生成结果", result), + error -> showAlert("生成失败: " + error.getMessage())); + } catch (IllegalArgumentException error) { + showAlert(error.getMessage()); } } + private void resetForm() { + patternField.clear(); + tableItems.clear(); + loadDefaultDirs(); + } + private String getDirText(TextField field) { String text = field == null ? "" : field.getText().trim(); return text.isEmpty() ? "./generator" : text; @@ -566,12 +603,16 @@ public class MyBatisGeneratorWindow { Button generateBtn = StyledButton.primary("生成", Font.font("Microsoft YaHei", fsButton() + 1)); generateBtn.setPrefWidth(100); generateBtn.setOnAction(e -> onGenerate()); + LoadingButtonBinder.bind(generateBtn, generateOperation, "生成"); - Button cancelBtn = StyledButton.secondary("取消", Font.font("Microsoft YaHei", fsButton())); - cancelBtn.setPrefWidth(80); - cancelBtn.setOnAction(e -> close()); + Button resetBtn = StyledButton.secondary( + "重置配置", Font.font("Microsoft YaHei", fsButton())); + resetBtn.setPrefWidth(100); + resetBtn.setOnAction(e -> resetForm()); + OperationStatusPane generateStatus = new OperationStatusPane( + generateOperation, fsBody() - 1, false); - box.getChildren().addAll(generateBtn, cancelBtn); + box.getChildren().addAll(generateStatus, generateBtn, resetBtn); return box; } diff --git a/src/main/java/com/example/workutils/biz/mybatisgenerator/application/GenerateArtifactsRequest.java b/src/main/java/com/example/workutils/biz/mybatisgenerator/application/GenerateArtifactsRequest.java new file mode 100644 index 0000000..ff6cdb3 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/mybatisgenerator/application/GenerateArtifactsRequest.java @@ -0,0 +1,27 @@ +package com.example.workutils.biz.mybatisgenerator.application; + +import java.util.List; +import java.util.Objects; + +public record GenerateArtifactsRequest( + List tables, + String modelDirectory, + String xmlDirectory, + String mapperDirectory) { + + public GenerateArtifactsRequest { + tables = List.copyOf(Objects.requireNonNull(tables, "tables")); + if (tables.isEmpty()) { + throw new IllegalArgumentException("请至少选择一个表"); + } + modelDirectory = directoryOrDefault(modelDirectory); + xmlDirectory = directoryOrDefault(xmlDirectory); + mapperDirectory = directoryOrDefault(mapperDirectory); + } + + private static String directoryOrDefault(String value) { + return value == null || value.isBlank() + ? "./generator" + : value.trim(); + } +} diff --git a/src/main/java/com/example/workutils/biz/mybatisgenerator/application/GenerateArtifactsUseCase.java b/src/main/java/com/example/workutils/biz/mybatisgenerator/application/GenerateArtifactsUseCase.java new file mode 100644 index 0000000..36df585 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/mybatisgenerator/application/GenerateArtifactsUseCase.java @@ -0,0 +1,21 @@ +package com.example.workutils.biz.mybatisgenerator.application; + +import java.util.Objects; + +public final class GenerateArtifactsUseCase { + + @FunctionalInterface + public interface Gateway { + String generate(GenerateArtifactsRequest request) throws Exception; + } + + private final Gateway gateway; + + public GenerateArtifactsUseCase(Gateway gateway) { + this.gateway = Objects.requireNonNull(gateway, "gateway"); + } + + public String execute(GenerateArtifactsRequest request) throws Exception { + return gateway.generate(Objects.requireNonNull(request, "request")); + } +} diff --git a/src/main/java/com/example/workutils/biz/mybatisgenerator/application/QueryTablesRequest.java b/src/main/java/com/example/workutils/biz/mybatisgenerator/application/QueryTablesRequest.java new file mode 100644 index 0000000..0bf26a8 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/mybatisgenerator/application/QueryTablesRequest.java @@ -0,0 +1,13 @@ +package com.example.workutils.biz.mybatisgenerator.application; + +import java.util.Objects; + +public record QueryTablesRequest(String pattern) { + + public QueryTablesRequest { + pattern = Objects.requireNonNull(pattern, "pattern").trim(); + if (pattern.isEmpty()) { + throw new IllegalArgumentException("请输入表名通配符"); + } + } +} diff --git a/src/main/java/com/example/workutils/biz/mybatisgenerator/application/QueryTablesUseCase.java b/src/main/java/com/example/workutils/biz/mybatisgenerator/application/QueryTablesUseCase.java new file mode 100644 index 0000000..91eb3ab --- /dev/null +++ b/src/main/java/com/example/workutils/biz/mybatisgenerator/application/QueryTablesUseCase.java @@ -0,0 +1,23 @@ +package com.example.workutils.biz.mybatisgenerator.application; + +import java.util.List; +import java.util.Objects; + +public final class QueryTablesUseCase { + + @FunctionalInterface + public interface Gateway { + List query(String pattern) throws Exception; + } + + private final Gateway gateway; + + public QueryTablesUseCase(Gateway gateway) { + this.gateway = Objects.requireNonNull(gateway, "gateway"); + } + + public List execute(QueryTablesRequest request) throws Exception { + Objects.requireNonNull(request, "request"); + return List.copyOf(gateway.query(request.pattern())); + } +} diff --git a/src/main/java/com/example/workutils/biz/mybatisgenerator/presentation/DatasourceSummary.java b/src/main/java/com/example/workutils/biz/mybatisgenerator/presentation/DatasourceSummary.java new file mode 100644 index 0000000..e678817 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/mybatisgenerator/presentation/DatasourceSummary.java @@ -0,0 +1,73 @@ +package com.example.workutils.biz.mybatisgenerator.presentation; + +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public record DatasourceSummary( + String alias, + String databaseType, + String maskedHost, + String databaseName) { + + private static final Pattern JDBC_URL = Pattern.compile( + "^jdbc:([^:]+)://([^/]+)/([^?;]+).*$", + Pattern.CASE_INSENSITIVE); + + public static DatasourceSummary from(String alias, String jdbcUrl) { + String safeAlias = alias == null || alias.isBlank() + ? "默认配置" + : alias.trim(); + Matcher matcher = JDBC_URL.matcher( + jdbcUrl == null ? "" : jdbcUrl.trim()); + if (!matcher.matches()) { + return new DatasourceSummary( + safeAlias, "数据库", "地址已隐藏", "数据库名未知"); + } + String type = databaseType(matcher.group(1)); + String authority = matcher.group(2); + int at = authority.lastIndexOf('@'); + if (at >= 0) { + authority = authority.substring(at + 1); + } + String host = authority; + String port = ""; + int colon = authority.lastIndexOf(':'); + if (colon > 0) { + host = authority.substring(0, colon); + port = authority.substring(colon); + } + return new DatasourceSummary( + safeAlias, + type, + maskHost(host) + port, + matcher.group(3)); + } + + public String displayText() { + return alias + " · " + databaseType + " · " + + maskedHost + "/" + databaseName; + } + + private static String databaseType(String protocol) { + String value = protocol.toLowerCase(Locale.ROOT); + if (value.contains("oceanbase")) { + return "OceanBase"; + } + if (value.contains("mysql")) { + return "MySQL"; + } + return protocol.toUpperCase(Locale.ROOT); + } + + private static String maskHost(String host) { + if (host.matches("\\d+(\\.\\d+){3}")) { + String[] parts = host.split("\\."); + return parts[0] + ".…" + parts[3]; + } + if (host.length() <= 6) { + return host; + } + return host.substring(0, 3) + "…" + host.substring(host.length() - 3); + } +} 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 58ca8a7..710c5f0 100644 --- a/src/main/java/com/example/workutils/biz/scanner/ScannerWindow.java +++ b/src/main/java/com/example/workutils/biz/scanner/ScannerWindow.java @@ -1,13 +1,22 @@ package com.example.workutils.biz.scanner; +import com.example.workutils.biz.scanner.application.RunScanUseCase; +import com.example.workutils.biz.scanner.application.ScanRequest; import com.example.workutils.biz.scanner.service.ScannerService; import com.example.workutils.system.config.WorkUtilsProperties; +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; +import com.example.workutils.system.ui.async.LoadingButtonBinder; +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 javafx.application.Platform; import javafx.collections.FXCollections; import javafx.collections.ObservableList; @@ -15,7 +24,6 @@ import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.Scene; import javafx.scene.control.*; -import javafx.scene.control.cell.PropertyValueFactory; import javafx.scene.layout.*; import javafx.scene.Parent; import javafx.stage.Stage; @@ -39,16 +47,16 @@ public class ScannerWindow { private final ScannerService scannerService; private final WorkUtilsProperties properties; private final OutputHelper outputHelper; + private final RunScanUseCase runScanUseCase; + private final AsyncOperationController scanOperation; // UI private CheckBox regexCb; private TextField patternField; private Button scanBtn; - private ProgressBar progressBar; - private Label progressLabel; private TextArea logArea; - private TableView historyTable; private final ObservableList historyRows = FXCollections.observableArrayList(); + private final ObservableList historyItems = FXCollections.observableArrayList(); private static final SimpleDateFormat SDF = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); private int historyCounter = 0; @@ -62,7 +70,14 @@ public class ScannerWindow { this.outputHelper = outputHelper; this.properties = properties; this.closeLatch = closeLatch; + this.runScanUseCase = new RunScanUseCase(scannerService, outputHelper); + this.scanOperation = new AsyncOperationController<>( + AppExecutors.io(), Platform::runLater); this.historyPersistence = new HistoryPersistence("scanner_history.txt"); + historyRows.addListener( + (javafx.collections.ListChangeListener) change -> + historyItems.setAll(historyRows.stream() + .map(ScannerWindow::toHistoryItem).toList())); this.stage = new Stage(); buildUI(); loadHistory(); @@ -128,61 +143,23 @@ public class ScannerWindow { scanBtn = StyledButton.primary("开始扫描", Theme.bold(fsBtn())); scanBtn.setOnAction(e -> startScan()); + LoadingButtonBinder.bind(scanBtn, scanOperation, "开始扫描"); patternRow.getChildren().addAll(patternLabel, patternField, scanBtn); configCard.getChildren().addAll(typeRow, patternRow); - // === 进度行 === - HBox progressRow = new HBox(Theme.S2); - progressRow.setAlignment(Pos.CENTER_LEFT); - progressBar = new ProgressBar(0); - progressBar.setPrefWidth(400); - progressBar.setPrefHeight(22); - progressLabel = new Label("就绪"); - progressLabel.setFont(Theme.base(fsBody() - 1)); - progressLabel.setTextFill(Theme.TEXT_SECONDARY); - progressRow.getChildren().addAll(progressBar, progressLabel); + OperationStatusPane progressRow = new OperationStatusPane( + scanOperation, fsBody() - 1, false); // === 标题 === Label tableTitle = new Label("扫描导出历史"); tableTitle.setFont(Theme.heading(fsSection())); tableTitle.setTextFill(Theme.TEXT_PRIMARY); - // === 历史记录表格 === - historyTable = new TableView<>(); - historyTable.setItems(historyRows); - historyTable.setColumnResizePolicy(TableView.UNCONSTRAINED_RESIZE_POLICY); - historyTable.setPlaceholder(new Label("暂无扫描记录")); - historyTable.setStyle("-fx-background-color: white;" - + "-fx-border-color: " + Theme.toWeb(Theme.BORDER) + ";" - + "-fx-border-width: 1; -fx-border-radius: " + Theme.RADIUS_SM + ";"); - - // 列定义 - TableColumn colIndex = new TableColumn<>("序号"); - colIndex.setCellValueFactory(new PropertyValueFactory<>("index")); - - TableColumn colQuery = new TableColumn<>("查询条件"); - colQuery.setCellValueFactory(new PropertyValueFactory<>("queryCondition")); - - TableColumn colFile = new TableColumn<>("生成文件"); - colFile.setCellValueFactory(new PropertyValueFactory<>("fileDisplay")); - - TableColumn colCount = new TableColumn<>("匹配数"); - colCount.setCellValueFactory(new PropertyValueFactory<>("matchCount")); - - TableColumn colTime = new TableColumn<>("时间"); - colTime.setCellValueFactory(new PropertyValueFactory<>("timestamp")); - - TableColumn colAction = new TableColumn<>("操作"); - colAction.setCellValueFactory(new PropertyValueFactory<>("actionLabel")); - colAction.setCellFactory(col -> buildActionCell()); - colAction.setPrefWidth(170); - colAction.setResizable(false); - - historyTable.getColumns().addAll(colAction, colIndex, colQuery, colFile, colCount, colTime); - - VBox.setVgrow(historyTable, Priority.ALWAYS); + ExportHistoryPane historyPane = new ExportHistoryPane( + "匹配数", historyItems, historyFileCommands(), fsBody()); + VBox.setVgrow(historyPane, Priority.ALWAYS); // === 日志区 === logArea = new TextArea(); @@ -191,82 +168,22 @@ public class ScannerWindow { logArea.setFont(Theme.mono(properties.getFontMono())); logArea.getStyleClass().add(Theme.CSS_LOG_AREA); - root.getChildren().addAll(configCard, progressRow, tableTitle, historyTable, logArea); + root.getChildren().addAll(configCard, progressRow, tableTitle, historyPane, logArea); stage.setScene(new Scene(root)); } - /** - * 构建操作列单元格,包含「打开」按钮和右键菜单 - */ - private TableCell buildActionCell() { - TableCell cell = new TableCell() { - private final Button openBtn = new Button("打开"); - private final Button dirBtn = new Button("目录"); - private final HBox btnBox = new HBox(4, openBtn, dirBtn); - { setAlignment(Pos.CENTER); btnBox.setAlignment(Pos.CENTER); } - private final ContextMenu menu = new ContextMenu(); - private String currentFileName; - - { - openBtn.setFont(Theme.base(fsBody() - 2)); - openBtn.setStyle("-fx-background-color: " + Theme.toWeb(Theme.PRIMARY) + ";" - + "-fx-text-fill: white; -fx-cursor: hand;" - + "-fx-background-radius: " + Theme.RADIUS_SM + "; -fx-padding: 3 8;"); - - dirBtn.setFont(Theme.base(fsBody() - 2)); - dirBtn.setStyle("-fx-background-color: " + Theme.toWeb(Theme.BORDER) + ";" - + "-fx-text-fill: " + Theme.toWeb(Theme.TEXT_PRIMARY) + "; -fx-cursor: hand;" - + "-fx-background-radius: " + Theme.RADIUS_SM + "; -fx-padding: 3 6;"); - dirBtn.setTooltip(new Tooltip("打开文件所在目录")); - - // 右键菜单选项 - MenuItem defaultOpen = new MenuItem("系统默认程序打开"); - defaultOpen.setOnAction(e -> openFile(currentFileName)); - MenuItem openNotepad = new MenuItem("用系统编辑器打开"); - openNotepad.setOnAction(e -> openWithNotepad(currentFileName)); - MenuItem openFolder = new MenuItem("打开所在文件夹"); - openFolder.setOnAction(e -> openFolder(currentFileName)); - MenuItem copyPath = new MenuItem("复制文件路径"); - copyPath.setOnAction(e -> copyFilePath(currentFileName)); - menu.getItems().addAll(defaultOpen, openNotepad, openFolder, copyPath); - - // 左键「打开」→ 系统默认程序打开 - openBtn.setOnAction(e -> openFile(currentFileName)); - // 左键「📁」→ 打开所在目录 - dirBtn.setOnAction(e -> openFolder(currentFileName)); - // 右键点击 → 弹出菜单 - btnBox.setOnContextMenuRequested(e -> { - menu.show(btnBox, e.getScreenX(), e.getScreenY()); + 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"); }); - } - - @Override - protected void updateItem(String item, boolean empty) { - super.updateItem(item, empty); - if (empty) { - setGraphic(null); - currentFileName = null; - } else { - // 从表格数据中获取当前行并缓存文件路径 - int idx = getIndex(); - if (idx >= 0 && getTableView() != null) { - HistoryRow row = getTableView().getItems().get(idx); - currentFileName = row != null ? row.getFileName() : null; - } - setGraphic(btnBox); - } - } - - private void copyFilePath(String path) { - if (path == null) return; - javafx.scene.input.Clipboard.getSystemClipboard() - .setContent(new javafx.scene.input.ClipboardContent() {{ - putString(path); - }}); - logArea.appendText("[INFO] 已复制路径: " + path + "\n"); - } - }; - return cell; } // ==================== 历史持久化 ==================== @@ -304,59 +221,36 @@ public class ScannerWindow { } private void startScan() { - String pattern = patternField.getText().trim(); - if (pattern.isEmpty()) { - logArea.appendText("[WARN] 请输入匹配模式\n"); + final ScanRequest request; + try { + request = ScanRequest.create(patternField.getText(), regexCb.isSelected()); + } catch (RuntimeException error) { + logArea.appendText("[WARN] " + error.getMessage() + "\n"); return; } - boolean isRegex = regexCb.isSelected(); - String modeLabel = isRegex ? "正则" : "关键词"; - - scanBtn.setDisable(true); - progressBar.setProgress(ProgressBar.INDETERMINATE_PROGRESS); - progressLabel.setText("正在扫描..."); logArea.clear(); - logArea.appendText("[" + modeLabel + "] 模式: " + pattern + "\n"); + logArea.appendText("[" + request.modeLabel() + "] 模式: " + request.pattern() + "\n"); - new Thread(() -> { - try { - ScannerService.ScanResult result = scannerService.scanAllProjects(ScannerService.ScanType.KEYWORD, pattern); - int totalMatches = result.getTotalMatches(); + scanOperation.start("正在扫描", context -> runScanUseCase.execute(request, context), + result -> completeScan(request, result), + error -> logArea.appendText("[ERROR] " + error.getMessage() + "\n")); + } - // 自动导出到 CSV - String fileName = outputHelper.generateOutputFileName("scan_result", "csv"); - scannerService.exportScanResults(result, fileName); + private void completeScan(ScanRequest request, RunScanUseCase.Result result) { + int totalMatches = result.scanResult().getTotalMatches(); + int hc = ++historyCounter; + String timestamp = SDF.format(new Date()); + String queryCondition = request.modeLabel() + "扫描: " + request.pattern(); - int hc = ++historyCounter; - String timestamp = SDF.format(new Date()); - String queryCond = modeLabel + "扫描: " + pattern; + historyRows.add(0, new HistoryRow( + String.valueOf(hc), queryCondition, result.fileName(), + totalMatches, timestamp, "打开")); + saveHistory(); - Platform.runLater(() -> { - progressBar.setProgress(1); - progressLabel.setText("扫描完成"); - scanBtn.setDisable(false); - - // 添加历史记录行(最新在前) - historyRows.add(0, new HistoryRow( - String.valueOf(hc), queryCond, fileName, - totalMatches, timestamp, "打开")); - saveHistory(); - - logArea.appendText(String.format("扫描完成: %d 个项目, %d 条匹配\n", - result.getProjectResults().size(), totalMatches)); - logArea.appendText("已导出: " + fileName + "\n"); - }); - - } catch (Exception ex) { - Platform.runLater(() -> { - progressBar.setProgress(1); - progressLabel.setText("扫描失败"); - logArea.appendText("[ERROR] " + ex.getMessage() + "\n"); - scanBtn.setDisable(false); - }); - } - }, "Scanner-Worker").start(); + logArea.appendText(String.format("扫描完成: %d 个项目, %d 条匹配\n", + result.scanResult().getProjectResults().size(), totalMatches)); + logArea.appendText("已导出: " + result.fileName() + "\n"); } // ==================== 文件打开工具方法 ==================== @@ -409,6 +303,15 @@ public class ScannerWindow { // ==================== 数据行 ==================== + static HistoryItem toHistoryItem(HistoryRow row) { + return new HistoryItem( + row.getIndex(), + row.getQueryCondition(), + row.getFileName(), + row.getMatchCount(), + row.getTimestamp()); + } + public static class HistoryRow { private final String index; private final String queryCondition; diff --git a/src/main/java/com/example/workutils/biz/scanner/application/RunScanUseCase.java b/src/main/java/com/example/workutils/biz/scanner/application/RunScanUseCase.java new file mode 100644 index 0000000..0fab926 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/scanner/application/RunScanUseCase.java @@ -0,0 +1,30 @@ +package com.example.workutils.biz.scanner.application; + +import com.example.workutils.biz.scanner.service.ScannerService; +import com.example.workutils.system.support.OutputHelper; +import com.example.workutils.system.ui.async.OperationContext; + +public final class RunScanUseCase { + + private final ScannerService scannerService; + private final OutputHelper outputHelper; + + public RunScanUseCase(ScannerService scannerService, OutputHelper outputHelper) { + this.scannerService = scannerService; + this.outputHelper = outputHelper; + } + + public Result execute(ScanRequest request, OperationContext context) throws Exception { + context.report("正在扫描项目", -1); + ScannerService.ScanResult scanResult = scannerService.scanAllProjects( + ScannerService.ScanType.KEYWORD, request.effectivePattern()); + + context.report("正在生成 CSV", -1); + String fileName = outputHelper.generateOutputFileName("scan_result", "csv"); + scannerService.exportScanResults(scanResult, fileName); + return new Result(scanResult, fileName); + } + + public record Result(ScannerService.ScanResult scanResult, String fileName) { + } +} diff --git a/src/main/java/com/example/workutils/biz/scanner/application/ScanProgress.java b/src/main/java/com/example/workutils/biz/scanner/application/ScanProgress.java new file mode 100644 index 0000000..32857bc --- /dev/null +++ b/src/main/java/com/example/workutils/biz/scanner/application/ScanProgress.java @@ -0,0 +1,4 @@ +package com.example.workutils.biz.scanner.application; + +public record ScanProgress(String message, double progress) { +} diff --git a/src/main/java/com/example/workutils/biz/scanner/application/ScanRequest.java b/src/main/java/com/example/workutils/biz/scanner/application/ScanRequest.java new file mode 100644 index 0000000..0901fa3 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/scanner/application/ScanRequest.java @@ -0,0 +1,25 @@ +package com.example.workutils.biz.scanner.application; + +import java.util.regex.Pattern; + +public record ScanRequest(String pattern, boolean regularExpression) { + + public static ScanRequest create(String pattern, boolean regularExpression) { + String normalized = pattern == null ? "" : pattern.trim(); + if (normalized.isEmpty()) { + throw new IllegalArgumentException("请输入匹配模式"); + } + if (regularExpression) { + Pattern.compile(normalized); + } + return new ScanRequest(normalized, regularExpression); + } + + public String effectivePattern() { + return regularExpression ? pattern : Pattern.quote(pattern); + } + + public String modeLabel() { + return regularExpression ? "正则" : "关键词"; + } +} diff --git a/src/main/java/com/example/workutils/system/support/AppExecutors.java b/src/main/java/com/example/workutils/system/support/AppExecutors.java new file mode 100644 index 0000000..b550ad6 --- /dev/null +++ b/src/main/java/com/example/workutils/system/support/AppExecutors.java @@ -0,0 +1,28 @@ +package com.example.workutils.system.support; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.atomic.AtomicInteger; + +public final class AppExecutors { + + private static final ExecutorService IO = Executors.newCachedThreadPool( + namedDaemonFactory("workutils-io-")); + + private AppExecutors() { + } + + public static ExecutorService io() { + return IO; + } + + private static ThreadFactory namedDaemonFactory(String prefix) { + AtomicInteger sequence = new AtomicInteger(); + return runnable -> { + Thread thread = new Thread(runnable, prefix + sequence.incrementAndGet()); + thread.setDaemon(true); + return thread; + }; + } +} diff --git a/src/main/java/com/example/workutils/system/ui/EmptyStatePane.java b/src/main/java/com/example/workutils/system/ui/EmptyStatePane.java new file mode 100644 index 0000000..ec5375a --- /dev/null +++ b/src/main/java/com/example/workutils/system/ui/EmptyStatePane.java @@ -0,0 +1,50 @@ +package com.example.workutils.system.ui; + +import javafx.geometry.Pos; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.layout.VBox; + +import java.util.Objects; + +public final class EmptyStatePane extends VBox { + + public record Content(String title, String description, String actionLabel) { + public Content { + title = Objects.requireNonNull(title, "title").trim(); + description = Objects.requireNonNull(description, "description").trim(); + actionLabel = Objects.requireNonNull(actionLabel, "actionLabel").trim(); + if (title.isEmpty()) { + throw new IllegalArgumentException("title must not be blank"); + } + } + } + + public EmptyStatePane(Content content, Runnable action, int fontSize) { + super(Theme.S2); + Objects.requireNonNull(content, "content"); + setAlignment(Pos.CENTER); + getStyleClass().add("empty-state"); + + Label title = new Label(content.title()); + title.setFont(Theme.heading(fontSize)); + title.getStyleClass().add("empty-state-title"); + + Label description = new Label(content.description()); + description.setFont(Theme.base(fontSize - 1)); + description.setWrapText(true); + description.getStyleClass().add("empty-state-description"); + getChildren().addAll(title, description); + + if (!content.actionLabel().isEmpty()) { + if (action == null) { + throw new IllegalArgumentException( + "action is required when actionLabel is present"); + } + Button button = StyledButton.secondary( + content.actionLabel(), Theme.base(fontSize - 1)); + button.setOnAction(event -> action.run()); + getChildren().add(button); + } + } +} diff --git a/src/main/java/com/example/workutils/system/ui/async/AsyncOperationController.java b/src/main/java/com/example/workutils/system/ui/async/AsyncOperationController.java new file mode 100644 index 0000000..e7bb479 --- /dev/null +++ b/src/main/java/com/example/workutils/system/ui/async/AsyncOperationController.java @@ -0,0 +1,105 @@ +package com.example.workutils.system.ui.async; + +import javafx.beans.property.ReadOnlyBooleanProperty; +import javafx.beans.property.ReadOnlyBooleanWrapper; +import javafx.beans.property.ReadOnlyObjectProperty; +import javafx.beans.property.ReadOnlyObjectWrapper; + +import java.util.Objects; +import java.util.concurrent.Executor; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; + +public final class AsyncOperationController { + + private final Executor executor; + private final Consumer uiDispatcher; + private final OperationStateMachine stateMachine = new OperationStateMachine(); + private final AtomicBoolean cancellationRequested = new AtomicBoolean(); + private final ReadOnlyObjectWrapper snapshot = + new ReadOnlyObjectWrapper<>(OperationSnapshot.idle()); + private final ReadOnlyBooleanWrapper running = new ReadOnlyBooleanWrapper(false); + + public AsyncOperationController(Executor executor, Consumer uiDispatcher) { + this.executor = Objects.requireNonNull(executor, "executor"); + this.uiDispatcher = Objects.requireNonNull(uiDispatcher, "uiDispatcher"); + } + + public void start(String runningMessage, OperationTask task, Consumer onSuccess) { + start(runningMessage, task, onSuccess, ignored -> { }); + } + + public void start(String runningMessage, OperationTask task, + Consumer onSuccess, Consumer onFailure) { + Objects.requireNonNull(task, "task"); + if (running.get()) { + throw new IllegalStateException("Operation is already running"); + } + prepareStart(runningMessage); + + executor.execute(() -> { + OperationContext context = new OperationContext( + (message, progress) -> dispatchSnapshot( + () -> stateMachine.progress(message, progress)), + cancellationRequested::get); + try { + T result = task.execute(context); + uiDispatcher.accept(() -> { + publish(stateMachine.succeed( + cancellationRequested.get() ? "已停止" : "操作完成")); + running.set(false); + onSuccess.accept(result); + }); + } catch (Throwable error) { + uiDispatcher.accept(() -> { + publish(stateMachine.fail(readableMessage(error))); + running.set(false); + onFailure.accept(error); + }); + } + }); + } + + public void cancel() { + if (!running.get()) return; + cancellationRequested.set(true); + publish(stateMachine.requestCancel()); + } + + public OperationSnapshot snapshot() { + return snapshot.get(); + } + + public ReadOnlyObjectProperty snapshotProperty() { + return snapshot.getReadOnlyProperty(); + } + + public ReadOnlyBooleanProperty runningProperty() { + return running.getReadOnlyProperty(); + } + + private void prepareStart(String message) { + OperationState current = stateMachine.snapshot().state(); + if (current == OperationState.SUCCEEDED || current == OperationState.FAILED) { + stateMachine.reset(); + } + cancellationRequested.set(false); + publish(stateMachine.start(message)); + running.set(true); + } + + private void dispatchSnapshot(java.util.function.Supplier transition) { + uiDispatcher.accept(() -> publish(transition.get())); + } + + private void publish(OperationSnapshot value) { + snapshot.set(value); + } + + private String readableMessage(Throwable error) { + String message = error.getMessage(); + return message == null || message.isBlank() + ? error.getClass().getSimpleName() + : message; + } +} diff --git a/src/main/java/com/example/workutils/system/ui/async/LoadingButtonBinder.java b/src/main/java/com/example/workutils/system/ui/async/LoadingButtonBinder.java new file mode 100644 index 0000000..2bf6d03 --- /dev/null +++ b/src/main/java/com/example/workutils/system/ui/async/LoadingButtonBinder.java @@ -0,0 +1,64 @@ +package com.example.workutils.system.ui.async; + +import javafx.beans.value.ChangeListener; +import javafx.scene.Node; +import javafx.scene.control.Button; +import javafx.scene.control.ProgressIndicator; + +import java.util.Objects; + +public final class LoadingButtonBinder { + + private LoadingButtonBinder() { + } + + public static void bind(Button button, AsyncOperationController controller, + String idleText) { + Objects.requireNonNull(button, "button"); + Objects.requireNonNull(controller, "controller"); + Node idleGraphic = button.getGraphic(); + ProgressIndicator spinner = createSpinner(); + + ChangeListener listener = + (observable, oldValue, snapshot) -> + apply(button, idleGraphic, spinner, snapshot, idleText); + controller.snapshotProperty().addListener(listener); + apply(button, idleGraphic, spinner, controller.snapshot(), idleText); + } + + public static String textFor(OperationSnapshot snapshot, String idleText) { + return switch (snapshot.state()) { + case RUNNING -> { + String message = snapshot.message(); + yield (message == null || message.isBlank() ? "处理中" : trimEllipsis(message)) + "…"; + } + case CANCELLING -> "正在停止…"; + default -> idleText; + }; + } + + private static void apply(Button button, Node idleGraphic, ProgressIndicator spinner, + OperationSnapshot snapshot, String idleText) { + boolean busy = snapshot.state() == OperationState.RUNNING + || snapshot.state() == OperationState.CANCELLING; + button.setText(textFor(snapshot, idleText)); + button.setDisable(busy); + button.setGraphic(busy ? spinner : idleGraphic); + } + + private static String trimEllipsis(String message) { + String trimmed = message.trim(); + while (trimmed.endsWith("…")) { + trimmed = trimmed.substring(0, trimmed.length() - 1); + } + return trimmed; + } + + private static ProgressIndicator createSpinner() { + ProgressIndicator spinner = new ProgressIndicator(); + spinner.setPrefSize(14, 14); + spinner.setMinSize(14, 14); + spinner.setMaxSize(14, 14); + return spinner; + } +} diff --git a/src/main/java/com/example/workutils/system/ui/async/OperationContext.java b/src/main/java/com/example/workutils/system/ui/async/OperationContext.java new file mode 100644 index 0000000..0b5a809 --- /dev/null +++ b/src/main/java/com/example/workutils/system/ui/async/OperationContext.java @@ -0,0 +1,28 @@ +package com.example.workutils.system.ui.async; + +import java.util.function.BooleanSupplier; +import java.util.function.BiConsumer; + +public final class OperationContext { + + private final BiConsumer progressReporter; + private final BooleanSupplier cancellationRequested; + + OperationContext(BiConsumer progressReporter, + BooleanSupplier cancellationRequested) { + this.progressReporter = progressReporter; + this.cancellationRequested = cancellationRequested; + } + + public static OperationContext none() { + return new OperationContext((message, progress) -> {}, () -> false); + } + + public void report(String message, double progress) { + progressReporter.accept(message, progress); + } + + public boolean isCancellationRequested() { + return cancellationRequested.getAsBoolean(); + } +} diff --git a/src/main/java/com/example/workutils/system/ui/async/OperationSnapshot.java b/src/main/java/com/example/workutils/system/ui/async/OperationSnapshot.java new file mode 100644 index 0000000..17762f7 --- /dev/null +++ b/src/main/java/com/example/workutils/system/ui/async/OperationSnapshot.java @@ -0,0 +1,8 @@ +package com.example.workutils.system.ui.async; + +public record OperationSnapshot(OperationState state, String message, double progress) { + + public static OperationSnapshot idle() { + return new OperationSnapshot(OperationState.IDLE, "", 0); + } +} diff --git a/src/main/java/com/example/workutils/system/ui/async/OperationState.java b/src/main/java/com/example/workutils/system/ui/async/OperationState.java new file mode 100644 index 0000000..e20d5c8 --- /dev/null +++ b/src/main/java/com/example/workutils/system/ui/async/OperationState.java @@ -0,0 +1,9 @@ +package com.example.workutils.system.ui.async; + +public enum OperationState { + IDLE, + RUNNING, + CANCELLING, + SUCCEEDED, + FAILED +} diff --git a/src/main/java/com/example/workutils/system/ui/async/OperationStateMachine.java b/src/main/java/com/example/workutils/system/ui/async/OperationStateMachine.java new file mode 100644 index 0000000..5053ddf --- /dev/null +++ b/src/main/java/com/example/workutils/system/ui/async/OperationStateMachine.java @@ -0,0 +1,61 @@ +package com.example.workutils.system.ui.async; + +import java.util.Objects; + +public final class OperationStateMachine { + + private OperationSnapshot snapshot = OperationSnapshot.idle(); + + public synchronized OperationSnapshot snapshot() { + return snapshot; + } + + public synchronized OperationSnapshot start(String message) { + requireState(OperationState.IDLE); + return update(OperationState.RUNNING, message, -1); + } + + public synchronized OperationSnapshot progress(String message, double progress) { + requireState(OperationState.RUNNING); + return update(OperationState.RUNNING, message, normalizeProgress(progress)); + } + + public synchronized OperationSnapshot requestCancel() { + requireState(OperationState.RUNNING); + return update(OperationState.CANCELLING, "正在停止…", snapshot.progress()); + } + + public synchronized OperationSnapshot succeed(String message) { + requireState(OperationState.RUNNING, OperationState.CANCELLING); + return update(OperationState.SUCCEEDED, message, 1); + } + + public synchronized OperationSnapshot fail(String message) { + requireState(OperationState.RUNNING, OperationState.CANCELLING); + return update(OperationState.FAILED, message, snapshot.progress()); + } + + public synchronized OperationSnapshot reset() { + requireState(OperationState.SUCCEEDED, OperationState.FAILED); + snapshot = OperationSnapshot.idle(); + return snapshot; + } + + private OperationSnapshot update(OperationState state, String message, double progress) { + snapshot = new OperationSnapshot(state, Objects.requireNonNullElse(message, ""), progress); + return snapshot; + } + + private double normalizeProgress(double progress) { + if (progress < 0) return -1; + return Math.min(progress, 1); + } + + private void requireState(OperationState... allowed) { + OperationState current = snapshot.state(); + for (OperationState state : allowed) { + if (state == current) return; + } + throw new IllegalStateException("Operation state " + current + " does not allow this transition"); + } +} diff --git a/src/main/java/com/example/workutils/system/ui/async/OperationStatusPane.java b/src/main/java/com/example/workutils/system/ui/async/OperationStatusPane.java new file mode 100644 index 0000000..60bbca5 --- /dev/null +++ b/src/main/java/com/example/workutils/system/ui/async/OperationStatusPane.java @@ -0,0 +1,71 @@ +package com.example.workutils.system.ui.async; + +import com.example.workutils.system.ui.Theme; +import javafx.geometry.Pos; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.ProgressBar; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; + +public final class OperationStatusPane extends HBox { + + private final ProgressBar progressBar = new ProgressBar(0); + private final Label messageLabel = new Label("就绪"); + private final Button cancelButton; + private final boolean cancellable; + + public OperationStatusPane(AsyncOperationController controller, int fontSize) { + this(controller, fontSize, true); + } + + public OperationStatusPane(AsyncOperationController controller, int fontSize, + boolean cancellable) { + super(Theme.S2); + this.cancellable = cancellable; + setAlignment(Pos.CENTER_LEFT); + getStyleClass().add("operation-status"); + + progressBar.setPrefWidth(240); + messageLabel.setFont(Theme.base(fontSize)); + messageLabel.setTextFill(Theme.TEXT_SECONDARY); + HBox.setHgrow(messageLabel, Priority.ALWAYS); + + cancelButton = com.example.workutils.system.ui.StyledButton.danger( + "停止", Theme.base(fontSize)); + cancelButton.setOnAction(event -> controller.cancel()); + + getChildren().addAll(progressBar, messageLabel, cancelButton); + controller.snapshotProperty().addListener( + (observable, oldValue, value) -> render(value)); + render(controller.snapshot()); + } + + private void render(OperationSnapshot snapshot) { + progressBar.setProgress(snapshot.progress()); + messageLabel.setText(snapshot.message().isBlank() ? defaultMessage(snapshot.state()) + : snapshot.message()); + cancelButton.setVisible(cancellable + && snapshot.state() == OperationState.RUNNING); + cancelButton.setManaged(cancelButton.isVisible()); + cancelButton.setDisable(snapshot.state() != OperationState.RUNNING); + + getStyleClass().removeAll( + "operation-status-success", "operation-status-error"); + if (snapshot.state() == OperationState.SUCCEEDED) { + getStyleClass().add("operation-status-success"); + } else if (snapshot.state() == OperationState.FAILED) { + getStyleClass().add("operation-status-error"); + } + } + + private String defaultMessage(OperationState state) { + return switch (state) { + case IDLE -> "就绪"; + case RUNNING -> "处理中…"; + case CANCELLING -> "正在停止…"; + case SUCCEEDED -> "操作完成"; + case FAILED -> "操作失败"; + }; + } +} diff --git a/src/main/java/com/example/workutils/system/ui/async/OperationTask.java b/src/main/java/com/example/workutils/system/ui/async/OperationTask.java new file mode 100644 index 0000000..044df79 --- /dev/null +++ b/src/main/java/com/example/workutils/system/ui/async/OperationTask.java @@ -0,0 +1,6 @@ +package com.example.workutils.system.ui.async; + +@FunctionalInterface +public interface OperationTask { + T execute(OperationContext context) throws Exception; +} diff --git a/src/main/java/com/example/workutils/system/ui/history/ExportHistoryPane.java b/src/main/java/com/example/workutils/system/ui/history/ExportHistoryPane.java new file mode 100644 index 0000000..242e0c2 --- /dev/null +++ b/src/main/java/com/example/workutils/system/ui/history/ExportHistoryPane.java @@ -0,0 +1,130 @@ +package com.example.workutils.system.ui.history; + +import com.example.workutils.system.ui.EmptyStatePane; +import com.example.workutils.system.ui.StyledButton; +import com.example.workutils.system.ui.Theme; +import javafx.beans.property.ReadOnlyStringWrapper; +import javafx.collections.ObservableList; +import javafx.geometry.Pos; +import javafx.scene.control.Button; +import javafx.scene.control.ContextMenu; +import javafx.scene.control.MenuItem; +import javafx.scene.control.TableCell; +import javafx.scene.control.TableColumn; +import javafx.scene.control.TableView; +import javafx.scene.control.Tooltip; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.VBox; + +import java.util.Objects; +import java.util.function.Function; + +public final class ExportHistoryPane extends VBox { + + private final TableView table = new TableView<>(); + + public ExportHistoryPane( + String countColumnTitle, + ObservableList items, + HistoryFileCommands commands, + int fontSize) { + this("查询条件", countColumnTitle, items, commands, fontSize); + } + + public ExportHistoryPane( + String queryColumnTitle, + String countColumnTitle, + ObservableList items, + HistoryFileCommands commands, + int fontSize) { + super(Theme.S2); + Objects.requireNonNull(queryColumnTitle, "queryColumnTitle"); + Objects.requireNonNull(countColumnTitle, "countColumnTitle"); + Objects.requireNonNull(items, "items"); + Objects.requireNonNull(commands, "commands"); + + table.setItems(items); + table.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY); + table.setPlaceholder(new EmptyStatePane( + new EmptyStatePane.Content( + "暂无历史记录", "完成一次任务后,生成的文件会显示在这里。", ""), + null, fontSize)); + table.getStyleClass().add("export-history-table"); + + table.getColumns().add(actionColumn(commands, fontSize)); + table.getColumns().add(textColumn("序号", HistoryItem::index)); + table.getColumns().add(textColumn(queryColumnTitle, HistoryItem::query)); + table.getColumns().add(fileColumn()); + table.getColumns().add(textColumn(countColumnTitle, HistoryItem::count)); + table.getColumns().add(textColumn("时间", HistoryItem::timestamp)); + + VBox.setVgrow(table, Priority.ALWAYS); + getChildren().add(table); + } + + public TableView table() { + return table; + } + + private TableColumn textColumn( + String title, Function value) { + TableColumn column = new TableColumn<>(title); + column.setCellValueFactory(cell -> + new ReadOnlyStringWrapper(value.apply(cell.getValue()))); + return column; + } + + private TableColumn fileColumn() { + TableColumn column = + textColumn("生成文件", item -> item.fileDisplay(42)); + column.setCellFactory(ignored -> new TableCell<>() { + @Override + protected void updateItem(String value, boolean empty) { + super.updateItem(value, empty); + setText(empty ? null : value); + HistoryItem row = empty ? null : getTableRow().getItem(); + setTooltip(row == null ? null : new Tooltip(row.filePath())); + } + }); + return column; + } + + private TableColumn actionColumn( + HistoryFileCommands commands, int fontSize) { + TableColumn column = new TableColumn<>("操作"); + column.setPrefWidth(150); + column.setResizable(false); + column.setCellFactory(ignored -> new TableCell<>() { + private final Button open = StyledButton.small( + "打开", Theme.base(fontSize - 1)); + private final Button folder = StyledButton.small( + "目录", Theme.base(fontSize - 1)); + private final HBox actions = new HBox(4, open, folder); + private final ContextMenu menu = new ContextMenu(); + private HistoryItem row; + + { + actions.setAlignment(Pos.CENTER); + folder.setTooltip(new Tooltip("打开文件所在目录")); + MenuItem edit = new MenuItem("用系统编辑器打开"); + MenuItem copy = new MenuItem("复制文件路径"); + edit.setOnAction(event -> commands.openInEditor(row.filePath())); + copy.setOnAction(event -> commands.copyPath(row.filePath())); + menu.getItems().addAll(edit, copy); + actions.setOnContextMenuRequested(event -> + menu.show(actions, event.getScreenX(), event.getScreenY())); + open.setOnAction(event -> commands.openFile(row.filePath())); + folder.setOnAction(event -> commands.openFolder(row.filePath())); + } + + @Override + protected void updateItem(Void value, boolean empty) { + super.updateItem(value, empty); + row = empty ? null : getTableRow().getItem(); + setGraphic(row == null ? null : actions); + } + }); + return column; + } +} diff --git a/src/main/java/com/example/workutils/system/ui/history/HistoryFileCommands.java b/src/main/java/com/example/workutils/system/ui/history/HistoryFileCommands.java new file mode 100644 index 0000000..caf6069 --- /dev/null +++ b/src/main/java/com/example/workutils/system/ui/history/HistoryFileCommands.java @@ -0,0 +1,39 @@ +package com.example.workutils.system.ui.history; + +import java.util.Objects; +import java.util.function.Consumer; + +public final class HistoryFileCommands { + + private final Consumer openFile; + private final Consumer openInEditor; + private final Consumer openFolder; + private final Consumer copyPath; + + public HistoryFileCommands( + Consumer openFile, + Consumer openInEditor, + Consumer openFolder, + Consumer copyPath) { + this.openFile = Objects.requireNonNull(openFile, "openFile"); + this.openInEditor = Objects.requireNonNull(openInEditor, "openInEditor"); + this.openFolder = Objects.requireNonNull(openFolder, "openFolder"); + this.copyPath = Objects.requireNonNull(copyPath, "copyPath"); + } + + public void openFile(String path) { + openFile.accept(path); + } + + public void openInEditor(String path) { + openInEditor.accept(path); + } + + public void openFolder(String path) { + openFolder.accept(path); + } + + public void copyPath(String path) { + copyPath.accept(path); + } +} diff --git a/src/main/java/com/example/workutils/system/ui/history/HistoryItem.java b/src/main/java/com/example/workutils/system/ui/history/HistoryItem.java new file mode 100644 index 0000000..6b01346 --- /dev/null +++ b/src/main/java/com/example/workutils/system/ui/history/HistoryItem.java @@ -0,0 +1,36 @@ +package com.example.workutils.system.ui.history; + +import java.nio.file.Path; +import java.util.Objects; + +public record HistoryItem( + String index, + String query, + String filePath, + String count, + String timestamp) { + + public HistoryItem { + Objects.requireNonNull(index, "index"); + Objects.requireNonNull(query, "query"); + Objects.requireNonNull(filePath, "filePath"); + Objects.requireNonNull(count, "count"); + Objects.requireNonNull(timestamp, "timestamp"); + } + + public String fileDisplay(int maxLength) { + if (maxLength < 1) { + throw new IllegalArgumentException("maxLength must be positive"); + } + if (filePath.length() <= maxLength) { + return filePath; + } + String fileName = Path.of(filePath).getFileName().toString(); + String shortened = "…/" + fileName; + if (shortened.length() <= maxLength) { + return shortened; + } + int visible = Math.max(1, maxLength - 1); + return "…" + fileName.substring(Math.max(0, fileName.length() - visible)); + } +} diff --git a/src/main/resources/css/light-theme.css b/src/main/resources/css/light-theme.css index 3f08373..d0598a9 100644 --- a/src/main/resources/css/light-theme.css +++ b/src/main/resources/css/light-theme.css @@ -425,3 +425,28 @@ .compact-list .list-cell:filled:hover { -fx-background-color: -workutils-card; } +.operation-status { + -fx-padding: 6 0; +} + +.operation-status-success .label { + -fx-text-fill: #059669; +} + +.operation-status-error .label { + -fx-text-fill: #dc2626; +} + +.empty-state { + -fx-padding: 28 20; + -fx-background-color: #f8fafc; + -fx-background-radius: 8; +} + +.empty-state-title { + -fx-text-fill: #1e293b; +} + +.empty-state-description { + -fx-text-fill: #64748b; +} diff --git a/src/main/resources/templates/plugins.config b/src/main/resources/templates/plugins.config index ca716cb..fa1b15b 100644 --- a/src/main/resources/templates/plugins.config +++ b/src/main/resources/templates/plugins.config @@ -1,5 +1,5 @@ # ====== WorkUtils 插件配置文件 ====== # 插件顺序(按逗号分隔,顺序即菜单显示顺序) # 插件状态(disabled 列表,不在列表中的即为启用) -plugins.order=scanner,diff,apidoc,git,gitreview,mobileuiauto,mybatisexport,mybatisgenerator -plugins.disabled=mobileuiauto +plugins.order=scan,diff,api-doc,git-sync,git-review,mobile-ui-auto,mybatisexport,mybatisgenerator +plugins.disabled=mobile-ui-auto diff --git a/src/test/java/com/example/workutils/biz/apidoc/ApiDocHistoryMappingTest.java b/src/test/java/com/example/workutils/biz/apidoc/ApiDocHistoryMappingTest.java new file mode 100644 index 0000000..9cb42d5 --- /dev/null +++ b/src/test/java/com/example/workutils/biz/apidoc/ApiDocHistoryMappingTest.java @@ -0,0 +1,24 @@ +package com.example.workutils.biz.apidoc; + +import com.example.workutils.system.ui.history.HistoryItem; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class ApiDocHistoryMappingTest { + + @Test + void mapsHistoryRowToSharedPresentation() { + ApiDocWindow.HistoryRow row = new ApiDocWindow.HistoryRow( + "2", "HTML", "", "C:/out/api.html", + "2026-07-30", "打开"); + + HistoryItem item = ApiDocWindow.toHistoryItem(row); + + assertEquals("2", item.index()); + assertEquals("HTML", item.query()); + assertEquals("C:/out/api.html", item.filePath()); + assertEquals("1", item.count()); + assertEquals("2026-07-30", item.timestamp()); + } +} diff --git a/src/test/java/com/example/workutils/biz/apidoc/application/ApiDocExportRequestTest.java b/src/test/java/com/example/workutils/biz/apidoc/application/ApiDocExportRequestTest.java new file mode 100644 index 0000000..3f38f56 --- /dev/null +++ b/src/test/java/com/example/workutils/biz/apidoc/application/ApiDocExportRequestTest.java @@ -0,0 +1,47 @@ +package com.example.workutils.biz.apidoc.application; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ApiDocExportRequestTest { + + @Test + void pathModeRequiresAPath() { + assertThrows(IllegalArgumentException.class, + () -> ApiDocExportRequest.path(" ", false)); + } + + @Test + void fqnModeTrimsClassName() { + ApiDocExportRequest request = + ApiDocExportRequest.fqn(" com.example.UserFacade ", true); + + assertEquals(ApiDocExportRequest.Mode.FQN, request.mode()); + assertEquals("com.example.UserFacade", request.input()); + assertTrue(request.debugProxyEnabled()); + } + + @Test + void batchModeCopiesProjectPaths() { + List paths = new ArrayList<>(List.of("D:/a")); + ApiDocExportRequest request = ApiDocExportRequest.batch( + paths, true, true, false, false); + + paths.add("D:/b"); + + assertEquals(List.of("D:/a"), request.projectPaths()); + } + + @Test + void batchModeRequiresAtLeastOneProject() { + assertThrows(IllegalArgumentException.class, + () -> ApiDocExportRequest.batch( + List.of(), true, true, true, false)); + } +} diff --git a/src/test/java/com/example/workutils/biz/apidoc/application/RunApiDocExportUseCaseTest.java b/src/test/java/com/example/workutils/biz/apidoc/application/RunApiDocExportUseCaseTest.java new file mode 100644 index 0000000..c8f53e5 --- /dev/null +++ b/src/test/java/com/example/workutils/biz/apidoc/application/RunApiDocExportUseCaseTest.java @@ -0,0 +1,50 @@ +package com.example.workutils.biz.apidoc.application; + +import com.example.workutils.system.ui.async.OperationContext; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class RunApiDocExportUseCaseTest { + + @Test + void disabledDebugDoesNotResolveProxyEndpoint() throws Exception { + AtomicInteger proxyCalls = new AtomicInteger(); + AtomicReference endpoint = new AtomicReference<>(); + RunApiDocExportUseCase useCase = new RunApiDocExportUseCase( + () -> { + proxyCalls.incrementAndGet(); + return "proxy"; + }, + (request, debugEndpoint, context) -> + endpoint.set(debugEndpoint)); + + useCase.execute(ApiDocExportRequest.path("A.java", false), + OperationContext.none()); + + assertEquals(0, proxyCalls.get()); + assertEquals("", endpoint.get()); + } + + @Test + void enabledDebugResolvesProxyEndpointOnce() throws Exception { + AtomicInteger proxyCalls = new AtomicInteger(); + AtomicReference endpoint = new AtomicReference<>(); + RunApiDocExportUseCase useCase = new RunApiDocExportUseCase( + () -> { + proxyCalls.incrementAndGet(); + return "proxy"; + }, + (request, debugEndpoint, context) -> + endpoint.set(debugEndpoint)); + + useCase.execute(ApiDocExportRequest.path("A.java", true), + OperationContext.none()); + + assertEquals(1, proxyCalls.get()); + assertEquals("proxy", endpoint.get()); + } +} diff --git a/src/test/java/com/example/workutils/biz/apidoc/generator/ApiDocGeneratorProxyTest.java b/src/test/java/com/example/workutils/biz/apidoc/generator/ApiDocGeneratorProxyTest.java new file mode 100644 index 0000000..8e8ba14 --- /dev/null +++ b/src/test/java/com/example/workutils/biz/apidoc/generator/ApiDocGeneratorProxyTest.java @@ -0,0 +1,35 @@ +package com.example.workutils.biz.apidoc.generator; + +import com.example.workutils.system.config.WorkUtilsProperties; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class ApiDocGeneratorProxyTest { + + @Test + void defaultGeneratorUsesNoDebugEndpoint() { + ApiDocGenerator generator = + new ApiDocGenerator(new WorkUtilsProperties()); + + assertEquals("", generator.resolveDebugEndpoint()); + } + + @Test + void injectedEndpointIsResolvedLazily() { + AtomicInteger calls = new AtomicInteger(); + ApiDocGenerator generator = new ApiDocGenerator( + new WorkUtilsProperties(), + () -> { + calls.incrementAndGet(); + return "http://127.0.0.1:18080/dubbo/invoke"; + }); + + assertEquals(0, calls.get()); + assertEquals("http://127.0.0.1:18080/dubbo/invoke", + generator.resolveDebugEndpoint()); + assertEquals(1, calls.get()); + } +} diff --git a/src/test/java/com/example/workutils/biz/diff/domain/DiffEngineCharacterTest.java b/src/test/java/com/example/workutils/biz/diff/domain/DiffEngineCharacterTest.java new file mode 100644 index 0000000..cfd648a --- /dev/null +++ b/src/test/java/com/example/workutils/biz/diff/domain/DiffEngineCharacterTest.java @@ -0,0 +1,59 @@ +package com.example.workutils.biz.diff.domain; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class DiffEngineCharacterTest { + + private final DiffEngine engine = new DiffEngine(); + + @Test + void identifiesInsertedCharacters() { + DiffEngine.CharacterDiff diff = + engine.compareCharacters("cat", "coat"); + + assertEquals(List.of( + new DiffEngine.CharacterSegment( + DiffEngine.CharacterType.EQUAL, "cat")), + diff.left()); + assertEquals(List.of( + new DiffEngine.CharacterSegment( + DiffEngine.CharacterType.EQUAL, "c"), + new DiffEngine.CharacterSegment( + DiffEngine.CharacterType.INSERTED, "o"), + new DiffEngine.CharacterSegment( + DiffEngine.CharacterType.EQUAL, "at")), + diff.right()); + } + + @Test + void identifiesReplacementOnBothSides() { + DiffEngine.CharacterDiff diff = + engine.compareCharacters("cat", "cut"); + + assertEquals(DiffEngine.CharacterType.DELETED, + diff.left().get(1).type()); + assertEquals("a", diff.left().get(1).text()); + assertEquals(DiffEngine.CharacterType.INSERTED, + diff.right().get(1).type()); + assertEquals("u", diff.right().get(1).text()); + } + + @Test + void keepsHtmlLikeTextUnescaped() { + DiffEngine.CharacterDiff diff = + engine.compareCharacters("", ""); + + assertEquals("<", diff.left().get(0).text()); + } + + @Test + void calculatesLcsSimilarity() { + assertEquals(1.0, engine.similarity("same", "same")); + assertEquals(0.75, engine.similarity("test", "tent")); + assertEquals(1.0, engine.similarity("", "")); + } +} diff --git a/src/test/java/com/example/workutils/biz/diff/domain/DiffEngineLineTest.java b/src/test/java/com/example/workutils/biz/diff/domain/DiffEngineLineTest.java new file mode 100644 index 0000000..a1f6793 --- /dev/null +++ b/src/test/java/com/example/workutils/biz/diff/domain/DiffEngineLineTest.java @@ -0,0 +1,48 @@ +package com.example.workutils.biz.diff.domain; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class DiffEngineLineTest { + + private final DiffEngine engine = new DiffEngine(); + + @Test + void reportsUnchangedAddedAndRemovedLines() { + DiffEngine.DiffResult result = engine.compareLines( + List.of("same", "old"), + List.of("same", "new")); + + assertEquals(1, result.unchanged()); + assertEquals(1, result.removed()); + assertEquals(1, result.added()); + assertEquals(List.of( + DiffEngine.LineType.UNCHANGED, + DiffEngine.LineType.REMOVED, + DiffEngine.LineType.ADDED), + result.lines().stream().map(DiffEngine.LineDiff::type).toList()); + } + + @Test + void copiesInputContentIntoImmutableResult() { + List left = new ArrayList<>(List.of("before")); + DiffEngine.DiffResult result = + engine.compareLines(left, List.of("before")); + + left.set(0, "after"); + + assertEquals("before", result.lines().get(0).content()); + } + + @Test + void handlesEmptySides() { + assertEquals(2, + engine.compareLines(List.of(), List.of("a", "b")).added()); + assertEquals(2, + engine.compareLines(List.of("a", "b"), List.of()).removed()); + } +} diff --git a/src/test/java/com/example/workutils/biz/git/application/RunGitSyncUseCaseTest.java b/src/test/java/com/example/workutils/biz/git/application/RunGitSyncUseCaseTest.java new file mode 100644 index 0000000..8f8ae09 --- /dev/null +++ b/src/test/java/com/example/workutils/biz/git/application/RunGitSyncUseCaseTest.java @@ -0,0 +1,48 @@ +package com.example.workutils.biz.git.application; + +import com.example.workutils.system.ui.async.OperationContext; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class RunGitSyncUseCaseTest { + + @Test + void requestDefaultsBlankBranchToMaster() { + assertEquals("master", new GitSyncRequest(" ").branch()); + } + + @Test + void translatesProgressAndCompletion() throws Exception { + List logs = new ArrayList<>(); + RunGitSyncUseCase useCase = new RunGitSyncUseCase((branch, listener) -> { + listener.onLog("starting"); + listener.onProgress(new GitSyncProgress(1, 2, "repo", "成功", false)); + listener.onCompleted(new RunGitSyncUseCase.Result( + 2, 1, 1, 0, 3, List.of())); + }); + + RunGitSyncUseCase.Result result = useCase.execute( + new GitSyncRequest("develop"), OperationContext.none(), logs::add); + + assertEquals(2, result.total()); + assertEquals(1, result.success()); + assertEquals(List.of("starting"), logs); + } + + @Test + void failsWhenGatewayNeverCompletes() { + RunGitSyncUseCase useCase = + new RunGitSyncUseCase((branch, listener) -> listener.onLog("stopped")); + + assertThrows(IllegalStateException.class, + () -> useCase.execute( + new GitSyncRequest("main"), + OperationContext.none(), + ignored -> {})); + } +} diff --git a/src/test/java/com/example/workutils/biz/git/presentation/ProjectFilterTest.java b/src/test/java/com/example/workutils/biz/git/presentation/ProjectFilterTest.java new file mode 100644 index 0000000..640c807 --- /dev/null +++ b/src/test/java/com/example/workutils/biz/git/presentation/ProjectFilterTest.java @@ -0,0 +1,27 @@ +package com.example.workutils.biz.git.presentation; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ProjectFilterTest { + + @Test + void matchesNameOrPathIgnoringCase() { + assertTrue(ProjectFilter.matches( + "OrderService", "D:/workspace/order", "正常", "ORDER", "全部状态")); + assertTrue(ProjectFilter.matches( + "OrderService", "D:/workspace/order", "正常", "WORKSPACE", "全部状态")); + assertFalse(ProjectFilter.matches( + "OrderService", "D:/workspace/order", "正常", "payment", "全部状态")); + } + + @Test + void combinesQueryAndRequiredStatus() { + assertTrue(ProjectFilter.matches( + "OrderService", "D:/workspace/order", "正常", "order", "正常")); + assertFalse(ProjectFilter.matches( + "OrderService", "D:/workspace/order", "正常", "order", "目录不存在")); + } +} diff --git a/src/test/java/com/example/workutils/biz/git/presentation/RemoteUrlDisplayTest.java b/src/test/java/com/example/workutils/biz/git/presentation/RemoteUrlDisplayTest.java new file mode 100644 index 0000000..e17f888 --- /dev/null +++ b/src/test/java/com/example/workutils/biz/git/presentation/RemoteUrlDisplayTest.java @@ -0,0 +1,33 @@ +package com.example.workutils.biz.git.presentation; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class RemoteUrlDisplayTest { + + @Test + void removesHttpsCredentials() { + assertEquals("git.example.com/team/repo.git", + RemoteUrlDisplay.safe( + "https://user:secret@git.example.com/team/repo.git")); + } + + @Test + void removesSshUsername() { + assertEquals("git.example.com/team/repo.git", + RemoteUrlDisplay.safe("git@git.example.com:team/repo.git")); + } + + @Test + void keepsHostAndPathWithoutScheme() { + assertEquals("git.example.com/team/repo", + RemoteUrlDisplay.safe("git.example.com/team/repo")); + } + + @Test + void rendersBlankAsDash() { + assertEquals("—", RemoteUrlDisplay.safe(" ")); + assertEquals("—", RemoteUrlDisplay.safe(null)); + } +} diff --git a/src/test/java/com/example/workutils/biz/mybatisexport/MyBatisExportHistoryMappingTest.java b/src/test/java/com/example/workutils/biz/mybatisexport/MyBatisExportHistoryMappingTest.java new file mode 100644 index 0000000..0f71aed --- /dev/null +++ b/src/test/java/com/example/workutils/biz/mybatisexport/MyBatisExportHistoryMappingTest.java @@ -0,0 +1,23 @@ +package com.example.workutils.biz.mybatisexport; + +import com.example.workutils.system.ui.history.HistoryItem; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class MyBatisExportHistoryMappingTest { + + @Test + void mapsPersistedRowToSharedHistoryItem() { + MyBatisExportWindow.HistoryRow row = new MyBatisExportWindow.HistoryRow( + "4", "table=user", "C:/out/sql.csv", 20, "2026-07-30", "打开"); + + HistoryItem item = MyBatisExportWindow.toHistoryItem(row); + + assertEquals("4", item.index()); + assertEquals("table=user", item.query()); + assertEquals("C:/out/sql.csv", item.filePath()); + assertEquals("20", item.count()); + assertEquals("2026-07-30", item.timestamp()); + } +} diff --git a/src/test/java/com/example/workutils/biz/mybatisexport/application/SqlExportRequestTest.java b/src/test/java/com/example/workutils/biz/mybatisexport/application/SqlExportRequestTest.java new file mode 100644 index 0000000..bd764c2 --- /dev/null +++ b/src/test/java/com/example/workutils/biz/mybatisexport/application/SqlExportRequestTest.java @@ -0,0 +1,39 @@ +package com.example.workutils.biz.mybatisexport.application; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class SqlExportRequestTest { + + @Test + void allModeDoesNotRequireAFilterValue() { + SqlExportRequest request = SqlExportRequest.create( + SqlExportRequest.FilterMode.ALL, " ignored "); + + assertEquals("", request.filterValue()); + assertEquals("all", request.serviceFilterMode()); + assertEquals("全部 SQL", request.queryDisplay()); + } + + @Test + void filteredModesRequireAValue() { + assertThrows(IllegalArgumentException.class, + () -> SqlExportRequest.create( + SqlExportRequest.FilterMode.TABLE, " ")); + assertThrows(IllegalArgumentException.class, + () -> SqlExportRequest.create( + SqlExportRequest.FilterMode.TAG_ID, null)); + } + + @Test + void filteredValueIsTrimmedAndDescribed() { + SqlExportRequest request = SqlExportRequest.create( + SqlExportRequest.FilterMode.KEYWORD, " status "); + + assertEquals("status", request.filterValue()); + assertEquals("keyword", request.serviceFilterMode()); + assertEquals("按关键字: status", request.queryDisplay()); + } +} diff --git a/src/test/java/com/example/workutils/biz/mybatisgenerator/application/GeneratorRequestTest.java b/src/test/java/com/example/workutils/biz/mybatisgenerator/application/GeneratorRequestTest.java new file mode 100644 index 0000000..5fe3f72 --- /dev/null +++ b/src/test/java/com/example/workutils/biz/mybatisgenerator/application/GeneratorRequestTest.java @@ -0,0 +1,41 @@ +package com.example.workutils.biz.mybatisgenerator.application; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class GeneratorRequestTest { + + @Test + void queryRequiresAndTrimsPattern() { + assertEquals("trade%", + new QueryTablesRequest(" trade% ").pattern()); + assertThrows(IllegalArgumentException.class, + () -> new QueryTablesRequest(" ")); + } + + @Test + void generateCopiesTablesAndDefaultsDirectories() { + List tables = new ArrayList<>(List.of("trade_order")); + GenerateArtifactsRequest request = new GenerateArtifactsRequest( + tables, " ", null, "./custom"); + + tables.add("trade_item"); + + assertEquals(List.of("trade_order"), request.tables()); + assertEquals("./generator", request.modelDirectory()); + assertEquals("./generator", request.xmlDirectory()); + assertEquals("./custom", request.mapperDirectory()); + } + + @Test + void generateRequiresSelectedTable() { + assertThrows(IllegalArgumentException.class, + () -> new GenerateArtifactsRequest( + List.of(), "a", "b", "c")); + } +} diff --git a/src/test/java/com/example/workutils/biz/mybatisgenerator/application/GeneratorUseCaseTest.java b/src/test/java/com/example/workutils/biz/mybatisgenerator/application/GeneratorUseCaseTest.java new file mode 100644 index 0000000..0999645 --- /dev/null +++ b/src/test/java/com/example/workutils/biz/mybatisgenerator/application/GeneratorUseCaseTest.java @@ -0,0 +1,44 @@ +package com.example.workutils.biz.mybatisgenerator.application; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class GeneratorUseCaseTest { + + @Test + void queryDelegatesAndCopiesResult() throws Exception { + List gatewayResult = + new ArrayList<>(List.of("trade_order")); + QueryTablesUseCase useCase = + new QueryTablesUseCase(pattern -> gatewayResult); + + List result = + useCase.execute(new QueryTablesRequest("trade%")); + gatewayResult.add("trade_item"); + + assertEquals(List.of("trade_order"), result); + } + + @Test + void generateDelegatesCompleteRequest() throws Exception { + AtomicReference received = + new AtomicReference<>(); + GenerateArtifactsUseCase useCase = + new GenerateArtifactsUseCase(request -> { + received.set(request); + return "generated"; + }); + GenerateArtifactsRequest request = new GenerateArtifactsRequest( + List.of("trade_order"), "model", "xml", "mapper"); + + String result = useCase.execute(request); + + assertEquals("generated", result); + assertEquals(request, received.get()); + } +} diff --git a/src/test/java/com/example/workutils/biz/mybatisgenerator/presentation/DatasourceSummaryTest.java b/src/test/java/com/example/workutils/biz/mybatisgenerator/presentation/DatasourceSummaryTest.java new file mode 100644 index 0000000..96d094f --- /dev/null +++ b/src/test/java/com/example/workutils/biz/mybatisgenerator/presentation/DatasourceSummaryTest.java @@ -0,0 +1,43 @@ +package com.example.workutils.biz.mybatisgenerator.presentation; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +class DatasourceSummaryTest { + + @Test + void summarizesMysqlWithoutCredentialsOrParameters() { + DatasourceSummary summary = DatasourceSummary.from( + "订单库", + "jdbc:mysql://db.internal:3306/orders?useSSL=false&token=secret"); + + assertEquals("订单库", summary.alias()); + assertEquals("MySQL", summary.databaseType()); + assertEquals("db.…nal:3306", summary.maskedHost()); + assertEquals("orders", summary.databaseName()); + assertFalse(summary.displayText().contains("token")); + assertFalse(summary.displayText().contains("secret")); + } + + @Test + void recognizesOceanBase() { + DatasourceSummary summary = DatasourceSummary.from( + "OB", "jdbc:oceanbase://10.20.30.40:2881/pay"); + + assertEquals("OceanBase", summary.databaseType()); + assertEquals("10.…40:2881", summary.maskedHost()); + } + + @Test + void handlesMalformedAndBlankAlias() { + DatasourceSummary summary = + DatasourceSummary.from(" ", "not-a-jdbc-url"); + + assertEquals("默认配置", summary.alias()); + assertEquals("数据库", summary.databaseType()); + assertEquals("地址已隐藏", summary.maskedHost()); + assertEquals("数据库名未知", summary.databaseName()); + } +} diff --git a/src/test/java/com/example/workutils/biz/scanner/ScannerHistoryMappingTest.java b/src/test/java/com/example/workutils/biz/scanner/ScannerHistoryMappingTest.java new file mode 100644 index 0000000..815e948 --- /dev/null +++ b/src/test/java/com/example/workutils/biz/scanner/ScannerHistoryMappingTest.java @@ -0,0 +1,23 @@ +package com.example.workutils.biz.scanner; + +import com.example.workutils.system.ui.history.HistoryItem; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class ScannerHistoryMappingTest { + + @Test + void mapsPersistedRowToSharedHistoryItem() { + ScannerWindow.HistoryRow row = new ScannerWindow.HistoryRow( + "3", "token", "C:/out/scan.csv", 12, "2026-07-30", "打开"); + + HistoryItem item = ScannerWindow.toHistoryItem(row); + + assertEquals("3", item.index()); + assertEquals("token", item.query()); + assertEquals("C:/out/scan.csv", item.filePath()); + assertEquals("12", item.count()); + assertEquals("2026-07-30", item.timestamp()); + } +} diff --git a/src/test/java/com/example/workutils/biz/scanner/application/ScanRequestTest.java b/src/test/java/com/example/workutils/biz/scanner/application/ScanRequestTest.java new file mode 100644 index 0000000..f620e06 --- /dev/null +++ b/src/test/java/com/example/workutils/biz/scanner/application/ScanRequestTest.java @@ -0,0 +1,32 @@ +package com.example.workutils.biz.scanner.application; + +import org.junit.jupiter.api.Test; + +import java.util.regex.PatternSyntaxException; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class ScanRequestTest { + + @Test + void blankPatternIsRejected() { + assertThrows(IllegalArgumentException.class, + () -> ScanRequest.create(" ", false)); + } + + @Test + void invalidRegularExpressionIsRejectedBeforeScanning() { + assertThrows(PatternSyntaxException.class, + () -> ScanRequest.create("[", true)); + } + + @Test + void literalModeEscapesRegularExpressionCharacters() { + ScanRequest request = ScanRequest.create(" a+b ", false); + + assertEquals("a+b", request.pattern()); + assertEquals("\\Qa+b\\E", request.effectivePattern()); + assertEquals("关键词", request.modeLabel()); + } +} diff --git a/src/test/java/com/example/workutils/system/plugin/DefaultPluginConfigurationTest.java b/src/test/java/com/example/workutils/system/plugin/DefaultPluginConfigurationTest.java new file mode 100644 index 0000000..0285af9 --- /dev/null +++ b/src/test/java/com/example/workutils/system/plugin/DefaultPluginConfigurationTest.java @@ -0,0 +1,55 @@ +package com.example.workutils.system.plugin; + +import com.example.workutils.system.config.WorkUtilsProperties; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class DefaultPluginConfigurationTest { + + @TempDir + Path tempDir; + + @Test + void defaultConfigurationLoadsEveryPluginExceptMobileAutomation() throws Exception { + Path configFile = tempDir.resolve("plugins.config"); + try (InputStream input = getClass().getClassLoader() + .getResourceAsStream("templates/plugins.config")) { + Files.copy(input, configFile); + } + + WorkUtilsProperties properties = new WorkUtilsProperties(); + properties.setPluginsConfigFile(configFile.toString()); + List plugins = List.of( + plugin("scan"), + plugin("diff"), + plugin("api-doc"), + plugin("git-sync"), + plugin("git-review"), + plugin("mobile-ui-auto"), + plugin("mybatisexport"), + plugin("mybatisgenerator")); + + PluginManager manager = new PluginManager(plugins, properties); + manager.init(); + + assertEquals( + List.of("scan", "diff", "api-doc", "git-sync", "git-review", + "mybatisexport", "mybatisgenerator"), + manager.getEnabledPlugins().stream().map(Plugin::getId).toList()); + } + + private Plugin plugin(String id) { + PluginDescriptor descriptor = PluginDescriptor.builder() + .id(id) + .name(id) + .build(); + return () -> descriptor; + } +} diff --git a/src/test/java/com/example/workutils/system/ui/EmptyStateContentTest.java b/src/test/java/com/example/workutils/system/ui/EmptyStateContentTest.java new file mode 100644 index 0000000..8c869f9 --- /dev/null +++ b/src/test/java/com/example/workutils/system/ui/EmptyStateContentTest.java @@ -0,0 +1,25 @@ +package com.example.workutils.system.ui; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class EmptyStateContentTest { + + @Test + void trimsDisplayText() { + EmptyStatePane.Content content = + new EmptyStatePane.Content(" 暂无结果 ", " 请先执行任务 ", ""); + + assertEquals("暂无结果", content.title()); + assertEquals("请先执行任务", content.description()); + assertEquals("", content.actionLabel()); + } + + @Test + void rejectsBlankTitle() { + assertThrows(IllegalArgumentException.class, + () -> new EmptyStatePane.Content(" ", "description", "")); + } +} diff --git a/src/test/java/com/example/workutils/system/ui/async/AsyncOperationControllerTest.java b/src/test/java/com/example/workutils/system/ui/async/AsyncOperationControllerTest.java new file mode 100644 index 0000000..c886ac2 --- /dev/null +++ b/src/test/java/com/example/workutils/system/ui/async/AsyncOperationControllerTest.java @@ -0,0 +1,89 @@ +package com.example.workutils.system.ui.async; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class AsyncOperationControllerTest { + + private final ExecutorService executor = Executors.newSingleThreadExecutor(); + + @AfterEach + void stopExecutor() { + executor.shutdownNow(); + } + + @Test + void successfulTaskPublishesProgressAndResult() throws Exception { + AsyncOperationController controller = + new AsyncOperationController<>(executor, Runnable::run); + CountDownLatch finished = new CountDownLatch(1); + AtomicReference result = new AtomicReference<>(); + + controller.start("扫描中", context -> { + context.report("项目 1/2", 0.5); + return "done"; + }, value -> { + result.set(value); + finished.countDown(); + }); + + assertTrue(finished.await(2, TimeUnit.SECONDS)); + assertEquals("done", result.get()); + assertEquals(OperationState.SUCCEEDED, controller.snapshot().state()); + assertEquals(1, controller.snapshot().progress()); + } + + @Test + void failedTaskPublishesReadableFailure() throws Exception { + AsyncOperationController controller = + new AsyncOperationController<>(executor, Runnable::run); + CountDownLatch failed = new CountDownLatch(1); + AtomicReference error = new AtomicReference<>(); + + controller.start("导出中", context -> { + throw new IllegalArgumentException("invalid filter"); + }, ignored -> { }, throwable -> { + error.set(throwable); + failed.countDown(); + }); + + assertTrue(failed.await(2, TimeUnit.SECONDS)); + assertEquals("invalid filter", error.get().getMessage()); + assertEquals(OperationState.FAILED, controller.snapshot().state()); + assertEquals("invalid filter", controller.snapshot().message()); + } + + @Test + void cancellationIsVisibleToRunningTask() throws Exception { + AsyncOperationController controller = + new AsyncOperationController<>(executor, Runnable::run); + CountDownLatch started = new CountDownLatch(1); + CountDownLatch observedCancellation = new CountDownLatch(1); + CountDownLatch finished = new CountDownLatch(1); + + controller.start("运行中", context -> { + started.countDown(); + while (!context.isCancellationRequested()) { + Thread.onSpinWait(); + } + observedCancellation.countDown(); + return "cancelled"; + }, ignored -> finished.countDown()); + + assertTrue(started.await(2, TimeUnit.SECONDS)); + controller.cancel(); + + assertTrue(observedCancellation.await(2, TimeUnit.SECONDS)); + assertTrue(finished.await(2, TimeUnit.SECONDS)); + assertEquals(OperationState.SUCCEEDED, controller.snapshot().state()); + } +} diff --git a/src/test/java/com/example/workutils/system/ui/async/LoadingButtonTextTest.java b/src/test/java/com/example/workutils/system/ui/async/LoadingButtonTextTest.java new file mode 100644 index 0000000..d482b35 --- /dev/null +++ b/src/test/java/com/example/workutils/system/ui/async/LoadingButtonTextTest.java @@ -0,0 +1,34 @@ +package com.example.workutils.system.ui.async; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class LoadingButtonTextTest { + + @Test + void idleAndTerminalStatesUseIdleText() { + assertEquals("开始扫描", + LoadingButtonBinder.textFor(OperationSnapshot.idle(), "开始扫描")); + assertEquals("开始扫描", + LoadingButtonBinder.textFor( + new OperationSnapshot(OperationState.SUCCEEDED, "完成", 1), + "开始扫描")); + } + + @Test + void runningStateUsesCurrentOperationMessage() { + assertEquals("项目 2/10…", + LoadingButtonBinder.textFor( + new OperationSnapshot(OperationState.RUNNING, "项目 2/10", 0.2), + "开始扫描")); + } + + @Test + void cancellingStateHasStableText() { + assertEquals("正在停止…", + LoadingButtonBinder.textFor( + new OperationSnapshot(OperationState.CANCELLING, "任意消息", 0.2), + "开始扫描")); + } +} diff --git a/src/test/java/com/example/workutils/system/ui/async/OperationStateMachineTest.java b/src/test/java/com/example/workutils/system/ui/async/OperationStateMachineTest.java new file mode 100644 index 0000000..df144bf --- /dev/null +++ b/src/test/java/com/example/workutils/system/ui/async/OperationStateMachineTest.java @@ -0,0 +1,55 @@ +package com.example.workutils.system.ui.async; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class OperationStateMachineTest { + + @Test + void runningOperationCanProgressAndSucceed() { + OperationStateMachine machine = new OperationStateMachine(); + + machine.start("扫描中"); + machine.progress("项目 1/3", 1.0 / 3.0); + machine.succeed("扫描完成"); + + OperationSnapshot snapshot = machine.snapshot(); + assertEquals(OperationState.SUCCEEDED, snapshot.state()); + assertEquals("扫描完成", snapshot.message()); + assertEquals(1.0, snapshot.progress()); + } + + @Test + void idleOperationCannotBeCancelled() { + OperationStateMachine machine = new OperationStateMachine(); + + assertThrows(IllegalStateException.class, machine::requestCancel); + } + + @Test + void progressIsClampedToSupportedRange() { + OperationStateMachine machine = new OperationStateMachine(); + machine.start("运行"); + + machine.progress("未知", -2); + assertEquals(-1, machine.snapshot().progress()); + + machine.progress("完成", 2); + assertEquals(1, machine.snapshot().progress()); + } + + @Test + void terminalOperationMustResetBeforeRestarting() { + OperationStateMachine machine = new OperationStateMachine(); + machine.start("运行"); + machine.fail("失败"); + + assertThrows(IllegalStateException.class, () -> machine.start("再次运行")); + + machine.reset(); + machine.start("再次运行"); + assertEquals(OperationState.RUNNING, machine.snapshot().state()); + } +} diff --git a/src/test/java/com/example/workutils/system/ui/history/HistoryFileCommandsTest.java b/src/test/java/com/example/workutils/system/ui/history/HistoryFileCommandsTest.java new file mode 100644 index 0000000..af72089 --- /dev/null +++ b/src/test/java/com/example/workutils/system/ui/history/HistoryFileCommandsTest.java @@ -0,0 +1,36 @@ +package com.example.workutils.system.ui.history; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class HistoryFileCommandsTest { + + @Test + void routesEveryCommandToItsConsumer() { + List calls = new ArrayList<>(); + HistoryFileCommands commands = new HistoryFileCommands( + path -> calls.add("open:" + path), + path -> calls.add("edit:" + path), + path -> calls.add("folder:" + path), + path -> calls.add("copy:" + path)); + + commands.openFile("a.csv"); + commands.openInEditor("b.csv"); + commands.openFolder("c.csv"); + commands.copyPath("d.csv"); + + assertEquals(List.of( + "open:a.csv", "edit:b.csv", "folder:c.csv", "copy:d.csv"), calls); + } + + @Test + void rejectsMissingCommand() { + assertThrows(NullPointerException.class, + () -> new HistoryFileCommands(path -> {}, null, path -> {}, path -> {})); + } +} diff --git a/src/test/java/com/example/workutils/system/ui/history/HistoryItemTest.java b/src/test/java/com/example/workutils/system/ui/history/HistoryItemTest.java new file mode 100644 index 0000000..2fb9b84 --- /dev/null +++ b/src/test/java/com/example/workutils/system/ui/history/HistoryItemTest.java @@ -0,0 +1,33 @@ +package com.example.workutils.system.ui.history; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class HistoryItemTest { + + @Test + void keepsShortFilePathUnchanged() { + HistoryItem item = item("C:/out/result.csv"); + + assertEquals("C:/out/result.csv", item.fileDisplay(30)); + } + + @Test + void ellipsizesLongPathAndKeepsFileName() { + HistoryItem item = item("C:/very/long/project/export/result.csv"); + + assertEquals("…/result.csv", item.fileDisplay(15)); + } + + @Test + void rejectsNullValues() { + assertThrows(NullPointerException.class, + () -> new HistoryItem(null, "query", "file", "1", "time")); + } + + private HistoryItem item(String path) { + return new HistoryItem("1", "query", path, "2", "time"); + } +}