diff --git a/README.md b/README.md index b54870f5..22650828 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,13 @@ When both sides changed, Git File Sync keeps the conflict explicit. Choose **Kee | **GitLab** | Token, project ID, base URL | `read_repository`, `write_repository` | | **Gitea** | Token, owner, repository, base URL | `write:repository` on Gitea 1.19+ | -Other settings include language, branch, repository root path, vault-folder scope, startup refresh, ignore patterns, and symbolic-link handling. See [Symbolic link handling](docs/symlink-handling.md) for details. +Other settings include language, branch, repository root path, vault-folder scope, ignore patterns, and symbolic-link handling. See [Symbolic link handling](docs/symlink-handling.md) for details. + +### Automatic sync + +Automatic sync is **off by default**. When enabled, Git File Sync refreshes local and remote state on a configurable interval (minimum 1 minute) and applies the same default action the manual **Sync** button would for each pending change — push, pull, or remote delete — through the normal Source Control pipeline. An optional **Sync on startup** runs one automatic pass after Obsidian finishes loading without opening the Source Control view. + +Files that need manual conflict resolution are always skipped: they stay visible as conflicts while unrelated safe changes continue to sync. Automatic runs never show confirmation or conflict dialogs, stay silent on success, and skip a tick when another sync is already running. **Refresh status on startup** is a separate setting that only refreshes the Source Control status view. > **Security:** scope tokens to the smallest possible repository access and permissions, set an expiration where possible, and never place a token inside a note that may be synced. Revoke and rotate a token immediately if it may have been exposed. diff --git a/docs/architecture.md b/docs/architecture.md index bd5dcef6..3d617752 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -34,7 +34,10 @@ The dependency direction should normally flow downward. Results and state flow b | Application | `ChangeActionPolicy` | allowed/default action for each change kind | ViewModel, selection reconciliation, intent execution | UI rendering, network calls | | Application | `SourceControlViewModel` | read-only projection of application state for UI | repository, selection, operation/refresh state | side effects, provider calls, filesystem writes | | Application | `SourceControlActionService` | stable UI-facing facade for immediate Source Control commands | `SyncWorkspace`, `SyncIntentExecutor` | provider-specific logic, duplicated sync planning | -| Application | `SyncIntentExecutor` | one Sync Queue workflow: resolve intent, plan, confirm, execute, aggregate | repository, action policy, `SyncWorkspace`, notifier | UI DOM, provider API implementation | +| Application | `SyncIntentExecutor` | one Sync Queue workflow: resolve intent, plan, confirm, execute, aggregate; selects interactive vs background execution policy per run | repository, action policy, `SyncWorkspace`, notifier, `SyncExecutionGuard` | UI DOM, provider API implementation | +| Application | `AutomaticSyncService` | one scheduled automatic sync run: refresh, read repository, build default intents, execute in background, refresh again | `SyncWorkspace`, `ChangeRepository`, `SourceControlActionService` | timer/scheduling mechanics, UI rendering, classification rules | +| Application | `SyncExecutionGuard` | application-level serialization of provider mutations (manual waits, automatic try-acquires) | `SyncIntentExecutor`, `SourceControlActionService` | provider calls, scheduling | +| Plugin runtime | `AutomaticSyncScheduler` (`src/runtime/AutomaticSyncScheduler.ts`) | the automatic-sync interval timer and its lifecycle registration | settings, `AutomaticSyncService`, Obsidian `registerInterval` | sync execution, change classification | | Boundary | `SyncWorkspace` | application-to-sync execution boundary | `SyncManager`, refresh service, diff service | Source Control rendering | | Sync domain | `SyncManager` | compatibility/domain facade for sync operations | coordinators, executors, metadata/status services | Source Control UI state | | Sync domain | `PushCoordinator` | batch push use case including planning/conflict/review/commit coordination | planner, conflict resolver, push executor | Source Control selection state | @@ -108,15 +111,37 @@ SyncIntentExecutor ↓ resolve current ChangeId + revalidate explicit action ↓ -build one merged Sync Plan +build one merged Sync plan ↓ -confirm once +execution policy (interactive: confirm once / background: auto-accept, skip conflicts) ↓ SyncWorkspace ├─ remote mutation bucket (max one provider batch) └─ local pull bucket ``` +### Automatic sync flow + +```text +AutomaticSyncScheduler (timer, plugin runtime) + ↓ +AutomaticSyncService.runOnce() + ↓ +refresh authoritative local + remote state + ↓ +ChangeRepository → exclude synced/conflict → default intents via ChangeActionPolicy + ↓ +SourceControlActionService.sync(intents, 'background') + ↓ +SyncIntentExecutor (skip-conflict planning, no confirmation) + ↓ +SyncWorkspace → Sync domain → provider + ↓ +refresh status again +``` + +Automatic sync reuses the same application → `SyncWorkspace` → domain → provider path as manual Sync. It does not own classification, rename detection, action routing, conflict algorithms, push/pull planning, or provider mutation logic, and it never calls a concrete provider service directly. + ## 4. Architecture rules ### MUST @@ -129,6 +154,9 @@ SyncWorkspace - One Sync Queue action must produce one merged review/confirmation flow. - Remote mutations from one Sync Queue execution must be grouped into at most one provider mutation batch when supported by the current workflow. - Existing compatibility identifiers such as `sync-status-view` and `open-sync-status` must be preserved unless a migration explicitly removes them. +- Automatic sync must execute through `SourceControlActionService`/`SyncIntentExecutor`/`SyncWorkspace`, never a second sync engine. +- Manual and automatic provider mutations must be serialized (`SyncExecutionGuard`); automatic work skips a tick rather than queuing when the path is busy. +- Interactive vs background behavior must be an explicit per-execution policy (`SyncExecutionMode` / `PushConflictBehavior`), not a collection of independent booleans. ### MUST NOT diff --git a/docs/source-control.md b/docs/source-control.md index ff1058ea..6eb1f25e 100644 --- a/docs/source-control.md +++ b/docs/source-control.md @@ -40,6 +40,26 @@ SourceControlItemView talks directly to a provider or bypasses the workspace to reach sync-domain coordinators/executors. +## Automatic sync + +`AutomaticSyncService` is the application-level use case for one automatic run +(refresh → read `ChangeRepository` → default intents → execute in background → +refresh). It reuses this same call chain and the same `SyncWorkspace` boundary; +it never reaches a provider or coordinator directly. + +- Manual `sync()` stays `interactive`: one merged plan, one confirmation, batch + conflict interaction, and a result notice. +- Automatic runs pass `background`: the same plan is built and validated, but + conflicts are skipped (`PushConflictBehavior = 'skip'`) instead of prompting, + the final plan is auto-accepted, and success is silent. +- `PushCoordinator` stays UI-free: it only receives the small conflict-behavior + switch, never Obsidian or an execution mode. +- Conflicting paths left out of the plan are never marked as operation success; + they remain conflicts after the final refresh. +- `SyncExecutionGuard` serializes provider mutations: manual work waits, + automatic work try-acquires and skips when busy. Timer mechanics live in + `AutomaticSyncScheduler` (plugin runtime), not in the service. + ## Sync Queue invariant One Sync click produces one explicit-intent workflow. Requested action diff --git a/e2e-tests/provider/suites/sync-manager.e2e.test.ts b/e2e-tests/provider/suites/sync-manager.e2e.test.ts index 88b591fa..d668c882 100644 --- a/e2e-tests/provider/suites/sync-manager.e2e.test.ts +++ b/e2e-tests/provider/suites/sync-manager.e2e.test.ts @@ -51,6 +51,9 @@ function makeSettings(branch: string): GitLabFilesPushSettings { bannerDismissedVersion: '', language: 'system', autoRefreshOnStartup: true, + automaticSyncEnabled: false, + automaticSyncIntervalMinutes: 5, + automaticSyncOnStartup: false, }; } diff --git a/e2e-tests/provider/support/sync-manager-fixture.ts b/e2e-tests/provider/support/sync-manager-fixture.ts index 03880fb9..12866694 100644 --- a/e2e-tests/provider/support/sync-manager-fixture.ts +++ b/e2e-tests/provider/support/sync-manager-fixture.ts @@ -130,6 +130,9 @@ export async function createSyncManagerFixture(options: SyncManagerFixtureOption bannerDismissedVersion: '', language: 'system', autoRefreshOnStartup: true, + automaticSyncEnabled: false, + automaticSyncIntervalMinutes: 5, + automaticSyncOnStartup: false, }; } diff --git a/eslint.config.mts b/eslint.config.mts index c529bbe5..08e72042 100644 --- a/eslint.config.mts +++ b/eslint.config.mts @@ -145,6 +145,7 @@ export default tseslint.config( files: [ "tests/ui/SettingsConnectionStatus.test.ts", "tests/ui/SettingsObsidian113Compatibility.test.ts", + "tests/ui/SettingsAutomaticSync.test.ts", ], rules: { "@typescript-eslint/no-deprecated": "off", diff --git a/feature_list.json b/feature_list.json index 904c01f6..b5879a3f 100644 --- a/feature_list.json +++ b/feature_list.json @@ -1,7 +1,15 @@ { "_note": "GitHub Issues (firstsun-dev/git-files-sync, Project #6) is the source of truth for the full backlog and priority/estimate fields. This file mirrors only the active feature and the next few candidates so an agent session has a local, offline checkpoint — sync it against `gh issue list --repo firstsun-dev/git-files-sync --state open` at the start of a session rather than treating it as authoritative.", - "_lastSync": "2026-08-31: Synced against open GitHub issues; issue #143 is active locally, while #139 awaits review on PR #140.", + "_lastSync": "2026-09-12: Issue #141 (Automatic Syncing) implemented on claude/automatic-sync-141; #143/#139 entries below are carried over from the base branch history.", "features": [ + { + "id": "feat-029", + "name": "feat(sync): automatic scheduled sync (issue #141)", + "description": "Scheduled + optional startup automatic sync that reuses the Source Control execution path in a background policy, skipping conflicts safely.", + "dependencies": ["claude/mobile-source-control-density (PR #156)"], + "status": "in-review", + "evidence": "Branch claude/automatic-sync-141; eslint 0 errors, build passed, vitest 80 files / 1010 tests passed; stacked PR pending." + }, { "id": "feat-027", "name": "test(e2e): run disposable Gitea safely in local and CI environments (issue #139)", diff --git a/progress.md b/progress.md index f0a6c220..a8c305dc 100644 --- a/progress.md +++ b/progress.md @@ -4,20 +4,16 @@ Completed work is archived in [archive/](./archive/), one file per calendar mont ## Current State -**Last Updated:** 2026-09-01 -**Active Feature:** PR2 responsibility cleanup, item 5 done — provider contract cleanup, partial (no tracked issue number; an ad-hoc follow-up plan on top of `origin/1.6.1`, not in `feature_list.json`). -**Branch / PR:** `claude/pr2-source-control-boundary`, branched from `origin/1.6.1` (commit `69e5540`). Pushed; opened as [PR #154](https://github.com/firstsun-dev/git-files-sync/pull/154) against `1.6.1` (covers items 1-4; item 5 below lands as a follow-up commit on the same branch/PR). +**Last Updated:** 2026-09-12 +**Active Feature:** Issue #141 — Automatic Syncing (v1.7.0). Implementation complete, verified locally; PR pending. +**Branch / PR:** `claude/automatic-sync-141`, a child branch of `claude/mobile-source-control-density` (PR #156, still open). The #141 PR is stacked on that baseline; retarget to `main` only if #156 merges first. PR title: `feat(sync): add automatic scheduled sync` (semantic-release owns the 1.7.0 bump). -**Scope (item 5, per the PR2 plan):** Moved `ConnectionTestResult` out of `git-service-base.ts` into `git-service-interface.ts` — it's a contract type consumed by `GitServiceInterface.testConnection`, so it belongs with the interface, not the base implementation class. `git-service-base.ts` now imports it back for its own `abstract testConnection` signature; `github-service.ts`/`gitlab-service.ts`/`gitea-service.ts`/`main.ts`/`GitLabSyncSettingTab.ts`/`tests/ui/SettingsConnectionStatus.test.ts` updated to import from the new location. Reviewed `updateConfig(...args: unknown[])` on `GitServiceInterface` per the plan's ask, but did **not** convert it to a typed discriminated union: every actual call site (`main.ts` `initializeGitService()`, 3 branches) already calls `updateConfig` on the concrete class (`GitLabService`/`GiteaService`/`GitHubService`), never through the loose interface type, so the untyped signature isn't causing a real type-safety gap today. A discriminated union would mean reshaping the interface, all three services' `updateConfig` bodies, and all three `main.ts` call sites into config-object form for no functional benefit — exactly the "touches too much, leave for later" case the plan calls out, so left as-is. +**What landed (#141):** persisted `automaticSyncEnabled` / `automaticSyncIntervalMinutes` / `automaticSyncOnStartup` (defaults OFF / 5 / OFF, interval min 1); settings UI rows distinct from the existing `autoRefreshOnStartup`; EN/zh-TW/zh-CN strings; `AutomaticSyncService` (refresh → repository → default intents → background execute → refresh) wired through `createSyncRuntime`; `AutomaticSyncScheduler` in plugin runtime; `SyncExecutionMode` per-execution policy with `PushConflictBehavior = 'skip'` at the `PushCoordinator` planning boundary; `SyncExecutionGuard` serialization; startup sync that never opens Source Control and supersedes the legacy startup refresh; hand-curated 1.7.0 What's New entry. -**Next:** PR2 plan is now fully worked through (items 1-5). Nothing further planned here; watch PR #154 for review feedback. +**Next:** open the stacked PR (body includes `Closes #141`), then monitor CI. Manual Obsidian verification not performed in this environment (no executable Obsidian runtime) — checklist is in the PR body. Below that: the previous "Outstanding Items"/"Verification Evidence" entries track separate, still-open work on PR #129 / `claude/source-control-foundation`, Issue #143, and `claude/fix-source-control-explicit-sync-intent` — not superseded by this entry, carried over from the base branch history. -- `npx eslint .` — 0 errors. -- `npx vitest run` — 76 files / 953 tests passed (unchanged count; pure type-relocation, no new tests needed). -- `npm run build` (tsc + Obsidian 1.11.0 compat typecheck + esbuild) — passed. - ## Outstanding Items 1. Run `npm run test:e2e -- --provider github`, `gitlab`, and `gitea` with provisioned credentials; verify mixed-100 remains under 120s (target <30s) and the provider matrix passes. @@ -25,6 +21,15 @@ Below that: the previous "Outstanding Items"/"Verification Evidence" entries tra ## Verification Evidence +This session (Issue #141 — Automatic Syncing, `claude/automatic-sync-141` stacked on `claude/mobile-source-control-density`): + +- `npx eslint .` — 0 errors, 0 warnings. +- `npm run build` (tsc + Obsidian 1.11.0 compat typecheck + esbuild) — passed. +- `npx vitest run` — 80 files / 1010 tests passed (baseline from the parent branch was 76 files / 958; +41 new Automatic Sync tests across settings, execution policy, service, guard, scheduler, startup, i18n, settings UI, and changelog, plus settings-literal updates). +- New coverage: `tests/logic/source-control/AutomaticSyncService.test.ts`, `tests/logic/source-control/SyncExecutionGuard.test.ts`, `tests/runtime/AutomaticSyncScheduler.test.ts`, `tests/ui/SettingsAutomaticSync.test.ts`; extended `tests/logic/sync/PushCoordinator.test.ts` (prompt vs skip), `tests/logic/source-control/SourceControlActionService.test.ts` (background mode, skipped-conflict OperationState edge case, manual-vs-background serialization), `tests/main.test.ts` (startup decision), `tests/runtime/createSyncRuntime.test.ts`, `tests/settings.test.ts`, `tests/i18n/index.test.ts`, `tests/changelog.test.ts`, `tests/ui/SettingsConnectionStatus.test.ts`. +- **Not verified in this environment:** manual Obsidian runtime verification (no executable Obsidian environment) and the real-provider E2E suite. The manual checklist is included in the #141 PR body. +- semantic-release owns the actual 1.7.0 version bump; `manifest.json`/`package.json`/`versions.json`/generated `CHANGELOG.md` were intentionally not hand-edited. + This session (explicit per-file sync actions, 7 commits on `claude/fix-source-control-explicit-sync-intent`): - Each commit individually verified before being made: `npx eslint .` (0 errors), `npx vitest run` (68 files, growing from 892 to 914 tests across the branch), `npm run build` (tsc + Obsidian 1.11.0 compat typecheck + esbuild) — all passed at every commit. diff --git a/session-handoff.md b/session-handoff.md index 48f0ad66..095d59a4 100644 --- a/session-handoff.md +++ b/session-handoff.md @@ -1,25 +1,38 @@ # Session Handoff -**Date:** 2026-08-31 -**Active feature:** Issue #143 — reduce redundant real-provider E2E round trips. -**Working tree:** Complete and uncommitted; no production files changed. +**Date:** 2026-09-12 +**Active feature:** Issue #141 — Automatic Syncing (target v1.7.0). Implementation complete and locally verified; PR pending. +**Branch:** `claude/automatic-sync-141`, a child branch of `origin/claude/mobile-source-control-density` (`a1f01b3`, PR #156 still open). The #141 PR must be stacked with base `claude/mobile-source-control-density`; retarget the SAME PR to `main` only if #156 merges first. Do not commit #141 work onto #156. -## Completed +## Completed this session -1. Provider settings identity is now the selected `github` / `gitlab` / `gitea` provider in both SyncManager fixtures. -2. Added batch baseline helpers. The single-client multi-file scenarios (including mixed-100's 70 files) now create one baseline commit; two-client P0-2 does the same while mirroring verified metadata into client B. -3. Added fetch-once `GitVerifier.snapshot()` / `GitSnapshot`; `SourceControlScenario` invalidates and recreates a shared snapshot after remote mutations, and convergence assertions use one snapshot for all files/tree reads. -4. GitHub batch/rename/delete checks poll only branch-head movement, then verify one snapshot. GitLab and Gitea batch tests also assert exactly one new commit. -5. Added `e2e-tests/provider/suites/git-verifier.e2e.test.ts`, a local-git regression test proving all snapshot reads run after one fetch. -6. Registered that suite in `scripts/e2e-suites.txt`; the first CI run rejected it as unregistered before executing provider tests. +- Settings model: `automaticSyncEnabled` (false), `automaticSyncIntervalMinutes` (5), `automaticSyncOnStartup` (false); `normalizeAutomaticSyncIntervalMinutes` / `MIN_AUTOMATIC_SYNC_INTERVAL_MINUTES` in `src/settings/helpers.ts` reject zero/negative/NaN/non-numeric input so no rapid timer can be created. +- Settings UI (`src/ui/settings/GitLabSyncSettingTab.ts`): Automatic sync toggle, Sync interval, Sync on startup — all distinct from the existing Refresh status on startup. EN/zh-TW/zh-CN strings added. +- `SyncExecutionMode = 'interactive' | 'background'` on `SyncIntentExecutor.execute` / `SourceControlActionService.sync`; background auto-accepts the plan, opens no conflict UI, stays silent on success. +- `PushConflictBehavior = 'prompt' | 'skip'` at the `PushCoordinator.planSyncBatch` planning boundary (UI-free); `SyncWorkspace.planPush` / `SyncManager.planSyncBatch` pass it through. +- Skipped-conflict paths are excluded from commit/pull target sets and reset to idle, so they are never marked success (regression test in `SourceControlActionService.test.ts`). +- `SyncExecutionGuard`: manual work waits, automatic work try-acquires and skips. Owned by `SourceControlActionService`, injected into `SyncIntentExecutor`; immediate row actions + conflict resolution also serialized. +- `AutomaticSyncService` (`runOnce`): refresh → `ChangeRepository` → exclude synced/conflict → default intents via `ChangeActionPolicy` → background execute → refresh. Overlap-skipping and error-safe (never permanently locked). Wired in `createSyncRuntime`. +- `AutomaticSyncScheduler` (`src/runtime/AutomaticSyncScheduler.ts`): interval timer, idempotent `apply()`, `registerInterval`, `dispose()`. `main.ts` applies it on load and every `saveSettings`, disposes on unload. +- Startup: `handleLayoutReady()` runs background automatic sync (never opens Source Control) when enabled + on startup, else falls back to the legacy refresh-on-startup; no duplicate boot fetch. +- What's New: `src/changelog/1.7.0/index.ts` registered first in `src/changelog/index.ts` (EN/zh-TW/zh-CN headline, summary, 3 notable entries). ## Verification - `npx eslint .` — 0 errors, 0 warnings. -- `npx vitest run` — 68 files / 862 tests passed. -- `npx vitest -c vitest.e2e.config.ts run e2e-tests/provider/suites/git-verifier.e2e.test.ts` — 1 file / 1 test passed. -- `npm run build` — passed (including Obsidian 1.11.0 compatibility check). +- `npm run build` — passed (tsc + Obsidian 1.11.0 compat typecheck + esbuild). +- `npx vitest run` — 80 files / 1010 tests passed. +- NOT done: manual Obsidian runtime verification (no executable Obsidian here) and real-provider E2E. Both are explicitly noted in the PR body; do not claim they ran. -## Remaining +## Next steps -Run the provisioned real-provider matrix for GitHub, GitLab, and Gitea. Confirm mixed-100 stays within its 120s timeout and measure against the <60s / <30s targets before committing and pushing. +1. Commit and push `claude/automatic-sync-141`; open the stacked PR (base `claude/mobile-source-control-density`) titled `feat(sync): add automatic scheduled sync`, body includes `Closes #141` + the manual checklist. +2. If #156 merges: merge latest `main` into this SAME branch and retarget the SAME PR to `main`. +3. Manual Obsidian verification (desktop + mobile) against the checklist in the PR. +4. Do not hand-bump `manifest.json`/`package.json`/`versions.json`/generated `CHANGELOG.md`; semantic-release performs the 1.7.0 bump. + +## Gotchas + +- `tests/setup.ts` now has `ToggleComponent` and a faithful `.setting-item` `Setting` mock (name/desc/control children); `window.setInterval`/`clearInterval` delegate to globals so fake timers work. +- `tests/ui/SettingsAutomaticSync.test.ts` is added to the ESLint `display()`-deprecation exemption block (legacy pre-1.13 fallback path, same as the other two settings suites). +- All settings object literals in tests/e2e needed the three new fields; `tests/settings.test.ts` asserts the exact `DEFAULT_SETTINGS` shape. diff --git a/src/changelog/1.7.0/index.ts b/src/changelog/1.7.0/index.ts new file mode 100644 index 00000000..1492067b --- /dev/null +++ b/src/changelog/1.7.0/index.ts @@ -0,0 +1,43 @@ +import { type ChangelogRelease } from '../types'; + +export const release: ChangelogRelease = { + version: '1.7.0', + + headline: { + en: 'Automatic sync, on your schedule', + 'zh-tw': '依照你的時間自動同步', + 'zh-cn': '按照你的时间自动同步', + }, + summary: { + en: 'Automatically keep your vault and remote repository in sync on a configurable schedule, while leaving conflicts safely for manual resolution.', + 'zh-tw': '依照你設定的週期自動同步 Vault 與遠端儲存庫;遇到需要人工判斷的衝突時會安全略過,保留給你手動處理。', + 'zh-cn': '按照你设置的周期自动同步 Vault 与远程仓库;遇到需要人工判断的冲突时会安全跳过,保留给你手动处理。', + }, + + entries: [ + { + notable: true, + text: { + en: '⏱️ Automatic sync — Apply the default Sync action for pending changes on a configurable interval.', + 'zh-tw': '⏱️ 自動同步 — 依照可設定的週期,自動套用待處理變更的預設同步動作。', + 'zh-cn': '⏱️ 自动同步 — 按照可设置的周期,自动应用待处理更改的默认同步动作。', + }, + }, + { + notable: true, + text: { + en: '🚀 Optional sync on startup — Run one automatic sync right after Obsidian finishes loading.', + 'zh-tw': '🚀 可選的啟動時同步 — 在 Obsidian 載入完成後立即執行一次自動同步。', + 'zh-cn': '🚀 可选的启动时同步 — 在 Obsidian 加载完成后立即执行一次自动同步。', + }, + }, + { + notable: true, + text: { + en: '🛡️ Conflicts are skipped safely — Files that need manual resolution are left untouched while unrelated changes keep syncing.', + 'zh-tw': '🛡️ 衝突會安全略過 — 需要手動處理的檔案會保持原狀,其餘變更則照常同步。', + 'zh-cn': '🛡️ 冲突会安全跳过 — 需要手动处理的文件会保持原样,其余更改则照常同步。', + }, + }, + ], +}; diff --git a/src/changelog/index.ts b/src/changelog/index.ts index 0068044c..d8648bbb 100644 --- a/src/changelog/index.ts +++ b/src/changelog/index.ts @@ -1,6 +1,7 @@ import { compareVersions } from '../utils/version'; import { getActiveLocale } from '../i18n'; import { type ChangelogEntry, type ChangelogEntryText, type ChangelogRelease } from './types'; +import { release as release_1_7_0 } from './1.7.0'; import { release as release_1_6_0 } from './1.6.0'; import { release as release_1_5_0 } from './1.5.0'; import { release as release_1_4_0 } from './1.4.0'; @@ -30,6 +31,7 @@ export { * by exact string, so keep them in sync. */ export const CHANGELOG: ChangelogRelease[] = [ + release_1_7_0, release_1_6_0, release_1_5_0, release_1_4_0, diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index bac2a952..a2b32e64 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -30,6 +30,13 @@ const en = { 'settings.autoRefreshOnStartup.name': 'Refresh status on startup', 'settings.autoRefreshOnStartup.desc': 'Automatically refresh the Sync Status View when Obsidian finishes loading', + 'settings.automaticSync.name': 'Automatic sync', + 'settings.automaticSync.desc': 'Automatically apply the default Sync action for pending changes on a schedule, without opening the Source Control view', + 'settings.automaticSyncInterval.name': 'Sync interval (minutes)', + 'settings.automaticSyncInterval.desc': 'How often automatic sync runs. Minimum {min} minute.', + 'settings.automaticSyncOnStartup.name': 'Sync on startup', + 'settings.automaticSyncOnStartup.desc': 'Run one automatic sync after Obsidian finishes loading', + 'settings.ignorePatterns.name': 'Ignore patterns', 'settings.ignorePatterns.desc': 'Optional: .gitignore-style patterns (one per line) to exclude local files from sync, in addition to the repository\'s own .gitignore.', diff --git a/src/i18n/locales/zh-cn.ts b/src/i18n/locales/zh-cn.ts index 60898b6f..15a2ba49 100644 --- a/src/i18n/locales/zh-cn.ts +++ b/src/i18n/locales/zh-cn.ts @@ -32,6 +32,13 @@ const zhCn: Partial> = { 'settings.autoRefreshOnStartup.name': '启动时刷新状态', 'settings.autoRefreshOnStartup.desc': 'Obsidian 加载完成后,自动刷新同步状态窗口', + 'settings.automaticSync.name': '自动同步', + 'settings.automaticSync.desc': '按照计划自动应用待处理更改的默认同步动作,不会打开源代码控制窗口', + 'settings.automaticSyncInterval.name': '同步间隔(分钟)', + 'settings.automaticSyncInterval.desc': '自动同步的执行频率。最短为 {min} 分钟。', + 'settings.automaticSyncOnStartup.name': '启动时同步', + 'settings.automaticSyncOnStartup.desc': 'Obsidian 加载完成后执行一次自动同步', + 'settings.ignorePatterns.name': '忽略规则', 'settings.ignorePatterns.desc': '选填:以 .gitignore 语法(每行一条)排除本机文件,会与远程仓库的 .gitignore 规则一并应用。', diff --git a/src/i18n/locales/zh-tw.ts b/src/i18n/locales/zh-tw.ts index dec9fc09..02a65f37 100644 --- a/src/i18n/locales/zh-tw.ts +++ b/src/i18n/locales/zh-tw.ts @@ -32,6 +32,13 @@ const zhTw: Partial> = { 'settings.autoRefreshOnStartup.name': '啟動時重新整理狀態', 'settings.autoRefreshOnStartup.desc': 'Obsidian 載入完成後,自動重新整理同步狀態視窗', + 'settings.automaticSync.name': '自動同步', + 'settings.automaticSync.desc': '依照排程自動套用待處理變更的預設同步動作,不會開啟原始碼控制視窗', + 'settings.automaticSyncInterval.name': '同步間隔(分鐘)', + 'settings.automaticSyncInterval.desc': '自動同步的執行頻率。最短為 {min} 分鐘。', + 'settings.automaticSyncOnStartup.name': '啟動時同步', + 'settings.automaticSyncOnStartup.desc': 'Obsidian 載入完成後執行一次自動同步', + 'settings.ignorePatterns.name': '忽略規則', 'settings.ignorePatterns.desc': '選填:以 .gitignore 語法(每行一條)排除本機檔案,會與遠端儲存庫的 .gitignore 規則一併套用。', diff --git a/src/logic/source-control/AutomaticSyncService.ts b/src/logic/source-control/AutomaticSyncService.ts new file mode 100644 index 00000000..0f0c4ddb --- /dev/null +++ b/src/logic/source-control/AutomaticSyncService.ts @@ -0,0 +1,74 @@ +import type { SyncWorkspace } from '../sync/SyncWorkspace'; +import { defaultSyncAction } from './ChangeActionPolicy'; +import type { ChangeRepository } from './ChangeRepository'; +import type { SourceControlActionService } from './SourceControlActionService'; +import type { SyncIntentRequest } from './SyncIntent'; +import type { SyncChange } from './types'; + +export interface AutomaticSyncDependencies { + workspace: Pick; + changes: ChangeRepository; + actions: Pick; + /** Optional diagnostic sink; automatic runs stay silent on success. */ + onError?: (error: unknown) => void; +} + +/** + * Application-level use case for one automatic sync run. + * + * Reuses the existing Source Control application layer end to end: it does not + * classify changes, detect renames, route actions, plan push/pull, or talk to a + * provider itself. The sequence is refresh -> read ChangeRepository -> exclude + * synced/conflict -> build default intents via ChangeActionPolicy -> execute + * through the shared Sync Queue path in background mode -> refresh again. + * + * Timer mechanics deliberately live outside this service (plugin runtime + * scheduling); this class only knows how to run once. + */ +export class AutomaticSyncService { + private running = false; + + constructor(private readonly dependencies: AutomaticSyncDependencies) {} + + /** + * Runs at most one automatic sync at a time. A tick that fires while a run + * is already active is skipped rather than queued, and an execution error + * never leaves the service permanently locked. + */ + async runOnce(): Promise { + if (this.running) return; + this.running = true; + try { + await this.dependencies.workspace.refresh(); + const intents = this.pendingIntents(); + if (intents.length > 0) { + await this.dependencies.actions.sync(intents, 'background'); + // Re-read after execution so the refreshed status reflects the + // changes that were just applied (and keeps conflicts visible). + } + await this.dependencies.workspace.refresh(); + } catch (error) { + this.dependencies.onError?.(error); + } finally { + this.running = false; + } + } + + /** + * Every pending change except 'synced' (nothing to do) and 'conflict' + * (must be resolved manually). Actions come from the same default routing + * the manual Sync button uses, with no explicit override. + */ + private pendingIntents(): SyncIntentRequest[] { + return this.dependencies.changes + .getAll() + .filter(change => change.kind !== 'synced' && change.kind !== 'conflict') + .map(change => toDefaultIntent(change)); + } +} + +function toDefaultIntent(change: SyncChange): SyncIntentRequest { + // Route through the action policy so the default action has one source of + // truth; execution re-validates it against the live change kind anyway. + return { changeId: change.id, action: defaultSyncAction(change.kind) }; +} diff --git a/src/logic/source-control/SourceControlActionService.ts b/src/logic/source-control/SourceControlActionService.ts index 225d9aeb..8513abf6 100644 --- a/src/logic/source-control/SourceControlActionService.ts +++ b/src/logic/source-control/SourceControlActionService.ts @@ -5,7 +5,8 @@ import type { OperationState } from './OperationState'; import type { SourceControlItem } from './SourceControlViewModel'; import type { SyncSelectionStore } from './SyncSelectionStore'; import { defaultSyncAction, type SyncAction } from './ChangeActionPolicy'; -import { SyncIntentExecutor } from './SyncIntentExecutor'; +import { SyncIntentExecutor, type SyncExecutionMode } from './SyncIntentExecutor'; +import { SyncExecutionGuard } from './SyncExecutionGuard'; import type { SyncIntentRequest } from './SyncIntent'; import type { ChangeId, SyncChange } from './types'; @@ -39,6 +40,12 @@ export interface SourceControlDiffContent { */ export class SourceControlActionService { private readonly syncIntentExecutor: SyncIntentExecutor; + /** + * Serializes every provider-mutating operation (the Sync Queue workflow and + * the immediate row actions) against automatic sync. User-triggered work + * waits for the lock; automatic work try-acquires and skips when busy. + */ + private readonly guard = new SyncExecutionGuard(); constructor( private readonly changes: ChangeRepository, @@ -52,9 +59,20 @@ export class SourceControlActionService { operations, workspace, syncResultNotifier, + this.guard, ); } + /** Runs `operation` while holding the shared execution guard (manual semantics: waits, never discards). */ + private async serialized(operation: () => Promise): Promise { + const release = await this.guard.acquire(); + try { + return await operation(); + } finally { + release(); + } + } + /** Adds one change to the Sync Queue. */ selectForSync(changeId: ChangeId): void { this.selection.selectForSync(changeId); @@ -103,13 +121,15 @@ export class SourceControlActionService { if (targets.length === 0) return; this.startAll(targets); - try { - const results = await this.workspace.push(targets.map(target => target.path)); - const failed = new Set(results.errors.map(error => error.file)); - this.finishAll(targets, path => failed.has(path) ? 'failed' : 'success'); - } catch { - this.failAll(targets); - } + await this.serialized(async () => { + try { + const results = await this.workspace.push(targets.map(target => target.path)); + const failed = new Set(results.errors.map(error => error.file)); + this.finishAll(targets, path => failed.has(path) ? 'failed' : 'success'); + } catch { + this.failAll(targets); + } + }); } /** Pulls one or more changes. */ @@ -118,13 +138,15 @@ export class SourceControlActionService { if (targets.length === 0) return; this.startAll(targets); - try { - const results = await this.workspace.pull(targets.map(target => target.path)); - const failed = new Set(results.errors.map(error => error.file)); - this.finishAll(targets, path => failed.has(path) ? 'failed' : 'success'); - } catch { - this.failAll(targets); - } + await this.serialized(async () => { + try { + const results = await this.workspace.pull(targets.map(target => target.path)); + const failed = new Set(results.errors.map(error => error.file)); + this.finishAll(targets, path => failed.has(path) ? 'failed' : 'success'); + } catch { + this.failAll(targets); + } + }); } /** Deletes one or more changes from the remote only. */ @@ -133,36 +155,41 @@ export class SourceControlActionService { if (targets.length === 0) return; this.startAll(targets); - try { - const result = await this.workspace.deleteRemote(targets.map(target => target.path)); - const failed = new Set(result.errors.map(error => error.path)); - this.finishAll(targets, path => failed.has(path) ? 'failed' : 'success'); - } catch { - this.failAll(targets); - } + await this.serialized(async () => { + try { + const result = await this.workspace.deleteRemote(targets.map(target => target.path)); + const failed = new Set(result.errors.map(error => error.path)); + this.finishAll(targets, path => failed.has(path) ? 'failed' : 'success'); + } catch { + this.failAll(targets); + } + }); } /** * Executes the whole Sync Queue as one explicit-intent workflow. * Kept as the stable UI-facing facade; orchestration lives in - * SyncIntentExecutor. + * SyncIntentExecutor. `mode` is per-execution: manual Sync stays + * interactive, Automatic Sync passes `background`. */ - async sync(intents: readonly SyncIntentRequest[]): Promise { - await this.syncIntentExecutor.execute(intents); + async sync(intents: readonly SyncIntentRequest[], mode: SyncExecutionMode = 'interactive'): Promise { + await this.syncIntentExecutor.execute(intents, mode); } /** Deletes one or more changes from the local vault only. */ async deleteLocal(changeIds: readonly ChangeId[]): Promise { const targets = this.resolve(changeIds); - for (const target of targets) { - this.operations.start(target.id); - try { - await this.workspace.deleteLocal(target.path); - this.operations.succeed(target.id); - } catch { - this.operations.fail(target.id); + await this.serialized(async () => { + for (const target of targets) { + this.operations.start(target.id); + try { + await this.workspace.deleteLocal(target.path); + this.operations.succeed(target.id); + } catch { + this.operations.fail(target.id); + } } - } + }); } /** @@ -174,6 +201,11 @@ export class SourceControlActionService { const change = this.changes.getById(changeId); if (!change) return; + await this.serialized(() => this.resolveConflictLocked(change, resolution)); + } + + private async resolveConflictLocked(change: SyncChange, resolution: ConflictResolution): Promise { + const changeId = change.id; this.operations.start(changeId); try { if (resolution === 'local') { diff --git a/src/logic/source-control/SyncExecutionGuard.ts b/src/logic/source-control/SyncExecutionGuard.ts new file mode 100644 index 00000000..c2ede605 --- /dev/null +++ b/src/logic/source-control/SyncExecutionGuard.ts @@ -0,0 +1,53 @@ +/** + * Application-level serialization for sync mutations. + * + * Manual work acquires (and waits for) the guard so a user-triggered sync is + * never discarded. Automatic work uses {@link tryAcquire} and skips its tick + * when the sync execution path is already busy, rather than queueing a backlog + * or running provider mutations concurrently. + * + * Deliberately minimal: one lock, no queue priority, no cancellation/preemption. + */ +export class SyncExecutionGuard { + private locked = false; + private readonly waiters: Array<() => void> = []; + + get isLocked(): boolean { + return this.locked; + } + + /** Waits for the guard, then returns a one-shot release function. */ + async acquire(): Promise<() => void> { + if (!this.locked) { + this.locked = true; + return this.releaseOnce(); + } + await new Promise(resolve => this.waiters.push(resolve)); + return this.releaseOnce(); + } + + /** Acquires only if idle; returns null (and runs nothing) when already busy. */ + tryAcquire(): (() => void) | null { + if (this.locked) return null; + this.locked = true; + return this.releaseOnce(); + } + + /** + * Hands the lock directly to the next waiter on release so a waiting + * manual operation doesn't race a fresh automatic tick for it. + */ + private releaseOnce(): () => void { + let released = false; + return () => { + if (released) return; + released = true; + const next = this.waiters.shift(); + if (next) { + next(); + } else { + this.locked = false; + } + }; + } +} diff --git a/src/logic/source-control/SyncIntentExecutor.ts b/src/logic/source-control/SyncIntentExecutor.ts index 9db08ecd..77dc9f21 100644 --- a/src/logic/source-control/SyncIntentExecutor.ts +++ b/src/logic/source-control/SyncIntentExecutor.ts @@ -12,6 +12,7 @@ import type { ChangeRepository } from './ChangeRepository'; import type { OperationState } from './OperationState'; import type { SyncExecutionResult, SyncResultNotificationPort } from './SyncResultNotifier'; import type { SyncIntentRequest } from './SyncIntent'; +import { SyncExecutionGuard } from './SyncExecutionGuard'; import type { SyncChange } from './types'; interface ResolvedSyncIntent { @@ -30,6 +31,22 @@ interface ConfirmedSyncPlan { confirmed: boolean; } +/** + * Per-execution interaction policy for the shared Sync Queue path. + * + * - `interactive` (manual Sync): unchanged behavior -- one merged plan, one + * confirmation modal, batch conflict interaction, and a result notice. + * - `background` (automatic sync): build/validate the same plan but + * auto-accept it, never open a conflict modal, skip conflicting paths + * instead of aborting, and stay silent on success. Errors are still logged + * by the caller. + * + * This is deliberately an explicit mode rather than a set of independent + * booleans, so invalid combinations (e.g. confirm + showConflictModal) can't + * be expressed. + */ +export type SyncExecutionMode = 'interactive' | 'background'; + /** * Executes the Sync Queue use-case from explicit user intent. * @@ -50,9 +67,24 @@ export class SyncIntentExecutor { private readonly operations: OperationState, private readonly workspace: SyncWorkspace, private readonly notifier: SyncResultNotificationPort = { notify: () => {} }, + private readonly guard: SyncExecutionGuard = new SyncExecutionGuard(), ) {} - async execute(intents: readonly SyncIntentRequest[]): Promise { + async execute(intents: readonly SyncIntentRequest[], mode: SyncExecutionMode = 'interactive'): Promise { + // Serialize automatic mutations against user-triggered ones. Automatic + // work skips its tick entirely when the path is busy; manual work waits + // so a user action is never discarded. + const release = mode === 'background' ? this.guard.tryAcquire() : await this.guard.acquire(); + if (!release) return; + + try { + await this.executeLocked(intents, mode); + } finally { + release(); + } + } + + private async executeLocked(intents: readonly SyncIntentRequest[], mode: SyncExecutionMode): Promise { const resolved = this.resolveIntents(intents); if (resolved.length === 0) return; @@ -61,26 +93,38 @@ export class SyncIntentExecutor { let plan: ConfirmedSyncPlan | null; try { - plan = await this.planAndConfirm(buckets); + plan = await this.planAndConfirm(buckets, mode); } catch { this.failAll(targets); - this.notifier.notify({ ...emptyExecutionResult(), failed: targets.length }); + if (mode !== 'background') this.notifier.notify({ ...emptyExecutionResult(), failed: targets.length }); return; } if (!plan || !plan.confirmed) return; + // Paths the planner left out because they still need manual conflict + // resolution. They must never be reported as successfully synced or + // transitioned to operation success merely because they were in the + // original target set -- after the final refresh they remain conflicts. + const skippedPaths = new Set(plan.plannedPush.conflictedPaths); + this.startAll(targets); const summary = emptyExecutionResult(); if (hasRemoteMutations(plan.plannedPush, buckets.deleteRemote)) { - await this.commitRemoteBucket(plan.plannedPush, buckets.push, buckets.deleteRemote, summary); + await this.commitRemoteBucket(plan.plannedPush, buckets.push, buckets.deleteRemote, summary, skippedPaths); } if (buckets.pull.length > 0) { - await this.applyPullBucket(buckets.pull, summary); + await this.applyPullBucket(buckets.pull, summary, skippedPaths); + } + + // Any skipped/conflicted target that a commit or pull may have marked + // (or left running) is reset to idle rather than success/failure. + for (const target of targets) { + if (skippedPaths.has(target.path)) this.operations.reset(target.id); } - this.notifier.notify(summary); + if (mode !== 'background') this.notifier.notify(summary); } private resolveIntents(intents: readonly SyncIntentRequest[]): ResolvedSyncIntent[] { @@ -106,13 +150,20 @@ export class SyncIntentExecutor { return buckets; } - private async planAndConfirm(buckets: SyncIntentBuckets): Promise { + private async planAndConfirm( + buckets: SyncIntentBuckets, + mode: SyncExecutionMode, + ): Promise { + // The execution policy is per-run: interactive planning may prompt and + // cancel the batch on conflict, background planning skips conflicts and + // continues with the safe paths. Both validate the normal sync plan. + const conflictBehavior = mode === 'background' ? 'skip' : 'prompt'; const plannedPush = buckets.push.length > 0 - ? await this.workspace.planPush(buckets.push.map(change => change.path)) + ? await this.workspace.planPush(buckets.push.map(change => change.path), conflictBehavior) : emptyPlannedBatch(); - // Batch conflict resolution is an interactive planning step. If the - // user cancels it, no merged review modal or mutation should follow. + // An interactive batch may be aborted by cancelling conflict + // resolution; background planning never returns `cancelled`. if (plannedPush.cancelled) return null; const pullPlan = buckets.pull.length > 0 @@ -135,9 +186,11 @@ export class SyncIntentExecutor { if (isSyncPlanEmpty(mergedPlan)) return null; + // Background mode auto-accepts the reviewed plan; it never opens the + // final confirmation modal. return { plannedPush, - confirmed: await this.workspace.confirmPlan(mergedPlan, 'sync'), + confirmed: mode === 'background' ? true : await this.workspace.confirmPlan(mergedPlan, 'sync'), }; } @@ -146,8 +199,9 @@ export class SyncIntentExecutor { pushTargets: readonly SyncChange[], deleteTargets: readonly SyncChange[], summary: SyncExecutionResult, + skippedPaths: ReadonlySet, ): Promise { - const targets = [...pushTargets, ...deleteTargets]; + const targets = [...pushTargets, ...deleteTargets].filter(target => !skippedPaths.has(target.path)); try { const deleteEntries: DeleteQueueEntry[] = deleteTargets.map(change => ({ path: change.path, @@ -187,20 +241,23 @@ export class SyncIntentExecutor { private async applyPullBucket( pullTargets: readonly SyncChange[], summary: SyncExecutionResult, + skippedPaths: ReadonlySet, ): Promise { + const targets = pullTargets.filter(target => !skippedPaths.has(target.path)); try { + if (targets.length === 0) return; const results = await this.workspace.applyPull( - pullTargets.map(change => change.path), + targets.map(change => change.path), { notify: false }, ); const failed = new Set(results.errors.map(error => error.file)); - this.finishAll(pullTargets, path => failed.has(path) ? 'failed' : 'success'); + this.finishAll(targets, path => failed.has(path) ? 'failed' : 'success'); summary.downloaded += results.added + results.updated; summary.failed += results.failed; summary.conflicts += results.conflicts; summary.errors.push(...results.errors); } catch { - this.failAll(pullTargets); + this.failAll(targets); summary.failed += pullTargets.length; } } diff --git a/src/logic/sync/PushCoordinator.ts b/src/logic/sync/PushCoordinator.ts index 0c6ce956..370a7db5 100644 --- a/src/logic/sync/PushCoordinator.ts +++ b/src/logic/sync/PushCoordinator.ts @@ -23,6 +23,19 @@ import { type BatchOutcome = 'done' | 'unchanged' | 'conflict'; +/** + * How push planning reacts when a candidate conflicts with the remote. + * + * - `prompt` (interactive/manual): ask the user to resolve the batch through + * the interaction port; cancelling aborts the whole batch. + * - `skip` (background/automatic): do not touch UI, leave each conflicting + * path out of the plan, and continue with the safe paths. + * + * Deliberately UI-independent: `PushCoordinator` never learns about ExecutionMode + * or Obsidian, only this small behavior switch supplied by the caller. + */ +export type PushConflictBehavior = 'prompt' | 'skip'; + interface BatchPushPlan { pushes: PushQueueEntry[]; moves: MoveQueueEntry[]; @@ -123,6 +136,7 @@ export class PushCoordinator { files: Array, onProgress?: (current: number, total: number, fileName: string) => void, remoteTree?: GitTreeEntry[], + conflictBehavior: PushConflictBehavior = 'prompt', ): Promise { const syncableFiles = files.filter(file => file && !this.dependencies.isPathIgnored(this.fileInfo(file).path)); if (syncableFiles.length === 0) { @@ -150,6 +164,26 @@ export class PushCoordinator { errors: immediate.errors, syncedPaths: immediate.syncedPaths, }; + + // Background planning skips the interaction port entirely: every + // conflicting path is left out of the plan (safe paths continue), and + // the batch is never cancelled. Interactive planning keeps the existing + // prompt-and-maybe-cancel behavior untouched. + if (conflictBehavior === 'skip') { + const skippedConflicts = plan.conflicts.length + plan.autoSkipped.length; + return { + reviewPlan: this.buildReviewPlan(plan, plan.conflicts, []), + pushes: plan.pushes, + moves: plan.moves, + keepRemote: [], + keepLocal: [], + skippedConflicts, + conflictedPaths: this.conflictedPaths(plan), + cancelled: false, + immediate, + }; + } + const skipped: BatchPushConflict[] = []; const keepRemote: BatchPushConflict[] = []; const keepLocal: BatchPushConflict[] = []; diff --git a/src/logic/sync/SyncManager.ts b/src/logic/sync/SyncManager.ts index 478e22fb..31330f93 100644 --- a/src/logic/sync/SyncManager.ts +++ b/src/logic/sync/SyncManager.ts @@ -18,7 +18,7 @@ import { SyncScanner } from './SyncScanner'; import { ConflictResolver } from './ConflictResolver'; import { SyncExecutor } from './SyncExecutor'; import { PullCoordinator } from './PullCoordinator'; -import { PushCoordinator } from './PushCoordinator'; +import { PushCoordinator, type PushConflictBehavior } from './PushCoordinator'; import { HeadlessSyncInteraction, type ConflictDiffLoader, @@ -315,8 +315,9 @@ export class SyncManager { files: (TFile | string)[], onProgress?: (current: number, total: number, fileName: string) => void, remoteTree?: GitTreeEntry[], + conflictBehavior?: PushConflictBehavior, ): ReturnType { - return this.pushCoordinator.planSyncBatch(files, onProgress, remoteTree); + return this.pushCoordinator.planSyncBatch(files, onProgress, remoteTree, conflictBehavior); } /** Commits already-planned pushes/moves/deletions as one provider mutation set. */ diff --git a/src/logic/sync/SyncWorkspace.ts b/src/logic/sync/SyncWorkspace.ts index c1c01079..1c548ef3 100644 --- a/src/logic/sync/SyncWorkspace.ts +++ b/src/logic/sync/SyncWorkspace.ts @@ -3,7 +3,7 @@ import type { GitServiceInterface, GitTreeEntry } from '../../services/git-servi import { getServiceName, type GitLabFilesPushSettings } from '../../settings'; import type { GitignoreManager } from '../gitignore-manager'; import type { FileStatus, SyncStatusService } from '../sync-status-service'; -import type { PlannedPushBatch } from './PushCoordinator'; +import type { PlannedPushBatch, PushConflictBehavior } from './PushCoordinator'; import { RemoteDeleteExecutor, type RemoteDeleteResult } from './RemoteDeleteExecutor'; import { SyncDiffService } from './SyncDiffService'; import type { SyncManager } from './SyncManager'; @@ -45,8 +45,13 @@ export interface SyncWorkspace { getDiff(path: string): Promise; /** Repo-relative path a provider mutation needs for a given vault path. */ toRepoPath(path: string): string; - /** Classifies and conflict-resolves a push batch without confirming or committing — for a unified Sync Plan. */ - planPush(paths: readonly string[]): Promise; + /** + * Classifies and conflict-resolves a push batch without confirming or + * committing — for a unified Sync Plan. `conflictBehavior` selects + * interactive prompt-and-maybe-cancel ('prompt', default) or + * skip-conflicts-and-continue ('skip', for background execution). + */ + planPush(paths: readonly string[], conflictBehavior?: PushConflictBehavior): Promise; /** Computes what a pull batch would do, without writing anything — for a unified Sync Plan. */ planPull(paths: readonly string[]): Promise; /** Applies an already-confirmed pull batch without showing its own confirm modal. */ @@ -183,9 +188,9 @@ export class SyncManagerWorkspace implements SyncWorkspace { return this.dependencies.normalizePath(path); } - async planPush(paths: readonly string[]): Promise { + async planPush(paths: readonly string[], conflictBehavior: PushConflictBehavior = 'prompt'): Promise { const remoteTree = await this.reusableRemoteTree(); - return this.dependencies.manager().planSyncBatch([...paths], undefined, remoteTree); + return this.dependencies.manager().planSyncBatch([...paths], undefined, remoteTree, conflictBehavior); } async planPull(paths: readonly string[]): Promise { @@ -254,7 +259,7 @@ export class BoundarySyncWorkspace implements SyncWorkspace { trackRename(newPath: string, oldPath: string): Promise { return this.getManager().trackRename(newPath, oldPath); } getDiff(path: string): Promise { return this.boundaries.getDiff(path); } toRepoPath(path: string): string { return path; } - planPush(paths: readonly string[]): Promise { return this.getManager().planSyncBatch([...paths]); } + planPush(paths: readonly string[], conflictBehavior?: PushConflictBehavior): Promise { return this.getManager().planSyncBatch([...paths], undefined, undefined, conflictBehavior); } planPull(paths: readonly string[]): Promise { return this.getManager().planPullBatch([...paths]); } applyPull(paths: readonly string[], options?: PullExecutionOptions): Promise { return this.getManager().applyPullBatch([...paths], undefined, undefined, options); } commitResolvedBatch( diff --git a/src/main.ts b/src/main.ts index 2a5c6d4a..2625a035 100644 --- a/src/main.ts +++ b/src/main.ts @@ -24,6 +24,8 @@ import type { SyncSelectionStore } from './logic/source-control/SyncSelectionSto import type { SourceControlViewModel } from './logic/source-control/SourceControlViewModel'; import type { SourceControlActionService } from './logic/source-control/SourceControlActionService'; import { createSyncRuntime } from './runtime/createSyncRuntime'; +import { AutomaticSyncScheduler } from './runtime/AutomaticSyncScheduler'; +import type { AutomaticSyncService } from './logic/source-control/AutomaticSyncService'; import { filterFilesByVaultFolder as scopeFilterFiles, filterPathByVaultFolder as scopeFilterPath, @@ -52,6 +54,8 @@ export default class GitLabFilesPush extends Plugin { refreshState: RefreshState; sourceControlViewModel: SourceControlViewModel; sourceControlActions: SourceControlActionService; + automaticSync: AutomaticSyncService; + private automaticSyncScheduler?: AutomaticSyncScheduler; private disposeSyncRuntime?: () => void; private gitignoreConfigKey = ''; private pushRibbonEl: HTMLElement; @@ -114,7 +118,14 @@ export default class GitLabFilesPush extends Plugin { this.refreshState = runtime.refreshState; this.sourceControlViewModel = runtime.sourceControlViewModel; this.sourceControlActions = runtime.sourceControlActions; + this.automaticSync = runtime.automaticSync; this.disposeSyncRuntime = () => runtime.dispose(); + this.automaticSyncScheduler = new AutomaticSyncScheduler({ + getSettings: () => this.settings, + run: () => this.automaticSync.runOnce(), + registerInterval: id => this.registerInterval(id), + }); + this.automaticSyncScheduler.apply(); this.statusBarEl = this.addStatusBarItem(); this.statusBarEl.addClass('gfs-status-bar-connection'); @@ -276,18 +287,29 @@ export default class GitLabFilesPush extends Plugin { }) ); - this.app.workspace.onLayoutReady(() => { - // Legacy workspaces may hold more than one persisted sync-status - // leaf (duplicates accumulated across old plugin versions). - // Normalize first so startup activation reuses a single canonical - // leaf instead of revealing one duplicate while others linger. - this.normalizeSourceControlLeaves(); - if (this.settings.autoRefreshOnStartup) void this.refreshSyncStatusOnStartup(); - }); + this.app.workspace.onLayoutReady(() => this.handleLayoutReady()); await this.checkForUpdateNotice(); } + /** + * Startup decision after Obsidian's layout is ready: + * - legacy workspaces may hold duplicate persisted leaves; normalize first + * so activation reuses one canonical leaf; + * - startup Automatic Sync runs its own authoritative refresh in the + * background and must NOT open/focus Source Control, and it supersedes the + * legacy refresh-on-startup so boot doesn't fetch twice; + * - otherwise the existing refresh-on-startup behavior is preserved. + */ + private handleLayoutReady(): void { + this.normalizeSourceControlLeaves(); + if (this.settings.automaticSyncEnabled && this.settings.automaticSyncOnStartup) { + void this.automaticSync.runOnce(); + } else if (this.settings.autoRefreshOnStartup) { + void this.refreshSyncStatusOnStartup(); + } + } + private async refreshSyncStatusOnStartup(): Promise { await this.activateSourceControlView(); await this.sourceControlViewModel.refresh('startup'); @@ -678,7 +700,11 @@ export default class GitLabFilesPush extends Plugin { // Cleanup of registered components (views, commands, DOM/vault event // listeners) is handled by Obsidian. The sync runtime's cross-object // wiring (the ChangeRepository subscription) isn't Obsidian-managed, - // so it's disposed explicitly. + // so it's disposed explicitly. The automatic-sync timer is also stopped + // here even though registerInterval covers unload, so an interval is + // cleared the moment the plugin is disabled rather than lingering. + this.automaticSyncScheduler?.dispose(); + this.automaticSyncScheduler = undefined; this.disposeSyncRuntime?.(); this.disposeSyncRuntime = undefined; } @@ -693,5 +719,8 @@ export default class GitLabFilesPush extends Plugin { this.initializeGitService(); this.updateGitignoreManager(); this.updateRibbonTooltip(); + // Interval/enabled changes must take effect without a plugin reload; + // unchanged values are a no-op (see AutomaticSyncScheduler.apply). + this.automaticSyncScheduler?.apply(); } } diff --git a/src/runtime/AutomaticSyncScheduler.ts b/src/runtime/AutomaticSyncScheduler.ts new file mode 100644 index 00000000..f6721cde --- /dev/null +++ b/src/runtime/AutomaticSyncScheduler.ts @@ -0,0 +1,68 @@ +import { DEFAULT_SETTINGS } from '../settings/model'; +import { normalizeAutomaticSyncIntervalMinutes } from '../settings/helpers'; + +export interface AutomaticSyncScheduleSettings { + automaticSyncEnabled: boolean; + automaticSyncIntervalMinutes: number; +} + +export interface AutomaticSyncSchedulerDependencies { + /** Reads the current setting each time the schedule is (re)applied. */ + getSettings(): AutomaticSyncScheduleSettings; + /** Runs one background automatic sync. Overlapping ticks are skipped by the service. */ + run(): Promise; + /** Obsidian's `registerInterval`, so the plugin unload clears the timer too. */ + registerInterval(id: number): number; +} + +/** + * Owns only the timer for Automatic Sync. Whether a run should happen, and what + * it does, stays in AutomaticSyncService; Obsidian lifecycle stays in main.ts. + * + * Re-applying the schedule is idempotent for an unchanged enabled/interval + * pair, so frequent settings saves (e.g. token typing) don't postpone the next + * tick. A genuine enabled/interval change clears the old timer immediately and + * installs the new one, so it takes effect without a plugin reload. + */ +export class AutomaticSyncScheduler { + private timer: number | null = null; + private appliedKey = ''; + + constructor(private readonly dependencies: AutomaticSyncSchedulerDependencies) {} + + get isScheduled(): boolean { + return this.timer !== null; + } + + /** Clears and installs the timer to match the current settings. */ + apply(): void { + const settings = this.dependencies.getSettings(); + const minutes = normalizeAutomaticSyncIntervalMinutes( + settings.automaticSyncIntervalMinutes, + DEFAULT_SETTINGS.automaticSyncIntervalMinutes, + ); + const key = `${settings.automaticSyncEnabled ? 'on' : 'off'}:${minutes}`; + if (key === this.appliedKey) return; + this.appliedKey = key; + + this.clearTimer(); + if (!settings.automaticSyncEnabled) return; + + this.timer = window.setInterval(() => { + void this.dependencies.run(); + }, minutes * 60_000); + this.dependencies.registerInterval(this.timer); + } + + /** Stops any scheduled execution. */ + dispose(): void { + this.clearTimer(); + this.appliedKey = ''; + } + + private clearTimer(): void { + if (this.timer === null) return; + window.clearInterval(this.timer); + this.timer = null; + } +} diff --git a/src/runtime/createSyncRuntime.ts b/src/runtime/createSyncRuntime.ts index f6965927..122cd43e 100644 --- a/src/runtime/createSyncRuntime.ts +++ b/src/runtime/createSyncRuntime.ts @@ -12,9 +12,11 @@ import { RefreshState } from '../logic/source-control/RefreshState'; import { SyncSelectionStore } from '../logic/source-control/SyncSelectionStore'; import { SourceControlViewModel } from '../logic/source-control/SourceControlViewModel'; import { SourceControlActionService } from '../logic/source-control/SourceControlActionService'; +import { AutomaticSyncService } from '../logic/source-control/AutomaticSyncService'; import { SyncResultNotifier } from '../logic/source-control/SyncResultNotifier'; import { toSyncChanges } from '../logic/source-control/FileStatusAdapter'; import { ObsidianSyncInteraction } from '../ui/ObsidianSyncInteraction'; +import { logger } from '../utils/logger'; export interface SyncRuntimeDependencies { app: App; @@ -45,6 +47,7 @@ export interface SyncRuntime { refreshState: RefreshState; sourceControlViewModel: SourceControlViewModel; sourceControlActions: SourceControlActionService; + automaticSync: AutomaticSyncService; /** Tears down cross-object wiring (the ChangeRepository subscription) that Obsidian does not manage. */ dispose(): void; } @@ -115,6 +118,12 @@ export function createSyncRuntime(deps: SyncRuntimeDependencies): SyncRuntime { syncWorkspace, new SyncResultNotifier(deps.notify), ); + const automaticSync = new AutomaticSyncService({ + workspace: syncWorkspace, + changes: changeRepository, + actions: sourceControlActions, + onError: error => logger.error('Automatic sync failed', error), + }); // Selection-intent reconciliation is wired here, at the composition // root, rather than inside SourceControlViewModel: it is a write-side @@ -144,6 +153,7 @@ export function createSyncRuntime(deps: SyncRuntimeDependencies): SyncRuntime { refreshState, sourceControlViewModel, sourceControlActions, + automaticSync, dispose: () => { unsubscribeChangeRepository(); unsubscribeSelectionReconciliation(); diff --git a/src/settings/helpers.ts b/src/settings/helpers.ts index fded38ce..9058663b 100644 --- a/src/settings/helpers.ts +++ b/src/settings/helpers.ts @@ -1,5 +1,28 @@ import type { GitLabFilesPushSettings, SymlinkHandling, SyncMetadata } from './model'; +/** + * Smallest interval an automatic sync may be scheduled for, in minutes. The + * settings UI also clamps to this, but the scheduler resolves through this + * helper as well so an invalid persisted value (0, negative, NaN, a stray + * string from older data) can never produce a tight/zero-interval timer. + */ +export const MIN_AUTOMATIC_SYNC_INTERVAL_MINUTES = 1; + +/** + * Coerces a stored/typed automatic-sync interval to a valid minute count. + * Non-finite, non-numeric, or below the minimum values fall back to the + * default, so older settings that predate this field (or hand-edited data) + * can never create a rapid/zero-delay timer. + */ +export function normalizeAutomaticSyncIntervalMinutes( + value: unknown, + fallback: number, +): number { + const parsed = typeof value === 'number' ? value : Number(value); + if (!Number.isFinite(parsed) || parsed < MIN_AUTOMATIC_SYNC_INTERVAL_MINUTES) return fallback; + return Math.floor(parsed); +} + /** * Metadata written before `lastKnownPath` was introduced used its record key * as the path. Keep that format eligible for rename reconciliation. diff --git a/src/settings/model.ts b/src/settings/model.ts index ca91cfe6..b71dab9a 100644 --- a/src/settings/model.ts +++ b/src/settings/model.ts @@ -51,6 +51,12 @@ export interface GitLabFilesPushSettings { language: LanguageSetting; /** Refresh the sync status automatically after Obsidian finishes loading. */ autoRefreshOnStartup: boolean; + /** Run the default Sync action for pending changes automatically on a schedule. */ + automaticSyncEnabled: boolean; + /** How often scheduled automatic sync runs, in minutes. Minimum enforced by `normalizeAutomaticSyncIntervalMinutes`. */ + automaticSyncIntervalMinutes: number; + /** Run one background automatic sync once Obsidian finishes loading, in addition to the interval. */ + automaticSyncOnStartup: boolean; } export const DEFAULT_SETTINGS: GitLabFilesPushSettings = { @@ -75,4 +81,7 @@ export const DEFAULT_SETTINGS: GitLabFilesPushSettings = { bannerDismissedVersion: '', language: 'system', autoRefreshOnStartup: true, + automaticSyncEnabled: false, + automaticSyncIntervalMinutes: 5, + automaticSyncOnStartup: false, }; diff --git a/src/ui/settings/GitLabSyncSettingTab.ts b/src/ui/settings/GitLabSyncSettingTab.ts index 462a5bad..af673dd8 100644 --- a/src/ui/settings/GitLabSyncSettingTab.ts +++ b/src/ui/settings/GitLabSyncSettingTab.ts @@ -11,8 +11,8 @@ import { RemoteFolderSuggest } from '../RemoteFolderSuggest'; import { WhatsNewModal } from '../WhatsNewModal'; import { t, setLanguageOverride, type LanguageSetting } from '../../i18n'; import { CHANGELOG, entryText } from '../../changelog'; -import type { GitLabFilesPushSettings, GitServiceType, SymlinkHandling } from '../../settings/model'; -import { getServiceName, getEffectiveSymlinkHandling } from '../../settings/helpers'; +import { DEFAULT_SETTINGS, type GitLabFilesPushSettings, type GitServiceType, type SymlinkHandling } from '../../settings/model'; +import { getServiceName, getEffectiveSymlinkHandling, normalizeAutomaticSyncIntervalMinutes, MIN_AUTOMATIC_SYNC_INTERVAL_MINUTES } from '../../settings/helpers'; // Minimal shape of Obsidian >= 1.13's SettingDefinitionItem. Declared locally so // the plugin still type-checks against older Obsidian typings (minAppVersion @@ -279,6 +279,43 @@ export class GitLabSyncSettingTab extends PluginSettingTab { FolderSuggest.attach(this.app, text.inputEl); }); + new Setting(containerEl) + .setName(t('settings.automaticSync.name')) + .setDesc(t('settings.automaticSync.desc')) + .addToggle(toggle => toggle + .setValue(this.host.settings.automaticSyncEnabled) + .onChange((value) => { + this.host.settings.automaticSyncEnabled = value; + void this.host.saveSettings(); + this.refresh(); + })); + + new Setting(containerEl) + .setName(t('settings.automaticSyncInterval.name')) + .setDesc(t('settings.automaticSyncInterval.desc', { min: MIN_AUTOMATIC_SYNC_INTERVAL_MINUTES })) + .setDisabled(!this.host.settings.automaticSyncEnabled) + .addText(text => text + .setPlaceholder(String(DEFAULT_SETTINGS.automaticSyncIntervalMinutes)) + .setValue(String(this.host.settings.automaticSyncIntervalMinutes)) + .onChange((value) => { + this.host.settings.automaticSyncIntervalMinutes = normalizeAutomaticSyncIntervalMinutes( + value.trim() === '' ? Number.NaN : value, + DEFAULT_SETTINGS.automaticSyncIntervalMinutes, + ); + void this.host.saveSettings(); + })); + + new Setting(containerEl) + .setName(t('settings.automaticSyncOnStartup.name')) + .setDesc(t('settings.automaticSyncOnStartup.desc')) + .setDisabled(!this.host.settings.automaticSyncEnabled) + .addToggle(toggle => toggle + .setValue(this.host.settings.automaticSyncOnStartup) + .onChange((value) => { + this.host.settings.automaticSyncOnStartup = value; + void this.host.saveSettings(); + })); + new Setting(containerEl) .setName(t('settings.autoRefreshOnStartup.name')) .setDesc(t('settings.autoRefreshOnStartup.desc')) diff --git a/tests/changelog.test.ts b/tests/changelog.test.ts index 6fdc47a0..3dec989a 100644 --- a/tests/changelog.test.ts +++ b/tests/changelog.test.ts @@ -46,7 +46,45 @@ describe('1.5.0 release notes', () => { }); }); +describe('1.7.0 release notes', () => { + it('is registered in the changelog', () => { + expect(CHANGELOG.some(release => release.version === '1.7.0')).toBe(true); + }); + + it('provides a headline and summary in every supported language', () => { + const release = CHANGELOG.find(r => r.version === '1.7.0'); + expect(release?.headline?.en).toBeTruthy(); + expect(release?.headline?.['zh-tw']).toBeTruthy(); + expect(release?.headline?.['zh-cn']).toBeTruthy(); + expect(release?.summary?.en).toBeTruthy(); + expect(release?.summary?.['zh-tw']).toBeTruthy(); + expect(release?.summary?.['zh-cn']).toBeTruthy(); + }); + + it('has notable entries covering scheduled sync, startup sync, and safe conflict skipping', () => { + const release = CHANGELOG.find(r => r.version === '1.7.0'); + const notable = release?.entries.filter(entry => entry.notable) ?? []; + expect(notable.length).toBeGreaterThanOrEqual(3); + for (const entry of notable) { + expect(entry.text.en).toBeTruthy(); + expect(entry.text['zh-tw']).toBeTruthy(); + expect(entry.text['zh-cn']).toBeTruthy(); + } + }); + + it('is surfaced as an unseen release for a vault upgrading from 1.6.0', () => { + const unseen = getUnseenReleases(CHANGELOG, '1.6.0'); + expect(unseen.map(release => release.version)).toContain('1.7.0'); + }); +}); + describe('CHANGELOG ordering and content', () => { + it('lists 1.7.0 before 1.6.0 before 1.5.0', () => { + const versions = CHANGELOG.map(r => r.version); + expect(versions.indexOf('1.7.0')).toBeLessThan(versions.indexOf('1.6.0')); + expect(versions.indexOf('1.7.0')).toBeGreaterThanOrEqual(0); + }); + it('lists 1.6.0 before 1.5.0', () => { const versions = CHANGELOG.map(r => r.version); expect(versions.indexOf('1.6.0')).toBeLessThan(versions.indexOf('1.5.0')); diff --git a/tests/i18n/index.test.ts b/tests/i18n/index.test.ts index 9030bbca..f3fee87f 100644 --- a/tests/i18n/index.test.ts +++ b/tests/i18n/index.test.ts @@ -52,6 +52,49 @@ describe('i18n', () => { expect(typeof t('confirmModal.title')).toBe('string'); }); + describe('automatic sync settings keys', () => { + const keys = [ + 'settings.automaticSync.name', + 'settings.automaticSync.desc', + 'settings.automaticSyncInterval.name', + 'settings.automaticSyncInterval.desc', + 'settings.automaticSyncOnStartup.name', + 'settings.automaticSyncOnStartup.desc', + ] as const; + + it('exist and resolve in EN', () => { + setMomentLocale(undefined); + for (const key of keys) { + expect(t(key)).toBeTruthy(); + } + }); + + it('exist and resolve in zh-TW and zh-CN without falling back to English', () => { + setMomentLocale('zh-tw'); + for (const key of keys) { + expect(t(key)).toBeTruthy(); + } + expect(t('settings.automaticSync.name')).toBe('自動同步'); + + setMomentLocale('zh-cn'); + for (const key of keys) { + expect(t(key)).toBeTruthy(); + } + expect(t('settings.automaticSync.name')).toBe('自动同步'); + }); + + it('keeps the existing refresh-on-startup setting as a separate key', () => { + setMomentLocale(undefined); + expect(t('settings.autoRefreshOnStartup.name')).toBe('Refresh status on startup'); + expect(t('settings.autoRefreshOnStartup.name')).not.toBe(t('settings.automaticSync.name')); + }); + + it('interpolates the minimum interval into the interval description', () => { + setMomentLocale(undefined); + expect(t('settings.automaticSyncInterval.desc', { min: 1 })).toContain('1'); + }); + }); + describe('inline plural forms ({name|singular|plural})', () => { it('renders the singular branch when the value is exactly 1 and plural otherwise', () => { setMomentLocale(undefined); diff --git a/tests/logic/source-control/AutomaticSyncService.test.ts b/tests/logic/source-control/AutomaticSyncService.test.ts new file mode 100644 index 00000000..785f48b0 --- /dev/null +++ b/tests/logic/source-control/AutomaticSyncService.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it, vi } from 'vitest'; +import { AutomaticSyncService } from '../../../src/logic/source-control/AutomaticSyncService'; +import { ChangeRepository } from '../../../src/logic/source-control/ChangeRepository'; +import type { SyncExecutionMode } from '../../../src/logic/source-control/SyncIntentExecutor'; +import type { SyncIntentRequest } from '../../../src/logic/source-control/SyncIntent'; +import { toChangeId } from '../../../src/logic/source-control/types'; +import type { SyncChange, SyncChangeKind } from '../../../src/logic/source-control/types'; +import type { SyncStatusRefreshResult } from '../../../src/logic/sync/SyncStatusRefreshService'; + +function change(path: string, kind: SyncChangeKind): SyncChange { + return { id: toChangeId(path), path, kind }; +} + +function emptyRefreshResult(): SyncStatusRefreshResult { + return {} as SyncStatusRefreshResult; +} + +function buildService(changes: SyncChange[], overrides: { + refresh?: () => Promise; + sync?: (intents: readonly SyncIntentRequest[], mode?: SyncExecutionMode) => Promise; + onError?: (error: unknown) => void; +} = {}) { + const repository = new ChangeRepository(); + repository.replace(changes); + const refresh = vi.fn(overrides.refresh ?? (() => Promise.resolve(emptyRefreshResult()))); + const sync = vi.fn(overrides.sync ?? (() => Promise.resolve(undefined))); + const service = new AutomaticSyncService({ + workspace: { refresh }, + changes: repository, + actions: { sync }, + onError: overrides.onError, + }); + return { service, refresh, sync, repository }; +} + +describe('AutomaticSyncService', () => { + it('refreshes authoritative state before and after execution, and uses the refreshed repository snapshot', async () => { + const order: string[] = []; + const repository = new ChangeRepository(); + const refresh = vi.fn() + .mockImplementation(async () => { + if (!repository.getById(toChangeId('note.md'))) { + repository.replace([change('note.md', 'local-modified')]); + } + order.push('refresh'); + }); + const sync = vi.fn().mockImplementation(async () => { order.push('sync'); }); + const service = new AutomaticSyncService({ + workspace: { refresh }, + changes: repository, + actions: { sync }, + }); + + await service.runOnce(); + + expect(refresh).toHaveBeenCalledTimes(2); + // Intent generation read the repository only after the first refresh. + expect(sync).toHaveBeenCalledTimes(1); + const intents = sync.mock.calls[0]?.[0] as SyncIntentRequest[]; + expect(intents.map(intent => intent.changeId)).toContain(toChangeId('note.md')); + expect(order).toEqual(['refresh', 'sync', 'refresh']); + }); + + it('excludes synced and already-conflicted changes from intent generation', async () => { + const { service, sync } = buildService([ + change('a.md', 'local-only'), + change('b.md', 'remote-only'), + change('synced.md', 'synced'), + change('conflict.md', 'conflict'), + ]); + + await service.runOnce(); + + const intents = sync.mock.calls[0]?.[0] as SyncIntentRequest[]; + const paths = intents.map(intent => intent.changeId); + expect(paths).toEqual([toChangeId('a.md'), toChangeId('b.md')]); + }); + + it('routes each change through the default action policy and executes in background mode', async () => { + const { service, sync } = buildService([ + change('push.md', 'local-modified'), + change('pull.md', 'remote-modified'), + change('delete.md', 'local-deleted'), + change('move.md', 'moved'), + ]); + + await service.runOnce(); + + const [intents, mode] = sync.mock.calls[0] as [SyncIntentRequest[], SyncExecutionMode]; + expect(mode).toBe('background'); + expect(intents).toEqual([ + { changeId: toChangeId('push.md'), action: 'push' }, + { changeId: toChangeId('pull.md'), action: 'pull' }, + { changeId: toChangeId('delete.md'), action: 'delete-remote' }, + { changeId: toChangeId('move.md'), action: 'push' }, + ]); + }); + + it('performs no mutation when there are no pending changes', async () => { + const { service, sync, refresh } = buildService([ + change('synced.md', 'synced'), + change('conflict.md', 'conflict'), + ]); + + await service.runOnce(); + + expect(sync).not.toHaveBeenCalled(); + // Still refreshes before/after so the panel reflects current state. + expect(refresh).toHaveBeenCalledTimes(2); + }); + + it('does not execute concurrently on overlapping runOnce calls; the second tick is skipped', async () => { + let releaseFirst: (() => void) | undefined; + const firstRun = new Promise(resolve => { releaseFirst = resolve; }); + const { service, sync } = buildService([change('a.md', 'local-only')], { + sync: () => firstRun, + }); + + const first = service.runOnce(); + const second = service.runOnce(); + + // The overlapping call returns immediately without a second sync. + await second; + expect(sync).toHaveBeenCalledTimes(1); + + releaseFirst?.(); + await first; + expect(sync).toHaveBeenCalledTimes(1); + }); + + it('is not left permanently locked after an execution error, and reports it', async () => { + const onError = vi.fn(); + const sync = vi.fn() + .mockRejectedValueOnce(new Error('provider down')) + .mockResolvedValueOnce(undefined); + const { service } = buildService([change('a.md', 'local-only')], { sync, onError }); + + await service.runOnce(); + expect(onError).toHaveBeenCalledTimes(1); + + // A later tick still runs and does not swallow the fresh call. + await service.runOnce(); + expect(sync).toHaveBeenCalledTimes(2); + }); +}); diff --git a/tests/logic/source-control/SourceControlActionService.test.ts b/tests/logic/source-control/SourceControlActionService.test.ts index 60b8ac92..219cbc8f 100644 --- a/tests/logic/source-control/SourceControlActionService.test.ts +++ b/tests/logic/source-control/SourceControlActionService.test.ts @@ -374,6 +374,138 @@ describe('SourceControlActionService', () => { expect(operations.get(toChangeId('c-1'))).toBe('idle'); }); + describe('background execution mode', () => { + it('does not show the final confirmation modal and auto-accepts the merged plan', async () => { + const confirmPlan = vi.fn().mockResolvedValue(true); + const commitResolvedBatch = vi.fn().mockResolvedValue(undefined); + const notify = vi.fn(); + const planPush = vi.fn().mockResolvedValue(emptyPlannedBatch({ + reviewPlan: emptySyncPlan({ modifications: [{ path: 'a.md', name: 'a.md' }] }), + pushes: [{ path: 'a.md', name: 'a.md', repoPath: 'a.md', content: 'updated', existingSha: 'sha-a' }], + })); + const { service, operations } = buildService( + [{ id: toChangeId('c-1'), path: 'a.md', kind: 'local-modified' }], + fakeWorkspace({ planPush, commitResolvedBatch, confirmPlan }), + { notify }, + ); + + await service.sync(intents(toChangeId('c-1')), 'background'); + + expect(confirmPlan).not.toHaveBeenCalled(); + expect(commitResolvedBatch).toHaveBeenCalledTimes(1); + expect(operations.get(toChangeId('c-1'))).toBe('success'); + // Background runs stay silent on success. + expect(notify).not.toHaveBeenCalled(); + }); + + it('requests skip conflict behavior from the planner, not the interactive prompt', async () => { + const planPush = vi.fn().mockResolvedValue(emptyPlannedBatch()); + const { service } = buildService( + [{ id: toChangeId('c-1'), path: 'a.md', kind: 'local-modified' }], + fakeWorkspace({ planPush }), + ); + + await service.sync(intents(toChangeId('c-1')), 'background'); + + expect(planPush).toHaveBeenCalledWith(['a.md'], 'skip'); + }); + + it('never marks a path that became a conflict as operation success, even when it was in the target set', async () => { + const commitResolvedBatch = vi.fn().mockResolvedValue(undefined); + const notify = vi.fn(); + // `safe.md` commits fine; `clash.md` was requested but the + // background planner skipped it as a conflict and left it out + // of `pushes`. + const planPush = vi.fn().mockResolvedValue(emptyPlannedBatch({ + reviewPlan: emptySyncPlan({ + modifications: [{ path: 'safe.md', name: 'safe.md' }], + skippedConflicts: [{ path: 'clash.md', name: 'clash.md' }], + }), + pushes: [{ path: 'safe.md', name: 'safe.md', repoPath: 'safe.md', content: 'x', existingSha: 'sha-safe' }], + conflictedPaths: ['clash.md'], + })); + const { service, operations } = buildService( + [ + { id: toChangeId('safe'), path: 'safe.md', kind: 'local-modified' }, + { id: toChangeId('clash'), path: 'clash.md', kind: 'conflict' }, + ], + fakeWorkspace({ planPush, commitResolvedBatch }), + { notify }, + ); + + await service.sync(intents(toChangeId('safe'), toChangeId('clash')), 'background'); + + expect(operations.get(toChangeId('safe'))).toBe('success'); + // The skipped conflict must not be reported as a successful operation. + expect(operations.get(toChangeId('clash'))).not.toBe('success'); + expect(operations.get(toChangeId('clash'))).toBe('idle'); + }); + + it('keeps syncing unrelated safe files when another push path conflicts', async () => { + const commitResolvedBatch = vi.fn().mockResolvedValue(undefined); + const planPush = vi.fn().mockResolvedValue(emptyPlannedBatch({ + reviewPlan: emptySyncPlan({ modifications: [{ path: 'safe.md', name: 'safe.md' }] }), + pushes: [{ path: 'safe.md', name: 'safe.md', repoPath: 'safe.md', content: 'x' }], + conflictedPaths: ['clash.md'], + })); + const { service, operations } = buildService( + [ + { id: toChangeId('safe'), path: 'safe.md', kind: 'local-modified' }, + { id: toChangeId('clash'), path: 'clash.md', kind: 'conflict' }, + ], + fakeWorkspace({ planPush, commitResolvedBatch }), + ); + + await service.sync(intents(toChangeId('safe'), toChangeId('clash')), 'background'); + + expect(commitResolvedBatch).toHaveBeenCalledTimes(1); + expect(operations.get(toChangeId('safe'))).toBe('success'); + }); + }); + + it('serializes a manual push behind an in-flight background run instead of discarding or racing it', async () => { + // Background sync holds the execution guard while its provider + // commit is pending; the manual push must wait for it. + let releaseBackground: (() => void) | undefined; + const backgroundCommit = new Promise(resolve => { releaseBackground = resolve; }); + const order: string[] = []; + + const planPush = vi.fn().mockResolvedValue(emptyPlannedBatch({ + reviewPlan: emptySyncPlan({ modifications: [{ path: 'bg.md', name: 'bg.md' }] }), + pushes: [{ path: 'bg.md', name: 'bg.md', repoPath: 'bg.md', content: 'x' }], + })); + const commitResolvedBatch = vi.fn().mockImplementation(async () => { + order.push('background-commit-start'); + await backgroundCommit; + order.push('background-commit-end'); + }); + const push = vi.fn().mockImplementation(async () => { + order.push('manual-push'); + return emptyPushResults({ syncedPaths: [{ path: 'manual.md' }] }); + }); + + const { service } = buildService( + [ + { id: toChangeId('bg'), path: 'bg.md', kind: 'local-modified' }, + { id: toChangeId('manual'), path: 'manual.md', kind: 'local-only' }, + ], + fakeWorkspace({ planPush, commitResolvedBatch, push }), + ); + + const background = service.sync(intents(toChangeId('bg')), 'background'); + const manual = service.push([toChangeId('manual')]); + + // Let the background planning/commit reach its pending provider + // call; the manual push must not have started yet. + await vi.waitFor(() => expect(order).toEqual(['background-commit-start'])); + + releaseBackground?.(); + await background; + await manual; + + expect(order).toEqual(['background-commit-start', 'background-commit-end', 'manual-push']); + }); + it('counts keep-remote conflict resolutions as acceptedRemote in the single sync notification (full success)', async () => { const notify = vi.fn(); const commitResolvedBatch = vi.fn(async (_pushes, _moves, _deletions, _keepRemote, _keepLocal, results: PushResults) => { diff --git a/tests/logic/source-control/SyncExecutionGuard.test.ts b/tests/logic/source-control/SyncExecutionGuard.test.ts new file mode 100644 index 00000000..fde7f176 --- /dev/null +++ b/tests/logic/source-control/SyncExecutionGuard.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; +import { SyncExecutionGuard } from '../../../src/logic/source-control/SyncExecutionGuard'; + +describe('SyncExecutionGuard', () => { + it('reports locked while held and unlocks on release', async () => { + const guard = new SyncExecutionGuard(); + expect(guard.isLocked).toBe(false); + + const release = await guard.acquire(); + expect(guard.isLocked).toBe(true); + + release(); + expect(guard.isLocked).toBe(false); + }); + + it('tryAcquire fails (returns null) while held, so automatic work can skip', async () => { + const guard = new SyncExecutionGuard(); + const release = await guard.acquire(); + + expect(guard.tryAcquire()).toBeNull(); + + release(); + expect(guard.tryAcquire()).not.toBeNull(); + }); + + it('queues a waiting acquire so manual work is serialized rather than discarded', async () => { + const guard = new SyncExecutionGuard(); + const release = await guard.acquire(); + + const order: string[] = []; + const waiting = guard.acquire().then(release2 => { + order.push('acquired'); + release2(); + }); + order.push('waiting'); + + release(); + await waiting; + + expect(order).toEqual(['waiting', 'acquired']); + }); + + it('ignores a second release call from the same holder', async () => { + const guard = new SyncExecutionGuard(); + const release = await guard.acquire(); + release(); + release(); + expect(guard.isLocked).toBe(false); + }); +}); diff --git a/tests/logic/sync-manager-mapping.test.ts b/tests/logic/sync-manager-mapping.test.ts index 4277d8f5..ae7d8abe 100644 --- a/tests/logic/sync-manager-mapping.test.ts +++ b/tests/logic/sync-manager-mapping.test.ts @@ -65,6 +65,9 @@ const mockSettings: GitLabFilesPushSettings = { bannerDismissedVersion: '', language: 'system', autoRefreshOnStartup: true, + automaticSyncEnabled: false, + automaticSyncIntervalMinutes: 5, + automaticSyncOnStartup: false, }; describe('SyncManager Mapping', () => { diff --git a/tests/logic/sync-manager.test.ts b/tests/logic/sync-manager.test.ts index 7f3fc31e..45405332 100644 --- a/tests/logic/sync-manager.test.ts +++ b/tests/logic/sync-manager.test.ts @@ -77,7 +77,10 @@ const mockSettings: GitLabFilesPushSettings = { lastSeenVersion: '', bannerDismissedVersion: '', language: 'system', - autoRefreshOnStartup: true + autoRefreshOnStartup: true, + automaticSyncEnabled: false, + automaticSyncIntervalMinutes: 5, + automaticSyncOnStartup: false, }; describe('SyncManager', () => { diff --git a/tests/logic/sync/PushCoordinator.test.ts b/tests/logic/sync/PushCoordinator.test.ts index c0a7bba4..6c737593 100644 --- a/tests/logic/sync/PushCoordinator.test.ts +++ b/tests/logic/sync/PushCoordinator.test.ts @@ -31,12 +31,17 @@ function settings(): GitLabFilesPushSettings { bannerDismissedVersion: '', language: 'system', autoRefreshOnStartup: true, + automaticSyncEnabled: false, + automaticSyncIntervalMinutes: 5, + automaticSyncOnStartup: false, }; } function createHarness(overrides: { confirmPlan?: boolean; pathExists?: (path: string) => Promise; + resolveConflicts?: (conflicts: unknown[], safeCount: number) => Promise; + settings?: GitLabFilesPushSettings; } = {}) { const listFilesDetailed = vi.fn().mockResolvedValue([]); const provider = { @@ -62,7 +67,8 @@ function createHarness(overrides: { }); const saveSettings = vi.fn().mockResolvedValue(undefined); const confirmPlan = vi.fn().mockResolvedValue(overrides.confirmPlan ?? true); - const syncSettings = settings(); + const resolveConflicts = vi.fn(overrides.resolveConflicts ?? (() => Promise.resolve(true))); + const syncSettings = overrides.settings ?? settings(); const coordinator = new PushCoordinator({ app: { vault: { getFileByPath: vi.fn().mockReturnValue({}) } } as unknown as App, gitService: () => provider, @@ -72,14 +78,28 @@ function createHarness(overrides: { conflicts: { findStale: vi.fn().mockResolvedValue([]), applyRemote: vi.fn() } as unknown as ConflictResolver, isPathIgnored: () => false, confirmPlan, - resolveConflicts: vi.fn().mockResolvedValue(true), + resolveConflicts, updateMetadata: vi.fn().mockResolvedValue(undefined), migrateBaseline: vi.fn().mockResolvedValue(undefined), saveSettings, notify: vi.fn(), serviceName: () => 'GitHub', }); - return { coordinator, listFilesDetailed, commitBatch, confirmPlan, saveSettings, settings: syncSettings }; + return { coordinator, listFilesDetailed, commitBatch, confirmPlan, resolveConflicts, saveSettings, settings: syncSettings }; +} + +/** + * One conflicted candidate (`clash.md`, tracked baseline + divergent remote) + * alongside one safe candidate (`safe.md`, no tracked baseline). + */ +function conflictingSettings(): GitLabFilesPushSettings { + const base = settings(); + base.syncMetadata['clash.md'] = { + lastSyncedSha: 'base-clash', + lastSyncedAt: 0, + lastKnownPath: 'clash.md', + }; + return base; } describe('PushCoordinator', () => { @@ -124,6 +144,49 @@ describe('PushCoordinator', () => { expect(harness.commitBatch).not.toHaveBeenCalled(); }); + describe('planSyncBatch conflict behavior', () => { + function conflictedHarness(resolveConflicts?: (conflicts: unknown[], safeCount: number) => Promise) { + const harness = createHarness({ settings: conflictingSettings(), resolveConflicts }); + harness.listFilesDetailed.mockResolvedValue([ + { path: 'clash.md', symlink: false, sha: 'remote-clash' }, + ]); + return harness; + } + + it('prompts for conflict resolution in interactive mode and cancels the batch when declined', async () => { + const harness = conflictedHarness(() => Promise.resolve(false)); + + const plan = await harness.coordinator.planSyncBatch( + ['safe.md', 'clash.md'], + undefined, + undefined, + 'prompt', + ); + + expect(harness.resolveConflicts).toHaveBeenCalledTimes(1); + expect(plan.cancelled).toBe(true); + }); + + it('skips conflicting paths and continues with safe paths in background mode, invoking no conflict UI', async () => { + const resolveConflicts = vi.fn().mockResolvedValue(true); + const harness = conflictedHarness(resolveConflicts); + + const plan = await harness.coordinator.planSyncBatch( + ['safe.md', 'clash.md'], + undefined, + undefined, + 'skip', + ); + + expect(resolveConflicts).not.toHaveBeenCalled(); + expect(plan.cancelled).toBe(false); + expect(plan.conflictedPaths).toContain('clash.md'); + expect(plan.skippedConflicts).toBeGreaterThan(0); + expect(plan.pushes.map(entry => entry.path)).not.toContain('clash.md'); + expect(plan.pushes.map(entry => entry.path)).toContain('safe.md'); + }); + }); + it('plans an edited tracked rename as a move when the destination is free', async () => { const harness = createHarness(); harness.settings.syncMetadata['notes/new.md'] = { diff --git a/tests/main.test.ts b/tests/main.test.ts index aa7e14ab..35d083ef 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -37,6 +37,71 @@ describe('GitLabFilesPush.trackFolderRename', () => { expect(handleFileRenamed).toHaveBeenCalledTimes(2); }); + describe('startup behavior', () => { + function callLayoutReady(fakePlugin: Record): void { + (GitLabFilesPush.prototype as unknown as { + handleLayoutReady(this: unknown): void; + }).handleLayoutReady.call(fakePlugin); + } + + it('runs background automatic sync and does NOT open Source Control when enabled + on startup', () => { + const runOnce = vi.fn().mockResolvedValue(undefined); + const refresh = vi.fn().mockResolvedValue(undefined); + const activateSourceControlView = vi.fn().mockResolvedValue(undefined); + const fakePlugin = { + settings: { automaticSyncEnabled: true, automaticSyncOnStartup: true, autoRefreshOnStartup: true }, + automaticSync: { runOnce }, + sourceControlViewModel: { refresh }, + activateSourceControlView, + normalizeSourceControlLeaves: vi.fn(), + }; + + callLayoutReady(fakePlugin); + + expect(runOnce).toHaveBeenCalledTimes(1); + // No redundant legacy startup refresh / view reveal. + expect(activateSourceControlView).not.toHaveBeenCalled(); + expect(refresh).not.toHaveBeenCalled(); + }); + + it('falls back to the existing refresh-on-startup behavior when automatic sync is off', () => { + const runOnce = vi.fn().mockResolvedValue(undefined); + const fakePlugin = { + settings: { automaticSyncEnabled: false, automaticSyncOnStartup: true, autoRefreshOnStartup: true }, + automaticSync: { runOnce }, + // refreshSyncStatusOnStartup is a prototype method; stub the two + // calls it makes on this object. + sourceControlViewModel: { refresh: vi.fn().mockResolvedValue(undefined) }, + activateSourceControlView: vi.fn().mockResolvedValue(undefined), + normalizeSourceControlLeaves: vi.fn(), + }; + (fakePlugin as Record).refreshSyncStatusOnStartup = + (GitLabFilesPush.prototype as unknown as Record).refreshSyncStatusOnStartup; + + callLayoutReady(fakePlugin); + + expect(runOnce).not.toHaveBeenCalled(); + expect(fakePlugin.activateSourceControlView).toHaveBeenCalledTimes(1); + }); + + it('does nothing at startup when both automatic sync and refresh-on-startup are off', () => { + const runOnce = vi.fn(); + const activateSourceControlView = vi.fn(); + const fakePlugin = { + settings: { automaticSyncEnabled: false, automaticSyncOnStartup: false, autoRefreshOnStartup: false }, + automaticSync: { runOnce }, + sourceControlViewModel: { refresh: vi.fn() }, + activateSourceControlView, + normalizeSourceControlLeaves: vi.fn(), + }; + + callLayoutReady(fakePlugin); + + expect(runOnce).not.toHaveBeenCalled(); + expect(activateSourceControlView).not.toHaveBeenCalled(); + }); + }); + it('does nothing when no files live under the moved folder', async () => { const trackRename = vi.fn().mockResolvedValue(undefined); const fakePlugin = { diff --git a/tests/runtime/AutomaticSyncScheduler.test.ts b/tests/runtime/AutomaticSyncScheduler.test.ts new file mode 100644 index 00000000..46911827 --- /dev/null +++ b/tests/runtime/AutomaticSyncScheduler.test.ts @@ -0,0 +1,129 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { AutomaticSyncScheduler } from '../../src/runtime/AutomaticSyncScheduler'; + +interface Settings { + automaticSyncEnabled: boolean; + automaticSyncIntervalMinutes: number; +} + +function buildScheduler(initial: Settings, run = vi.fn().mockResolvedValue(undefined)) { + let settings: Settings = { ...initial }; + const registerInterval = vi.fn((id: number) => id); + const scheduler = new AutomaticSyncScheduler({ + getSettings: () => settings, + run, + registerInterval, + }); + return { + scheduler, + run, + registerInterval, + setSettings(next: Partial) { settings = { ...settings, ...next }; }, + }; +} + +describe('AutomaticSyncScheduler', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('does not schedule anything while automatic sync is disabled', () => { + vi.useFakeTimers(); + const harness = buildScheduler({ automaticSyncEnabled: false, automaticSyncIntervalMinutes: 5 }); + + harness.scheduler.apply(); + + expect(harness.scheduler.isScheduled).toBe(false); + vi.advanceTimersByTime(60 * 60_000); + expect(harness.run).not.toHaveBeenCalled(); + }); + + it('runs on the configured interval when enabled', async () => { + vi.useFakeTimers(); + const harness = buildScheduler({ automaticSyncEnabled: true, automaticSyncIntervalMinutes: 5 }); + + harness.scheduler.apply(); + expect(harness.scheduler.isScheduled).toBe(true); + + await vi.advanceTimersByTimeAsync(5 * 60_000); + expect(harness.run).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(5 * 60_000); + expect(harness.run).toHaveBeenCalledTimes(2); + }); + + it('clears the old interval and installs the new one when the interval changes', async () => { + vi.useFakeTimers(); + const harness = buildScheduler({ automaticSyncEnabled: true, automaticSyncIntervalMinutes: 5 }); + harness.scheduler.apply(); + + harness.setSettings({ automaticSyncIntervalMinutes: 10 }); + harness.scheduler.apply(); + + // The old 5-minute tick no longer fires. + await vi.advanceTimersByTimeAsync(5 * 60_000); + expect(harness.run).not.toHaveBeenCalled(); + + // The new 10-minute schedule is active. + await vi.advanceTimersByTimeAsync(5 * 60_000); + expect(harness.run).toHaveBeenCalledTimes(1); + }); + + it('stops scheduled execution when disabled again', async () => { + vi.useFakeTimers(); + const harness = buildScheduler({ automaticSyncEnabled: true, automaticSyncIntervalMinutes: 5 }); + harness.scheduler.apply(); + + harness.setSettings({ automaticSyncEnabled: false }); + harness.scheduler.apply(); + + expect(harness.scheduler.isScheduled).toBe(false); + await vi.advanceTimersByTimeAsync(30 * 60_000); + expect(harness.run).not.toHaveBeenCalled(); + }); + + it('does not postpone the existing timer when unrelated settings are re-applied unchanged', async () => { + vi.useFakeTimers(); + const harness = buildScheduler({ automaticSyncEnabled: true, automaticSyncIntervalMinutes: 5 }); + harness.scheduler.apply(); + + await vi.advanceTimersByTimeAsync(4 * 60_000); + harness.scheduler.apply(); + await vi.advanceTimersByTimeAsync(1 * 60_000); + + expect(harness.run).toHaveBeenCalledTimes(1); + }); + + it('never creates a zero/rapid timer from an invalid interval, falling back to the default', async () => { + vi.useFakeTimers(); + const harness = buildScheduler({ automaticSyncEnabled: true, automaticSyncIntervalMinutes: 0 }); + harness.scheduler.apply(); + + // 0 falls back to the 5-minute default. + await vi.advanceTimersByTimeAsync(1_000); + expect(harness.run).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(5 * 60_000 - 1_000); + expect(harness.run).toHaveBeenCalledTimes(1); + }); + + it('dispose stops scheduled execution and clears registration state', async () => { + vi.useFakeTimers(); + const harness = buildScheduler({ automaticSyncEnabled: true, automaticSyncIntervalMinutes: 1 }); + harness.scheduler.apply(); + + harness.scheduler.dispose(); + + expect(harness.scheduler.isScheduled).toBe(false); + await vi.advanceTimersByTimeAsync(10 * 60_000); + expect(harness.run).not.toHaveBeenCalled(); + }); + + it('registers the interval with the plugin lifecycle so unload clears it', () => { + vi.useFakeTimers(); + const harness = buildScheduler({ automaticSyncEnabled: true, automaticSyncIntervalMinutes: 5 }); + + harness.scheduler.apply(); + + expect(harness.registerInterval).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/runtime/createSyncRuntime.test.ts b/tests/runtime/createSyncRuntime.test.ts index f6eb8e3b..6ee05e90 100644 --- a/tests/runtime/createSyncRuntime.test.ts +++ b/tests/runtime/createSyncRuntime.test.ts @@ -60,6 +60,28 @@ describe('createSyncRuntime', () => { expect(runtime.refreshState).toBeDefined(); expect(runtime.sourceControlViewModel).toBeDefined(); expect(runtime.sourceControlActions).toBeDefined(); + expect(runtime.automaticSync).toBeDefined(); + }); + + it('wires AutomaticSyncService to refresh, the shared ChangeRepository, and the background sync path', async () => { + const runtime = createSyncRuntime(buildDeps()); + // Stub the workspace refresh (the real service needs a full vault mock) + // and the sync execution, isolating the wiring under test. + const refresh = vi.spyOn(runtime.syncWorkspace, 'refresh').mockResolvedValue({ + statuses: new Map(), + remoteEntries: [], + } as never); + const sync = vi.spyOn(runtime.sourceControlActions, 'sync').mockResolvedValue(undefined); + + runtime.sync.status.set({ path: 'note.md', status: 'unsynced' }); + await runtime.automaticSync.runOnce(); + + expect(refresh).toHaveBeenCalledTimes(2); + expect(sync).toHaveBeenCalledTimes(1); + expect(sync).toHaveBeenCalledWith( + [expect.objectContaining({ changeId: 'note.md' })], + 'background', + ); }); it('keeps ChangeRepository in sync with the shared SyncStatusService until disposed', () => { diff --git a/tests/settings.test.ts b/tests/settings.test.ts index 3fcce733..3e2b2995 100644 --- a/tests/settings.test.ts +++ b/tests/settings.test.ts @@ -34,9 +34,54 @@ describe('settings module split', () => { bannerDismissedVersion: '', language: 'system', autoRefreshOnStartup: true, + automaticSyncEnabled: false, + automaticSyncIntervalMinutes: 5, + automaticSyncOnStartup: false, }); }); + it('defaults automatic sync to OFF, 5 minutes, and startup sync OFF', () => { + expect(settingsModel.DEFAULT_SETTINGS.automaticSyncEnabled).toBe(false); + expect(settingsModel.DEFAULT_SETTINGS.automaticSyncIntervalMinutes).toBe(5); + expect(settingsModel.DEFAULT_SETTINGS.automaticSyncOnStartup).toBe(false); + }); + + it('merges older stored settings with the new automatic-sync defaults', () => { + // A settings object persisted before automatic sync existed. + const stored = { gitlabToken: 'abc', branch: 'develop' } as Partial; + const merged = { ...settingsModel.DEFAULT_SETTINGS, ...stored }; + expect(merged.automaticSyncEnabled).toBe(false); + expect(merged.automaticSyncIntervalMinutes).toBe(5); + expect(merged.automaticSyncOnStartup).toBe(false); + expect(merged.branch).toBe('develop'); + }); +}); + +describe('normalizeAutomaticSyncIntervalMinutes', () => { + const fallback = settingsModel.DEFAULT_SETTINGS.automaticSyncIntervalMinutes; + + it('accepts a finite interval at or above the minimum', () => { + expect(settingsHelpers.normalizeAutomaticSyncIntervalMinutes(1, fallback)).toBe(1); + expect(settingsHelpers.normalizeAutomaticSyncIntervalMinutes(45, fallback)).toBe(45); + }); + + it('floors fractional minute values', () => { + expect(settingsHelpers.normalizeAutomaticSyncIntervalMinutes(2.9, fallback)).toBe(2); + }); + + it('rejects zero, negative, NaN, Infinity, and non-numeric input so no rapid timer can be created', () => { + expect(settingsHelpers.normalizeAutomaticSyncIntervalMinutes(0, fallback)).toBe(fallback); + expect(settingsHelpers.normalizeAutomaticSyncIntervalMinutes(-5, fallback)).toBe(fallback); + expect(settingsHelpers.normalizeAutomaticSyncIntervalMinutes(Number.NaN, fallback)).toBe(fallback); + expect(settingsHelpers.normalizeAutomaticSyncIntervalMinutes(Number.POSITIVE_INFINITY, fallback)).toBe(fallback); + expect(settingsHelpers.normalizeAutomaticSyncIntervalMinutes('nonsense', fallback)).toBe(fallback); + expect(settingsHelpers.normalizeAutomaticSyncIntervalMinutes(undefined, fallback)).toBe(fallback); + }); + + it('parses a numeric string from older storage', () => { + expect(settingsHelpers.normalizeAutomaticSyncIntervalMinutes('10', fallback)).toBe(10); + }); + it('getServiceName still maps every GitServiceType to its display name', () => { expect(settingsHelpers.getServiceName({ ...settingsModel.DEFAULT_SETTINGS, serviceType: 'gitlab' })).toBe('GitLab'); expect(settingsHelpers.getServiceName({ ...settingsModel.DEFAULT_SETTINGS, serviceType: 'github' })).toBe('GitHub'); diff --git a/tests/setup.ts b/tests/setup.ts index 63c8eb34..b1410678 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -19,11 +19,11 @@ if (typeof document === 'undefined') { } if (typeof window === 'undefined') { (globalThis as unknown as { window: unknown }).window = { - setInterval: vi.fn(), - clearInterval: vi.fn(), // Delegate to the global timers (not bound methods captured once) so // vi.useFakeTimers()/vi.useRealTimers() — which patch globalThis — keep - // controlling window.setTimeout/clearTimeout too. + // controlling window.setTimeout/clearTimeout/setInterval too. + setInterval: (handler: (...args: unknown[]) => void, timeout?: number) => globalThis.setInterval(handler, timeout), + clearInterval: (handle?: ReturnType) => globalThis.clearInterval(handle), setTimeout: (handler: (...args: unknown[]) => void, timeout?: number) => globalThis.setTimeout(handler, timeout), clearTimeout: (handle?: ReturnType) => globalThis.clearTimeout(handle), }; @@ -66,6 +66,8 @@ class BaseTextComponent { onChange(handler: (value: string) => void) { this.changeHandler = handler; + this.inputEl.addEventListener('input', () => handler(this.inputEl.value)); + this.inputEl.addEventListener('change', () => handler(this.inputEl.value)); return this; } @@ -78,6 +80,7 @@ class BaseTextComponent { export const TextComponent = class extends BaseTextComponent { constructor(containerEl?: HTMLElement) { const inputEl = document.createElement('input'); + inputEl.type = 'text'; containerEl?.appendChild(inputEl); super(inputEl); } @@ -157,31 +160,103 @@ export const ButtonComponent = class { export const ExtraButtonComponent = class extends ButtonComponent {}; +export const ToggleComponent = class { + toggleEl: HTMLInputElement; + private changeHandler?: (value: boolean) => void; + + constructor(containerEl?: HTMLElement) { + const inputEl = document.createElement('input'); + inputEl.type = 'checkbox'; + containerEl?.appendChild(inputEl); + this.toggleEl = inputEl; + } + + setValue(value: boolean) { + this.toggleEl.checked = value; + return this; + } + + setDisabled(disabled: boolean) { + this.toggleEl.disabled = disabled; + return this; + } + + onChange(handler: (value: boolean) => void) { + this.changeHandler = handler; + this.toggleEl.addEventListener('change', () => handler(this.toggleEl.checked)); + return this; + } + + triggerChange(value: boolean) { + this.toggleEl.checked = value; + this.changeHandler?.(value); + } + + getValue() { + return this.toggleEl.checked; + } +}; + export const Setting = class { containerEl?: HTMLElement; + settingEl?: HTMLElement; + private disabled = false; constructor(containerEl?: HTMLElement) { this.containerEl = containerEl; + // Build a faithful `.setting-item` row (name/desc/control) so tests can + // find a control by its row label, like the real Obsidian DOM. + if (containerEl) { + const row = document.createElement('div'); + row.className = 'setting-item'; + this.settingEl = row; + containerEl.appendChild(row); + } + } + + private controlContainer(): HTMLElement | undefined { + return this.settingEl ? this.settingEl.appendChild(document.createElement('div')) : this.containerEl; + } + + setName(name: string) { + if (!this.settingEl) return this; + const el = document.createElement('div'); + el.className = 'setting-item-name'; + el.textContent = name; + this.settingEl.appendChild(el); + return this; + } + + setDesc(desc: string) { + if (!this.settingEl) return this; + const el = document.createElement('div'); + el.className = 'setting-item-description'; + el.textContent = desc; + this.settingEl.appendChild(el); + return this; } - setName() { return this; } - setDesc() { return this; } setHeading() { return this; } - addToggle() { return this; } + setDisabled(disabled: boolean) { this.disabled = disabled; return this; } + isDisabled() { return this.disabled; } + addToggle(callback?: (component: InstanceType) => void) { + if (callback) callback(new ToggleComponent(this.controlContainer())); + return this; + } addText(callback?: (component: InstanceType) => void) { - if (callback) callback(new TextComponent(this.containerEl)); + if (callback) callback(new TextComponent(this.controlContainer())); return this; } addTextArea(callback?: (component: InstanceType) => void) { - if (callback) callback(new TextAreaComponent(this.containerEl)); + if (callback) callback(new TextAreaComponent(this.controlContainer())); return this; } addButton(callback?: (component: InstanceType) => void) { - if (callback) callback(new ButtonComponent(this.containerEl)); + if (callback) callback(new ButtonComponent(this.controlContainer())); return this; } addExtraButton(callback?: (component: InstanceType) => void) { - if (callback) callback(new ExtraButtonComponent(this.containerEl)); + if (callback) callback(new ExtraButtonComponent(this.controlContainer())); return this; } addDropdown(callback?: (component: InstanceType) => void) { @@ -399,6 +474,7 @@ vi.mock('obsidian', () => ({ DropdownComponent, ButtonComponent, ExtraButtonComponent, + ToggleComponent, requestUrl, setTooltip, setIcon, diff --git a/tests/ui/SettingsAutomaticSync.test.ts b/tests/ui/SettingsAutomaticSync.test.ts new file mode 100644 index 00000000..9c29b534 --- /dev/null +++ b/tests/ui/SettingsAutomaticSync.test.ts @@ -0,0 +1,148 @@ +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import { App } from 'obsidian'; +import { DEFAULT_SETTINGS, GitLabSyncSettingTab } from '../../src/settings'; +import GitLabFilesPush, { type ConnectionStatus } from '../../src/main'; +import type { ConnectionTestResult } from '../../src/services/git-service-interface'; +import { createContainer, setupObsidianDOM } from './setup-dom'; + +vi.mock('../../src/main', () => ({ + default: class {}, +})); + +beforeAll(() => { setupObsidianDOM(); }); + +interface PluginStub { + plugin: GitLabFilesPush; + saveSettings: ReturnType; +} + +function createPluginStub(): PluginStub { + const saveSettings = vi.fn().mockResolvedValue(undefined); + const plugin = { + settings: { ...DEFAULT_SETTINGS }, + manifest: { version: '0.0.0-test' }, + saveSettings, + initializeGitService: vi.fn(), + testConnection: vi.fn().mockResolvedValue({ repoOk: true, branchOk: true } satisfies ConnectionTestResult), + onConnectionStatusChange: vi.fn((listener: (status: ConnectionStatus) => void) => { + listener({ state: 'checking' }); + return () => undefined; + }), + } as unknown as GitLabFilesPush; + return { plugin, saveSettings }; +} + +function renderTab(overrides: Partial = {}): { + tab: GitLabSyncSettingTab; + plugin: GitLabFilesPush; + saveSettings: ReturnType; +} { + vi.useFakeTimers(); + const { plugin, saveSettings } = createPluginStub(); + plugin.settings = { ...plugin.settings, ...overrides }; + const tab = new GitLabSyncSettingTab(new App(), plugin, plugin); + tab.containerEl = createContainer(); + tab.display(); + return { tab, plugin, saveSettings }; +} + +/** Finds the toggle input whose surrounding Setting row has the given name. */ +function toggleByName(tab: GitLabSyncSettingTab, name: string): HTMLInputElement | null { + const inputs = Array.from(tab.containerEl.querySelectorAll('input[type="checkbox"]')); + for (const input of inputs) { + const row = input.closest('.setting-item'); + if (row?.textContent?.includes(name)) return input; + } + return null; +} + +/** The Automatic sync interval text input (placeholder mirrors the 5-minute default). */ +function intervalInputByName(tab: GitLabSyncSettingTab): HTMLInputElement | undefined { + return Array.from(tab.containerEl.querySelectorAll('input[type="text"]')) + .find(input => input.placeholder === '5'); +} + +/** Flips a checkbox the way a user click would (JSDOM click() misses change events). */ +function flipToggle(input: HTMLInputElement): void { + input.checked = !input.checked; + input.dispatchEvent(new Event('change')); +} + +describe('GitLabSyncSettingTab automatic sync', () => { + afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); + }); + + it('defaults automatic sync OFF, interval 5 minutes, and startup sync OFF', () => { + const { plugin } = renderTab(); + expect(plugin.settings.automaticSyncEnabled).toBe(false); + expect(plugin.settings.automaticSyncIntervalMinutes).toBe(5); + expect(plugin.settings.automaticSyncOnStartup).toBe(false); + }); + + it('renders the Automatic sync, Sync interval, and Sync on startup rows plus the separate refresh row', () => { + const { tab } = renderTab(); + + const text = tab.containerEl.textContent ?? ''; + // These strings come from en.ts (default locale in tests). + expect(text).toContain('Automatic sync'); + expect(text).toContain('Sync interval (minutes)'); + expect(text).toContain('Sync on startup'); + // The existing refresh setting stays a distinct, separately-labeled control. + expect(text).toContain('Refresh status on startup'); + }); + + it('persists an enabled automatic sync toggle through saveSettings', () => { + const { tab, plugin, saveSettings } = renderTab(); + const toggle = toggleByName(tab, 'Automatic sync'); + expect(toggle).not.toBeNull(); + + flipToggle(toggle!); + + expect(plugin.settings.automaticSyncEnabled).toBe(true); + expect(saveSettings).toHaveBeenCalled(); + }); + + it('seeds the interval control with the saved value and sanitizes invalid input to the default', () => { + const { tab, plugin } = renderTab({ automaticSyncEnabled: true, automaticSyncIntervalMinutes: 15 }); + const intervalInput = intervalInputByName(tab); + expect(intervalInput?.value).toBe('15'); + + intervalInput!.value = '0'; + intervalInput!.dispatchEvent(new Event('input')); + expect(plugin.settings.automaticSyncIntervalMinutes).toBe(5); + }); + + it('clamps the interval to the minimum of 1 minute', () => { + const { tab, plugin } = renderTab({ automaticSyncEnabled: true, automaticSyncIntervalMinutes: 5 }); + const intervalInput = intervalInputByName(tab); + + intervalInput!.value = '1'; + intervalInput!.dispatchEvent(new Event('input')); + expect(plugin.settings.automaticSyncIntervalMinutes).toBe(1); + }); + + it('persists the Sync on startup toggle', () => { + const { tab, plugin, saveSettings } = renderTab({ automaticSyncEnabled: true }); + const toggle = toggleByName(tab, 'Sync on startup'); + expect(toggle).not.toBeNull(); + + flipToggle(toggle!); + + expect(plugin.settings.automaticSyncOnStartup).toBe(true); + expect(saveSettings).toHaveBeenCalled(); + }); + + it('does not change the existing refresh status on startup semantics', () => { + const { tab, plugin } = renderTab({ autoRefreshOnStartup: true }); + const toggle = toggleByName(tab, 'Refresh status on startup'); + expect(toggle).not.toBeNull(); + + flipToggle(toggle!); + + expect(plugin.settings.autoRefreshOnStartup).toBe(false); + // The automatic-sync fields remain untouched by this control. + expect(plugin.settings.automaticSyncEnabled).toBe(false); + }); +}); diff --git a/tests/ui/SettingsConnectionStatus.test.ts b/tests/ui/SettingsConnectionStatus.test.ts index 8508b41d..88ca856b 100644 --- a/tests/ui/SettingsConnectionStatus.test.ts +++ b/tests/ui/SettingsConnectionStatus.test.ts @@ -184,4 +184,19 @@ describe('GitLabSyncSettingTab what\'s new banner', () => { const buttons = Array.from(tab.containerEl.querySelectorAll('button')); expect(buttons.some(button => button.textContent === 'View release history')).toBe(true); }); + + it('surfaces the 1.7.0 notable entries in the banner when the manifest version is 1.7.0', () => { + const tab = renderTab('1.7.0'); + const banner = tab.containerEl.querySelector('.gfs-whats-new-banner'); + expect(banner).not.toBeNull(); + expect(banner?.textContent).toContain('1.7.0'); + // At least one user-facing automatic-sync highlight is shown. + const items = Array.from(tab.containerEl.querySelectorAll('.gfs-whats-new-banner-list li')); + expect(items.some(item => item.textContent?.includes('Automatic sync'))).toBe(true); + }); + + it('hides the 1.7.0 banner once dismissed for that exact version', () => { + const tab = renderTab('1.7.0', '1.7.0'); + expect(tab.containerEl.querySelector('.gfs-whats-new-banner')).toBeNull(); + }); });