diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8656c3c..049dbd2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -217,7 +217,7 @@ jobs: E2E_PROVIDER: ${{ matrix.provider }} with: timeout_minutes: 15 - max_attempts: 2 + max_attempts: 3 retry_wait_seconds: 15 command: | set -a @@ -251,26 +251,37 @@ jobs: # "Determine whether this provider leg should run" step above; this gate # only asks "did whatever ran, pass?"). A gated-off leg's steps are all # skipped without failing the job, so it still reports "success" here. - # `if: always()` so a real provider-e2e failure/cancellation is caught - # here and blocks CI/release, instead of GitHub Actions silently treating - # an upstream failure as "this job never needed to run". + # `if: always()` so a real provider-e2e failure is caught here and blocks + # CI/release. A cancelled matrix means a newer run in the same branch/provider + # concurrency group replaced this duplicate run; report that as neutral and + # do not start another copy of downstream CI. e2e-gate: name: E2E gate needs: provider-e2e if: always() runs-on: ubuntu-latest + outputs: + run-ci: ${{ steps.check.outputs.run-ci }} steps: - name: Check provider-e2e result + id: check run: | result="${{ needs.provider-e2e.result }}" echo "provider-e2e result: $result" + echo "run-ci=true" >> "$GITHUB_OUTPUT" + if [ "$result" = "cancelled" ]; then + echo "run-ci=false" >> "$GITHUB_OUTPUT" + echo "::notice::provider-e2e was replaced by a newer run in the same concurrency group." + exit 0 + fi if [ "$result" != "success" ] && [ "$result" != "skipped" ]; then - echo "::error::provider-e2e failed or was cancelled ($result) -- blocking CI/release." + echo "::error::provider-e2e failed ($result) -- blocking CI/release." exit 1 fi CI: needs: e2e-gate + if: needs.e2e-gate.outputs.run-ci == 'true' uses: firstsun-dev/.github/.github/workflows/obsidian-plugin-ci.yml@v1 with: plugin-id: "git-file-sync" diff --git a/docs/testing/real-provider-e2e.md b/docs/testing/real-provider-e2e.md index 95498a4..4e7ff78 100644 --- a/docs/testing/real-provider-e2e.md +++ b/docs/testing/real-provider-e2e.md @@ -122,6 +122,9 @@ sandbox and starved each other (observed as real GitLab API timeouts under that Keying by branch name alone means the later of the two cancels the earlier instead. The two cleanup workflows below share this same group naming for the same branch, with `cancel-in-progress: false`, so cleanup queues behind rather than races an active run. +The cancelled duplicate's `e2e-gate` reports the replacement as neutral and sets `run-ci=false`, +so it neither leaves a misleading aggregate failure nor starts a second copy of downstream CI. +The surviving run remains responsible for the real provider result and release gate. **Cancellation is not a cleanup mechanism.** A cancelled run's `cleanup` step may never execute, or may be mid-delete when the runner is terminated; the next run is still safe because it always allocates a brand-new `run--` branch rather than deleting and reusing the old @@ -263,9 +266,10 @@ changes -> provider-e2e [github | gitlab | gitea, parallel] -> e2e-gate -> CI (s ``` `e2e-gate` runs with `if: always()` and treats `provider-e2e`'s aggregate result as pass-through -on `success` or `skipped` (the latter covers path-filtered-out runs), and a hard failure on -anything else — so a real provider regression blocks the release instead of shipping and being -caught after the fact. +on `success` or `skipped` (the latter covers path-filtered-out runs), a neutral replacement on +`cancelled` (with downstream CI suppressed for that duplicate run), and a hard failure on any +other result. A real provider regression therefore still blocks the release instead of shipping +and being caught after the fact. **Branch protection** (not something this repo checkout can change — a GitHub repo-settings change, left for whoever has admin access): add `E2E / gitea` as a required status check. diff --git a/e2e/suites/sync-manager.e2e.test.ts b/e2e/suites/sync-manager.e2e.test.ts index dd4e4be..4299a09 100644 --- a/e2e/suites/sync-manager.e2e.test.ts +++ b/e2e/suites/sync-manager.e2e.test.ts @@ -2,6 +2,8 @@ import { describe, it, expect, beforeAll, vi } from 'vitest'; import { SyncManager, BatchPushConflict, ConflictResolution } from '../../src/logic/sync-manager'; import { SyncPlanModal, SyncPlanDirection } from '../../src/ui/SyncPlanModal'; import { BatchConflictResolutionModal } from '../../src/ui/BatchConflictResolutionModal'; +import { ObsidianSyncInteraction } from '../../src/ui/ObsidianSyncInteraction'; +import { describePushResult } from '../support/push-result-diagnostic'; // `import type` deliberately, not a value import: src/settings.ts also // exports settings-tab UI (GitLabSyncSettingTab -> FolderSuggest -> // AbstractInputSuggest etc.) which pulls in far more of `obsidian` than this @@ -94,7 +96,8 @@ describe('SyncManager E2E', () => { }, timeouts.containerReadyMs + 30_000); function newManager(vault: FakeVault, settings: GitLabFilesPushSettings): SyncManager { - return new SyncManager(fakeApp(vault), service, settings, undefined, () => false); + const app = fakeApp(vault); + return new SyncManager(app, service, settings, undefined, () => false, undefined, new ObsidianSyncInteraction(app)); } it('pushes a new local file, verified independently of the service', async () => { @@ -106,7 +109,8 @@ describe('SyncManager E2E', () => { const result = await manager.pushFiles([filePath]); - expect(result.success).toBe(1); + expect(result.success, describePushResult(result)).toBe(1); + expect(result.failed, describePushResult(result)).toBe(0); const pushedSha = result.syncedPaths.find(p => p.path === filePath)?.sha; expect(pushedSha).toBeTruthy(); const remote = await verifier.getFile(filePath, branch); @@ -121,7 +125,9 @@ describe('SyncManager E2E', () => { vault.writeLocal(filePath, 'steady state'); const settings = makeSettings(branch); const manager = newManager(vault, settings); - await manager.pushFiles([filePath]); + const initialPush = await manager.pushFiles([filePath]); + expect(initialPush.success, describePushResult(initialPush)).toBe(1); + expect(initialPush.failed, describePushResult(initialPush)).toBe(0); const shasBefore = await verifier.listCommitShas(branch); const result = await manager.pushFiles([filePath]); @@ -164,7 +170,9 @@ describe('SyncManager E2E', () => { vault.writeLocal(filePath, 'baseline'); const settings = makeSettings(branch); const manager = newManager(vault, settings); - await manager.pushFiles([filePath]); + const initialPush = await manager.pushFiles([filePath]); + expect(initialPush.success, describePushResult(initialPush)).toBe(1); + expect(initialPush.failed, describePushResult(initialPush)).toBe(0); const baselineMeta = settings.syncMetadata[filePath]; // Diverge both sides from the synced baseline. @@ -190,7 +198,9 @@ describe('SyncManager E2E', () => { vault.writeLocal(oldPath, 'move me'); const settings = makeSettings(branch); const manager = newManager(vault, settings); - await manager.pushFiles([oldPath]); + const initialPush = await manager.pushFiles([oldPath]); + expect(initialPush.success, describePushResult(initialPush)).toBe(1); + expect(initialPush.failed, describePushResult(initialPush)).toBe(0); vault.renameLocal(oldPath, newPath); await manager.trackRename(newPath, oldPath); @@ -204,7 +214,9 @@ describe('SyncManager E2E', () => { // `!isString && fileOrPath instanceof TFile` before consulting // `renamedFrom`). const newFile: TFileLike = vault.fileAt(newPath); - await manager.pushFiles([newFile as unknown as string]); + const moveResult = await manager.pushFiles([newFile as unknown as string]); + expect(moveResult.success, describePushResult(moveResult)).toBe(1); + expect(moveResult.failed, describePushResult(moveResult)).toBe(0); expect(await verifier.fileMissing(oldPath, branch)).toBe(true); const remote = await verifier.getFile(newPath, branch); @@ -222,7 +234,9 @@ describe('SyncManager E2E', () => { vault.writeLocal(filePath, 'delete me'); const settings = makeSettings(branch); const manager = newManager(vault, settings); - await manager.pushFiles([filePath]); + const initialPush = await manager.pushFiles([filePath]); + expect(initialPush.success, describePushResult(initialPush)).toBe(1); + expect(initialPush.failed, describePushResult(initialPush)).toBe(0); expect(await verifier.fileMissing(filePath, branch)).toBe(false); await service.deleteFile(filePath, branch, 'e2e: delete file'); @@ -242,8 +256,8 @@ describe('SyncManager E2E', () => { const results = await manager.pushFiles(paths); - expect(results.success).toBe(paths.length); - expect(results.failed).toBe(0); + expect(results.success, describePushResult(results)).toBe(paths.length); + expect(results.failed, describePushResult(results)).toBe(0); for (const p of paths) { const remote = await verifier.getFile(p, branch); expect(remote?.content).toBe(`content for ${p}`); diff --git a/e2e/support/push-result-diagnostic.ts b/e2e/support/push-result-diagnostic.ts new file mode 100644 index 0000000..a63f882 --- /dev/null +++ b/e2e/support/push-result-diagnostic.ts @@ -0,0 +1,9 @@ +interface PushResultDiagnostic { + success: number; + failed: number; + errors: ReadonlyArray; +} + +export function describePushResult(result: PushResultDiagnostic): string { + return `push result: success=${result.success}, failed=${result.failed}, errors=${JSON.stringify(result.errors)}`; +} diff --git a/feature_list.json b/feature_list.json index 82b453f..74d6b91 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-07: Archived feat-001 through feat-025 (complete). Backlog candidates below.", + "_lastSync": "2026-08-19: Synced against open GitHub issues; issue #105 is the active architecture refactor.", "features": [ + { + "id": "feat-026", + "name": "refactor(sync): separate planning, execution, conflicts, metadata, and UI (issue #105)", + "description": "Preserve sync behavior while extracting SyncStatusView presentation state/controller boundaries and SyncManager scanner/planner/executor/workspace boundaries with regression and integration coverage.", + "dependencies": [], + "status": "in-progress", + "evidence": "Commits dff95db/948df28 on refactor/sync-domain-pipeline: unified sync decisions and CI hardening are covered; 613 tests, local Gitea E2E, and real CI run 32338116598 are green; desktop/mobile smoke pending." + }, { "id": "feat-004", "name": "fix: resolve sonarqube issues (issue #45)", diff --git a/progress.md b/progress.md index 727de37..bb8a526 100644 --- a/progress.md +++ b/progress.md @@ -4,8 +4,8 @@ Completed work is archived in [archive/](./archive/), one file per calendar mont ## Current State -**Last Updated:** 2026-08-13 -**Active Feature:** Real-provider E2E Phase 2 (multi-run isolation) on `test/real-provider-e2e`; PR #124 open against `main`, now fully green (CI/CD incl. real GitHub/GitLab/Gitea E2E, SonarCloud Security Rating A, lint/build/vitest). The initial Phase 2 push (c8382cb) had three real bugs, all fixed in three follow-up commits on the same branch/PR (see Latest Evidence): (1) `ci.yml` used the `runner` context inside job-level `env:`, which GitHub rejects at parse time (0 jobs ever created for that push); (2) SonarCloud Security Rating gate failure (weak-hash branch-hashing, unpinned actions, missing `--ignore-scripts`, cleartext-protocol findings on the local Gitea sandbox, plus a NOSONAR-placement bug on line-continued `curl` calls in a same-day follow-up); (3) `provider-e2e`'s concurrency group keyed PR runs by PR number and branch-only runs by branch name, so a push to a branch with an open PR fired both a `push` and a `pull_request` run in *different* groups that ran fully concurrently against the same shared GitLab sandbox and starved each other's real API calls (confirmed via two reproduced failures, fixed by keying the group by branch name alone regardless of trigger event). Phase 0+1 CI is green end-to-end including real GitHub/GitLab E2E runs; the CI `E2E / gitea` job still reports "success" but is actually gated off (its "Determine whether this provider leg should run" step outputs `run=false`, so every later step is skipped-not-failed) pending runner-topology follow-up (see item 0a) — code untouched, still verified locally instead (`npm run test:e2e -- --provider gitea`, 14/14, run twice this session against a live Docker sandbox while validating the harness edits above). +**Last Updated:** 2026-08-20 +**Active Feature:** feat-026 / issue #105 — sync architecture refactor on `refactor/sync-domain-pipeline`. `SyncPlanner` is now the decision source for normal push, batch pull/preview, single pull, and moves. Edited tracked renames with a free destination plan one move instead of being auto-skipped; remote-only changes pull without false conflicts; real two-sided divergence and occupied move destinations remain conflicts. Post-push CI hardening is locally green; real provider CI plus Obsidian desktop/mobile manual verification remain before declaring the feature complete. **Parallel Work:** PR #87 (4x Dependabot security alerts via npm overrides) and Issue #57 (live-credential smoke test). ## Outstanding Items @@ -17,6 +17,15 @@ Completed work is archived in [archive/](./archive/), one file per calendar mont ## Latest Evidence +- [x] Issue #105 post-push CI hardening (2026-08-20), commit `948df28`: diagnosed run 32336155736 as two exhausted transient-provider attempts rather than a planner regression (GitHub 503/socket close; GitLab deadline exceeded). Increased provider E2E attempts from 2 to 3. A duplicate matrix cancelled by the shared push/PR concurrency group now produces a neutral aggregate gate with `run-ci=false`, so it neither creates a misleading `E2E gate` failure nor starts duplicate downstream CI; real failures still block. SyncManager E2E push preconditions now include `success`, `failed`, and provider `errors` in assertion diagnostics instead of surfacing only a secondary count mismatch. Added workflow contract and diagnostic unit tests and updated the E2E documentation. Verification: `actionlint v1.7.12 .github/workflows/ci.yml` — 0 errors; `npx eslint .` — 0 errors; `npm run build` — clean including Obsidian 1.11 compatibility; `npx vitest run` — 56 files / 613 tests; `npm run test:e2e -- --provider gitea` — 2 files / 14 tests and container cleanup; `git diff --check` — clean. Real CI run 32338116598 passed GitHub/GitLab production E2E, independent verification, cleanup, aggregate gate, Node 22/24 tests, lint, package, and build/release. The initial disabled-Gitea job landed on offline runner `heavenweb-runner-8`; failed-only rerun completed its skip in 11s and the full run concluded success. Provider API checks found no remaining `e2e/pr/127/**` or branch-source E2E refs. AGENTS-required Haiku was unavailable, so verification ran locally and through real CI. + +- [x] Issue #105 unified sync decisions and move regression (2026-08-20): added operation-aware `SyncPlanner.planFor(push|pull)`, `MoveFacts`, and the `move` domain action. Normal push, batch pull and preview, single pull, and tracked moves now consume planner decisions instead of reimplementing SHA conflict checks. Removed `PushCoordinator.queueMove`'s stale-metadata gate, so an edited tracked rename with a free destination appears under Moves and commits once; occupied destinations remain conflicts. Fixed the complementary pull false positive: a remote-only change now pulls, while real two-sided divergence still resolves as conflict. Content-fetched text/binary paths normalize equal bytes to the provider blob SHA before planning, preserving binary and GitLab legacy-baseline behavior. Added planner operation matrix, coordinator move regression, batch pull, and single pull coverage. Verification: `npx eslint .` — 0 errors; `npm run build` — clean including Obsidian 1.11 compatibility; `npx vitest run` — 54 files / 610 tests; `git diff --check` — clean. Manual Obsidian verification remains. + +- [x] Issue #105 architecture implementation (2026-08-19): extracted `SyncStatusRenderer` and `SyncStatusComposition`; `SyncStatusView.ts` is 11.5 KB / 251 lines. Extracted `PullCoordinator` and `PushCoordinator`; `SyncManager.ts` is 13.7 KB / 298 lines and retains its public compatibility API. `SyncManagerWorkspace` now owns refresh/tree-snapshot reuse, push/pull, diff, local/remote deletion, move, metadata mutations, provider URLs and UI-safe workspace info; sync-status UI code no longer reaches provider/tree/settings/vault mutation helpers, and `src/logic/**` has no UI imports. Legacy refresh characterization cases now target the extracted service instead of private View delegates; legacy modal tests explicitly inject the Obsidian interaction adapter. Added real refresh integration plus focused push-coordinator/workspace regression tests. Independent verification: `npx eslint .` — 0 errors; `npm run build` — clean including Obsidian 1.11 compatibility; `npx vitest run` — 54 files / 598 tests; `npm run test:e2e -- --provider gitea` — 2 files / 14 tests with container cleanup; `git diff --check` — clean. Desktop/mobile Obsidian smoke remains manual. + +- [x] Issue #105 architecture slice 3 (2026-08-19): added tested `SyncDiffService` and `SyncStatusNavigator`, so lazy blob loading/cache/content-kind projection is a domain `FileDiff` boundary. Extracted single-file, batch push/pull, local/remote delete, move revert, remote-tree reuse, progress/confirmation, and optimistic-status orchestration into `SyncStatusOperations`; all View row/group events now enter through `SyncStatusController`. The actual View is about 40 KB (down from 58 KB this slice and 80 KB initially). Independent verification: `npx eslint .` — 0 errors; `npm run build` — clean including Obsidian 1.11 compatibility; `npx vitest run` — 52 files / 594 tests; `npm run test:e2e -- --provider gitea` — 2 files / 14 tests with container cleanup; `git diff --check` — clean. Feature remains in progress because renderer composition and the ~50 KB manager facade are still oversized. +- [x] Issue #105 architecture slice 2 (2026-08-19): extracted `SyncStatusRefreshService` for local/remote discovery, hidden files, symlinks, SHA/content classification, out-of-band move reconciliation, and live modify/rename transitions. The actual View fell from about 80 KB to 58 KB while legacy characterization entrypoints remain thin delegates. Added `SyncInteractionPort` plus `ObsidianSyncInteraction`; `logic/sync/SyncManager.ts` no longer imports Modal or Notice classes. Independent verification: `npx eslint .` — 0 errors; `npm run build` — clean including Obsidian 1.11 compatibility; `npx vitest run` — 51 files / 585 tests; `npm run test:e2e -- --provider gitea` — 2 files / 14 tests with container cleanup; `git diff --check` — clean. Feature remains in progress: action orchestration is still View-owned, the manager core is about 50 KB, and desktop/mobile manual smoke tests remain pending. +- [x] Issue #105 architecture slice (2026-08-19): moved compatibility entrypoints to thin re-exports; added presentation state, pure selectors, path-only controller commands, pure planner matrix, scanner, metadata store, push/pull/remote-delete/conflict executors, `SyncManagerWorkspace`, `FileDiff`, and four workspace integration paths. `npx eslint .` — 0 errors; `npm run build` — clean including Obsidian 1.11 compatibility; `npx vitest run` — 51 files / 585 tests passed; `npm run test:e2e -- --provider gitea` — 2 files / 14 tests passed with sandbox cleanup. Feature remains in progress: the implementation files are still oversized (`sync-status/SyncStatusView.ts` ~80 KB, `sync/SyncManager.ts` ~50 KB), domain still imports modal adapters, and manual Obsidian desktop/mobile checks are pending. - [x] Real-provider E2E Phase 2, PR #124 fully green (2 more follow-up commits, same branch/PR): (1) NOSONAR placement fix — the previous commit's `# NOSONAR` comments on the gitea-provisioning `curl` calls landed on the *closing* line of each multi-line statement, but SonarCloud attributes diff --git a/session-handoff.md b/session-handoff.md index 6874243..9a92258 100644 --- a/session-handoff.md +++ b/session-handoff.md @@ -1,73 +1,44 @@ # Session Handoff -**Date:** 2026-08-13 -**Branch:** `test/real-provider-e2e` (PR #124 open against `main`) +**Date:** 2026-08-20 +**Branch:** `refactor/sync-domain-pipeline` (PR #127) +**Active Feature:** feat-026 / issue #105 — sync architecture refactor ## Completed This Session -PR #124 ("feat(e2e): real-provider E2E with multi-run isolation (Phases 0-2)") is now fully -green. It started this session already containing the Phase 2 multi-run-isolation work -(namespace scheme, 3-layer cleanup hierarchy, per-source/provider concurrency) from a prior -session, but the first push (`c8382cb`) had three real bugs, all found and fixed here: - -1. `ci.yml`'s `provider-e2e` job set `E2E_WORKDIR` using `${{ runner.temp }}` inside a job-level - `env:` block — the `runner` context isn't allowed there, so GitHub Actions rejected the whole - workflow file at parse time (0 jobs ever created for that push, no `CI/CD` check run at all). - Fixed by computing `E2E_WORKDIR` in an unconditional first step instead, via `$GITHUB_ENV`. -2. SonarCloud's Security Rating gate failed (D, required ≥A): `sha1sum` in the branch-hashing - helper (switched to `sha256sum`), unpinned `actions/checkout`/`setup-node`/`paths-filter` in - the new jobs/workflows (pinned to full SHAs), missing `--ignore-scripts` on the new job's - `npm ci`, and justified `# NOSONAR` suppressions on `http://` calls that only ever talk to a - per-run Docker-bridge-only Gitea sandbox. A same-day follow-up fixed a NOSONAR-placement bug - (marker landed on the wrong line of two multi-line `curl` statements). -3. `provider-e2e`'s concurrency group keyed PR runs by PR number and branch-only runs by branch - name — different groups for the same branch when it has an open PR, so a push fired both a - `push` and a `pull_request` run *concurrently* against the same shared GitLab sandbox and - starved each other's real API calls. Reproduced twice (real `400: Deadline Exceeded` and a - `testConnection` timeout on the `pull_request`-triggered run, while the `push`-triggered run - for the identical commit passed cleanly both times). Fixed by keying the group by branch name - alone (`github.head_ref || github.ref_name`) regardless of trigger event, and updating the two - cleanup workflows' groups to match. User was asked and explicitly chose "fix the dedup now" - over deferring. Verified by re-pushing: the duplicate run this time correctly got cancelled by - the concurrency group instead of racing, and the survivor passed 100% clean. +Investigated the failed real-provider CI after the unified planner commit. The move paths passed; +GitHub exhausted two attempts on a 503 and `UND_ERR_SOCKET`, while GitLab exhausted two attempts +on provider deadline errors. The tests then surfaced secondary count/existence assertions that +hid those original request failures. + +Hardened CI with three provider attempts, explicit push-result diagnostics in SyncManager E2E, +and workflow contract coverage. When the shared push/PR concurrency group cancels a duplicate +matrix, its aggregate gate now reports the replacement neutrally and emits `run-ci=false`, so it +does not leave an additional aggregate red check or run downstream CI twice. Real failures remain +blocking. Updated the real-provider E2E documentation to match. + +Committed as `948df28` (`fix(ci): harden provider e2e failures`) and pushed to +`origin/refactor/sync-domain-pipeline`. The pre-existing untracked `.codex-gitlab.env` remains +untouched. + +## Verification Evidence + +```text +npx eslint . -> PASS, 0 errors +npm run build -> PASS, incl. Obsidian 1.11 compatibility +npx vitest run -> PASS, 56 files / 613 tests +npm run test:e2e -- --provider gitea -> PASS, 2 files / 14 tests; container removed +actionlint v1.7.12 .github/workflows/ci.yml -> PASS, 0 errors +git diff --check -> PASS +real CI run 32338116598 -> PASS after failed-only rerun of a disabled Gitea leg assigned to an offline runner +GitHub/GitLab sandbox branch query -> PASS, no e2e/pr/127 or source-branch refs remain +``` -All automated checks pass and were captured as evidence in `progress.md`: `actionlint` 0 errors, -`npx eslint .` 0 errors, `npm run build` clean, `npx vitest run` 527 passed, a live local Gitea -E2E run (14/14, run twice against real Docker), and PR #124's CI/CD run fully green (real -GitHub/GitLab/Gitea E2E, SonarCloud Security Rating A, Node 22/24 tests, build/release/package). +The AGENTS-required Haiku verifier was unavailable in this environment, so verification ran +locally in this session. ## Exact Next Step -**PR #124 is ready** — https://github.com/firstsun-dev/git-files-sync/pull/124. Nothing is -currently blocking it (note: `main`'s branch protection ruleset is disabled, so CI status isn't -enforced, but it's genuinely green regardless). Next step is for the user to review and merge it, -or ask for further changes. - -After merge, pick up: - -**Priority 1: item 0a in `progress.md`** — re-enable the gitea leg in CI (currently gated off in -`ci.yml`'s "Determine whether this provider leg should run" step after earlier runner-topology -failures that were already fixed in code but never re-verified live). Harness code passes locally -every time; this is a "flip the gate back on and watch one more real CI run" task, not new -development. - -**Priority 2:** Pick next issue from GitHub Project #6 backlog (see `feature_list.json` — re-sync -against GitHub Issues first, it's the source of truth). - -## Verification Baseline - -``` -actionlint (v1.7.12 binary) -> 0 errors on all 4 workflow files -npx eslint . -> 0 errors -npm run build -> clean (incl. Obsidian 1.11.0 compat typecheck) -npx vitest run -> 36 files, 527 tests passed -npm run test:e2e -- --provider gitea -> 14/14 passed (real local Docker sandbox, run twice) -PR #124 CI/CD (surviving run) -> fully green: E2E/github, E2E/gitlab, E2E/gitea (gated-skip), - E2E gate, CI Lint, Test Node 22/24, Build and Release, - Package Artifact, SonarCloud Code Analysis (Security Rating A) -``` - -## Active Branches - -- **test/real-provider-e2e** — PR #124 open against `main`, green, ready for review/merge. -- **main** — unaffected, no changes. +Complete the remaining Obsidian desktop/mobile move smoke tests. Verify moving and editing a +tracked file appears under Moves and applies as one remote move, while an occupied remote +destination remains a skipped conflict. diff --git a/src/logic/sync-manager.ts b/src/logic/sync-manager.ts index 528432b..2fa0043 100644 --- a/src/logic/sync-manager.ts +++ b/src/logic/sync-manager.ts @@ -1,1293 +1,3 @@ -import { TFile, App, Notice } from 'obsidian'; -import { GitServiceInterface, GitTreeEntry, GitFile } from '../services/git-service-interface'; -import { MAX_BATCH_PUSH_SIZE } from '../services/git-service-base'; -import { GitLabFilesPushSettings, getServiceName, getEffectiveSymlinkHandling, isSyncMetadataAtPath } from '../settings'; -import { SyncConflictModal } from '../ui/SyncConflictModal'; -import { SyncPlanModal, SyncPlanDirection } from '../ui/SyncPlanModal'; -import { BatchConflictResolutionModal } from '../ui/BatchConflictResolutionModal'; -import { SyncPlan, SyncPlanEntry, isSyncPlanEmpty } from '../ui/types'; -import { logger } from '../utils/logger'; -import { isBinaryPath, contentsEqual } from '../utils/path'; -import { readLocalSymlinkTarget, createLocalSymlink } from '../utils/symlink'; -import { gitBlobSha } from '../utils/git-blob-sha'; -import { ensureParentDirs } from '../utils/vault-path'; -import { SyncStatusService } from './sync-status-service'; - -/** Result of syncing one file within a batch push/pull. */ -type BatchOutcome = 'done' | 'unchanged' | 'conflict'; - -/** A file classified as needing a push, queued for the grouped batch-commit call. */ -type ToPushEntry = { path: string; name: string; repoPath: string; content: string | ArrayBuffer; existingSha?: string; existingRevision?: string }; - -/** A renamed file classified as a safe move, queued for the grouped batch-commit call. */ -type ToMoveEntry = { path: string; name: string; repoPath: string; oldPath: string; oldRepoPath: string; content: string | ArrayBuffer; oldRevision?: string }; - -/** How the user chose to resolve one batch-push conflict in `BatchConflictResolutionModal`. */ -export type ConflictResolution = 'keep-local' | 'keep-remote' | 'skip'; - -/** - * A file whose local and remote content have both changed since the last - * sync, detected while planning a batch push. Carries everything needed to - * resolve it against the exact remote snapshot the plan was built from — - * `remoteSha` (and `remoteRevision` for GitLab's optimistic lock) — without - * re-fetching the remote tree. Remote content itself is fetched lazily (via - * `getBlob(remoteSha, repoPath)`) only when the user asks to view the diff or - * once "keep remote" is actually applied, so resolving a large batch of - * conflicts via a bulk action never has to download content nobody looks at. - */ -export type BatchPushConflict = { - path: string; - name: string; - repoPath: string; - localContent: string | ArrayBuffer; - remoteSha: string; - remoteRevision?: string; - resolution?: ConflictResolution; -}; - -/** The result of classifying a whole batch push before anything is written: what's ready to commit, and what needs a conflict decision first. */ -type BatchPushPlan = { - pushes: ToPushEntry[]; - moves: ToMoveEntry[]; - conflicts: BatchPushConflict[]; - /** Rename-safety conflicts (target already exists, or the old path moved on) — always left alone, never offered for interactive resolution. */ - autoSkipped: SyncPlanEntry[]; -}; - -/** - * Result of a batch push. `syncedPaths` lists every path that's now confirmed - * synced (content just written matches what's now on the remote), with its - * new blob sha when known. The caller uses this to mark those files' UI - * status directly rather than re-fetching the remote tree right after a - * write — GitHub's tree-by-branch-name read can lag a successful write by a - * moment, so an immediate re-fetch can misreport a just-pushed file as - * "modified" even though nothing is actually different. - */ -export type PushResults = { - success: number; - failed: number; - /** Total conflicts detected (both interactively resolved and rename-safety auto-skips). */ - conflicts: number; - /** Conflicts resolved as "keep local" or "keep remote" and applied. */ - resolvedConflicts: number; - /** Conflicts left untouched — by explicit "skip", or a rename-safety auto-skip. */ - skippedConflicts: number; - /** True when the user cancelled conflict resolution or the final plan review — nothing was written for the batch commit. */ - cancelled?: boolean; - errors: Array<{ file: string; error: string }>; - syncedPaths: Array<{ path: string; sha?: string }>; - conflictedPaths?: string[]; -}; - -export class SyncManager { - private readonly app: App; - private gitService: GitServiceInterface; - private readonly settings: GitLabFilesPushSettings; - private readonly onSaveSettings?: () => Promise; - private readonly isPathIgnored: (path: string) => boolean; - readonly status: SyncStatusService; - - constructor( - app: App, - gitService: GitServiceInterface, - settings: GitLabFilesPushSettings, - onSaveSettings?: () => Promise, - isPathIgnored: (path: string) => boolean = () => false, - status: SyncStatusService = new SyncStatusService(), - ) { - this.app = app; - this.gitService = gitService; - this.settings = settings; - this.onSaveSettings = onSaveSettings; - this.isPathIgnored = isPathIgnored; - this.status = status; - } - - private get serviceName(): string { - return getServiceName(this.settings); - } - - public async updateMetadata(path: string, sha: string): Promise { - this.settings.syncMetadata[path] = { - lastSyncedSha: sha, - lastSyncedAt: Date.now(), - lastKnownPath: path - }; - await this.saveSettings(); - this.status.markSynced(path, sha); - } - - /** Drop sync metadata for a path that's been deleted, so it can't be mistaken for a rename source later. */ - public async clearMetadata(path: string): Promise { - if (!(path in this.settings.syncMetadata)) return; - delete this.settings.syncMetadata[path]; - await this.saveSettings(); - } - - /** - * Records a vault 'rename' event so a later push recognizes it as a real - * move — no content probing or remote lookup needed, Obsidian already - * told us the exact old path. A file with no tracked metadata was never - * synced, so there's nothing to carry forward: it's just a new file at a - * new name. - * - * A chain of renames (A→B→C) collapses to a single pending move by always - * recording the still-unpushed remote path, not the most recent hop; and - * renaming back to that path (B→A) cancels the pending move entirely, - * since the file is once again exactly what's on the remote. - */ - public async trackRename(newPath: string, oldPath: string): Promise { - const metadata = this.settings.syncMetadata[oldPath]; - if (!metadata) return; - - delete this.settings.syncMetadata[oldPath]; - const remotePath = metadata.renamedFrom ?? oldPath; - - this.settings.syncMetadata[newPath] = { - lastSyncedSha: metadata.lastSyncedSha, - lastSyncedAt: metadata.lastSyncedAt, - lastKnownPath: newPath, - ...(newPath === remotePath ? {} : { renamedFrom: remotePath }), - }; - - await this.saveSettings(); - } - - private getNormalizedPath(path: string): string { - if (!this.settings.vaultFolder) return path; - const folderPath = this.settings.vaultFolder + '/'; - if (path.startsWith(folderPath)) { - return path.substring(folderPath.length); - } - if (path === this.settings.vaultFolder) return ''; - return path; - } - - updateGitService(gitService: GitServiceInterface): void { - this.gitService = gitService; - } - - /** A plan with exactly one entry, for a single-file push/pull's confirm step. */ - private singleEntryPlan(kind: 'addition' | 'modification', path: string, name: string): SyncPlan { - const plan: SyncPlan = { additions: [], modifications: [], deletions: [], moves: [] }; - const entry: SyncPlanEntry = { path, name }; - (kind === 'addition' ? plan.additions : plan.modifications).push(entry); - return plan; - } - - /** - * Shows the plan for review and resolves once the user confirms or - * cancels. A plan with nothing to apply (e.g. every candidate file was - * already in sync or skipped as a conflict) resolves immediately without - * showing anything — there is nothing to review. - */ - private confirmPlan(plan: SyncPlan, direction: SyncPlanDirection): Promise { - if (isSyncPlanEmpty(plan)) return Promise.resolve(true); - return new Promise(resolve => { - new SyncPlanModal(this.app, plan, direction, () => resolve(true), () => resolve(false)).open(); - }); - } - - /** - * A missing local file at a tracked path is only weak evidence of a rename — - * any orphaned metadata entry (e.g. from a local delete) matches it too. Only - * report a rename once the remote content at the old path still matches the - * content being pushed now, confirming it's really the same file that moved. - * - * Given a pre-fetched tree this costs no requests: the tree carries each - * blob's sha, so one comparison against the local content's git blob sha - * answers both "is the old path still on the remote" and "is it the same - * bytes" (`contentsEqual` is exact equality, so the two agree). Without a - * tree every candidate needs its own `getFile`, and a candidate the remote - * no longer has 404s — which is why the batch push must pass its tree in - * rather than re-probing the same dead paths once per file being pushed. - */ - private async detectRename( - file: TFile, - content: string | ArrayBuffer, - treeByFullPath?: Map - ): Promise { - const candidates = Object.keys(this.settings.syncMetadata).filter(oldPath => { - const metadata = this.settings.syncMetadata[oldPath]; - return oldPath !== file.path && isSyncMetadataAtPath(metadata, oldPath) - && !this.app.vault.getFileByPath(oldPath); - }); - if (candidates.length === 0) return null; - - // A single-file push (ribbon/command/context-menu/sync-view row) has no - // prefetched tree to hand in, unlike batch push. Fetching it once here - // (only when there's actually a candidate to check) replaces what used to - // be one live getFile() round trip per orphaned syncMetadata entry -- a - // silent, sequential delay that grew with however many stale entries had - // accumulated (e.g. files deleted outside Obsidian's vault events). - let tree = treeByFullPath; - if (!tree) { - try { - const entries = await this.gitService.listFilesDetailed(this.settings.branch, false); - tree = new Map(entries.map(e => [e.path, e])); - } catch (e) { - logger.warn('Failed to fetch remote tree for rename detection; falling back to per-candidate lookups', e); - } - } - - const localSha = tree ? await gitBlobSha(content) : undefined; - for (const oldPath of candidates) { - const oldRepoPath = this.getNormalizedPath(oldPath); - const treeMatch = this.matchRenameFromTree(localSha, oldRepoPath, tree); - if (treeMatch === true) return oldPath; - if (treeMatch === false) continue; - - const remoteAtOldPath = await this.gitService.getFile(oldRepoPath, this.settings.branch); - if (remoteAtOldPath.sha && this.contentsEqual(content, remoteAtOldPath.content)) return oldPath; - } - return null; - } - - private matchRenameFromTree( - localSha: string | undefined, - oldRepoPath: string, - treeByFullPath: Map | undefined - ): boolean | undefined { - if (!treeByFullPath) return undefined; - const entry = treeByFullPath.get(this.getFullPathForTree(oldRepoPath)); - if (!entry || entry.symlink) return false; - return entry.sha ? entry.sha === localSha : undefined; - } - - private async performPush(file: {path: string, name: string}, content: string | ArrayBuffer, existingSha?: string, existingRevision?: string, silent = false): Promise { - const repoPath = this.getNormalizedPath(file.path); - const result = await this.gitService.pushFile( - repoPath, - content, - this.settings.branch, - `Update ${file.name} from Obsidian`, - existingSha, - existingRevision - ); - - // Update metadata - const newSha = result.sha ?? await gitBlobSha(content); - await this.updateMetadata(file.path, newSha); - - if (!silent) new Notice(`Pushed ${file.name} to ${this.serviceName}`); - return newSha; - } - - /** - * Handles pushing a local symbolic link per the configured behavior. - * Returns true if the link was handled (pushed as a symlink, or intentionally - * skipped); returns false to let the caller fall through to a normal content - * push ("follow", which reads through the link). - */ - /** `handled`: the symlink flow owns this push, caller should not fall through to a normal push. `synced`: content is now confirmed synced (false for "skip" mode, where nothing was actually written). */ - private async handleSymlinkPush(file: {path: string, name: string}, target: string, silent = false): Promise<{ handled: boolean; synced: boolean; sha?: string }> { - const mode = getEffectiveSymlinkHandling(this.settings); - if (mode === 'skip') { - if (!silent) new Notice(`Skipped symlink ${file.name}.`); - return { handled: true, synced: false }; - } - if (mode === 'real' && this.gitService.pushSymlink) { - const repoPath = this.getNormalizedPath(file.path); - const result = await this.gitService.pushSymlink(repoPath, target, this.settings.branch, `Update ${file.name} from Obsidian`); - if (result.sha) await this.updateMetadata(file.path, result.sha); - if (!silent) new Notice(`Pushed symlink ${file.name} to ${this.serviceName}`); - return { handled: true, synced: true, sha: result.sha }; - } - return { handled: false, synced: false }; - } - - /** The symlink target to recreate on pull, or undefined when the remote isn't a symlink. */ - private symlinkPullTarget(remote: { isSymlink?: boolean; symlinkTarget?: string }): string | undefined { - return remote.isSymlink ? remote.symlinkTarget ?? '' : undefined; - } - - async pullFile(fileOrPath: TFile | string) { - const { path, name, isString } = this.getFileInfo(fileOrPath); - const repoPath = this.getNormalizedPath(path); - - try { - const remote = await this.gitService.getFile(repoPath, this.settings.branch); - if (!remote.sha) { - new Notice(`File ${name} not found on remote.`); - return; - } - - const exists = await this.checkFileExists(path, isString); - const localContent = exists ? await this.getFileContent(fileOrPath) : null; - const lastSynced = this.settings.syncMetadata[path]; - - if (exists && localContent !== null && this.contentsEqual(localContent, remote.content)) { - // Still update metadata even if content matches - await this.updateMetadata(path, remote.sha); - new Notice(`${name} is already up to date.`); - return; - } - - // Conflict detection for pull (only if local exists) - if (exists && remote.sha && lastSynced && !this.isSameBaseline(lastSynced.lastSyncedSha, remote)) { - new SyncConflictModal(this.app, name, localContent ?? '', remote.content, (choice) => { - void (async () => { - try { - const fileRep = typeof fileOrPath === 'string' ? { path, name } : fileOrPath; - if (choice === 'local') { - await this.performPush(fileRep, localContent || '', remote.sha, remote.revision); - } else { - await this.performPull(fileRep, remote.content, remote.sha, false, this.symlinkPullTarget(remote)); - } - } catch (e) { - this.handleError(`Failed to resolve conflict for ${name}`, e); - } - })(); - }).open(); - return; - } - - const confirmed = await this.confirmPlan(this.singleEntryPlan(exists ? 'modification' : 'addition', path, name), 'pull'); - if (!confirmed) return; - - const fileRep = typeof fileOrPath === 'string' ? { path, name } : fileOrPath; - await this.performPull(fileRep, remote.content, remote.sha); - } catch (e) { - this.handleError(`Failed to pull ${name} from ${this.serviceName}`, e); - } - } - - private contentsEqual(a: string | ArrayBuffer, b: string | ArrayBuffer): boolean { - return contentsEqual(a, b); - } - - /** Check if remote baseline matches metadata, supporting lazy migration of old metadata. */ - private isSameBaseline(lastSyncedSha: string, remoteFile: GitFile): boolean { - return lastSyncedSha === remoteFile.sha || lastSyncedSha === remoteFile.revision; - } - - private isBinary(path: string): boolean { - return isBinaryPath(path); - } - - private async performPull(file: TFile | {path: string, name: string}, remoteContent: string | ArrayBuffer, remoteSha: string, silent = false, symlinkTarget?: string) { - await ensureParentDirs(this.app.vault.adapter, file.path); - - if (symlinkTarget !== undefined) { - // Remote blob is a symbolic link. Recreate a real OS link when the - // setting is "real" and the platform supports it… - if (getEffectiveSymlinkHandling(this.settings) === 'real' && createLocalSymlink(this.app, file.path, symlinkTarget)) { - await this.updateMetadata(file.path, remoteSha); - if (!silent) new Notice(`Pulled symlink ${file.name} from ${this.serviceName}`); - return; - } - // …otherwise record where it pointed by writing the target as content. - remoteContent = symlinkTarget; - } - - await this.writePulledContent(file, remoteContent); - await this.updateMetadata(file.path, remoteSha); - if (!silent) new Notice(`Pulled ${file.name} from ${this.serviceName}`); - } - - private async writePulledContent(file: TFile | {path: string, name: string}, remoteContent: string | ArrayBuffer): Promise { - if (typeof remoteContent !== 'string') { - if (file instanceof TFile) { - await this.app.vault.modifyBinary(file, remoteContent); - } else { - await this.app.vault.adapter.writeBinary(file.path, remoteContent); - } - } else if (file instanceof TFile) { - await this.app.vault.modify(file, remoteContent); - } else { - await this.app.vault.adapter.write(file.path, remoteContent); - } - } - - private async saveSettings() { - if (this.onSaveSettings) { - await this.onSaveSettings(); - } - } - - private handleError(message: string, error: unknown): void { - logger.error(message, error); - const detail = error instanceof Error ? error.message : String(error); - new Notice(`${message}: ${detail}`); - } - - /** - * The one push pipeline for every entry point (ribbon, command palette, - * context menu, sync-status row, "selected", "all modified") — a single - * file is just a one-element array, never a special case. Classifies - * every candidate first (nothing written yet), lets the user resolve any - * conflicts and review the final plan, then commits everything that's - * ready to push/move in one grouped call — a "keep local" conflict - * resolution rides along in that same commit as an ordinary update, and a - * "keep remote" resolution is only applied to the vault after that commit - * succeeds. Cancelling either the conflict-resolution or the final-review - * step aborts the whole batch: no commit, no local overwrite, no metadata - * change. - */ - async pushFiles( - files: (TFile | string)[], - onProgress?: (current: number, total: number, fileName: string) => void, - remoteTree?: GitTreeEntry[] - ): Promise { - const emptyResults = (): PushResults => ( - { success: 0, failed: 0, conflicts: 0, resolvedConflicts: 0, skippedConflicts: 0, errors: [], syncedPaths: [] } - ); - - const syncableFiles = files.filter(file => file && !this.isPathIgnored(this.getFileInfo(file).path)); - if (syncableFiles.length === 0) return emptyResults(); - - const tree = remoteTree ?? await this.gitService.listFilesDetailed(this.settings.branch, false); - const { plan, immediate } = await this.buildBatchPushPlan(syncableFiles, onProgress, tree); - - const results: PushResults = { - ...emptyResults(), - success: immediate.success, - failed: immediate.failed, - conflicts: plan.conflicts.length + plan.autoSkipped.length, - errors: immediate.errors, - syncedPaths: immediate.syncedPaths, - }; - - const skipped: BatchPushConflict[] = []; - const keepRemote: BatchPushConflict[] = []; - const keepLocal: BatchPushConflict[] = []; - - if (plan.conflicts.length > 0) { - const resolved = await this.resolveBatchConflicts(plan.conflicts, syncableFiles.length, plan.pushes.length + plan.moves.length); - if (!resolved) { - results.cancelled = true; - results.conflictedPaths = [...plan.conflicts.map(c => c.path), ...plan.autoSkipped.map(e => e.path)]; - await this.saveSettings(); - return results; - } - for (const conflict of plan.conflicts) { - if (conflict.resolution === 'keep-local') { - keepLocal.push(conflict); - plan.pushes.push({ - path: conflict.path, - name: conflict.name, - repoPath: conflict.repoPath, - content: conflict.localContent, - existingSha: conflict.remoteSha, - existingRevision: conflict.remoteRevision, - }); - } else if (conflict.resolution === 'keep-remote') { - keepRemote.push(conflict); - } else { - skipped.push(conflict); - } - } - } - - const reviewPlan: SyncPlan = { - additions: plan.pushes.filter(p => !p.existingSha).map(p => ({ path: p.path, name: p.name })), - modifications: plan.pushes.filter(p => p.existingSha).map(p => ({ path: p.path, name: p.name })), - moves: plan.moves.map(m => ({ path: m.path, name: m.name, movedFrom: m.oldPath })), - deletions: [], - acceptedRemote: keepRemote.map(c => ({ path: c.path, name: c.name })), - skippedConflicts: [ - ...skipped.map(c => ({ path: c.path, name: c.name })), - ...plan.autoSkipped.map(e => ({ path: e.path, name: e.name })), - ], - }; - - if (!isSyncPlanEmpty(reviewPlan) && !await this.confirmPlan(reviewPlan, 'push')) { - results.cancelled = true; - results.skippedConflicts = skipped.length + plan.autoSkipped.length; - results.conflictedPaths = [...plan.conflicts.map(c => c.path), ...plan.autoSkipped.map(e => e.path)]; - await this.saveSettings(); - return results; - } - - await this.commitResolvedBatch(plan.pushes, plan.moves, keepRemote, keepLocal, results); - results.skippedConflicts = skipped.length + plan.autoSkipped.length; - - await this.saveSettings(); - this.notifyPushBatchResult(results); - return results; - } - - /** - * Opens the batch conflict resolution modal and resolves once the user - * clicks Continue (every conflict object in `conflicts` now carries a - * `resolution`) or cancels (`false` — the conflicts are left untouched). - */ - private resolveBatchConflicts(conflicts: BatchPushConflict[], totalFiles: number, safeCount: number): Promise { - return new Promise(resolve => { - new BatchConflictResolutionModal( - this.app, - this.gitService, - conflicts, - totalFiles, - safeCount, - () => resolve(true), - () => resolve(false), - ).open(); - }); - } - - /** - * Commits the resolved batch as one transaction. A "keep local" decision - * was made against a specific remote snapshot (`remoteSha`); if the - * remote has moved on for any of those paths since the user reviewed - * them, the whole commit is aborted rather than silently applying a - * stale decision — the user reviewed one version of the remote file, not - * whatever is there now. Only once the commit (if any was needed) - * actually succeeds are "keep remote" conflicts written to the vault — - * a failed or aborted commit leaves both sides exactly as they were. - */ - private async commitResolvedBatch( - toPush: ToPushEntry[], - toMove: ToMoveEntry[], - keepRemote: BatchPushConflict[], - keepLocal: BatchPushConflict[], - results: PushResults - ): Promise { - if (keepLocal.length > 0) { - const stale = await this.staleResolvedConflicts(keepLocal); - if (stale.length > 0) { - const message = `Remote content changed since you reviewed this conflict (${stale.map(c => c.path).join(', ')}). Nothing was pushed — resolve the conflict again.`; - for (const f of toPush) { - results.failed++; - results.errors.push({ file: f.path, error: message }); - } - for (const f of toMove) { - results.failed++; - results.errors.push({ file: f.path, error: message }); - } - return; - } - } - - const hadWork = toPush.length > 0 || toMove.length > 0; - const failedBefore = results.failed; - if (hadWork) { - await this.commitPushBatch(toPush, toMove, results); - } - - const committedOk = !hadWork || results.failed === failedBefore; - if (!committedOk) return; - - const keepLocalPaths = new Set(keepLocal.map(c => c.path)); - results.resolvedConflicts += results.syncedPaths.filter(p => keepLocalPaths.has(p.path)).length; - - await this.applyKeepRemote(keepRemote, results); - } - - /** Re-checks each "keep local" conflict's remote blob against the sha recorded when the plan was built, so a resolution reviewed against one remote snapshot is never silently applied to a different, newer one. */ - private async staleResolvedConflicts(keepLocal: BatchPushConflict[]): Promise { - const stale: BatchPushConflict[] = []; - for (const conflict of keepLocal) { - const current = await this.gitService.getFile(conflict.repoPath, this.settings.branch); - if (current.sha !== conflict.remoteSha) stale.push(conflict); - } - return stale; - } - - /** Writes each "keep remote" conflict's reviewed content to the vault. Fetched by the exact blob sha recorded at plan time (via getBlob), not a fresh getFile, so what's applied is exactly what the user reviewed. */ - private async applyKeepRemote(keepRemote: BatchPushConflict[], results: PushResults): Promise { - for (const conflict of keepRemote) { - try { - const blob = await this.gitService.getBlob(conflict.remoteSha, conflict.repoPath); - await this.performPull({ path: conflict.path, name: conflict.name }, blob.content, blob.sha, true, this.symlinkPullTarget(blob)); - results.resolvedConflicts++; - results.syncedPaths.push({ path: conflict.path, sha: blob.sha }); - } catch (e) { - results.failed++; - results.errors.push({ file: conflict.path, error: e instanceof Error ? e.message : String(e) }); - } - } - } - - async pullAllFiles( - files: (TFile | string)[], - onProgress?: (current: number, total: number, fileName: string) => void, - remoteTree?: GitTreeEntry[] - ): Promise<{ success: number; failed: number; conflicts: number; errors: Array<{ file: string; error: string }> }> { - let tree = remoteTree; - if (!tree) { - try { - tree = await this.gitService.listFilesDetailed(this.settings.branch, false); - } catch (e) { - logger.warn('Failed to fetch remote tree for pull; falling back to per-file fetches', e); - } - } - const plan = await this.planPullBatch(files, tree); - if (!isSyncPlanEmpty(plan) && !await this.confirmPlan(plan, 'pull')) { - return { success: 0, failed: 0, conflicts: 0, errors: [] }; - } - return this.processPullBatch(files, onProgress, tree); - } - - /** Computes what a pull-all would do, without writing anything, for the plan-review modal. */ - async planPullBatch(files: (TFile | string)[], remoteTree?: GitTreeEntry[]): Promise { - let treeByFullPath: Map | undefined; - if (remoteTree) { - treeByFullPath = new Map(remoteTree.map(e => [e.path, e])); - } - - const plan: SyncPlan = { additions: [], modifications: [], deletions: [], moves: [] }; - for (const fileOrPath of files) { - if (!fileOrPath) continue; - const { path, name, isString } = this.getFileInfo(fileOrPath); - try { - const kind = await this.classifyPullForPlan(fileOrPath, path, isString, treeByFullPath); - this.addPlanEntry(plan, kind, path, name); - } catch (e) { - logger.warn(`Skipping ${path} from pull plan preview`, e); - } - } - return plan; - } - - private addPlanEntry(plan: SyncPlan, kind: string, path: string, name: string, movedFrom?: string): void { - const entry: SyncPlanEntry = { path, name, movedFrom }; - if (kind === 'addition') plan.additions.push(entry); - else if (kind === 'modification') plan.modifications.push(entry); - else if (kind === 'move') plan.moves.push(entry); - // 'unchanged' / 'conflict' / 'skip' aren't part of what would be applied. - } - - /** - * Read-only mirror of the pull classification path, for the plan preview. - * Only ever reads the pre-fetched tree's blob sha -- never a network - * fetch, so a plan preview can't double the number of remote reads a - * pull-all already makes. A symlink entry, an entry with no sha, or no - * tree at all can't be compared locally, so those are optimistically - * bucketed as "modification"; the real pull path (unchanged) still does - * the authoritative check when applied. - */ - private async classifyPullForPlan( - fileOrPath: TFile | string, - path: string, - isString: boolean, - treeByFullPath?: Map - ): Promise<'addition' | 'modification' | 'unchanged' | 'conflict' | 'skip'> { - const repoPath = this.getNormalizedPath(path); - const entry = treeByFullPath?.get(this.getFullPathForTree(repoPath)); - if (treeByFullPath && !entry) return 'skip'; - - if (!await this.checkFileExists(path, isString)) return 'addition'; - if (!entry?.sha || entry.symlink) return 'modification'; - - const localSha = await gitBlobSha(await this.getFileContent(fileOrPath)); - if (localSha === entry.sha) return 'unchanged'; - await this.migrateGitLabLegacyBaseline(path, repoPath, entry); - const lastSynced = this.settings.syncMetadata[path]; - if (lastSynced && entry.sha !== lastSynced.lastSyncedSha) return 'conflict'; - return 'modification'; - } - - private async processPullBatch( - files: (TFile | string)[], - onProgress?: (current: number, total: number, fileName: string) => void, - remoteTree?: GitTreeEntry[] - ): Promise<{ success: number; failed: number; conflicts: number; errors: Array<{ file: string; error: string }> }> { - const results = { success: 0, failed: 0, conflicts: 0, errors: [] as Array<{ file: string; error: string }> }; - - // One tree read decides which files actually need downloading; without it - // (a failed fetch) every file falls back to its own content request. - let treeByFullPath: Map | undefined; - try { - const tree = remoteTree ?? await this.gitService.listFilesDetailed(this.settings.branch, false); - treeByFullPath = new Map(tree.map(e => [e.path, e])); - } catch (e) { - logger.warn('Failed to fetch remote tree for pull; falling back to per-file fetches', e); - } - - for (let i = 0; i < files.length; i++) { - const fileOrPath = files[i]; - if (!fileOrPath) continue; - - const { path, name, isString } = this.getFileInfo(fileOrPath); - onProgress?.(i + 1, files.length, name); - - try { - const outcome = await this.processSingleBatchPull(fileOrPath, path, name, isString, treeByFullPath); - if (outcome === 'done') results.success++; - else if (outcome === 'conflict') results.conflicts++; - } catch (e) { - logger.error(`Failed to pull ${path}:`, e); - results.failed++; - results.errors.push({ file: path, error: e instanceof Error ? e.message : String(e) }); - } - } - - await this.saveSettings(); - this.notifyBatchResult('pull', results.success, results.failed, results.conflicts); - - return results; - } - - /** - * Classifies every candidate for a batch push without writing anything - * (except the pre-existing exception: a "real"-mode symlink push, which - * has never gone through conflict detection and is committed immediately - * exactly as before). Returns a `BatchPushPlan` of what's ready to push, - * what needs a move, and what conflicts still need a resolution — plus - * `immediate`, the outcome of anything already written during - * classification. - */ - private async buildBatchPushPlan( - files: (TFile | string)[], - onProgress?: (current: number, total: number, fileName: string) => void, - remoteTree?: GitTreeEntry[] - ): Promise<{ - plan: BatchPushPlan; - immediate: { success: number; failed: number; errors: Array<{ file: string; error: string }>; syncedPaths: Array<{ path: string; sha?: string }> }; - }> { - const plan: BatchPushPlan = { pushes: [], moves: [], conflicts: [], autoSkipped: [] }; - const immediate = { success: 0, failed: 0, errors: [] as Array<{ file: string; error: string }>, syncedPaths: [] as Array<{ path: string; sha?: string }> }; - - const tree = remoteTree ?? await this.gitService.listFilesDetailed(this.settings.branch, false); - const treeByFullPath = new Map(tree.map(e => [e.path, e])); - // Computed once per batch rather than per file: the fallback rename - // scan is only worth running at all when some metadata entry has no - // matching local file. Most files carry a tracked renamedFrom (set live - // by the vault 'rename' handler) or aren't renamed at all, so this - // avoids an Object.keys(syncMetadata) walk for every file in the batch. - const hasOrphans = this.hasOrphanedRenameMetadata(); - - for (let i = 0; i < files.length; i++) { - const fileOrPath = files[i]; - if (!fileOrPath) continue; - - const { path, name, isString } = this.getFileInfo(fileOrPath); - onProgress?.(i + 1, files.length, name); - - try { - const outcome = await this.classifyPushCandidate( - fileOrPath, path, name, isString, treeByFullPath, plan.pushes, plan.moves, hasOrphans, plan.conflicts, plan.autoSkipped - ); - if (outcome === 'done') { - immediate.success++; - // Symlink pushes are committed immediately outside the - // toPush queue, so the new sha isn't known here — the caller - // still gets to mark the path synced, just without a sha update. - immediate.syncedPaths.push({ path }); - } - // 'unchanged'/'conflict'/'queued' don't move any of these - // counters directly: conflicts are recorded into plan.conflicts - // / plan.autoSkipped by classifyPushCandidate itself, and the - // queued outcomes are resolved once the plan is committed. - } catch (e) { - logger.error(`Failed to push ${path}:`, e); - immediate.failed++; - immediate.errors.push({ file: path, error: e instanceof Error ? e.message : String(e) }); - } - } - - return { plan, immediate }; - } - - /** Whether any syncMetadata entry no longer has a matching local file — the only case detectRename's fallback scan can find anything. */ - private hasOrphanedRenameMetadata(): boolean { - for (const trackedPath of Object.keys(this.settings.syncMetadata)) { - const metadata = this.settings.syncMetadata[trackedPath]; - if (!isSyncMetadataAtPath(metadata, trackedPath)) continue; - if (!this.app.vault.getFileByPath(trackedPath)) return true; - } - return false; - } - - /** - * Classifies one file for the batch-push flow using a purely local - * comparison (git blob sha vs. the pre-fetched remote tree's blob sha) — - * no getFile network call. Symlinks are pushed immediately and never - * queued; a confirmed rename is queued into `toMove` so it lands in the - * same commit as everything else; everything else is either resolved - * immediately ('unchanged'/'conflict') or appended to `toPush`. - */ - private async classifyPushCandidate( - fileOrPath: TFile | string, - path: string, - name: string, - isString: boolean, - treeByFullPath: Map, - toPush: ToPushEntry[], - toMove: ToMoveEntry[], - hasOrphans: boolean, - conflicts: BatchPushConflict[], - autoSkipped: SyncPlanEntry[] - ): Promise { - if (!await this.checkFileExists(path, isString)) throw new Error('File no longer exists'); - - // Symbolic link handling: real → push as a symlink (GitHub), skip → ignore. - const symlinkTarget = readLocalSymlinkTarget(this.app, path); - if (symlinkTarget !== null) { - const symlinkOutcome = await this.handleSymlinkPush({ path, name }, symlinkTarget, true); - if (symlinkOutcome.handled) return symlinkOutcome.synced ? 'done' : 'unchanged'; - } - - const content = await this.getFileContent(fileOrPath); - const repoPath = this.getNormalizedPath(path); - - const moveOutcome = await this.classifyAsMoveCandidate(fileOrPath, path, name, isString, content, treeByFullPath, toMove, hasOrphans, autoSkipped); - if (moveOutcome) return moveOutcome; - - let treeEntry = treeByFullPath.get(this.getFullPathForTree(repoPath)); - await this.migrateGitLabLegacyBaseline(path, repoPath, treeEntry); - const revision = await this.refreshGitLabBatchRevision(repoPath, treeEntry); - if (revision) treeEntry = { ...treeEntry!, sha: revision.sha }; - const outcome = await this.classifyAgainstTreeEntry(path, content, treeEntry); - if (outcome === 'conflict') { - conflicts.push({ - path, - name, - repoPath, - localContent: content, - remoteSha: treeEntry!.sha!, - remoteRevision: revision?.revision, - }); - return 'conflict'; - } - if (outcome !== 'queued') return outcome; - - toPush.push({ path, name, repoPath, content, existingSha: treeEntry?.sha, existingRevision: revision?.revision }); - return 'queued'; - } - - /** - * `undefined` means "not a tracked rename" — the caller falls through to - * a normal content classification. A rename-safety conflict (target - * already exists, or the old path changed remotely) is a structural - * block, not a case of "two versions of the same content" — there's - * nothing to arbitrate with keep-local/keep-remote, so it's always left - * alone rather than offered in the interactive resolution modal. - * - * The tracked fast path (`syncMetadata[path]?.renamedFrom`, set live by - * the vault 'rename' handler) is keyed purely by path, not by object - * identity, so it works whether the caller hands in a live `TFile` or - * just its path string — every push entry point must classify a rename - * identically regardless of which one it happens to have on hand (e.g. a - * sync-status row with no cached `TFile` yet falls back to its path). - * Only the content-based fallback scan (`detectRename`, for renames the - * plugin missed tracking live) needs an actual `TFile`, so a string input - * resolves one from the vault first. - */ - private async classifyAsMoveCandidate( - fileOrPath: TFile | string, - path: string, - name: string, - isString: boolean, - content: string | ArrayBuffer, - treeByFullPath: Map, - toMove: ToMoveEntry[], - hasOrphans: boolean, - autoSkipped: SyncPlanEntry[] - ): Promise { - const trackedOldPath = this.settings.syncMetadata[path]?.renamedFrom; - let renamedFrom = trackedOldPath ?? null; - if (!renamedFrom && hasOrphans) { - const file = !isString && fileOrPath instanceof TFile ? fileOrPath : this.app.vault.getFileByPath(path); - if (file) renamedFrom = await this.detectRename(file, content, treeByFullPath); - } - if (!renamedFrom) return undefined; - - const outcome = await this.queueMove(path, name, renamedFrom, content, treeByFullPath, toMove); - if (outcome === 'conflict') autoSkipped.push({ path, name }); - return outcome; - } - - /** - * Decides a confirmed rename's outcome purely from the pre-fetched tree — - * no network call — and queues it for the grouped commit. Mirrors the two - * safety checks handleRename applies to the single-file flow: a target - * that already exists on the remote is never silently overwritten, and an - * old path whose remote content has moved on since the last sync is never - * silently deleted. Both surface as 'conflict' so the batch can't quietly - * clobber either side the way a plain content push already refuses to. - */ - private async queueMove( - path: string, - name: string, - oldPath: string, - content: string | ArrayBuffer, - treeByFullPath: Map, - toMove: ToMoveEntry[] - ): Promise { - const repoPath = this.getNormalizedPath(path); - const oldRepoPath = this.getNormalizedPath(oldPath); - - if (treeByFullPath.get(this.getFullPathForTree(repoPath))) return 'conflict'; - - let oldEntry = treeByFullPath.get(this.getFullPathForTree(oldRepoPath)); - await this.migrateGitLabLegacyBaseline(oldPath, oldRepoPath, oldEntry); - const oldRevision = await this.refreshGitLabBatchRevision(oldRepoPath, oldEntry); - if (oldRevision) oldEntry = { ...oldEntry!, sha: oldRevision.sha }; - const metadata = this.settings.syncMetadata[path] ?? this.settings.syncMetadata[oldPath]; - const safeToDeleteOld = !oldEntry?.sha || !metadata?.lastSyncedSha || oldEntry.sha === metadata.lastSyncedSha; - if (oldEntry?.sha && !safeToDeleteOld) return 'conflict'; - - toMove.push({ path, name, repoPath, oldPath, oldRepoPath, content, oldRevision: oldRevision?.revision }); - return 'queued'; - } - - /** GitLab tree rows expose blob identity but not the commit revision needed - * for optimistic locking. Read it during planning and compare the fresh blob - * again before accepting the action; the stored revision then protects the - * interval between planning and the atomic commit. */ - private async refreshGitLabBatchRevision(repoPath: string, entry: GitTreeEntry | undefined): Promise<{ sha: string; revision?: string } | undefined> { - if (this.settings.serviceType !== 'gitlab' || !entry?.sha) return undefined; - const remote = await this.gitService.getFile(repoPath, this.settings.branch); - return remote.sha ? { sha: remote.sha, revision: remote.revision } : undefined; - } - - /** Migrates a legacy GitLab last_commit_id baseline only when the current - * file endpoint proves it still describes this tree blob. */ - private async migrateGitLabLegacyBaseline(path: string, repoPath: string, entry: GitTreeEntry | undefined): Promise { - const metadata = this.settings.syncMetadata[path]; - if (this.settings.serviceType !== 'gitlab' || !metadata?.lastSyncedSha || !entry?.sha || entry.sha === metadata.lastSyncedSha) return; - const remote = await this.gitService.getFile(repoPath, this.settings.branch); - if (remote.sha === entry.sha && remote.revision === metadata.lastSyncedSha) await this.updateMetadata(path, remote.sha); - } - - /** - * Decides a non-symlink, non-renamed file's outcome purely from a - * pre-fetched tree entry and a locally-computed git blob sha — no network - * call. Split out of classifyPushCandidate to keep both under the - * cognitive-complexity limit. - */ - private async classifyAgainstTreeEntry( - path: string, - content: string | ArrayBuffer, - treeEntry: GitTreeEntry | undefined, - dryRun = false - ): Promise { - // Don't convert a remote symlink into a regular file. - if (treeEntry?.symlink) return 'unchanged'; - - // Skip if already in sync — compared locally, no network round trip. - if (treeEntry?.sha) { - const localSha = await gitBlobSha(content); - if (localSha === treeEntry.sha) { - if (!dryRun) await this.updateMetadata(path, treeEntry.sha); - return 'unchanged'; - } - } - - // Same conflict check as the single-file flow: if the remote has moved on - // from what we last synced, overwriting it here would silently discard - // whatever changed on the remote. Skip it instead of force-pushing so the - // batch action can't quietly clobber changes the way a single push would - // stop and ask about via SyncConflictModal. - const lastSynced = this.settings.syncMetadata[path]; - if (treeEntry?.sha && lastSynced && treeEntry.sha !== lastSynced.lastSyncedSha) { - return 'conflict'; - } - - return 'queued'; - } - - /** - * Path relative to rootPath, matching how each git service's getFullPath - * would resolve `repoPath` — mirrors that logic locally so pre-fetched tree - * entries (always full repo paths) can be looked up without depending on - * each service's protected getFullPath. - */ - private getFullPathForTree(repoPath: string): string { - if (repoPath.startsWith('/')) return repoPath.slice(1); - const rootPath = this.settings.rootPath; - if (!rootPath) return repoPath; - const cleanRoot = rootPath.endsWith('/') ? rootPath : `${rootPath}/`; - if (repoPath.startsWith(cleanRoot)) return repoPath; - return cleanRoot + repoPath; - } - - /** - * Commits every queued file (and every queued move) in one or more - * grouped batch-commit calls. When both are present they're chunked and - * committed together via commitBatch, so a push-all that both edits and - * moves files produces one commit per chunk, not one commit per kind. - */ - private async commitPushBatch(toPush: ToPushEntry[], toMove: ToMoveEntry[], results: PushResults): Promise { - if (toMove.length === 0) { - if (!this.gitService.pushBatch) { - await this.pushSequentialFallback(toPush, results); - return; - } - for (let i = 0; i < toPush.length; i += MAX_BATCH_PUSH_SIZE) { - await this.commitOneChunk(toPush.slice(i, i + MAX_BATCH_PUSH_SIZE), results); - } - return; - } - - if (!this.gitService.commitBatch) { - // Sequential fallback for providers without an atomic multi-file - // commit: each move is its own push-then-delete (mirrors the - // single-file flow), and plain pushes go through the existing - // sequential fallback. - await this.moveSequentialFallback(toMove, results); - await this.pushSequentialFallback(toPush, results); - return; - } - - const combined: Array<{ kind: 'push'; entry: ToPushEntry } | { kind: 'move'; entry: ToMoveEntry }> = [ - ...toPush.map(entry => ({ kind: 'push' as const, entry })), - ...toMove.map(entry => ({ kind: 'move' as const, entry })), - ]; - for (let i = 0; i < combined.length; i += MAX_BATCH_PUSH_SIZE) { - await this.commitCombinedChunk(combined.slice(i, i + MAX_BATCH_PUSH_SIZE), results); - } - } - - /** Provider doesn't support a batch/atomic multi-file commit — fall back to - * the same sequential per-file push used by the single-file flow. */ - private async pushSequentialFallback(toPush: ToPushEntry[], results: PushResults): Promise { - for (const f of toPush) { - try { - const sha = await this.performPush({ path: f.path, name: f.name }, f.content, f.existingSha, f.existingRevision, true); - results.success++; - results.syncedPaths.push({ path: f.path, sha }); - } catch (e) { - results.failed++; - results.errors.push({ file: f.path, error: e instanceof Error ? e.message : String(e) }); - } - } - } - - /** Provider doesn't support commitBatch — each queued move becomes its own push-then-delete commit. */ - private async moveSequentialFallback(toMove: ToMoveEntry[], results: PushResults): Promise { - for (const f of toMove) { - try { - const pushResult = await this.gitService.pushFile(f.repoPath, f.content, this.settings.branch, `Move ${f.oldRepoPath} to ${f.repoPath}`); - const sha = pushResult.sha ?? await gitBlobSha(f.content); - await this.gitService.deleteFile(f.oldRepoPath, this.settings.branch, `Remove ${f.oldRepoPath} (moved to ${f.repoPath})`); - await this.updateMetadata(f.path, sha); - delete this.settings.syncMetadata[f.oldPath]; - results.success++; - results.syncedPaths.push({ path: f.path, sha }); - } catch (e) { - results.failed++; - results.errors.push({ file: f.path, error: e instanceof Error ? e.message : String(e) }); - } - } - } - - private async commitOneChunk(chunk: ToPushEntry[], results: PushResults): Promise { - try { - const commitMessage = `Push ${chunk.length} file(s) from Obsidian`; - const batchResults = await this.gitService.pushBatch!( - chunk.map(f => ({ path: f.repoPath, content: f.content, existedRemotely: !!f.existingSha, revision: f.existingRevision })), - this.settings.branch, - commitMessage - ); - const shaByPath = new Map(batchResults.map(r => [r.path, r.sha])); - for (const f of chunk) { - // GitHub's createCommitOnBranch reports only the commit oid, so - // the provider returns no per-file sha. The content we just - // committed hashes to exactly what the remote now holds, so - // derive it locally — leaving the metadata stale would make the - // next push read the remote as "moved since last sync" and skip - // the file as a conflict. - const sha = shaByPath.get(f.repoPath) ?? await gitBlobSha(f.content); - await this.updateMetadata(f.path, sha); - results.success++; - results.syncedPaths.push({ path: f.path, sha }); - } - } catch (e) { - // Atomic per-provider failure: none of this chunk's files were - // actually written, so every file in it is failed, not dropped. - const message = e instanceof Error ? e.message : String(e); - for (const f of chunk) { - results.failed++; - results.errors.push({ file: f.path, error: message }); - } - } - } - - private combinedChunkCommitMessage(pushCount: number, moveCount: number): string { - if (moveCount === 0) return `Push ${pushCount} file(s) from Obsidian`; - if (pushCount === 0) return `Move ${moveCount} file(s) from Obsidian`; - return `Push ${pushCount} file(s) and move ${moveCount} file(s) from Obsidian`; - } - - /** Commits a chunk mixing plain pushes and moves in one commitBatch call — one commit for the whole chunk regardless of kind. */ - private async commitCombinedChunk( - chunk: Array<{ kind: 'push'; entry: ToPushEntry } | { kind: 'move'; entry: ToMoveEntry }>, - results: PushResults - ): Promise { - const pushEntries = chunk.filter((c): c is { kind: 'push'; entry: ToPushEntry } => c.kind === 'push').map(c => c.entry); - const moveEntries = chunk.filter((c): c is { kind: 'move'; entry: ToMoveEntry } => c.kind === 'move').map(c => c.entry); - - try { - const commitMessage = this.combinedChunkCommitMessage(pushEntries.length, moveEntries.length); - - const batchResults = await this.gitService.commitBatch!( - pushEntries.map(f => ({ path: f.repoPath, content: f.content, existedRemotely: !!f.existingSha, revision: f.existingRevision })), - moveEntries.map(f => ({ oldPath: f.oldRepoPath, newPath: f.repoPath, content: f.content, oldRevision: f.oldRevision })), - this.settings.branch, - commitMessage - ); - const shaByPath = new Map(batchResults.map(r => [r.path, r.sha])); - - for (const f of pushEntries) { - const sha = shaByPath.get(f.repoPath) ?? await gitBlobSha(f.content); - await this.updateMetadata(f.path, sha); - results.success++; - results.syncedPaths.push({ path: f.path, sha }); - } - for (const f of moveEntries) { - const sha = shaByPath.get(f.repoPath) ?? await gitBlobSha(f.content); - await this.updateMetadata(f.path, sha); - delete this.settings.syncMetadata[f.oldPath]; - results.success++; - results.syncedPaths.push({ path: f.path, sha }); - } - } catch (e) { - const message = e instanceof Error ? e.message : String(e); - for (const c of chunk) { - results.failed++; - results.errors.push({ file: c.entry.path, error: message }); - } - } - } - - private notifyBatchResult(op: 'push' | 'pull', success: number, failed: number, conflicts: number): void { - const opName = op === 'push' ? 'Pushed' : 'Pulled'; - if (success > 0) { - new Notice(`${opName} ${success} file(s) to ${this.serviceName}`); - } - if (conflicts > 0) { - new Notice(`Skipped ${conflicts} file(s) with conflicting changes on both sides. Push or pull each one individually to resolve.`, 8000); - } - if (failed > 0) { - new Notice(`Failed to ${op} ${failed} file(s). Check console for details.`); - } - } - - /** - * One summary notification for a resolved batch push, distinguishing a - * plain push from one that also resolved conflicts along the way — e.g. - * "Pushed 19 files in one commit. 1 conflict kept remote. 1 conflict - * skipped." Never more than one Notice per outcome kind, so resolving a - * batch of many conflicts doesn't spam a notification per file. - */ - private notifyPushBatchResult(results: PushResults): void { - if (results.success > 0) { - const commitNote = results.resolvedConflicts > 0 ? ' in one commit' : ''; - new Notice(`Pushed ${results.success} file(s) to ${this.serviceName}${commitNote}.`); - } - if (results.resolvedConflicts > 0) { - new Notice(`Resolved ${results.resolvedConflicts} conflict(s).`); - } - if (results.skippedConflicts > 0) { - new Notice(`Skipped ${results.skippedConflicts} conflict(s).`, 8000); - } - if (results.failed > 0) { - new Notice(`Failed to push ${results.failed} file(s). Check console for details.`); - } - } - - private getFileInfo(fileOrPath: TFile | string) { - const isString = typeof fileOrPath === 'string'; - const path = isString ? fileOrPath : fileOrPath.path; - const name = isString ? path.split('/').pop() || path : fileOrPath.name; - return { path, name, isString }; - } - - private async checkFileExists(path: string, isString: boolean): Promise { - if (isString) { - return await this.app.vault.adapter.exists(path); - } - return !!this.app.vault.getFileByPath(path); - } - - private async getFileContent(fileOrPath: TFile | string): Promise { - const path = typeof fileOrPath === 'string' ? fileOrPath : fileOrPath.path; - const binary = this.isBinary(path); - - if (typeof fileOrPath === 'string') { - return binary - ? await this.app.vault.adapter.readBinary(fileOrPath) - : await this.app.vault.adapter.read(fileOrPath); - } - try { - return binary - ? await this.app.vault.readBinary(fileOrPath) - : await this.app.vault.read(fileOrPath); - } catch (e) { - // Obsidian's cached vault.read can fail for symlinked files (notably - // on mobile); fall back to reading the path directly via the adapter. - logger.warn(`vault.read failed for ${path}; falling back to adapter`, e); - return binary - ? await this.app.vault.adapter.readBinary(path) - : await this.app.vault.adapter.read(path); - } - } - - private async processSingleBatchPull( - fileOrPath: TFile | string, - path: string, - name: string, - isString: boolean, - treeByFullPath?: Map - ): Promise { - const repoPath = this.getNormalizedPath(path); - - if (treeByFullPath) { - const entry = treeByFullPath.get(this.getFullPathForTree(repoPath)); - if (!entry) throw new Error('File not found in remote'); - const decided = await this.classifyPullAgainstTreeEntry(fileOrPath, path, isString, entry); - if (decided) return decided; - } - - const remote = await this.gitService.getFile(repoPath, this.settings.branch); - if (!remote.sha) throw new Error('File not found in remote'); - - const exists = await this.checkFileExists(path, isString); - if (exists) { - const localContent = await this.getFileContent(fileOrPath); - if (this.contentsEqual(localContent, remote.content)) { - await this.updateMetadata(path, remote.sha); - return 'unchanged'; - } - - // Same conflict check as the single-file flow (see processSingleBatchPush). - const lastSynced = this.settings.syncMetadata[path]; - if (lastSynced && !this.isSameBaseline(lastSynced.lastSyncedSha, remote)) { - return 'conflict'; - } - } - - const fileRep = typeof fileOrPath === 'string' ? { path, name } : fileOrPath; - await this.performPull(fileRep, remote.content, remote.sha, true, this.symlinkPullTarget(remote)); - return 'done'; - } - - /** - * The outcome of a pull that's decidable from the tree entry alone, or null - * when the file's content is genuinely needed. Downloading a file only to - * discover it already matches costs one request per file, so an in-sync - * "pull all" would re-fetch the whole vault; the entry's blob sha answers - * that locally, exactly as the push side already does. - */ - private async classifyPullAgainstTreeEntry( - fileOrPath: TFile | string, - path: string, - isString: boolean, - entry: GitTreeEntry - ): Promise { - // A symlink's blob is its target path, and an entry without a sha can't - // be compared — both still need the real fetch. - if (entry.symlink || !entry.sha) return null; - // Nothing local to compare against: it has to be written. - if (!await this.checkFileExists(path, isString)) return null; - - const localSha = await gitBlobSha(await this.getFileContent(fileOrPath)); - if (localSha === entry.sha) { - await this.updateMetadata(path, entry.sha); - return 'unchanged'; - } - - await this.migrateGitLabLegacyBaseline(path, this.getNormalizedPath(path), entry); - - // Same conflict check as the content path below: local differs and the - // remote has moved since we last synced, so pulling would discard one of - // the two changes. - const lastSynced = this.settings.syncMetadata[path]; - if (lastSynced && entry.sha !== lastSynced.lastSyncedSha) return 'conflict'; - - return null; - } -} +/** Historical import path retained as a domain-only compatibility re-export. */ +export { SyncManager } from './sync/SyncManager'; +export type { BatchPushConflict, ConflictResolution, PushResults } from './sync/types'; diff --git a/src/logic/sync/ConflictResolver.ts b/src/logic/sync/ConflictResolver.ts new file mode 100644 index 0000000..1734cb9 --- /dev/null +++ b/src/logic/sync/ConflictResolver.ts @@ -0,0 +1,45 @@ +import type { GitServiceInterface } from '../../services/git-service-interface'; +import type { BatchPushConflict, PushResults } from './types'; +import type { PullExecutor } from './PullExecutor'; + +/** Applies already-decided conflict resolutions against the reviewed remote snapshot. */ +export class ConflictResolver { + constructor( + private readonly getGitService: () => GitServiceInterface, + private readonly getBranch: () => string, + private readonly pullExecutor: PullExecutor, + ) {} + + async findStale(conflicts: readonly BatchPushConflict[]): Promise { + const stale: BatchPushConflict[] = []; + for (const conflict of conflicts) { + const current = await this.getGitService().getFile(conflict.repoPath, this.getBranch()); + if (current.sha !== conflict.remoteSha) stale.push(conflict); + } + return stale; + } + + async applyRemote(conflicts: readonly BatchPushConflict[], results: PushResults): Promise { + for (const conflict of conflicts) { + try { + const blob = await this.getGitService().getBlob(conflict.remoteSha, conflict.repoPath); + const symlinkTarget = blob.isSymlink ? blob.symlinkTarget ?? '' : undefined; + await this.pullExecutor.pull( + { path: conflict.path, name: conflict.name }, + blob.content, + blob.sha, + true, + symlinkTarget, + ); + results.resolvedConflicts += 1; + results.syncedPaths.push({ path: conflict.path, sha: blob.sha }); + } catch (error) { + results.failed += 1; + results.errors.push({ + file: conflict.path, + error: error instanceof Error ? error.message : String(error), + }); + } + } + } +} diff --git a/src/logic/sync/PullCoordinator.ts b/src/logic/sync/PullCoordinator.ts new file mode 100644 index 0000000..59fb332 --- /dev/null +++ b/src/logic/sync/PullCoordinator.ts @@ -0,0 +1,231 @@ +import { TFile } from 'obsidian'; +import type { GitLabFilesPushSettings } from '../../settings'; +import type { GitFile, GitServiceInterface, GitTreeEntry } from '../../services/git-service-interface'; +import { gitBlobSha } from '../../utils/git-blob-sha'; +import { logger } from '../../utils/logger'; +import { contentsEqual, isBinaryPath } from '../../utils/path'; +import type { PullExecutor } from './PullExecutor'; +import type { SyncScanner } from './SyncScanner'; +import { SyncPlanner } from './SyncPlanner'; +import type { PlannedFileAction, SyncPlan, SyncPlanEntry, SyncResult } from './types'; +import { isSyncPlanEmpty } from './types'; + +type BatchOutcome = 'done' | 'unchanged' | 'conflict'; +type PlanKind = 'addition' | 'modification' | 'unchanged' | 'conflict' | 'skip'; + +export interface PullCoordinatorDependencies { + gitService(): GitServiceInterface; + settings: GitLabFilesPushSettings; + scanner: SyncScanner; + executor: PullExecutor; + confirmPlan(plan: SyncPlan): Promise; + updateMetadata(path: string, sha: string): Promise; + migrateBaseline(path: string, repoPath: string, entry: GitTreeEntry | undefined): Promise; + saveSettings(): Promise; + notify(message: string, duration?: number): void; + serviceName(): string; +} + +/** Plans and executes batch pulls without exposing orchestration in the facade. */ +export class PullCoordinator { + private readonly planner = new SyncPlanner(); + + constructor(private readonly dependencies: PullCoordinatorDependencies) {} + + async pullAllFiles( + files: Array, + onProgress?: (current: number, total: number, fileName: string) => void, + remoteTree?: GitTreeEntry[], + ): Promise { + const tree = await this.resolveTree(remoteTree); + const plan = await this.planPullBatch(files, tree); + if (!isSyncPlanEmpty(plan) && !await this.dependencies.confirmPlan(plan)) { + return { success: 0, failed: 0, conflicts: 0, errors: [] }; + } + return this.processBatch(files, onProgress, tree); + } + + async planPullBatch(files: Array, remoteTree?: GitTreeEntry[]): Promise { + const tree = remoteTree ? new Map(remoteTree.map(entry => [entry.path, entry])) : undefined; + const plan: SyncPlan = { additions: [], modifications: [], deletions: [], moves: [] }; + for (const file of files) { + if (!file) continue; + const { path, name, isString } = this.dependencies.scanner.fileInfo(file); + try { + this.addPlanEntry(plan, await this.classifyForPlan(file, path, isString, tree), path, name); + } catch (error) { + logger.warn(`Skipping ${path} from pull plan preview`, error); + } + } + return plan; + } + + private async resolveTree(remoteTree?: GitTreeEntry[]): Promise { + if (remoteTree) return remoteTree; + try { + return await this.dependencies.gitService().listFilesDetailed(this.dependencies.settings.branch, false); + } catch (error) { + logger.warn('Failed to fetch remote tree for pull; falling back to per-file fetches', error); + return undefined; + } + } + + private async processBatch( + files: Array, + onProgress?: (current: number, total: number, fileName: string) => void, + remoteTree?: GitTreeEntry[], + ): Promise { + const results: SyncResult = { success: 0, failed: 0, conflicts: 0, errors: [] }; + const tree = remoteTree ? new Map(remoteTree.map(entry => [entry.path, entry])) : undefined; + for (let index = 0; index < files.length; index += 1) { + const file = files[index]; + if (!file) continue; + const { path, name, isString } = this.dependencies.scanner.fileInfo(file); + onProgress?.(index + 1, files.length, name); + try { + const outcome = await this.processFile(file, path, name, isString, tree); + if (outcome === 'done') results.success += 1; + else if (outcome === 'conflict') results.conflicts += 1; + } catch (error) { + logger.error(`Failed to pull ${path}:`, error); + results.failed += 1; + results.errors.push({ file: path, error: this.errorMessage(error) }); + } + } + await this.dependencies.saveSettings(); + this.notifyResult(results); + return results; + } + + private async classifyForPlan( + file: TFile | string, + path: string, + isString: boolean, + tree?: Map, + ): Promise { + const repoPath = this.dependencies.scanner.toRepoPath(path); + const entry = tree?.get(this.dependencies.scanner.toTreePath(repoPath)); + if (tree && !entry) return 'skip'; + if (!entry?.sha || entry.symlink) return 'modification'; + const decision = await this.planFromTree(file, path, isString, entry); + return this.planKindFor(decision); + } + + private async processFile( + file: TFile | string, + path: string, + name: string, + isString: boolean, + tree?: Map, + ): Promise { + const repoPath = this.dependencies.scanner.toRepoPath(path); + if (tree) { + const entry = tree.get(this.dependencies.scanner.toTreePath(repoPath)); + if (!entry) throw new Error('File not found in remote'); + const outcome = await this.classifyFromTree(file, path, isString, entry); + if (outcome) return outcome; + } + const remote = await this.dependencies.gitService().getFile(repoPath, this.dependencies.settings.branch); + if (!remote.sha) throw new Error('File not found in remote'); + const decision = await this.planFromRemote(file, path, isString, remote); + if (decision.action === 'none') { + await this.dependencies.updateMetadata(path, remote.sha); + return 'unchanged'; + } + if (decision.action === 'resolve-conflict') return 'conflict'; + const target = typeof file === 'string' ? { path, name } : file; + await this.dependencies.executor.pull(target, remote.content, remote.sha, true, this.symlinkTarget(remote)); + return 'done'; + } + + private async classifyFromTree( + file: TFile | string, + path: string, + isString: boolean, + entry: GitTreeEntry, + ): Promise { + if (entry.symlink || !entry.sha) return null; + const decision = await this.planFromTree(file, path, isString, entry); + if (decision.action === 'none') { + await this.dependencies.updateMetadata(path, entry.sha); + return 'unchanged'; + } + return decision.action === 'resolve-conflict' ? 'conflict' : null; + } + + private async planFromTree( + file: TFile | string, + path: string, + isString: boolean, + entry: GitTreeEntry, + ): Promise { + const repoPath = this.dependencies.scanner.toRepoPath(path); + await this.dependencies.migrateBaseline(path, repoPath, entry); + const exists = await this.fileExists(file); + const kind = isBinaryPath(path) ? 'binary' : 'text'; + const localSha = exists ? await gitBlobSha(await this.dependencies.scanner.readContent(file)) : undefined; + return this.planner.planFor('pull', { + local: { path, exists, blobSha: localSha, kind }, + remote: { path, repoPath, exists: true, blobSha: entry.sha, kind }, + base: { blobSha: this.dependencies.settings.syncMetadata[path]?.lastSyncedSha }, + }); + } + + private async planFromRemote( + file: TFile | string, + path: string, + isString: boolean, + remote: GitFile, + ): Promise { + const exists = await this.fileExists(file); + const kind = isBinaryPath(path) ? 'binary' : 'text'; + const localContent = exists ? await this.dependencies.scanner.readContent(file) : undefined; + let localSha: string | undefined; + if (localContent !== undefined) { + localSha = contentsEqual(localContent, remote.content) ? remote.sha : await gitBlobSha(localContent); + } + const baseline = this.dependencies.settings.syncMetadata[path]?.lastSyncedSha; + const blobBaseline = baseline === remote.revision ? remote.sha : baseline; + return this.planner.planFor('pull', { + local: { path, exists, blobSha: localSha, kind }, + remote: { path, repoPath: this.dependencies.scanner.toRepoPath(path), exists: true, blobSha: remote.sha, kind }, + base: { blobSha: blobBaseline }, + }); + } + + private planKindFor(decision: PlannedFileAction): PlanKind { + if (decision.action === 'pull-create') return 'addition'; + if (decision.action === 'pull-overwrite') return 'modification'; + if (decision.action === 'resolve-conflict') return 'conflict'; + if (decision.action === 'none') return 'unchanged'; + return 'skip'; + } + + private fileExists(file: TFile | string): Promise | boolean { + return typeof file === 'string' + ? this.dependencies.scanner.pathExists(file) + : this.dependencies.scanner.indexedFileExists(file.path); + } + + private addPlanEntry(plan: SyncPlan, kind: PlanKind, path: string, name: string): void { + const entry: SyncPlanEntry = { path, name }; + if (kind === 'addition') plan.additions.push(entry); + else if (kind === 'modification') plan.modifications.push(entry); + } + + private symlinkTarget(remote: GitFile): string | undefined { + return remote.isSymlink ? remote.symlinkTarget ?? '' : undefined; + } + + private notifyResult(result: SyncResult): void { + if (result.success > 0) this.dependencies.notify(`Pulled ${result.success} file(s) to ${this.dependencies.serviceName()}`); + if (result.conflicts > 0) { + this.dependencies.notify(`Skipped ${result.conflicts} file(s) with conflicting changes on both sides. Push or pull each one individually to resolve.`, 8000); + } + if (result.failed > 0) this.dependencies.notify(`Failed to pull ${result.failed} file(s). Check console for details.`); + } + + private errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); + } +} diff --git a/src/logic/sync/PullExecutor.ts b/src/logic/sync/PullExecutor.ts new file mode 100644 index 0000000..e2377f4 --- /dev/null +++ b/src/logic/sync/PullExecutor.ts @@ -0,0 +1,57 @@ +import { TFile, type App } from 'obsidian'; +import type { GitLabFilesPushSettings } from '../../settings'; +import { getEffectiveSymlinkHandling } from '../../settings'; +import { createLocalSymlink } from '../../utils/symlink'; +import { ensureParentDirs } from '../../utils/vault-path'; + +export interface PullFileTarget { + path: string; + name: string; +} + +/** Executes one vault-side pull mutation; conflict decisions remain outside. */ +export class PullExecutor { + constructor( + private readonly app: App, + private readonly settings: GitLabFilesPushSettings, + private readonly updateMetadata: (path: string, sha: string) => Promise, + private readonly getServiceName: () => string, + private readonly notify: (message: string) => void = () => undefined, + ) {} + + async pull( + file: TFile | PullFileTarget, + remoteContent: string | ArrayBuffer, + remoteSha: string, + silent = false, + symlinkTarget?: string, + ): Promise { + await ensureParentDirs(this.app.vault.adapter, file.path); + + if (symlinkTarget !== undefined) { + if ( + getEffectiveSymlinkHandling(this.settings) === 'real' + && createLocalSymlink(this.app, file.path, symlinkTarget) + ) { + await this.updateMetadata(file.path, remoteSha); + if (!silent) this.notify(`Pulled symlink ${file.name} from ${this.getServiceName()}`); + return; + } + remoteContent = symlinkTarget; + } + + await this.write(file, remoteContent); + await this.updateMetadata(file.path, remoteSha); + if (!silent) this.notify(`Pulled ${file.name} from ${this.getServiceName()}`); + } + + private async write(file: TFile | PullFileTarget, content: string | ArrayBuffer): Promise { + if (typeof content !== 'string') { + if (file instanceof TFile) await this.app.vault.modifyBinary(file, content); + else await this.app.vault.adapter.writeBinary(file.path, content); + return; + } + if (file instanceof TFile) await this.app.vault.modify(file, content); + else await this.app.vault.adapter.write(file.path, content); + } +} diff --git a/src/logic/sync/PushCoordinator.ts b/src/logic/sync/PushCoordinator.ts new file mode 100644 index 0000000..c5923e8 --- /dev/null +++ b/src/logic/sync/PushCoordinator.ts @@ -0,0 +1,441 @@ +import { App, TFile } from 'obsidian'; +import type { GitServiceInterface, GitTreeEntry } from '../../services/git-service-interface'; +import { getEffectiveSymlinkHandling, isSyncMetadataAtPath, type GitLabFilesPushSettings } from '../../settings'; +import { gitBlobSha } from '../../utils/git-blob-sha'; +import { logger } from '../../utils/logger'; +import { contentsEqual, isBinaryPath } from '../../utils/path'; +import { readLocalSymlinkTarget } from '../../utils/symlink'; +import type { ConflictResolver } from './ConflictResolver'; +import type { PushExecutor } from './PushExecutor'; +import type { SyncScanner } from './SyncScanner'; +import { SyncPlanner } from './SyncPlanner'; +import { + type BatchPushConflict, + type MoveQueueEntry, + type PushQueueEntry, + type PushResults, + type SyncPlan, + type SyncPlanEntry, + isSyncPlanEmpty, +} from './types'; + +type BatchOutcome = 'done' | 'unchanged' | 'conflict'; + +interface BatchPushPlan { + pushes: PushQueueEntry[]; + moves: MoveQueueEntry[]; + conflicts: BatchPushConflict[]; + autoSkipped: SyncPlanEntry[]; +} + +interface PushCoordinatorDependencies { + app: App; + gitService(): GitServiceInterface; + settings: GitLabFilesPushSettings; + scanner: SyncScanner; + executor: PushExecutor; + conflicts: ConflictResolver; + isPathIgnored(path: string): boolean; + confirmPlan(plan: SyncPlan): Promise; + resolveConflicts(conflicts: BatchPushConflict[], totalFiles: number, safeCount: number): Promise; + updateMetadata(path: string, sha: string): Promise; + migrateBaseline(path: string, repoPath: string, entry: GitTreeEntry | undefined): Promise; + saveSettings(): Promise; + notify(message: string, duration?: number): void; + serviceName(): string; +} + +/** Owns the complete batch-push use case while SyncManager remains a compatibility facade. */ +export class PushCoordinator { + private readonly planner = new SyncPlanner(); + + constructor(private readonly dependencies: PushCoordinatorDependencies) {} + + async pushFiles( + files: Array, + onProgress?: (current: number, total: number, fileName: string) => void, + remoteTree?: GitTreeEntry[], + ): Promise { + const syncableFiles = files.filter(file => file && !this.dependencies.isPathIgnored(this.fileInfo(file).path)); + if (syncableFiles.length === 0) return this.emptyResults(); + + const tree = remoteTree ?? await this.dependencies.gitService().listFilesDetailed(this.dependencies.settings.branch, false); + const { plan, immediate } = await this.buildPlan(syncableFiles, onProgress, tree); + const results: PushResults = { + ...this.emptyResults(), + success: immediate.success, + failed: immediate.failed, + conflicts: plan.conflicts.length + plan.autoSkipped.length, + errors: immediate.errors, + syncedPaths: immediate.syncedPaths, + }; + const skipped: BatchPushConflict[] = []; + const keepRemote: BatchPushConflict[] = []; + const keepLocal: BatchPushConflict[] = []; + + if (!await this.resolvePlanConflicts(plan, syncableFiles.length, results, skipped, keepRemote, keepLocal)) { + return results; + } + + const reviewPlan = this.buildReviewPlan(plan, skipped, keepRemote); + if (!isSyncPlanEmpty(reviewPlan) && !await this.dependencies.confirmPlan(reviewPlan)) { + results.cancelled = true; + results.skippedConflicts = skipped.length + plan.autoSkipped.length; + results.conflictedPaths = this.conflictedPaths(plan); + await this.dependencies.saveSettings(); + return results; + } + + await this.commitResolvedBatch(plan.pushes, plan.moves, keepRemote, keepLocal, results); + results.skippedConflicts = skipped.length + plan.autoSkipped.length; + await this.dependencies.saveSettings(); + this.notifyResult(results); + return results; + } + + private emptyResults(): PushResults { + return { success: 0, failed: 0, conflicts: 0, resolvedConflicts: 0, skippedConflicts: 0, errors: [], syncedPaths: [] }; + } + + private async resolvePlanConflicts( + plan: BatchPushPlan, + totalFiles: number, + results: PushResults, + skipped: BatchPushConflict[], + keepRemote: BatchPushConflict[], + keepLocal: BatchPushConflict[], + ): Promise { + if (plan.conflicts.length === 0) return true; + const resolved = await this.dependencies.resolveConflicts( + plan.conflicts, + totalFiles, + plan.pushes.length + plan.moves.length, + ); + if (!resolved) { + results.cancelled = true; + results.conflictedPaths = this.conflictedPaths(plan); + await this.dependencies.saveSettings(); + return false; + } + for (const conflict of plan.conflicts) { + if (conflict.resolution === 'keep-local') { + keepLocal.push(conflict); + plan.pushes.push({ + path: conflict.path, + name: conflict.name, + repoPath: conflict.repoPath, + content: conflict.localContent, + existingSha: conflict.remoteSha, + existingRevision: conflict.remoteRevision, + }); + } else if (conflict.resolution === 'keep-remote') { + keepRemote.push(conflict); + } else { + skipped.push(conflict); + } + } + return true; + } + + private buildReviewPlan( + plan: BatchPushPlan, + skipped: BatchPushConflict[], + keepRemote: BatchPushConflict[], + ): SyncPlan { + return { + additions: plan.pushes.filter(item => !item.existingSha).map(item => ({ path: item.path, name: item.name })), + modifications: plan.pushes.filter(item => item.existingSha).map(item => ({ path: item.path, name: item.name })), + moves: plan.moves.map(item => ({ path: item.path, name: item.name, movedFrom: item.oldPath })), + deletions: [], + acceptedRemote: keepRemote.map(item => ({ path: item.path, name: item.name })), + skippedConflicts: [ + ...skipped.map(item => ({ path: item.path, name: item.name })), + ...plan.autoSkipped, + ], + }; + } + + private conflictedPaths(plan: BatchPushPlan): string[] { + return [...plan.conflicts.map(conflict => conflict.path), ...plan.autoSkipped.map(entry => entry.path)]; + } + + private async commitResolvedBatch( + pushes: PushQueueEntry[], + moves: MoveQueueEntry[], + keepRemote: BatchPushConflict[], + keepLocal: BatchPushConflict[], + results: PushResults, + ): Promise { + const stale = keepLocal.length > 0 ? await this.dependencies.conflicts.findStale(keepLocal) : []; + if (stale.length > 0) { + this.recordStaleFailure(pushes, moves, stale, results); + return; + } + + const hadWork = pushes.length > 0 || moves.length > 0; + const failedBefore = results.failed; + if (hadWork) await this.dependencies.executor.commitBatch(pushes, moves, results); + if (hadWork && results.failed !== failedBefore) return; + + const keepLocalPaths = new Set(keepLocal.map(conflict => conflict.path)); + results.resolvedConflicts += results.syncedPaths.filter(path => keepLocalPaths.has(path.path)).length; + await this.dependencies.conflicts.applyRemote(keepRemote, results); + } + + private recordStaleFailure( + pushes: PushQueueEntry[], + moves: MoveQueueEntry[], + stale: BatchPushConflict[], + results: PushResults, + ): void { + const message = `Remote content changed since you reviewed this conflict (${stale.map(conflict => conflict.path).join(', ')}). Nothing was pushed — resolve the conflict again.`; + for (const item of [...pushes, ...moves]) { + results.failed += 1; + results.errors.push({ file: item.path, error: message }); + } + } + + private async buildPlan( + files: Array, + onProgress: ((current: number, total: number, fileName: string) => void) | undefined, + remoteTree: GitTreeEntry[], + ): Promise<{ + plan: BatchPushPlan; + immediate: { success: number; failed: number; errors: Array<{ file: string; error: string }>; syncedPaths: Array<{ path: string; sha?: string }> }; + }> { + const plan: BatchPushPlan = { pushes: [], moves: [], conflicts: [], autoSkipped: [] }; + const immediate = { success: 0, failed: 0, errors: [] as Array<{ file: string; error: string }>, syncedPaths: [] as Array<{ path: string; sha?: string }> }; + const tree = new Map(remoteTree.map(entry => [entry.path, entry])); + const hasOrphans = this.hasOrphanedRenameMetadata(); + + for (let index = 0; index < files.length; index += 1) { + const file = files[index]; + if (!file) continue; + const info = this.fileInfo(file); + onProgress?.(index + 1, files.length, info.name); + try { + const outcome = await this.classifyCandidate(file, info, tree, plan, hasOrphans); + if (outcome === 'done') { + immediate.success += 1; + immediate.syncedPaths.push({ path: info.path }); + } + } catch (error) { + logger.error(`Failed to push ${info.path}:`, error); + immediate.failed += 1; + immediate.errors.push({ file: info.path, error: this.errorMessage(error) }); + } + } + return { plan, immediate }; + } + + private hasOrphanedRenameMetadata(): boolean { + for (const trackedPath of Object.keys(this.dependencies.settings.syncMetadata)) { + const metadata = this.dependencies.settings.syncMetadata[trackedPath]; + if (isSyncMetadataAtPath(metadata, trackedPath) && !this.dependencies.app.vault.getFileByPath(trackedPath)) return true; + } + return false; + } + + private async classifyCandidate( + file: TFile | string, + info: ReturnType, + tree: Map, + plan: BatchPushPlan, + hasOrphans: boolean, + ): Promise { + if (!await this.fileExists(file)) throw new Error('File no longer exists'); + const symlinkTarget = readLocalSymlinkTarget(this.dependencies.app, info.path); + if (symlinkTarget !== null) { + const outcome = await this.dependencies.executor.pushSymlink( + { path: info.path, name: info.name }, + symlinkTarget, + getEffectiveSymlinkHandling(this.dependencies.settings), + true, + ); + if (outcome.handled) return outcome.synced ? 'done' : 'unchanged'; + } + + const content = await this.dependencies.scanner.readContent(file); + const moveOutcome = await this.classifyMove(file, info, content, tree, plan.moves, hasOrphans, plan.autoSkipped); + if (moveOutcome) return moveOutcome; + + const repoPath = this.dependencies.scanner.toRepoPath(info.path); + let entry = tree.get(this.dependencies.scanner.toTreePath(repoPath)); + await this.dependencies.migrateBaseline(info.path, repoPath, entry); + const revision = await this.refreshGitLabRevision(repoPath, entry); + if (revision) entry = { ...entry!, sha: revision.sha }; + const outcome = await this.classifyAgainstTree(info.path, content, entry); + if (outcome === 'conflict') { + plan.conflicts.push({ + path: info.path, + name: info.name, + repoPath, + localContent: content, + remoteSha: entry!.sha!, + remoteRevision: revision?.revision, + }); + return outcome; + } + if (outcome === 'queued') { + plan.pushes.push({ + path: info.path, + name: info.name, + repoPath, + content, + existingSha: entry?.sha, + existingRevision: revision?.revision, + }); + } + return outcome; + } + + private async classifyMove( + file: TFile | string, + info: ReturnType, + content: string | ArrayBuffer, + tree: Map, + moves: MoveQueueEntry[], + hasOrphans: boolean, + autoSkipped: SyncPlanEntry[], + ): Promise { + let oldPath = this.dependencies.settings.syncMetadata[info.path]?.renamedFrom ?? null; + if (!oldPath && hasOrphans) { + const vaultFile = !info.isString && file instanceof TFile ? file : this.dependencies.app.vault.getFileByPath(info.path); + if (vaultFile) oldPath = await this.detectRename(vaultFile, content, tree); + } + if (!oldPath) return undefined; + const outcome = await this.queueMove(info.path, info.name, oldPath, content, tree, moves); + if (outcome === 'conflict') autoSkipped.push({ path: info.path, name: info.name }); + return outcome; + } + + private async detectRename( + file: TFile, + content: string | ArrayBuffer, + tree?: Map, + ): Promise { + const candidates = Object.keys(this.dependencies.settings.syncMetadata).filter(oldPath => { + const metadata = this.dependencies.settings.syncMetadata[oldPath]; + return oldPath !== file.path && isSyncMetadataAtPath(metadata, oldPath) + && !this.dependencies.app.vault.getFileByPath(oldPath); + }); + if (candidates.length === 0) return null; + + let availableTree = tree; + if (!availableTree) { + try { + const entries = await this.dependencies.gitService().listFilesDetailed(this.dependencies.settings.branch, false); + availableTree = new Map(entries.map(entry => [entry.path, entry])); + } catch (error) { + logger.warn('Failed to fetch remote tree for rename detection; falling back to per-candidate lookups', error); + } + } + const localSha = availableTree ? await gitBlobSha(content) : undefined; + for (const oldPath of candidates) { + const repoPath = this.dependencies.scanner.toRepoPath(oldPath); + const match = this.matchRename(localSha, repoPath, availableTree); + if (match === true) return oldPath; + if (match === false) continue; + const remote = await this.dependencies.gitService().getFile(repoPath, this.dependencies.settings.branch); + if (remote.sha && contentsEqual(content, remote.content)) return oldPath; + } + return null; + } + + private matchRename( + localSha: string | undefined, + repoPath: string, + tree: Map | undefined, + ): boolean | undefined { + if (!tree) return undefined; + const entry = tree.get(this.dependencies.scanner.toTreePath(repoPath)); + if (!entry || entry.symlink) return false; + return entry.sha ? entry.sha === localSha : undefined; + } + + private async queueMove( + path: string, + name: string, + oldPath: string, + content: string | ArrayBuffer, + tree: Map, + moves: MoveQueueEntry[], + ): Promise { + const repoPath = this.dependencies.scanner.toRepoPath(path); + const oldRepoPath = this.dependencies.scanner.toRepoPath(oldPath); + const destination = tree.get(this.dependencies.scanner.toTreePath(repoPath)); + let oldEntry = tree.get(this.dependencies.scanner.toTreePath(oldRepoPath)); + const revision = await this.refreshGitLabRevision(oldRepoPath, oldEntry); + if (revision) oldEntry = { ...oldEntry!, sha: revision.sha }; + const kind = isBinaryPath(path) ? 'binary' : 'text'; + const decision = this.planner.planMove({ + local: { path, exists: true, blobSha: await gitBlobSha(content), kind }, + source: { path: oldPath, repoPath: oldRepoPath, exists: oldEntry !== undefined, blobSha: oldEntry?.sha, kind }, + destination: { path, repoPath, exists: destination !== undefined, blobSha: destination?.sha, kind }, + }); + if (decision.action === 'resolve-conflict') return 'conflict'; + + moves.push({ path, name, repoPath, oldPath, oldRepoPath, content, oldRevision: revision?.revision }); + return 'queued'; + } + + private async refreshGitLabRevision( + repoPath: string, + entry: GitTreeEntry | undefined, + ): Promise<{ sha: string; revision?: string } | undefined> { + if (this.dependencies.settings.serviceType !== 'gitlab' || !entry?.sha) return undefined; + const remote = await this.dependencies.gitService().getFile(repoPath, this.dependencies.settings.branch); + return remote.sha ? { sha: remote.sha, revision: remote.revision } : undefined; + } + + private async classifyAgainstTree( + path: string, + content: string | ArrayBuffer, + entry: GitTreeEntry | undefined, + ): Promise { + if (entry?.symlink) return 'unchanged'; + const localKind = isBinaryPath(path) ? 'binary' : 'text'; + const lastSynced = this.dependencies.settings.syncMetadata[path]; + const decision = this.planner.planFor('push', { + local: { path, exists: true, blobSha: await gitBlobSha(content), kind: localKind }, + remote: { + path, + repoPath: this.dependencies.scanner.toRepoPath(path), + exists: entry !== undefined, + blobSha: entry?.sha, + kind: localKind, + }, + base: { blobSha: lastSynced?.lastSyncedSha }, + }); + if (decision.action === 'none' && entry?.sha) { + await this.dependencies.updateMetadata(path, entry.sha); + return 'unchanged'; + } + if (decision.action === 'resolve-conflict') return 'conflict'; + return 'queued'; + } + + private notifyResult(results: PushResults): void { + if (results.success > 0) { + const commitNote = results.resolvedConflicts > 0 ? ' in one commit' : ''; + this.dependencies.notify(`Pushed ${results.success} file(s) to ${this.dependencies.serviceName()}${commitNote}.`); + } + if (results.resolvedConflicts > 0) this.dependencies.notify(`Resolved ${results.resolvedConflicts} conflict(s).`); + if (results.skippedConflicts > 0) this.dependencies.notify(`Skipped ${results.skippedConflicts} conflict(s).`, 8000); + if (results.failed > 0) this.dependencies.notify(`Failed to push ${results.failed} file(s). Check console for details.`); + } + + private fileInfo(file: TFile | string): ReturnType { + return this.dependencies.scanner.fileInfo(file); + } + + private fileExists(file: TFile | string): Promise | boolean { + return typeof file === 'string' + ? this.dependencies.scanner.pathExists(file) + : this.dependencies.scanner.indexedFileExists(file.path); + } + + private errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); + } +} diff --git a/src/logic/sync/PushExecutor.ts b/src/logic/sync/PushExecutor.ts new file mode 100644 index 0000000..c003568 --- /dev/null +++ b/src/logic/sync/PushExecutor.ts @@ -0,0 +1,202 @@ +import type { GitServiceInterface } from '../../services/git-service-interface'; +import { gitBlobSha } from '../../utils/git-blob-sha'; +import { MAX_BATCH_PUSH_SIZE } from '../../services/git-service-base'; +import type { MoveQueueEntry, PushQueueEntry, PushResults } from './types'; + +export interface PushFileTarget { + path: string; + name: string; +} + +export interface SymlinkPushResult { + handled: boolean; + synced: boolean; + sha?: string; +} + +/** Executes one provider-side push mutation; planning remains outside. */ +export class PushExecutor { + constructor( + private readonly getGitService: () => GitServiceInterface, + private readonly getBranch: () => string, + private readonly toRepoPath: (path: string) => string, + private readonly updateMetadata: (path: string, sha: string) => Promise, + private readonly getServiceName: () => string, + private readonly notify: (message: string) => void = () => undefined, + private readonly clearMovedSource: (path: string) => void = () => undefined, + ) {} + + async push( + file: PushFileTarget, + content: string | ArrayBuffer, + existingSha?: string, + existingRevision?: string, + silent = false, + ): Promise { + const result = await this.getGitService().pushFile( + this.toRepoPath(file.path), + content, + this.getBranch(), + `Update ${file.name} from Obsidian`, + existingSha, + existingRevision, + ); + const sha = result.sha ?? await gitBlobSha(content); + await this.updateMetadata(file.path, sha); + if (!silent) this.notify(`Pushed ${file.name} to ${this.getServiceName()}`); + return sha; + } + + async pushSymlink( + file: PushFileTarget, + target: string, + mode: 'skip' | 'follow' | 'real', + silent = false, + ): Promise { + if (mode === 'skip') { + if (!silent) this.notify(`Skipped symlink ${file.name}.`); + return { handled: true, synced: false }; + } + + const service = this.getGitService(); + if (mode !== 'real' || !service.pushSymlink) return { handled: false, synced: false }; + const result = await service.pushSymlink( + this.toRepoPath(file.path), + target, + this.getBranch(), + `Update ${file.name} from Obsidian`, + ); + if (result.sha) await this.updateMetadata(file.path, result.sha); + if (!silent) this.notify(`Pushed symlink ${file.name} to ${this.getServiceName()}`); + return { handled: true, synced: true, sha: result.sha }; + } + + async commitBatch(toPush: PushQueueEntry[], toMove: MoveQueueEntry[], results: PushResults): Promise { + const service = this.getGitService(); + if (toMove.length === 0) { + if (!service.pushBatch) return this.pushSequentially(toPush, results); + for (let index = 0; index < toPush.length; index += MAX_BATCH_PUSH_SIZE) { + await this.commitPushChunk(toPush.slice(index, index + MAX_BATCH_PUSH_SIZE), results); + } + return; + } + + if (!service.commitBatch) { + await this.moveSequentially(toMove, results); + await this.pushSequentially(toPush, results); + return; + } + + const combined: Array<{ kind: 'push'; entry: PushQueueEntry } | { kind: 'move'; entry: MoveQueueEntry }> = [ + ...toPush.map(entry => ({ kind: 'push' as const, entry })), + ...toMove.map(entry => ({ kind: 'move' as const, entry })), + ]; + for (let index = 0; index < combined.length; index += MAX_BATCH_PUSH_SIZE) { + await this.commitCombinedChunk(combined.slice(index, index + MAX_BATCH_PUSH_SIZE), results); + } + } + + private async pushSequentially(entries: PushQueueEntry[], results: PushResults): Promise { + for (const entry of entries) { + try { + const sha = await this.push(entry, entry.content, entry.existingSha, entry.existingRevision, true); + this.recordSuccess(entry.path, sha, results); + } catch (error) { + this.recordFailure(entry.path, error, results); + } + } + } + + private async moveSequentially(entries: MoveQueueEntry[], results: PushResults): Promise { + for (const entry of entries) { + try { + const service = this.getGitService(); + const pushed = await service.pushFile( + entry.repoPath, entry.content, this.getBranch(), `Move ${entry.oldRepoPath} to ${entry.repoPath}`, + ); + const sha = pushed.sha ?? await gitBlobSha(entry.content); + await service.deleteFile( + entry.oldRepoPath, this.getBranch(), `Remove ${entry.oldRepoPath} (moved to ${entry.repoPath})`, + ); + await this.updateMetadata(entry.path, sha); + this.clearMovedSource(entry.oldPath); + this.recordSuccess(entry.path, sha, results); + } catch (error) { + this.recordFailure(entry.path, error, results); + } + } + } + + private async commitPushChunk(entries: PushQueueEntry[], results: PushResults): Promise { + try { + const batchResults = await this.getGitService().pushBatch!( + entries.map(entry => ({ + path: entry.repoPath, + content: entry.content, + existedRemotely: !!entry.existingSha, + revision: entry.existingRevision, + })), + this.getBranch(), + `Push ${entries.length} file(s) from Obsidian`, + ); + const shaByPath = new Map(batchResults.map(result => [result.path, result.sha])); + for (const entry of entries) { + const sha = shaByPath.get(entry.repoPath) ?? await gitBlobSha(entry.content); + await this.updateMetadata(entry.path, sha); + this.recordSuccess(entry.path, sha, results); + } + } catch (error) { + for (const entry of entries) this.recordFailure(entry.path, error, results); + } + } + + private async commitCombinedChunk( + chunk: Array<{ kind: 'push'; entry: PushQueueEntry } | { kind: 'move'; entry: MoveQueueEntry }>, + results: PushResults, + ): Promise { + const pushes = chunk.filter((item): item is { kind: 'push'; entry: PushQueueEntry } => item.kind === 'push').map(item => item.entry); + const moves = chunk.filter((item): item is { kind: 'move'; entry: MoveQueueEntry } => item.kind === 'move').map(item => item.entry); + try { + const batchResults = await this.getGitService().commitBatch!( + pushes.map(entry => ({ path: entry.repoPath, content: entry.content, existedRemotely: !!entry.existingSha, revision: entry.existingRevision })), + moves.map(entry => ({ oldPath: entry.oldRepoPath, newPath: entry.repoPath, content: entry.content, oldRevision: entry.oldRevision })), + this.getBranch(), + this.combinedCommitMessage(pushes.length, moves.length), + ); + const shaByPath = new Map(batchResults.map(result => [result.path, result.sha])); + for (const entry of pushes) await this.recordCommittedEntry(entry, shaByPath, results); + for (const entry of moves) { + await this.recordCommittedEntry(entry, shaByPath, results); + this.clearMovedSource(entry.oldPath); + } + } catch (error) { + for (const item of chunk) this.recordFailure(item.entry.path, error, results); + } + } + + private async recordCommittedEntry( + entry: PushQueueEntry | MoveQueueEntry, + shaByPath: ReadonlyMap, + results: PushResults, + ): Promise { + const sha = shaByPath.get(entry.repoPath) ?? await gitBlobSha(entry.content); + await this.updateMetadata(entry.path, sha); + this.recordSuccess(entry.path, sha, results); + } + + private combinedCommitMessage(pushCount: number, moveCount: number): string { + if (moveCount === 0) return `Push ${pushCount} file(s) from Obsidian`; + if (pushCount === 0) return `Move ${moveCount} file(s) from Obsidian`; + return `Push ${pushCount} file(s) and move ${moveCount} file(s) from Obsidian`; + } + + private recordSuccess(path: string, sha: string, results: PushResults): void { + results.success += 1; + results.syncedPaths.push({ path, sha }); + } + + private recordFailure(path: string, error: unknown, results: PushResults): void { + results.failed += 1; + results.errors.push({ file: path, error: error instanceof Error ? error.message : String(error) }); + } +} diff --git a/src/logic/sync/RemoteDeleteExecutor.ts b/src/logic/sync/RemoteDeleteExecutor.ts new file mode 100644 index 0000000..765d599 --- /dev/null +++ b/src/logic/sync/RemoteDeleteExecutor.ts @@ -0,0 +1,75 @@ +import type { GitServiceInterface } from '../../services/git-service-interface'; +import { MAX_BATCH_PUSH_SIZE } from '../../services/git-service-base'; + +export interface RemoteDeleteTarget { + path: string; + repoPath: string; +} + +export interface RemoteDeleteResult { + deletedPaths: string[]; + errors: Array<{ path: string; message: string }>; +} + +/** Owns provider mutation and atomic-chunk failure semantics for remote deletion. */ +export class RemoteDeleteExecutor { + constructor( + private readonly gitService: GitServiceInterface, + private readonly branch: string, + private readonly batchSize = MAX_BATCH_PUSH_SIZE, + ) {} + + async execute( + targets: readonly RemoteDeleteTarget[], + onProgress?: (current: number, target: RemoteDeleteTarget) => void, + ): Promise { + if (!this.gitService.deleteBatch) return this.executeSequentially(targets, onProgress); + return this.executeInBatches(targets, onProgress); + } + + private async executeInBatches( + targets: readonly RemoteDeleteTarget[], + onProgress?: (current: number, target: RemoteDeleteTarget) => void, + ): Promise { + const result: RemoteDeleteResult = { deletedPaths: [], errors: [] }; + targets.forEach((target, index) => onProgress?.(index + 1, target)); + + for (let index = 0; index < targets.length; index += this.batchSize) { + const chunk = targets.slice(index, index + this.batchSize); + try { + await this.gitService.deleteBatch!( + chunk.map(target => target.repoPath), + this.branch, + `Delete ${chunk.length} file(s) from Obsidian`, + ); + result.deletedPaths.push(...chunk.map(target => target.path)); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + result.errors.push(...chunk.map(target => ({ path: target.path, message }))); + } + } + return result; + } + + private async executeSequentially( + targets: readonly RemoteDeleteTarget[], + onProgress?: (current: number, target: RemoteDeleteTarget) => void, + ): Promise { + const result: RemoteDeleteResult = { deletedPaths: [], errors: [] }; + let index = 0; + for (const target of targets) { + onProgress?.(index + 1, target); + try { + await this.gitService.deleteFile(target.repoPath, this.branch, `Delete ${target.repoPath}`); + result.deletedPaths.push(target.path); + } catch (error) { + result.errors.push({ + path: target.path, + message: error instanceof Error ? error.message : String(error), + }); + } + index += 1; + } + return result; + } +} diff --git a/src/logic/sync/SyncDiffService.ts b/src/logic/sync/SyncDiffService.ts new file mode 100644 index 0000000..ab362ac --- /dev/null +++ b/src/logic/sync/SyncDiffService.ts @@ -0,0 +1,32 @@ +import type { SyncStatusService } from '../sync-status-service'; +import { isBinaryPath } from '../../utils/path'; +import type { FileDiff } from './types'; + +export type BlobReader = (sha: string, path: string) => Promise<{ content: string | ArrayBuffer }>; + +/** Builds the only diff DTO exposed across the UI/domain boundary. */ +export class SyncDiffService { + constructor( + private readonly statuses: SyncStatusService, + private readonly readBlob: BlobReader, + ) {} + + async getDiff(path: string): Promise { + const status = this.statuses.get(path); + if (!status) throw new Error(`No sync status for ${path}`); + + if (status.remoteContent === undefined && status.remoteSha) { + const blob = await this.readBlob(status.remoteSha, status.movedFrom ?? status.path); + status.remoteContent = blob.content; + } + + let kind: FileDiff['kind'] = isBinaryPath(status.path) ? 'binary' : 'text'; + if (status.isSymlink) kind = 'symlink'; + return { + path: status.path, + localContent: status.localContent, + remoteContent: status.remoteContent, + kind, + }; + } +} diff --git a/src/logic/sync/SyncExecutor.ts b/src/logic/sync/SyncExecutor.ts new file mode 100644 index 0000000..2b5ae38 --- /dev/null +++ b/src/logic/sync/SyncExecutor.ts @@ -0,0 +1,12 @@ +import type { ConflictResolver } from './ConflictResolver'; +import type { PullExecutor } from './PullExecutor'; +import type { PushExecutor } from './PushExecutor'; + +/** Mutation facade shared by SyncManager orchestration. */ +export class SyncExecutor { + constructor( + readonly push: PushExecutor, + readonly pull: PullExecutor, + readonly conflicts: ConflictResolver, + ) {} +} diff --git a/src/logic/sync/SyncInteractionPort.ts b/src/logic/sync/SyncInteractionPort.ts new file mode 100644 index 0000000..07acaaf --- /dev/null +++ b/src/logic/sync/SyncInteractionPort.ts @@ -0,0 +1,31 @@ +import type { GitServiceInterface } from '../../services/git-service-interface'; +import type { BatchPushConflict, SyncPlan } from './types'; + +export type SyncPlanDirection = 'push' | 'pull' | 'delete'; +export type SingleConflictChoice = 'local' | 'remote'; + +/** User interaction required by sync workflows, supplied by the composition layer. */ +export interface SyncInteractionPort { + confirmPlan(plan: SyncPlan, direction: SyncPlanDirection): Promise; + openConflict( + fileName: string, + localContent: string | ArrayBuffer, + remoteContent: string | ArrayBuffer, + onChoose: (choice: SingleConflictChoice) => void, + ): void; + resolveBatchConflicts( + gitService: GitServiceInterface, + conflicts: BatchPushConflict[], + totalFiles: number, + safeCount: number, + ): Promise; + notify(message: string, duration?: number): void; +} + +/** Safe non-visual fallback for tests or embedders that do not provide a UI. */ +export class HeadlessSyncInteraction implements SyncInteractionPort { + confirmPlan(): Promise { return Promise.resolve(true); } + openConflict(): void {} + resolveBatchConflicts(): Promise { return Promise.resolve(false); } + notify(): void {} +} diff --git a/src/logic/sync/SyncManager.ts b/src/logic/sync/SyncManager.ts new file mode 100644 index 0000000..d0a6d44 --- /dev/null +++ b/src/logic/sync/SyncManager.ts @@ -0,0 +1,306 @@ +import { TFile, App } from 'obsidian'; +import { GitServiceInterface, GitTreeEntry } from '../../services/git-service-interface'; +import { GitLabFilesPushSettings, getServiceName } from '../../settings'; +import { + type PushResults, + SyncPlan, + SyncPlanEntry, + isSyncPlanEmpty, +} from './types'; +import { logger } from '../../utils/logger'; +import { contentsEqual, isBinaryPath } from '../../utils/path'; +import { gitBlobSha } from '../../utils/git-blob-sha'; +import { SyncStatusService } from '../sync-status-service'; +import { PushExecutor } from './PushExecutor'; +import { PullExecutor } from './PullExecutor'; +import { SyncMetadataStore } from './SyncMetadataStore'; +import { SyncScanner } from './SyncScanner'; +import { ConflictResolver } from './ConflictResolver'; +import { SyncExecutor } from './SyncExecutor'; +import { PullCoordinator } from './PullCoordinator'; +import { PushCoordinator } from './PushCoordinator'; +import { SyncPlanner } from './SyncPlanner'; +import { + HeadlessSyncInteraction, + type SyncInteractionPort, + type SyncPlanDirection, +} from './SyncInteractionPort'; + +export class SyncManager { + private readonly app: App; + private gitService: GitServiceInterface; + private readonly settings: GitLabFilesPushSettings; + private readonly onSaveSettings?: () => Promise; + private readonly isPathIgnored: (path: string) => boolean; + private readonly executor: SyncExecutor; + private readonly metadataStore: SyncMetadataStore; + private readonly scanner: SyncScanner; + private readonly pullCoordinator: PullCoordinator; + private readonly pushCoordinator: PushCoordinator; + private readonly planner = new SyncPlanner(); + private readonly interaction: SyncInteractionPort; + readonly status: SyncStatusService; + + constructor( + app: App, + gitService: GitServiceInterface, + settings: GitLabFilesPushSettings, + onSaveSettings?: () => Promise, + isPathIgnored: (path: string) => boolean = () => false, + status: SyncStatusService = new SyncStatusService(), + interaction: SyncInteractionPort = new HeadlessSyncInteraction(), + ) { + this.app = app; + this.gitService = gitService; + this.settings = settings; + this.onSaveSettings = onSaveSettings; + this.isPathIgnored = isPathIgnored; + this.status = status; + this.interaction = interaction; + this.metadataStore = new SyncMetadataStore(this.settings, () => this.saveSettings(), this.status); + this.scanner = new SyncScanner(this.app, this.settings); + const pushExecutor = new PushExecutor( + () => this.gitService, + () => this.settings.branch, + path => this.getNormalizedPath(path), + (path, sha) => this.updateMetadata(path, sha), + () => this.serviceName, + message => this.interaction.notify(message), + oldPath => { delete this.settings.syncMetadata[oldPath]; }, + ); + const pullExecutor = new PullExecutor( + this.app, + this.settings, + (path, sha) => this.updateMetadata(path, sha), + () => this.serviceName, + message => this.interaction.notify(message), + ); + const conflictResolver = new ConflictResolver(() => this.gitService, () => this.settings.branch, pullExecutor); + this.executor = new SyncExecutor( + pushExecutor, + pullExecutor, + conflictResolver, + ); + this.pullCoordinator = new PullCoordinator({ + gitService: () => this.gitService, + settings: this.settings, + scanner: this.scanner, + executor: pullExecutor, + confirmPlan: plan => this.confirmPlan(plan, 'pull'), + updateMetadata: (path, sha) => this.updateMetadata(path, sha), + migrateBaseline: (path, repoPath, entry) => this.migrateGitLabLegacyBaseline(path, repoPath, entry), + saveSettings: () => this.saveSettings(), + notify: (message, duration) => this.interaction.notify(message, duration), + serviceName: () => this.serviceName, + }); + this.pushCoordinator = new PushCoordinator({ + app: this.app, + gitService: () => this.gitService, + settings: this.settings, + scanner: this.scanner, + executor: pushExecutor, + conflicts: conflictResolver, + isPathIgnored: path => this.isPathIgnored(path), + confirmPlan: plan => this.confirmPlan(plan, 'push'), + resolveConflicts: (conflicts, totalFiles, safeCount) => ( + this.interaction.resolveBatchConflicts(this.gitService, conflicts, totalFiles, safeCount) + ), + updateMetadata: (path, sha) => this.updateMetadata(path, sha), + migrateBaseline: (path, repoPath, entry) => this.migrateGitLabLegacyBaseline(path, repoPath, entry), + saveSettings: () => this.saveSettings(), + notify: (message, duration) => this.interaction.notify(message, duration), + serviceName: () => this.serviceName, + }); + } + + private get serviceName(): string { + return getServiceName(this.settings); + } + + public async updateMetadata(path: string, sha: string): Promise { + await this.metadataStore.update(path, sha); + } + + /** Drop sync metadata for a path that's been deleted, so it can't be mistaken for a rename source later. */ + public async clearMetadata(path: string): Promise { + await this.metadataStore.clear(path); + } + + /** + * Records a vault 'rename' event so a later push recognizes it as a real + * move — no content probing or remote lookup needed, Obsidian already + * told us the exact old path. A file with no tracked metadata was never + * synced, so there's nothing to carry forward: it's just a new file at a + * new name. + * + * A chain of renames (A→B→C) collapses to a single pending move by always + * recording the still-unpushed remote path, not the most recent hop; and + * renaming back to that path (B→A) cancels the pending move entirely, + * since the file is once again exactly what's on the remote. + */ + public async trackRename(newPath: string, oldPath: string): Promise { + await this.metadataStore.trackRename(newPath, oldPath); + } + + private getNormalizedPath(path: string): string { + return this.scanner.toRepoPath(path); + } + + updateGitService(gitService: GitServiceInterface): void { + this.gitService = gitService; + } + + /** A plan with exactly one entry, for a single-file push/pull's confirm step. */ + private singleEntryPlan(kind: 'addition' | 'modification', path: string, name: string): SyncPlan { + const plan: SyncPlan = { additions: [], modifications: [], deletions: [], moves: [] }; + const entry: SyncPlanEntry = { path, name }; + (kind === 'addition' ? plan.additions : plan.modifications).push(entry); + return plan; + } + + /** + * Shows the plan for review and resolves once the user confirms or + * cancels. A plan with nothing to apply (e.g. every candidate file was + * already in sync or skipped as a conflict) resolves immediately without + * showing anything — there is nothing to review. + */ + private confirmPlan(plan: SyncPlan, direction: SyncPlanDirection): Promise { + if (isSyncPlanEmpty(plan)) return Promise.resolve(true); + return this.interaction.confirmPlan(plan, direction); + } + + private async performPush(file: {path: string, name: string}, content: string | ArrayBuffer, existingSha?: string, existingRevision?: string, silent = false): Promise { + return this.executor.push.push(file, content, existingSha, existingRevision, silent); + } + + /** The symlink target to recreate on pull, or undefined when the remote isn't a symlink. */ + private symlinkPullTarget(remote: { isSymlink?: boolean; symlinkTarget?: string }): string | undefined { + return remote.isSymlink ? remote.symlinkTarget ?? '' : undefined; + } + + async pullFile(fileOrPath: TFile | string) { + const { path, name } = this.getFileInfo(fileOrPath); + const repoPath = this.getNormalizedPath(path); + + try { + const remote = await this.gitService.getFile(repoPath, this.settings.branch); + if (!remote.sha) { + this.interaction.notify(`File ${name} not found on remote.`); + return; + } + + const exists = await this.fileExists(fileOrPath); + const localContent = exists ? await this.getFileContent(fileOrPath) : null; + const lastSynced = this.settings.syncMetadata[path]; + const kind = isBinaryPath(path) ? 'binary' : 'text'; + const baseline = lastSynced?.lastSyncedSha === remote.revision ? remote.sha : lastSynced?.lastSyncedSha; + let localSha: string | undefined; + if (localContent !== null) { + localSha = contentsEqual(localContent, remote.content) ? remote.sha : await gitBlobSha(localContent); + } + const decision = this.planner.planFor('pull', { + local: { + path, + exists, + blobSha: localSha, + kind, + }, + remote: { path, repoPath, exists: true, blobSha: remote.sha, kind }, + base: { blobSha: baseline }, + }); + + if (decision.action === 'none') { + await this.updateMetadata(path, remote.sha); + this.interaction.notify(`${name} is already up to date.`); + return; + } + + if (decision.action === 'resolve-conflict') { + this.interaction.openConflict(name, localContent ?? '', remote.content, (choice) => { + void (async () => { + try { + const fileRep = typeof fileOrPath === 'string' ? { path, name } : fileOrPath; + if (choice === 'local') { + await this.performPush(fileRep, localContent || '', remote.sha, remote.revision); + } else { + await this.performPull(fileRep, remote.content, remote.sha, false, this.symlinkPullTarget(remote)); + } + } catch (e) { + this.handleError(`Failed to resolve conflict for ${name}`, e); + } + })(); + }); + return; + } + + const confirmed = await this.confirmPlan(this.singleEntryPlan(exists ? 'modification' : 'addition', path, name), 'pull'); + if (!confirmed) return; + + const fileRep = typeof fileOrPath === 'string' ? { path, name } : fileOrPath; + await this.performPull(fileRep, remote.content, remote.sha); + } catch (e) { + this.handleError(`Failed to pull ${name} from ${this.serviceName}`, e); + } + } + + private async performPull(file: TFile | {path: string, name: string}, remoteContent: string | ArrayBuffer, remoteSha: string, silent = false, symlinkTarget?: string) { + await this.executor.pull.pull(file, remoteContent, remoteSha, silent, symlinkTarget); + } + + private async saveSettings() { + if (this.onSaveSettings) { + await this.onSaveSettings(); + } + } + + private handleError(message: string, error: unknown): void { + logger.error(message, error); + const detail = error instanceof Error ? error.message : String(error); + this.interaction.notify(`${message}: ${detail}`); + } + + async pushFiles( + files: (TFile | string)[], + onProgress?: (current: number, total: number, fileName: string) => void, + remoteTree?: GitTreeEntry[] + ): Promise { + return this.pushCoordinator.pushFiles(files, onProgress, remoteTree); + } + + async pullAllFiles( + files: (TFile | string)[], + onProgress?: (current: number, total: number, fileName: string) => void, + remoteTree?: GitTreeEntry[] + ): Promise<{ success: number; failed: number; conflicts: number; errors: Array<{ file: string; error: string }> }> { + return this.pullCoordinator.pullAllFiles(files, onProgress, remoteTree); + } + + /** Computes what a pull-all would do, without writing anything, for the plan-review modal. */ + async planPullBatch(files: (TFile | string)[], remoteTree?: GitTreeEntry[]): Promise { + return this.pullCoordinator.planPullBatch(files, remoteTree); + } + + /** Migrates a legacy GitLab last_commit_id baseline only when the current + * file endpoint proves it still describes this tree blob. */ + private async migrateGitLabLegacyBaseline(path: string, repoPath: string, entry: GitTreeEntry | undefined): Promise { + const metadata = this.settings.syncMetadata[path]; + if (this.settings.serviceType !== 'gitlab' || !metadata?.lastSyncedSha || !entry?.sha || entry.sha === metadata.lastSyncedSha) return; + const remote = await this.gitService.getFile(repoPath, this.settings.branch); + if (remote.sha === entry.sha && remote.revision === metadata.lastSyncedSha) await this.updateMetadata(path, remote.sha); + } + + private getFileInfo(fileOrPath: TFile | string) { + return this.scanner.fileInfo(fileOrPath); + } + + private async fileExists(fileOrPath: TFile | string): Promise { + return typeof fileOrPath === 'string' + ? this.scanner.pathExists(fileOrPath) + : this.scanner.indexedFileExists(fileOrPath.path); + } + + private async getFileContent(fileOrPath: TFile | string): Promise { + return this.scanner.readContent(fileOrPath); + } + +} diff --git a/src/logic/sync/SyncMetadataStore.ts b/src/logic/sync/SyncMetadataStore.ts new file mode 100644 index 0000000..ca0ec64 --- /dev/null +++ b/src/logic/sync/SyncMetadataStore.ts @@ -0,0 +1,42 @@ +import type { GitLabFilesPushSettings } from '../../settings'; +import type { SyncStatusService } from '../sync-status-service'; + +/** Owns sync baseline persistence and rename metadata transitions. */ +export class SyncMetadataStore { + constructor( + private readonly settings: GitLabFilesPushSettings, + private readonly save: () => Promise, + private readonly status: SyncStatusService, + ) {} + + async update(path: string, sha: string): Promise { + this.settings.syncMetadata[path] = { + lastSyncedSha: sha, + lastSyncedAt: Date.now(), + lastKnownPath: path, + }; + await this.save(); + this.status.markSynced(path, sha); + } + + async clear(path: string): Promise { + if (!(path in this.settings.syncMetadata)) return; + delete this.settings.syncMetadata[path]; + await this.save(); + } + + async trackRename(newPath: string, oldPath: string): Promise { + const metadata = this.settings.syncMetadata[oldPath]; + if (!metadata) return; + + delete this.settings.syncMetadata[oldPath]; + const remotePath = metadata.renamedFrom ?? oldPath; + this.settings.syncMetadata[newPath] = { + lastSyncedSha: metadata.lastSyncedSha, + lastSyncedAt: metadata.lastSyncedAt, + lastKnownPath: newPath, + ...(newPath === remotePath ? {} : { renamedFrom: remotePath }), + }; + await this.save(); + } +} diff --git a/src/logic/sync/SyncPlanner.ts b/src/logic/sync/SyncPlanner.ts new file mode 100644 index 0000000..17138ce --- /dev/null +++ b/src/logic/sync/SyncPlanner.ts @@ -0,0 +1,99 @@ +import type { + ContentKind, + MoveFacts, + PlannedFileAction, + SyncAction, + SyncClassification, + SyncFacts, + SyncOperation, +} from './types'; + +/** Pure comparison of local, remote, and last-synced snapshots. */ +export class SyncPlanner { + classify(facts: SyncFacts): SyncClassification { + const { local, remote, base } = facts; + if (!local.exists && !remote.exists) return 'synced'; + if (local.exists && !remote.exists) return 'local-only'; + if (!local.exists && remote.exists) return 'remote-only'; + if (local.blobSha === remote.blobSha) return 'synced'; + if (!base.blobSha) return 'conflict'; + + const localChanged = local.blobSha !== base.blobSha; + const remoteChanged = remote.blobSha !== base.blobSha; + if (localChanged && remoteChanged) return 'conflict'; + if (localChanged) return 'local-modified'; + if (remoteChanged) return 'remote-modified'; + return 'synced'; + } + + actionFor(classification: SyncClassification): SyncAction { + switch (classification) { + case 'local-only': return 'push-create'; + case 'local-modified': return 'push-update'; + case 'remote-only': return 'pull-create'; + case 'remote-modified': return 'pull-overwrite'; + case 'conflict': return 'resolve-conflict'; + case 'synced': return 'none'; + } + } + + plan(facts: SyncFacts): PlannedFileAction { + const classification = this.classify(facts); + return { + path: facts.local.path || facts.remote.path, + repoPath: facts.remote.repoPath, + kind: this.contentKind(facts), + classification, + action: this.actionFor(classification), + }; + } + + planFor(operation: SyncOperation, facts: SyncFacts): PlannedFileAction { + const classification = this.classifyForOperation(operation, facts); + return { + path: facts.local.path || facts.remote.path, + repoPath: facts.remote.repoPath, + kind: this.contentKind(facts), + classification, + action: this.actionForOperation(operation, classification), + }; + } + + planMove(facts: MoveFacts): PlannedFileAction { + const destinationOccupied = facts.destination.exists; + const contentClassification: SyncClassification = facts.local.blobSha === facts.source.blobSha + ? 'synced' + : 'local-modified'; + const classification: SyncClassification = destinationOccupied ? 'conflict' : contentClassification; + return { + path: facts.local.path, + repoPath: facts.destination.repoPath, + kind: facts.local.kind, + classification, + action: destinationOccupied ? 'resolve-conflict' : 'move', + }; + } + + private contentKind(facts: SyncFacts): ContentKind { + return facts.local.exists ? facts.local.kind : facts.remote.kind; + } + + private classifyForOperation(operation: SyncOperation, facts: SyncFacts): SyncClassification { + const classification = this.classify(facts); + if (classification !== 'conflict' || facts.base.blobSha) return classification; + return operation === 'push' ? 'local-modified' : 'remote-modified'; + } + + private actionForOperation(operation: SyncOperation, classification: SyncClassification): SyncAction { + if (classification === 'synced') return 'none'; + if (classification === 'conflict') return 'resolve-conflict'; + if (operation === 'push') { + if (classification === 'local-only') return 'push-create'; + if (classification === 'local-modified') return 'push-update'; + return 'resolve-conflict'; + } + if (classification === 'remote-only') return 'pull-create'; + if (classification === 'local-only') return 'none'; + return 'pull-overwrite'; + } +} diff --git a/src/logic/sync/SyncScanner.ts b/src/logic/sync/SyncScanner.ts new file mode 100644 index 0000000..60133c7 --- /dev/null +++ b/src/logic/sync/SyncScanner.ts @@ -0,0 +1,69 @@ +import { TFile, type App } from 'obsidian'; +import type { GitLabFilesPushSettings } from '../../settings'; +import { logger } from '../../utils/logger'; +import { isBinaryPath } from '../../utils/path'; + +export interface ScannedFileInfo { + path: string; + name: string; + isString: boolean; +} + +/** Reads local snapshots and owns vault/repository/tree path mapping. */ +export class SyncScanner { + constructor( + private readonly app: App, + private readonly settings: GitLabFilesPushSettings, + ) {} + + fileInfo(fileOrPath: TFile | string): ScannedFileInfo { + const isString = typeof fileOrPath === 'string'; + const path = isString ? fileOrPath : fileOrPath.path; + const name = isString ? path.split('/').pop() || path : fileOrPath.name; + return { path, name, isString }; + } + + toRepoPath(path: string): string { + if (!this.settings.vaultFolder) return path; + const folderPath = `${this.settings.vaultFolder}/`; + if (path.startsWith(folderPath)) return path.substring(folderPath.length); + return path === this.settings.vaultFolder ? '' : path; + } + + toTreePath(repoPath: string): string { + if (repoPath.startsWith('/')) return repoPath.slice(1); + const rootPath = this.settings.rootPath; + if (!rootPath) return repoPath; + const cleanRoot = rootPath.endsWith('/') ? rootPath : `${rootPath}/`; + return repoPath.startsWith(cleanRoot) ? repoPath : cleanRoot + repoPath; + } + + pathExists(path: string): Promise { + return this.app.vault.adapter.exists(path); + } + + indexedFileExists(path: string): boolean { + return this.app.vault.getFileByPath(path) !== null; + } + + async readContent(fileOrPath: TFile | string): Promise { + const path = typeof fileOrPath === 'string' ? fileOrPath : fileOrPath.path; + const binary = isBinaryPath(path); + if (typeof fileOrPath === 'string') { + return binary + ? this.app.vault.adapter.readBinary(path) + : this.app.vault.adapter.read(path); + } + + try { + return binary + ? await this.app.vault.readBinary(fileOrPath) + : await this.app.vault.read(fileOrPath); + } catch (error) { + logger.warn(`vault.read failed for ${path}; falling back to adapter`, error); + return binary + ? this.app.vault.adapter.readBinary(path) + : this.app.vault.adapter.read(path); + } + } +} diff --git a/src/logic/sync/SyncStatusRefreshService.ts b/src/logic/sync/SyncStatusRefreshService.ts new file mode 100644 index 0000000..59c4a2b --- /dev/null +++ b/src/logic/sync/SyncStatusRefreshService.ts @@ -0,0 +1,501 @@ +import { type App, TFile } from 'obsidian'; +import { getEffectiveSymlinkHandling, isSyncMetadataAtPath, type GitLabFilesPushSettings, type SymlinkHandling } from '../../settings'; +import type { GitignoreManager } from '../gitignore-manager'; +import { type FileStatus, SyncStatusService } from '../sync-status-service'; +import type { GitServiceInterface, GitTreeEntry } from '../../services/git-service-interface'; +import { gitBlobSha } from '../../utils/git-blob-sha'; +import { logger } from '../../utils/logger'; +import { contentsEqual, isBinaryPath } from '../../utils/path'; +import { readLocalSymlinkTarget } from '../../utils/symlink'; +import type { SyncManager } from './SyncManager'; + +export interface SyncStatusRefreshDependencies { + app: App; + settings: () => GitLabFilesPushSettings; + gitService: () => GitServiceInterface; + gitignoreManager: () => GitignoreManager; + syncManager: () => SyncManager; + filterFilesByVaultFolder(files: TFile[]): TFile[]; + filterPathByVaultFolder(path: string): boolean; + getNormalizedPath(path: string): string; + getVaultPath(path: string): string; +} + +export interface SyncStatusRefreshProgress { + current: number; + total: number; +} + +export interface SyncStatusRefreshResult { + localCount: number; + remoteCount: number; + remoteHead?: string; + remoteEntries: GitTreeEntry[]; +} + +interface DiscoveredFiles { + local: TFile[]; + remoteEntries: GitTreeEntry[]; + remoteHead?: string; + remoteMap: Map; + localMap: Set; + allMap: Map; + hiddenLocalPaths: Set; +} + +/** + * Scans local/remote state and projects it into the shared status store. + * It deliberately exposes no rendering or notification concepts. + */ +export class SyncStatusRefreshService { + private static readonly STATUS_CHECK_CONCURRENCY = 8; + + constructor( + private readonly dependencies: SyncStatusRefreshDependencies, + private readonly statuses: SyncStatusService, + ) {} + + async refresh(onProgress?: (progress: SyncStatusRefreshProgress) => void): Promise { + this.statuses.clear(); + const files = await this.discoverFiles(); + this.initializeFileStatuses(files.local); + for (const hiddenPath of files.hiddenLocalPaths) { + this.statuses.set(hiddenPath, { path: hiddenPath, status: 'checking' }); + } + const extra = await this.identifyExtraFiles( + files.remoteMap, + files.localMap, + files.allMap, + this.pendingMoveOldPaths(), + ); + this.addExtraToStatuses(extra); + + const filesToCheck = this.getCheckableFiles(files.local, extra, files.hiddenLocalPaths); + await this.performStatusCheck(filesToCheck, files.remoteMap, onProgress); + await this.reconcileOutOfBandMoves(files.remoteMap); + + return { + localCount: files.local.length + files.hiddenLocalPaths.size, + remoteCount: files.remoteMap.size, + remoteHead: files.remoteHead, + remoteEntries: files.remoteEntries, + }; + } + + async discoverFiles(): Promise { + const { app } = this.dependencies; + const settings = this.dependencies.settings(); + const gitService = this.dependencies.gitService(); + const gitignoreManager = this.dependencies.gitignoreManager(); + const allFiles = app.vault.getFiles(); + let local = this.dependencies.filterFilesByVaultFolder(allFiles); + const remoteHead = await gitService.getBranchHead?.(settings.branch); + const remoteEntries = await gitService.listFilesDetailed(remoteHead ?? settings.branch, false); + + await gitignoreManager.loadGitignores(remoteEntries); + + const remoteMap = new Map(); + const skipSymlinks = getEffectiveSymlinkHandling(settings) === 'skip'; + for (const entry of remoteEntries) { + if (entry.symlink && skipSymlinks) continue; + const normalized = this.getNormalizedRemotePath(entry.path); + if (normalized === null) continue; + + const vaultPath = this.dependencies.getVaultPath(normalized); + if (!gitignoreManager.isIgnored(normalized)) remoteMap.set(vaultPath, entry); + } + + local = local.filter(file => !gitignoreManager.isIgnored(this.dependencies.getNormalizedPath(file.path))); + const hiddenLocalPaths = await this.discoverHiddenLocalFiles(); + const filteredHiddenPaths = new Set( + hiddenLocalPaths + .filter(path => this.dependencies.filterPathByVaultFolder(path)) + .filter(path => !gitignoreManager.isIgnored(this.dependencies.getNormalizedPath(path))), + ); + + return { + local, + remoteEntries, + remoteHead, + remoteMap, + localMap: new Set([...local.map(file => file.path), ...filteredHiddenPaths]), + allMap: new Map(allFiles.map(file => [file.path, file])), + hiddenLocalPaths: filteredHiddenPaths, + }; + } + + getNormalizedRemotePath(remotePath: string): string | null { + const rootPath = this.dependencies.settings().rootPath; + if (!rootPath) return remotePath; + const cleanRoot = rootPath.endsWith('/') ? rootPath : `${rootPath}/`; + if (remotePath.startsWith(cleanRoot)) return remotePath.substring(cleanRoot.length); + return remotePath === rootPath ? '' : null; + } + + async discoverHiddenLocalFiles(): Promise { + const result: string[] = []; + await this.recursiveScan(this.dependencies.settings().vaultFolder || '', result); + return result; + } + + async recursiveScan(folderPath: string, result: string[]): Promise { + try { + const listing = await this.dependencies.app.vault.adapter.list(folderPath); + for (const file of listing.files) { + if (!this.isHidden(file)) continue; + if (readLocalSymlinkTarget(this.dependencies.app, file) !== null || await this.isLocalFile(file)) result.push(file); + } + for (const folder of listing.folders) { + if (folder === '.git' || folder.endsWith('/.git')) continue; + if (readLocalSymlinkTarget(this.dependencies.app, folder) !== null) { + if (this.isHidden(folder)) result.push(folder); + continue; + } + await this.recursiveScan(folder, result); + } + } catch { + // Some Obsidian adapters do not support raw directory listing. + } + } + + async identifyExtraFiles( + remoteMap: Map, + localFilePaths: Set, + allLocalFileMap: Map, + pendingMoveOldPaths: Set = new Set(), + ): Promise> { + const extra: Array = []; + for (const [vaultPath] of remoteMap) { + if (localFilePaths.has(vaultPath) || pendingMoveOldPaths.has(vaultPath)) continue; + + let localFile = allLocalFileMap.get(vaultPath); + if (!localFile) { + const abstractFile = this.dependencies.app.vault.getAbstractFileByPath(vaultPath); + if (abstractFile instanceof TFile) localFile = abstractFile; + } + + if (localFile) extra.push(localFile); + else if (await this.isLocalFile(vaultPath)) extra.push(vaultPath); + else { + this.statuses.set(vaultPath, { + path: vaultPath, + status: this.statuses.classify({ localExists: false, remoteExists: true }), + }); + } + } + return extra; + } + + async reconcileOutOfBandMoves(remoteMap: Map): Promise { + const orphansBySha = this.orphanedMoveSourcesBySha(remoteMap); + if (orphansBySha.size === 0) return; + const candidatesBySha = await this.unsyncedMoveDestinationsBySha(remoteMap, orphansBySha); + + for (const [sha, orphanPaths] of orphansBySha) { + if (orphanPaths.length !== 1) continue; + const newPaths = candidatesBySha.get(sha); + if (!newPaths || newPaths.length !== 1) continue; + const oldPath = orphanPaths[0] as string; + const newPath = newPaths[0] as string; + await this.dependencies.syncManager().trackRename(newPath, oldPath); + this.statuses.delete(oldPath); + await this.refreshFileStatus(newPath, remoteMap.get(newPath)); + } + } + + async performStatusCheck( + filesToCheck: Array, + remoteMap: Map, + onProgress?: (progress: SyncStatusRefreshProgress) => void, + ): Promise { + const total = filesToCheck.length; + let current = 0; + let next = 0; + onProgress?.({ current, total }); + + const worker = async (): Promise => { + while (next < total) { + const file = filesToCheck[next++]; + if (file) { + const path = typeof file === 'string' ? file : file.path; + await this.refreshFileStatus(file, remoteMap.get(path), remoteMap); + } + current += 1; + onProgress?.({ current, total }); + } + }; + + const workerCount = Math.min(SyncStatusRefreshService.STATUS_CHECK_CONCURRENCY, total); + await Promise.all(Array.from({ length: workerCount }, () => worker())); + } + + async handleFileModified(file: TFile): Promise { + const existing = this.statuses.get(file.path); + if (!existing || !['synced', 'modified', 'unsynced', 'moved'].includes(existing.status)) return false; + const localContent = await this.readFileContent(file, isBinaryPath(file.path), false); + let status: FileStatus['status'] = existing.status; + if (existing.status !== 'moved') { + status = existing.remoteSha === undefined + ? this.statuses.classify({ localExists: true, remoteExists: false }) + : this.statuses.classify({ + localExists: true, + remoteExists: true, + contentsEqual: await gitBlobSha(localContent) === existing.remoteSha, + }); + } + this.statuses.set(file.path, { ...existing, status, localContent }); + return true; + } + + handleFileRenamed(file: TFile, oldPath: string): boolean { + const existing = this.statuses.get(oldPath); + if (!existing || existing.status === 'checking') return false; + this.statuses.delete(oldPath); + if (!this.dependencies.filterPathByVaultFolder(file.path)) return true; + + const renamedFrom = this.dependencies.settings().syncMetadata?.[file.path]?.renamedFrom; + if (renamedFrom !== undefined) { + this.statuses.set(file.path, { + file, + path: file.path, + status: this.statuses.classify({ movedFrom: renamedFrom }), + movedFrom: renamedFrom, + remoteSha: existing.remoteSha, + localContent: existing.localContent, + isSymlink: existing.isSymlink, + }); + } else { + this.statuses.set(file.path, { ...existing, file, path: file.path }); + } + return true; + } + + async refreshFileStatus( + fileOrPath: TFile | string, + remoteEntry: GitTreeEntry | undefined, + remoteMap?: Map, + ): Promise { + try { + const path = typeof fileOrPath === 'string' ? fileOrPath : fileOrPath.path; + const renamedFrom = this.dependencies.settings().syncMetadata?.[path]?.renamedFrom; + if (renamedFrom !== undefined) { + await this.refreshMovedFileStatus(fileOrPath, renamedFrom, remoteMap?.get(renamedFrom)); + } else if (remoteEntry === undefined) { + await this.refreshLocalOnlyStatus(fileOrPath); + } else if (remoteEntry.sha !== undefined) { + await this.refreshFileStatusBySha(fileOrPath, remoteEntry); + } else { + await this.refreshFileStatusByContent(fileOrPath); + } + } catch (error) { + const path = typeof fileOrPath === 'string' ? fileOrPath : fileOrPath.path; + logger.warn(`Failed to determine sync status for ${path}`, error); + this.statuses.set(path, { + file: typeof fileOrPath === 'string' ? undefined : fileOrPath, + path, + status: this.statuses.classify({ localExists: true, remoteExists: false }), + }); + } + } + + async refreshFileStatusBySha(fileOrPath: TFile | string, remoteEntry: GitTreeEntry): Promise { + const isStringPath = typeof fileOrPath === 'string'; + const path = isStringPath ? fileOrPath : fileOrPath.path; + const file = isStringPath ? undefined : fileOrPath; + const binary = isBinaryPath(path); + const symlinkMode = getEffectiveSymlinkHandling(this.dependencies.settings()); + const localContent = await this.readLocalContentForSha(fileOrPath, isStringPath, binary, remoteEntry.symlink, symlinkMode); + const status = this.statuses.classify({ + localExists: true, + remoteExists: true, + contentsEqual: await gitBlobSha(localContent) === remoteEntry.sha, + }); + if (status === 'synced' && remoteEntry.sha) { + await this.dependencies.syncManager().updateMetadata(path, remoteEntry.sha); + } + this.statuses.set(path, { + file, + path, + status, + localContent, + remoteSha: remoteEntry.sha, + isSymlink: remoteEntry.symlink, + }); + } + + async refreshFileStatusByContent(fileOrPath: TFile | string): Promise { + const isStringPath = typeof fileOrPath === 'string'; + const path = isStringPath ? fileOrPath : fileOrPath.path; + const file = isStringPath ? undefined : fileOrPath; + const localContent = await this.readFileContent(fileOrPath, isBinaryPath(path), isStringPath); + const remote = await this.dependencies.gitService().getFile( + this.dependencies.getNormalizedPath(path), + this.dependencies.settings().branch, + ); + const status = remote.sha + ? this.statuses.classify({ + localExists: true, + remoteExists: true, + contentsEqual: contentsEqual(localContent, remote.content), + }) + : this.statuses.classify({ localExists: true, remoteExists: false }); + if (status === 'synced' && remote.sha) { + await this.dependencies.syncManager().updateMetadata(path, remote.sha); + } + this.statuses.set(path, { + file, + path, + status, + localContent, + remoteContent: remote.content, + remoteSha: remote.sha, + }); + } + + private isHidden(path: string): boolean { + return path.split('/').some(part => part.startsWith('.')); + } + + private async isLocalFile(vaultPath: string): Promise { + const stat = await this.dependencies.app.vault.adapter.stat(vaultPath); + return stat?.type === 'file'; + } + + private initializeFileStatuses(localFiles: TFile[]): void { + for (const file of localFiles) this.statuses.set(file.path, { file, path: file.path, status: 'checking' }); + } + + private pendingMoveOldPaths(): Set { + const paths = new Set(); + for (const metadata of Object.values(this.dependencies.settings().syncMetadata ?? {})) { + if (metadata.renamedFrom) paths.add(metadata.renamedFrom); + } + return paths; + } + + private orphanedMoveSourcesBySha(remoteMap: Map): Map { + const metadata = this.dependencies.settings().syncMetadata ?? {}; + const orphansBySha = new Map(); + for (const [path, status] of this.statuses) { + if (status.status !== 'remote-only') continue; + const pathMetadata = metadata[path]; + if (!isSyncMetadataAtPath(pathMetadata, path) || pathMetadata.renamedFrom) continue; + const entry = remoteMap.get(path); + if (!entry || entry.symlink || !entry.sha) continue; + const paths = orphansBySha.get(entry.sha) ?? []; + paths.push(path); + orphansBySha.set(entry.sha, paths); + } + return orphansBySha; + } + + private async unsyncedMoveDestinationsBySha( + remoteMap: Map, + orphansBySha: Map, + ): Promise> { + const candidatesBySha = new Map(); + for (const [path, status] of this.statuses) { + if (status.status !== 'unsynced' || status.localContent === undefined || remoteMap.has(path)) continue; + const sha = await gitBlobSha(status.localContent); + if (!orphansBySha.has(sha)) continue; + const paths = candidatesBySha.get(sha) ?? []; + paths.push(path); + candidatesBySha.set(sha, paths); + } + return candidatesBySha; + } + + private addExtraToStatuses(extra: Array): void { + for (const item of extra) { + const path = typeof item === 'string' ? item : item.path; + this.statuses.set(path, { + file: typeof item === 'string' ? undefined : item, + path, + status: 'checking', + }); + } + } + + private getCheckableFiles( + local: TFile[], + extra: Array, + hiddenLocalPaths: Set, + ): Array { + const extraPaths = new Set(extra.map(file => typeof file === 'string' ? file : file.path)); + const hiddenToAdd = [...hiddenLocalPaths].filter(path => !extraPaths.has(path)); + const gitignoreManager = this.dependencies.gitignoreManager(); + return [...local, ...extra, ...hiddenToAdd].filter(file => { + const path = typeof file === 'string' ? file : file.path; + return !gitignoreManager.isIgnored(this.dependencies.getNormalizedPath(path)); + }); + } + + private async refreshMovedFileStatus(fileOrPath: TFile | string, movedFrom: string, sourceEntry?: GitTreeEntry): Promise { + const isStringPath = typeof fileOrPath === 'string'; + const path = isStringPath ? fileOrPath : fileOrPath.path; + const localContent = await this.readFileContent(fileOrPath, isBinaryPath(path), isStringPath); + this.statuses.set(path, { + file: isStringPath ? undefined : fileOrPath, + path, + status: this.statuses.classify({ movedFrom }), + movedFrom, + localContent, + remoteSha: sourceEntry?.sha, + isSymlink: sourceEntry?.symlink, + }); + } + + private async refreshLocalOnlyStatus(fileOrPath: TFile | string): Promise { + const isStringPath = typeof fileOrPath === 'string'; + const path = isStringPath ? fileOrPath : fileOrPath.path; + const localContent = await this.readFileContent(fileOrPath, isBinaryPath(path), isStringPath); + this.statuses.set(path, { + file: isStringPath ? undefined : fileOrPath, + path, + status: this.statuses.classify({ localExists: true, remoteExists: false }), + localContent, + }); + } + + private async readLocalContentForSha( + fileOrPath: TFile | string, + isStringPath: boolean, + binary: boolean, + remoteIsSymlink: boolean, + symlinkMode: SymlinkHandling, + ): Promise { + if (remoteIsSymlink && symlinkMode === 'real') { + const path = typeof fileOrPath === 'string' ? fileOrPath : fileOrPath.path; + const target = readLocalSymlinkTarget(this.dependencies.app, path); + if (target !== null) return target; + } + return this.readFileContent(fileOrPath, binary, isStringPath); + } + + private async readFileContent(fileOrPath: TFile | string, binary: boolean, isStringPath: boolean): Promise { + if (isStringPath) return this.readStringPathContent(fileOrPath as string, binary); + if (!(fileOrPath instanceof TFile)) throw new Error('Expected TFile when isStringPath is false'); + try { + return binary + ? await this.dependencies.app.vault.readBinary(fileOrPath) + : await this.dependencies.app.vault.read(fileOrPath); + } catch (error) { + logger.warn(`vault.read failed for ${fileOrPath.path}; falling back to adapter`, error); + return binary + ? await this.dependencies.app.vault.adapter.readBinary(fileOrPath.path) + : await this.dependencies.app.vault.adapter.read(fileOrPath.path); + } + } + + private async readStringPathContent(path: string, binary: boolean): Promise { + try { + return binary + ? await this.dependencies.app.vault.adapter.readBinary(path) + : await this.dependencies.app.vault.adapter.read(path); + } catch (error) { + const target = readLocalSymlinkTarget(this.dependencies.app, path); + if (target !== null) return target; + throw error; + } + } +} diff --git a/src/logic/sync/SyncWorkspace.ts b/src/logic/sync/SyncWorkspace.ts new file mode 100644 index 0000000..c653717 --- /dev/null +++ b/src/logic/sync/SyncWorkspace.ts @@ -0,0 +1,236 @@ +import { TFile, type App } from 'obsidian'; +import type { GitServiceInterface, GitTreeEntry } from '../../services/git-service-interface'; +import { getServiceName, type GitLabFilesPushSettings } from '../../settings'; +import type { GitignoreManager } from '../gitignore-manager'; +import type { FileStatus, SyncStatusService } from '../sync-status-service'; +import { RemoteDeleteExecutor, type RemoteDeleteResult } from './RemoteDeleteExecutor'; +import { SyncDiffService } from './SyncDiffService'; +import type { SyncManager } from './SyncManager'; +import { + SyncStatusRefreshService, + type SyncStatusRefreshDependencies, + SyncStatusRefreshProgress, + SyncStatusRefreshResult, +} from './SyncStatusRefreshService'; +import type { FileDiff, PushResults, SyncResult } from './types'; +import { ensureParentDirs } from '../../utils/vault-path'; +import { buildRemoteFileUrl } from '../../utils/remote-url'; + +export type SyncProgress = (current: number, total: number, fileName: string) => void; +export type RemoteDeleteProgress = (current: number, path: string) => void; + +export interface SyncWorkspaceInfo { + serviceName: string; + branch: string; + vaultFolder: string; +} + +export interface SyncWorkspace { + getStatuses(): readonly FileStatus[]; + getInfo(): SyncWorkspaceInfo; + getRemoteFileUrl(path: string): string | null; + refresh(onProgress?: (progress: SyncStatusRefreshProgress) => void): Promise; + push(paths: readonly string[], onProgress?: SyncProgress): Promise; + pull(paths: readonly string[], onProgress?: SyncProgress): Promise; + pullOne(path: string): Promise; + deleteRemote(paths: readonly string[], onProgress?: RemoteDeleteProgress): Promise; + deleteLocal(path: string): Promise; + moveLocal(path: string, target: string): Promise; + clearMetadata(path: string): Promise; + trackRename(newPath: string, oldPath: string): Promise; + getDiff(path: string): Promise; +} + +export interface SyncWorkspaceRuntimeDependencies { + manager(): SyncManager; + gitService(): GitServiceInterface; + settings(): GitLabFilesPushSettings; + refreshService: SyncStatusRefreshService; + diffService: SyncDiffService; + normalizePath(path: string): string; + app: App; +} + +interface RemoteTreeSnapshot { + branch: string; + rootPath: string; + head: string; + entries: GitTreeEntry[]; +} + +/** Runtime implementation of the sole domain API consumed by the sync-status UI. */ +export class SyncManagerWorkspace implements SyncWorkspace { + private remoteTreeSnapshot?: RemoteTreeSnapshot; + + constructor(private readonly dependencies: SyncWorkspaceRuntimeDependencies) {} + + getStatuses(): readonly FileStatus[] { + return [...this.dependencies.manager().status.values()]; + } + + getInfo(): SyncWorkspaceInfo { + const settings = this.dependencies.settings(); + return { serviceName: getServiceName(settings), branch: settings.branch, vaultFolder: settings.vaultFolder }; + } + + getRemoteFileUrl(path: string): string | null { + return buildRemoteFileUrl(this.dependencies.settings(), this.dependencies.normalizePath(path)); + } + + async refresh(onProgress?: (progress: SyncStatusRefreshProgress) => void): Promise { + const result = await this.dependencies.refreshService.refresh(onProgress); + const settings = this.dependencies.settings(); + this.remoteTreeSnapshot = result.remoteHead + ? { branch: settings.branch, rootPath: settings.rootPath, head: result.remoteHead, entries: result.remoteEntries } + : undefined; + return result; + } + + async push(paths: readonly string[], onProgress?: SyncProgress): Promise { + const remoteTree = await this.reusableRemoteTree(); + if (!onProgress && !remoteTree) return this.dependencies.manager().pushFiles([...paths]); + return this.dependencies.manager().pushFiles([...paths], onProgress, remoteTree); + } + + async pull(paths: readonly string[], onProgress?: SyncProgress): Promise { + const remoteTree = await this.reusableRemoteTree(); + if (!onProgress && !remoteTree) return this.dependencies.manager().pullAllFiles([...paths]); + return this.dependencies.manager().pullAllFiles([...paths], onProgress, remoteTree); + } + + async pullOne(path: string): Promise { + await this.dependencies.manager().pullFile(path); + } + + async deleteRemote(paths: readonly string[], onProgress?: RemoteDeleteProgress): Promise { + const executor = new RemoteDeleteExecutor( + this.dependencies.gitService(), + this.dependencies.settings().branch, + ); + return executor.execute( + paths.map(path => ({ path, repoPath: this.dependencies.normalizePath(path) })), + (current, target) => onProgress?.(current, target.path), + ); + } + + async deleteLocal(path: string): Promise { + const file = this.dependencies.app.vault.getFileByPath(path); + if (file instanceof TFile) await this.dependencies.app.fileManager.trashFile(file); + else await this.dependencies.app.vault.adapter.remove(path); + await this.clearMetadata(path); + } + + async moveLocal(path: string, target: string): Promise { + await ensureParentDirs(this.dependencies.app.vault.adapter, target); + const file = this.dependencies.app.vault.getFileByPath(path); + if (file instanceof TFile) await this.dependencies.app.fileManager.renameFile(file, target); + else { + await this.dependencies.app.vault.adapter.rename(path, target); + await this.trackRename(target, path); + } + } + + clearMetadata(path: string): Promise { + return this.dependencies.manager().clearMetadata(path); + } + + trackRename(newPath: string, oldPath: string): Promise { + return this.dependencies.manager().trackRename(newPath, oldPath); + } + + getDiff(path: string): Promise { + return this.dependencies.diffService.getDiff(path); + } + + private async reusableRemoteTree(): Promise { + const snapshot = this.remoteTreeSnapshot; + const settings = this.dependencies.settings(); + const gitService = this.dependencies.gitService(); + if (!snapshot || !gitService.getBranchHead + || snapshot.branch !== settings.branch + || snapshot.rootPath !== settings.rootPath) return undefined; + try { + return await gitService.getBranchHead(snapshot.branch) === snapshot.head ? snapshot.entries : undefined; + } catch { + return undefined; + } + } +} + +/** Test-only boundary adapter retained for focused workspace wiring tests. */ +export class BoundarySyncWorkspace implements SyncWorkspace { + constructor( + private readonly getManager: () => SyncManager, + private readonly boundaries: { + refresh(): Promise; + deleteRemote(paths: readonly string[]): Promise; + getDiff(path: string): Promise; + }, + ) {} + + getStatuses(): readonly FileStatus[] { return [...this.getManager().status.values()]; } + getInfo(): SyncWorkspaceInfo { return { serviceName: '', branch: '', vaultFolder: '' }; } + getRemoteFileUrl(): string | null { return null; } + refresh(): Promise { return this.boundaries.refresh(); } + push(paths: readonly string[]): Promise { return this.getManager().pushFiles([...paths]); } + pull(paths: readonly string[]): Promise { return this.getManager().pullAllFiles([...paths]); } + pullOne(path: string): Promise { return this.getManager().pullFile(path); } + deleteRemote(paths: readonly string[]): Promise { return this.boundaries.deleteRemote(paths); } + async deleteLocal(path: string): Promise { await this.getManager().clearMetadata(path); } + moveLocal(path: string, target: string): Promise { return this.getManager().trackRename(target, path); } + clearMetadata(path: string): Promise { return this.getManager().clearMetadata(path); } + trackRename(newPath: string, oldPath: string): Promise { return this.getManager().trackRename(newPath, oldPath); } + getDiff(path: string): Promise { return this.boundaries.getDiff(path); } +} + +export type SyncFile = TFile | string; + +interface SyncRuntimeHost { + settings: GitLabFilesPushSettings; + gitService: GitServiceInterface; + sync: SyncManager; + syncWorkspace?: SyncWorkspace; + syncStatusRefresh?: SyncStatusRefreshService; + gitignoreManager?: GitignoreManager; + filterFilesByVaultFolder?(files: TFile[]): TFile[]; + filterPathByVaultFolder?(path: string): boolean; + getNormalizedPath(path: string): string; + getVaultPath?(path: string): string; +} + +/** Supplies the runtime boundary to lightweight tests and older hosts that do not construct it during startup. */ +export function ensureSyncWorkspaceRuntime( + app: App, + host: SyncRuntimeHost, + statuses: SyncStatusService, +): { workspace: SyncWorkspace; refreshService: SyncStatusRefreshService } { + if (host.syncWorkspace && host.syncStatusRefresh) { + return { workspace: host.syncWorkspace, refreshService: host.syncStatusRefresh }; + } + const fallbackGitignore = { + loadGitignores: () => Promise.resolve(), + isIgnored: () => false, + } as unknown as GitignoreManager; + const refreshDependencies: SyncStatusRefreshDependencies = { + app, + settings: () => host.settings, + gitService: () => host.gitService, + gitignoreManager: () => host.gitignoreManager ?? fallbackGitignore, + syncManager: () => host.sync, + filterFilesByVaultFolder: files => host.filterFilesByVaultFolder?.(files) ?? files, + filterPathByVaultFolder: path => host.filterPathByVaultFolder?.(path) ?? true, + getNormalizedPath: path => host.getNormalizedPath(path), + getVaultPath: path => host.getVaultPath?.(path) ?? path, + }; + const refreshService = new SyncStatusRefreshService(refreshDependencies, statuses); + const workspace = new SyncManagerWorkspace({ + manager: () => host.sync, + gitService: () => host.gitService, + settings: () => host.settings, + refreshService, + diffService: new SyncDiffService(statuses, (sha, path) => host.gitService.getBlob(sha, path)), + normalizePath: path => host.getNormalizedPath(path), + app, + }); + return { workspace, refreshService }; +} diff --git a/src/logic/sync/types.ts b/src/logic/sync/types.ts new file mode 100644 index 0000000..7b87b6e --- /dev/null +++ b/src/logic/sync/types.ts @@ -0,0 +1,150 @@ +export type ContentKind = 'text' | 'binary' | 'symlink'; + +export interface LocalSnapshot { + path: string; + exists: boolean; + blobSha?: string; + kind: ContentKind; +} + +export interface RemoteSnapshot { + /** Vault-relative path exposed to the UI. */ + path: string; + /** Provider repository-relative path used for mutations. */ + repoPath: string; + exists: boolean; + blobSha?: string; + revision?: string; + kind: ContentKind; +} + +export interface BaseSnapshot { + blobSha?: string; + renamedFrom?: string; +} + +export interface SyncFacts { + local: LocalSnapshot; + remote: RemoteSnapshot; + base: BaseSnapshot; +} + +export interface MoveFacts { + local: LocalSnapshot; + source: RemoteSnapshot; + destination: RemoteSnapshot; +} + +export type SyncClassification = + | 'synced' + | 'local-modified' + | 'remote-modified' + | 'local-only' + | 'remote-only' + | 'conflict'; + +export type SyncAction = + | 'none' + | 'move' + | 'push-create' + | 'push-update' + | 'pull-create' + | 'pull-overwrite' + | 'resolve-conflict'; + +export type SyncOperation = 'push' | 'pull'; + +export interface PlannedFileAction { + path: string; + repoPath: string; + kind: ContentKind; + classification: SyncClassification; + action: SyncAction; +} + +/** One file that a reviewed sync operation would touch. */ +export interface SyncPlanEntry { + path: string; + name: string; + movedFrom?: string; +} + +export interface SyncPlan { + additions: SyncPlanEntry[]; + modifications: SyncPlanEntry[]; + deletions: SyncPlanEntry[]; + moves: SyncPlanEntry[]; + acceptedRemote?: SyncPlanEntry[]; + skippedConflicts?: SyncPlanEntry[]; +} + +export interface SyncFailure { + file: string; + error: string; +} + +export interface SyncResult { + success: number; + failed: number; + conflicts: number; + errors: SyncFailure[]; +} + +export interface FileDiff { + path: string; + localContent?: string | ArrayBuffer; + remoteContent?: string | ArrayBuffer; + kind: ContentKind; +} + +export type ConflictResolution = 'keep-local' | 'keep-remote' | 'skip'; + +export interface BatchPushConflict { + path: string; + name: string; + repoPath: string; + localContent: string | ArrayBuffer; + remoteSha: string; + remoteRevision?: string; + resolution?: ConflictResolution; +} + +export interface PushResults { + success: number; + failed: number; + conflicts: number; + resolvedConflicts: number; + skippedConflicts: number; + cancelled?: boolean; + errors: Array<{ file: string; error: string }>; + syncedPaths: Array<{ path: string; sha?: string }>; + conflictedPaths?: string[]; +} + +export interface PushQueueEntry { + path: string; + name: string; + repoPath: string; + content: string | ArrayBuffer; + existingSha?: string; + existingRevision?: string; +} + +export interface MoveQueueEntry { + path: string; + name: string; + repoPath: string; + oldPath: string; + oldRepoPath: string; + content: string | ArrayBuffer; + oldRevision?: string; +} + +export function isSyncPlanEmpty(plan: SyncPlan): boolean { + return plan.additions.length === 0 + && plan.modifications.length === 0 + && plan.deletions.length === 0 + && plan.moves.length === 0 + && !plan.acceptedRemote?.length + && !plan.skippedConflicts?.length; +} diff --git a/src/main.ts b/src/main.ts index b6d9f34..bea24f8 100644 --- a/src/main.ts +++ b/src/main.ts @@ -15,6 +15,10 @@ import { WhatsNewModal } from './ui/WhatsNewModal'; import { CHANGELOG, getUnseenReleases } from './changelog'; import { compareVersions } from './utils/version'; import { t, setLanguageOverride } from './i18n'; +import { ObsidianSyncInteraction } from './ui/ObsidianSyncInteraction'; +import { SyncStatusRefreshService } from './logic/sync/SyncStatusRefreshService'; +import { SyncDiffService } from './logic/sync/SyncDiffService'; +import { SyncManagerWorkspace, type SyncWorkspace } from './logic/sync/SyncWorkspace'; export type ConnectionStatusState = 'checking' | 'connected' | 'disconnected'; @@ -27,6 +31,8 @@ export default class GitLabFilesPush extends Plugin { settings: GitLabFilesPushSettings; gitService: GitServiceInterface; sync: SyncManager; + syncWorkspace: SyncWorkspace; + syncStatusRefresh: SyncStatusRefreshService; gitignoreManager: GitignoreManager; private gitignoreConfigKey = ''; private pushRibbonEl: HTMLElement; @@ -71,7 +77,29 @@ export default class GitLabFilesPush extends Plugin { this.settings, this.saveSettings.bind(this), (path) => this.gitignoreManager.isIgnored(this.getNormalizedPath(path)), + undefined, + new ObsidianSyncInteraction(this.app), ); + this.syncStatusRefresh = new SyncStatusRefreshService({ + app: this.app, + settings: () => this.settings, + gitService: () => this.gitService, + gitignoreManager: () => this.gitignoreManager, + syncManager: () => this.sync, + filterFilesByVaultFolder: files => this.filterFilesByVaultFolder(files), + filterPathByVaultFolder: path => this.filterPathByVaultFolder(path), + getNormalizedPath: path => this.getNormalizedPath(path), + getVaultPath: path => this.getVaultPath(path), + }, this.sync.status); + this.syncWorkspace = new SyncManagerWorkspace({ + manager: () => this.sync, + gitService: () => this.gitService, + settings: () => this.settings, + refreshService: this.syncStatusRefresh, + diffService: new SyncDiffService(this.sync.status, (sha, path) => this.gitService.getBlob(sha, path)), + normalizePath: path => this.getNormalizedPath(path), + app: this.app, + }); this.statusBarEl = this.addStatusBarItem(); this.statusBarEl.addClass('gfs-status-bar-connection'); diff --git a/src/ui/DiffView.ts b/src/ui/DiffView.ts index 3ee2479..1f213b8 100644 --- a/src/ui/DiffView.ts +++ b/src/ui/DiffView.ts @@ -1,6 +1,6 @@ import { ItemView, WorkspaceLeaf } from 'obsidian'; import { renderDiffPanel } from './components/DiffPanel'; -import { type FileStatus } from './types'; +import { type FileDiff } from '../logic/sync/types'; import { t } from '../i18n'; export const SYNC_DIFF_VIEW_TYPE = 'sync-diff-view'; @@ -18,7 +18,7 @@ export class DiffView extends ItemView { private path: string | null = null; private remoteContent?: string | ArrayBuffer; private localContent?: string | ArrayBuffer; - private isSymlink = false; + private kind: FileDiff['kind'] = 'text'; constructor(leaf: WorkspaceLeaf) { super(leaf); @@ -36,11 +36,11 @@ export class DiffView extends ItemView { /** The file currently on screen, so the caller can tell when it goes stale. */ getPath(): string | null { return this.path; } - setDiff(fileStatus: FileStatus): void { - this.path = fileStatus.path; - this.remoteContent = fileStatus.remoteContent; - this.localContent = fileStatus.localContent; - this.isSymlink = fileStatus.isSymlink === true; + setDiff(diff: FileDiff): void { + this.path = diff.path; + this.remoteContent = diff.remoteContent; + this.localContent = diff.localContent; + this.kind = diff.kind; // Obsidian reads the title from getDisplayText(); nudge it to re-read. this.leaf.setViewState({ type: SYNC_DIFF_VIEW_TYPE, active: true }).catch(() => { /* title only */ }); this.render(); @@ -66,7 +66,7 @@ export class DiffView extends ItemView { container.createDiv({ cls: 'ssv-diff-pane-path', text: this.path }); const body = container.createDiv({ cls: 'ssv-diff-pane' }); - if (this.isSymlink) { + if (this.kind === 'symlink') { body.createDiv({ cls: 'ssv-diff-binary', text: t('fileListItem.diff.symlinkChanged') }); return; } diff --git a/src/ui/ObsidianSyncInteraction.ts b/src/ui/ObsidianSyncInteraction.ts new file mode 100644 index 0000000..ac94354 --- /dev/null +++ b/src/ui/ObsidianSyncInteraction.ts @@ -0,0 +1,54 @@ +import { type App, Notice } from 'obsidian'; +import type { GitServiceInterface } from '../services/git-service-interface'; +import { BatchConflictResolutionModal } from './BatchConflictResolutionModal'; +import { SyncConflictModal } from './SyncConflictModal'; +import { SyncPlanModal } from './SyncPlanModal'; +import type { + SingleConflictChoice, + SyncInteractionPort, + SyncPlanDirection, +} from '../logic/sync/SyncInteractionPort'; +import type { BatchPushConflict, SyncPlan } from '../logic/sync/types'; + +/** Obsidian implementation of the domain's user-interaction boundary. */ +export class ObsidianSyncInteraction implements SyncInteractionPort { + constructor(private readonly app: App) {} + + confirmPlan(plan: SyncPlan, direction: SyncPlanDirection): Promise { + return new Promise(resolve => { + new SyncPlanModal(this.app, plan, direction, () => resolve(true), () => resolve(false)).open(); + }); + } + + openConflict( + fileName: string, + localContent: string | ArrayBuffer, + remoteContent: string | ArrayBuffer, + onChoose: (choice: SingleConflictChoice) => void, + ): void { + new SyncConflictModal(this.app, fileName, localContent, remoteContent, onChoose).open(); + } + + resolveBatchConflicts( + gitService: GitServiceInterface, + conflicts: BatchPushConflict[], + totalFiles: number, + safeCount: number, + ): Promise { + return new Promise(resolve => { + new BatchConflictResolutionModal( + this.app, + gitService, + conflicts, + totalFiles, + safeCount, + () => resolve(true), + () => resolve(false), + ).open(); + }); + } + + notify(message: string, duration?: number): void { + new Notice(message, duration); + } +} diff --git a/src/ui/SyncPlanModal.ts b/src/ui/SyncPlanModal.ts index 058c945..e764800 100644 --- a/src/ui/SyncPlanModal.ts +++ b/src/ui/SyncPlanModal.ts @@ -2,8 +2,9 @@ import { App, Modal, setIcon, ButtonComponent } from 'obsidian'; import { t, TranslationKey } from '../i18n'; import { SyncPlan, SyncPlanEntry } from './types'; import { ICONS } from './components/icons'; +import type { SyncPlanDirection } from '../logic/sync/SyncInteractionPort'; -export type SyncPlanDirection = 'push' | 'pull' | 'delete'; +export type { SyncPlanDirection } from '../logic/sync/SyncInteractionPort'; const SECTION_ORDER: Array<{ key: keyof SyncPlan; icon: string; titleKey: TranslationKey; destructive: boolean }> = [ { key: 'additions', icon: ICONS.addition, titleKey: 'syncPlanModal.section.additions', destructive: false }, diff --git a/src/ui/SyncStatusView.ts b/src/ui/SyncStatusView.ts index 84144a9..e4ba558 100644 --- a/src/ui/SyncStatusView.ts +++ b/src/ui/SyncStatusView.ts @@ -1,1669 +1 @@ -import { ItemView, WorkspaceLeaf, TFile, Notice, Platform, debounce, setIcon, setTooltip } from 'obsidian'; -import GitLabFilesPush from '../main'; -import { getServiceName, getEffectiveSymlinkHandling, isSyncMetadataAtPath, type SymlinkHandling } from '../settings'; -import { ConfirmModal } from './ConfirmModal'; -import { SyncPlanModal } from './SyncPlanModal'; -import { logger } from '../utils/logger'; -import { type FileStatus, type FilterValue, type SyncPlan } from './types'; -import { renderActionBar } from './components/ActionBar'; -import { renderFileItem, renderMoveGroupItem, statusMeta, type FileItemCallbacks, type MoveGroupCallbacks } from './components/FileListItem'; -import { renderFolderItem, type FolderTreeItemCallbacks } from './components/FolderTreeItem'; -import { buildStatusTree, type StatusTreeNode } from './components/StatusTree'; -import { ICONS } from './components/icons'; -import { isBinaryPath, contentsEqual } from '../utils/path'; -import { buildRemoteFileUrl } from '../utils/remote-url'; -import { DiffView, SYNC_DIFF_VIEW_TYPE } from './DiffView'; -import { readLocalSymlinkTarget } from '../utils/symlink'; -import { gitBlobSha } from '../utils/git-blob-sha'; -import { ensureParentDirs } from '../utils/vault-path'; -import { type GitTreeEntry } from '../services/git-service-interface'; -import { MAX_BATCH_PUSH_SIZE } from '../services/git-service-base'; -import { t, type TranslationKey } from '../i18n'; -import { type PushResults } from '../logic/sync-manager'; -import { SyncStatusService } from '../logic/sync-status-service'; - -export const SYNC_STATUS_VIEW_TYPE = 'sync-status-view'; - -type RemoteTreeSnapshot = { branch: string; rootPath: string; head: string; entries: GitTreeEntry[] }; - -export class SyncStatusView extends ItemView { - plugin: GitLabFilesPush; - private isRefreshing = false; - private refreshProgress = { current: 0, total: 0 }; - private statusFilter: FilterValue = 'all'; - private treeViewEnabled = true; - private showSyncedInAll = false; - private searchQuery = ''; - private readonly selectedFiles: Set = new Set(); - /** Folders the user collapsed; all other folders start expanded. */ - private readonly collapsedFolders: Set = new Set(); - /** Group keys (see groupKey()) currently expanded to show their member rows. */ - private readonly expandedMoveGroups: Set = new Set(); - private lastSyncTime: number = 0; - private remoteTreeSnapshot?: RemoteTreeSnapshot; - private unsubscribeStatuses?: () => void; - private readonly detachedStatusService = new SyncStatusService(); - private readonly renderStatusChanges = debounce( - () => this.renderView(), - SyncStatusView.RENDER_THROTTLE_MS, - false, - ); - // Persistent containers created once in onOpen(). renderView() rebuilds - // only what's inside them, so the search input (which lives in the header, - // outside both) is never destroyed mid-typing. - private infoEl?: HTMLElement; - private bodyEl?: HTMLElement; - - constructor(leaf: WorkspaceLeaf, plugin: GitLabFilesPush) { - super(leaf); - this.plugin = plugin; - } - - /** The plugin-wide snapshot; the view owns presentation state, not file state. */ - private get fileStatuses(): SyncStatusService { - return this.plugin.sync?.status ?? this.detachedStatusService; - } - - getViewType(): string { return SYNC_STATUS_VIEW_TYPE; } - getDisplayText(): string { return t('syncStatus.viewTitle'); } - getIcon(): string { return 'git-compare'; } - - onOpen(): Promise { - const container = this.containerEl.children[1] as HTMLElement | null; - if (!container) return Promise.resolve(); - container.empty(); - container.addClass('sync-status-view'); - - // The search input must sit outside everything renderView() rebuilds: - // renderView() empties its containers on every interaction (even a - // checkbox tick), and an input destroyed mid-typing loses focus after - // a single character. The info strip still needs re-rendering for its - // last-sync time, so it gets its own slot inside the header. - const headerEl = container.createDiv({ cls: 'ssv-header' }); - this.infoEl = headerEl.createDiv({ cls: 'ssv-info-slot' }); - this.renderSearchBox(headerEl); - this.bodyEl = container.createDiv({ cls: 'ssv-body' }); - this.unsubscribeStatuses = this.fileStatuses.subscribe(() => this.renderStatusChanges()); - - this.renderView(); - return Promise.resolve(); - } - - private renderView(): void { - const infoEl = this.infoEl; - const container = this.bodyEl; - if (!infoEl || !container) return; - - const prevListEl = container.querySelector('.ssv-list'); - const scrollTop = prevListEl?.scrollTop ?? 0; - - infoEl.empty(); - this.renderInfoStrip(infoEl); - - container.empty(); - - this.renderTabs(container); - this.renderActionBarSection(container); - - const listEl = container.createDiv({ cls: 'ssv-list' }); - - if (this.isRefreshing) { - this.renderProgressBar(listEl); - this.renderCheckedFilesDuringRefresh(listEl); - } else if (this.fileStatuses.size === 0) { - listEl.createDiv({ cls: 'ssv-empty', text: t('syncStatus.emptyPrompt') }); - } else { - this.renderFileList(listEl); - } - - listEl.scrollTop = scrollTop; - } - - private renderProgressBar(container: HTMLElement): void { - const { current, total } = this.refreshProgress; - const pct = total > 0 ? Math.round((current / total) * 100) : 0; - const prog = container.createDiv({ cls: 'ssv-progress' }); - prog.createDiv({ - cls: 'ssv-progress-text', - text: total > 0 ? t('syncStatus.progress.checkingWithCount', { current, total, pct }) : t('syncStatus.progress.checking') - }); - const bar = prog.createDiv({ cls: 'ssv-progress-bar' }); - bar.createDiv({ cls: 'ssv-progress-fill' }).setAttr('style', `width: ${pct}%`); - } - - private renderCheckedFilesDuringRefresh(container: HTMLElement): void { - const checked = this.visibleStatuses().filter(s => s.status !== 'checking'); - if (checked.length === 0) return; - const checkedList = container.createDiv({ cls: 'ssv-list-checked' }); - const cb = this.fileItemCallbacks(); - for (const fs of checked) { - renderFileItem(checkedList, fs, this.selectedFiles.has(fs.path), cb); - } - } - - // ── Search filter ────────────────────────────────────────────── - - /** - * Built once from onOpen() and deliberately never re-rendered — see the - * comment there. Applying a filter re-renders the body only, so the - * input's focus and caret position survive typing. - */ - private renderSearchBox(container: HTMLElement): void { - const row = container.createDiv({ cls: 'ssv-search' }); - setIcon(row.createSpan({ cls: 'ssv-search-icon' }), ICONS.search); - - const input = row.createEl('input', { - type: 'text', - cls: 'ssv-search-input', - attr: { placeholder: t('syncStatus.search.placeholder'), spellcheck: 'false' }, - }); - - const clearBtn = row.createEl('button', { cls: 'ssv-search-clear' }); - setIcon(clearBtn, ICONS.clear); - setTooltip(clearBtn, t('syncStatus.search.clear')); - - const apply = (value: string): void => { - const next = value.trim(); - if (next === this.searchQuery) return; - this.searchQuery = next; - this.pruneSelectionToVisible(); - row.toggleClass('has-query', next.length > 0); - this.renderView(); - }; - - // Debounced: every keystroke re-renders the whole list, which is - // noticeable on a large vault. - const applyDebounced = debounce(apply, 150, false); - - input.addEventListener('input', () => applyDebounced(input.value)); - input.addEventListener('keydown', (evt) => { - if (evt.key !== 'Escape' || input.value === '') return; - evt.preventDefault(); - input.value = ''; - apply(''); - }); - clearBtn.addEventListener('click', () => { - input.value = ''; - apply(''); - input.focus(); - }); - } - - /** - * Files matching the search box, before the status tab is applied. - * Case-insensitive substring against the *full* path — not fuzzy, so a - * match is always explainable, and typing a folder prefix filters to that - * folder. - */ - private searchedStatuses(): FileStatus[] { - const all = Array.from(this.fileStatuses.values()); - if (this.searchQuery === '') return all; - const query = this.searchQuery.toLowerCase(); - return all.filter(s => s.path.toLowerCase().includes(query)); - } - - /** The rows actually on screen: search and status tab applied together. */ - private visibleStatuses(): FileStatus[] { - const searched = this.searchedStatuses(); - if (this.statusFilter !== 'all') return searched.filter(s => s.status === this.statusFilter); - if (!this.treeViewEnabled) return this.sortAllStatuses(searched); - return this.showSyncedInAll ? searched : searched.filter(s => s.status !== 'synced'); - } - - /** The legacy flat view keeps completed entries out of the way. */ - private sortAllStatuses(statuses: FileStatus[]): FileStatus[] { - return [...statuses].sort((left, right) => Number(left.status === 'synced') - Number(right.status === 'synced')); - } - - /** - * Keeps the invariant that the selection is always a subset of what's on - * screen, by dropping only the entries the current filter hides. Call it - * after any change to the search or the status tab. - * - * The alternative — letting the selection outlive the filter — puts a - * count on Push/Pull/Delete that the visible rows don't explain. Those - * actions overwrite the remote, overwrite local files, and delete remote - * files irreversibly, so acting on something off-screen is not a risk worth - * trading for the convenience of accumulating a selection across filters. - * Clearing the selection outright is the other extreme, and throws away - * ticks that the new filter would still have shown. - */ - private pruneSelectionToVisible(): void { - const visible = new Set(this.visibleStatuses().map(s => s.path)); - for (const path of this.selectedFiles) { - if (!visible.has(path)) this.selectedFiles.delete(path); - } - } - - // ── Info strip ───────────────────────────────────────────────── - - private renderInfoStrip(container: HTMLElement): void { - const el = container.createDiv({ cls: 'ssv-info' }); - const serviceName = getServiceName(this.plugin.settings); - - el.createSpan({ cls: 'ssv-info-item', text: serviceName }); - - if (!Platform.isMobile) { - el.createSpan({ cls: 'ssv-info-sep', text: '·' }); - const branchItem = el.createSpan({ cls: 'ssv-info-item' }); - setIcon(branchItem.createSpan({ cls: 'ssv-info-icon' }), ICONS.branch); - branchItem.createSpan({ text: ` ${this.plugin.settings.branch}` }); - } - - if (this.plugin.settings.vaultFolder) { - el.createSpan({ cls: 'ssv-info-sep', text: '·' }); - const folderItem = el.createSpan({ cls: 'ssv-info-item' }); - setIcon(folderItem.createSpan({ cls: 'ssv-info-icon' }), ICONS.folder); - folderItem.createSpan({ text: ` ${this.plugin.settings.vaultFolder}` }); - } - - if (this.lastSyncTime > 0) { - el.createSpan({ cls: 'ssv-info-sep', text: '·' }); - el.createSpan({ - cls: 'ssv-info-time', - text: Platform.isMobile - ? new Date(this.lastSyncTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) - : t('syncStatus.lastSync', { time: new Date(this.lastSyncTime).toLocaleTimeString() }) - }); - } - } - - // ── Status filter ──────────────────────────────────────────────── - - private renderTabs(container: HTMLElement): void { - // Counted against the search results, not the whole vault: with a - // filter active the tabs answer "where did my match go?" directly - // (e.g. `modified 0 · remote-only 3`) instead of leaving the user to - // hunt through tabs for a file the search has already found. - const all = this.searchedStatuses(); - const counts: Record = { - all: !this.treeViewEnabled || this.showSyncedInAll ? all.length : all.filter(s => s.status !== 'synced').length, - synced: all.filter(s => s.status === 'synced').length, - modified: all.filter(s => s.status === 'modified').length, - unsynced: all.filter(s => s.status === 'unsynced').length, - 'remote-only': all.filter(s => s.status === 'remote-only').length, - // Rows, not files: a collapsed 40-file folder move counts as 1, same - // as every other tab counting what's actually on screen. - moved: this.movedRowCount(all), - }; - - const tabs: Array<{ value: FilterValue; label: string }> = [ - { value: 'all', label: t('syncStatus.tab.all') }, - { value: 'modified', label: t('syncStatus.tab.modified') }, - { value: 'unsynced', label: t('syncStatus.tab.unsynced') }, - { value: 'remote-only', label: t('syncStatus.tab.remote-only') }, - // Shown only when there's at least one moved row — most vaults - // never see one, and a permanent empty tab would just be clutter. - ...(counts.moved > 0 ? [{ value: 'moved' as const, label: t('syncStatus.tab.moved') }] : []), - { value: 'synced', label: t('syncStatus.tab.synced') }, - ]; - - if (Platform.isMobile) { - this.renderMobileFilter(container, tabs, counts); - return; - } - - const tabsEl = container.createDiv({ cls: 'ssv-tabs' }); - for (const tab of tabs) { - const btn = tabsEl.createEl('button', { - cls: `ssv-tab${this.statusFilter === tab.value ? ' active' : ''}` - }); - // Share the status icon set with the file list so tabs never drift. - if (tab.value !== 'all') { - setIcon(btn.createSpan(), statusMeta(tab.value).icon); - } - btn.createSpan({ cls: 'ssv-tab-label', text: ` ${tab.label}` }); - const count = counts[tab.value]; - if (tab.value === 'all' || count > 0) { - btn.createSpan({ cls: 'ssv-tab-count', text: String(count) }); - } - setTooltip(btn, tab.label); - btn.addEventListener('click', () => this.applyStatusFilter(tab.value)); - } - - } - - private renderMobileFilter( - container: HTMLElement, - tabs: Array<{ value: FilterValue; label: string }>, - counts: Record, - ): void { - const select = container.createEl('select', { - cls: 'ssv-filter-select', - attr: { 'aria-label': t('syncStatus.filterByStatus') }, - }); - for (const tab of tabs) { - select.createEl('option', { - text: `${tab.label} (${counts[tab.value]})`, - value: tab.value, - }); - } - select.value = this.statusFilter; - select.addEventListener('change', () => this.applyStatusFilter(select.value as FilterValue)); - } - - /** Apply a status filter while preserving selections still visible in it. */ - private applyStatusFilter(filter: FilterValue): void { - this.statusFilter = filter; - this.pruneSelectionToVisible(); - this.renderView(); - } - - /** The 'moved' tab count: rows, not files — a collapsed folder-move group is 1 row. */ - private movedRowCount(statuses: FileStatus[]): number { - const groups = this.collapsibleMoveGroups(statuses); - const groupedPaths = new Set(); - for (const group of groups.values()) for (const m of group.members) groupedPaths.add(m.path); - - const ungroupedMoved = statuses.filter(s => s.status === 'moved' && !groupedPaths.has(s.path)).length; - return ungroupedMoved + groups.size; - } - - // ── Action bar ───────────────────────────────────────────────── - - private renderActionBarSection(container: HTMLElement): void { - const visible = this.visibleStatuses(); - const selected = Array.from(this.selectedFiles) - .map(p => this.fileStatuses.get(p)) - .filter(Boolean) as FileStatus[]; - - const allSelected = visible.length > 0 && visible.every(s => this.selectedFiles.has(s.path)); - - renderActionBar(container, { - hasFiles: this.fileStatuses.size > 0, - allSelected, - indeterminate: this.selectedFiles.size > 0 && !allSelected, - canPush: selected.filter(s => s.status === 'modified' || s.status === 'unsynced' || s.status === 'moved').length, - // Moved rows are excluded here too: a bulk Pull on a moved row - // would silently undo the move, so it only has a per-row revert - // action with its own confirm — see FileListItem's onRevertMove. - canPull: selected.filter(s => s.status === 'modified' || s.status === 'remote-only').length, - canDelete: selected.filter(s => s.status !== 'moved').length, - treeViewEnabled: this.treeViewEnabled, - showSynced: this.showSyncedInAll, - }, { - onRefresh: () => void this.refreshAllStatuses(), - onSelectAll: (select) => { - // Symmetric with select, and both act only on what's on screen — - // consistent with the invariant that the selection never holds - // anything the current filter is hiding. - for (const s of visible) { - if (select) this.selectedFiles.add(s.path); - else this.selectedFiles.delete(s.path); - } - this.renderView(); - }, - onPush: () => void this.pushSelected(), - onPull: () => void this.pullSelected(), - onDelete: () => void this.deleteSelected(), - onTreeViewChange: (enabled) => { - this.treeViewEnabled = enabled; - this.pruneSelectionToVisible(); - this.renderView(); - }, - onShowSyncedChange: (show) => { - this.showSyncedInAll = show; - this.pruneSelectionToVisible(); - this.renderView(); - }, - }); - } - - // ── File list ────────────────────────────────────────────────── - - private fileItemCallbacks(): FileItemCallbacks { - return { - onSelect: (path, selected) => { - if (selected) this.selectedFiles.add(path); - else this.selectedFiles.delete(path); - this.renderView(); - }, - onPush: (fs) => void this.runSingleFile(fs, 'push'), - onPull: (fs) => void this.runSingleFile(fs, 'pull'), - onDelete: (fs) => void this.handleLocalDelete(fs), - onExpandDiff: (fs) => this.loadDiffContent(fs), - onOpen: (fs, newLeaf) => this.openFileFromRow(fs, newLeaf), - canOpen: (fs) => this.openTargetFor(fs) !== null, - onOpenDiffPane: (fs) => void this.openDiffPane(fs), - onRevertMove: (fs) => void this.revertMove(fs), - }; - } - - /** - * Undoes a pending move by moving the local file back to where it was - * last synced. Reuses the same trackRename mechanism a real vault rename - * goes through: moving back to the still-unpushed remote path is exactly - * the "rename cancels itself" case, so the pending move disappears. - */ - private async revertMove(fileStatus: FileStatus): Promise { - if (!fileStatus.movedFrom) return; - const confirmed = await this.showConfirmDialog( - t('syncStatus.confirmRevertMove', { from: fileStatus.path, to: fileStatus.movedFrom }) - ); - if (!confirmed) return; - - try { - await ensureParentDirs(this.app.vault.adapter, fileStatus.movedFrom); - const file = fileStatus.file ?? this.app.vault.getFileByPath(fileStatus.path); - if (file instanceof TFile) { - await this.app.fileManager.renameFile(file, fileStatus.movedFrom); - } else { - await this.app.vault.adapter.rename(fileStatus.path, fileStatus.movedFrom); - await this.plugin.sync.trackRename(fileStatus.movedFrom, fileStatus.path); - } - new Notice(t('syncStatus.notice.moveReverted', { path: fileStatus.movedFrom })); - await this.refreshAllStatuses(); - } catch (e) { - new Notice(t('syncStatus.notice.revertFailed', { message: e instanceof Error ? e.message : String(e) })); - } - } - - /** - * Shows a file's diff in its own workspace pane, reusing the one already - * open rather than stacking a pane per file. The first pane goes in a new - * tab and stays wherever the user drags it, since reuse keeps it there. - */ - private async openDiffPane(fileStatus: FileStatus): Promise { - await this.loadDiffContent(fileStatus); - - const existing = this.app.workspace.getLeavesOfType(SYNC_DIFF_VIEW_TYPE)[0]; - const leaf = existing ?? this.app.workspace.getLeaf('tab'); - if (!existing) { - await leaf.setViewState({ type: SYNC_DIFF_VIEW_TYPE, active: true }); - } - - const view = leaf.view; - if (view instanceof DiffView) view.setDiff(fileStatus); - await this.app.workspace.revealLeaf(leaf); - } - - /** - * Closes the diff pane when it's showing a file whose content has just - * changed under it. The pane would otherwise keep displaying the pre-push - * diff while looking perfectly current. - */ - private closeDiffPaneFor(paths: Iterable): void { - const changed = new Set(paths); - for (const leaf of this.app.workspace.getLeavesOfType(SYNC_DIFF_VIEW_TYPE)) { - const view = leaf.view; - const shown = view instanceof DiffView ? view.getPath() : null; - if (shown !== null && changed.has(shown)) leaf.detach(); - } - } - - /** - * Where a row's path points: the vault when there's a local file to open, - * the provider's site when the file only exists on the remote. Null means - * neither is possible, and the caller renders plain text — a link that goes - * nowhere is worse than no link. - */ - private openTargetFor(fileStatus: FileStatus): { kind: 'local'; file: TFile } | { kind: 'remote'; url: string } | null { - if (fileStatus.status === 'remote-only') { - const url = buildRemoteFileUrl(this.plugin.settings, this.plugin.getNormalizedPath(fileStatus.path)); - return url ? { kind: 'remote', url } : null; - } - - // The panel deliberately tracks paths outside Obsidian's file index - // (hidden files, .obsidian/), and those have no TFile to open. They - // don't fall back to the remote link: a local-only file isn't there. - const file = fileStatus.file ?? this.app.vault.getFileByPath(fileStatus.path); - return file instanceof TFile ? { kind: 'local', file } : null; - } - - private openFileFromRow(fileStatus: FileStatus, newLeaf: boolean): boolean { - const target = this.openTargetFor(fileStatus); - if (!target) return false; - - if (target.kind === 'local') { - void this.app.workspace.getLeaf(newLeaf).openFile(target.file); - } else { - window.open(target.url, '_blank'); - } - return true; - } - - /** - * Lazily fetches a modified file's remote content by SHA (Phase 2 of the - * SHA-based refresh) so the diff panel has something to render. Mutates - * the FileStatus object in place, caching the result on the same instance - * held in this.fileStatuses so re-expanding doesn't refetch. - */ - private async loadDiffContent(fileStatus: FileStatus): Promise { - if (fileStatus.remoteContent !== undefined || !fileStatus.remoteSha) return; - try { - const blob = await this.plugin.gitService.getBlob(fileStatus.remoteSha, fileStatus.movedFrom ?? fileStatus.path); - fileStatus.remoteContent = blob.content; - } catch (e) { - logger.warn(`Failed to load diff content for ${fileStatus.path}`, e); - } - } - - private renderFileList(container: HTMLElement): void { - const statuses = this.visibleStatuses(); - - if (statuses.length === 0) { - // With a search active, "no Changed files" would misattribute the - // empty list to the tab when it's the query that matched nothing. - const text = this.searchQuery !== '' - ? t('syncStatus.noFilesForSearch', { query: this.searchQuery }) - : t('syncStatus.noFilesForFilter', { - filter: this.statusFilter === 'all' ? t('syncStatus.tab.all') : statusMeta(this.statusFilter).label - }); - container.createDiv({ cls: 'ssv-empty', text }); - return; - } - - if (this.treeViewEnabled) this.renderTreeNodes(container, buildStatusTree(statuses).children); - else this.renderFlatFileList(container, statuses); - } - - private renderFlatFileList(container: HTMLElement, statuses: FileStatus[]): void { - const groups = this.collapsibleMoveGroups(statuses); - const groupedPaths = new Set(); - for (const group of groups.values()) for (const member of group.members) groupedPaths.add(member.path); - - const callbacks = this.fileItemCallbacks(); - const renderedGroups = new Set(); - for (const status of statuses) { - if (groupedPaths.has(status.path)) this.renderGroupedRowOnce(container, status, groups, renderedGroups); - else renderFileItem(container, status, this.selectedFiles.has(status.path), callbacks); - } - } - - private renderTreeNodes(container: HTMLElement, nodes: StatusTreeNode[]): void { - const fileCallbacks = this.fileItemCallbacks(); - const folderCallbacks = this.folderItemCallbacks(); - for (const node of nodes) { - if (node.kind === 'file') { - renderFileItem(container, node.status, this.selectedFiles.has(node.status.path), fileCallbacks); - continue; - } - const children = renderFolderItem( - container, node, this.selectedFiles, !this.collapsedFolders.has(node.path), folderCallbacks, - ); - if (children) this.renderTreeNodes(children, node.children); - } - } - - private folderItemCallbacks(): FolderTreeItemCallbacks { - return { - onSelect: (paths, selected) => { - for (const path of paths) { - if (selected) this.selectedFiles.add(path); - else this.selectedFiles.delete(path); - } - this.renderView(); - }, - onToggle: (path) => { - if (this.collapsedFolders.has(path)) this.collapsedFolders.delete(path); - else this.collapsedFolders.add(path); - this.renderView(); - }, - }; - } - - /** Renders a collapsed folder-move row the first time one of its members is reached, then skips its later members. */ - private renderGroupedRowOnce( - container: HTMLElement, - fs: FileStatus, - groups: Map, - renderedGroups: Set - ): void { - const key = this.groupKey(fs); - if (key === null || renderedGroups.has(key)) return; - renderedGroups.add(key); - - const group = groups.get(key); - if (!group) return; - renderMoveGroupItem( - container, key, group.oldPrefix, group.newPrefix, group.members, - group.members.every(m => this.selectedFiles.has(m.path)), - this.expandedMoveGroups.has(key), - this.moveGroupCallbacks() - ); - } - - // ── Folder-move collapsing (#67) ──────────────────────────── - - /** - * The (oldPrefix, newPrefix) pair a moved file belongs to, derived by - * matching path segments from the end: everything that still matches - * between the old and new path is the unchanged relative suffix, and - * whatever differs before that is the folder that moved. This generalizes - * to any nesting depth without needing to know the folder move's actual - * boundary up front — a file whose own name also changed (not just its - * folder) simply gets a prefix pair unique to itself, so it naturally - * never groups with anything else. JSON-encoded so the pair round-trips - * exactly through a Map key regardless of what characters the paths - * themselves contain. - */ - private groupKey(fs: FileStatus): string | null { - const prefixes = this.groupPrefixes(fs); - return prefixes && JSON.stringify(prefixes); - } - - private groupPrefixes(fs: FileStatus): { oldPrefix: string; newPrefix: string } | null { - if (!fs.movedFrom) return null; - const oldSegs = fs.movedFrom.split('/'); - const newSegs = fs.path.split('/'); - let i = oldSegs.length - 1; - let j = newSegs.length - 1; - while (i >= 1 && j >= 1 && oldSegs[i] === newSegs[j]) { i--; j--; } - return { - oldPrefix: oldSegs.slice(0, i + 1).join('/'), - newPrefix: newSegs.slice(0, j + 1).join('/'), - }; - } - - /** - * True when some currently-tracked file still lives under `oldPrefix` - * without having moved — i.e. only part of that folder's contents moved. - * Partial moves are exactly where the user needs the per-file detail, so - * a group failing this check is left as individual rows instead of being - * collapsed. - */ - private isPartialMove(oldPrefix: string): boolean { - const prefix = `${oldPrefix}/`; - for (const fs of this.fileStatuses.values()) { - if (fs.status === 'moved') continue; - if (fs.path === oldPrefix || fs.path.startsWith(prefix)) return true; - } - return false; - } - - /** - * Groups of 'moved' rows worth collapsing into a single folder row: more - * than one file sharing a (oldPrefix, newPrefix) pair, with nothing left - * behind under the old prefix. A group of one is just a plain moved row — - * collapsing it would add an expand affordance for nothing. - */ - private collapsibleMoveGroups(statuses: FileStatus[]): Map { - const byKey = new Map(); - for (const fs of statuses) { - if (fs.status !== 'moved') continue; - const prefixes = this.groupPrefixes(fs); - if (!prefixes) continue; - const key = JSON.stringify(prefixes); - const existing = byKey.get(key); - if (existing) existing.members.push(fs); - else byKey.set(key, { ...prefixes, members: [fs] }); - } - - const collapsible = new Map(); - for (const [key, group] of byKey) { - if (group.members.length < 2) continue; - if (this.isPartialMove(group.oldPrefix)) continue; - collapsible.set(key, group); - } - return collapsible; - } - - private moveGroupCallbacks(): MoveGroupCallbacks { - return { - onSelect: (members, selected) => { - for (const m of members) { - if (selected) this.selectedFiles.add(m.path); - else this.selectedFiles.delete(m.path); - } - this.renderView(); - }, - onPush: (members) => void this.pushMoveGroup(members), - onRevertMove: (members) => void this.revertMoveGroup(members), - onToggleExpand: (key) => { - if (this.expandedMoveGroups.has(key)) this.expandedMoveGroups.delete(key); - else this.expandedMoveGroups.add(key); - this.renderView(); - }, - }; - } - - /** Pushes every member of a collapsed folder-move row through the batch flow, so the whole group lands in one commit. */ - private async pushMoveGroup(members: FileStatus[]): Promise { - const files = members.map(m => m.file || m.path); - try { - const results = await this.plugin.sync.pushFiles(files); - this.applyOptimisticSyncedStatus(results.syncedPaths); - this.renderView(); - } catch (e) { - new Notice(t('syncStatus.notice.opFailed', { verb: t('main.verb.push'), message: e instanceof Error ? e.message : String(e) })); - } - } - - /** Reverts every member of a collapsed folder-move row — moves each local file back to where it was. */ - private async revertMoveGroup(members: FileStatus[]): Promise { - const confirmed = await this.showConfirmDialog(t('syncStatus.confirmRevertMoveGroup', { count: members.length })); - if (!confirmed) return; - - for (const m of members) { - if (!m.movedFrom) continue; - try { - await ensureParentDirs(this.app.vault.adapter, m.movedFrom); - const file = m.file ?? this.app.vault.getFileByPath(m.path); - if (file instanceof TFile) { - await this.app.fileManager.renameFile(file, m.movedFrom); - } else { - await this.app.vault.adapter.rename(m.path, m.movedFrom); - await this.plugin.sync.trackRename(m.movedFrom, m.path); - } - } catch (e) { - logger.warn(`Failed to revert move for ${m.path}`, e); - } - } - new Notice(t('syncStatus.notice.moveReverted', { path: `${members.length} file(s)` })); - await this.refreshAllStatuses(); - } - - // ── Single-file operations ────────────────────────────────────── - - private async handleLocalDelete(fileStatus: FileStatus): Promise { - const confirmed = await this.showConfirmDialog(t('syncStatus.confirmDeleteLocal', { path: fileStatus.path })); - if (!confirmed) return; - try { - if (fileStatus.file) { - await this.app.fileManager.trashFile(fileStatus.file); - } else { - await this.app.vault.adapter.remove(fileStatus.path); - } - await this.plugin.sync.clearMetadata(fileStatus.path); - new Notice(t('syncStatus.notice.deleted', { path: fileStatus.path })); - this.fileStatuses.delete(fileStatus.path); - this.renderView(); - } catch (e) { - new Notice(t('syncStatus.notice.deleteFailed', { message: e instanceof Error ? e.message : String(e) })); - } - } - - private async runSingleFile(fileStatus: FileStatus, op: 'push' | 'pull'): Promise { - // Unlike the batch operations below, this had no "in progress" feedback at - // all -- only the row's icon flipped to `checking`. pushFile() can do a - // few sequential remote requests (conflict check, rename detection) before - // its own success/failure Notice fires, so a slow network made a push look - // like a no-op until a toast finally appeared. - const runVerb = op === 'push' ? t('main.verb.pushing') : t('main.verb.pulling'); - const prog = new Notice(t('syncStatus.notice.opStarted', { verb: runVerb, name: fileStatus.path }), 0); - try { - this.fileStatuses.set({ ...fileStatus, status: 'checking' }); - this.closeDiffPaneFor([fileStatus.path]); - this.renderView(); - - if (op === 'push') { - // Individual push is just a one-element pushFiles() batch -- the same - // pipeline "Selected x1" uses -- so a tracked rename is classified - // identically regardless of which button the user clicked. - const results = await this.plugin.sync.pushFiles([fileStatus.file || fileStatus.path]); - prog.hide(); - const synced = results.syncedPaths.find(p => p.path === fileStatus.path); - if (synced) { - // Same approach as executeBatchOperation's applyOptimisticSyncedStatus: - // trust what was just written instead of re-fetching the remote tree, - // which can lag a successful write by a few seconds. Passing `undefined` - // as refreshFileStatus's remoteEntry (the old code path) claims the file - // isn't on the remote at all, which forces 'unsynced' right after a - // successful push -- the bug being fixed here. - this.applyOptimisticSyncedStatus([synced]); - } else { - // Not a confirmed sync (cancelled, conflict left unresolved, file - // deleted, or remote symlink left untouched) -- fall back to an - // accurate live check. - await this.refreshFileStatusByContent(fileStatus.file || fileStatus.path); - } - } else { - await this.plugin.sync.pullFile(fileStatus.file || fileStatus.path); - prog.hide(); - await this.refreshFileStatusByContent(fileStatus.file || fileStatus.path); - } - - this.renderView(); - } catch (e) { - prog.hide(); - const verb = op === 'push' ? t('main.verb.push') : t('main.verb.pull'); - new Notice(t('syncStatus.notice.opFailed', { verb, message: e instanceof Error ? e.message : String(e) })); - await this.refreshFileStatusByContent(fileStatus.file || fileStatus.path); - this.renderView(); - } - } - - // ── Batch / refresh operations ───────────────────────────────── - - async refreshAllStatuses(): Promise { - if (this.isRefreshing) { - new Notice(t('syncStatus.notice.alreadyRefreshing')); - return; - } - - this.isRefreshing = true; - this.fileStatuses.clear(); - this.renderView(); // Show initial progress state - - try { - const files = await this.discoverFiles(); - this.initializeFileStatuses(files.local); - for (const hiddenPath of files.hiddenLocalPaths) { - this.fileStatuses.set(hiddenPath, { path: hiddenPath, status: 'checking' }); - } - const extra = await this.identifyExtraFiles(files.remoteMap, files.localMap, files.allMap, this.pendingMoveOldPaths()); - this.addExtraToStatuses(extra); - - // Re-render info/tabs but keep progress bar (renderView handles this) - this.renderView(); - - const filesToCheck = this.getCheckableFiles(files.local, extra, files.hiddenLocalPaths); - await this.performStatusCheck(filesToCheck, files.remoteMap); - await this.reconcileOutOfBandMoves(files.remoteMap); - - this.saveRemoteTreeSnapshot(files.remoteHead, files.remoteEntries); - this.lastSyncTime = Date.now(); - this.isRefreshing = false; // Set to false BEFORE final renderView - this.renderView(); - new Notice(t('syncStatus.notice.refreshed', { local: files.local.length + files.hiddenLocalPaths.size, remote: files.remoteMap.size })); - } catch (e) { - this.isRefreshing = false; - this.renderView(); - new Notice(t('syncStatus.notice.refreshFailed', { message: e instanceof Error ? e.message : String(e) })); - } - } - - private async discoverFiles() { - const allFiles = this.app.vault.getFiles(); - let local = this.plugin.filterFilesByVaultFolder(allFiles); - const remoteHead = await this.plugin.gitService.getBranchHead?.(this.plugin.settings.branch); - // Unfiltered: getNormalizedRemotePath below applies the same rootPath - // filter, and gitignore discovery needs the entries outside rootPath - // (e.g. the repo-root .gitignore). Sharing this one tree saves - // loadGitignores a second full-tree fetch on every refresh. - const remoteEntries = await this.plugin.gitService.listFilesDetailed(remoteHead ?? this.plugin.settings.branch, false); - - await this.plugin.gitignoreManager.loadGitignores(remoteEntries); - - // Map remote paths to vault paths - const remoteMap = new Map(); // vaultPath -> tree entry (path, symlink, sha) - const skipSymlinks = getEffectiveSymlinkHandling(this.plugin.settings) === 'skip'; - for (const entry of remoteEntries) { - if (entry.symlink && skipSymlinks) continue; // Symlink handling: skip - const normalized = this.getNormalizedRemotePath(entry.path); - if (normalized === null) continue; // Not under rootPath - - const vaultPath = this.plugin.getVaultPath(normalized); - if (!this.plugin.gitignoreManager.isIgnored(normalized)) { - remoteMap.set(vaultPath, entry); - } - } - - local = local.filter(f => !this.plugin.gitignoreManager.isIgnored(this.plugin.getNormalizedPath(f.path))); - - // vault.getFiles() skips hidden dirs; scan them via adapter - const hiddenLocalPaths = await this.discoverHiddenLocalFiles(); - const filteredHiddenPaths = new Set( - hiddenLocalPaths - .filter(p => this.plugin.filterPathByVaultFolder(p)) - .filter(p => !this.plugin.gitignoreManager.isIgnored(this.plugin.getNormalizedPath(p))) - ); - - return { - local, - remoteEntries, - remoteHead, - remoteMap, - localMap: new Set([...local.map(f => f.path), ...filteredHiddenPaths]), - allMap: new Map(allFiles.map(f => [f.path, f])), - hiddenLocalPaths: filteredHiddenPaths - }; - } - - private getNormalizedRemotePath(remotePath: string): string | null { - const rootPath = this.plugin.settings.rootPath; - if (!rootPath) return remotePath; - - const cleanRoot = rootPath.endsWith('/') ? rootPath : `${rootPath}/`; - if (remotePath.startsWith(cleanRoot)) { - return remotePath.substring(cleanRoot.length); - } - if (remotePath === rootPath) return ''; - return null; - } - - private async discoverHiddenLocalFiles(): Promise { - const result: string[] = []; - const vaultFolder = this.plugin.settings.vaultFolder || ''; - await this.recursiveScan(vaultFolder, result); - return result; - } - - private async recursiveScan(folderPath: string, result: string[]): Promise { - try { - const listing = await this.app.vault.adapter.list(folderPath); - for (const file of listing.files) { - if (!this.isHidden(file)) continue; - // Guard against a symlinked folder being misclassified as a file - // by the adapter's raw listing (Node's dirent type doesn't follow - // links) — still track it as a link entry rather than a readable - // file, same as a symlinked folder found via listing.folders below. - if (readLocalSymlinkTarget(this.app, file) !== null || await this.isLocalFile(file)) { - result.push(file); - } - } - for (const folder of listing.folders) { - if (folder === '.git' || folder.endsWith('/.git')) continue; - // A symlinked folder is a single blob on the remote, not a real tree — - // walking into it would scan whatever unrelated directory it points at. - // Track the link itself (same as a hidden file) so push/pull can still - // handle it via the existing symlink machinery, without recursing. - if (readLocalSymlinkTarget(this.app, folder) !== null) { - if (this.isHidden(folder)) result.push(folder); - continue; - } - await this.recursiveScan(folder, result); - } - } catch { /* adapter may not support listing */ } - } - - private isHidden(path: string): boolean { - return path.split('/').some(part => part.startsWith('.')); - } - - /** True only for an actual local file — excludes real directories (and symlinks to one), which `adapter.stat()` follows. */ - private async isLocalFile(vaultPath: string): Promise { - const stat = await this.app.vault.adapter.stat(vaultPath); - return stat?.type === 'file'; - } - - private initializeFileStatuses(localFiles: TFile[]): void { - for (const file of localFiles) { - this.fileStatuses.set(file.path, { file, path: file.path, status: 'checking' }); - } - } - - /** Every path a pending move's `renamedFrom` still points at — the remote's copy at each is represented by the moved row at its new path, not a separate remote-only row. */ - private pendingMoveOldPaths(): Set { - const paths = new Set(); - for (const meta of Object.values(this.plugin.settings.syncMetadata ?? {})) { - if (meta.renamedFrom) paths.add(meta.renamedFrom); - } - return paths; - } - - private async identifyExtraFiles(remoteMap: Map, localFilePaths: Set, allLocalFileMap: Map, pendingMoveOldPaths: Set = new Set()) { - const extra: Array = []; - for (const [vaultPath] of remoteMap.entries()) { - if (localFilePaths.has(vaultPath)) continue; - if (pendingMoveOldPaths.has(vaultPath)) continue; - - let localFile = allLocalFileMap.get(vaultPath); - if (!localFile) { - const abs = this.app.vault.getAbstractFileByPath(vaultPath); - if (abs instanceof TFile) localFile = abs; - } - - if (localFile) { - extra.push(localFile); - } else if (await this.isLocalFile(vaultPath)) { - extra.push(vaultPath); - } else { - // Either nothing exists locally, or the remote's record (e.g. a - // stale symlink push) now collides with a real local folder of - // the same name — either way there's no readable local file to - // compare, so it's remote-only. - this.fileStatuses.set(vaultPath, { - path: vaultPath, - status: this.fileStatuses.classify({ localExists: false, remoteExists: true }), - }); - } - } - return extra; - } - - /** - * Pairs an orphaned remote-only entry with a local-only file sharing its - * exact content — for moves that happened while the plugin wasn't - * observing the vault's live 'rename' event (Obsidian closed, an - * external tool or another device moved it, or the plugin hadn't - * finished loading yet). Live tracking in main.ts already covers every - * in-app move; this only fills the gap live tracking can't see. - * - * An orphan only counts if it still carries synced metadata pointing at - * that exact path — a brand-new remote file that happens to share - * content with an unrelated local draft must never be mistaken for a - * move. And a sha match only counts when it's unambiguous on both - * sides — a boilerplate/template file legitimately duplicated at - * several paths must not get paired at random. - */ - private async reconcileOutOfBandMoves(remoteMap: Map): Promise { - const orphansBySha = this.orphanedMoveSourcesBySha(remoteMap); - if (orphansBySha.size === 0) return; - - const candidatesBySha = await this.unsyncedMoveDestinationsBySha(remoteMap, orphansBySha); - - for (const [sha, orphanPaths] of orphansBySha) { - if (orphanPaths.length !== 1) continue; // ambiguous on the remote side - const newPaths = candidatesBySha.get(sha); - if (!newPaths || newPaths.length !== 1) continue; // ambiguous or unmatched on the local side - const oldPath = orphanPaths[0] as string; - const newPath = newPaths[0] as string; - await this.plugin.sync.trackRename(newPath, oldPath); - this.fileStatuses.delete(oldPath); - await this.refreshFileStatus(newPath, remoteMap.get(newPath)); - } - } - - /** Every 'remote-only' row that still carries synced metadata at that exact path, grouped by its remote blob sha. */ - private orphanedMoveSourcesBySha(remoteMap: Map): Map { - const metadata = this.plugin.settings.syncMetadata ?? {}; - const orphansBySha = new Map(); - for (const [path, status] of this.fileStatuses) { - if (status.status !== 'remote-only') continue; - const meta = metadata[path]; - if (!isSyncMetadataAtPath(meta, path) || meta.renamedFrom) continue; - const entry = remoteMap.get(path); - if (!entry || entry.symlink || !entry.sha) continue; - const list = orphansBySha.get(entry.sha) ?? []; - list.push(path); - orphansBySha.set(entry.sha, list); - } - return orphansBySha; - } - - /** Every 'unsynced' row with no remote entry of its own, grouped by its local blob sha — but only shas an orphan actually needs. */ - private async unsyncedMoveDestinationsBySha( - remoteMap: Map, - orphansBySha: Map - ): Promise> { - const candidatesBySha = new Map(); - for (const [path, status] of this.fileStatuses) { - if (status.status !== 'unsynced' || status.localContent === undefined) continue; - if (remoteMap.has(path)) continue; // has its own remote entry; not a move destination - const sha = await gitBlobSha(status.localContent); - if (!orphansBySha.has(sha)) continue; - const list = candidatesBySha.get(sha) ?? []; - list.push(path); - candidatesBySha.set(sha, list); - } - return candidatesBySha; - } - - private addExtraToStatuses(extra: Array): void { - for (const item of extra) { - const path = typeof item === 'string' ? item : item.path; - const file = typeof item === 'string' ? undefined : item; - this.fileStatuses.set(path, { file, path, status: 'checking' }); - } - } - - private getCheckableFiles(local: TFile[], extra: Array, hiddenLocalPaths: Set = new Set()): Array { - const extraPaths = new Set(extra.map(f => typeof f === 'string' ? f : f.path)); - // Hidden local files already in localMap won't appear in extra; add them directly - const hiddenToAdd = [...hiddenLocalPaths].filter(p => !extraPaths.has(p)); - return ([...local, ...extra, ...hiddenToAdd] as Array).filter(f => { - const p = typeof f === 'string' ? f : f.path; - return !this.plugin.gitignoreManager.isIgnored(this.plugin.getNormalizedPath(p)); - }); - } - - // Checks run with bounded concurrency (each file is an independent network - // request) and the view is re-rendered on a throttle rather than once per - // file, so a large vault refreshes far faster. - private static readonly STATUS_CHECK_CONCURRENCY = 8; - private static readonly RENDER_THROTTLE_MS = 150; - - private async performStatusCheck(filesToCheck: Array, remoteMap: Map): Promise { - const total = filesToCheck.length; - this.refreshProgress = { current: 0, total }; - - let next = 0; - let lastRender = 0; - const maybeRender = (force = false): void => { - const now = Date.now(); - if (force || now - lastRender >= SyncStatusView.RENDER_THROTTLE_MS) { - lastRender = now; - this.renderView(); - } - }; - - const worker = async (): Promise => { - while (next < total) { - const file = filesToCheck[next++]; - if (file) { - const path = typeof file === 'string' ? file : file.path; - await this.refreshFileStatus(file, remoteMap.get(path), remoteMap); - } - this.refreshProgress.current++; - maybeRender(); - } - }; - - const workerCount = Math.min(SyncStatusView.STATUS_CHECK_CONCURRENCY, total); - await Promise.all(Array.from({ length: workerCount }, () => worker())); - maybeRender(true); - } - - /** - * Live-updates one already-tracked file's status after Obsidian reports its - * content changed (called from main.ts's vault 'modify' handler, gated to - * files inside the configured vault folder). Re-derives 'synced'/'modified' - * from a local hash against the remote SHA already known from the last full - * refresh -- no network call, so this is cheap enough to run on every edit. - * - * Applies to rows with a local file. 'moved' stays 'moved' regardless of - * edits, but its local content is refreshed so it can be compared with - * the remote content at its old path. 'remote-only' has no local file to - * have changed; 'checking' - * means a full refresh is already in flight and will supersede this. A - * path the panel isn't currently tracking at all is left alone too -- - * discovering new files requires the remote tree and belongs to a full - * refresh, not a per-edit hook. - */ - async handleFileModified(file: TFile): Promise { - const existing = this.fileStatuses.get(file.path); - if (!existing || (existing.status !== 'synced' && existing.status !== 'modified' && existing.status !== 'unsynced' && existing.status !== 'moved')) return; - - const localContent = await this.readFileContent(file, this.isBinary(file.path), false); - - let status: FileStatus['status'] = existing.status; - if (existing.status !== 'moved') { - status = existing.remoteSha === undefined - ? this.fileStatuses.classify({ localExists: true, remoteExists: false }) - : this.fileStatuses.classify({ - localExists: true, - remoteExists: true, - contentsEqual: await gitBlobSha(localContent) === existing.remoteSha, - }); - } - this.fileStatuses.set(file.path, { ...existing, status, localContent }); - - this.renderView(); - } - - /** - * Live-updates the sync panel after Obsidian reports a rename (called from - * main.ts's vault 'rename' handler, once SyncManager.trackRename has - * already updated syncMetadata -- so this only ever reads state that's - * already settled, no network call). Mirrors what a full refresh would - * classify the new path as, from data already known: - * - * - Not currently tracked at the old path (gitignored, or the panel - * hasn't refreshed since it appeared) -- nothing to do. - * - A refresh is mid-flight for the old path ('checking') -- it will - * settle on its own; touching it here would race the refresh. - * - The new path fell outside the configured vault folder -- the row - * simply disappears, same as any other out-of-scope file. - * - syncMetadata carries a renamedFrom for the new path (the common - * case, set by the trackRename this follows) -- becomes a 'moved' row. - * - No renamedFrom (never synced, or renamed back to its last-synced - * path and the pending move cancelled itself) -- carries the previous - * status over at the new path rather than inventing one. - */ - handleFileRenamed(file: TFile, oldPath: string): void { - const existing = this.fileStatuses.get(oldPath); - if (!existing || existing.status === 'checking') return; - - this.fileStatuses.delete(oldPath); - - if (!this.plugin.filterPathByVaultFolder(file.path)) { - this.renderView(); - return; - } - - const renamedFrom = this.plugin.settings.syncMetadata?.[file.path]?.renamedFrom; - if (renamedFrom !== undefined) { - this.fileStatuses.set(file.path, { - file, - path: file.path, - status: this.fileStatuses.classify({ movedFrom: renamedFrom }), - movedFrom: renamedFrom, - remoteSha: existing.remoteSha, - localContent: existing.localContent, - isSymlink: existing.isSymlink, - }); - } else { - this.fileStatuses.set(file.path, { ...existing, file, path: file.path }); - } - - this.renderView(); - } - - /** - * Classifies a file's sync status. When the remote tree entry carries a git - * blob SHA (the common case), this is a single local hash + comparison with - * no network request (Phase 1 of the SHA-based refresh). Falls back to the - * previous full-content comparison via getFile() only when a tree entry - * exists but the provider did not supply its SHA. A missing tree entry is - * already conclusive: it is a new local-only file and needs no 404 probe. - */ - private async refreshFileStatus(fileOrPath: TFile | string, remoteEntry: GitTreeEntry | undefined, remoteMap?: Map): Promise { - try { - const path = typeof fileOrPath === 'string' ? fileOrPath : fileOrPath.path; - const renamedFrom = this.plugin.settings.syncMetadata?.[path]?.renamedFrom; - if (renamedFrom !== undefined) { - await this.refreshMovedFileStatus(fileOrPath, renamedFrom, remoteMap?.get(renamedFrom)); - return; - } - - if (remoteEntry === undefined) { - await this.refreshLocalOnlyStatus(fileOrPath); - } else if (remoteEntry.sha !== undefined) { - await this.refreshFileStatusBySha(fileOrPath, remoteEntry); - } else { - await this.refreshFileStatusByContent(fileOrPath); - } - } catch (e) { - const path = typeof fileOrPath === 'string' ? fileOrPath : fileOrPath.path; - logger.warn(`Failed to determine sync status for ${path}`, e); - this.fileStatuses.set(path, { - file: typeof fileOrPath === 'string' ? undefined : fileOrPath, - path, - status: this.fileStatuses.classify({ localExists: true, remoteExists: false }) - }); - } - } - - /** Keeps a pending move distinct while retaining the old remote blob for an on-demand diff. */ - private async refreshMovedFileStatus(fileOrPath: TFile | string, movedFrom: string, sourceEntry?: GitTreeEntry): Promise { - const isStr = typeof fileOrPath === 'string'; - const path = isStr ? fileOrPath : fileOrPath.path; - const localContent = await this.readFileContent(fileOrPath, this.isBinary(path), isStr); - this.fileStatuses.set(path, { - file: isStr ? undefined : fileOrPath, - path, - status: this.fileStatuses.classify({ movedFrom }), - movedFrom, - localContent, - remoteSha: sourceEntry?.sha, - isSymlink: sourceEntry?.symlink, - }); - } - - private async refreshLocalOnlyStatus(fileOrPath: TFile | string): Promise { - const isStr = typeof fileOrPath === 'string'; - const path = isStr ? fileOrPath : fileOrPath.path; - const localContent = await this.readFileContent(fileOrPath, this.isBinary(path), isStr); - this.fileStatuses.set(path, { - file: isStr ? undefined : fileOrPath, - path, - status: this.fileStatuses.classify({ localExists: true, remoteExists: false }), - localContent, - }); - } - - private async refreshFileStatusBySha(fileOrPath: TFile | string, remoteEntry: GitTreeEntry): Promise { - const isStr = typeof fileOrPath === 'string'; - const path = isStr ? fileOrPath : fileOrPath.path; - const file = isStr ? undefined : fileOrPath; - const binary = this.isBinary(path); - - const symlinkMode = getEffectiveSymlinkHandling(this.plugin.settings); - const localContent = await this.readLocalContentForSha(fileOrPath, isStr, binary, remoteEntry.symlink, symlinkMode); - const localSha = await gitBlobSha(localContent); - - const status = this.fileStatuses.classify({ - localExists: true, - remoteExists: true, - contentsEqual: localSha === remoteEntry.sha, - }); - // A file can reach 'synced' here purely because its content already - // matches the remote -- e.g. it was never pushed/pulled through this - // plugin (cloned in, or coincidentally identical). Without recording - // that in syncMetadata, a later move/rename of this file finds no - // metadata at its old path: SyncManager.trackRename and - // reconcileOutOfBandMoves both silently no-op on a missing entry, so - // the move never gets recognized -- it just shows as a stray - // remote-only + unsynced pair instead of 'moved'. - if (status === 'synced' && remoteEntry.sha) { - await this.plugin.sync.updateMetadata(path, remoteEntry.sha); - } - this.fileStatuses.set(path, { - file, path, status, localContent, - remoteSha: remoteEntry.sha, - isSymlink: remoteEntry.symlink, - }); - } - - /** - * Determines what to hash locally so it's comparable to the remote blob SHA. - * A symlink's blob content is its target path string, not the content it - * points at, so "real" mode hashes the raw link target instead of following - * it. "follow" mode (and "real" without an actual local OS symlink, e.g. on - * mobile) always hashes the local file's content as read normally. - */ - private async readLocalContentForSha( - fileOrPath: TFile | string, isStr: boolean, binary: boolean, remoteIsSymlink: boolean, symlinkMode: SymlinkHandling - ): Promise { - if (remoteIsSymlink && symlinkMode === 'real') { - const path = typeof fileOrPath === 'string' ? fileOrPath : fileOrPath.path; - const target = readLocalSymlinkTarget(this.app, path); - if (target !== null) return target; - } - return this.readFileContent(fileOrPath, binary, isStr); - } - - /** Fallback status check via full content fetch, for entries without a usable tree SHA. */ - private async refreshFileStatusByContent(fileOrPath: TFile | string): Promise { - const isStr = typeof fileOrPath === 'string'; - const path = isStr ? fileOrPath : fileOrPath.path; - const file = isStr ? undefined : fileOrPath; - - const binary = this.isBinary(path); - const localContent = await this.readFileContent(fileOrPath, binary, isStr); - - // Important: Use SyncManager's logic which handles rootPath/vaultFolder mapping - const repoPath = this.plugin.getNormalizedPath(path); - const remote = await this.plugin.gitService.getFile(repoPath, this.plugin.settings.branch); - - const status = remote.sha - ? this.fileStatuses.classify({ - localExists: true, - remoteExists: true, - contentsEqual: this.contentsEqual(localContent, remote.content), - }) - : this.fileStatuses.classify({ localExists: true, remoteExists: false }); - - // Same backfill as refreshFileStatusBySha's synced branch -- see its - // comment for why a content-only match must still be recorded. - if (status === 'synced' && remote.sha) { - await this.plugin.sync.updateMetadata(path, remote.sha); - } - this.fileStatuses.set(path, { file, path, status, localContent, remoteContent: remote.content, remoteSha: remote.sha }); - } - - private async readStringPathContent(path: string, binary: boolean): Promise { - try { - return binary - ? await this.app.vault.adapter.readBinary(path) - : await this.app.vault.adapter.read(path); - } catch (e) { - // A folder that's an OS symlink can surface here (not yet known to - // the remote, so it skipped the sha-based symlink handling above); - // adapter.read() follows the link and throws EISDIR trying to read - // a directory. Fall back to the raw link target, consistent with - // how a symlinked folder is treated as a single blob elsewhere. - const target = readLocalSymlinkTarget(this.app, path); - if (target !== null) return target; - throw e; - } - } - - private async readFileContent(fileOrPath: TFile | string, binary: boolean, isStr: boolean): Promise { - if (isStr) { - return this.readStringPathContent(fileOrPath as string, binary); - } - if (fileOrPath instanceof TFile) { - try { - return binary - ? await this.app.vault.readBinary(fileOrPath) - : await this.app.vault.read(fileOrPath); - } catch (e) { - // Obsidian's cached vault.read can fail for symlinked files - // (notably on mobile); fall back to reading the path directly. - logger.warn(`vault.read failed for ${fileOrPath.path}; falling back to adapter`, e); - return binary - ? await this.app.vault.adapter.readBinary(fileOrPath.path) - : await this.app.vault.adapter.read(fileOrPath.path); - } - } - // This should not happen if isStr is false and fileOrPath is TFile - throw new Error('Expected TFile when isStr is false'); - } - - private isBinary(path: string): boolean { return isBinaryPath(path); } - - private contentsEqual(a: string | ArrayBuffer, b: string | ArrayBuffer): boolean { - return contentsEqual(a, b); - } - - // ── Batch push/pull/delete ───────────────────────────────────── - - async pushAllModified(): Promise { await this.runBatchOperation('modified', 'push'); } - async pullAllModified(): Promise { await this.runBatchOperation('modified', 'pull'); } - async pushSelected(): Promise { await this.runBatchOperation('selected', 'push'); } - async pullSelected(): Promise { await this.runBatchOperation('selected', 'pull'); } - - private static readonly NO_RUNNABLE_FILES_KEYS: Record<'push' | 'pull', Record<'selected' | 'found', TranslationKey>> = { - push: { selected: 'syncStatus.notice.noPushableFiles.selected', found: 'syncStatus.notice.noPushableFiles.found' }, - pull: { selected: 'syncStatus.notice.noPullableFiles.selected', found: 'syncStatus.notice.noPullableFiles.found' }, - }; - - private async runBatchOperation(filter: 'modified' | 'selected', op: 'push' | 'pull'): Promise { - const targets = Array.from(this.fileStatuses.values()).filter(s => { - if (filter === 'selected' && !this.selectedFiles.has(s.path)) return false; - return op === 'push' - ? s.status === 'modified' || s.status === 'unsynced' || s.status === 'moved' - : s.status === 'modified' || s.status === 'remote-only'; - }); - - if (targets.length === 0) { - const scope = filter === 'selected' ? 'selected' : 'found'; - new Notice(t(SyncStatusView.NO_RUNNABLE_FILES_KEYS[op][scope])); - return; - } - - const files = targets.map(s => s.file || s.path); - const serviceName = getServiceName(this.plugin.settings); - const msg = op === 'push' - ? t('syncStatus.confirm.pushSelected', { count: files.length, service: serviceName }) - : t('syncStatus.confirm.pullSelected', { count: files.length, service: serviceName }); - - if (!await this.showConfirmDialog(msg)) return; - - await this.executeBatchOperation(filter, op, files); - } - - /** - * Marks just-pushed paths as 'synced' directly from data already in hand - * (the content that was just written, and the new sha when the provider - * reported one), instead of re-fetching the remote tree. Used in place of - * refreshAllStatuses() right after a push — see the call site's comment. - */ - private applyOptimisticSyncedStatus(syncedPaths: Array<{ path: string; sha?: string }>): void { - for (const { path, sha } of syncedPaths) { - this.fileStatuses.markSynced(path, sha); - } - } - - private saveRemoteTreeSnapshot(head: string | undefined, entries: GitTreeEntry[]): void { - this.remoteTreeSnapshot = head - ? { branch: this.plugin.settings.branch, rootPath: this.plugin.settings.rootPath, head, entries } - : undefined; - } - - private async getReusableRemoteTree(): Promise { - const snapshot = this.remoteTreeSnapshot; - if (!snapshot || !this.plugin.gitService.getBranchHead || snapshot.branch !== this.plugin.settings.branch || snapshot.rootPath !== this.plugin.settings.rootPath) return undefined; - - try { - const currentHead = await this.plugin.gitService.getBranchHead(snapshot.branch); - return currentHead === snapshot.head ? snapshot.entries : undefined; - } catch (error) { - logger.warn('Failed to validate remote tree snapshot; fetching a fresh tree for push.', error); - return undefined; - } - } - - private async executeBatchOperation(filter: 'modified' | 'selected', op: 'push' | 'pull', files: Array): Promise { - const runVerb = op === 'push' ? t('main.verb.pushing') : t('main.verb.pulling'); - const prog = new Notice(t('main.progress.running', { verb: runVerb, total: files.length }), 0); - this.closeDiffPaneFor(files.map(f => typeof f === 'string' ? f : f.path)); - try { - const remoteTree = await this.getReusableRemoteTree(); - const results = op === 'push' - ? await this.plugin.sync.pushFiles(files, (cur, total, name) => prog.setMessage(t('syncStatus.progress.pushing', { current: cur, total, name })), remoteTree) - : await this.plugin.sync.pullAllFiles(files, (cur, total, name) => prog.setMessage(t('syncStatus.progress.pulling', { current: cur, total, name })), remoteTree); - - prog.hide(); - if (results.errors.length > 0) logger.error(`${op} errors:`, results.errors); - if (filter === 'selected') this.selectedFiles.clear(); - const doneVerb = op === 'push' ? t('main.verb.push') : t('main.verb.pull'); - new Notice(t('syncStatus.notice.opCompleted', { verb: doneVerb })); - - if (op === 'push') { - // Mark just-pushed files synced directly instead of re-fetching the - // remote tree: GitHub's tree-by-branch-name read can lag a few - // seconds behind a just-completed write, so an immediate refresh - // can misreport a file we know just synced correctly as "modified". - this.applyOptimisticSyncedStatus((results as PushResults).syncedPaths); - this.renderView(); - } else { - await this.refreshAllStatuses(); - } - } catch (e) { - prog.hide(); - const failVerb = op === 'push' ? t('main.verb.push') : t('main.verb.pull'); - new Notice(t('syncStatus.notice.opFailed', { verb: failVerb, message: e instanceof Error ? e.message : String(e) })); - } - } - - async deleteSelected(): Promise { - const targets = this.getSelectedTargets(); - if (targets.length === 0) return; - - const { local, remote } = this.partitionTargets(targets); - if (local.length === 0 && remote.length === 0) { new Notice(t('syncStatus.notice.nothingToDelete')); return; } - if (!await this.confirmDeletion(local, remote)) return; - - const total = local.length + remote.length; - const prog = new Notice(t('syncStatus.progress.deleting', { total }), 0); - const errors: { path: string, message: string }[] = []; - - await this.performLocalDeletion(local, total, prog, errors); - await this.performRemoteDeletion(remote, total, local.length, prog, errors); - - prog.hide(); - if (errors.length > 0) { - logger.error('Delete errors:', errors); - new Notice(t('syncStatus.notice.deleteResult.partialWithMessage', { - succeeded: total - errors.length, - total, - failed: errors.length, - message: errors.map(e => e.message).join('; ') - })); - } else { - new Notice(t('syncStatus.notice.deleteResult.success', { total })); - } - this.renderView(); - } - - private getSelectedTargets(): FileStatus[] { - if (this.selectedFiles.size === 0) { new Notice(t('syncStatus.notice.noFilesSelected')); return []; } - return Array.from(this.selectedFiles) - .map(p => this.fileStatuses.get(p)) - .filter(Boolean) as FileStatus[]; - } - - private partitionTargets(targets: FileStatus[]) { - return { - // Moved rows go through neither bucket: bulk delete on a moved row - // is ambiguous (delete the new local file? the pending remote - // move?) and isn't offered — see canDelete's count above. - local: targets.filter(s => s.status !== 'remote-only' && s.status !== 'moved'), - remote: targets.filter(s => s.status === 'remote-only') - }; - } - - private async confirmDeletion(local: FileStatus[], remote: FileStatus[]): Promise { - // Local deletes go through Obsidian's own trash handling, whose actual - // destination (vault .trash/, OS trash, or permanent) depends on the - // user's "Deleted files" setting — not something this plugin can read. - // So local wording defers to that setting rather than promising - // recoverability; remote deletes are unconditionally permanent, so - // those get the full plan-review modal instead of a plain confirm. - if (remote.length === 0) { - return this.showConfirmDialog(t('syncStatus.confirmDelete.localOnly', { local: local.length })); - } - - const plan: SyncPlan = { - additions: [], modifications: [], moves: [], - deletions: remote.map(s => ({ path: s.path, name: s.file?.name ?? s.path.split('/').pop() ?? s.path })) - }; - const description = local.length > 0 ? t('syncStatus.confirmDelete.alsoLocal', { local: local.length }) : undefined; - return new Promise(resolve => { - new SyncPlanModal(this.app, plan, 'delete', () => resolve(true), () => resolve(false), description).open(); - }); - } - - private async performLocalDeletion(local: FileStatus[], total: number, prog: Notice, errors: { path: string, message: string }[]): Promise { - let cur = 0; - for (const s of local) { - cur++; - prog.setMessage(t('syncStatus.progress.deletingLocal', { current: cur, total, path: s.path })); - try { - if (s.file) await this.app.fileManager.trashFile(s.file); - else await this.app.vault.adapter.remove(s.path); - await this.plugin.sync.clearMetadata(s.path); - this.fileStatuses.delete(s.path); - this.selectedFiles.delete(s.path); - } catch (e) { - errors.push({ path: s.path, message: e instanceof Error ? e.message : String(e) }); - } - } - } - - private async performRemoteDeletion(remote: FileStatus[], total: number, localCount: number, prog: Notice, errors: { path: string, message: string }[]): Promise { - if (remote.length === 0) return; - - // s.path is a vault-relative path (may carry the vaultFolder prefix); the - // git service expects a path relative to rootPath only, so strip - // vaultFolder first, same as every other gitService call site. - const entries = remote.map(s => ({ status: s, repoPath: this.plugin.getNormalizedPath(s.path) })); - - if (!this.plugin.gitService.deleteBatch) { - await this.performRemoteDeletionSequential(entries, total, localCount, prog, errors); - return; - } - - let cur = localCount; - for (const e of entries) { - cur++; - prog.setMessage(t('syncStatus.progress.deletingRemote', { current: cur, total, path: e.status.path })); - } - - const branch = this.plugin.settings.branch; - for (let i = 0; i < entries.length; i += MAX_BATCH_PUSH_SIZE) { - const chunk = entries.slice(i, i + MAX_BATCH_PUSH_SIZE); - try { - const message = `Delete ${chunk.length} file(s) from Obsidian`; - await this.plugin.gitService.deleteBatch(chunk.map(e => e.repoPath), branch, message); - for (const e of chunk) { - this.fileStatuses.delete(e.status.path); - this.selectedFiles.delete(e.status.path); - } - } catch (err) { - // Atomic per-provider failure: none of this chunk's files were - // actually deleted, so every path in it is failed, not dropped. - const message = err instanceof Error ? err.message : String(err); - for (const e of chunk) errors.push({ path: e.status.path, message }); - } - } - } - - /** Provider doesn't support a batch/atomic multi-file delete commit — - * fall back to the original sequential per-file delete. */ - private async performRemoteDeletionSequential( - entries: Array<{ status: FileStatus; repoPath: string }>, - total: number, - localCount: number, - prog: Notice, - errors: { path: string, message: string }[] - ): Promise { - let cur = localCount; - for (const e of entries) { - cur++; - prog.setMessage(t('syncStatus.progress.deletingRemote', { current: cur, total, path: e.status.path })); - try { - await this.plugin.gitService.deleteFile(e.repoPath, this.plugin.settings.branch, `Delete ${e.repoPath}`); - this.fileStatuses.delete(e.status.path); - this.selectedFiles.delete(e.status.path); - } catch (err) { - errors.push({ path: e.status.path, message: err instanceof Error ? err.message : String(err) }); - } - } - } - - onClose(): Promise { - this.unsubscribeStatuses?.(); - this.unsubscribeStatuses = undefined; - return Promise.resolve(); - } - - private showConfirmDialog(message: string): Promise { - return new Promise(resolve => { - new ConfirmModal(this.app, message, () => resolve(true), () => resolve(false)).open(); - }); - } -} +export { SyncStatusView, SYNC_STATUS_VIEW_TYPE } from './sync-status/SyncStatusView'; diff --git a/src/ui/sync-status/SyncStatusComposition.ts b/src/ui/sync-status/SyncStatusComposition.ts new file mode 100644 index 0000000..dcf9f3d --- /dev/null +++ b/src/ui/sync-status/SyncStatusComposition.ts @@ -0,0 +1,70 @@ +import type { App } from 'obsidian'; +import type GitLabFilesPush from '../../main'; +import type { SyncStatusService } from '../../logic/sync-status-service'; +import { SyncStatusRefreshService } from '../../logic/sync/SyncStatusRefreshService'; +import { ensureSyncWorkspaceRuntime } from '../../logic/sync/SyncWorkspace'; +import type { SyncWorkspace } from '../../logic/sync/SyncWorkspace'; +import { SyncStatusController } from './SyncStatusController'; +import { SyncStatusNavigator } from './SyncStatusNavigator'; +import { SyncStatusOperations } from './SyncStatusOperations'; +import { SyncStatusRenderer } from './SyncStatusRenderer'; +import type { SyncStatusViewState } from './SyncStatusViewState'; + +export interface SyncStatusComposition { + controller: SyncStatusController; + navigator: SyncStatusNavigator; + operations: SyncStatusOperations; + renderer: SyncStatusRenderer; + statusRefresh: SyncStatusRefreshService; + workspace: SyncWorkspace; +} + +export interface SyncStatusCompositionCallbacks { + render(): void; + refresh(): Promise; + refreshStatuses(): Promise; +} + +/** Composition root for the sync-status UI and its domain-facing adapters. */ +export function createSyncStatusComposition( + app: App, + plugin: GitLabFilesPush, + state: SyncStatusViewState, + statuses: SyncStatusService, + callbacks: SyncStatusCompositionCallbacks, + providedController?: SyncStatusController, +): SyncStatusComposition { + const runtime = ensureSyncWorkspaceRuntime(app, plugin, statuses); + const statusRefresh = runtime.refreshService; + const navigator = new SyncStatusNavigator(app, runtime.workspace); + const operations = new SyncStatusOperations( + app, + runtime.workspace, + statuses, + state, + statusRefresh, + navigator, + () => callbacks.render(), + () => callbacks.refresh(), + ); + const controller = providedController ?? new SyncStatusController({ + refresh: () => callbacks.refreshStatuses(), + push: paths => operations.runPaths(paths, 'push'), + pull: paths => operations.runPaths(paths, 'pull'), + delete: paths => operations.deletePaths(paths), + openDiff: path => navigator.openDiff(path), + pushOne: status => operations.runSingle(status, 'push'), + pullOne: status => operations.runSingle(status, 'pull'), + deleteLocal: status => operations.deleteLocal(status), + loadDiff: path => navigator.loadDiff(path), + openFile: (status, newLeaf) => navigator.openFile(status, newLeaf), + canOpen: status => navigator.targetFor(status) !== null, + revertMove: status => operations.revertMove(status), + pushMoveGroup: members => operations.pushMoveGroup(members), + revertMoveGroup: members => operations.revertMoveGroup(members), + pushAllModified: () => operations.runBatch('modified', 'push'), + pullAllModified: () => operations.runBatch('modified', 'pull'), + }); + const renderer = new SyncStatusRenderer(() => runtime.workspace.getInfo(), state, statuses, controller, () => callbacks.render()); + return { controller, navigator, operations, renderer, statusRefresh, workspace: runtime.workspace }; +} diff --git a/src/ui/sync-status/SyncStatusController.ts b/src/ui/sync-status/SyncStatusController.ts new file mode 100644 index 0000000..7310e7c --- /dev/null +++ b/src/ui/sync-status/SyncStatusController.ts @@ -0,0 +1,57 @@ +import type { FileStatus } from '../../logic/sync-status-service'; + +export interface SyncStatusCommandPort { + refresh(): Promise; + push(paths: readonly string[]): Promise; + pull(paths: readonly string[]): Promise; + delete(paths: readonly string[]): Promise; + openDiff(path: string): Promise; + pushOne(status: FileStatus): Promise; + pullOne(status: FileStatus): Promise; + deleteLocal(status: FileStatus): Promise; + loadDiff(path: string): Promise; + openFile(status: FileStatus, newLeaf: boolean): boolean; + canOpen(status: FileStatus): boolean; + revertMove(status: FileStatus): Promise; + pushMoveGroup(members: FileStatus[]): Promise; + revertMoveGroup(members: FileStatus[]): Promise; + pushAllModified(): Promise; + pullAllModified(): Promise; +} + +/** Converts view events into path-only workspace commands. */ +export class SyncStatusController { + constructor(private readonly commands: SyncStatusCommandPort) {} + + refresh(): Promise { + return this.commands.refresh(); + } + + push(paths: readonly string[]): Promise { + return this.commands.push(paths); + } + + pull(paths: readonly string[]): Promise { + return this.commands.pull(paths); + } + + delete(paths: readonly string[]): Promise { + return this.commands.delete(paths); + } + + openDiff(path: string): Promise { + return this.commands.openDiff(path); + } + + pushOne(status: FileStatus): Promise { return this.commands.pushOne(status); } + pullOne(status: FileStatus): Promise { return this.commands.pullOne(status); } + deleteLocal(status: FileStatus): Promise { return this.commands.deleteLocal(status); } + loadDiff(path: string): Promise { return this.commands.loadDiff(path); } + openFile(status: FileStatus, newLeaf: boolean): boolean { return this.commands.openFile(status, newLeaf); } + canOpen(status: FileStatus): boolean { return this.commands.canOpen(status); } + revertMove(status: FileStatus): Promise { return this.commands.revertMove(status); } + pushMoveGroup(members: FileStatus[]): Promise { return this.commands.pushMoveGroup(members); } + revertMoveGroup(members: FileStatus[]): Promise { return this.commands.revertMoveGroup(members); } + pushAllModified(): Promise { return this.commands.pushAllModified(); } + pullAllModified(): Promise { return this.commands.pullAllModified(); } +} diff --git a/src/ui/sync-status/SyncStatusNavigator.ts b/src/ui/sync-status/SyncStatusNavigator.ts new file mode 100644 index 0000000..54ced6e --- /dev/null +++ b/src/ui/sync-status/SyncStatusNavigator.ts @@ -0,0 +1,59 @@ +import { type App, TFile } from 'obsidian'; +import type { FileDiff } from '../../logic/sync/types'; +import type { FileStatus } from '../../logic/sync-status-service'; +import { DiffView, SYNC_DIFF_VIEW_TYPE } from '../DiffView'; + +export type SyncStatusOpenTarget = + | { kind: 'local'; file: TFile } + | { kind: 'remote'; url: string }; + +export interface DiffWorkspace { + getDiff(path: string): Promise; + getRemoteFileUrl(path: string): string | null; +} + +/** Owns Obsidian navigation and diff-pane presentation for sync-status rows. */ +export class SyncStatusNavigator { + constructor( + private readonly app: App, + private readonly workspace: DiffWorkspace, + ) {} + + targetFor(status: FileStatus): SyncStatusOpenTarget | null { + if (status.status === 'remote-only') { + const url = this.workspace.getRemoteFileUrl(status.path); + return url ? { kind: 'remote', url } : null; + } + const file = status.file ?? this.app.vault.getFileByPath(status.path); + return file instanceof TFile ? { kind: 'local', file } : null; + } + + openFile(status: FileStatus, newLeaf: boolean): boolean { + const target = this.targetFor(status); + if (!target) return false; + if (target.kind === 'local') void this.app.workspace.getLeaf(newLeaf).openFile(target.file); + else window.open(target.url, '_blank'); + return true; + } + + async loadDiff(path: string): Promise { + await this.workspace.getDiff(path); + } + + async openDiff(path: string): Promise { + const diff = await this.workspace.getDiff(path); + const existing = this.app.workspace.getLeavesOfType(SYNC_DIFF_VIEW_TYPE)[0]; + const leaf = existing ?? this.app.workspace.getLeaf('tab'); + if (!existing) await leaf.setViewState({ type: SYNC_DIFF_VIEW_TYPE, active: true }); + if (leaf.view instanceof DiffView) leaf.view.setDiff(diff); + await this.app.workspace.revealLeaf(leaf); + } + + closeDiffFor(paths: Iterable): void { + const changed = new Set(paths); + for (const leaf of this.app.workspace.getLeavesOfType(SYNC_DIFF_VIEW_TYPE)) { + const shown = leaf.view instanceof DiffView ? leaf.view.getPath() : null; + if (shown !== null && changed.has(shown)) leaf.detach(); + } + } +} diff --git a/src/ui/sync-status/SyncStatusOperations.ts b/src/ui/sync-status/SyncStatusOperations.ts new file mode 100644 index 0000000..96f78ab --- /dev/null +++ b/src/ui/sync-status/SyncStatusOperations.ts @@ -0,0 +1,306 @@ +import { type App, Notice } from 'obsidian'; +import { t, type TranslationKey } from '../../i18n'; +import type { PushResults, SyncPlan } from '../../logic/sync/types'; +import type { SyncWorkspace } from '../../logic/sync/SyncWorkspace'; +import type { FileStatus, SyncStatusService } from '../../logic/sync-status-service'; +import { logger } from '../../utils/logger'; +import { ConfirmModal } from '../ConfirmModal'; +import { SyncPlanModal } from '../SyncPlanModal'; +import type { SyncStatusRefreshService } from '../../logic/sync/SyncStatusRefreshService'; +import type { SyncStatusViewState } from './SyncStatusViewState'; +import type { SyncStatusNavigator } from './SyncStatusNavigator'; + +type BatchFilter = 'modified' | 'selected'; +type SyncOperation = 'push' | 'pull'; + +const NO_RUNNABLE_FILES_KEYS: Record> = { + push: { selected: 'syncStatus.notice.noPushableFiles.selected', found: 'syncStatus.notice.noPushableFiles.found' }, + pull: { selected: 'syncStatus.notice.noPullableFiles.selected', found: 'syncStatus.notice.noPullableFiles.found' }, +}; + +/** Orchestrates sync-status commands while the View only forwards UI events. */ +export class SyncStatusOperations { + constructor( + private readonly app: App, + private readonly workspace: SyncWorkspace, + private readonly statuses: SyncStatusService, + private readonly state: SyncStatusViewState, + private readonly statusRefresh: SyncStatusRefreshService, + private readonly navigator: SyncStatusNavigator, + private readonly render: () => void, + private readonly refresh: () => Promise, + ) {} + + async revertMove(status: FileStatus): Promise { + if (!status.movedFrom) return; + const confirmed = await this.confirm(t('syncStatus.confirmRevertMove', { from: status.path, to: status.movedFrom })); + if (!confirmed) return; + try { + await this.moveBack(status); + new Notice(t('syncStatus.notice.moveReverted', { path: status.movedFrom })); + await this.refresh(); + } catch (error) { + new Notice(t('syncStatus.notice.revertFailed', { message: this.errorMessage(error) })); + } + } + + async pushMoveGroup(members: FileStatus[]): Promise { + try { + const results = await this.workspace.push(members.map(member => member.path)); + this.markSynced(results.syncedPaths); + this.render(); + } catch (error) { + new Notice(t('syncStatus.notice.opFailed', { verb: t('main.verb.push'), message: this.errorMessage(error) })); + } + } + + async revertMoveGroup(members: FileStatus[]): Promise { + if (!await this.confirm(t('syncStatus.confirmRevertMoveGroup', { count: members.length }))) return; + for (const member of members) { + if (!member.movedFrom) continue; + try { + await this.moveBack(member); + } catch (error) { + logger.warn(`Failed to revert move for ${member.path}`, error); + } + } + new Notice(t('syncStatus.notice.moveReverted', { path: `${members.length} file(s)` })); + await this.refresh(); + } + + async deleteLocal(status: FileStatus): Promise { + if (!await this.confirm(t('syncStatus.confirmDeleteLocal', { path: status.path }))) return; + try { + await this.workspace.deleteLocal(status.path); + new Notice(t('syncStatus.notice.deleted', { path: status.path })); + this.statuses.delete(status.path); + this.render(); + } catch (error) { + new Notice(t('syncStatus.notice.deleteFailed', { message: this.errorMessage(error) })); + } + } + + async runSingle(status: FileStatus, operation: SyncOperation): Promise { + const runVerb = operation === 'push' ? t('main.verb.pushing') : t('main.verb.pulling'); + const progress = new Notice(t('syncStatus.notice.opStarted', { verb: runVerb, name: status.path }), 0); + try { + this.statuses.set({ ...status, status: 'checking' }); + this.navigator.closeDiffFor([status.path]); + this.render(); + await this.executeSingle(status, operation); + progress.hide(); + this.render(); + } catch (error) { + progress.hide(); + const verb = operation === 'push' ? t('main.verb.push') : t('main.verb.pull'); + new Notice(t('syncStatus.notice.opFailed', { verb, message: this.errorMessage(error) })); + await this.statusRefresh.refreshFileStatusByContent(status.file || status.path); + this.render(); + } + } + + async runBatch(filter: BatchFilter, operation: SyncOperation): Promise { + const targets = this.runnableStatuses(operation, filter === 'selected' ? this.state.selectedFiles : undefined); + if (targets.length === 0) { + const scope = filter === 'selected' ? 'selected' : 'found'; + new Notice(t(NO_RUNNABLE_FILES_KEYS[operation][scope])); + return; + } + const files = targets.map(status => status.path); + if (!await this.confirmBatch(operation, files.length)) return; + await this.executeBatch(filter, operation, files); + } + + async runPaths(paths: readonly string[], operation: SyncOperation): Promise { + const targets = this.runnableStatuses(operation, new Set(paths)); + if (targets.length === 0) { + new Notice(t(NO_RUNNABLE_FILES_KEYS[operation].selected)); + return; + } + const files = targets.map(status => status.path); + if (!await this.confirmBatch(operation, files.length)) return; + await this.executeBatch('selected', operation, files); + } + + async executeBatch(filter: BatchFilter, operation: SyncOperation, files: string[]): Promise { + const runVerb = operation === 'push' ? t('main.verb.pushing') : t('main.verb.pulling'); + const progress = new Notice(t('main.progress.running', { verb: runVerb, total: files.length }), 0); + this.navigator.closeDiffFor(files); + try { + const results = operation === 'push' + ? await this.workspace.push(files, (current, total, name) => progress.setMessage(t('syncStatus.progress.pushing', { current, total, name }))) + : await this.workspace.pull(files, (current, total, name) => progress.setMessage(t('syncStatus.progress.pulling', { current, total, name }))); + progress.hide(); + if (results.errors.length > 0) logger.error(`${operation} errors:`, results.errors); + if (filter === 'selected') this.state.clearSelection(); + const doneVerb = operation === 'push' ? t('main.verb.push') : t('main.verb.pull'); + new Notice(t('syncStatus.notice.opCompleted', { verb: doneVerb })); + if (operation === 'push') { + this.markSynced((results as PushResults).syncedPaths); + this.render(); + } else { + await this.refresh(); + } + } catch (error) { + progress.hide(); + const verb = operation === 'push' ? t('main.verb.push') : t('main.verb.pull'); + new Notice(t('syncStatus.notice.opFailed', { verb, message: this.errorMessage(error) })); + } + } + + async deletePaths(paths: readonly string[]): Promise { + const targets = [...new Set(paths)] + .map(path => this.statuses.get(path)) + .filter((status): status is FileStatus => status !== undefined); + if (targets.length === 0) { + if (this.state.selectedFiles.size === 0) new Notice(t('syncStatus.notice.noFilesSelected')); + return; + } + const { local, remote } = this.partitionTargets(targets); + if (local.length === 0 && remote.length === 0) { + new Notice(t('syncStatus.notice.nothingToDelete')); + return; + } + if (!await this.confirmDeletion(local, remote)) return; + + const total = local.length + remote.length; + const progress = new Notice(t('syncStatus.progress.deleting', { total }), 0); + const errors: Array<{ path: string; message: string }> = []; + await this.performLocalDeletion(local, total, progress, errors); + await this.performRemoteDeletion(remote, total, local.length, progress, errors); + progress.hide(); + this.notifyDeleteResult(total, errors); + this.render(); + } + + async confirmDeletion(local: FileStatus[], remote: FileStatus[]): Promise { + if (remote.length === 0) return this.confirm(t('syncStatus.confirmDelete.localOnly', { local: local.length })); + const plan: SyncPlan = { + additions: [], + modifications: [], + moves: [], + deletions: remote.map(status => ({ + path: status.path, + name: status.file?.name ?? status.path.split('/').pop() ?? status.path, + })), + }; + const description = local.length > 0 ? t('syncStatus.confirmDelete.alsoLocal', { local: local.length }) : undefined; + return new Promise(resolve => { + new SyncPlanModal(this.app, plan, 'delete', () => resolve(true), () => resolve(false), description).open(); + }); + } + + async performRemoteDeletion( + remote: FileStatus[], + total: number, + localCount: number, + progress: Notice, + errors: Array<{ path: string; message: string }>, + ): Promise { + if (remote.length === 0) return; + const result = await this.workspace.deleteRemote( + remote.map(status => status.path), + (current, path) => progress.setMessage(t('syncStatus.progress.deletingRemote', { + current: localCount + current, + total, + path, + })), + ); + errors.push(...result.errors); + for (const path of result.deletedPaths) { + this.statuses.delete(path); + this.state.deselect(path); + } + } + + private async executeSingle(status: FileStatus, operation: SyncOperation): Promise { + const file = status.file || status.path; + if (operation === 'pull') { + await this.workspace.pullOne(status.path); + await this.statusRefresh.refreshFileStatusByContent(file); + return; + } + const results = await this.workspace.push([status.path]); + const synced = results.syncedPaths.find(path => path.path === status.path); + if (synced) this.markSynced([synced]); + else await this.statusRefresh.refreshFileStatusByContent(file); + } + + private runnableStatuses(operation: SyncOperation, paths?: ReadonlySet): FileStatus[] { + return Array.from(this.statuses.values()).filter(status => { + if (paths && !paths.has(status.path)) return false; + return operation === 'push' + ? ['modified', 'unsynced', 'moved'].includes(status.status) + : ['modified', 'remote-only'].includes(status.status); + }); + } + + private async confirmBatch(operation: SyncOperation, count: number): Promise { + const service = this.workspace.getInfo().serviceName; + const message = operation === 'push' + ? t('syncStatus.confirm.pushSelected', { count, service }) + : t('syncStatus.confirm.pullSelected', { count, service }); + return this.confirm(message); + } + + private markSynced(paths: Array<{ path: string; sha?: string }>): void { + for (const { path, sha } of paths) this.statuses.markSynced(path, sha); + } + + private async moveBack(status: FileStatus): Promise { + const target = status.movedFrom; + if (!target) return; + await this.workspace.moveLocal(status.path, target); + } + + private partitionTargets(targets: FileStatus[]): { local: FileStatus[]; remote: FileStatus[] } { + return { + local: targets.filter(status => status.status !== 'remote-only' && status.status !== 'moved'), + remote: targets.filter(status => status.status === 'remote-only'), + }; + } + + private async performLocalDeletion( + local: FileStatus[], + total: number, + progress: Notice, + errors: Array<{ path: string; message: string }>, + ): Promise { + let current = 0; + for (const status of local) { + current += 1; + progress.setMessage(t('syncStatus.progress.deletingLocal', { current, total, path: status.path })); + try { + await this.workspace.deleteLocal(status.path); + this.statuses.delete(status.path); + this.state.deselect(status.path); + } catch (error) { + errors.push({ path: status.path, message: this.errorMessage(error) }); + } + } + } + + private notifyDeleteResult(total: number, errors: Array<{ path: string; message: string }>): void { + if (errors.length === 0) { + new Notice(t('syncStatus.notice.deleteResult.success', { total })); + return; + } + logger.error('Delete errors:', errors); + new Notice(t('syncStatus.notice.deleteResult.partialWithMessage', { + succeeded: total - errors.length, + total, + failed: errors.length, + message: errors.map(error => error.message).join('; '), + })); + } + + private confirm(message: string): Promise { + return new Promise(resolve => { + new ConfirmModal(this.app, message, () => resolve(true), () => resolve(false)).open(); + }); + } + + private errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); + } +} diff --git a/src/ui/sync-status/SyncStatusRenderer.ts b/src/ui/sync-status/SyncStatusRenderer.ts new file mode 100644 index 0000000..5624458 --- /dev/null +++ b/src/ui/sync-status/SyncStatusRenderer.ts @@ -0,0 +1,373 @@ +import { Platform, debounce, setIcon, setTooltip } from 'obsidian'; +import type { SyncWorkspaceInfo } from '../../logic/sync/SyncWorkspace'; +import type { FileStatus, SyncStatusService } from '../../logic/sync-status-service'; +import { t } from '../../i18n'; +import { renderActionBar } from '../components/ActionBar'; +import { + renderFileItem, + renderMoveGroupItem, + statusMeta, + type FileItemCallbacks, + type MoveGroupCallbacks, +} from '../components/FileListItem'; +import { renderFolderItem, type FolderTreeItemCallbacks } from '../components/FolderTreeItem'; +import { buildStatusTree, type StatusTreeNode } from '../components/StatusTree'; +import { ICONS } from '../components/icons'; +import type { FilterValue } from '../types'; +import type { SyncStatusController } from './SyncStatusController'; +import type { SyncStatusViewState } from './SyncStatusViewState'; +import { + collapsibleMoveGroups, + isMoveGroupExpanded, + isTreeFolderExpanded, + moveGroupKey, + pruneSelection, + searchedStatuses, + sortStatuses, + visibleStatuses, +} from './SyncStatusSelectors'; + +type MoveGroups = Map; + +/** Renders sync-status presentation from state and domain DTOs only. */ +export class SyncStatusRenderer { + constructor( + private readonly workspaceInfo: () => SyncWorkspaceInfo, + private readonly state: SyncStatusViewState, + private readonly statuses: SyncStatusService, + private readonly controller: SyncStatusController, + private readonly rerender: () => void, + ) {} + + render(info: HTMLElement, body: HTMLElement): void { + const scrollTop = body.querySelector('.ssv-list')?.scrollTop ?? 0; + info.empty(); + this.renderInfoStrip(info); + body.empty(); + this.renderTabs(body); + this.renderActionBar(body); + const list = body.createDiv({ cls: 'ssv-list' }); + if (this.state.refreshState.isRefreshing) { + this.renderProgress(list); + this.renderCheckedFiles(list); + } else if (this.statuses.size === 0) { + list.createDiv({ cls: 'ssv-empty', text: t('syncStatus.emptyPrompt') }); + } else { + this.renderFileList(list); + } + list.scrollTop = scrollTop; + } + + renderSearchBox(container: HTMLElement): void { + const row = container.createDiv({ cls: 'ssv-search' }); + setIcon(row.createSpan({ cls: 'ssv-search-icon' }), ICONS.search); + const input = row.createEl('input', { + type: 'text', + cls: 'ssv-search-input', + attr: { placeholder: t('syncStatus.search.placeholder'), spellcheck: 'false' }, + }); + const clear = row.createEl('button', { cls: 'ssv-search-clear' }); + setIcon(clear, ICONS.clear); + setTooltip(clear, t('syncStatus.search.clear')); + const apply = (value: string): void => { + const next = value.trim(); + if (next === this.state.searchQuery) return; + this.state.setSearchQuery(next); + this.pruneSelection(); + row.toggleClass('has-query', next.length > 0); + this.rerender(); + }; + const applyDebounced = debounce(apply, 150, false); + input.addEventListener('input', () => applyDebounced(input.value)); + input.addEventListener('keydown', event => { + if (event.key !== 'Escape' || input.value === '') return; + event.preventDefault(); + input.value = ''; + apply(''); + }); + clear.addEventListener('click', () => { + input.value = ''; + apply(''); + input.focus(); + }); + } + + searchedStatuses(): FileStatus[] { + return searchedStatuses(this.state, Array.from(this.statuses.values())); + } + + visibleStatuses(): FileStatus[] { + return visibleStatuses(this.state, Array.from(this.statuses.values())); + } + + sortStatuses(statuses: FileStatus[]): FileStatus[] { + return sortStatuses(statuses); + } + + pruneSelection(): void { + this.state.retainSelected(pruneSelection(this.state.selectedFiles, this.visibleStatuses())); + } + + renderTabs(container: HTMLElement): void { + const all = this.searchedStatuses(); + const counts: Record = { + all: !this.state.treeViewEnabled || this.state.showSyncedInAll ? all.length : all.filter(status => status.status !== 'synced').length, + synced: all.filter(status => status.status === 'synced').length, + modified: all.filter(status => status.status === 'modified').length, + unsynced: all.filter(status => status.status === 'unsynced').length, + 'remote-only': all.filter(status => status.status === 'remote-only').length, + moved: this.movedRowCount(all), + }; + const tabs: Array<{ value: FilterValue; label: string }> = [ + { value: 'all', label: t('syncStatus.tab.all') }, + { value: 'modified', label: t('syncStatus.tab.modified') }, + { value: 'unsynced', label: t('syncStatus.tab.unsynced') }, + { value: 'remote-only', label: t('syncStatus.tab.remote-only') }, + ...(counts.moved > 0 ? [{ value: 'moved' as const, label: t('syncStatus.tab.moved') }] : []), + { value: 'synced', label: t('syncStatus.tab.synced') }, + ]; + if (Platform.isMobile) { + this.renderMobileFilter(container, tabs, counts); + return; + } + const tabsElement = container.createDiv({ cls: 'ssv-tabs' }); + for (const tab of tabs) { + const button = tabsElement.createEl('button', { cls: `ssv-tab${this.state.statusFilter === tab.value ? ' active' : ''}` }); + if (tab.value !== 'all') setIcon(button.createSpan(), statusMeta(tab.value).icon); + button.createSpan({ cls: 'ssv-tab-label', text: ` ${tab.label}` }); + if (tab.value === 'all' || counts[tab.value] > 0) button.createSpan({ cls: 'ssv-tab-count', text: String(counts[tab.value]) }); + setTooltip(button, tab.label); + button.addEventListener('click', () => this.applyFilter(tab.value)); + } + } + + movedRowCount(statuses: FileStatus[]): number { + const groups = this.collapsibleMoveGroups(statuses); + const groupedPaths = new Set(); + for (const group of groups.values()) for (const member of group.members) groupedPaths.add(member.path); + return statuses.filter(status => status.status === 'moved' && !groupedPaths.has(status.path)).length + groups.size; + } + + collapsibleMoveGroups(displayed: FileStatus[]): MoveGroups { + return collapsibleMoveGroups(displayed, Array.from(this.statuses.values())); + } + + private renderProgress(container: HTMLElement): void { + const { current, total } = this.state.refreshState; + const percentage = total > 0 ? Math.round((current / total) * 100) : 0; + const progress = container.createDiv({ cls: 'ssv-progress' }); + progress.createDiv({ + cls: 'ssv-progress-text', + text: total > 0 + ? t('syncStatus.progress.checkingWithCount', { current, total, pct: percentage }) + : t('syncStatus.progress.checking'), + }); + const bar = progress.createDiv({ cls: 'ssv-progress-bar' }); + bar.createDiv({ cls: 'ssv-progress-fill' }).setAttr('style', `width: ${percentage}%`); + } + + private renderCheckedFiles(container: HTMLElement): void { + const checked = this.visibleStatuses().filter(status => status.status !== 'checking'); + if (checked.length === 0) return; + const list = container.createDiv({ cls: 'ssv-list-checked' }); + const callbacks = this.fileCallbacks(); + for (const status of checked) renderFileItem(list, status, this.state.selectedFiles.has(status.path), callbacks); + } + + private renderInfoStrip(container: HTMLElement): void { + const infoModel = this.workspaceInfo(); + const info = container.createDiv({ cls: 'ssv-info' }); + info.createSpan({ cls: 'ssv-info-item', text: infoModel.serviceName }); + if (!Platform.isMobile) { + info.createSpan({ cls: 'ssv-info-sep', text: '·' }); + const branch = info.createSpan({ cls: 'ssv-info-item' }); + setIcon(branch.createSpan({ cls: 'ssv-info-icon' }), ICONS.branch); + branch.createSpan({ text: ` ${infoModel.branch}` }); + } + if (infoModel.vaultFolder) { + info.createSpan({ cls: 'ssv-info-sep', text: '·' }); + const folder = info.createSpan({ cls: 'ssv-info-item' }); + setIcon(folder.createSpan({ cls: 'ssv-info-icon' }), ICONS.folder); + folder.createSpan({ text: ` ${infoModel.vaultFolder}` }); + } + if (this.state.refreshState.lastSyncTime > 0) { + info.createSpan({ cls: 'ssv-info-sep', text: '·' }); + const date = new Date(this.state.refreshState.lastSyncTime); + info.createSpan({ + cls: 'ssv-info-time', + text: Platform.isMobile + ? date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + : t('syncStatus.lastSync', { time: date.toLocaleTimeString() }), + }); + } + } + + private renderMobileFilter(container: HTMLElement, tabs: Array<{ value: FilterValue; label: string }>, counts: Record): void { + const select = container.createEl('select', { cls: 'ssv-filter-select', attr: { 'aria-label': t('syncStatus.filterByStatus') } }); + for (const tab of tabs) select.createEl('option', { text: `${tab.label} (${counts[tab.value]})`, value: tab.value }); + select.value = this.state.statusFilter; + select.addEventListener('change', () => this.applyFilter(select.value as FilterValue)); + } + + private applyFilter(filter: FilterValue): void { + this.state.setStatusFilter(filter); + this.pruneSelection(); + this.rerender(); + } + + private renderActionBar(container: HTMLElement): void { + const visible = this.visibleStatuses(); + const selected = Array.from(this.state.selectedFiles) + .map(path => this.statuses.get(path)) + .filter((status): status is FileStatus => status !== undefined); + const allSelected = visible.length > 0 && visible.every(status => this.state.selectedFiles.has(status.path)); + renderActionBar(container, { + hasFiles: this.statuses.size > 0, + allSelected, + indeterminate: this.state.selectedFiles.size > 0 && !allSelected, + canPush: selected.filter(status => ['modified', 'unsynced', 'moved'].includes(status.status)).length, + canPull: selected.filter(status => ['modified', 'remote-only'].includes(status.status)).length, + canDelete: selected.filter(status => status.status !== 'moved').length, + treeViewEnabled: this.state.treeViewEnabled, + showSynced: this.state.showSyncedInAll, + }, { + onRefresh: () => void this.controller.refresh(), + onSelectAll: select => { + for (const status of visible) { + if (select) this.state.select(status.path); + else this.state.deselect(status.path); + } + this.rerender(); + }, + onPush: () => void this.controller.push([...this.state.selectedFiles]), + onPull: () => void this.controller.pull([...this.state.selectedFiles]), + onDelete: () => void this.controller.delete([...this.state.selectedFiles]), + onTreeViewChange: enabled => { + this.state.setTreeViewEnabled(enabled); + this.pruneSelection(); + this.rerender(); + }, + onShowSyncedChange: show => { + this.state.setShowSyncedInAll(show); + this.pruneSelection(); + this.rerender(); + }, + }); + } + + private renderFileList(container: HTMLElement): void { + const statuses = this.visibleStatuses(); + if (statuses.length === 0) { + const text = this.state.searchQuery !== '' + ? t('syncStatus.noFilesForSearch', { query: this.state.searchQuery }) + : t('syncStatus.noFilesForFilter', { + filter: this.state.statusFilter === 'all' ? t('syncStatus.tab.all') : statusMeta(this.state.statusFilter).label, + }); + container.createDiv({ cls: 'ssv-empty', text }); + return; + } + if (this.state.treeViewEnabled) this.renderTreeNodes(container, buildStatusTree(statuses).children); + else this.renderFlatList(container, statuses); + } + + private renderFlatList(container: HTMLElement, statuses: FileStatus[]): void { + const groups = this.collapsibleMoveGroups(statuses); + const groupedPaths = new Set(); + for (const group of groups.values()) for (const member of group.members) groupedPaths.add(member.path); + const callbacks = this.fileCallbacks(); + const renderedGroups = new Set(); + for (const status of statuses) { + if (groupedPaths.has(status.path)) this.renderGroupOnce(container, status, groups, renderedGroups); + else renderFileItem(container, status, this.state.selectedFiles.has(status.path), callbacks); + } + } + + private renderTreeNodes(container: HTMLElement, nodes: StatusTreeNode[]): void { + const fileCallbacks = this.fileCallbacks(); + const folderCallbacks = this.folderCallbacks(); + for (const node of nodes) { + if (node.kind === 'file') { + renderFileItem(container, node.status, this.state.selectedFiles.has(node.status.path), fileCallbacks); + continue; + } + const children = renderFolderItem( + container, + node, + this.state.selectedFiles, + isTreeFolderExpanded(this.state.collapsedFolders, node.path), + folderCallbacks, + ); + if (children) this.renderTreeNodes(children, node.children); + } + } + + private renderGroupOnce(container: HTMLElement, status: FileStatus, groups: MoveGroups, rendered: Set): void { + const key = moveGroupKey(status); + if (key === null || rendered.has(key)) return; + rendered.add(key); + const group = groups.get(key); + if (!group) return; + renderMoveGroupItem( + container, + key, + group.oldPrefix, + group.newPrefix, + group.members, + group.members.every(member => this.state.selectedFiles.has(member.path)), + isMoveGroupExpanded(this.state.expandedMoveGroups, key), + this.moveGroupCallbacks(), + ); + } + + fileCallbacks(): FileItemCallbacks { + return { + onSelect: (path, selected) => { + if (selected) this.state.select(path); + else this.state.deselect(path); + this.rerender(); + }, + onPush: status => void this.controller.pushOne(status), + onPull: status => void this.controller.pullOne(status), + onDelete: status => void this.controller.deleteLocal(status), + onExpandDiff: status => this.controller.loadDiff(status.path), + onOpen: (status, newLeaf) => this.controller.openFile(status, newLeaf), + canOpen: status => this.controller.canOpen(status), + onOpenDiffPane: status => void this.controller.openDiff(status.path), + onRevertMove: status => void this.controller.revertMove(status), + }; + } + + private folderCallbacks(): FolderTreeItemCallbacks { + return { + onSelect: (paths, selected) => { + for (const path of paths) { + if (selected) this.state.select(path); + else this.state.deselect(path); + } + this.rerender(); + }, + onToggle: path => { + this.state.toggleCollapsedFolder(path); + this.rerender(); + }, + }; + } + + private moveGroupCallbacks(): MoveGroupCallbacks { + return { + onSelect: (members, selected) => { + for (const member of members) { + if (selected) this.state.select(member.path); + else this.state.deselect(member.path); + } + this.rerender(); + }, + onPush: members => void this.controller.pushMoveGroup(members), + onRevertMove: members => void this.controller.revertMoveGroup(members), + onToggleExpand: key => { + this.state.toggleExpandedMoveGroup(key); + this.rerender(); + }, + }; + } +} diff --git a/src/ui/sync-status/SyncStatusSelectors.ts b/src/ui/sync-status/SyncStatusSelectors.ts new file mode 100644 index 0000000..c7fb865 --- /dev/null +++ b/src/ui/sync-status/SyncStatusSelectors.ts @@ -0,0 +1,126 @@ +import type { FileStatus, FilterValue } from '../types'; + +export interface SyncStatusSelectionState { + readonly statusFilter: FilterValue; + readonly treeViewEnabled: boolean; + readonly showSyncedInAll: boolean; + readonly searchQuery: string; + readonly selectedFiles: ReadonlySet; +} + +export interface MoveGroup { + oldPrefix: string; + newPrefix: string; + members: FileStatus[]; +} + +export function searchedStatuses( + state: Pick, + statuses: readonly FileStatus[], +): FileStatus[] { + if (state.searchQuery === '') return [...statuses]; + const query = state.searchQuery.toLowerCase(); + return statuses.filter(status => status.path.toLowerCase().includes(query)); +} + +export function visibleStatuses( + state: Pick, + statuses: readonly FileStatus[], +): FileStatus[] { + const searched = searchedStatuses(state, statuses); + if (state.statusFilter !== 'all') { + return searched.filter(status => status.status === state.statusFilter); + } + if (!state.treeViewEnabled) return sortStatuses(searched); + return state.showSyncedInAll + ? searched + : searched.filter(status => status.status !== 'synced'); +} + +/** Keeps completed rows at the end of the legacy flat view. */ +export function sortStatuses(statuses: readonly FileStatus[]): FileStatus[] { + return [...statuses].sort((left, right) => Number(left.status === 'synced') - Number(right.status === 'synced')); +} + +export function selectedVisibleFiles( + state: Pick, + visible: readonly FileStatus[], +): FileStatus[] { + return visible.filter(status => state.selectedFiles.has(status.path)); +} + +export function pruneSelection( + selectedFiles: ReadonlySet, + visible: readonly FileStatus[], +): Set { + const visiblePaths = new Set(visible.map(status => status.path)); + return new Set([...selectedFiles].filter(path => visiblePaths.has(path))); +} + +export function isTreeFolderExpanded(collapsedFolders: ReadonlySet, path: string): boolean { + return !collapsedFolders.has(path); +} + +export function isMoveGroupExpanded(expandedMoveGroups: ReadonlySet, key: string): boolean { + return expandedMoveGroups.has(key); +} + +export function moveGroupPrefixes(status: FileStatus): { oldPrefix: string; newPrefix: string } | null { + if (!status.movedFrom) return null; + const oldSegments = status.movedFrom.split('/'); + const newSegments = status.path.split('/'); + let oldIndex = oldSegments.length - 1; + let newIndex = newSegments.length - 1; + while ( + oldIndex >= 1 + && newIndex >= 1 + && oldSegments[oldIndex] === newSegments[newIndex] + ) { + oldIndex -= 1; + newIndex -= 1; + } + return { + oldPrefix: oldSegments.slice(0, oldIndex + 1).join('/'), + newPrefix: newSegments.slice(0, newIndex + 1).join('/'), + }; +} + +export function moveGroupKey(status: FileStatus): string | null { + const prefixes = moveGroupPrefixes(status); + return prefixes ? JSON.stringify(prefixes) : null; +} + +export function collapsibleMoveGroups( + statuses: readonly FileStatus[], + allStatuses: readonly FileStatus[], +): Map { + const candidates = collectMoveGroups(statuses); + const collapsible = new Map(); + for (const [key, group] of candidates) { + if (group.members.length < 2 || isPartialMove(group.oldPrefix, allStatuses)) continue; + collapsible.set(key, group); + } + return collapsible; +} + +function collectMoveGroups(statuses: readonly FileStatus[]): Map { + const groups = new Map(); + for (const status of statuses) { + if (status.status !== 'moved') continue; + const prefixes = moveGroupPrefixes(status); + if (!prefixes) continue; + const key = JSON.stringify(prefixes); + const existing = groups.get(key); + if (existing) existing.members.push(status); + else groups.set(key, { ...prefixes, members: [status] }); + } + return groups; +} + +function isPartialMove(oldPrefix: string, allStatuses: readonly FileStatus[]): boolean { + const childPrefix = `${oldPrefix}/`; + return allStatuses.some(status => ( + status.status !== 'moved' + && (status.path === oldPrefix || status.path.startsWith(childPrefix)) + )); +} diff --git a/src/ui/sync-status/SyncStatusView.ts b/src/ui/sync-status/SyncStatusView.ts new file mode 100644 index 0000000..df28478 --- /dev/null +++ b/src/ui/sync-status/SyncStatusView.ts @@ -0,0 +1,251 @@ +import { ItemView, WorkspaceLeaf, TFile, Notice, debounce } from 'obsidian'; +import GitLabFilesPush from '../../main'; +import { logger } from '../../utils/logger'; +import { type FileStatus, type FilterValue } from '../types'; +import type { FileItemCallbacks } from '../components/FileListItem'; +import { t } from '../../i18n'; +import { SyncStatusService } from '../../logic/sync-status-service'; +import { SyncStatusRefreshService } from '../../logic/sync/SyncStatusRefreshService'; +import type { SyncWorkspace } from '../../logic/sync/SyncWorkspace'; +import { SyncStatusViewState } from './SyncStatusViewState'; +import { SyncStatusController } from './SyncStatusController'; +import { SyncStatusNavigator, type SyncStatusOpenTarget } from './SyncStatusNavigator'; +import { SyncStatusOperations } from './SyncStatusOperations'; +import { SyncStatusRenderer } from './SyncStatusRenderer'; +import { createSyncStatusComposition } from './SyncStatusComposition'; +import { + moveGroupKey, + moveGroupPrefixes, +} from './SyncStatusSelectors'; + +export const SYNC_STATUS_VIEW_TYPE = 'sync-status-view'; + +export class SyncStatusView extends ItemView { + private static readonly RENDER_THROTTLE_MS = 150; + plugin: GitLabFilesPush; + private readonly viewState = new SyncStatusViewState(); + private readonly controller: SyncStatusController; + private readonly statusRefresh: SyncStatusRefreshService; + private readonly navigator: SyncStatusNavigator; + private readonly operations: SyncStatusOperations; + private readonly renderer: SyncStatusRenderer; + private readonly workspace: SyncWorkspace; + private unsubscribeStatuses?: () => void; + private readonly detachedStatusService = new SyncStatusService(); + private readonly renderStatusChanges = debounce( + () => this.renderView(), + SyncStatusView.RENDER_THROTTLE_MS, + false, + ); + private infoEl?: HTMLElement; + private bodyEl?: HTMLElement; + + constructor(leaf: WorkspaceLeaf, plugin: GitLabFilesPush, controller?: SyncStatusController) { + super(leaf); + this.plugin = plugin; + const composition = createSyncStatusComposition( + this.app, + this.plugin, + this.viewState, + this.fileStatuses, + { + render: () => this.renderView(), + refresh: () => this.refreshAllStatuses(), + refreshStatuses: () => this.refreshStatuses(), + }, + controller, + ); + this.statusRefresh = composition.statusRefresh; + this.navigator = composition.navigator; + this.operations = composition.operations; + this.controller = composition.controller; + this.renderer = composition.renderer; + this.workspace = composition.workspace; + } + + private get isRefreshing(): boolean { return this.viewState.refreshState.isRefreshing; } + private set isRefreshing(value: boolean) { this.viewState.refreshState.isRefreshing = value; } + private get refreshProgress(): { current: number; total: number } { return this.viewState.refreshState; } + private set refreshProgress(value: { current: number; total: number }) { + this.viewState.updateRefreshProgress(value.current, value.total); + } + private get statusFilter(): FilterValue { return this.viewState.statusFilter; } + private set statusFilter(value: FilterValue) { this.viewState.setStatusFilter(value); } + private get treeViewEnabled(): boolean { return this.viewState.treeViewEnabled; } + private set treeViewEnabled(value: boolean) { this.viewState.setTreeViewEnabled(value); } + private get showSyncedInAll(): boolean { return this.viewState.showSyncedInAll; } + private set showSyncedInAll(value: boolean) { this.viewState.setShowSyncedInAll(value); } + private get searchQuery(): string { return this.viewState.searchQuery; } + private set searchQuery(value: string) { this.viewState.setSearchQuery(value); } + private get selectedFiles(): Set { return this.viewState.selectedFiles; } + private get collapsedFolders(): Set { return this.viewState.collapsedFolders; } + private get expandedMoveGroups(): Set { return this.viewState.expandedMoveGroups; } + private get lastSyncTime(): number { return this.viewState.refreshState.lastSyncTime; } + private set lastSyncTime(value: number) { this.viewState.refreshState.lastSyncTime = value; } + + private get fileStatuses(): SyncStatusService { + return this.plugin.sync?.status ?? this.detachedStatusService; + } + + getViewType(): string { return SYNC_STATUS_VIEW_TYPE; } + getDisplayText(): string { return t('syncStatus.viewTitle'); } + getIcon(): string { return 'git-compare'; } + + onOpen(): Promise { + const container = this.containerEl.children[1] as HTMLElement | null; + if (!container) return Promise.resolve(); + container.empty(); + container.addClass('sync-status-view'); + const header = container.createDiv({ cls: 'ssv-header' }); + this.infoEl = header.createDiv({ cls: 'ssv-info-slot' }); + this.renderer.renderSearchBox(header); + this.bodyEl = container.createDiv({ cls: 'ssv-body' }); + this.unsubscribeStatuses = this.fileStatuses.subscribe(() => this.renderStatusChanges()); + this.renderView(); + return Promise.resolve(); + } + + private renderView(): void { + if (this.infoEl && this.bodyEl) this.renderer.render(this.infoEl, this.bodyEl); + } + + private searchedStatuses(): FileStatus[] { return this.renderer.searchedStatuses(); } + private visibleStatuses(): FileStatus[] { return this.renderer.visibleStatuses(); } + private sortAllStatuses(statuses: FileStatus[]): FileStatus[] { return this.renderer.sortStatuses(statuses); } + private movedRowCount(statuses: FileStatus[]): number { return this.renderer.movedRowCount(statuses); } + private fileItemCallbacks(): FileItemCallbacks { return this.renderer.fileCallbacks(); } + + async refreshAllStatuses(): Promise { await this.controller.refresh(); } + + private async refreshStatuses(): Promise { + if (this.isRefreshing) { + new Notice(t('syncStatus.notice.alreadyRefreshing')); + return; + } + this.viewState.startRefresh(); + this.renderView(); + try { + const result = await this.workspace.refresh(({ current, total }) => { + this.viewState.updateRefreshProgress(current, total); + this.renderStatusChanges(); + }); + this.viewState.finishRefresh(Date.now()); + this.renderView(); + new Notice(t('syncStatus.notice.refreshed', { local: result.localCount, remote: result.remoteCount })); + } catch (error) { + this.viewState.finishRefresh(); + this.renderView(); + new Notice(t('syncStatus.notice.refreshFailed', { message: error instanceof Error ? error.message : String(error) })); + } + } + + private async pushMoveGroup(members: FileStatus[]): Promise { await this.operations.pushMoveGroup(members); } + private async revertMoveGroup(members: FileStatus[]): Promise { await this.operations.revertMoveGroup(members); } + private async handleLocalDelete(status: FileStatus): Promise { await this.operations.deleteLocal(status); } + private async runSingleFile(status: FileStatus, operation: 'push' | 'pull'): Promise { + await this.operations.runSingle(status, operation); + } + + private pruneSelectionToVisible(): void { + this.renderer.pruneSelection(); + } + + + private renderTabs(container: HTMLElement): void { + this.renderer.renderTabs(container); + } + + private async revertMove(fileStatus: FileStatus): Promise { + await this.operations.revertMove(fileStatus); + } + + private async openDiffPane(fileStatus: FileStatus): Promise { + if (!this.fileStatuses.has(fileStatus.path)) this.fileStatuses.set(fileStatus); + await this.navigator.openDiff(fileStatus.path); + } + + private async openDiffPath(path: string): Promise { + if (this.fileStatuses.has(path)) await this.navigator.openDiff(path); + } + + private closeDiffPaneFor(paths: Iterable): void { + this.navigator.closeDiffFor(paths); + } + + private openTargetFor(fileStatus: FileStatus): SyncStatusOpenTarget | null { + return this.navigator.targetFor(fileStatus); + } + + private openFileFromRow(fileStatus: FileStatus, newLeaf: boolean): boolean { + return this.navigator.openFile(fileStatus, newLeaf); + } + + private async loadDiffContent(fileStatus: FileStatus): Promise { + try { + if (!this.fileStatuses.has(fileStatus.path)) this.fileStatuses.set(fileStatus); + await this.navigator.loadDiff(fileStatus.path); + } catch (e) { + logger.warn(`Failed to load diff content for ${fileStatus.path}`, e); + } + } + + private groupKey(fs: FileStatus): string | null { + return moveGroupKey(fs); + } + + private groupPrefixes(fs: FileStatus): { oldPrefix: string; newPrefix: string } | null { + return moveGroupPrefixes(fs); + } + + private collapsibleMoveGroups(statuses: FileStatus[]): Map { + return this.renderer.collapsibleMoveGroups(statuses); + } + + async handleFileModified(file: TFile): Promise { + if (await this.statusRefresh.handleFileModified(file)) this.renderView(); + } + + handleFileRenamed(file: TFile, oldPath: string): void { + if (this.statusRefresh.handleFileRenamed(file, oldPath)) this.renderView(); + } + + async pushAllModified(): Promise { await this.controller.pushAllModified(); } + async pullAllModified(): Promise { await this.controller.pullAllModified(); } + async pushSelected(): Promise { await this.controller.push([...this.selectedFiles]); } + async pullSelected(): Promise { await this.controller.pull([...this.selectedFiles]); } + + private async runBatchOperation(filter: 'modified' | 'selected', op: 'push' | 'pull'): Promise { + await this.operations.runBatch(filter, op); + } + + private async runPathBatchOperation(paths: readonly string[], op: 'push' | 'pull'): Promise { + await this.operations.runPaths(paths, op); + } + + private async executeBatchOperation(filter: 'modified' | 'selected', op: 'push' | 'pull', files: Array): Promise { + await this.operations.executeBatch(filter, op, files.map(file => typeof file === 'string' ? file : file.path)); + } + + async deleteSelected(): Promise { + await this.controller.delete([...this.selectedFiles]); + } + + private async deletePaths(paths: readonly string[]): Promise { + await this.operations.deletePaths(paths); + } + + private async confirmDeletion(local: FileStatus[], remote: FileStatus[]): Promise { + return this.operations.confirmDeletion(local, remote); + } + + private async performRemoteDeletion(remote: FileStatus[], total: number, localCount: number, prog: Notice, errors: { path: string, message: string }[]): Promise { + await this.operations.performRemoteDeletion(remote, total, localCount, prog, errors); + } + + onClose(): Promise { + this.unsubscribeStatuses?.(); + this.unsubscribeStatuses = undefined; + return Promise.resolve(); + } + +} diff --git a/src/ui/sync-status/SyncStatusViewState.ts b/src/ui/sync-status/SyncStatusViewState.ts new file mode 100644 index 0000000..8afd12d --- /dev/null +++ b/src/ui/sync-status/SyncStatusViewState.ts @@ -0,0 +1,92 @@ +import type { FilterValue } from '../types'; + +export interface SyncStatusRefreshState { + isRefreshing: boolean; + current: number; + total: number; + lastSyncTime: number; +} + +/** Mutable presentation state for SyncStatusView. Domain file state lives elsewhere. */ +export class SyncStatusViewState { + statusFilter: FilterValue = 'all'; + treeViewEnabled = true; + showSyncedInAll = false; + searchQuery = ''; + readonly selectedFiles = new Set(); + readonly collapsedFolders = new Set(); + readonly expandedMoveGroups = new Set(); + readonly refreshState: SyncStatusRefreshState = { + isRefreshing: false, + current: 0, + total: 0, + lastSyncTime: 0, + }; + + setStatusFilter(filter: FilterValue): void { + this.statusFilter = filter; + } + + setTreeViewEnabled(enabled: boolean): void { + this.treeViewEnabled = enabled; + } + + setShowSyncedInAll(show: boolean): void { + this.showSyncedInAll = show; + } + + setSearchQuery(query: string): void { + this.searchQuery = query.trim(); + } + + select(path: string): void { + this.selectedFiles.add(path); + } + + deselect(path: string): void { + this.selectedFiles.delete(path); + } + + retainSelected(visiblePaths: ReadonlySet): void { + for (const path of this.selectedFiles) { + if (!visiblePaths.has(path)) this.selectedFiles.delete(path); + } + } + + clearSelection(): void { + this.selectedFiles.clear(); + } + + toggleCollapsedFolder(path: string): void { + this.toggleSetValue(this.collapsedFolders, path); + } + + toggleExpandedMoveGroup(key: string): void { + this.toggleSetValue(this.expandedMoveGroups, key); + } + + startRefresh(): void { + this.refreshState.isRefreshing = true; + this.refreshState.current = 0; + this.refreshState.total = 0; + } + + updateRefreshProgress(current: number, total: number): void { + this.refreshState.current = current; + this.refreshState.total = total; + } + + incrementRefreshProgress(): void { + this.refreshState.current += 1; + } + + finishRefresh(lastSyncTime = this.refreshState.lastSyncTime): void { + this.refreshState.isRefreshing = false; + this.refreshState.lastSyncTime = lastSyncTime; + } + + private toggleSetValue(values: Set, value: string): void { + if (values.has(value)) values.delete(value); + else values.add(value); + } +} diff --git a/src/ui/types.ts b/src/ui/types.ts index 8fb6ef6..f4b9c81 100644 --- a/src/ui/types.ts +++ b/src/ui/types.ts @@ -1,38 +1,5 @@ export type { FileStatus } from '../logic/sync-status-service'; +export type { SyncPlan, SyncPlanEntry } from '../logic/sync/types'; +export { isSyncPlanEmpty } from '../logic/sync/types'; export type FilterValue = 'all' | 'synced' | 'modified' | 'unsynced' | 'remote-only' | 'moved'; - -/** One file that a sync plan would touch. */ -export interface SyncPlanEntry { - path: string; - name: string; - /** For a move: the path it would move from. */ - movedFrom?: string; -} - -/** - * The full set of changes a push, pull, or remote deletion would apply, - * computed before anything is written so it can be shown for review. Only - * entries that would actually be written appear here — files that are - * already in sync or skipped due to a conflict are left out. - * - * `acceptedRemote`/`skippedConflicts` are only populated for a batch push - * whose conflicts have already been resolved via `BatchConflictResolutionModal`: - * they don't represent remote writes, just what this confirmation covers. - */ -export interface SyncPlan { - additions: SyncPlanEntry[]; - modifications: SyncPlanEntry[]; - deletions: SyncPlanEntry[]; - moves: SyncPlanEntry[]; - /** Conflicts resolved as "keep remote" — applied locally only after the batch commit succeeds. */ - acceptedRemote?: SyncPlanEntry[]; - /** Conflicts resolved as "skip" — left untouched on both sides. */ - skippedConflicts?: SyncPlanEntry[]; -} - -export function isSyncPlanEmpty(plan: SyncPlan): boolean { - return plan.additions.length === 0 && plan.modifications.length === 0 - && plan.deletions.length === 0 && plan.moves.length === 0 - && !plan.acceptedRemote?.length && !plan.skippedConflicts?.length; -} diff --git a/tests/ci-workflow.test.ts b/tests/ci-workflow.test.ts new file mode 100644 index 0000000..ee42c31 --- /dev/null +++ b/tests/ci-workflow.test.ts @@ -0,0 +1,18 @@ +// CI contract tests run in Node and intentionally inspect the committed workflow file. +// eslint-disable-next-line import/no-nodejs-modules +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +const workflow = readFileSync('.github/workflows/ci.yml', 'utf8'); + +describe('CI workflow contracts', () => { + it('retries transient provider failures three times', () => { + expect(workflow).toContain('max_attempts: 3'); + }); + + it('does not fail or continue downstream CI when a provider run is replaced', () => { + expect(workflow).toContain('if [ "$result" = "cancelled" ]; then'); + expect(workflow).toContain('echo "run-ci=false" >> "$GITHUB_OUTPUT"'); + expect(workflow).toContain("if: needs.e2e-gate.outputs.run-ci == 'true'"); + }); +}); diff --git a/tests/e2e/push-result-diagnostic.test.ts b/tests/e2e/push-result-diagnostic.test.ts new file mode 100644 index 0000000..5daf6e5 --- /dev/null +++ b/tests/e2e/push-result-diagnostic.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest'; +import { describePushResult } from '../../e2e/support/push-result-diagnostic'; + +describe('describePushResult', () => { + it('includes provider errors in a failed push assertion', () => { + expect(describePushResult({ + success: 0, + failed: 1, + errors: [{ file: 'new.md', error: 'GitHub returned 503' }], + })).toContain('GitHub returned 503'); + }); +}); diff --git a/tests/integration/sync/SyncWorkspace.conflict.test.ts b/tests/integration/sync/SyncWorkspace.conflict.test.ts new file mode 100644 index 0000000..f8ef75a --- /dev/null +++ b/tests/integration/sync/SyncWorkspace.conflict.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it, vi } from 'vitest'; +import { SyncPlanner } from '../../../src/logic/sync/SyncPlanner'; +import { BoundarySyncWorkspace } from '../../../src/logic/sync/SyncWorkspace'; +import { createSyncManagerMocks } from '../../logic/sync-manager-test-helpers'; + +vi.mock('obsidian'); + +describe('SyncWorkspace conflict integration', () => { + it('keeps planner conflict output and diff DTO behind the workspace boundary', async () => { + const { manager } = createSyncManagerMocks(); + const planner = new SyncPlanner(); + const classification = planner.classify({ + local: { path: 'a.md', exists: true, blobSha: 'local', kind: 'text' }, + remote: { path: 'a.md', repoPath: 'a.md', exists: true, blobSha: 'remote', kind: 'text' }, + base: { blobSha: 'base' }, + }); + const workspace = new BoundarySyncWorkspace(() => manager, { + refresh: vi.fn(), + deleteRemote: vi.fn(), + getDiff: vi.fn().mockResolvedValue({ path: 'a.md', localContent: 'local', remoteContent: 'remote', kind: 'text' }), + }); + + expect(classification).toBe('conflict'); + await expect(workspace.getDiff('a.md')).resolves.toEqual({ + path: 'a.md', localContent: 'local', remoteContent: 'remote', kind: 'text', + }); + }); +}); diff --git a/tests/integration/sync/SyncWorkspace.pull.test.ts b/tests/integration/sync/SyncWorkspace.pull.test.ts new file mode 100644 index 0000000..dcfdcdc --- /dev/null +++ b/tests/integration/sync/SyncWorkspace.pull.test.ts @@ -0,0 +1,34 @@ +/* eslint-disable @typescript-eslint/unbound-method */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { BoundarySyncWorkspace } from '../../../src/logic/sync/SyncWorkspace'; +import { createSyncManagerMocks } from '../../logic/sync-manager-test-helpers'; +import { SyncPlanModal } from '../../../src/ui/SyncPlanModal'; + +vi.mock('obsidian'); +vi.mock('../../../src/ui/SyncPlanModal'); + +describe('SyncWorkspace pull integration', () => { + beforeEach(() => { + vi.mocked(SyncPlanModal).mockImplementation(function ( + this: SyncPlanModal, _app: unknown, _plan: unknown, _direction: unknown, onConfirm: () => void, + ) { + onConfirm(); + return this; + } as never); + }); + + it('creates a remote-only file through the real pull executor', async () => { + const { manager, mockAdapter, mockGitService } = createSyncManagerMocks(); + mockAdapter.exists.mockResolvedValue(false); + mockGitService.listFilesDetailed.mockResolvedValue([{ path: 'remote.md', sha: 'sha', symlink: false }]); + mockGitService.getFile.mockResolvedValue({ sha: 'sha', content: 'remote' }); + const workspace = new BoundarySyncWorkspace(() => manager, { + refresh: vi.fn(), deleteRemote: vi.fn(), getDiff: vi.fn(), + }); + + const result = await workspace.pull(['remote.md']); + + expect(result.success).toBe(1); + expect(mockAdapter.write).toHaveBeenCalledWith('remote.md', 'remote'); + }); +}); diff --git a/tests/integration/sync/SyncWorkspace.push.test.ts b/tests/integration/sync/SyncWorkspace.push.test.ts new file mode 100644 index 0000000..83d35a9 --- /dev/null +++ b/tests/integration/sync/SyncWorkspace.push.test.ts @@ -0,0 +1,36 @@ +/* eslint-disable @typescript-eslint/unbound-method */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { BoundarySyncWorkspace } from '../../../src/logic/sync/SyncWorkspace'; +import { createSyncManagerMocks } from '../../logic/sync-manager-test-helpers'; +import { SyncPlanModal } from '../../../src/ui/SyncPlanModal'; + +vi.mock('obsidian'); +vi.mock('../../../src/ui/SyncPlanModal'); + +describe('SyncWorkspace push integration', () => { + beforeEach(() => { + vi.mocked(SyncPlanModal).mockImplementation(function ( + this: SyncPlanModal, _app: unknown, _plan: unknown, _direction: unknown, onConfirm: () => void, + ) { + onConfirm(); + return this; + } as never); + }); + + it('runs the real scanner/planner/push executor through the manager facade', async () => { + const { manager, mockAdapter, mockGitService } = createSyncManagerMocks(); + mockAdapter.exists.mockResolvedValue(true); + mockAdapter.read.mockResolvedValue('local'); + mockGitService.listFilesDetailed.mockResolvedValue([]); + mockGitService.pushFile.mockResolvedValue({ path: 'a.md', sha: 'sha' }); + const workspace = new BoundarySyncWorkspace(() => manager, { + refresh: vi.fn(), deleteRemote: vi.fn(), getDiff: vi.fn(), + }); + + const result = await workspace.push(['a.md']); + + expect(result.success).toBe(1); + expect(mockGitService.pushFile).toHaveBeenCalledWith('a.md', 'local', 'main', expect.any(String), undefined, undefined); + expect(manager.status.get('a.md')).toBeUndefined(); + }); +}); diff --git a/tests/integration/sync/SyncWorkspace.refresh.test.ts b/tests/integration/sync/SyncWorkspace.refresh.test.ts new file mode 100644 index 0000000..3a83657 --- /dev/null +++ b/tests/integration/sync/SyncWorkspace.refresh.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, vi } from 'vitest'; +import { BoundarySyncWorkspace } from '../../../src/logic/sync/SyncWorkspace'; +import { SyncStatusRefreshService } from '../../../src/logic/sync/SyncStatusRefreshService'; +import { createSyncManagerMocks } from '../../logic/sync-manager-test-helpers'; + +vi.mock('obsidian'); + +describe('SyncWorkspace refresh integration', () => { + it('exposes the shared status snapshot populated by the real refresh service', async () => { + const { manager, mockApp, mockAdapter, mockGitService, mockSettings } = createSyncManagerMocks(); + mockGitService.listFilesDetailed.mockResolvedValue([ + { path: 'remote.md', sha: 'remote-sha', symlink: false }, + ]); + mockApp.vault.getFiles = vi.fn().mockReturnValue([]); + mockApp.vault.getAbstractFileByPath = vi.fn().mockReturnValue(null); + mockAdapter.list = vi.fn().mockResolvedValue({ files: [], folders: [] }); + mockAdapter.stat = vi.fn().mockResolvedValue(null); + const gitignore = { + loadGitignores: vi.fn().mockResolvedValue(undefined), + isIgnored: vi.fn().mockReturnValue(false), + }; + const refreshService = new SyncStatusRefreshService({ + app: mockApp, + settings: () => mockSettings, + gitService: () => mockGitService, + gitignoreManager: () => gitignore as never, + syncManager: () => manager, + filterFilesByVaultFolder: files => files, + filterPathByVaultFolder: () => true, + getNormalizedPath: path => path, + getVaultPath: path => path, + }, manager.status); + const refresh = vi.fn(() => refreshService.refresh()); + const workspace = new BoundarySyncWorkspace(() => manager, { + refresh, deleteRemote: vi.fn(), getDiff: vi.fn(), + }); + + await workspace.refresh(); + + expect(workspace.getStatuses()).toEqual([{ path: 'remote.md', status: 'remote-only' }]); + expect(gitignore.loadGitignores).toHaveBeenCalledOnce(); + }); +}); diff --git a/tests/logic/sync-manager-batch.test.ts b/tests/logic/sync-manager-batch.test.ts index dd92c49..0f4be05 100644 --- a/tests/logic/sync-manager-batch.test.ts +++ b/tests/logic/sync-manager-batch.test.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/unbound-method */ import { describe, it, expect, vi, beforeEach, Mocked } from 'vitest'; import { SyncManager, BatchPushConflict, ConflictResolution } from '../../src/logic/sync-manager'; +import { ObsidianSyncInteraction } from '../../src/ui/ObsidianSyncInteraction'; import { App, DataAdapter, TFile } from 'obsidian'; import { GitLabFilesPushSettings } from '../../src/settings'; import { GitServiceInterface } from '../../src/services/git-service-interface'; @@ -88,7 +89,7 @@ describe('SyncManager Batch Operations', () => { syncMetadata: {}, } as unknown as GitLabFilesPushSettings; - manager = new SyncManager(mockApp, mockGitService, mockSettings); + manager = new SyncManager(mockApp, mockGitService, mockSettings, undefined, undefined, undefined, new ObsidianSyncInteraction(mockApp)); // @ts-ignore - accessing private for test manager.saveSettings = vi.fn().mockResolvedValue(undefined); }); @@ -360,6 +361,26 @@ describe('SyncManager Batch Operations', () => { expect(adapter.write).not.toHaveBeenCalled(); }); + it('pulls a remote-only change when the local file still matches the baseline', async () => { + const path = 'remote-edited.md'; + const adapter = mockApp.vault.adapter as Mocked; + const baseSha = await gitBlobSha('base content'); + mockSettings.syncMetadata = { + [path]: { lastSyncedSha: baseSha, lastSyncedAt: 0, lastKnownPath: path } + }; + vi.mocked(adapter.exists).mockResolvedValue(true); + vi.mocked(adapter.read).mockResolvedValue('base content'); + vi.mocked(mockGitService.listFilesDetailed).mockResolvedValue([ + { path, symlink: false, sha: 'remote-edit-sha' } + ]); + vi.mocked(mockGitService.getFile).mockResolvedValue({ content: 'remote edit', sha: 'remote-edit-sha' }); + + const results = await manager.pullAllFiles([path]); + + expect(results).toMatchObject({ success: 1, conflicts: 0 }); + expect(adapter.write).toHaveBeenCalledWith(path, 'remote edit'); + }); + it('migrates a legacy GitLab last_commit_id baseline instead of creating a false pull conflict', async () => { const path = 'legacy.md'; const adapter = mockApp.vault.adapter as Mocked; diff --git a/tests/logic/sync-manager-mapping.test.ts b/tests/logic/sync-manager-mapping.test.ts index 443f9f9..34ec21b 100644 --- a/tests/logic/sync-manager-mapping.test.ts +++ b/tests/logic/sync-manager-mapping.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { SyncManager } from '../../src/logic/sync-manager'; +import { ObsidianSyncInteraction } from '../../src/ui/ObsidianSyncInteraction'; import { App, TFile } from 'obsidian'; import { SyncPlanModal, SyncPlanDirection } from '../../src/ui/SyncPlanModal'; @@ -78,7 +79,7 @@ describe('SyncManager Mapping', () => { return this; } as never); mockSettings.syncMetadata = {}; - manager = new SyncManager(mockApp, mockGitService, mockSettings); + manager = new SyncManager(mockApp, mockGitService, mockSettings, undefined, undefined, undefined, new ObsidianSyncInteraction(mockApp)); }); it('should strip vaultFolder when pushing', async () => { @@ -123,7 +124,7 @@ describe('SyncManager Mapping', () => { it('should handle root-level files correctly when no vaultFolder', async () => { mockSettings.vaultFolder = ''; - manager = new SyncManager(mockApp, mockGitService, mockSettings); + manager = new SyncManager(mockApp, mockGitService, mockSettings, undefined, undefined, undefined, new ObsidianSyncInteraction(mockApp)); const path = 'root.md'; const mockFile = Object.assign(new TFile(), { path, name: 'root.md' }); diff --git a/tests/logic/sync-manager-test-helpers.ts b/tests/logic/sync-manager-test-helpers.ts index 87f8314..45ccaa1 100644 --- a/tests/logic/sync-manager-test-helpers.ts +++ b/tests/logic/sync-manager-test-helpers.ts @@ -1,5 +1,6 @@ import { vi, Mocked } from 'vitest'; import { SyncManager } from '../../src/logic/sync-manager'; +import { ObsidianSyncInteraction } from '../../src/ui/ObsidianSyncInteraction'; import { App, DataAdapter } from 'obsidian'; import { GitLabFilesPushSettings } from '../../src/settings'; import { GitServiceInterface } from '../../src/services/git-service-interface'; @@ -55,7 +56,7 @@ export function createSyncManagerMocks(): SyncManagerMocks { rootPath: '', } as unknown as GitLabFilesPushSettings; - const manager = new SyncManager(mockApp, mockGitService, mockSettings); + const manager = new SyncManager(mockApp, mockGitService, mockSettings, undefined, undefined, undefined, new ObsidianSyncInteraction(mockApp)); // @ts-ignore - accessing private for test manager.saveSettings = vi.fn().mockResolvedValue(undefined); diff --git a/tests/logic/sync-manager.test.ts b/tests/logic/sync-manager.test.ts index a8d3322..cc5ba2c 100644 --- a/tests/logic/sync-manager.test.ts +++ b/tests/logic/sync-manager.test.ts @@ -6,7 +6,9 @@ import { SyncManager, BatchPushConflict, ConflictResolution } from '../../src/lo import { App, TFile } from 'obsidian'; import { SyncPlanModal, SyncPlanDirection } from '../../src/ui/SyncPlanModal'; import { BatchConflictResolutionModal } from '../../src/ui/BatchConflictResolutionModal'; +import { SyncConflictModal } from '../../src/ui/SyncConflictModal'; import { gitBlobSha } from '../../src/utils/git-blob-sha'; +import { ObsidianSyncInteraction } from '../../src/ui/ObsidianSyncInteraction'; vi.mock('../../src/ui/SyncConflictModal'); // Every push/pull now shows a plan for review before applying. These tests @@ -108,7 +110,7 @@ describe('SyncManager', () => { mockSettings.syncMetadata = {}; // Default: file exists in vault vi.spyOn(mockApp.vault, 'getFileByPath').mockReturnValue(new TFile()); - manager = new SyncManager(mockApp, mockGitLab, mockSettings); + manager = new SyncManager(mockApp, mockGitLab, mockSettings, undefined, undefined, undefined, new ObsidianSyncInteraction(mockApp)); }); it('publishes a confirmed synced status whenever it records sync metadata', async () => { @@ -126,6 +128,8 @@ describe('SyncManager', () => { mockSettings, undefined, (path) => path === 'private.md', + undefined, + new ObsidianSyncInteraction(mockApp), ); const file = Object.assign(new TFile(), { path: 'private.md', name: 'private.md' }); const readSpy = vi.spyOn(mockApp.vault, 'read').mockResolvedValue('secret'); @@ -703,6 +707,24 @@ describe('SyncManager', () => { await manager.pullFile(mockFile); expect(consoleSpy).toHaveBeenCalled(); }); + + it('pulls a remote-only change without opening conflict resolution', async () => { + const path = 'remote-edited.md'; + const file = Object.assign(new TFile(), { path, name: path }); + const baseSha = await gitBlobSha('base content'); + mockSettings.syncMetadata[path] = { + lastSyncedSha: baseSha, + lastSyncedAt: 0, + lastKnownPath: path, + }; + vi.spyOn(mockApp.vault, 'read').mockResolvedValue('base content'); + vi.mocked(mockGitLab.getFile).mockResolvedValue({ content: 'remote edit', sha: 'remote-edit-sha' }); + + await manager.pullFile(file); + + expect(mockApp.vault.modify).toHaveBeenCalledWith(file, 'remote edit'); + expect(SyncConflictModal).not.toHaveBeenCalled(); + }); }); describe('Plan preview (issue #63)', () => { diff --git a/tests/logic/sync/ConflictResolver.test.ts b/tests/logic/sync/ConflictResolver.test.ts new file mode 100644 index 0000000..96ef7ec --- /dev/null +++ b/tests/logic/sync/ConflictResolver.test.ts @@ -0,0 +1,53 @@ +/* eslint-disable @typescript-eslint/unbound-method */ +import { describe, expect, it, vi } from 'vitest'; +import { ConflictResolver } from '../../../src/logic/sync/ConflictResolver'; +import type { PullExecutor } from '../../../src/logic/sync/PullExecutor'; +import type { GitServiceInterface } from '../../../src/services/git-service-interface'; +import type { BatchPushConflict, PushResults } from '../../../src/logic/sync/types'; + +const conflict = (path: string, sha = 'reviewed'): BatchPushConflict => ({ + path, + name: path, + repoPath: path, + localContent: 'local', + remoteSha: sha, +}); + +function results(): PushResults { + return { success: 0, failed: 0, conflicts: 0, resolvedConflicts: 0, skippedConflicts: 0, errors: [], syncedPaths: [] }; +} + +describe('ConflictResolver', () => { + it('detects a remote that changed after review', async () => { + const service = { getFile: vi.fn().mockResolvedValue({ sha: 'new' }) } as unknown as GitServiceInterface; + const resolver = new ConflictResolver(() => service, () => 'main', {} as PullExecutor); + + await expect(resolver.findStale([conflict('a.md')])).resolves.toEqual([conflict('a.md')]); + }); + + it('applies the exact reviewed blob and records success', async () => { + const service = { getBlob: vi.fn().mockResolvedValue({ sha: 'reviewed', content: 'remote' }) } as unknown as GitServiceInterface; + const pull = { pull: vi.fn().mockResolvedValue(undefined) } as unknown as PullExecutor; + const resolver = new ConflictResolver(() => service, () => 'main', pull); + const output = results(); + + await resolver.applyRemote([conflict('a.md')], output); + + expect(service.getBlob).toHaveBeenCalledWith('reviewed', 'a.md'); + expect(pull.pull).toHaveBeenCalledWith({ path: 'a.md', name: 'a.md' }, 'remote', 'reviewed', true, undefined); + expect(output.resolvedConflicts).toBe(1); + expect(output.syncedPaths).toEqual([{ path: 'a.md', sha: 'reviewed' }]); + }); + + it('reports a per-file failure without applying metadata', async () => { + const service = { getBlob: vi.fn().mockRejectedValue(new Error('provider failed')) } as unknown as GitServiceInterface; + const pull = { pull: vi.fn() } as unknown as PullExecutor; + const output = results(); + + await new ConflictResolver(() => service, () => 'main', pull).applyRemote([conflict('a.md')], output); + + expect(output.failed).toBe(1); + expect(output.errors).toEqual([{ file: 'a.md', error: 'provider failed' }]); + expect(pull.pull).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/logic/sync/PullExecutor.test.ts b/tests/logic/sync/PullExecutor.test.ts new file mode 100644 index 0000000..d0ed533 --- /dev/null +++ b/tests/logic/sync/PullExecutor.test.ts @@ -0,0 +1,55 @@ +/* eslint-disable @typescript-eslint/unbound-method */ +import { describe, expect, it, vi } from 'vitest'; +import { PullExecutor } from '../../../src/logic/sync/PullExecutor'; +import type { App } from 'obsidian'; +import type { GitLabFilesPushSettings } from '../../../src/settings'; + +function app() { + return { + vault: { + adapter: { + exists: vi.fn().mockResolvedValue(true), + mkdir: vi.fn().mockResolvedValue(undefined), + write: vi.fn().mockResolvedValue(undefined), + writeBinary: vi.fn().mockResolvedValue(undefined), + }, + modify: vi.fn().mockResolvedValue(undefined), + modifyBinary: vi.fn().mockResolvedValue(undefined), + }, + } as unknown as App; +} + +const settings = { symlinkHandling: 'follow' } as unknown as GitLabFilesPushSettings; + +describe('PullExecutor', () => { + it('writes text through the adapter before updating metadata', async () => { + const mockApp = app(); + const updateMetadata = vi.fn().mockResolvedValue(undefined); + const executor = new PullExecutor(mockApp, settings, updateMetadata, () => 'GitHub'); + + await executor.pull({ path: 'Folder/a.md', name: 'a.md' }, 'remote', 'sha', true); + + expect(mockApp.vault.adapter.write).toHaveBeenCalledWith('Folder/a.md', 'remote'); + expect(updateMetadata).toHaveBeenCalledWith('Folder/a.md', 'sha'); + }); + + it('preserves binary content and propagates vault write failures', async () => { + const mockApp = app(); + vi.mocked(mockApp.vault.adapter.writeBinary).mockRejectedValue(new Error('disk full')); + const updateMetadata = vi.fn(); + const executor = new PullExecutor(mockApp, settings, updateMetadata, () => 'GitHub'); + const content = new Uint8Array([1, 2]).buffer; + + await expect(executor.pull({ path: 'image.png', name: 'image.png' }, content, 'sha', true)).rejects.toThrow('disk full'); + expect(updateMetadata).not.toHaveBeenCalled(); + }); + + it('writes a remote symlink target as content when real links are disabled', async () => { + const mockApp = app(); + const executor = new PullExecutor(mockApp, settings, vi.fn(), () => 'GitHub'); + + await executor.pull({ path: 'link', name: 'link' }, 'blob-content', 'sha', true, '../target'); + + expect(mockApp.vault.adapter.write).toHaveBeenCalledWith('link', '../target'); + }); +}); diff --git a/tests/logic/sync/PushCoordinator.test.ts b/tests/logic/sync/PushCoordinator.test.ts new file mode 100644 index 0000000..c7d668d --- /dev/null +++ b/tests/logic/sync/PushCoordinator.test.ts @@ -0,0 +1,151 @@ +import type { App } from 'obsidian'; +import { describe, expect, it, vi } from 'vitest'; +import type { GitServiceInterface } from '../../../src/services/git-service-interface'; +import type { GitLabFilesPushSettings } from '../../../src/settings'; +import type { ConflictResolver } from '../../../src/logic/sync/ConflictResolver'; +import type { PushExecutor } from '../../../src/logic/sync/PushExecutor'; +import { PushCoordinator } from '../../../src/logic/sync/PushCoordinator'; +import type { SyncScanner } from '../../../src/logic/sync/SyncScanner'; +import type { PushResults } from '../../../src/logic/sync/types'; + +function settings(): GitLabFilesPushSettings { + return { + serviceType: 'github', + gitlabToken: '', + gitlabBaseUrl: '', + projectId: '', + githubToken: '', + githubOwner: '', + githubRepo: '', + giteaToken: '', + giteaBaseUrl: '', + giteaOwner: '', + giteaRepo: '', + branch: 'main', + rootPath: '', + syncMetadata: {}, + vaultFolder: '', + symlinkHandling: 'real', + ignorePatterns: '', + lastSeenVersion: '', + bannerDismissedVersion: '', + language: 'system', + autoRefreshOnStartup: true, + }; +} + +function createHarness(overrides: { + confirmPlan?: boolean; + pathExists?: (path: string) => Promise; +} = {}) { + const listFilesDetailed = vi.fn().mockResolvedValue([]); + const provider = { + listFilesDetailed, + } as unknown as GitServiceInterface; + const scanner = { + fileInfo: (path: string) => ({ path, name: path.split('/').pop() ?? path, isString: true }), + toRepoPath: (path: string) => path, + toTreePath: (path: string) => path, + pathExists: overrides.pathExists ?? vi.fn().mockResolvedValue(true), + indexedFileExists: vi.fn().mockReturnValue(true), + readContent: vi.fn().mockImplementation(async (path: string) => `content:${path}`), + } as unknown as SyncScanner; + const commitBatch = vi.fn().mockImplementation(async ( + pushes: Array<{ path: string }>, + moves: Array<{ path: string }>, + result: PushResults, + ) => { + const committed = [...pushes, ...moves]; + result.success += committed.length; + result.syncedPaths.push(...committed.map(entry => ({ path: entry.path, sha: `sha:${entry.path}` }))); + }); + const saveSettings = vi.fn().mockResolvedValue(undefined); + const confirmPlan = vi.fn().mockResolvedValue(overrides.confirmPlan ?? true); + const syncSettings = settings(); + const coordinator = new PushCoordinator({ + app: { vault: { getFileByPath: vi.fn().mockReturnValue({}) } } as unknown as App, + gitService: () => provider, + settings: syncSettings, + scanner, + executor: { commitBatch, pushSymlink: vi.fn() } as unknown as PushExecutor, + conflicts: { findStale: vi.fn().mockResolvedValue([]), applyRemote: vi.fn() } as unknown as ConflictResolver, + isPathIgnored: () => false, + confirmPlan, + resolveConflicts: vi.fn().mockResolvedValue(true), + updateMetadata: vi.fn().mockResolvedValue(undefined), + migrateBaseline: vi.fn().mockResolvedValue(undefined), + saveSettings, + notify: vi.fn(), + serviceName: () => 'GitHub', + }); + return { coordinator, listFilesDetailed, commitBatch, confirmPlan, saveSettings, settings: syncSettings }; +} + +describe('PushCoordinator', () => { + it('plans local-only files and commits them through the executor', async () => { + const harness = createHarness(); + + const result = await harness.coordinator.pushFiles(['notes/a.md']); + + expect(harness.confirmPlan).toHaveBeenCalledWith(expect.objectContaining({ + additions: [{ path: 'notes/a.md', name: 'a.md' }], + })); + expect(harness.commitBatch).toHaveBeenCalledOnce(); + expect(result).toMatchObject({ success: 1, failed: 0, syncedPaths: [{ path: 'notes/a.md', sha: 'sha:notes/a.md' }] }); + }); + + it('does not mutate the provider when final plan review is cancelled', async () => { + const harness = createHarness({ confirmPlan: false }); + + const result = await harness.coordinator.pushFiles(['a.md']); + + expect(result.cancelled).toBe(true); + expect(harness.commitBatch).not.toHaveBeenCalled(); + expect(harness.saveSettings).toHaveBeenCalledOnce(); + }); + + it('keeps classifying the batch after one local file fails', async () => { + const harness = createHarness({ pathExists: async path => path !== 'missing.md' }); + + const result = await harness.coordinator.pushFiles(['missing.md', 'ready.md']); + + expect(result.failed).toBe(1); + expect(result.errors).toEqual([{ file: 'missing.md', error: 'File no longer exists' }]); + expect(result.success).toBe(1); + expect(harness.commitBatch).toHaveBeenCalledOnce(); + }); + + it('surfaces a provider tree failure before any mutation is attempted', async () => { + const harness = createHarness(); + harness.listFilesDetailed.mockRejectedValue(new Error('provider unavailable')); + + await expect(harness.coordinator.pushFiles(['a.md'])).rejects.toThrow('provider unavailable'); + expect(harness.commitBatch).not.toHaveBeenCalled(); + }); + + it('plans an edited tracked rename as a move when the destination is free', async () => { + const harness = createHarness(); + harness.settings.syncMetadata['notes/new.md'] = { + lastSyncedSha: 'stale-baseline', + lastSyncedAt: 0, + lastKnownPath: 'notes/new.md', + renamedFrom: 'notes/old.md', + }; + harness.listFilesDetailed.mockResolvedValue([ + { path: 'notes/old.md', symlink: false, sha: 'current-source' }, + ]); + + const result = await harness.coordinator.pushFiles(['notes/new.md']); + + expect(harness.confirmPlan).toHaveBeenCalledWith(expect.objectContaining({ + moves: [{ path: 'notes/new.md', name: 'new.md', movedFrom: 'notes/old.md' }], + skippedConflicts: [], + })); + expect(harness.commitBatch).toHaveBeenCalledWith( + [], + [expect.objectContaining({ path: 'notes/new.md', oldPath: 'notes/old.md', content: 'content:notes/new.md' })], + expect.any(Object), + ); + expect(result).toMatchObject({ success: 1, conflicts: 0, skippedConflicts: 0 }); + }); +}); diff --git a/tests/logic/sync/PushExecutor.test.ts b/tests/logic/sync/PushExecutor.test.ts new file mode 100644 index 0000000..c8710f9 --- /dev/null +++ b/tests/logic/sync/PushExecutor.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it, vi } from 'vitest'; +import { PushExecutor } from '../../../src/logic/sync/PushExecutor'; +import type { GitServiceInterface } from '../../../src/services/git-service-interface'; + +describe('PushExecutor', () => { + it('pushes mapped content and updates metadata with a provider sha', async () => { + const pushFile = vi.fn().mockResolvedValue({ sha: 'remote-sha' }); + const updateMetadata = vi.fn().mockResolvedValue(undefined); + const executor = new PushExecutor( + () => ({ pushFile } as unknown as GitServiceInterface), + () => 'main', + path => path.replace('Vault/', ''), + updateMetadata, + () => 'GitHub', + ); + + const sha = await executor.push({ path: 'Vault/a.md', name: 'a.md' }, 'hello', 'old-sha', 'revision', true); + + expect(pushFile).toHaveBeenCalledWith('a.md', 'hello', 'main', 'Update a.md from Obsidian', 'old-sha', 'revision'); + expect(updateMetadata).toHaveBeenCalledWith('Vault/a.md', 'remote-sha'); + expect(sha).toBe('remote-sha'); + }); + + it('does not update metadata when the provider fails', async () => { + const updateMetadata = vi.fn(); + const executor = new PushExecutor( + () => ({ pushFile: vi.fn().mockRejectedValue(new Error('provider failed')) } as unknown as GitServiceInterface), + () => 'main', + path => path, + updateMetadata, + () => 'GitHub', + ); + + await expect(executor.push({ path: 'a.md', name: 'a.md' }, 'hello')).rejects.toThrow('provider failed'); + expect(updateMetadata).not.toHaveBeenCalled(); + }); + + it('reads the current provider lazily after a provider switch', async () => { + const firstPush = vi.fn(); + const secondPush = vi.fn().mockResolvedValue({ sha: 'sha' }); + let current = { pushFile: firstPush } as unknown as GitServiceInterface; + const executor = new PushExecutor(() => current, () => 'main', path => path, vi.fn(), () => 'GitHub'); + current = { pushFile: secondPush } as unknown as GitServiceInterface; + + await executor.push({ path: 'a.md', name: 'a.md' }, 'hello', undefined, undefined, true); + + expect(firstPush).not.toHaveBeenCalled(); + expect(secondPush).toHaveBeenCalledOnce(); + }); +}); diff --git a/tests/logic/sync/RemoteDeleteExecutor.test.ts b/tests/logic/sync/RemoteDeleteExecutor.test.ts new file mode 100644 index 0000000..9f4deaa --- /dev/null +++ b/tests/logic/sync/RemoteDeleteExecutor.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it, vi } from 'vitest'; +import { RemoteDeleteExecutor } from '../../../src/logic/sync/RemoteDeleteExecutor'; +import type { GitServiceInterface } from '../../../src/services/git-service-interface'; + +function service(overrides: Partial = {}): GitServiceInterface { + return { + deleteFile: vi.fn().mockResolvedValue(undefined), + ...overrides, + } as unknown as GitServiceInterface; +} + +describe('RemoteDeleteExecutor', () => { + it('uses the provider batch boundary and reports deleted vault paths', async () => { + const deleteBatch = vi.fn().mockResolvedValue(undefined); + const executor = new RemoteDeleteExecutor(service({ deleteBatch }), 'main', 2); + + const result = await executor.execute([ + { path: 'Vault/a.md', repoPath: 'a.md' }, + { path: 'Vault/b.md', repoPath: 'b.md' }, + { path: 'Vault/c.md', repoPath: 'c.md' }, + ]); + + expect(deleteBatch).toHaveBeenCalledTimes(2); + expect(result.deletedPaths).toEqual(['Vault/a.md', 'Vault/b.md', 'Vault/c.md']); + expect(result.errors).toEqual([]); + }); + + it('marks every item in a failed atomic chunk and continues later chunks', async () => { + const deleteBatch = vi.fn() + .mockRejectedValueOnce(new Error('provider unavailable')) + .mockResolvedValueOnce(undefined); + const executor = new RemoteDeleteExecutor(service({ deleteBatch }), 'main', 2); + + const result = await executor.execute([ + { path: 'a.md', repoPath: 'a.md' }, + { path: 'b.md', repoPath: 'b.md' }, + { path: 'c.md', repoPath: 'c.md' }, + ]); + + expect(result.deletedPaths).toEqual(['c.md']); + expect(result.errors).toEqual([ + { path: 'a.md', message: 'provider unavailable' }, + { path: 'b.md', message: 'provider unavailable' }, + ]); + }); + + it('falls back to sequential deletion with partial success', async () => { + const deleteFile = vi.fn() + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error('locked')); + const executor = new RemoteDeleteExecutor(service({ deleteFile }), 'main'); + + const result = await executor.execute([ + { path: 'a.md', repoPath: 'a.md' }, + { path: 'b.md', repoPath: 'b.md' }, + ]); + + expect(result.deletedPaths).toEqual(['a.md']); + expect(result.errors).toEqual([{ path: 'b.md', message: 'locked' }]); + }); +}); diff --git a/tests/logic/sync/SyncDiffService.test.ts b/tests/logic/sync/SyncDiffService.test.ts new file mode 100644 index 0000000..65507b7 --- /dev/null +++ b/tests/logic/sync/SyncDiffService.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it, vi } from 'vitest'; +import { SyncStatusService } from '../../../src/logic/sync-status-service'; +import { SyncDiffService } from '../../../src/logic/sync/SyncDiffService'; + +describe('SyncDiffService', () => { + it('loads remote content lazily and returns a FileDiff DTO', async () => { + const statuses = new SyncStatusService(); + statuses.set({ + path: 'notes/a.md', + status: 'modified', + localContent: 'local', + remoteSha: 'remote-sha', + }); + const getBlob = vi.fn().mockResolvedValue({ content: 'remote' }); + const service = new SyncDiffService(statuses, getBlob); + + await expect(service.getDiff('notes/a.md')).resolves.toEqual({ + path: 'notes/a.md', + localContent: 'local', + remoteContent: 'remote', + kind: 'text', + }); + await service.getDiff('notes/a.md'); + + expect(getBlob).toHaveBeenCalledOnce(); + expect(getBlob).toHaveBeenCalledWith('remote-sha', 'notes/a.md'); + }); + + it.each([ + ['image.png', false, 'binary'], + ['link.md', true, 'symlink'], + ] as const)('projects %s as %s content', async (path, isSymlink, kind) => { + const statuses = new SyncStatusService(); + statuses.set({ path, status: 'modified', localContent: new ArrayBuffer(1), isSymlink }); + const service = new SyncDiffService(statuses, vi.fn()); + + await expect(service.getDiff(path)).resolves.toMatchObject({ path, kind }); + }); + + it('rejects an unknown path instead of exposing an incomplete view model', async () => { + const service = new SyncDiffService(new SyncStatusService(), vi.fn()); + + await expect(service.getDiff('missing.md')).rejects.toThrow('No sync status for missing.md'); + }); +}); diff --git a/tests/logic/sync/SyncMetadataStore.test.ts b/tests/logic/sync/SyncMetadataStore.test.ts new file mode 100644 index 0000000..ba42724 --- /dev/null +++ b/tests/logic/sync/SyncMetadataStore.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it, vi } from 'vitest'; +import { SyncMetadataStore } from '../../../src/logic/sync/SyncMetadataStore'; +import { SyncStatusService } from '../../../src/logic/sync-status-service'; +import type { GitLabFilesPushSettings } from '../../../src/settings'; + +function setup() { + const settings = { syncMetadata: {} } as unknown as GitLabFilesPushSettings; + const save = vi.fn().mockResolvedValue(undefined); + const status = new SyncStatusService(); + status.set({ path: 'a.md', status: 'modified' }); + return { settings, save, status, store: new SyncMetadataStore(settings, save, status) }; +} + +describe('SyncMetadataStore', () => { + it('persists a baseline and marks the shared status snapshot synced', async () => { + const { settings, save, status, store } = setup(); + + await store.update('a.md', 'sha'); + + expect(settings.syncMetadata['a.md']).toMatchObject({ lastSyncedSha: 'sha', lastKnownPath: 'a.md' }); + expect(status.get('a.md')).toMatchObject({ status: 'synced', remoteSha: 'sha' }); + expect(save).toHaveBeenCalledOnce(); + }); + + it('collapses rename chains and cancels a rename back to its remote path', async () => { + const { settings, store } = setup(); + settings.syncMetadata['a.md'] = { lastSyncedSha: 'sha', lastSyncedAt: 1, lastKnownPath: 'a.md' }; + + await store.trackRename('b.md', 'a.md'); + await store.trackRename('c.md', 'b.md'); + expect(settings.syncMetadata['c.md']).toMatchObject({ renamedFrom: 'a.md' }); + + await store.trackRename('a.md', 'c.md'); + expect(settings.syncMetadata['a.md']).not.toHaveProperty('renamedFrom'); + }); + + it('clears only existing metadata', async () => { + const { settings, save, store } = setup(); + settings.syncMetadata['a.md'] = { lastSyncedSha: 'sha', lastSyncedAt: 1, lastKnownPath: 'a.md' }; + + await store.clear('a.md'); + await store.clear('missing.md'); + + expect(settings.syncMetadata).toEqual({}); + expect(save).toHaveBeenCalledOnce(); + }); +}); diff --git a/tests/logic/sync/SyncPlanner.test.ts b/tests/logic/sync/SyncPlanner.test.ts new file mode 100644 index 0000000..c946af4 --- /dev/null +++ b/tests/logic/sync/SyncPlanner.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest'; +import { SyncPlanner } from '../../../src/logic/sync/SyncPlanner'; +import type { MoveFacts, SyncFacts, SyncClassification } from '../../../src/logic/sync/types'; + +const planner = new SyncPlanner(); + +function facts(localSha: string | undefined, remoteSha: string | undefined, baseSha?: string): SyncFacts { + return { + local: { path: 'note.md', exists: localSha !== undefined, blobSha: localSha, kind: 'text' }, + remote: { path: 'note.md', repoPath: 'note.md', exists: remoteSha !== undefined, blobSha: remoteSha, kind: 'text' }, + base: { blobSha: baseSha }, + }; +} + +describe('SyncPlanner', () => { + it.each<[string, SyncFacts, SyncClassification]>([ + ['same local, remote, and base', facts('base', 'base', 'base'), 'synced'], + ['local changed', facts('local', 'base', 'base'), 'local-modified'], + ['remote changed', facts('base', 'remote', 'base'), 'remote-modified'], + ['both changed differently', facts('local', 'remote', 'base'), 'conflict'], + ['both changed identically', facts('next', 'next', 'base'), 'synced'], + ['local only', facts('local', undefined), 'local-only'], + ['remote only', facts(undefined, 'remote'), 'remote-only'], + ['both absent', facts(undefined, undefined), 'synced'], + ['untracked but equal', facts('same', 'same'), 'synced'], + ['untracked and different', facts('local', 'remote'), 'conflict'], + ])('%s', (_name, input, expected) => { + expect(planner.classify(input)).toBe(expected); + }); + + it.each([ + ['local-modified', 'push-update'], + ['local-only', 'push-create'], + ['remote-modified', 'pull-overwrite'], + ['remote-only', 'pull-create'], + ['conflict', 'resolve-conflict'], + ['synced', 'none'], + ] as const)('maps %s to %s without IO', (classification, action) => { + expect(planner.actionFor(classification)).toBe(action); + }); + + it('keeps binary and symlink kinds in the immutable plan', () => { + const binary = facts('local', undefined); + binary.local.kind = 'binary'; + const symlink = facts(undefined, 'remote'); + symlink.remote.kind = 'symlink'; + + expect(planner.plan(binary)).toMatchObject({ classification: 'local-only', action: 'push-create', kind: 'binary' }); + expect(planner.plan(symlink)).toMatchObject({ classification: 'remote-only', action: 'pull-create', kind: 'symlink' }); + }); + + it('plans an edited rename as one move regardless of an older content baseline', () => { + const move: MoveFacts = { + local: { path: 'new.md', exists: true, blobSha: 'local-edit', kind: 'text' }, + source: { path: 'old.md', repoPath: 'old.md', exists: true, blobSha: 'remote-source', kind: 'text' }, + destination: { path: 'new.md', repoPath: 'new.md', exists: false, kind: 'text' }, + }; + + expect(planner.planMove(move)).toMatchObject({ + path: 'new.md', + repoPath: 'new.md', + classification: 'local-modified', + action: 'move', + }); + }); + + it('resolves a move target collision as a conflict', () => { + const move: MoveFacts = { + local: { path: 'new.md', exists: true, blobSha: 'local', kind: 'text' }, + source: { path: 'old.md', repoPath: 'old.md', exists: true, blobSha: 'source', kind: 'text' }, + destination: { path: 'new.md', repoPath: 'new.md', exists: true, blobSha: 'target', kind: 'text' }, + }; + + expect(planner.planMove(move)).toMatchObject({ classification: 'conflict', action: 'resolve-conflict' }); + }); + + it.each([ + ['push', facts('local', 'base', 'base'), 'local-modified', 'push-update'], + ['push', facts('base', 'remote', 'base'), 'remote-modified', 'resolve-conflict'], + ['pull', facts('local', 'base', 'base'), 'local-modified', 'pull-overwrite'], + ['pull', facts('base', 'remote', 'base'), 'remote-modified', 'pull-overwrite'], + ['push', facts('local', 'remote'), 'local-modified', 'push-update'], + ['pull', facts('local', 'remote'), 'remote-modified', 'pull-overwrite'], + ['pull', facts('local', 'remote', 'base'), 'conflict', 'resolve-conflict'], + ] as const)('plans %s operations through one decision matrix', (direction, input, classification, action) => { + expect(planner.planFor(direction, input)).toMatchObject({ classification, action }); + }); +}); diff --git a/tests/logic/sync/SyncScanner.test.ts b/tests/logic/sync/SyncScanner.test.ts new file mode 100644 index 0000000..b1a51c0 --- /dev/null +++ b/tests/logic/sync/SyncScanner.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { App } from 'obsidian'; +import type { GitLabFilesPushSettings } from '../../../src/settings'; +import { SyncScanner } from '../../../src/logic/sync/SyncScanner'; + +function scanner(settings: Partial = {}) { + const adapter = { + exists: vi.fn().mockResolvedValue(true), + read: vi.fn().mockResolvedValue('text'), + readBinary: vi.fn().mockResolvedValue(new ArrayBuffer(1)), + }; + const app = { vault: { adapter, getFileByPath: vi.fn() } } as unknown as App; + return { adapter, scanner: new SyncScanner(app, { vaultFolder: '', rootPath: '', ...settings } as GitLabFilesPushSettings) }; +} + +describe('SyncScanner', () => { + it.each([ + [{ vaultFolder: '' }, 'Notes/a.md', 'Notes/a.md'], + [{ vaultFolder: 'Vault' }, 'Vault/Notes/a.md', 'Notes/a.md'], + [{ vaultFolder: 'Vault' }, 'Vault', ''], + ] as const)('maps vault paths to repository paths', (settings, path, expected) => { + expect(scanner(settings).scanner.toRepoPath(path)).toBe(expected); + }); + + it('maps repository paths to full tree paths exactly once', () => { + const subject = scanner({ rootPath: 'docs/' }).scanner; + + expect(subject.toTreePath('a.md')).toBe('docs/a.md'); + expect(subject.toTreePath('docs/a.md')).toBe('docs/a.md'); + expect(subject.toTreePath('/docs/a.md')).toBe('docs/a.md'); + }); + + it('reads hidden text and binary paths through the adapter', async () => { + const { scanner: subject, adapter } = scanner(); + + await expect(subject.readContent('.hidden/config')).resolves.toBe('text'); + await expect(subject.readContent('.hidden/icon.png')).resolves.toBeInstanceOf(ArrayBuffer); + expect(adapter.read).toHaveBeenCalledWith('.hidden/config'); + expect(adapter.readBinary).toHaveBeenCalledWith('.hidden/icon.png'); + }); +}); diff --git a/tests/logic/sync/SyncWorkspace.test.ts b/tests/logic/sync/SyncWorkspace.test.ts new file mode 100644 index 0000000..6ea6e3d --- /dev/null +++ b/tests/logic/sync/SyncWorkspace.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it, vi } from 'vitest'; +import { SyncManagerWorkspace } from '../../../src/logic/sync/SyncWorkspace'; +import type { SyncManager } from '../../../src/logic/sync/SyncManager'; +import type { SyncStatusRefreshService } from '../../../src/logic/sync/SyncStatusRefreshService'; +import type { SyncDiffService } from '../../../src/logic/sync/SyncDiffService'; +import type { GitServiceInterface, GitTreeEntry } from '../../../src/services/git-service-interface'; +import type { GitLabFilesPushSettings } from '../../../src/settings'; +import type { App } from 'obsidian'; + +function createWorkspace(head: string) { + const tree: GitTreeEntry[] = [{ path: 'a.md', symlink: false, sha: 'sha-a' }]; + const pushFiles = vi.fn().mockResolvedValue({ success: 0, failed: 0, conflicts: 0, errors: [], syncedPaths: [] }); + const getBranchHead = vi.fn().mockResolvedValue(head); + const manager = { + status: { values: () => [] }, + pushFiles, + } as unknown as SyncManager; + const settings = { branch: 'main', rootPath: '' } as GitLabFilesPushSettings; + const refreshService = { + refresh: vi.fn().mockResolvedValue({ localCount: 1, remoteCount: 1, remoteHead: 'commit-1', remoteEntries: tree }), + } as unknown as SyncStatusRefreshService; + const workspace = new SyncManagerWorkspace({ + manager: () => manager, + gitService: () => ({ getBranchHead } as unknown as GitServiceInterface), + settings: () => settings, + refreshService, + diffService: {} as SyncDiffService, + normalizePath: path => path, + app: {} as App, + }); + return { workspace, tree, pushFiles }; +} + +describe('SyncManagerWorkspace remote snapshot', () => { + it('reuses a refreshed tree while the branch head is unchanged', async () => { + const { workspace, tree, pushFiles } = createWorkspace('commit-1'); + await workspace.refresh(); + + await workspace.push(['a.md'], vi.fn()); + + expect(pushFiles).toHaveBeenCalledWith(['a.md'], expect.any(Function), tree); + }); + + it('drops a refreshed tree after the branch head changes', async () => { + const { workspace, pushFiles } = createWorkspace('commit-2'); + await workspace.refresh(); + + await workspace.push(['a.md'], vi.fn()); + + expect(pushFiles).toHaveBeenCalledWith(['a.md'], expect.any(Function), undefined); + }); +}); diff --git a/tests/ui/DiffView.test.ts b/tests/ui/DiffView.test.ts index 49f20d0..fa126b9 100644 --- a/tests/ui/DiffView.test.ts +++ b/tests/ui/DiffView.test.ts @@ -29,7 +29,7 @@ describe('DiffView', () => { it('renders the side-by-side grid for a text diff', async () => { const view = makeDiffView(); await view.onOpen(); - view.setDiff({ path: 'notes/todo.md', status: 'modified', remoteContent: 'a', localContent: 'b' }); + view.setDiff({ path: 'notes/todo.md', kind: 'text', remoteContent: 'a', localContent: 'b' }); expect(view.getPath()).toBe('notes/todo.md'); expect(body(view).querySelector('.ssv-diff-grid')).not.toBeNull(); @@ -40,7 +40,7 @@ describe('DiffView', () => { it('wraps the diff in its own query container', async () => { const view = makeDiffView(); await view.onOpen(); - view.setDiff({ path: 'a.md', status: 'modified', remoteContent: 'a', localContent: 'b' }); + view.setDiff({ path: 'a.md', kind: 'text', remoteContent: 'a', localContent: 'b' }); expect(body(view).querySelector('.ssv-diff-pane')).not.toBeNull(); }); @@ -48,7 +48,7 @@ describe('DiffView', () => { it('shows a symlink message instead of a text diff', async () => { const view = makeDiffView(); await view.onOpen(); - view.setDiff({ path: 'link', status: 'modified', isSymlink: true }); + view.setDiff({ path: 'link', kind: 'symlink' }); expect(body(view).querySelector('.ssv-diff-binary')?.textContent).toBe('Symlink target changed'); }); @@ -58,15 +58,15 @@ describe('DiffView', () => { await view.onOpen(); expect(view.getDisplayText()).toBe('Diff'); - view.setDiff({ path: 'notes/todo.md', status: 'modified', remoteContent: 'a', localContent: 'b' }); + view.setDiff({ path: 'notes/todo.md', kind: 'text', remoteContent: 'a', localContent: 'b' }); expect(view.getDisplayText()).toBe('Diff: notes/todo.md'); }); it('replaces the previous file rather than appending to it', async () => { const view = makeDiffView(); await view.onOpen(); - view.setDiff({ path: 'a.md', status: 'modified', remoteContent: 'a', localContent: 'b' }); - view.setDiff({ path: 'b.md', status: 'modified', remoteContent: 'c', localContent: 'd' }); + view.setDiff({ path: 'a.md', kind: 'text', remoteContent: 'a', localContent: 'b' }); + view.setDiff({ path: 'b.md', kind: 'text', remoteContent: 'c', localContent: 'd' }); expect(view.getPath()).toBe('b.md'); expect(body(view).querySelectorAll('.ssv-diff-pane')).toHaveLength(1); @@ -144,7 +144,7 @@ describe('SyncStatusView diff pane', () => { it('closes the pane when the file it shows is pushed', async () => { const existing = makeDiffView(); await existing.onOpen(); - existing.setDiff(modified('a.md')); + existing.setDiff({ ...modified('a.md'), kind: 'text' }); const { view, leaves } = makeView([existing]); internals(view).closeDiffPaneFor(['a.md']); @@ -155,7 +155,7 @@ describe('SyncStatusView diff pane', () => { it('leaves a pane showing an unrelated file alone', async () => { const existing = makeDiffView(); await existing.onOpen(); - existing.setDiff(modified('a.md')); + existing.setDiff({ ...modified('a.md'), kind: 'text' }); const { view, leaves } = makeView([existing]); internals(view).closeDiffPaneFor(['other.md']); diff --git a/tests/ui/SyncStatusView.test.ts b/tests/ui/SyncStatusView.test.ts index 8e927c3..5a325f5 100644 --- a/tests/ui/SyncStatusView.test.ts +++ b/tests/ui/SyncStatusView.test.ts @@ -9,6 +9,11 @@ import type { GitTreeEntry } from '../../src/services/git-service-interface'; import { SyncPlanModal } from '../../src/ui/SyncPlanModal'; import { ConfirmModal } from '../../src/ui/ConfirmModal'; import { gitBlobSha } from '../../src/utils/git-blob-sha'; +import type { SyncStatusRefreshService } from '../../src/logic/sync/SyncStatusRefreshService'; + +function refreshService(view: SyncStatusView): SyncStatusRefreshService { + return (view as unknown as { statusRefresh: SyncStatusRefreshService }).statusRefresh; +} // The diff pane is a separate view; none of these fixtures open one, so the // stale-pane cleanup just finds nothing. @@ -293,9 +298,7 @@ describe('SyncStatusView.identifyExtraFiles folder/remote-record collisions', () ['.claude/skills/polish-blog', { path: '.claude/skills/polish-blog', symlink: false }], ]); - const extra = await (view as unknown as { - identifyExtraFiles(remoteMap: Map, localFilePaths: Set, allLocalFileMap: Map): Promise - }).identifyExtraFiles(remoteMap, new Set(), new Map()); + const extra = await refreshService(view).identifyExtraFiles(remoteMap, new Set(), new Map()); expect(extra).toEqual([]); const statuses = (view as unknown as { fileStatuses: Map }).fileStatuses; @@ -312,9 +315,7 @@ describe('SyncStatusView.identifyExtraFiles folder/remote-record collisions', () ['notes/hidden.md', { path: 'notes/hidden.md', symlink: false }], ]); - const extra = await (view as unknown as { - identifyExtraFiles(remoteMap: Map, localFilePaths: Set, allLocalFileMap: Map): Promise - }).identifyExtraFiles(remoteMap, new Set(), new Map()); + const extra = await refreshService(view).identifyExtraFiles(remoteMap, new Set(), new Map()); expect(extra).toEqual(['notes/hidden.md']); }); @@ -333,9 +334,7 @@ describe('SyncStatusView.identifyExtraFiles folder/remote-record collisions', () ['notes/old.md', { path: 'notes/old.md', symlink: false, sha: 'sha' }], ]); - const extra = await (view as unknown as { - identifyExtraFiles(remoteMap: Map, localFilePaths: Set, allLocalFileMap: Map, pendingMoveOldPaths: Set): Promise - }).identifyExtraFiles(remoteMap, new Set(), new Map(), new Set(['notes/old.md'])); + const extra = await refreshService(view).identifyExtraFiles(remoteMap, new Set(), new Map(), new Set(['notes/old.md'])); expect(extra).toEqual([]); const statuses = (view as unknown as { fileStatuses: Map }).fileStatuses; @@ -356,9 +355,7 @@ describe('SyncStatusView local-only status', () => { const leaf = { app: { workspace: noDiffPanes(), vault: { adapter: { read: vi.fn().mockResolvedValue('new content') } } } } as unknown as WorkspaceLeaf; const view = new SyncStatusView(leaf, plugin); - await (view as unknown as { - refreshFileStatus(fileOrPath: string, remoteEntry: GitTreeEntry | undefined): Promise - }).refreshFileStatus('new.md', undefined); + await refreshService(view).refreshFileStatus('new.md', undefined); expect(getFile).not.toHaveBeenCalled(); const statuses = (view as unknown as { fileStatuses: Map }).fileStatuses; @@ -369,16 +366,15 @@ describe('SyncStatusView local-only status', () => { // it) still needs the content fetch — that path must stay intact. it('still fetches content for a tree entry without a sha', async () => { const getFile = vi.fn().mockResolvedValue({ content: 'remote content', sha: 'remote-sha' }); - const { plugin, leaf } = makePlugin({ adapterExists: vi.fn().mockResolvedValue(true) }); + const { plugin, leaf } = makePlugin({ + adapterExists: vi.fn().mockResolvedValue(true), + adapterRead: vi.fn().mockResolvedValue('remote content'), + }); (plugin.gitService as unknown as { getFile: typeof getFile }).getFile = getFile; const view = new SyncStatusView(leaf, plugin); - vi.spyOn(view as unknown as { readFileContent(f: unknown, b: boolean, s: boolean): Promise }, 'readFileContent') - .mockResolvedValue('remote content'); - await (view as unknown as { - refreshFileStatus(fileOrPath: string, remoteEntry: GitTreeEntry | undefined): Promise - }).refreshFileStatus('notes/existing.md', { path: 'notes/existing.md', symlink: false }); + await refreshService(view).refreshFileStatus('notes/existing.md', { path: 'notes/existing.md', symlink: false }); expect(getFile).toHaveBeenCalledWith('notes/existing.md', 'main'); const statuses = (view as unknown as { fileStatuses: Map }).fileStatuses; @@ -393,47 +389,38 @@ describe('SyncStatusView local-only status', () => { // as a stray remote-only + unsynced pair instead of 'moved'. Classifying a // file as 'synced' must backfill syncMetadata so a later move is tracked. it('backfills syncMetadata when a sha-based comparison finds a file already synced', async () => { - const { plugin, leaf } = makePlugin(); + const { plugin, leaf } = makePlugin({ adapterRead: vi.fn().mockResolvedValue('same content') }); const view = new SyncStatusView(leaf, plugin); - vi.spyOn(view as unknown as { readLocalContentForSha(...args: unknown[]): Promise }, 'readLocalContentForSha') - .mockResolvedValue('same content'); - await (view as unknown as { - refreshFileStatusBySha(fileOrPath: string, remoteEntry: GitTreeEntry): Promise - }).refreshFileStatusBySha('notes/pre-existing.md', { path: 'notes/pre-existing.md', symlink: false, sha: await gitBlobSha('same content') }); + await refreshService(view).refreshFileStatusBySha('notes/pre-existing.md', { path: 'notes/pre-existing.md', symlink: false, sha: await gitBlobSha('same content') }); expect(plugin.settings.syncMetadata?.['notes/pre-existing.md']).toMatchObject({ lastKnownPath: 'notes/pre-existing.md' }); }); it('backfills syncMetadata when a content-based comparison finds a file already synced', async () => { const getFile = vi.fn().mockResolvedValue({ content: 'same content', sha: 'remote-sha' }); - const { plugin, leaf } = makePlugin({ adapterExists: vi.fn().mockResolvedValue(true) }); + const { plugin, leaf } = makePlugin({ + adapterExists: vi.fn().mockResolvedValue(true), + adapterRead: vi.fn().mockResolvedValue('same content'), + }); (plugin.gitService as unknown as { getFile: typeof getFile }).getFile = getFile; const view = new SyncStatusView(leaf, plugin); - vi.spyOn(view as unknown as { readFileContent(f: unknown, b: boolean, s: boolean): Promise }, 'readFileContent') - .mockResolvedValue('same content'); - await (view as unknown as { - refreshFileStatusByContent(fileOrPath: string): Promise - }).refreshFileStatusByContent('notes/pre-existing.md'); + await refreshService(view).refreshFileStatusByContent('notes/pre-existing.md'); expect(plugin.settings.syncMetadata?.['notes/pre-existing.md']).toMatchObject({ lastSyncedSha: 'remote-sha' }); }); it('end-to-end: a rename right after a sha-based synced classification is tracked as moved, not a stray remote-only + unsynced pair', async () => { - const { plugin, leaf } = makePlugin(); + const { plugin, leaf } = makePlugin({ adapterRead: vi.fn().mockResolvedValue('same content') }); const view = new SyncStatusView(leaf, plugin); - vi.spyOn(view as unknown as { readLocalContentForSha(...args: unknown[]): Promise }, 'readLocalContentForSha') - .mockResolvedValue('same content'); const sha = await gitBlobSha('same content'); // First refresh: the file was never pushed/pulled through the plugin, // but its content already matches remote -- classified 'synced' from a // clean slate, same as a freshly opened vault. - await (view as unknown as { - refreshFileStatusBySha(fileOrPath: string, remoteEntry: GitTreeEntry): Promise - }).refreshFileStatusBySha('notes/old.md', { path: 'notes/old.md', symlink: false, sha }); + await refreshService(view).refreshFileStatusBySha('notes/old.md', { path: 'notes/old.md', symlink: false, sha }); // Then the user renames it inside Obsidian -- mirrors main.ts's rename handler. await plugin.sync.trackRename('notes/new.md', 'notes/old.md'); @@ -451,9 +438,7 @@ describe('SyncStatusView local-only status', () => { (plugin.gitService as unknown as { getFile: typeof getFile }).getFile = getFile; const view = new SyncStatusView(leaf, plugin); - await (view as unknown as { - refreshFileStatus(fileOrPath: string, remoteEntry: GitTreeEntry | undefined): Promise - }).refreshFileStatus('notes/new.md', { path: 'notes/new.md', symlink: false, sha: 'irrelevant' }); + await refreshService(view).refreshFileStatus('notes/new.md', { path: 'notes/new.md', symlink: false, sha: 'irrelevant' }); expect(getFile).not.toHaveBeenCalled(); const statuses = (view as unknown as { fileStatuses: Map }).fileStatuses; @@ -491,18 +476,12 @@ describe('SyncStatusView move detection without a live rename event', () => { ]); // No pendingMoveOldPaths, since none was ever live-tracked. - const extra = await (view as unknown as { - identifyExtraFiles(remoteMap: Map, localFilePaths: Set, allLocalFileMap: Map, pendingMoveOldPaths: Set): Promise - }).identifyExtraFiles(remoteMap, new Set(), new Map(), new Set()); + const extra = await refreshService(view).identifyExtraFiles(remoteMap, new Set(), new Map(), new Set()); // The file now lives at Archive/Projects/a.md locally, with no remote entry yet. - await (view as unknown as { - refreshFileStatus(fileOrPath: string, remoteEntry: GitTreeEntry | undefined): Promise - }).refreshFileStatus('Archive/Projects/a.md', undefined); + await refreshService(view).refreshFileStatus('Archive/Projects/a.md', undefined); - await (view as unknown as { - reconcileOutOfBandMoves(remoteMap: Map): Promise - }).reconcileOutOfBandMoves(remoteMap); + await refreshService(view).reconcileOutOfBandMoves(remoteMap); expect(extra).toEqual([]); const statuses = (view as unknown as { fileStatuses: Map }).fileStatuses; @@ -530,15 +509,9 @@ describe('SyncStatusView move detection without a live rename event', () => { ['Notes/old-name.md', { path: 'Notes/old-name.md', symlink: false, sha }], ]); - await (view as unknown as { - identifyExtraFiles(remoteMap: Map, localFilePaths: Set, allLocalFileMap: Map, pendingMoveOldPaths: Set): Promise - }).identifyExtraFiles(remoteMap, new Set(), new Map(), new Set()); - await (view as unknown as { - refreshFileStatus(fileOrPath: string, remoteEntry: GitTreeEntry | undefined): Promise - }).refreshFileStatus('Archive/new-name.md', undefined); - await (view as unknown as { - reconcileOutOfBandMoves(remoteMap: Map): Promise - }).reconcileOutOfBandMoves(remoteMap); + await refreshService(view).identifyExtraFiles(remoteMap, new Set(), new Map(), new Set()); + await refreshService(view).refreshFileStatus('Archive/new-name.md', undefined); + await refreshService(view).reconcileOutOfBandMoves(remoteMap); const statuses = (view as unknown as { fileStatuses: Map }).fileStatuses; expect(statuses.get('Archive/new-name.md')).toMatchObject({ @@ -570,21 +543,15 @@ describe('SyncStatusView move detection without a live rename event', () => { ['Notes/Projects/a.md', { path: 'Notes/Projects/a.md', symlink: false, sha }], ]); - await (view as unknown as { - identifyExtraFiles(remoteMap: Map, localFilePaths: Set, allLocalFileMap: Map, pendingMoveOldPaths: Set): Promise - }).identifyExtraFiles(remoteMap, new Set(), new Map(), new Set()); + await refreshService(view).identifyExtraFiles(remoteMap, new Set(), new Map(), new Set()); - await (view as unknown as { - refreshFileStatus(fileOrPath: string, remoteEntry: GitTreeEntry | undefined): Promise - }).refreshFileStatus('Archive/Projects/a.md', undefined); + await refreshService(view).refreshFileStatus('Archive/Projects/a.md', undefined); // Simulates a vault 'delete' handler firing for the old path before // this refresh's reconciliation pass gets to run. delete plugin.settings.syncMetadata['Notes/Projects/a.md']; - await (view as unknown as { - reconcileOutOfBandMoves(remoteMap: Map): Promise - }).reconcileOutOfBandMoves(remoteMap); + await refreshService(view).reconcileOutOfBandMoves(remoteMap); const statuses = (view as unknown as { fileStatuses: Map }).fileStatuses; expect(statuses.get('Notes/Projects/a.md')).toMatchObject({ status: 'remote-only' }); @@ -743,9 +710,7 @@ describe('SyncStatusView moved diff data', () => { const file = Object.assign(new TFile(), { path: 'new.md' }); const remoteMap = new Map([['old.md', { path: 'old.md', sha: 'old-sha', symlink: false }]]); - await (view as unknown as { - refreshFileStatus(file: TFile, remoteEntry: GitTreeEntry | undefined, entries: Map): Promise - }).refreshFileStatus(file, undefined, remoteMap); + await refreshService(view).refreshFileStatus(file, undefined, remoteMap); const statuses = (view as unknown as { fileStatuses: Map }).fileStatuses; expect(statuses.get('new.md')).toMatchObject({ @@ -832,44 +797,6 @@ describe('SyncStatusView post-push status update', () => { expect(statuses.get('note.md')).toMatchObject({ path: 'note.md', status: 'synced', remoteSha: 'new-sha' }); }); - it('reuses a snapshot only when the branch head is unchanged', async () => { - const pushFiles = vi.fn().mockResolvedValue({ success: 1, failed: 0, conflicts: 0, errors: [], syncedPaths: [] }); - const tree: GitTreeEntry[] = [{ path: 'a.md', symlink: false, sha: 'sha-a' }]; - const getBranchHead = vi.fn().mockResolvedValue('commit-1'); - const plugin = { - settings: { branch: 'main', vaultFolder: '', rootPath: '' }, - gitService: { getBranchHead }, sync: { pushFiles }, - } as unknown as GitLabFilesPush; - const leaf = { app: { workspace: noDiffPanes(), vault: { adapter: { exists: vi.fn().mockResolvedValue(false) } } } } as unknown as WorkspaceLeaf; - const view = new SyncStatusView(leaf, plugin); - (view as unknown as { remoteTreeSnapshot: unknown }).remoteTreeSnapshot = { branch: 'main', rootPath: '', head: 'commit-1', entries: tree }; - - await (view as unknown as { - executeBatchOperation(filter: 'modified' | 'selected', op: 'push' | 'pull', files: Array): Promise - }).executeBatchOperation('selected', 'push', ['a.md']); - - expect(pushFiles).toHaveBeenCalledWith(['a.md'], expect.any(Function), tree); - }); - - it('fetches a fresh tree when the branch head changed after refresh', async () => { - const pushFiles = vi.fn().mockResolvedValue({ success: 1, failed: 0, conflicts: 0, errors: [], syncedPaths: [] }); - const plugin = { - settings: { branch: 'main', vaultFolder: '', rootPath: '' }, - gitService: { getBranchHead: vi.fn().mockResolvedValue('commit-2') }, sync: { pushFiles }, - } as unknown as GitLabFilesPush; - const leaf = { app: { workspace: noDiffPanes(), vault: { adapter: { exists: vi.fn().mockResolvedValue(false) } } } } as unknown as WorkspaceLeaf; - const view = new SyncStatusView(leaf, plugin); - (view as unknown as { remoteTreeSnapshot: unknown }).remoteTreeSnapshot = { - branch: 'main', rootPath: '', head: 'commit-1', entries: [{ path: 'a.md', symlink: false }], - }; - - await (view as unknown as { - executeBatchOperation(filter: 'modified' | 'selected', op: 'push' | 'pull', files: Array): Promise - }).executeBatchOperation('selected', 'push', ['a.md']); - - expect(pushFiles).toHaveBeenCalledWith(['a.md'], expect.any(Function), undefined); - }); - it('still does a full remote refresh after a pull (unaffected by this fix)', async () => { const pullAllFiles = vi.fn().mockResolvedValue({ success: 1, failed: 0, conflicts: 0, errors: [] }); diff --git a/tests/ui/sync-status/SyncStatusController.test.ts b/tests/ui/sync-status/SyncStatusController.test.ts new file mode 100644 index 0000000..44b9363 --- /dev/null +++ b/tests/ui/sync-status/SyncStatusController.test.ts @@ -0,0 +1,71 @@ +/* eslint-disable @typescript-eslint/unbound-method */ +import { describe, expect, it, vi } from 'vitest'; +import { SyncStatusController, type SyncStatusCommandPort } from '../../../src/ui/sync-status/SyncStatusController'; +import type { FileStatus } from '../../../src/logic/sync-status-service'; + +function setup() { + const commands: SyncStatusCommandPort = { + refresh: vi.fn().mockResolvedValue(undefined), + push: vi.fn().mockResolvedValue(undefined), + pull: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + openDiff: vi.fn().mockResolvedValue(undefined), + pushOne: vi.fn().mockResolvedValue(undefined), + pullOne: vi.fn().mockResolvedValue(undefined), + deleteLocal: vi.fn().mockResolvedValue(undefined), + loadDiff: vi.fn().mockResolvedValue(undefined), + openFile: vi.fn().mockReturnValue(true), + canOpen: vi.fn().mockReturnValue(true), + revertMove: vi.fn().mockResolvedValue(undefined), + pushMoveGroup: vi.fn().mockResolvedValue(undefined), + revertMoveGroup: vi.fn().mockResolvedValue(undefined), + pushAllModified: vi.fn().mockResolvedValue(undefined), + pullAllModified: vi.fn().mockResolvedValue(undefined), + }; + return { commands, controller: new SyncStatusController(commands) }; +} + +describe('SyncStatusController', () => { + it('forwards refresh to the workspace command boundary', async () => { + const { commands, controller } = setup(); + await controller.refresh(); + expect(commands.refresh).toHaveBeenCalledOnce(); + }); + + it.each(['push', 'pull', 'delete'] as const)('forwards selected paths to %s unchanged', async command => { + const { commands, controller } = setup(); + await controller[command](['a.md', 'Folder/b.md']); + expect(commands[command]).toHaveBeenCalledWith(['a.md', 'Folder/b.md']); + }); + + it('opens a diff by path without exposing provider details', async () => { + const { commands, controller } = setup(); + await controller.openDiff('a.md'); + expect(commands.openDiff).toHaveBeenCalledWith('a.md'); + }); + + it.each([ + ['pushOne', 'pushOne'], + ['pullOne', 'pullOne'], + ['deleteLocal', 'deleteLocal'], + ['revertMove', 'revertMove'], + ] as const)('forwards a row to %s', async (controllerMethod, portMethod) => { + const { commands, controller } = setup(); + const status: FileStatus = { path: 'a.md', status: 'modified' }; + + await controller[controllerMethod](status); + + expect(commands[portMethod]).toHaveBeenCalledWith(status); + }); + + it('forwards move groups without converting them to provider objects', async () => { + const { commands, controller } = setup(); + const members: FileStatus[] = [{ path: 'new/a.md', movedFrom: 'old/a.md', status: 'moved' }]; + + await controller.pushMoveGroup(members); + await controller.revertMoveGroup(members); + + expect(commands.pushMoveGroup).toHaveBeenCalledWith(members); + expect(commands.revertMoveGroup).toHaveBeenCalledWith(members); + }); +}); diff --git a/tests/ui/sync-status/SyncStatusSelectors.test.ts b/tests/ui/sync-status/SyncStatusSelectors.test.ts new file mode 100644 index 0000000..4a9a9ba --- /dev/null +++ b/tests/ui/sync-status/SyncStatusSelectors.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; +import type { FileStatus } from '../../../src/ui/types'; +import { + collapsibleMoveGroups, + pruneSelection, + searchedStatuses, + selectedVisibleFiles, + visibleStatuses, +} from '../../../src/ui/sync-status/SyncStatusSelectors'; +import { SyncStatusViewState } from '../../../src/ui/sync-status/SyncStatusViewState'; + +const STATUSES: FileStatus[] = [ + { path: 'Notes/alpha.md', status: 'modified' }, + { path: 'Notes/beta.md', status: 'unsynced' }, + { path: 'Notes/daily.md', status: 'synced' }, + { path: 'Remote/readme.md', status: 'remote-only' }, +]; + +describe('SyncStatusSelectors', () => { + it.each([ + { query: '', filter: 'all' as const, expected: ['Notes/alpha.md', 'Notes/beta.md', 'Remote/readme.md'] }, + { query: 'notes', filter: 'all' as const, expected: ['Notes/alpha.md', 'Notes/beta.md'] }, + { query: 'notes', filter: 'unsynced' as const, expected: ['Notes/beta.md'] }, + { query: 'REMOTE', filter: 'remote-only' as const, expected: ['Remote/readme.md'] }, + ])('combines search and filter: $query / $filter', ({ query, filter, expected }) => { + const state = new SyncStatusViewState(); + state.setSearchQuery(query); + state.setStatusFilter(filter); + + expect(visibleStatuses(state, STATUSES).map(status => status.path)).toEqual(expected); + }); + + it('shows synced rows in flat mode or when explicitly enabled', () => { + const state = new SyncStatusViewState(); + state.setTreeViewEnabled(false); + + expect(visibleStatuses(state, STATUSES).map(status => status.status)).toEqual([ + 'modified', 'unsynced', 'remote-only', 'synced', + ]); + + state.setTreeViewEnabled(true); + state.setShowSyncedInAll(true); + expect(visibleStatuses(state, STATUSES)).toEqual(STATUSES); + }); + + it('searches full folder paths case-insensitively', () => { + const state = new SyncStatusViewState(); + state.setSearchQuery('notes/'); + + expect(searchedStatuses(state, STATUSES).map(status => status.path)).toEqual([ + 'Notes/alpha.md', 'Notes/beta.md', 'Notes/daily.md', + ]); + }); + + it('returns selected visible files and a pruned selection without mutation', () => { + const state = new SyncStatusViewState(); + state.select('Notes/alpha.md'); + state.select('Notes/daily.md'); + const visible = visibleStatuses(state, STATUSES); + + expect(selectedVisibleFiles(state, visible).map(status => status.path)).toEqual(['Notes/alpha.md']); + expect([...pruneSelection(state.selectedFiles, visible)]).toEqual(['Notes/alpha.md']); + expect([...state.selectedFiles]).toEqual(['Notes/alpha.md', 'Notes/daily.md']); + }); + + it('groups complete folder moves but leaves partial moves visible', () => { + const moved: FileStatus[] = [ + { path: 'New/a.md', movedFrom: 'Old/a.md', status: 'moved' }, + { path: 'New/b.md', movedFrom: 'Old/b.md', status: 'moved' }, + ]; + + expect(collapsibleMoveGroups(moved, moved).size).toBe(1); + expect(collapsibleMoveGroups(moved, [...moved, { path: 'Old/left.md', status: 'synced' }]).size).toBe(0); + }); +}); diff --git a/tests/ui/sync-status/SyncStatusView.wiring.test.ts b/tests/ui/sync-status/SyncStatusView.wiring.test.ts new file mode 100644 index 0000000..8e2e847 --- /dev/null +++ b/tests/ui/sync-status/SyncStatusView.wiring.test.ts @@ -0,0 +1,55 @@ +import { beforeAll, describe, expect, it, vi } from 'vitest'; +import { WorkspaceLeaf } from 'obsidian'; +import { SyncStatusView } from '../../../src/ui/SyncStatusView'; +import { SyncStatusController } from '../../../src/ui/sync-status/SyncStatusController'; +import { SyncStatusService } from '../../../src/logic/sync-status-service'; +import type GitLabFilesPush from '../../../src/main'; +import { setupObsidianDOM } from '../setup-dom'; + +describe('SyncStatusView controller wiring', () => { + beforeAll(() => setupObsidianDOM()); + + it('routes refresh and selected batch actions through path-only controller commands', async () => { + const status = new SyncStatusService(); + status.set({ path: 'a.md', status: 'modified' }); + const commands = { + refresh: vi.fn().mockResolvedValue(undefined), + push: vi.fn().mockResolvedValue(undefined), + pull: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + openDiff: vi.fn().mockResolvedValue(undefined), + pushOne: vi.fn().mockResolvedValue(undefined), + pullOne: vi.fn().mockResolvedValue(undefined), + deleteLocal: vi.fn().mockResolvedValue(undefined), + loadDiff: vi.fn().mockResolvedValue(undefined), + openFile: vi.fn().mockReturnValue(true), + canOpen: vi.fn().mockReturnValue(true), + revertMove: vi.fn().mockResolvedValue(undefined), + pushMoveGroup: vi.fn().mockResolvedValue(undefined), + revertMoveGroup: vi.fn().mockResolvedValue(undefined), + pushAllModified: vi.fn().mockResolvedValue(undefined), + pullAllModified: vi.fn().mockResolvedValue(undefined), + }; + const plugin = { + settings: { branch: 'main', vaultFolder: '', rootPath: '' }, + sync: { status }, + } as unknown as GitLabFilesPush; + const leaf = { + app: { vault: { getFileByPath: vi.fn().mockReturnValue(null) }, workspace: {} }, + } as unknown as WorkspaceLeaf; + const view = new SyncStatusView(leaf, plugin, new SyncStatusController(commands)); + (view as unknown as { selectedFiles: Set }).selectedFiles.add('a.md'); + + await view.onOpen(); + const root = view.containerEl.children[1] as HTMLElement; + root.querySelector('.ssv-btn-refresh')!.click(); + root.querySelector('.ssv-btn-push')!.click(); + root.querySelector('.ssv-btn-pull')!.click(); + root.querySelector('.ssv-btn-danger')!.click(); + + expect(commands.refresh).toHaveBeenCalledOnce(); + expect(commands.push).toHaveBeenCalledWith(['a.md']); + expect(commands.pull).toHaveBeenCalledWith(['a.md']); + expect(commands.delete).toHaveBeenCalledWith(['a.md']); + }); +}); diff --git a/tests/ui/sync-status/SyncStatusViewState.test.ts b/tests/ui/sync-status/SyncStatusViewState.test.ts new file mode 100644 index 0000000..118b2b1 --- /dev/null +++ b/tests/ui/sync-status/SyncStatusViewState.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import { SyncStatusViewState } from '../../../src/ui/sync-status/SyncStatusViewState'; + +describe('SyncStatusViewState', () => { + it('owns presentation defaults independently of domain state', () => { + const state = new SyncStatusViewState(); + + expect(state.statusFilter).toBe('all'); + expect(state.treeViewEnabled).toBe(true); + expect(state.showSyncedInAll).toBe(false); + expect(state.searchQuery).toBe(''); + expect(state.selectedFiles.size).toBe(0); + expect(state.refreshState).toEqual({ isRefreshing: false, current: 0, total: 0, lastSyncTime: 0 }); + }); + + it('normalizes search queries and transitions refresh state', () => { + const state = new SyncStatusViewState(); + + state.setSearchQuery(' Notes/Daily '); + state.startRefresh(); + state.updateRefreshProgress(2, 5); + state.finishRefresh(1234); + + expect(state.searchQuery).toBe('Notes/Daily'); + expect(state.refreshState).toEqual({ isRefreshing: false, current: 2, total: 5, lastSyncTime: 1234 }); + }); + + it('encapsulates selection, folder, and move-group transitions', () => { + const state = new SyncStatusViewState(); + + state.select('a.md'); + state.select('b.md'); + state.toggleCollapsedFolder('Notes'); + state.toggleExpandedMoveGroup('move-key'); + state.retainSelected(new Set(['b.md'])); + + expect([...state.selectedFiles]).toEqual(['b.md']); + expect(state.collapsedFolders.has('Notes')).toBe(true); + expect(state.expandedMoveGroups.has('move-key')).toBe(true); + + state.toggleCollapsedFolder('Notes'); + state.toggleExpandedMoveGroup('move-key'); + expect(state.collapsedFolders.size).toBe(0); + expect(state.expandedMoveGroups.size).toBe(0); + }); +});