diff --git a/CLAUDE.md b/CLAUDE.md index 91607d5..a59f202 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,13 +18,27 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - Test: `npm run test` (runs vitest suite) - Version bump: `npm run version` (updates manifest.json and versions.json via script) -## Code Architecture -- **Type**: Obsidian Plugin (TypeScript) that syncs vault files with a GitLab or GitHub repository. -- **Entry Point**: `src/main.ts` contains the main `GitLabFilesPush` class extending `Plugin`. +## Architecture Contract + +Before production code changes: +- Read `docs/architecture.md`. +- For bug fixes also read `docs/bug-fix-guidelines.md`. +- Identify the owning module before editing. +- Preserve dependency direction. +- Do not bypass `SyncWorkspace`. +- Do not move domain/provider logic into UI. +- Do not duplicate status/conflict/rename/action policy. + +`docs/architecture.md` is the canonical module map (layers, ownership table, MUST/MUST NOT rules, current hotspots) — this file does not duplicate it. A short summary: + +- **Type**: Obsidian Plugin (TypeScript) that syncs vault files with a GitHub, GitLab, or Gitea repository. +- **Entry point**: `src/main.ts` owns only Obsidian lifecycle (settings load/save, command/view/ribbon/vault-event registration); the sync/Source Control constructor graph is wired by `src/runtime/createSyncRuntime.ts`. - **Settings**: `src/settings.ts` defines `GitLabFilesPushSettings` interface, `DEFAULT_SETTINGS` object, and `GitLabSyncSettingTab` for the Obsidian UI. -- **Services**: `src/services/` abstracts the git provider behind `GitServiceInterface`, with `GitHubService` and `GitLabService` implementations sharing common logic via `BaseGitService`. -- **Sync logic**: `src/logic/sync-manager.ts` handles push/pull, conflict detection, and rename detection; `src/logic/gitignore-manager.ts` merges local and remote `.gitignore` rules. -- **UI**: `src/ui/SyncStatusView.ts` renders the sync status side panel; `src/ui/components/` holds its sub-views. + +Two compatibility gotchas not covered by `docs/architecture.md`: +- Do not reintroduce `SyncStatusView` or `ui/sync-status/*` — that legacy presentation layer was replaced by the Source Control surface (`SourceControlItemView`/`SourceControlView`) and is blocked by an ESLint `no-restricted-imports` rule (`eslint.config.*`). The historical migration docs live in `docs/source-control-refactor/` and are marked as such; they are not current implementation guidance. +- `SOURCE_CONTROL_VIEW_TYPE` (`'sync-status-view'`) and the `open-sync-status` command id are intentionally kept as-is for pinned-leaf/workspace-layout compatibility — they resolve to the current `SourceControlItemView`, not a leftover of the old UI. Do not rename them as "cleanup." + - **Bundling**: Uses `esbuild.config.mjs` for compilation from TypeScript to a single `main.js` file. - **Deployment**: Relies on `manifest.json` for plugin metadata and `versions.json` for version mapping/compatibility. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..bd5dcef --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,179 @@ +# Architecture + +This document is the canonical architecture guide for the current `git-files-sync` implementation. + +Historical design notes and refactor plans are useful context, but they are **not** current implementation guidance. When code and an old plan disagree, follow this document and the current code boundaries. + +## 1. System layers + +```text +Obsidian / UI + ↓ +Source Control application layer + ↓ +SyncWorkspace + ↓ +Sync domain + ↓ +GitServiceInterface + ↓ +GitHub / GitLab / Gitea +``` + +The dependency direction should normally flow downward. Results and state flow back upward through return values, stores, subscriptions, or interaction ports rather than by introducing reverse imports. + +## 2. High-level modules + +| Layer | Module | Owns | Interacts with | Must not own | +| --- | --- | --- | --- | --- | +| Plugin runtime | `src/main.ts` | Obsidian lifecycle, command/view/event registration | settings, `createSyncRuntime`, UI | sync/Source Control constructor graph, sync planning, conflict algorithms, provider-specific workflow | +| Plugin runtime | `createSyncRuntime` (`src/runtime/createSyncRuntime.ts`) | wires `SyncManager`, `SyncStatusRefreshService`, `SyncDiffService`, `SyncWorkspace`, and the Source Control application layer together | the constructors it composes | Obsidian lifecycle events, commands, views, ribbons | +| UI | `src/ui/source-control/*` | rendering, user interaction, Source Control composition | `SourceControlViewModel`, `SourceControlActionService` | provider API calls, sync classification rules | +| Application | `ChangeRepository` | authoritative Source Control `SyncChange` snapshot | `FileStatusAdapter`, ViewModel, action services | remote Git or filesystem access | +| Application | `SyncSelectionStore` | queued selection and explicit per-change action override | ViewModel, current repository snapshot | sync execution | +| Application | `ChangeActionPolicy` | allowed/default action for each change kind | ViewModel, selection reconciliation, intent execution | UI rendering, network calls | +| Application | `SourceControlViewModel` | read-only projection of application state for UI | repository, selection, operation/refresh state | side effects, provider calls, filesystem writes | +| Application | `SourceControlActionService` | stable UI-facing facade for immediate Source Control commands | `SyncWorkspace`, `SyncIntentExecutor` | provider-specific logic, duplicated sync planning | +| Application | `SyncIntentExecutor` | one Sync Queue workflow: resolve intent, plan, confirm, execute, aggregate | repository, action policy, `SyncWorkspace`, notifier | UI DOM, provider API implementation | +| Boundary | `SyncWorkspace` | application-to-sync execution boundary | `SyncManager`, refresh service, diff service | Source Control rendering | +| Sync domain | `SyncManager` | compatibility/domain facade for sync operations | coordinators, executors, metadata/status services | Source Control UI state | +| Sync domain | `PushCoordinator` | batch push use case including planning/conflict/review/commit coordination | planner, conflict resolver, push executor | Source Control selection state | +| Sync domain | `PullCoordinator` | batch pull planning and application | pull executor, conflict flow | Source Control UI rendering | +| Sync domain | `SyncPlanner` | sync plan construction | coordinators | network/UI side effects | +| Sync domain | `ConflictResolver` | conflict validation and resolution coordination | interaction port, provider state | Source Control rendering | +| Sync domain | `PushExecutor` | provider-side batch mutations | `GitServiceInterface`, metadata | UI state | +| Sync domain | `PullExecutor` | local file application for pulls | Obsidian vault, metadata | Source Control UI state | +| Sync domain | `RemoteDeleteExecutor` | remote deletion execution | `GitServiceInterface` | UI | +| Sync domain | `SyncStatusRefreshService` | orchestrates discovery → resolve → reconcile → publish, plus incremental create/modify/delete/rename event handling | `SyncFileDiscovery`, `SyncStatusResolver`, `RenameReconciler`, status store | Source Control rendering, the three algorithms below (delegates to their owning class) | +| Sync domain | `SyncFileDiscovery` | vault/hidden-file/remote-tree/gitignore/symlink discovery, remote-only vs local-deleted classification | vault, provider, gitignore, status store | status resolution, rename reconciliation | +| Sync domain | `SyncStatusResolver` | local-vs-remote status resolution: SHA/content comparison, baseline diff direction, `FileStatus` classification | provider, sync manager, status store | discovery, rename reconciliation | +| Sync domain | `RenameReconciler` | out-of-band (external) rename detection by orphan/candidate blob-sha matching | sync manager, status store | discovery, status resolution | +| Sync domain | `SyncStatusService` | observable `FileStatus` store and status classification | refresh/sync domain, adapters | UI orchestration | +| Sync domain | `SyncMetadataStore` | last-synced SHA and rename metadata persistence | manager/executors/coordinators | presentation | +| Sync domain | `SyncDiffService` | diff content/stat loading and cache | status store, blob loader, workspace, `DiffStat` | sync orchestration | +| Sync domain | `DiffStat` (`src/logic/sync/DiffStat.ts`) | pure +/- diff-stat computation and the `DiffStatLoadResult` contract | diff utilities | UI rendering, provider calls | +| Interaction boundary | `SyncInteractionPort` | domain-facing confirmation/conflict interaction contract | domain, Obsidian adapter | provider implementation | +| UI adapter | `ObsidianSyncInteraction` | Obsidian modal/notice implementation of interaction port | `SyncInteractionPort`, modal UI | sync algorithms | +| Infrastructure | `GitServiceInterface` | provider abstraction used by the sync domain | concrete provider services | UI/application state | +| Infrastructure | `BaseGitService` | shared provider HTTP, encoding and error behavior | concrete provider services | sync workflow | +| Infrastructure | `GitHubService` / `GitLabService` / `GiteaService` | provider-specific Git API behavior | remote Git service | Source Control UI | +| Infrastructure | `GitignoreManager` | `.gitignore` evaluation for remote/local scope | refresh/scanning logic | UI behavior | +| Shared | settings model/helpers | persisted configuration and configuration rules | runtime, providers, sync | settings rendering concerns when avoidable | +| Shared | `utils/*` | low-level reusable helpers | low-level consumers | application workflow state | + +## 3. Core runtime flows + +### Status/read flow + +```text +Vault + Remote Git + ↓ +SyncStatusRefreshService + ↓ +SyncStatusService + ↓ +FileStatusAdapter + ↓ +ChangeRepository + ↓ +SourceControlViewModel + ↓ +SourceControlView +``` + +Status classification belongs in the sync/status path. The UI must not repair or reinterpret an incorrect status by adding presentation-only exceptions. + +### Immediate action flow + +```text +SourceControlView + ↓ +SourceControlActionService + ↓ +SyncWorkspace + ↓ +SyncManager / executors + ↓ +GitServiceInterface or Obsidian Vault +``` + +### Sync Queue flow + +```text +SourceControlView + ↓ +SourceControlActionService.sync() + ↓ +SyncIntentExecutor + ↓ +resolve current ChangeId + revalidate explicit action + ↓ +build one merged Sync Plan + ↓ +confirm once + ↓ +SyncWorkspace + ├─ remote mutation bucket (max one provider batch) + └─ local pull bucket +``` + +## 4. Architecture rules + +### MUST + +- Source Control execution must cross the sync boundary through `SyncWorkspace`. +- `SourceControlViewModel` reads must be observational and side-effect free. +- Provider-specific behavior must stay behind `GitServiceInterface` and concrete provider services. +- A status/change classification rule must have one source of truth rather than being duplicated in UI and domain code. +- Explicit Sync Queue actions must be revalidated against the current change kind immediately before planning/execution. +- One Sync Queue action must produce one merged review/confirmation flow. +- Remote mutations from one Sync Queue execution must be grouped into at most one provider mutation batch when supported by the current workflow. +- Existing compatibility identifiers such as `sync-status-view` and `open-sync-status` must be preserved unless a migration explicitly removes them. + +### MUST NOT + +- UI code must not import concrete `GitHubService`, `GitLabService`, or `GiteaService` to fix a feature or bug. +- Source Control application code must not bypass `SyncWorkspace` to call `SyncManager`, coordinators, executors, or providers directly. +- Sync-domain modules must not import `src/ui/source-control/*`. +- `getState()`, render methods, or projection helpers must not mutate selection, metadata, filesystem, or remote state. +- Bug fixes must not duplicate status, rename, conflict, or action-selection rules in a second layer. +- Provider differences must not be handled by scattering `serviceType === ...` checks through Source Control or UI code when the provider abstraction can own the behavior. + +## 5. Change placement guide + +Use the owning module first. The shortest patch is not automatically the correct patch. + +| Problem | Primary owner | Do not fix by | +| --- | --- | --- | +| Source Control displays the wrong label/icon/layout | UI presentation / `SourceControlViewModel` projection | changing `SyncManager` | +| A row chooses the wrong push/pull/delete action | `ChangeActionPolicy`, `SyncSelectionStore`, or `SyncIntentExecutor` | adding special-case `if` logic in `SourceControlView` | +| A file is classified incorrectly (`modified`, `remote-only`, `local-deleted`, conflict direction) | `SyncStatusRefreshService` / `SyncStatusService` classification path | correcting the status only in UI | +| Local/remote files are missing from refresh results | `SyncStatusRefreshService`, scope/gitignore logic | fetching the provider directly from ViewModel/UI | +| Rename/move is detected incorrectly | sync metadata + rename reconciliation in the sync refresh/domain path | duplicating rename detection in UI or provider service | +| Push plan/conflict behavior is wrong | `PushCoordinator`, `SyncPlanner`, `ConflictResolver` | reproducing the algorithm in `SourceControlActionService` | +| Pull application is wrong | `PullCoordinator` / `PullExecutor` | adding filesystem writes to ViewModel/UI | +| GitHub-only API behavior is wrong | `GitHubService` (or shared `BaseGitService` if common) | checking GitHub inside Source Control application code | +| GitLab/Gitea/provider-common HTTP behavior is wrong | provider implementation or `BaseGitService` | copy/pasting workarounds into all callers | +| Diff content/stat is wrong | `SyncDiffService` / diff presentation components depending on the defect | performing ad-hoc provider reads from the row component | + +If the owning module cannot fix a bug without crossing a forbidden boundary, improve the boundary first instead of adding a shortcut. + +## 6. Current hotspots + +Some current modules have high responsibility density. That is not permission to bypass them, and file size alone is not a reason to split them. + +- `main.ts`: reduced to Obsidian lifecycle (settings load/save, command/view/ribbon/vault-event registration). The sync/Source Control constructor graph now lives in `createSyncRuntime` (`src/runtime/createSyncRuntime.ts`). +- `SyncStatusRefreshService`: reduced to orchestration (discovery → resolve → reconcile → publish) plus the incremental create/modify/delete/rename handlers. Discovery, status resolution, and rename reconciliation each now have a single owner: `SyncFileDiscovery`, `SyncStatusResolver`, `RenameReconciler`. +- `SourceControlView`: reduced by extracting the "Sync Queue" and "Repository Changes" regions into `SyncQueueSection`/`RepositoryChangesSection` (`src/ui/source-control/`, pure state+callbacks render functions matching `FilterMenu`/`SourceControlHeader`). Still owns the diff pane, scroll-state management, and section composition. +- `PushCoordinator`: large, but still centered on one batch-push use case; split only when a stable responsibility boundary is identified. + +Future refactors should reduce these hotspots while preserving the dependency direction in this document. + +## 7. Documentation hierarchy + +- `docs/architecture.md` — canonical repo-wide architecture and dependency rules. +- `docs/source-control.md` — current Source Control subsystem details and invariants. +- `docs/bug-fix-guidelines.md` — required workflow for bug fixes and small changes. +- `docs/source-control-refactor/*` — historical migration/refactor records only; not current implementation guidance. + +When an architectural boundary changes, update this document in the same PR as the code change. diff --git a/docs/bug-fix-guidelines.md b/docs/bug-fix-guidelines.md new file mode 100644 index 0000000..2f2b923 --- /dev/null +++ b/docs/bug-fix-guidelines.md @@ -0,0 +1,58 @@ +# Bug Fix Guidelines + +Bug fixes must preserve the architecture, not merely make the failing behavior disappear. + +Read `docs/architecture.md` before changing production code that crosses module boundaries. + +## Required workflow + +1. **Identify the owner.** Determine which module owns the incorrect behavior using the responsibility matrix and change-placement guide in `docs/architecture.md`. +2. **Reproduce at the owning layer.** Add or update the narrowest useful test that proves the defect. Prefer unit/domain tests for policy and classification bugs; use integration/provider E2E when the defect depends on a real boundary. +3. **Fix the owner or its dependency.** Put the correction where the rule already belongs. +4. **Preserve dependency direction.** Do not bypass `SyncWorkspace`, provider interfaces, or existing application boundaries for convenience. +5. **Keep one source of truth.** Do not copy an existing status/conflict/rename/action rule into a second module. +6. **Review the diff for architecture drift.** A small bug fix should not quietly create a new cross-layer dependency or a second implementation of the same policy. + +## Hard rule + +> A bug fix must not introduce a new cross-layer dependency merely because it is the shortest patch. + +If the correct owner cannot solve the problem cleanly, improve the boundary first or keep the workaround explicitly local and documented until a boundary fix can be made. Do not normalize a shortcut into permanent architecture. + +## Common examples + +| Bug | Correct place to start | Avoid | +| --- | --- | --- | +| Wrong Source Control status | sync/status classification path | UI-only remapping | +| Wrong Sync Queue direction | `ChangeActionPolicy` / intent flow | row-component conditionals | +| Wrong conflict behavior | `PushCoordinator` / `ConflictResolver` | duplicating conflict logic in application facade | +| Missing remote file | refresh/discovery path | provider call from ViewModel | +| Provider-specific API failure | concrete provider / `BaseGitService` | `serviceType` branches in UI/application | +| Wrong local pull/write | pull executor/domain | filesystem operations in UI | + +## PR architecture check + +Before merging a bug fix, confirm: + +- [ ] Owning module was identified. +- [ ] The regression is covered at the appropriate layer. +- [ ] No new forbidden cross-layer dependency was introduced. +- [ ] No existing business rule was duplicated. +- [ ] UI did not gain provider or filesystem responsibility. +- [ ] Source Control did not bypass `SyncWorkspace`. +- [ ] Provider-specific behavior remains behind the provider abstraction. +- [ ] Architecture documentation was updated if a responsibility boundary changed. + +## AI-assisted changes + +When using an AI coding agent, provide or reference `docs/architecture.md` and require the agent to name the owning module before implementation. + +A valid fix plan should answer these questions before code changes: + +1. What module owns the bug? +2. What invariant is currently violated? +3. What test demonstrates the regression? +4. Which existing boundary will the fix use? +5. Does the fix add any new dependency edge? If yes, why is that edge architecturally valid? + +Do not accept a patch solely because tests pass if it moves domain logic into UI, introduces direct provider access above `SyncWorkspace`, or duplicates an existing policy. diff --git a/docs/source-control-refactor/phase-1-viewmodel-foundation.md b/docs/source-control-refactor/phase-1-viewmodel-foundation.md index ca14c94..cc23b50 100644 --- a/docs/source-control-refactor/phase-1-viewmodel-foundation.md +++ b/docs/source-control-refactor/phase-1-viewmodel-foundation.md @@ -1,5 +1,8 @@ # Phase 1 — Source Control ViewModel Foundation +> **Historical migration roadmap. Do not use as current implementation +> guidance.** See `docs/source-control.md` for the current architecture. + ## Goal 建立 Source Control UI 與 Sync domain 之間的 ViewModel layer。 diff --git a/docs/source-control-refactor/phase-2-action-unification.md b/docs/source-control-refactor/phase-2-action-unification.md index f9623bd..11b09ad 100644 --- a/docs/source-control-refactor/phase-2-action-unification.md +++ b/docs/source-control-refactor/phase-2-action-unification.md @@ -1,5 +1,8 @@ # Phase 2 — Sync Action Unification +> **Historical migration roadmap. Do not use as current implementation +> guidance.** See `docs/source-control.md` for the current architecture. + ## Goal 統一 Source Control、Context Menu、Single File 操作的 pipeline。 diff --git a/docs/source-control-refactor/phase-3-source-control-ui.md b/docs/source-control-refactor/phase-3-source-control-ui.md index f929917..1f89e9e 100644 --- a/docs/source-control-refactor/phase-3-source-control-ui.md +++ b/docs/source-control-refactor/phase-3-source-control-ui.md @@ -1,5 +1,8 @@ # Phase 3 — Source Control UI +> **Historical migration roadmap. Do not use as current implementation +> guidance.** See `docs/source-control.md` for the current architecture. + ## Goal 建立 VS Code style Source Control workflow。 diff --git a/docs/source-control-refactor/phase-4-legacy-cleanup.md b/docs/source-control-refactor/phase-4-legacy-cleanup.md index a1da710..56b4db1 100644 --- a/docs/source-control-refactor/phase-4-legacy-cleanup.md +++ b/docs/source-control-refactor/phase-4-legacy-cleanup.md @@ -1,5 +1,8 @@ # Phase 4 — Legacy Cleanup +> **Historical migration roadmap. Do not use as current implementation +> guidance.** See `docs/source-control.md` for the current architecture. + ## Goal 移除舊 Source Control orchestration,保留同步核心能力。 diff --git a/docs/source-control-refactor/roadmap.md b/docs/source-control-refactor/roadmap.md index a928ff3..d5cc3fd 100644 --- a/docs/source-control-refactor/roadmap.md +++ b/docs/source-control-refactor/roadmap.md @@ -1,8 +1,12 @@ # Source Control Refactor — Roadmap (v2) +> **Historical migration roadmap. Do not use as current implementation +> guidance.** The migration this document tracked has landed on `main`; for +> the current architecture see `docs/source-control.md`. + > Supersedes `phase-1..4-*.md`. Those phase docs are kept only as historical -> design notes; this file is the authoritative current plan, grounded in the -> actual branch state as of 2026-08-22. +> design notes; this file was the authoritative current plan as of +> 2026-08-22, before the migration it tracked landed on `main`. ## Where we actually are diff --git a/docs/source-control.md b/docs/source-control.md new file mode 100644 index 0000000..ff1058e --- /dev/null +++ b/docs/source-control.md @@ -0,0 +1,75 @@ +# Source Control — Current Architecture + +> Canonical repo-wide architecture and dependency rules: [`architecture.md`](./architecture.md). +> +> Bug-fix placement rules: [`bug-fix-guidelines.md`](./bug-fix-guidelines.md). + +The Source Control side panel is the plugin's only sync UI. Historical +migration notes live under `docs/source-control-refactor/`; they are not +current implementation guidance. + +## Call chain + +```text +SourceControlItemView + └─ SourceControlView + ├─ SourceControlViewModel # read-side projection + └─ SourceControlActionService # immediate action facade + ├─ SyncIntentExecutor # Sync Queue use-case only + └─ SyncWorkspace # immediate actions + └─ SyncManager + executors + └─ GitServiceInterface +``` + +## Responsibility boundaries + +- `ChangeRepository` is the authoritative Source Control snapshot populated + from `sync.status`. Snapshot replacements notify dependent read-side state. +- `SyncSelectionStore` owns queued selection plus explicit per-change action + overrides. It reconciles stale selection/overrides when the repository + snapshot changes. +- `SourceControlViewModel` is a read-only projection. `getState()` must not + mutate selection or execution state. +- `SourceControlActionService` is the UI-facing facade for immediate push, + pull, delete, conflict resolution, diff loading, and the stable `sync()` + entry point. +- `SyncIntentExecutor` owns the Sync Queue workflow: resolve current intent, + bucket by action, build one merged plan, confirm once, commit the remote + mutation bucket once, apply the local pull bucket, and aggregate results. +- `SyncWorkspace` remains the execution boundary. Source Control code never + talks directly to a provider or bypasses the workspace to reach sync-domain + coordinators/executors. + +## Sync Queue invariant + +One Sync click produces one explicit-intent workflow. Requested action +choices are revalidated against the change's current kind before execution; +a stale/illegal override falls back to the current default. Remote mutations +(push/move/delete/keep-local/keep-remote) are committed as one provider +batch, while pulls are local-only and applied after that remote bucket. + +## Bug-fix rule + +Do not repair Source Control defects by moving sync/provider behavior upward +into the UI. Identify the owning module first. In particular: + +- wrong display/projection → Source Control UI/ViewModel; +- wrong action intent → `ChangeActionPolicy` / selection / intent flow; +- wrong status/change classification → sync status/refresh path; +- wrong conflict/push semantics → sync domain coordinator/resolver; +- provider-specific behavior → concrete provider or shared provider base. + +See `docs/bug-fix-guidelines.md` for the full checklist. + +## Compatibility identifiers (do not remove) + +- `SOURCE_CONTROL_VIEW_TYPE = 'sync-status-view'` — retained so saved/pinned + leaves from before the Source Control migration continue to resolve. +- `open-sync-status` command id — retained for the same compatibility reason; + it routes to the current Source Control view. + +## Legacy surface (removed, do not reintroduce) + +`SyncStatusView` and `ui/sync-status/*` were the pre-migration UI and no +longer exist in `src/`. ESLint restrictions prevent those imports from being +reintroduced. diff --git a/e2e-tests/provider/suites/source-control-flows.e2e.test.ts b/e2e-tests/provider/suites/source-control-flows.e2e.test.ts index 7a77fa3..7603bd1 100644 --- a/e2e-tests/provider/suites/source-control-flows.e2e.test.ts +++ b/e2e-tests/provider/suites/source-control-flows.e2e.test.ts @@ -615,7 +615,7 @@ describe('Source Control Flows E2E', () => { const deleted = change(deletePath, 'local-deleted'); const { actionService, operations } = s.selectionStack([modified, deleted]); - await actionService.sync([modified.id, deleted.id]); + await actionService.sync([{ changeId: modified.id }, { changeId: deleted.id }]); expect(operations.get(modified.id)).toBe('success'); expect(operations.get(deleted.id)).toBe('success'); @@ -634,7 +634,7 @@ describe('Source Control Flows E2E', () => { const remoteOnly = change(p, 'remote-only'); const { actionService, operations } = s.selectionStack([remoteOnly]); - await actionService.sync([remoteOnly.id]); + await actionService.sync([{ changeId: remoteOnly.id }]); expect(operations.get(remoteOnly.id)).toBe('success'); expect(await s.readLocal(p)).toBe('remote-content'); diff --git a/e2e-tests/provider/suites/sync-manager.e2e.test.ts b/e2e-tests/provider/suites/sync-manager.e2e.test.ts index 57f16cc..88b591f 100644 --- a/e2e-tests/provider/suites/sync-manager.e2e.test.ts +++ b/e2e-tests/provider/suites/sync-manager.e2e.test.ts @@ -4,12 +4,21 @@ 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 { SyncManagerWorkspace } from '../../../src/logic/sync/SyncWorkspace'; +import { SourceControlActionService } from '../../../src/logic/source-control/SourceControlActionService'; +import { ChangeRepository } from '../../../src/logic/source-control/ChangeRepository'; +import { OperationState } from '../../../src/logic/source-control/OperationState'; +import { SyncSelectionStore } from '../../../src/logic/source-control/SyncSelectionStore'; +import { toChangeId } from '../../../src/logic/source-control/types'; // `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 // suite's minimal runtime shim provides. A type-only import is erased // entirely, so none of that module ever loads. import type { GitLabFilesPushSettings } from '../../../src/settings'; +import type { SyncStatusRefreshService } from '../../../src/logic/sync/SyncStatusRefreshService'; +import type { SyncDiffService } from '../../../src/logic/sync/SyncDiffService'; +import type { App } from 'obsidian'; import { TFile as ObsidianTFile } from 'obsidian'; import { GitVerifier } from '../support/git-verifier'; import { FakeVault, fakeApp, type TFileLike, type TFileCtor } from '../shim/fake-vault'; @@ -222,9 +231,14 @@ describe('SyncManager E2E', () => { expect(headAfterParent).toBe(headBefore); }); - it('deletes a file via the real service, verified independently', async () => { - // Deletion isn't a SyncManager method -- src/ui/SyncStatusView.ts calls - // gitService.deleteFile directly, so this reproduces that real path. + it('deletes a file via the current Source Control application path, verified independently', async () => { + // Deletion isn't a SyncManager method -- the production call chain is + // SourceControlActionService.deleteRemote() -> SyncWorkspace.deleteRemote() + // -> RemoteDeleteExecutor -> gitService.deleteFile(), not a direct + // provider call, so this exercises that full chain instead of + // bypassing it. `refreshService`/`diffService`/`app` are stubbed -- + // deleteRemote() never touches them -- the same pattern + // tests/logic/sync/SyncWorkspace.test.ts uses for its deleteRemote suite. const filePath = path('to-delete.md'); const vault = new FakeVault(TFile); vault.writeLocal(filePath, 'delete me'); @@ -235,9 +249,24 @@ describe('SyncManager E2E', () => { expect(initialPush.failed, describePushResult(initialPush)).toBe(0); expect(await verifier.fileMissing(filePath, branch)).toBe(false); - await service.deleteFile(filePath, branch, 'e2e: delete file'); - await manager.clearMetadata(filePath); + const changeId = toChangeId(filePath); + const repository = new ChangeRepository(); + repository.replace([{ id: changeId, path: filePath, kind: 'remote-only' }]); + const operations = new OperationState(); + const workspace = new SyncManagerWorkspace({ + manager: () => manager, + gitService: () => service, + settings: () => settings, + refreshService: {} as SyncStatusRefreshService, + diffService: {} as SyncDiffService, + normalizePath: p => p, + app: {} as App, + }); + const actionService = new SourceControlActionService(repository, new SyncSelectionStore(), operations, workspace); + + await actionService.deleteRemote([changeId]); + expect(operations.get(changeId)).toBe('success'); expect(await verifier.fileMissing(filePath, branch)).toBe(true); expect(settings.syncMetadata[filePath]).toBeUndefined(); }); diff --git a/e2e-tests/provider/support/source-control-scenarios.ts b/e2e-tests/provider/support/source-control-scenarios.ts index fdc6be7..cd24f76 100644 --- a/e2e-tests/provider/support/source-control-scenarios.ts +++ b/e2e-tests/provider/support/source-control-scenarios.ts @@ -211,7 +211,7 @@ export class SourceControlScenario { getDiff: (): Promise => Promise.resolve({ path: '', kind: 'text' } as FileDiff), }, ); - const actionService = new SourceControlActionService(repository, operations, workspace); + const actionService = new SourceControlActionService(repository, selection, operations, workspace); return { repository, selection, operations, actionService, workspace }; } } diff --git a/e2e-tests/provider/support/two-client-sync-scenario.ts b/e2e-tests/provider/support/two-client-sync-scenario.ts index abd9834..49a8611 100644 --- a/e2e-tests/provider/support/two-client-sync-scenario.ts +++ b/e2e-tests/provider/support/two-client-sync-scenario.ts @@ -14,6 +14,7 @@ import { GitignoreManager } from '../../../src/logic/gitignore-manager'; import { ensureSyncWorkspaceRuntime } from '../../../src/logic/sync/SyncWorkspace'; import { ChangeRepository } from '../../../src/logic/source-control/ChangeRepository'; import { OperationState } from '../../../src/logic/source-control/OperationState'; +import { SyncSelectionStore } from '../../../src/logic/source-control/SyncSelectionStore'; import { SourceControlActionService } from '../../../src/logic/source-control/SourceControlActionService'; import { toSyncChanges } from '../../../src/logic/source-control/FileStatusAdapter'; import { @@ -67,6 +68,7 @@ export class TwoClient { */ private readonly statuses: SyncStatusService; private readonly repository = new ChangeRepository(); + private readonly selection = new SyncSelectionStore(); private readonly operations = new OperationState(); private readonly refreshService: SyncStatusRefreshService; private readonly actionService: SourceControlActionService; @@ -110,7 +112,7 @@ export class TwoClient { sync: this.manager, getNormalizedPath: path => path, }, this.statuses); - this.actionService = new SourceControlActionService(this.repository, this.operations, workspace); + this.actionService = new SourceControlActionService(this.repository, this.selection, this.operations, workspace); } // --- local vault ops -------------------------------------------------- @@ -189,8 +191,8 @@ export class TwoClient { */ async sync(): Promise { await this.refresh(); - const changeIds = this.repository.getAll().map(change => change.id); - await timed(`sync ${this.name}`, () => this.actionService.sync(changeIds)); + const intents = this.repository.getAll().map(change => ({ changeId: change.id })); + await timed(`sync ${this.name}`, () => this.actionService.sync(intents)); } /** Push-only path (the per-row Sync/Push on one or more changes). */ diff --git a/eslint.config.mts b/eslint.config.mts index f90dfb0..c529bbe 100644 --- a/eslint.config.mts +++ b/eslint.config.mts @@ -50,6 +50,75 @@ export default tseslint.config( ], }, }, + { + // Architecture regression guard (docs/architecture.md): the UI layer + // must reach the sync domain only through SyncWorkspace / the Source + // Control application services -- never a concrete Git provider or a + // push/pull coordinator/executor directly. + files: ["src/ui/**/*.ts", "src/ui/**/*.tsx"], + rules: { + "no-restricted-imports": [ + "error", + { + patterns: [ + { + group: ["**/services/github-service", "**/services/gitlab-service", "**/services/gitea-service"], + message: "UI must not depend on a concrete Git provider; go through SyncWorkspace instead.", + }, + { + group: ["**/logic/sync/PushCoordinator", "**/logic/sync/PullCoordinator", "**/logic/sync/PushExecutor", "**/logic/sync/PullExecutor"], + message: "UI must not bypass SyncWorkspace to reach a push/pull coordinator or executor directly.", + }, + ], + }, + ], + }, + }, + { + // Architecture regression guard (docs/architecture.md): the Source + // Control application layer (ChangeRepository, SourceControlActionService, + // SyncIntentExecutor, ...) must reach the sync domain only through + // SyncWorkspace -- never a concrete Git provider or a push/pull + // coordinator/executor directly. + files: ["src/logic/source-control/**/*.ts"], + rules: { + "no-restricted-imports": [ + "error", + { + patterns: [ + { + group: ["**/services/github-service", "**/services/gitlab-service", "**/services/gitea-service"], + message: "Source Control must not depend on a concrete Git provider; go through SyncWorkspace instead.", + }, + { + group: ["**/logic/sync/PushCoordinator", "**/logic/sync/PullCoordinator", "**/logic/sync/PushExecutor", "**/logic/sync/PullExecutor"], + message: "Source Control must not bypass SyncWorkspace to reach a push/pull coordinator or executor directly.", + }, + ], + }, + ], + }, + }, + { + // Architecture regression guard (docs/architecture.md): sync-domain + // modules (SyncManager, coordinators, executors, status resolution) + // must not depend on the Source Control presentation layer -- the + // dependency direction runs UI -> application -> domain, never back. + files: ["src/logic/sync/**/*.ts"], + rules: { + "no-restricted-imports": [ + "error", + { + patterns: [ + { + group: ["**/ui/source-control", "**/ui/source-control/*"], + message: "Sync-domain modules must not depend on the Source Control UI; the dependency direction runs UI -> domain, never back.", + }, + ], + }, + ], + }, + }, { files: ["src/**/*.ts", "src/**/*.tsx"], ...sonarjs.configs.recommended, diff --git a/progress.md b/progress.md index f430950..f0a6c22 100644 --- a/progress.md +++ b/progress.md @@ -4,13 +4,19 @@ Completed work is archived in [archive/](./archive/), one file per calendar mont ## Current State -**Last Updated:** 2026-08-31 -**Active Feature:** Issue #143 — reduce redundant real-provider E2E round trips. Working tree changes complete, uncommitted. -**Branch / PR:** Current working branch; no commit or push created in this session. +**Last Updated:** 2026-09-01 +**Active Feature:** PR2 responsibility cleanup, item 5 done — provider contract cleanup, partial (no tracked issue number; an ad-hoc follow-up plan on top of `origin/1.6.1`, not in `feature_list.json`). +**Branch / PR:** `claude/pr2-source-control-boundary`, branched from `origin/1.6.1` (commit `69e5540`). Pushed; opened as [PR #154](https://github.com/firstsun-dev/git-files-sync/pull/154) against `1.6.1` (covers items 1-4; item 5 below lands as a follow-up commit on the same branch/PR). -**Scope:** E2E fixtures, verifier helpers, and tests only; production `SyncManager` and provider batching behavior remain unchanged. +**Scope (item 5, per the PR2 plan):** Moved `ConnectionTestResult` out of `git-service-base.ts` into `git-service-interface.ts` — it's a contract type consumed by `GitServiceInterface.testConnection`, so it belongs with the interface, not the base implementation class. `git-service-base.ts` now imports it back for its own `abstract testConnection` signature; `github-service.ts`/`gitlab-service.ts`/`gitea-service.ts`/`main.ts`/`GitLabSyncSettingTab.ts`/`tests/ui/SettingsConnectionStatus.test.ts` updated to import from the new location. Reviewed `updateConfig(...args: unknown[])` on `GitServiceInterface` per the plan's ask, but did **not** convert it to a typed discriminated union: every actual call site (`main.ts` `initializeGitService()`, 3 branches) already calls `updateConfig` on the concrete class (`GitLabService`/`GiteaService`/`GitHubService`), never through the loose interface type, so the untyped signature isn't causing a real type-safety gap today. A discriminated union would mean reshaping the interface, all three services' `updateConfig` bodies, and all three `main.ts` call sites into config-object form for no functional benefit — exactly the "touches too much, leave for later" case the plan calls out, so left as-is. -Below that: the previous "Outstanding Items"/"Verification Evidence" entries track separate, still-open work on PR #129 / `claude/source-control-foundation` — not superseded by this entry. +**Next:** PR2 plan is now fully worked through (items 1-5). Nothing further planned here; watch PR #154 for review feedback. + +Below that: the previous "Outstanding Items"/"Verification Evidence" entries track separate, still-open work on PR #129 / `claude/source-control-foundation`, Issue #143, and `claude/fix-source-control-explicit-sync-intent` — not superseded by this entry, carried over from the base branch history. + +- `npx eslint .` — 0 errors. +- `npx vitest run` — 76 files / 953 tests passed (unchanged count; pure type-relocation, no new tests needed). +- `npm run build` (tsc + Obsidian 1.11.0 compat typecheck + esbuild) — passed. ## Outstanding Items @@ -19,6 +25,12 @@ Below that: the previous "Outstanding Items"/"Verification Evidence" entries tra ## Verification Evidence +This session (explicit per-file sync actions, 7 commits on `claude/fix-source-control-explicit-sync-intent`): + +- Each commit individually verified before being made: `npx eslint .` (0 errors), `npx vitest run` (68 files, growing from 892 to 914 tests across the branch), `npm run build` (tsc + Obsidian 1.11.0 compat typecheck + esbuild) — all passed at every commit. +- Final state: `npx eslint .` — 0 errors. `npx vitest run` — 68 files / 914 tests passed. `npm run build` — passed. +- Not run this session: the real-provider E2E suite (`vitest.e2e.config.ts`) — only typechecked (two call sites updated for the new `SyncIntentRequest[]` shape), not executed; needs provisioned credentials. + This session (Issue #143 — reduce redundant real-provider E2E round trips): - `SyncManagerFixture.makeSettings()` and the standalone SyncManager E2E settings now use the selected provider identity, instead of hard-coding Gitea. diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index b55243d..bac2a95 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -211,6 +211,24 @@ const en = { 'sourceControl.queue.delete': 'Delete', 'sourceControl.action.download': 'Download', 'sourceControl.action.download.tooltip': 'Download from remote', + 'sourceControl.queue.action.push': 'Push local', + 'sourceControl.queue.action.pull': 'Use remote', + 'sourceControl.queue.action.deleteRemote': 'Delete remote', + 'sourceControl.queue.menu.viewDiff': 'View diff', + 'sourceControl.queue.menu.removeFromQueue': 'Remove from Sync Queue', + 'sourceControl.row.menu.tooltip': 'More actions', + 'sourceControl.row.menu.pushLocal': 'Push local', + 'sourceControl.row.menu.useRemote': 'Use remote', + 'sourceControl.row.menu.useRemoteEllipsis': 'Use remote…', + 'sourceControl.row.menu.pushLocalEllipsis': 'Push local…', + 'sourceControl.row.menu.deleteRemote': 'Delete remote', + 'sourceControl.row.menu.deleteRemoteEllipsis': 'Delete remote…', + 'sourceControl.row.menu.deleteLocalEllipsis': 'Delete local…', + 'sourceControl.row.menu.restoreLocal': 'Restore local', + 'sourceControl.row.menu.viewDiff': 'View diff', + 'sourceControl.row.menu.addToQueue': 'Add to Sync Queue', + 'sourceControl.row.menu.openRemote': 'Open remote', + 'sourceControl.row.confirmDeleteRemote': 'Delete "{path}" from the remote? This does not affect the local copy.', 'sourceControl.empty': 'No changes', 'sourceControl.detail.back': 'Back', 'sourceControl.mobile.filesSelected': '{count} files selected', diff --git a/src/i18n/locales/zh-cn.ts b/src/i18n/locales/zh-cn.ts index 21126ae..60898b6 100644 --- a/src/i18n/locales/zh-cn.ts +++ b/src/i18n/locales/zh-cn.ts @@ -213,6 +213,24 @@ const zhCn: Partial> = { 'sourceControl.queue.delete': '删除', 'sourceControl.action.download': '下载', 'sourceControl.action.download.tooltip': '从远程下载', + 'sourceControl.queue.action.push': '推送本机', + 'sourceControl.queue.action.pull': '使用远程', + 'sourceControl.queue.action.deleteRemote': '删除远程', + 'sourceControl.queue.menu.viewDiff': '查看差异', + 'sourceControl.queue.menu.removeFromQueue': '从同步队列移除', + 'sourceControl.row.menu.tooltip': '更多操作', + 'sourceControl.row.menu.pushLocal': '推送本机', + 'sourceControl.row.menu.useRemote': '使用远程', + 'sourceControl.row.menu.useRemoteEllipsis': '使用远程…', + 'sourceControl.row.menu.pushLocalEllipsis': '推送本机…', + 'sourceControl.row.menu.deleteRemote': '删除远程', + 'sourceControl.row.menu.deleteRemoteEllipsis': '删除远程…', + 'sourceControl.row.menu.deleteLocalEllipsis': '删除本机…', + 'sourceControl.row.menu.restoreLocal': '还原本机', + 'sourceControl.row.menu.viewDiff': '查看差异', + 'sourceControl.row.menu.addToQueue': '加入同步队列', + 'sourceControl.row.menu.openRemote': '打开远程', + 'sourceControl.row.confirmDeleteRemote': '要从远程删除“{path}”吗?这不会影响本地副本。', 'sourceControl.empty': '没有更改', 'sourceControl.detail.back': '返回', 'sourceControl.mobile.filesSelected': '已选 {count} 个文件', diff --git a/src/i18n/locales/zh-tw.ts b/src/i18n/locales/zh-tw.ts index 71973a7..dec9fc0 100644 --- a/src/i18n/locales/zh-tw.ts +++ b/src/i18n/locales/zh-tw.ts @@ -213,6 +213,24 @@ const zhTw: Partial> = { 'sourceControl.queue.delete': '刪除', 'sourceControl.action.download': '下載', 'sourceControl.action.download.tooltip': '從遠端下載', + 'sourceControl.queue.action.push': '推送本機', + 'sourceControl.queue.action.pull': '使用遠端', + 'sourceControl.queue.action.deleteRemote': '刪除遠端', + 'sourceControl.queue.menu.viewDiff': '檢視差異', + 'sourceControl.queue.menu.removeFromQueue': '從同步佇列移除', + 'sourceControl.row.menu.tooltip': '更多操作', + 'sourceControl.row.menu.pushLocal': '推送本機', + 'sourceControl.row.menu.useRemote': '使用遠端', + 'sourceControl.row.menu.useRemoteEllipsis': '使用遠端…', + 'sourceControl.row.menu.pushLocalEllipsis': '推送本機…', + 'sourceControl.row.menu.deleteRemote': '刪除遠端', + 'sourceControl.row.menu.deleteRemoteEllipsis': '刪除遠端…', + 'sourceControl.row.menu.deleteLocalEllipsis': '刪除本機…', + 'sourceControl.row.menu.restoreLocal': '還原本機', + 'sourceControl.row.menu.viewDiff': '檢視差異', + 'sourceControl.row.menu.addToQueue': '加入同步佇列', + 'sourceControl.row.menu.openRemote': '開啟遠端', + 'sourceControl.row.confirmDeleteRemote': '要從遠端刪除「{path}」嗎?這不會影響本機副本。', 'sourceControl.empty': '沒有變更', 'sourceControl.detail.back': '返回', 'sourceControl.mobile.filesSelected': '已選 {count} 個檔案', diff --git a/src/logic/source-control/ChangeActionPolicy.ts b/src/logic/source-control/ChangeActionPolicy.ts index c36bbb7..e983528 100644 --- a/src/logic/source-control/ChangeActionPolicy.ts +++ b/src/logic/source-control/ChangeActionPolicy.ts @@ -9,9 +9,9 @@ import type { SyncChangeKind } from './types'; * which primitive a change kind maps to isn't a rendering concern — so it * lives here instead, decoupled from presentation. */ -export type DefaultSyncAction = 'push' | 'pull' | 'delete-remote'; +export type SyncAction = 'push' | 'pull' | 'delete-remote'; -const DEFAULT_ACTION: Record = { +const DEFAULT_ACTION: Record = { 'local-only': 'push', 'local-modified': 'push', // A tracked file removed locally has no local content to push, so its @@ -29,8 +29,45 @@ const DEFAULT_ACTION: Record = { synced: 'push', }; +// Every action a change kind may legally resolve to, default first. Drives +// both what an explicit override is allowed to be and the fallback when a +// stored override no longer applies (see `resolveSyncAction`). `conflict` +// and `synced` intentionally allow only their default: conflict resolution +// runs through `BatchConflictResolutionModal`, not a Queue override, and +// `synced` never reaches the Sync Queue at all. +const AVAILABLE_ACTIONS: Record = { + 'local-only': ['push'], + 'local-modified': ['push', 'pull'], + 'local-deleted': ['delete-remote', 'pull'], + 'remote-only': ['pull', 'delete-remote'], + 'remote-modified': ['pull', 'push'], + moved: ['push'], + conflict: ['push'], + synced: ['push'], +}; + /** The default sync action a change kind routes to when synced from the Sync Queue. */ -export function defaultSyncAction(kind: SyncChangeKind): DefaultSyncAction { +export function defaultSyncAction(kind: SyncChangeKind): SyncAction { + return DEFAULT_ACTION[kind]; +} + +/** Every action a change kind may legally resolve to, default first. */ +export function availableSyncActions(kind: SyncChangeKind): readonly SyncAction[] { + return AVAILABLE_ACTIONS[kind]; +} + +/** + * Resolves the action a change actually syncs as: the given override if it's + * still legal for `kind`, otherwise the kind's default. This is what makes a + * stale override (e.g. the user picked "pull" on a `local-modified` change, + * then it became `local-only` after a remote delete) harmless — it silently + * falls back instead of ever executing an action the current kind can't + * support. + */ +export function resolveSyncAction(kind: SyncChangeKind, override?: SyncAction): SyncAction { + if (override && AVAILABLE_ACTIONS[kind].includes(override)) { + return override; + } return DEFAULT_ACTION[kind]; } diff --git a/src/logic/source-control/ChangeRepository.ts b/src/logic/source-control/ChangeRepository.ts index 2463e33..c1505a6 100644 --- a/src/logic/source-control/ChangeRepository.ts +++ b/src/logic/source-control/ChangeRepository.ts @@ -1,16 +1,20 @@ import type { ChangeId, SyncChange } from './types'; +type ChangeRepositoryListener = (changes: readonly SyncChange[]) => void; + /** - * Read-side lookup for the current set of pending `SyncChange`s. Holds no - * sync/business logic of its own — it's populated wholesale (`replace`) by - * whatever assembles `SyncChange[]` from the sync domain, and exists purely - * to give the ViewModel and UI O(1) lookup by id or path instead of scanning - * an array. + * Read-side lookup for the current set of Source Control changes. + * + * The repository is populated wholesale from the sync.status pipeline and + * exposes one snapshot-change notification so dependent state stores can + * reconcile when that source of truth changes. It still owns no sync or + * provider behavior. */ export class ChangeRepository { private changes: SyncChange[] = []; private readonly byId = new Map(); private readonly byPath = new Map(); + private readonly listeners = new Set(); /** Replaces the full change set, e.g. after a status refresh. */ replace(changes: readonly SyncChange[]): void { @@ -21,6 +25,13 @@ export class ChangeRepository { this.byId.set(change.id, change); this.byPath.set(change.path, change); } + for (const listener of this.listeners) listener(this.changes); + } + + /** Subscribes to authoritative snapshot replacements. */ + subscribe(listener: ChangeRepositoryListener): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); } getAll(): SyncChange[] { diff --git a/src/logic/source-control/SourceControlActionService.ts b/src/logic/source-control/SourceControlActionService.ts index 9cdb841..225d9ae 100644 --- a/src/logic/source-control/SourceControlActionService.ts +++ b/src/logic/source-control/SourceControlActionService.ts @@ -1,13 +1,16 @@ -import type { PlannedPushBatch } from '../sync/PushCoordinator'; import type { SyncWorkspace } from '../sync/SyncWorkspace'; -import { isSyncPlanEmpty, type DeleteQueueEntry, type PushResults, type SyncPlan, type SyncPlanEntry } from '../sync/types'; import { type SyncExecutionResult, type SyncResultNotificationPort } from './SyncResultNotifier'; -import { defaultSyncAction } from './ChangeActionPolicy'; import type { ChangeRepository } from './ChangeRepository'; import type { OperationState } from './OperationState'; import type { SourceControlItem } from './SourceControlViewModel'; +import type { SyncSelectionStore } from './SyncSelectionStore'; +import { defaultSyncAction, type SyncAction } from './ChangeActionPolicy'; +import { SyncIntentExecutor } from './SyncIntentExecutor'; +import type { SyncIntentRequest } from './SyncIntent'; import type { ChangeId, SyncChange } from './types'; +export type { SyncIntentRequest } from './SyncIntent'; + /** Which side wins when resolving a change in the 'conflict' state. */ export type ConflictResolution = 'local' | 'remote'; @@ -18,29 +21,81 @@ export interface SourceControlDiffContent { } /** - * Converts Source Control user intent (push / pull / delete-remote / - * delete-local / resolve-conflict on one or more `ChangeId`s) into calls - * against `SyncWorkspace` — the existing `SyncManager`-backed execution - * boundary already used by the sync-status UI — per - * docs/source-control-refactor/phase-2-action-unification.md. + * Application facade for immediate Source Control actions. + * + * Immediate row actions (push / pull / delete / conflict / diff) stay here. + * The Sync Queue's multi-step intent workflow is delegated to + * {@link SyncIntentExecutor}, so this facade no longer owns plan merging, + * confirmation, remote-bucket execution, pull-bucket execution, and result + * aggregation at the same time. + * + * Neither layer talks to a Git provider directly; SyncWorkspace remains the + * execution boundary. * - * Per that doc's rules, this service DOES convert user intent into the call - * `SyncWorkspace`/`SyncManager` need (effectively "build the SyncPlan"), but - * it never talks to a Git provider directly and never (re-)classifies - * changes — it only resolves `ChangeId` -> `SyncChange` via the Phase 1 - * `ChangeRepository` and reports per-change outcome through the Phase 1 - * `OperationState`. Unknown/stale `ChangeId`s (e.g. a change that dropped out - * between the UI snapshot and the click) are silently skipped rather than - * throwing, since the repository is the single source of truth for what's - * still actionable. + * Also owns the Sync Queue selection/action-override mutation boundary + * (select/deselect, set/clear a row's action override) on behalf of + * SyncSelectionStore, so the UI never reaches past this facade into that + * store directly. */ export class SourceControlActionService { + private readonly syncIntentExecutor: SyncIntentExecutor; + constructor( private readonly changes: ChangeRepository, + private readonly selection: SyncSelectionStore, private readonly operations: OperationState, private readonly workspace: SyncWorkspace, private readonly syncResultNotifier: SyncResultNotificationPort = { notify: () => {} }, - ) {} + ) { + this.syncIntentExecutor = new SyncIntentExecutor( + changes, + operations, + workspace, + syncResultNotifier, + ); + } + + /** Adds one change to the Sync Queue. */ + selectForSync(changeId: ChangeId): void { + this.selection.selectForSync(changeId); + } + + /** Removes one change from the Sync Queue, clearing any action override with it. */ + deselectFromSync(changeId: ChangeId): void { + this.selection.deselectFromSync(changeId); + } + + /** Adds several changes to the Sync Queue in one batch (e.g. a folder checkbox). */ + selectMany(changeIds: readonly ChangeId[]): void { + this.selection.selectMany(changeIds); + } + + /** Removes several changes from the Sync Queue in one batch. */ + deselectMany(changeIds: readonly ChangeId[]): void { + this.selection.deselectMany(changeIds); + } + + /** + * Sets a Sync Queue row's explicit action override. Picking the kind's + * own default clears the override instead of storing a redundant one, so + * `SourceControlItem.hasActionOverride` only means "the user chose + * something other than the default". + */ + setSyncAction(changeId: ChangeId, action: SyncAction): void { + const change = this.changes.getById(changeId); + if (!change) return; + + if (action === defaultSyncAction(change.kind)) { + this.selection.clearActionOverride(changeId); + } else { + this.selection.setActionOverride(changeId, action); + } + } + + /** Clears a Sync Queue row's explicit action override, reverting it to the kind default. */ + clearSyncAction(changeId: ChangeId): void { + this.selection.clearActionOverride(changeId); + } /** Pushes one or more changes (single push and batch push share this path). */ async push(changeIds: readonly ChangeId[]): Promise { @@ -51,7 +106,7 @@ export class SourceControlActionService { try { const results = await this.workspace.push(targets.map(target => target.path)); const failed = new Set(results.errors.map(error => error.file)); - this.finishAll(targets, path => (failed.has(path) ? 'failed' : 'success')); + this.finishAll(targets, path => failed.has(path) ? 'failed' : 'success'); } catch { this.failAll(targets); } @@ -66,7 +121,7 @@ export class SourceControlActionService { try { const results = await this.workspace.pull(targets.map(target => target.path)); const failed = new Set(results.errors.map(error => error.file)); - this.finishAll(targets, path => (failed.has(path) ? 'failed' : 'success')); + this.finishAll(targets, path => failed.has(path) ? 'failed' : 'success'); } catch { this.failAll(targets); } @@ -81,188 +136,22 @@ export class SourceControlActionService { try { const result = await this.workspace.deleteRemote(targets.map(target => target.path)); const failed = new Set(result.errors.map(error => error.path)); - this.finishAll(targets, path => (failed.has(path) ? 'failed' : 'success')); + this.finishAll(targets, path => failed.has(path) ? 'failed' : 'success'); } catch { this.failAll(targets); } } /** - * Syncs one or more changes as a single Sync Plan — the Sync Queue - * button's only entry point. Splits `changeIds` by - * {@link defaultSyncAction} into push/delete-remote/pull buckets, plans - * each without mutating anything, merges the result into one `SyncPlan`, - * shows exactly one confirm, and — if confirmed — commits the whole - * remote mutation set (pushes + moves + deletions) through - * `SyncWorkspace.commitResolvedBatch` as one provider call, then applies - * the pull bucket (zero-commit, local-only) separately. This is the fix - * for the "one Sync produces two remote commits" bug: previously the - * Sync Queue routed push/pull/delete-remote through three independent - * `SyncWorkspace` calls, each committing on its own. + * Executes the whole Sync Queue as one explicit-intent workflow. + * Kept as the stable UI-facing facade; orchestration lives in + * SyncIntentExecutor. */ - async sync(changeIds: readonly ChangeId[]): Promise { - const targets = this.resolve(changeIds); - if (targets.length === 0) return; - - const pushTargets: SyncChange[] = []; - const deleteTargets: SyncChange[] = []; - const pullTargets: SyncChange[] = []; - for (const target of targets) { - const action = defaultSyncAction(target.kind); - if (action === 'pull') pullTargets.push(target); - else if (action === 'delete-remote') deleteTargets.push(target); - else pushTargets.push(target); - } - - let plan: { planned: PlannedPushBatch; confirmed: boolean } | null; - try { - plan = await this.planSync(pushTargets, pullTargets, deleteTargets); - } catch { - this.failAll(targets); - this.syncResultNotifier.notify({ ...SourceControlActionService.emptyExecutionResult(), failed: targets.length }); - return; - } - if (!plan || !plan.confirmed) return; - const { planned } = plan; - - this.startAll(targets); - const summary = SourceControlActionService.emptyExecutionResult(); - if (planned.pushes.length > 0 || planned.moves.length > 0 || planned.keepRemote.length > 0 || planned.keepLocal.length > 0 || deleteTargets.length > 0) { - await this.commitRemoteBucket(planned, pushTargets, deleteTargets, summary); - } - if (pullTargets.length > 0) { - await this.applyPullBucket(pullTargets, summary); - } - this.syncResultNotifier.notify(summary); - } - - /** Builds and confirms the merged Sync Plan; returns null if there's nothing to do or the user cancelled. */ - private async planSync( - pushTargets: readonly SyncChange[], - pullTargets: readonly SyncChange[], - deleteTargets: readonly SyncChange[], - ): Promise<{ planned: PlannedPushBatch; confirmed: boolean } | null> { - const planned = pushTargets.length > 0 - ? await this.workspace.planPush(pushTargets.map(target => target.path)) - : SourceControlActionService.emptyPlannedBatch(); - // A cancelled batch-conflict resolution is a separate interactive - // step that happens before the merged plan is even shown; honor it - // the same way pushFiles() does, without touching anything. - if (planned.cancelled) return null; - - const pullPlan = pullTargets.length > 0 - ? await this.workspace.planPull(pullTargets.map(target => target.path)) - : SourceControlActionService.emptyPlan(); - - const deletions: SyncPlanEntry[] = deleteTargets.map(target => ({ path: target.path, name: basename(target.path) })); - const mergedPlan: SyncPlan = { - additions: planned.reviewPlan.additions, - modifications: planned.reviewPlan.modifications, - moves: planned.reviewPlan.moves, - deletions, - downloads: [...pullPlan.additions, ...pullPlan.modifications], - acceptedRemote: planned.reviewPlan.acceptedRemote, - skippedConflicts: planned.reviewPlan.skippedConflicts, - }; - if (isSyncPlanEmpty(mergedPlan)) return null; - - const confirmed = await this.workspace.confirmPlan(mergedPlan, 'sync'); - return { planned, confirmed }; - } - - /** Commits the merged push/move/delete-remote bucket; a failure here only fails that bucket, not any already-applied pull. */ - private async commitRemoteBucket( - planned: PlannedPushBatch, - pushTargets: readonly SyncChange[], - deleteTargets: readonly SyncChange[], - summary: SyncExecutionResult, - ): Promise { - try { - const deleteEntries: DeleteQueueEntry[] = deleteTargets.map(target => ({ - path: target.path, - name: basename(target.path), - repoPath: this.workspace.toRepoPath(target.path), - })); - const results: PushResults = { - success: planned.immediate.success, - added: 0, - updated: planned.immediate.updated, - failed: planned.immediate.failed, - conflicts: 0, - resolvedConflicts: 0, - skippedConflicts: 0, - errors: [...planned.immediate.errors], - syncedPaths: [...planned.immediate.syncedPaths], - }; - await this.workspace.commitResolvedBatch(planned.pushes, planned.moves, deleteEntries, planned.keepRemote, planned.keepLocal, results); - const failed = new Set(results.errors.map(error => error.file)); - this.finishAll([...pushTargets, ...deleteTargets], path => (failed.has(path) ? 'failed' : 'success')); - this.addRemoteResult(summary, planned, deleteEntries, results); - } catch { - this.failAll([...pushTargets, ...deleteTargets]); - summary.failed += pushTargets.length + deleteTargets.length; - } - } - - /** Applies the zero-commit pull bucket; a failure here only fails the pull targets, not any already-committed remote bucket. */ - private async applyPullBucket(pullTargets: readonly SyncChange[], summary: SyncExecutionResult): Promise { - try { - const pullResults = await this.workspace.applyPull(pullTargets.map(target => target.path), { notify: false }); - const failed = new Set(pullResults.errors.map(error => error.file)); - this.finishAll(pullTargets, path => (failed.has(path) ? 'failed' : 'success')); - summary.downloaded += pullResults.added + pullResults.updated; - summary.failed += pullResults.failed; - summary.conflicts += pullResults.conflicts; - summary.errors.push(...pullResults.errors); - } catch { - this.failAll(pullTargets); - summary.failed += pullTargets.length; - } - } - - private static emptyPlannedBatch(): PlannedPushBatch { - return { - reviewPlan: { additions: [], modifications: [], deletions: [], moves: [] }, - pushes: [], - moves: [], - keepRemote: [], - keepLocal: [], - skippedConflicts: 0, - conflictedPaths: [], - cancelled: false, - immediate: { success: 0, updated: 0, failed: 0, errors: [], syncedPaths: [] }, - }; - } - - private static emptyPlan(): SyncPlan { - return { additions: [], modifications: [], deletions: [], moves: [] }; + async sync(intents: readonly SyncIntentRequest[]): Promise { + await this.syncIntentExecutor.execute(intents); } - private static emptyExecutionResult(): SyncExecutionResult { - return { added: 0, updated: 0, moved: 0, deleted: 0, downloaded: 0, acceptedRemote: 0, failed: 0, conflicts: 0, skippedConflicts: 0, errors: [] }; - } - - private addRemoteResult( - summary: SyncExecutionResult, - planned: PlannedPushBatch, - deletions: readonly DeleteQueueEntry[], - results: PushResults, - ): void { - const failedPaths = new Set(results.errors.map(error => error.file)); - summary.added += planned.pushes.filter(entry => !entry.existingSha && !failedPaths.has(entry.path)).length; - summary.updated += planned.pushes.filter(entry => entry.existingSha && !failedPaths.has(entry.path)).length + planned.immediate.updated; - summary.moved += planned.moves.filter(entry => !failedPaths.has(entry.path)).length; - summary.deleted += deletions.filter(entry => !failedPaths.has(entry.path)).length; - summary.acceptedRemote += planned.keepRemote - .filter(conflict => !failedPaths.has(conflict.path)) - .length; - summary.failed += results.failed; - summary.conflicts += results.conflicts; - summary.skippedConflicts += results.skippedConflicts; - summary.errors.push(...results.errors); - } - - /** Deletes one or more changes from the local vault only. No batch primitive exists on `SyncWorkspace`, so each runs independently and one failure doesn't block the rest. */ + /** Deletes one or more changes from the local vault only. */ async deleteLocal(changeIds: readonly ChangeId[]): Promise { const targets = this.resolve(changeIds); for (const target of targets) { @@ -277,11 +166,9 @@ export class SourceControlActionService { } /** - * Resolves a single change in the 'conflict' state by pushing the local - * copy (local wins) or pulling the reviewed remote copy (remote wins). - * Remote resolution goes through the explicit acceptRemoteConflict - * boundary, which applies the reviewed remote blob without re-running the - * planner — so no second conflict modal can appear. + * Resolves a single conflict by keeping the local or reviewed remote + * version. Remote resolution uses the explicit acceptRemoteConflict + * boundary so it does not re-enter planning and show a second modal. */ async resolveConflict(changeId: ChangeId, resolution: ConflictResolution): Promise { const change = this.changes.getById(changeId); @@ -291,33 +178,30 @@ export class SourceControlActionService { try { if (resolution === 'local') { const results = await this.workspace.push([change.path]); - if (results.errors.length > 0) throw new Error(results.errors.map(error => error.error).join('; ')); + if (results.errors.length > 0) { + throw new Error(results.errors.map(error => error.error).join('; ')); + } this.operations.succeed(changeId); - this.syncResultNotifier.notify({ ...SourceControlActionService.emptyExecutionResult(), updated: 1 }); + this.syncResultNotifier.notify({ ...emptyExecutionResult(), updated: 1 }); } else { await this.workspace.acceptRemoteConflict(change.path); this.operations.succeed(changeId); - this.syncResultNotifier.notify({ ...SourceControlActionService.emptyExecutionResult(), acceptedRemote: 1 }); + this.syncResultNotifier.notify({ ...emptyExecutionResult(), acceptedRemote: 1 }); } } catch { this.operations.fail(changeId); - this.syncResultNotifier.notify({ ...SourceControlActionService.emptyExecutionResult(), failed: 1 }); + this.syncResultNotifier.notify({ ...emptyExecutionResult(), failed: 1 }); } } - /** - * Supplies `SourceControlView`'s `loadDiffContent` callback: delegates to - * the existing `SyncWorkspace.getDiff`/`SyncDiffService` (no new diff - * logic) and resolves to `null` for binary/symlink changes, which the - * text-only diff pane can't render. - */ + /** Loads text diff content for the Source Control diff surface. */ async loadDiffContent(item: SourceControlItem): Promise { const diff = await this.workspace.getDiff(item.path); if (typeof diff.remoteContent !== 'string' || typeof diff.localContent !== 'string') return null; return { remote: diff.remoteContent, local: diff.localContent }; } - /** Resolves ChangeIds to their current SyncChange, dropping any that are no longer known to the repository. */ + /** Resolves ChangeIds against the repository's current snapshot, dropping stale ids. */ private resolve(changeIds: readonly ChangeId[]): SyncChange[] { const targets: SyncChange[] = []; for (const id of changeIds) { @@ -331,7 +215,10 @@ export class SourceControlActionService { for (const target of targets) this.operations.start(target.id); } - private finishAll(targets: readonly SyncChange[], statusFor: (path: string) => 'success' | 'failed'): void { + private finishAll( + targets: readonly SyncChange[], + statusFor: (path: string) => 'success' | 'failed', + ): void { for (const target of targets) { if (statusFor(target.path) === 'success') this.operations.succeed(target.id); else this.operations.fail(target.id); @@ -343,8 +230,17 @@ export class SourceControlActionService { } } -/** Last path segment of a change path, for the Sync Plan's deletions section. */ -function basename(path: string): string { - const slash = path.lastIndexOf('/'); - return slash === -1 ? path : path.slice(slash + 1); +function emptyExecutionResult(): SyncExecutionResult { + return { + added: 0, + updated: 0, + moved: 0, + deleted: 0, + downloaded: 0, + acceptedRemote: 0, + failed: 0, + conflicts: 0, + skippedConflicts: 0, + errors: [], + }; } diff --git a/src/logic/source-control/SourceControlViewModel.ts b/src/logic/source-control/SourceControlViewModel.ts index d457c9d..62884c4 100644 --- a/src/logic/source-control/SourceControlViewModel.ts +++ b/src/logic/source-control/SourceControlViewModel.ts @@ -1,4 +1,5 @@ import type { ChangeRepository } from './ChangeRepository'; +import { resolveSyncAction, type SyncAction } from './ChangeActionPolicy'; import { buildSummary, type SourceControlCounts } from './SourceControlSummary'; import type { OperationState, OperationStatus } from './OperationState'; import type { RefreshReason } from './RefreshReason'; @@ -7,7 +8,7 @@ import type { SyncSelectionStore } from './SyncSelectionStore'; import { matchesFilter, type SourceControlFilter } from './SourceControlFilter'; import type { ChangeId, SyncChange, SyncChangeKind } from './types'; -/** One row of UI-ready state for a change: its own facts plus derived selection/operation status. */ +/** One UI-ready row: repository facts plus derived selection/operation state. */ export interface SourceControlItem { id: ChangeId; path: string; @@ -15,47 +16,30 @@ export interface SourceControlItem { kind: SyncChangeKind; isSelectedForSync: boolean; operationStatus: OperationStatus; + /** Current effective queue action: a still-legal override or the kind default. */ + syncAction: SyncAction; + /** True only when the effective action came from a still-legal user override. */ + hasActionOverride: boolean; } -/** The complete state the Source Control UI needs to render for a given filter. */ +/** The complete state the Source Control UI needs to render for a filter. */ export interface SourceControlViewState { filter: SourceControlFilter; items: SourceControlItem[]; - /** - * The actionable changes the user has currently selected for push, as - * full row items — the working sync queue. Empty when nothing is - * selected. Reuses the same `selected + non-synced` definition as - * `buildSummary.readyToPush` so the "SYNC QUEUE (N)" section and the Sync - * button count can't drift. - */ syncQueue: SourceControlItem[]; - /** Current view-wide refresh status, surfaced so the header can render its states. */ refreshStatus: RefreshStatus; - /** Single-source counts from {@link buildSummary} — the view never recomputes these. */ counts: SourceControlCounts; } /** - * Combines `SyncChange[]` (via `ChangeRepository`), `SyncSelectionStore`, and - * `OperationState` into a single UI-ready snapshot. Holds no sync behavior of - * its own — it's a pure projection, so `SyncManager`/`SyncPlanner`/`SyncExecutor` - * stay untouched and the UI never needs to reach past this layer. - * - * Every count the UI shows comes from one place: {@link buildSummary}. The - * ViewModel only projects items for the active filter and forwards the - * summary's counts unchanged, so the filter menu, section headers, and tree - * can never drift apart. - * - * `showSynced` governs whether the synced bucket is surfaced: when false the - * synced count is reported as `0` and the `synced` filter yields no items, - * matching the "Show synced" toggle (default off). + * Read-only projection of repository, selection, operation, and refresh state + * into UI-ready snapshots. * - * The one non-projection responsibility is {@link refresh}: it delegates to an - * injected refresh callback (wired to `SyncWorkspace.refresh()` in `main.ts`) - * and drives the injected {@link RefreshState} holder so the UI can surface - * loading/failed states. It holds no provider or refresh logic of its own, - * keeping the event-driven pipeline (`sync.status` → `ChangeRepository` → - * ViewModel → UI) intact — refresh never becomes a second population path. + * Purely observational: getState() never mutates queue intent, and this + * class exposes no selection mutation surface of its own. Selection-intent + * reconciliation against authoritative ChangeRepository replacements is + * wired by the runtime composition root (createSyncRuntime), not here, and + * mutation goes through SourceControlActionService instead of this class. */ export class SourceControlViewModel { constructor( @@ -66,15 +50,6 @@ export class SourceControlViewModel { private readonly refreshState: RefreshState, ) {} - /** - * The sync-selection store, exposed so the view can toggle/clear - * selection without holding its own reference and reaching past the - * ViewModel. Reached via `viewModel.selection` - * (`selectForSync`/`deselectFromSync`/`selectMany`/`deselectMany`/ - * `getSelectedChangeIds`). - */ - get selection(): SyncSelectionStore { return this.selectionStore; } - getState(filter: SourceControlFilter = 'all', showSynced = false): SourceControlViewState { const all = this.changes.getAll(); const summary = buildSummary(all, this.selectionStore, showSynced); @@ -83,22 +58,33 @@ export class SourceControlViewModel { .filter(() => this.isRenderable(filter, showSynced)) .map(change => this.toItem(change)); const syncQueue = summary.readyToPush.map(change => this.toItem(change)); - return { filter, items, syncQueue, refreshStatus: this.refreshState.get(), counts: summary.counts }; + + return { + filter, + items, + syncQueue, + refreshStatus: this.refreshState.get(), + counts: summary.counts, + }; + } + + /** + * Projects a single change by id, independent of any filter -- the sole + * projection path for callers (e.g. a diff pane host) that need one + * row's current selection/operation/syncAction state without hand-rolling + * a SourceControlItem themselves. Returns undefined once the change is no + * longer in the repository (e.g. it synced and dropped out, or was + * deleted). + */ + getItem(id: ChangeId): SourceControlItem | undefined { + const change = this.changes.getById(id); + return change ? this.toItem(change) : undefined; } /** - * Triggers a view-wide refresh by delegating to the injected refresh - * source (the Sync Status service boundary) and tracking its lifecycle on - * the {@link RefreshState} holder so the header can render "Refreshing…" - * / a failed state. Refresh republishes `sync.status`, so the existing - * subscription repopulates `ChangeRepository` — this never becomes a - * second population path. - * - * The {@link RefreshReason} is recorded on the {@link RefreshState} - * holder purely for observability ("Last checked" + why); it does not - * change what the refresh does. Defaults to `'manual'` (the Refresh - * button); callers pass `'startup'`/`'local-change'`/`'sync-complete'` to - * surface a non-manual trigger. + * Triggers a view-wide refresh through the injected source and records + * only its presentation lifecycle. Repository population still happens + * exclusively through the existing sync.status publish subscription. */ async refresh(reason: RefreshReason = 'manual'): Promise { this.refreshState.start(reason); @@ -112,13 +98,14 @@ export class SourceControlViewModel { } private isRenderable(filter: SourceControlFilter, showSynced: boolean): boolean { - // Synced rows only render under the `synced` filter, and only when the - // user has opted in via "Show synced". `all`/`changes`/etc. already - // exclude synced via matchesFilter, so this only gates the synced view. return !(filter === 'synced' && !showSynced); } private toItem(change: SyncChange): SourceControlItem { + const storedOverride = this.selectionStore.getActionOverride(change.id); + const syncAction = resolveSyncAction(change.kind, storedOverride); + const hasActionOverride = storedOverride !== undefined && storedOverride === syncAction; + return { id: change.id, path: change.path, @@ -126,6 +113,8 @@ export class SourceControlViewModel { kind: change.kind, isSelectedForSync: this.selectionStore.isIncluded(change.id), operationStatus: this.operations.get(change.id), + syncAction, + hasActionOverride, }; } -} \ No newline at end of file +} diff --git a/src/logic/source-control/SyncIntent.ts b/src/logic/source-control/SyncIntent.ts new file mode 100644 index 0000000..c83409d --- /dev/null +++ b/src/logic/source-control/SyncIntent.ts @@ -0,0 +1,13 @@ +import type { SyncAction } from './ChangeActionPolicy'; +import type { ChangeId } from './types'; + +/** + * One queued Source Control intent. `action` is present only when the user + * explicitly chose a legal non-default action for the change at the time the + * queue snapshot was built. Execution always re-validates it against the + * repository's current change kind before doing any work. + */ +export interface SyncIntentRequest { + changeId: ChangeId; + action?: SyncAction; +} diff --git a/src/logic/source-control/SyncIntentExecutor.ts b/src/logic/source-control/SyncIntentExecutor.ts new file mode 100644 index 0000000..9db08ec --- /dev/null +++ b/src/logic/source-control/SyncIntentExecutor.ts @@ -0,0 +1,290 @@ +import type { PlannedPushBatch } from '../sync/PushCoordinator'; +import type { SyncWorkspace } from '../sync/SyncWorkspace'; +import { + isSyncPlanEmpty, + type DeleteQueueEntry, + type PushResults, + type SyncPlan, + type SyncPlanEntry, +} from '../sync/types'; +import { resolveSyncAction, type SyncAction } from './ChangeActionPolicy'; +import type { ChangeRepository } from './ChangeRepository'; +import type { OperationState } from './OperationState'; +import type { SyncExecutionResult, SyncResultNotificationPort } from './SyncResultNotifier'; +import type { SyncIntentRequest } from './SyncIntent'; +import type { SyncChange } from './types'; + +interface ResolvedSyncIntent { + change: SyncChange; + action: SyncAction; +} + +interface SyncIntentBuckets { + push: SyncChange[]; + pull: SyncChange[]; + deleteRemote: SyncChange[]; +} + +interface ConfirmedSyncPlan { + plannedPush: PlannedPushBatch; + confirmed: boolean; +} + +/** + * Executes the Sync Queue use-case from explicit user intent. + * + * This class owns only the queued/batched workflow: resolve each ChangeId + * against the current repository snapshot, re-validate the requested action, + * build one merged review plan, confirm once, commit the remote mutation + * bucket once, then apply the local-only pull bucket. Immediate row actions + * remain on SourceControlActionService. + * + * Keeping this orchestration behind a dedicated boundary prevents + * SourceControlActionService from becoming the place where every Source + * Control concern accumulates, while preserving the existing SyncWorkspace + * execution boundary and provider behavior. + */ +export class SyncIntentExecutor { + constructor( + private readonly changes: ChangeRepository, + private readonly operations: OperationState, + private readonly workspace: SyncWorkspace, + private readonly notifier: SyncResultNotificationPort = { notify: () => {} }, + ) {} + + async execute(intents: readonly SyncIntentRequest[]): Promise { + const resolved = this.resolveIntents(intents); + if (resolved.length === 0) return; + + const targets = resolved.map(entry => entry.change); + const buckets = this.bucket(resolved); + + let plan: ConfirmedSyncPlan | null; + try { + plan = await this.planAndConfirm(buckets); + } catch { + this.failAll(targets); + this.notifier.notify({ ...emptyExecutionResult(), failed: targets.length }); + return; + } + + if (!plan || !plan.confirmed) return; + + this.startAll(targets); + const summary = emptyExecutionResult(); + + if (hasRemoteMutations(plan.plannedPush, buckets.deleteRemote)) { + await this.commitRemoteBucket(plan.plannedPush, buckets.push, buckets.deleteRemote, summary); + } + if (buckets.pull.length > 0) { + await this.applyPullBucket(buckets.pull, summary); + } + + this.notifier.notify(summary); + } + + private resolveIntents(intents: readonly SyncIntentRequest[]): ResolvedSyncIntent[] { + const resolved: ResolvedSyncIntent[] = []; + for (const intent of intents) { + const change = this.changes.getById(intent.changeId); + if (!change) continue; + resolved.push({ + change, + action: resolveSyncAction(change.kind, intent.action), + }); + } + return resolved; + } + + private bucket(intents: readonly ResolvedSyncIntent[]): SyncIntentBuckets { + const buckets: SyncIntentBuckets = { push: [], pull: [], deleteRemote: [] }; + for (const { change, action } of intents) { + if (action === 'pull') buckets.pull.push(change); + else if (action === 'delete-remote') buckets.deleteRemote.push(change); + else buckets.push.push(change); + } + return buckets; + } + + private async planAndConfirm(buckets: SyncIntentBuckets): Promise { + const plannedPush = buckets.push.length > 0 + ? await this.workspace.planPush(buckets.push.map(change => change.path)) + : emptyPlannedBatch(); + + // Batch conflict resolution is an interactive planning step. If the + // user cancels it, no merged review modal or mutation should follow. + if (plannedPush.cancelled) return null; + + const pullPlan = buckets.pull.length > 0 + ? await this.workspace.planPull(buckets.pull.map(change => change.path)) + : emptyPlan(); + + const deletions: SyncPlanEntry[] = buckets.deleteRemote.map(change => ({ + path: change.path, + name: basename(change.path), + })); + const mergedPlan: SyncPlan = { + additions: plannedPush.reviewPlan.additions, + modifications: plannedPush.reviewPlan.modifications, + moves: plannedPush.reviewPlan.moves, + deletions, + downloads: [...pullPlan.additions, ...pullPlan.modifications], + acceptedRemote: plannedPush.reviewPlan.acceptedRemote, + skippedConflicts: plannedPush.reviewPlan.skippedConflicts, + }; + + if (isSyncPlanEmpty(mergedPlan)) return null; + + return { + plannedPush, + confirmed: await this.workspace.confirmPlan(mergedPlan, 'sync'), + }; + } + + private async commitRemoteBucket( + plannedPush: PlannedPushBatch, + pushTargets: readonly SyncChange[], + deleteTargets: readonly SyncChange[], + summary: SyncExecutionResult, + ): Promise { + const targets = [...pushTargets, ...deleteTargets]; + try { + const deleteEntries: DeleteQueueEntry[] = deleteTargets.map(change => ({ + path: change.path, + name: basename(change.path), + repoPath: this.workspace.toRepoPath(change.path), + })); + const results: PushResults = { + success: plannedPush.immediate.success, + added: 0, + updated: plannedPush.immediate.updated, + failed: plannedPush.immediate.failed, + conflicts: 0, + resolvedConflicts: 0, + skippedConflicts: 0, + errors: [...plannedPush.immediate.errors], + syncedPaths: [...plannedPush.immediate.syncedPaths], + }; + + await this.workspace.commitResolvedBatch( + plannedPush.pushes, + plannedPush.moves, + deleteEntries, + plannedPush.keepRemote, + plannedPush.keepLocal, + results, + ); + + const failed = new Set(results.errors.map(error => error.file)); + this.finishAll(targets, path => failed.has(path) ? 'failed' : 'success'); + addRemoteResult(summary, plannedPush, deleteEntries, results); + } catch { + this.failAll(targets); + summary.failed += targets.length; + } + } + + private async applyPullBucket( + pullTargets: readonly SyncChange[], + summary: SyncExecutionResult, + ): Promise { + try { + const results = await this.workspace.applyPull( + pullTargets.map(change => change.path), + { notify: false }, + ); + const failed = new Set(results.errors.map(error => error.file)); + this.finishAll(pullTargets, path => failed.has(path) ? 'failed' : 'success'); + summary.downloaded += results.added + results.updated; + summary.failed += results.failed; + summary.conflicts += results.conflicts; + summary.errors.push(...results.errors); + } catch { + this.failAll(pullTargets); + summary.failed += pullTargets.length; + } + } + + private startAll(targets: readonly SyncChange[]): void { + for (const target of targets) this.operations.start(target.id); + } + + private finishAll( + targets: readonly SyncChange[], + statusFor: (path: string) => 'success' | 'failed', + ): void { + for (const target of targets) { + if (statusFor(target.path) === 'success') this.operations.succeed(target.id); + else this.operations.fail(target.id); + } + } + + private failAll(targets: readonly SyncChange[]): void { + for (const target of targets) this.operations.fail(target.id); + } +} + +function hasRemoteMutations(planned: PlannedPushBatch, deletions: readonly SyncChange[]): boolean { + return planned.pushes.length > 0 + || planned.moves.length > 0 + || planned.keepRemote.length > 0 + || planned.keepLocal.length > 0 + || deletions.length > 0; +} + +function emptyPlannedBatch(): PlannedPushBatch { + return { + reviewPlan: { additions: [], modifications: [], deletions: [], moves: [] }, + pushes: [], + moves: [], + keepRemote: [], + keepLocal: [], + skippedConflicts: 0, + conflictedPaths: [], + cancelled: false, + immediate: { success: 0, updated: 0, failed: 0, errors: [], syncedPaths: [] }, + }; +} + +function emptyPlan(): SyncPlan { + return { additions: [], modifications: [], deletions: [], moves: [] }; +} + +function emptyExecutionResult(): SyncExecutionResult { + return { + added: 0, + updated: 0, + moved: 0, + deleted: 0, + downloaded: 0, + acceptedRemote: 0, + failed: 0, + conflicts: 0, + skippedConflicts: 0, + errors: [], + }; +} + +function addRemoteResult( + summary: SyncExecutionResult, + planned: PlannedPushBatch, + deletions: readonly DeleteQueueEntry[], + results: PushResults, +): void { + const failedPaths = new Set(results.errors.map(error => error.file)); + summary.added += planned.pushes.filter(entry => !entry.existingSha && !failedPaths.has(entry.path)).length; + summary.updated += planned.pushes.filter(entry => entry.existingSha && !failedPaths.has(entry.path)).length + + planned.immediate.updated; + summary.moved += planned.moves.filter(entry => !failedPaths.has(entry.path)).length; + summary.deleted += deletions.filter(entry => !failedPaths.has(entry.path)).length; + summary.acceptedRemote += planned.keepRemote.filter(conflict => !failedPaths.has(conflict.path)).length; + summary.failed += results.failed; + summary.conflicts += results.conflicts; + summary.skippedConflicts += results.skippedConflicts; + summary.errors.push(...results.errors); +} + +function basename(path: string): string { + const slash = path.lastIndexOf('/'); + return slash === -1 ? path : path.slice(slash + 1); +} diff --git a/src/logic/source-control/SyncSelectionStore.ts b/src/logic/source-control/SyncSelectionStore.ts index b1a7060..c380a9b 100644 --- a/src/logic/source-control/SyncSelectionStore.ts +++ b/src/logic/source-control/SyncSelectionStore.ts @@ -1,19 +1,18 @@ -import type { ChangeId } from './types'; +import { resolveSyncAction, type SyncAction } from './ChangeActionPolicy'; +import type { ChangeId, SyncChange } from './types'; /** - * Tracks which pending sync changes are selected for the Sync Queue — - * independent of the underlying change/plan model and of any UI. Named for - * "selected for sync" rather than "push" since the Sync Queue it backs holds - * push, pull, and delete-remote candidates alike (a queued `remote-only` row - * pulls, a queued `local-deleted` row deletes remotely by default). Also - * deliberately avoids VCS stage/unstage terminology since this isn't a - * staging area. + * Tracks which pending changes are selected for the Sync Queue and the + * user's optional per-change action override. * - * Keyed by ChangeId rather than path so a rename/move doesn't drop the - * selection. + * Keyed by ChangeId rather than path so a rename/move does not drop intent. + * The store owns lifecycle cleanup for that intent: when a refreshed change + * disappears, or an override is no longer legal for its current kind, the + * stale state is discarded here instead of during ViewModel projection. */ export class SyncSelectionStore { private readonly selected = new Set(); + private readonly actionOverrides = new Map(); selectForSync(changeId: ChangeId): void { this.selected.add(changeId); @@ -21,16 +20,18 @@ export class SyncSelectionStore { deselectFromSync(changeId: ChangeId): void { this.selected.delete(changeId); + this.actionOverrides.delete(changeId); } - /** Selects a batch of changes for sync in one call (folder "select all"). */ selectMany(changeIds: readonly ChangeId[]): void { for (const id of changeIds) this.selected.add(id); } - /** Deselects a batch of changes from sync in one call ("clear queue" / folder deselect). */ deselectMany(changeIds: readonly ChangeId[]): void { - for (const id of changeIds) this.selected.delete(id); + for (const id of changeIds) { + this.selected.delete(id); + this.actionOverrides.delete(id); + } } isIncluded(changeId: ChangeId): boolean { @@ -41,13 +42,50 @@ export class SyncSelectionStore { return [...this.selected]; } - /** Drops selections for change ids that are no longer present, keeping the rest. */ + setActionOverride(changeId: ChangeId, action: SyncAction): void { + this.actionOverrides.set(changeId, action); + } + + clearActionOverride(changeId: ChangeId): void { + this.actionOverrides.delete(changeId); + } + + getActionOverride(changeId: ChangeId): SyncAction | undefined { + return this.actionOverrides.get(changeId); + } + + /** + * Reconciles queued intent with a freshly published repository snapshot. + * Missing ids are removed and action overrides are revalidated against + * each change's current kind. This is the write-side lifecycle boundary; + * read-only ViewModel projection must not clean state as a side effect. + */ + reconcile(changes: readonly SyncChange[]): void { + const currentById = new Map(changes.map(change => [change.id, change] as const)); + this.refresh([...currentById.keys()]); + + for (const [changeId, override] of this.actionOverrides) { + const change = currentById.get(changeId); + if (change && resolveSyncAction(change.kind, override) !== override) { + this.actionOverrides.delete(changeId); + } + } + } + + /** Drops selections for ids that are no longer present, keeping the rest. */ refresh(currentChangeIds: readonly ChangeId[]): void { const present = new Set(currentChangeIds); for (const changeId of this.selected) { if (!present.has(changeId)) { this.selected.delete(changeId); + this.actionOverrides.delete(changeId); } } + + // Defensive cleanup for callers/tests that recorded an override + // without first selecting the row. + for (const changeId of this.actionOverrides.keys()) { + if (!present.has(changeId)) this.actionOverrides.delete(changeId); + } } } diff --git a/src/logic/sync/DiffStat.ts b/src/logic/sync/DiffStat.ts new file mode 100644 index 0000000..061907a --- /dev/null +++ b/src/logic/sync/DiffStat.ts @@ -0,0 +1,71 @@ +import { computeSideBySideDiff } from '../../utils/diff'; + +/** Additions/deletions for a single change's diff, the +/- stat a row shows. */ +export interface ChangeStat { + additions: number; + deletions: number; +} + +/** + * What a diff-stat load resolved to for one row. The distinction matters + * because a cache treats the three outcomes differently: + * - `ready` — cached as a usable stat. + * - `unavailable` — permanent (binary, symlink, no two sides to diff); + * cached so the row is never retried. + * - `pending` — the backing content simply isn't in memory yet (e.g. a + * `local-only` row whose `localContent` hasn't been read). NOT cached: + * the next load pass retries the row, so a late-arriving stat still lands. + */ +export type DiffStatLoadResult = + | { status: 'ready'; stat: ChangeStat } + | { status: 'pending' } + | { status: 'unavailable' }; + +/** + * +/- stat for a two-sided diff (local-modified / remote-only / + * remote-modified / moved / conflict), reusing the existing LCS op logic in + * `utils/diff.ts`. Additions = added ops, deletions = removed ops. + */ +export function computeDiffStat(remote: string, local: string): ChangeStat { + const rows = computeSideBySideDiff(remote, local); + let additions = 0; + let deletions = 0; + for (const row of rows) { + if (row.right.type === 'added') additions++; + if (row.left.type === 'removed') deletions++; + } + return { additions, deletions }; +} + +/** + * Cheap stat for a `local-only` change: additions only (the local line + * count), no deletions and no remote/provider call. A trailing newline + * doesn't add a phantom line. + */ +export function cheapLocalStat(local: string): ChangeStat { + return { additions: countLines(local), deletions: 0 }; +} + +/** + * Stat for a one-sided change whose only content is the ADDED side: every + * line is an addition, no deletions. Used for `local-only` (A) and + * `remote-only` (↓) — both show +N, not the -N a content-vs-'' diff would + * produce for the download direction. + */ +export function addedContentStat(content: string): ChangeStat { + return { additions: countLines(content), deletions: 0 }; +} + +/** + * Stat for a one-sided DELETION: the content existed remotely and is gone + * locally, so every line is a deletion. Used for `local-deleted` (D). + */ +export function deletedContentStat(content: string): ChangeStat { + return { additions: 0, deletions: countLines(content) }; +} + +function countLines(s: string): number { + if (s === '') return 0; + const lines = s.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n'); + return lines[lines.length - 1] === '' ? lines.length - 1 : lines.length; +} diff --git a/src/logic/sync/PullCoordinator.ts b/src/logic/sync/PullCoordinator.ts index 9aec22f..bfe4a67 100644 --- a/src/logic/sync/PullCoordinator.ts +++ b/src/logic/sync/PullCoordinator.ts @@ -62,6 +62,25 @@ export class PullCoordinator { return this.processBatch(files, onProgress, tree, options); } + /** + * Plans one already-fetched remote file exactly like batch pull's own + * per-file classification without a prefetched tree (`planFromRemote`) -- + * the shared decision step `SyncManager.pullFile()` delegates to, so + * single- and batch-pull planning semantics (baseline resolution, + * exists/content/sha comparison) can't silently drift apart. + * + * Deliberately NOT unified: interactive conflict handling ("resolve + * conflict" opens a modal for single-file pull, but is skipped/aggregated + * for batch pull), per-call confirmation (single confirms every call; + * batch confirms once for the whole plan), and notification (single + * always reports "up to date"; batch only summarizes). Those differences + * are deliberate UX, not accidental drift, and stay owned by each caller. + */ + async planSingleFile(file: TFile | string, remote: GitFile): Promise { + const { path, isString } = this.dependencies.scanner.fileInfo(file); + return this.planFromRemote(file, path, isString, remote); + } + 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: [] }; diff --git a/src/logic/sync/RenameReconciler.ts b/src/logic/sync/RenameReconciler.ts new file mode 100644 index 0000000..8f244fa --- /dev/null +++ b/src/logic/sync/RenameReconciler.ts @@ -0,0 +1,86 @@ +import { isSyncMetadataAtPath, type GitLabFilesPushSettings } from '../../settings'; +import type { SyncStatusService } from '../sync-status-service'; +import type { GitTreeEntry } from '../../services/git-service-interface'; +import { gitBlobSha } from '../../utils/git-blob-sha'; +import type { SyncManager } from './SyncManager'; + +export interface RenameReconcilerDependencies { + settings: () => GitLabFilesPushSettings; + syncManager: () => SyncManager; + /** Republishes a single path's status after it's been reconciled as a rename target. */ + refreshFileStatus(path: string, remoteEntry: GitTreeEntry | undefined): Promise; +} + +/** + * Reconciles renames performed outside the plugin (e.g. via git directly): + * matches an orphaned tracked path against an unsynced local file by blob + * sha, and relocates the sync metadata. Deliberately owns no discovery or + * status-resolution logic. + */ +export class RenameReconciler { + constructor( + private readonly dependencies: RenameReconcilerDependencies, + private readonly statuses: SyncStatusService, + ) {} + + 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.dependencies.refreshFileStatus(newPath, remoteMap.get(newPath)); + } + } + + 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) { + // A tracked-then-deleted file is now classified `local-deleted` + // (not `remote-only`), so both qualify as an orphaned move + // source: the remote entry still exists, sync metadata is + // present for the path, and it isn't itself a pending move. + if (status.status !== 'remote-only' && status.status !== 'local-deleted') 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; + } +} diff --git a/src/logic/sync/SyncDiffService.ts b/src/logic/sync/SyncDiffService.ts index 27e1487..d6a6f1c 100644 --- a/src/logic/sync/SyncDiffService.ts +++ b/src/logic/sync/SyncDiffService.ts @@ -1,8 +1,7 @@ import type { SyncStatusService } from '../sync-status-service'; import { isBinaryPath } from '../../utils/path'; import type { FileDiff } from './types'; -import { computeDiffStat } from '../../ui/source-control/ChangePresentation'; -import type { DiffStatLoadResult } from '../../ui/source-control/DiffStatProvider'; +import { computeDiffStat, type DiffStatLoadResult } from './DiffStat'; export type BlobReader = (sha: string, path: string) => Promise<{ content: string | ArrayBuffer }>; diff --git a/src/logic/sync/SyncFileDiscovery.ts b/src/logic/sync/SyncFileDiscovery.ts new file mode 100644 index 0000000..72a8177 --- /dev/null +++ b/src/logic/sync/SyncFileDiscovery.ts @@ -0,0 +1,192 @@ +import { type App, TFile } from 'obsidian'; +import { getEffectiveSymlinkHandling, isSyncMetadataAtPath, type GitLabFilesPushSettings } from '../../settings'; +import type { GitignoreManager } from '../gitignore-manager'; +import type { SyncStatusService } from '../sync-status-service'; +import type { GitServiceInterface, GitTreeEntry } from '../../services/git-service-interface'; +import { readLocalSymlinkTarget } from '../../utils/symlink'; + +export interface SyncFileDiscoveryDependencies { + app: App; + settings: () => GitLabFilesPushSettings; + gitService: () => GitServiceInterface; + gitignoreManager: () => GitignoreManager; + filterFilesByVaultFolder(files: TFile[]): TFile[]; + filterPathByVaultFolder(path: string): boolean; + getNormalizedPath(path: string): string; + getVaultPath(path: string): string; +} + +export interface DiscoveredFiles { + local: TFile[]; + remoteEntries: GitTreeEntry[]; + remoteHead?: string; + remoteMap: Map; + localMap: Set; + allMap: Map; + hiddenLocalPaths: Set; +} + +/** + * Enumerates what exists locally and remotely (vault files, hidden files, + * remote tree, vault-folder scope, .gitignore, symlink filtering) and + * classifies remote-only-vs-local-deleted for paths with no local file. + * Deliberately owns no status-resolution or rename-reconciliation logic. + */ +export class SyncFileDiscovery { + constructor( + private readonly dependencies: SyncFileDiscoveryDependencies, + private readonly statuses: SyncStatusService, + ) {} + + 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 { + // No local file at all. A tracked file that's since been + // removed locally (sync metadata still present for the path, + // and not a pending move source) is a *local deletion* — a + // potential remote deletion — distinct from a never-tracked + // remote-only file, which is simply available to download. + this.statuses.set(vaultPath, { + path: vaultPath, + status: this.statuses.classify({ + localExists: false, + remoteExists: true, + wasTracked: this.wasTrackedBeforeDelete(vaultPath), + }), + }); + } + } + return extra; + } + + initializeFileStatuses(localFiles: TFile[]): void { + for (const file of localFiles) this.statuses.set(file.path, { file, path: file.path, status: 'checking' }); + } + + 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)); + }); + } + + /** + * Whether `vaultPath` was previously tracked locally and has since been + * removed (sync metadata present for the path, and not a pending move + * source). Used to distinguish a `local-deleted` row from a + * never-tracked `remote-only` download candidate. + */ + private wasTrackedBeforeDelete(vaultPath: string): boolean { + const metadata = this.dependencies.settings().syncMetadata; + const pathMetadata = metadata ? metadata[vaultPath] : undefined; + return isSyncMetadataAtPath(pathMetadata, vaultPath) && !pathMetadata.renamedFrom; + } + + 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'; + } +} diff --git a/src/logic/sync/SyncInteractionPort.ts b/src/logic/sync/SyncInteractionPort.ts index 1baeaec..021f352 100644 --- a/src/logic/sync/SyncInteractionPort.ts +++ b/src/logic/sync/SyncInteractionPort.ts @@ -1,4 +1,4 @@ -import type { DiffStatLoadResult } from '../../ui/source-control/DiffStatProvider'; +import type { DiffStatLoadResult } from './DiffStat'; import type { BatchPushConflict, SyncPlan } from './types'; export type SyncPlanDirection = 'push' | 'pull' | 'delete' | 'sync'; diff --git a/src/logic/sync/SyncManager.ts b/src/logic/sync/SyncManager.ts index f4c5ad9..478e22f 100644 --- a/src/logic/sync/SyncManager.ts +++ b/src/logic/sync/SyncManager.ts @@ -10,8 +10,6 @@ import { 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'; @@ -21,7 +19,6 @@ import { ConflictResolver } from './ConflictResolver'; import { SyncExecutor } from './SyncExecutor'; import { PullCoordinator } from './PullCoordinator'; import { PushCoordinator } from './PushCoordinator'; -import { SyncPlanner } from './SyncPlanner'; import { HeadlessSyncInteraction, type ConflictDiffLoader, @@ -41,7 +38,6 @@ export class SyncManager { private readonly scanner: SyncScanner; private readonly pullCoordinator: PullCoordinator; private readonly pushCoordinator: PushCoordinator; - private readonly planner = new SyncPlanner(); private readonly interaction: SyncInteractionPort; /** Optional progressive +/- diff-stat source handed to the batch conflict modal. */ private diffStatLoader?: ConflictDiffStatLoader; @@ -211,23 +207,12 @@ export class SyncManager { 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 }, - }); + // Shared with batch pull's own no-prefetched-tree classification + // (PullCoordinator.planFromRemote), so single- and batch-pull + // planning semantics can't silently drift apart. Interactive + // conflict handling, confirmation, and notification stay separate + // below -- see PullCoordinator.planSingleFile's doc comment. + const decision = await this.pullCoordinator.planSingleFile(fileOrPath, remote); if (decision.action === 'none') { await this.updateMetadata(path, remote.sha); diff --git a/src/logic/sync/SyncStatusRefreshService.ts b/src/logic/sync/SyncStatusRefreshService.ts index e6b5236..ad2078e 100644 --- a/src/logic/sync/SyncStatusRefreshService.ts +++ b/src/logic/sync/SyncStatusRefreshService.ts @@ -1,13 +1,15 @@ import { type App, TFile } from 'obsidian'; -import { getEffectiveSymlinkHandling, isSyncMetadataAtPath, type GitLabFilesPushSettings, type SymlinkHandling } from '../../settings'; +import type { GitLabFilesPushSettings } 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 { isBinaryPath } from '../../utils/path'; import type { SyncManager } from './SyncManager'; +import { SyncFileDiscovery } from './SyncFileDiscovery'; +import { SyncStatusResolver } from './SyncStatusResolver'; +import { RenameReconciler } from './RenameReconciler'; export interface SyncStatusRefreshDependencies { app: App; @@ -33,22 +35,17 @@ export interface SyncStatusRefreshResult { 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. + * Orchestrates a full status refresh — discovery → resolve → reconcile + * renames → publish — and owns the incremental create/modify/delete/rename + * handlers used between full refreshes. Discovery, status resolution, and + * rename reconciliation each live in their own collaborator; this class + * deliberately exposes no rendering or notification concepts. */ export class SyncStatusRefreshService { - private static readonly STATUS_CHECK_CONCURRENCY = 8; + private readonly discovery: SyncFileDiscovery; + private readonly resolver: SyncStatusResolver; + private readonly renameReconciler: RenameReconciler; /** Per-path monotonic revision ordering async content writes (create read vs a raced modify). */ private readonly contentRevisions = new Map(); @@ -56,26 +53,37 @@ export class SyncStatusRefreshService { constructor( private readonly dependencies: SyncStatusRefreshDependencies, private readonly statuses: SyncStatusService, - ) {} + ) { + this.discovery = new SyncFileDiscovery(dependencies, statuses); + this.resolver = new SyncStatusResolver(dependencies, statuses); + this.renameReconciler = new RenameReconciler( + { + settings: dependencies.settings, + syncManager: dependencies.syncManager, + refreshFileStatus: (path, remoteEntry) => this.resolver.refreshFileStatus(path, remoteEntry), + }, + statuses, + ); + } async refresh(onProgress?: (progress: SyncStatusRefreshProgress) => void): Promise { this.statuses.clear(); - const files = await this.discoverFiles(); - this.initializeFileStatuses(files.local); + const files = await this.discovery.discoverFiles(); + this.discovery.initializeFileStatuses(files.local); for (const hiddenPath of files.hiddenLocalPaths) { this.statuses.set(hiddenPath, { path: hiddenPath, status: 'checking' }); } - const extra = await this.identifyExtraFiles( + const extra = await this.discovery.identifyExtraFiles( files.remoteMap, files.localMap, files.allMap, - this.pendingMoveOldPaths(), + this.renameReconciler.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); + const filesToCheck = this.discovery.getCheckableFiles(files.local, extra, files.hiddenLocalPaths); + await this.resolver.performStatusCheck(filesToCheck, files.remoteMap, onProgress); + await this.renameReconciler.reconcileOutOfBandMoves(files.remoteMap); return { localCount: files.local.length + files.hiddenLocalPaths.size, @@ -85,192 +93,6 @@ export class SyncStatusRefreshService { }; } - 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 { - // No local file at all. A tracked file that's since been - // removed locally (sync metadata still present for the path, - // and not a pending move source) is a *local deletion* — a - // potential remote deletion — distinct from a never-tracked - // remote-only file, which is simply available to download. - this.statuses.set(vaultPath, { - path: vaultPath, - status: this.statuses.classify({ - localExists: false, - remoteExists: true, - wasTracked: this.wasTrackedBeforeDelete(vaultPath), - }), - }); - } - } - return extra; - } - - /** - * Whether `vaultPath` was previously tracked locally and has since been - * removed (sync metadata present for the path, and not a pending move - * source). Used to distinguish a `local-deleted` row from a - * never-tracked `remote-only` download candidate. - */ - private wasTrackedBeforeDelete(vaultPath: string): boolean { - const metadata = this.dependencies.settings().syncMetadata; - const pathMetadata = metadata ? metadata[vaultPath] : undefined; - return isSyncMetadataAtPath(pathMetadata, vaultPath) && !pathMetadata.renamedFrom; - } - - /** The last-synced blob sha on record for `path`, or undefined if never tracked there. */ - private baseShaFor(path: string): string | undefined { - const metadata = this.dependencies.settings().syncMetadata; - const pathMetadata = metadata ? metadata[path] : undefined; - return isSyncMetadataAtPath(pathMetadata, path) ? pathMetadata.lastSyncedSha : undefined; - } - - /** - * Direction facts for a two-sided diff, relative to the last-synced - * baseline: undefined for both when there is no baseline on record (the - * two-sided diff then falls back to the direction-blind `modified`). - */ - private diffDirection(path: string, localSha: string, remoteSha: string): { localChanged?: boolean; remoteChanged?: boolean } { - const baseSha = this.baseShaFor(path); - if (baseSha === undefined) return {}; - return { localChanged: localSha !== baseSha, remoteChanged: remoteSha !== baseSha }; - } - - 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())); - } - /** * Handles an out-of-band local create so a brand-new file appears in the * Source Control view immediately rather than waiting for the next full @@ -303,7 +125,7 @@ export class SyncStatusRefreshService { path: file.path, status: this.statuses.classify({ localExists: true, remoteExists: false }), }); - void this.readFileContent(file, isBinaryPath(file.path), false).then(localContent => { + void this.resolver.readFileContent(file, isBinaryPath(file.path), false).then(localContent => { const current = this.statuses.get(file.path); if (!current || current.file !== file @@ -315,18 +137,11 @@ export class SyncStatusRefreshService { return true; } - /** Monotonic per-path counter ordering async content reads so only the newest one may write. */ - private bumpContentRevision(path: string): number { - const next = (this.contentRevisions.get(path) ?? 0) + 1; - this.contentRevisions.set(path, next); - return next; - } - async handleFileModified(file: TFile): Promise { const existing = this.statuses.get(file.path); if (!existing || !['synced', 'modified', 'unsynced', 'moved'].includes(existing.status)) return false; const revision = this.bumpContentRevision(file.path); - const localContent = await this.readFileContent(file, isBinaryPath(file.path), false); + const localContent = await this.resolver.readFileContent(file, isBinaryPath(file.path), false); // A create's slow async read may still be in flight behind this // modify; only the newest read may write. if (this.contentRevisions.get(file.path) !== revision) return true; @@ -349,7 +164,7 @@ export class SyncStatusRefreshService { localExists: true, remoteExists: true, contentsEqual: localSha === remoteSha, - ...this.diffDirection(file.path, localSha, remoteSha), + ...this.resolver.diffDirection(file.path, localSha, remoteSha), }); } } @@ -418,151 +233,11 @@ export class SyncStatusRefreshService { 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 localSha = await gitBlobSha(localContent); - const remoteSha = remoteEntry.sha; - const status = this.statuses.classify({ - localExists: true, - remoteExists: true, - contentsEqual: localSha === remoteSha, - ...(remoteSha !== undefined ? this.diffDirection(path, localSha, remoteSha) : {}), - }); - 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, - ); - let status: FileStatus['status']; - if (!remote.sha) { - status = this.statuses.classify({ localExists: true, remoteExists: false }); - } else { - const equal = contentsEqual(localContent, remote.content); - status = this.statuses.classify({ - localExists: true, - remoteExists: true, - contentsEqual: equal, - ...(equal ? {} : this.diffDirection(path, await gitBlobSha(localContent), remote.sha)), - }); - } - 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) { - // A tracked-then-deleted file is now classified `local-deleted` - // (not `remote-only`), so both qualify as an orphaned move - // source: the remote entry still exists, sync metadata is - // present for the path, and it isn't itself a pending move. - if (status.status !== 'remote-only' && status.status !== 'local-deleted') 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; + /** Monotonic per-path counter ordering async content reads so only the newest one may write. */ + private bumpContentRevision(path: string): number { + const next = (this.contentRevisions.get(path) ?? 0) + 1; + this.contentRevisions.set(path, next); + return next; } private addExtraToStatuses(extra: Array): void { @@ -575,87 +250,4 @@ export class SyncStatusRefreshService { }); } } - - 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/SyncStatusResolver.ts b/src/logic/sync/SyncStatusResolver.ts new file mode 100644 index 0000000..36ce178 --- /dev/null +++ b/src/logic/sync/SyncStatusResolver.ts @@ -0,0 +1,239 @@ +import { type App, TFile } from 'obsidian'; +import { getEffectiveSymlinkHandling, isSyncMetadataAtPath, type GitLabFilesPushSettings, type SymlinkHandling } from '../../settings'; +import { type FileStatus, type 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 SyncStatusResolverDependencies { + app: App; + settings: () => GitLabFilesPushSettings; + gitService: () => GitServiceInterface; + syncManager: () => SyncManager; + getNormalizedPath(path: string): string; +} + +export interface SyncStatusResolverProgress { + current: number; + total: number; +} + +/** + * Resolves a file's sync status: local-vs-remote diff, SHA/content + * comparison, baseline direction, and `FileStatus` classification. + * Deliberately owns no discovery or rename-reconciliation logic. + */ +export class SyncStatusResolver { + private static readonly STATUS_CHECK_CONCURRENCY = 8; + + constructor( + private readonly dependencies: SyncStatusResolverDependencies, + private readonly statuses: SyncStatusService, + ) {} + + async performStatusCheck( + filesToCheck: Array, + remoteMap: Map, + onProgress?: (progress: SyncStatusResolverProgress) => 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(SyncStatusResolver.STATUS_CHECK_CONCURRENCY, total); + await Promise.all(Array.from({ length: workerCount }, () => worker())); + } + + 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 localSha = await gitBlobSha(localContent); + const remoteSha = remoteEntry.sha; + const status = this.statuses.classify({ + localExists: true, + remoteExists: true, + contentsEqual: localSha === remoteSha, + ...(remoteSha !== undefined ? this.diffDirection(path, localSha, remoteSha) : {}), + }); + 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, + ); + let status: FileStatus['status']; + if (!remote.sha) { + status = this.statuses.classify({ localExists: true, remoteExists: false }); + } else { + const equal = contentsEqual(localContent, remote.content); + status = this.statuses.classify({ + localExists: true, + remoteExists: true, + contentsEqual: equal, + ...(equal ? {} : this.diffDirection(path, await gitBlobSha(localContent), remote.sha)), + }); + } + 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, + }); + } + + /** + * Direction facts for a two-sided diff, relative to the last-synced + * baseline: undefined for both when there is no baseline on record (the + * two-sided diff then falls back to the direction-blind `modified`). + */ + diffDirection(path: string, localSha: string, remoteSha: string): { localChanged?: boolean; remoteChanged?: boolean } { + const baseSha = this.baseShaFor(path); + if (baseSha === undefined) return {}; + return { localChanged: localSha !== baseSha, remoteChanged: remoteSha !== baseSha }; + } + + 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); + } + } + + /** The last-synced blob sha on record for `path`, or undefined if never tracked there. */ + private baseShaFor(path: string): string | undefined { + const metadata = this.dependencies.settings().syncMetadata; + const pathMetadata = metadata ? metadata[path] : undefined; + return isSyncMetadataAtPath(pathMetadata, path) ? pathMetadata.lastSyncedSha : undefined; + } + + 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 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/main.ts b/src/main.ts index 1ac9f5e..2a5c6d4 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,9 +3,8 @@ import { DEFAULT_SETTINGS, GitLabFilesPushSettings, GitLabSyncSettingTab, getSer import { GitLabService } from './services/gitlab-service'; import { GitHubService } from './services/github-service'; import { GiteaService } from './services/gitea-service'; -import { GitServiceInterface, GitTreeEntry } from './services/git-service-interface'; -import { ConnectionTestResult } from './services/git-service-base'; -import { SyncManager } from './logic/sync-manager'; +import { ConnectionTestResult, GitServiceInterface, GitTreeEntry } from './services/git-service-interface'; +import type { SyncManager } from './logic/sync-manager'; import { SourceControlItemView, SOURCE_CONTROL_VIEW_TYPE } from './ui/source-control/SourceControlItemView'; import { DiffTabView, SOURCE_CONTROL_DIFF_VIEW_TYPE, type DiffTabContent } from './ui/source-control/DiffTabView'; import { GitignoreManager } from './logic/gitignore-manager'; @@ -15,18 +14,16 @@ 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'; -import { ChangeRepository } from './logic/source-control/ChangeRepository'; -import { OperationState } from './logic/source-control/OperationState'; -import { RefreshState } from './logic/source-control/RefreshState'; -import { SyncSelectionStore } from './logic/source-control/SyncSelectionStore'; -import { SourceControlViewModel } from './logic/source-control/SourceControlViewModel'; -import { SourceControlActionService } from './logic/source-control/SourceControlActionService'; -import { SyncResultNotifier } from './logic/source-control/SyncResultNotifier'; -import { toSyncChanges } from './logic/source-control/FileStatusAdapter'; +import type { SyncStatusRefreshService } from './logic/sync/SyncStatusRefreshService'; +import type { SyncDiffService } from './logic/sync/SyncDiffService'; +import type { SyncWorkspace } from './logic/sync/SyncWorkspace'; +import type { ChangeRepository } from './logic/source-control/ChangeRepository'; +import type { OperationState } from './logic/source-control/OperationState'; +import type { RefreshState } from './logic/source-control/RefreshState'; +import type { SyncSelectionStore } from './logic/source-control/SyncSelectionStore'; +import type { SourceControlViewModel } from './logic/source-control/SourceControlViewModel'; +import type { SourceControlActionService } from './logic/source-control/SourceControlActionService'; +import { createSyncRuntime } from './runtime/createSyncRuntime'; import { filterFilesByVaultFolder as scopeFilterFiles, filterPathByVaultFolder as scopeFilterPath, @@ -55,7 +52,7 @@ export default class GitLabFilesPush extends Plugin { refreshState: RefreshState; sourceControlViewModel: SourceControlViewModel; sourceControlActions: SourceControlActionService; - private unsubscribeChangeRepository?: () => void; + private disposeSyncRuntime?: () => void; private gitignoreConfigKey = ''; private pushRibbonEl: HTMLElement; private statusBarEl: HTMLElement; @@ -66,7 +63,7 @@ export default class GitLabFilesPush extends Plugin { async onload() { await this.loadSettings(); - this.addSettingTab(new GitLabSyncSettingTab(this.app, this)); + this.addSettingTab(new GitLabSyncSettingTab(this.app, this, this)); this.registerView( SOURCE_CONTROL_VIEW_TYPE, @@ -92,68 +89,32 @@ export default class GitLabFilesPush extends Plugin { this.initializeGitService(); this.updateGitignoreManager(); - this.sync = new SyncManager( - this.app, - this.gitService, - this.settings, - this.saveSettings.bind(this), - (path) => this.gitignoreManager.isIgnored(this.getNormalizedPath(path)), - undefined, - new ObsidianSyncInteraction(this.app), - ); - this.syncStatusRefresh = new SyncStatusRefreshService({ + const runtime = createSyncRuntime({ app: this.app, - settings: () => this.settings, - gitService: () => this.gitService, - gitignoreManager: () => this.gitignoreManager, - syncManager: () => this.sync, + gitService: this.gitService, + getGitService: () => this.gitService, + settings: this.settings, + getSettings: () => this.settings, + saveSettings: this.saveSettings.bind(this), + getGitignoreManager: () => this.gitignoreManager, + isIgnored: (path) => this.gitignoreManager.isIgnored(this.getNormalizedPath(path)), filterFilesByVaultFolder: files => this.filterFilesByVaultFolder(files), filterPathByVaultFolder: path => this.filterPathByVaultFolder(path), getNormalizedPath: path => this.getNormalizedPath(path), getVaultPath: path => this.getVaultPath(path), - }, this.sync.status); - // One diff data service shared by the sync workspace (diff pane), - // the batch conflict modal's progressive +/- stat, and its "View - // Diff" — the modal never grows its own getBlob/cache path (see - // SyncDiffService.getConflictDiff). - this.syncDiffService = new SyncDiffService(this.sync.status, (sha, path) => this.gitService.getBlob(sha, path)); - this.sync.setConflictDiffStatLoader(conflict => this.syncDiffService.getConflictStat(conflict)); - this.sync.setConflictDiffLoader(conflict => this.syncDiffService.getConflictDiff(conflict)); - this.syncWorkspace = new SyncManagerWorkspace({ - manager: () => this.sync, - gitService: () => this.gitService, - settings: () => this.settings, - refreshService: this.syncStatusRefresh, - diffService: this.syncDiffService, - normalizePath: path => this.getNormalizedPath(path), - app: this.app, - }); - - this.changeRepository = new ChangeRepository(); - this.syncSelectionStore = new SyncSelectionStore(); - this.operationState = new OperationState(); - this.refreshState = new RefreshState(); - this.sourceControlViewModel = new SourceControlViewModel( - this.changeRepository, - this.syncSelectionStore, - this.operationState, - () => this.syncWorkspace.refresh(), - this.refreshState, - ); - this.sourceControlActions = new SourceControlActionService( - this.changeRepository, - this.operationState, - this.syncWorkspace, - new SyncResultNotifier(message => new Notice(message)), - ); - // Keeps ChangeRepository (and therefore the Source Control view) in - // sync with the same SyncStatusService instance the sync domain - // already publishes to -- no separate refresh/polling path. - this.unsubscribeChangeRepository = this.sync.status.subscribe((statuses) => { - const changes = toSyncChanges([...statuses.values()]); - this.changeRepository.replace(changes); - this.syncSelectionStore.refresh(changes.map(change => change.id)); + notify: message => new Notice(message), }); + this.sync = runtime.sync; + this.syncStatusRefresh = runtime.syncStatusRefresh; + this.syncDiffService = runtime.syncDiffService; + this.syncWorkspace = runtime.syncWorkspace; + this.changeRepository = runtime.changeRepository; + this.syncSelectionStore = runtime.syncSelectionStore; + this.operationState = runtime.operationState; + this.refreshState = runtime.refreshState; + this.sourceControlViewModel = runtime.sourceControlViewModel; + this.sourceControlActions = runtime.sourceControlActions; + this.disposeSyncRuntime = () => runtime.dispose(); this.statusBarEl = this.addStatusBarItem(); this.statusBarEl.addClass('gfs-status-bar-connection'); @@ -715,10 +676,11 @@ export default class GitLabFilesPush extends Plugin { onunload() { // Cleanup of registered components (views, commands, DOM/vault event - // listeners) is handled by Obsidian. The ChangeRepository subscription - // isn't Obsidian-managed, so it's unsubscribed explicitly. - this.unsubscribeChangeRepository?.(); - this.unsubscribeChangeRepository = undefined; + // listeners) is handled by Obsidian. The sync runtime's cross-object + // wiring (the ChangeRepository subscription) isn't Obsidian-managed, + // so it's disposed explicitly. + this.disposeSyncRuntime?.(); + this.disposeSyncRuntime = undefined; } async loadSettings() { diff --git a/src/runtime/createSyncRuntime.ts b/src/runtime/createSyncRuntime.ts new file mode 100644 index 0000000..f696592 --- /dev/null +++ b/src/runtime/createSyncRuntime.ts @@ -0,0 +1,152 @@ +import type { App, TFile } from 'obsidian'; +import type { GitLabFilesPushSettings } from '../settings'; +import type { GitServiceInterface } from '../services/git-service-interface'; +import type { GitignoreManager } from '../logic/gitignore-manager'; +import { SyncManager } from '../logic/sync-manager'; +import { SyncStatusRefreshService } from '../logic/sync/SyncStatusRefreshService'; +import { SyncDiffService } from '../logic/sync/SyncDiffService'; +import { SyncManagerWorkspace, type SyncWorkspace } from '../logic/sync/SyncWorkspace'; +import { ChangeRepository } from '../logic/source-control/ChangeRepository'; +import { OperationState } from '../logic/source-control/OperationState'; +import { RefreshState } from '../logic/source-control/RefreshState'; +import { SyncSelectionStore } from '../logic/source-control/SyncSelectionStore'; +import { SourceControlViewModel } from '../logic/source-control/SourceControlViewModel'; +import { SourceControlActionService } from '../logic/source-control/SourceControlActionService'; +import { SyncResultNotifier } from '../logic/source-control/SyncResultNotifier'; +import { toSyncChanges } from '../logic/source-control/FileStatusAdapter'; +import { ObsidianSyncInteraction } from '../ui/ObsidianSyncInteraction'; + +export interface SyncRuntimeDependencies { + app: App; + /** The concrete git service in effect at construction time (SyncManager tracks changes via `updateGitService`). */ + gitService: GitServiceInterface; + getGitService: () => GitServiceInterface; + /** The settings object in effect at construction time (mutated in place, not replaced). */ + settings: GitLabFilesPushSettings; + getSettings: () => GitLabFilesPushSettings; + saveSettings: () => Promise; + getGitignoreManager: () => GitignoreManager; + isIgnored: (path: string) => boolean; + filterFilesByVaultFolder(files: TFile[]): TFile[]; + filterPathByVaultFolder(path: string): boolean; + getNormalizedPath(path: string): string; + getVaultPath(path: string): string; + notify: (message: string) => void; +} + +export interface SyncRuntime { + sync: SyncManager; + syncStatusRefresh: SyncStatusRefreshService; + syncDiffService: SyncDiffService; + syncWorkspace: SyncWorkspace; + changeRepository: ChangeRepository; + syncSelectionStore: SyncSelectionStore; + operationState: OperationState; + refreshState: RefreshState; + sourceControlViewModel: SourceControlViewModel; + sourceControlActions: SourceControlActionService; + /** Tears down cross-object wiring (the ChangeRepository subscription) that Obsidian does not manage. */ + dispose(): void; +} + +/** + * Wires the sync domain and Source Control application constructor graph + * together: SyncManager, SyncStatusRefreshService, SyncDiffService, + * SyncWorkspace, and the Source Control application layer built on top of + * it. Deliberately knows nothing about Obsidian lifecycle events, commands, + * views, or ribbons -- those stay owned by the plugin entry point. + */ +export function createSyncRuntime(deps: SyncRuntimeDependencies): SyncRuntime { + const sync = new SyncManager( + deps.app, + deps.gitService, + deps.settings, + deps.saveSettings, + deps.isIgnored, + undefined, + new ObsidianSyncInteraction(deps.app), + ); + + const syncStatusRefresh = new SyncStatusRefreshService({ + app: deps.app, + settings: deps.getSettings, + gitService: deps.getGitService, + gitignoreManager: deps.getGitignoreManager, + syncManager: () => sync, + filterFilesByVaultFolder: files => deps.filterFilesByVaultFolder(files), + filterPathByVaultFolder: path => deps.filterPathByVaultFolder(path), + getNormalizedPath: path => deps.getNormalizedPath(path), + getVaultPath: path => deps.getVaultPath(path), + }, sync.status); + + // One diff data service shared by the sync workspace (diff pane), the + // batch conflict modal's progressive +/- stat, and its "View Diff" -- the + // modal never grows its own getBlob/cache path (see + // SyncDiffService.getConflictDiff). + const syncDiffService = new SyncDiffService(sync.status, (sha, path) => deps.getGitService().getBlob(sha, path)); + sync.setConflictDiffStatLoader(conflict => syncDiffService.getConflictStat(conflict)); + sync.setConflictDiffLoader(conflict => syncDiffService.getConflictDiff(conflict)); + + const syncWorkspace = new SyncManagerWorkspace({ + manager: () => sync, + gitService: deps.getGitService, + settings: deps.getSettings, + refreshService: syncStatusRefresh, + diffService: syncDiffService, + normalizePath: path => deps.getNormalizedPath(path), + app: deps.app, + }); + + const changeRepository = new ChangeRepository(); + const syncSelectionStore = new SyncSelectionStore(); + const operationState = new OperationState(); + const refreshState = new RefreshState(); + const sourceControlViewModel = new SourceControlViewModel( + changeRepository, + syncSelectionStore, + operationState, + () => syncWorkspace.refresh(), + refreshState, + ); + const sourceControlActions = new SourceControlActionService( + changeRepository, + syncSelectionStore, + operationState, + syncWorkspace, + new SyncResultNotifier(deps.notify), + ); + + // Selection-intent reconciliation is wired here, at the composition + // root, rather than inside SourceControlViewModel: it is a write-side + // lifecycle concern (stale selections/overrides get dropped whenever the + // repository publishes an authoritative snapshot), not part of the + // ViewModel's read-only projection. + const unsubscribeSelectionReconciliation = changeRepository.subscribe(changes => syncSelectionStore.reconcile(changes)); + + // Keeps ChangeRepository (and therefore the Source Control view) in sync + // with the same SyncStatusService instance the sync domain already + // publishes to -- no separate refresh/polling path. SyncSelectionStore + // cleanup is handled by the reconciliation subscription above, which + // ChangeRepository.replace() below triggers, so it isn't repeated here. + const unsubscribeChangeRepository = sync.status.subscribe((statuses) => { + const changes = toSyncChanges([...statuses.values()]); + changeRepository.replace(changes); + }); + + return { + sync, + syncStatusRefresh, + syncDiffService, + syncWorkspace, + changeRepository, + syncSelectionStore, + operationState, + refreshState, + sourceControlViewModel, + sourceControlActions, + dispose: () => { + unsubscribeChangeRepository(); + unsubscribeSelectionReconciliation(); + }, + }; +} diff --git a/src/services/git-service-base.ts b/src/services/git-service-base.ts index 1442351..693ad05 100644 --- a/src/services/git-service-base.ts +++ b/src/services/git-service-base.ts @@ -1,6 +1,6 @@ import { requestUrl, RequestUrlResponse } from 'obsidian'; import { logger } from '../utils/logger'; -import { GitTreeEntry } from './git-service-interface'; +import { ConnectionTestResult, GitTreeEntry } from './git-service-interface'; import { isBinaryPath } from '../utils/path'; export interface GitFile { @@ -48,15 +48,6 @@ export interface GitLabTreeItem { id?: string; } -export interface ConnectionTestResult { - /** Whether the repository/project itself was reachable with the given credentials. */ - repoOk: boolean; - /** Whether the configured branch was found. Only meaningful when repoOk is true. */ - branchOk: boolean; - /** Populated when repoOk is false, describing the repo-level failure. */ - error?: string; -} - /** Max files per single batch-commit call. Guards against oversized request * bodies / provider payload limits when a vault has thousands of files. */ export const MAX_BATCH_PUSH_SIZE = 200; diff --git a/src/services/git-service-interface.ts b/src/services/git-service-interface.ts index 92bb758..d46929c 100644 --- a/src/services/git-service-interface.ts +++ b/src/services/git-service-interface.ts @@ -1,4 +1,11 @@ -import { ConnectionTestResult } from './git-service-base'; +export interface ConnectionTestResult { + /** Whether the repository/project itself was reachable with the given credentials. */ + repoOk: boolean; + /** Whether the configured branch was found. Only meaningful when repoOk is true. */ + branchOk: boolean; + /** Populated when repoOk is false, describing the repo-level failure. */ + error?: string; +} export interface GitFile { content: string | ArrayBuffer; diff --git a/src/services/gitea-service.ts b/src/services/gitea-service.ts index 7cca54a..2c75c6a 100644 --- a/src/services/gitea-service.ts +++ b/src/services/gitea-service.ts @@ -1,5 +1,6 @@ import { GitServiceInterface, GitTreeEntry, BatchPushItem, BatchPushResult, BatchCommitPlan } from './git-service-interface'; -import { BaseGitService, ConnectionTestResult, GitFile, GitHubContentResponse, GitHubTreeResponse, GIT_SYMLINK_MODE } from './git-service-base'; +import { BaseGitService, GitFile, GitHubContentResponse, GitHubTreeResponse, GIT_SYMLINK_MODE } from './git-service-base'; +import { ConnectionTestResult } from './git-service-interface'; /** One entry in a Gitea "change multiple files" request. */ interface GiteaChangeFileOperation { diff --git a/src/services/github-service.ts b/src/services/github-service.ts index 85eab91..5b76bc6 100644 --- a/src/services/github-service.ts +++ b/src/services/github-service.ts @@ -1,5 +1,6 @@ import { GitServiceInterface, GitTreeEntry, BatchPushItem, BatchPushResult, BatchCommitPlan } from './git-service-interface'; -import { BaseGitService, ConnectionTestResult, GitFile, GitHubContentResponse, GitHubTreeResponse, GIT_SYMLINK_MODE, BLOB_CREATE_CONCURRENCY } from './git-service-base'; +import { BaseGitService, GitFile, GitHubContentResponse, GitHubTreeResponse, GIT_SYMLINK_MODE, BLOB_CREATE_CONCURRENCY } from './git-service-base'; +import { ConnectionTestResult } from './git-service-interface'; import { PushTimingCollector, PushTimingHandler, PushTimingRecord } from './push-timing'; /** diff --git a/src/services/gitlab-service.ts b/src/services/gitlab-service.ts index f71aef1..a00568d 100644 --- a/src/services/gitlab-service.ts +++ b/src/services/gitlab-service.ts @@ -1,5 +1,6 @@ import { GitServiceInterface, GitTreeEntry, BatchPushItem, BatchPushResult, BatchCommitPlan } from './git-service-interface'; -import { BaseGitService, ConnectionTestResult, GitFile, GitLabFileResponse, GitLabTreeItem, GIT_SYMLINK_MODE } from './git-service-base'; +import { BaseGitService, GitFile, GitLabFileResponse, GitLabTreeItem, GIT_SYMLINK_MODE } from './git-service-base'; +import { ConnectionTestResult } from './git-service-interface'; import { isBinaryPath } from '../utils/path'; export class GitLabService extends BaseGitService implements GitServiceInterface { diff --git a/src/settings-implementation.ts b/src/settings-implementation.ts deleted file mode 100644 index 96ab2d7..0000000 --- a/src/settings-implementation.ts +++ /dev/null @@ -1,588 +0,0 @@ -import {App, PluginSettingTab, Setting, Notice, TextComponent, ButtonComponent} from 'obsidian'; -import GitLabFilesPush, { type ConnectionStatus } from "./main"; -import {FolderSuggest} from "./ui/FolderSuggest"; -import {RemoteFolderSuggest} from "./ui/RemoteFolderSuggest"; -import {WhatsNewModal} from "./ui/WhatsNewModal"; -import { t, setLanguageOverride, type LanguageSetting } from "./i18n"; -import { CHANGELOG, entryText } from "./changelog"; - -// Minimal shape of Obsidian >= 1.13's SettingDefinitionItem. Declared locally so -// the plugin still type-checks against older Obsidian typings (minAppVersion -// 1.11.0), where this type does not exist. Obsidian only calls -// getSettingDefinitions() on versions that understand it. -interface SettingDefinitionItem { - name: string; - render: (setting: unknown, group: { listEl: HTMLElement }) => void; -} - -export interface SyncMetadata { - lastSyncedSha: string; - lastSyncedAt: number; - lastKnownPath?: string; - /** - * Set when the vault's 'rename' event moved this entry from another path - * and the move hasn't been pushed yet. Always the path still live on the - * remote — a chain of renames (A→B→C) collapses to this pointing at A, not - * the most recent hop, so pushing deletes the right remote path. - */ - renamedFrom?: string; -} - -/** - * Metadata written before `lastKnownPath` was introduced used its record key - * as the path. Keep that format eligible for rename reconciliation. - */ -export function isSyncMetadataAtPath(metadata: SyncMetadata | undefined, path: string): metadata is SyncMetadata { - return metadata !== undefined && (metadata.lastKnownPath === undefined || metadata.lastKnownPath === path); -} - -export type GitServiceType = 'gitlab' | 'github' | 'gitea'; - -/** - * How symbolic links (Git blobs with mode 120000) are synced: - * - 'real': recreate a real OS symlink on desktop; on mobile (no symlink API) - * fall back to syncing the link target's content as a normal file. - * - 'follow': always sync the target file's content as a normal file. - * - 'skip': ignore symlinks entirely. - */ -export type SymlinkHandling = 'real' | 'follow' | 'skip'; - -export interface GitLabFilesPushSettings { - serviceType: GitServiceType; - gitlabToken: string; - gitlabBaseUrl: string; - projectId: string; - githubToken: string; - githubOwner: string; - githubRepo: string; - giteaToken: string; - giteaBaseUrl: string; - giteaOwner: string; - giteaRepo: string; - branch: string; - syncMetadata: Record; - rootPath: string; - vaultFolder: string; - symlinkHandling: SymlinkHandling; - /** Multi-line, .gitignore-style patterns applied locally, in addition to the remote repo's .gitignore rules. */ - ignorePatterns: string; - /** Plugin version last seen by this vault, used to show a "what's new" tip after an update. */ - lastSeenVersion: string; - /** Version whose "what's new" banner in the settings tab has been dismissed, if any. */ - bannerDismissedVersion: string; - /** UI language. 'system' follows Obsidian's display language, falling back to English if unsupported. */ - language: LanguageSetting; - /** Refresh the sync status automatically after Obsidian finishes loading. */ - autoRefreshOnStartup: boolean; -} - -export function getServiceName(settings: GitLabFilesPushSettings): string { - if (settings.serviceType === 'gitlab') return 'GitLab'; - if (settings.serviceType === 'gitea') return 'Gitea'; - return 'GitHub'; -} - -/** - * Resolves the symlink behavior that actually applies. Only GitHub can create or - * push real symlinks (it has the Git Data API); on other providers "real" is not - * possible, so it is treated as "skip" to avoid silently turning links into - * ordinary files. - */ -export function getEffectiveSymlinkHandling(settings: GitLabFilesPushSettings): SymlinkHandling { - if (settings.symlinkHandling === 'real' && settings.serviceType !== 'github') { - return 'skip'; - } - return settings.symlinkHandling; -} - -export const DEFAULT_SETTINGS: GitLabFilesPushSettings = { - serviceType: 'gitlab', - gitlabToken: '', - gitlabBaseUrl: 'https://gitlab.com', - projectId: '', - githubToken: '', - githubOwner: '', - githubRepo: '', - giteaToken: '', - giteaBaseUrl: '', - giteaOwner: '', - giteaRepo: '', - rootPath: "", - branch: 'main', - syncMetadata: {}, - vaultFolder: '', - symlinkHandling: 'real', - ignorePatterns: '', - lastSeenVersion: '', - bannerDismissedVersion: '', - language: 'system', - autoRefreshOnStartup: true -} - -const CONNECTION_TEST_DEBOUNCE_MS = 800; - -export class GitLabSyncSettingTab extends PluginSettingTab { - plugin: GitLabFilesPush; - private statusBadgeEl: HTMLElement | null = null; - private connectionTestTimer: number | null = null; - private unsubscribeConnectionStatus: (() => void) | null = null; - - constructor(app: App, plugin: GitLabFilesPush) { - super(app, plugin); - this.plugin = plugin; - } - - // The status badge mirrors the plugin's shared connection status (also - // driving the status bar item) instead of running its own test, so both - // stay in sync and don't race separate requests against the remote API. - hide(): void { - this.unsubscribeConnectionStatus?.(); - this.unsubscribeConnectionStatus = null; - if (this.connectionTestTimer) { - window.clearTimeout(this.connectionTestTimer); - this.connectionTestTimer = null; - } - } - - // Kept as a fallback for Obsidian < 1.13.0 (older than 1.13, down to - // minAppVersion 1.11.0), which don't know about getSettingDefinitions() - // and always call display(). - display(): void { - this.renderSettings(this.containerEl); - } - - getSettingDefinitions(): SettingDefinitionItem[] { - return [{ - name: '', - render: (_setting, group) => { - this.renderSettings(group.listEl); - } - }]; - } - - private refresh(): void { - // update() only exists on Obsidian >= 1.13. On older versions (down to - // minAppVersion 1.11.0) re-render manually instead. Accessed via a cast - // so this compiles against the 1.11 typings, which lack update(). - const maybeUpdate = (this as { update?: () => void }).update; - if (typeof maybeUpdate === 'function') { - maybeUpdate.call(this); - } else { - this.renderSettings(this.containerEl); - } - } - - // Persistent (until dismissed) banner surfacing the current version's notable - // highlights right at the top of the settings tab. Dismissing this only hides - // the attention banner; release history remains available from Settings. - private renderWhatsNewBanner(containerEl: HTMLElement): void { - const currentVersion = this.plugin.manifest.version; - if (this.plugin.settings.bannerDismissedVersion === currentVersion) return; - - const release = CHANGELOG.find(r => r.version === currentVersion); - const notableEntries = release?.entries.filter(entry => entry.notable) ?? []; - if (notableEntries.length === 0) return; - - // Onboarding releases already teach their mental model in the modal's - // step-by-step layout — keep the banner itself to a couple of highlights - // rather than repeating every notable entry. - const bannerEntries = release?.onboarding ? notableEntries.slice(0, 2) : notableEntries; - - const banner = containerEl.createDiv({ cls: 'gfs-whats-new-banner' }); - const textEl = banner.createDiv({ cls: 'gfs-whats-new-banner-text' }); - textEl.createEl('strong', { text: t('settings.whatsNewBanner.title', { version: currentVersion }) }); - const list = textEl.createEl('ul', { cls: 'gfs-whats-new-banner-list' }); - for (const entry of bannerEntries) { - list.createEl('li', { text: entryText(entry) }); - } - const viewBtn = new ButtonComponent(textEl) - .setButtonText(t('settings.whatsNewBanner.view')) - .onClick(() => { - new WhatsNewModal(this.app, CHANGELOG, () => void this.plugin.activateSourceControlView()).open(); - }); - viewBtn.buttonEl.addClass('gfs-whats-new-banner-view'); - - const dismissBtn = banner.createEl('button', { - cls: 'gfs-whats-new-banner-dismiss', - text: '×', - attr: { 'aria-label': t('settings.whatsNewBanner.dismiss') } - }); - dismissBtn.addEventListener('click', () => { - void (async () => { - this.plugin.settings.bannerDismissedVersion = currentVersion; - await this.plugin.saveSettings(); - this.refresh(); - })(); - }); - } - - private renderReleaseHistorySetting(containerEl: HTMLElement): void { - new Setting(containerEl) - .setName(t('settings.releaseHistory.name')) - .setDesc(t('settings.releaseHistory.desc')) - .addButton(button => button - .setButtonText(t('settings.releaseHistory.button')) - .onClick(() => { - new WhatsNewModal(this.app, CHANGELOG, () => void this.plugin.activateSourceControlView()).open(); - })); - } - - // Rebuilding the whole settings tab (renderSettings) to refresh the badge - // would empty and recreate every field, stealing focus mid-typing. The - // badge element is instead created once per renderSettings pass and - // updated in place by setStatusBadge(), driven by the plugin's shared - // connection status (see main.ts) so it stays in sync with the status bar. - private renderConnectionStatus(containerEl: HTMLElement): void { - this.statusBadgeEl = containerEl.createDiv({ cls: 'gfs-connection-status' }); - this.unsubscribeConnectionStatus?.(); - this.unsubscribeConnectionStatus = this.plugin.onConnectionStatusChange((status) => this.setStatusBadge(status)); - } - - private setStatusBadge(status: ConnectionStatus): void { - const badge = this.statusBadgeEl; - if (!badge) return; - - badge.removeClass('is-checking', 'is-connected', 'is-disconnected'); - badge.addClass(`is-${status.state}`); - - const labels: Record = { - checking: t('settings.connectionStatus.checking'), - connected: t('settings.connectionStatus.connected'), - disconnected: t('settings.connectionStatus.disconnected') - }; - const label = labels[status.state]; - badge.setText(status.detail ? t('settings.connectionStatus.withDetail', { label, detail: status.detail }) : label); - } - - // Debounced so token/branch fields (which call this on every keystroke) - // don't hit the remote API on every character typed. - private scheduleConnectionTest(): void { - if (this.connectionTestTimer) { - window.clearTimeout(this.connectionTestTimer); - } - this.connectionTestTimer = window.setTimeout(() => { - this.connectionTestTimer = null; - void this.plugin.testConnection(); - }, CONNECTION_TEST_DEBOUNCE_MS); - } - - private renderSettings(containerEl: HTMLElement): void { - containerEl.empty(); - - this.renderWhatsNewBanner(containerEl); - this.renderReleaseHistorySetting(containerEl); - this.renderConnectionStatus(containerEl); - - new Setting(containerEl) - .setName(t('settings.language.name')) - .setDesc(t('settings.language.desc')) - .addDropdown(dropdown => dropdown - .addOption('system', t('settings.language.option.system')) - .addOption('en', t('settings.language.option.en')) - .addOption('zh-tw', t('settings.language.option.zhTw')) - .addOption('zh-cn', t('settings.language.option.zhCn')) - .setValue(this.plugin.settings.language) - .onChange((value: string) => { - this.plugin.settings.language = value as LanguageSetting; - void this.plugin.saveSettings(); - setLanguageOverride(this.plugin.settings.language); - this.refresh(); - })); - - new Setting(containerEl) - .setName(t('settings.gitService.name')) - .setDesc(t('settings.gitService.desc')) - .addDropdown(dropdown => dropdown - .addOption('gitlab', 'GitLab') - .addOption('github', 'GitHub') - .addOption('gitea', 'Gitea') - .setValue(this.plugin.settings.serviceType) - .onChange((value: string) => { - this.plugin.settings.serviceType = value as GitServiceType; - void this.plugin.saveSettings(); - this.plugin.initializeGitService(); - this.refresh(); - })); - - new Setting(containerEl).setName('').setHeading(); - - if (this.plugin.settings.serviceType === 'gitlab') { - this.displayGitLabSettings(containerEl); - } else if (this.plugin.settings.serviceType === 'gitea') { - this.displayGiteaSettings(containerEl); - } else { - this.displayGitHubSettings(containerEl); - } - - new Setting(containerEl) - .setName(t('settings.branch.name')) - .setDesc(t('settings.branch.desc')) - .addText(text => text - .setPlaceholder(t('settings.branch.placeholder')) - .setValue(this.plugin.settings.branch) - .onChange((value) => { - this.plugin.settings.branch = value || 'main'; - void this.plugin.saveSettings(); - this.scheduleConnectionTest(); - })); - - new Setting(containerEl) - .setName(t('settings.rootPath.name')) - .setDesc(t('settings.rootPath.desc')) - .addText(text => { - text.setPlaceholder(t('settings.rootPath.placeholder')) - .setValue(this.plugin.settings.rootPath) - .onChange((value) => { - this.plugin.settings.rootPath = value.replace(/^\/|\/$/g, ''); - void this.plugin.saveSettings(); - this.plugin.initializeGitService(); - }); - RemoteFolderSuggest.attach(this.app, text.inputEl, this.plugin); - }); - - new Setting(containerEl) - .setName(t('settings.vaultFolder.name')) - .setDesc(t('settings.vaultFolder.desc')) - .addText(text => { - text.setPlaceholder(t('settings.vaultFolder.placeholder')) - .setValue(this.plugin.settings.vaultFolder) - .onChange((value) => { - this.plugin.settings.vaultFolder = value.replace(/^\/|\/$/g, ''); - void this.plugin.saveSettings(); - }); - FolderSuggest.attach(this.app, text.inputEl); - }); - - new Setting(containerEl) - .setName(t('settings.autoRefreshOnStartup.name')) - .setDesc(t('settings.autoRefreshOnStartup.desc')) - .addToggle(toggle => toggle - .setValue(this.plugin.settings.autoRefreshOnStartup) - .onChange((value) => { - this.plugin.settings.autoRefreshOnStartup = value; - void this.plugin.saveSettings(); - })); - - new Setting(containerEl) - .setName(t('settings.ignorePatterns.name')) - .setDesc(t('settings.ignorePatterns.desc')) - .addTextArea(text => { - text.setPlaceholder(`${this.app.vault.configDir}/\n*.tmp`) - .setValue(this.plugin.settings.ignorePatterns) - .onChange((value) => { - this.plugin.settings.ignorePatterns = value; - void this.plugin.saveSettings(); - }); - text.inputEl.rows = 4; - }); - - // "Real symlink" needs the Git Data API, which only GitHub offers. For - // other providers, offer follow/skip only so the option can't mislead. - const supportsRealSymlink = this.plugin.settings.serviceType === 'github'; - new Setting(containerEl) - .setName(t('settings.symlinks.name')) - .setDesc(supportsRealSymlink - ? t('settings.symlinks.desc.supported') - : t('settings.symlinks.desc.unsupported')) - .addDropdown(dropdown => { - if (supportsRealSymlink) dropdown.addOption('real', t('settings.symlinks.option.real')); - dropdown - .addOption('follow', t('settings.symlinks.option.follow')) - .addOption('skip', t('settings.symlinks.option.skip')) - .setValue(getEffectiveSymlinkHandling(this.plugin.settings)) - .onChange((value: string) => { - this.plugin.settings.symlinkHandling = value as SymlinkHandling; - void this.plugin.saveSettings(); - }); - }); - - new Setting(containerEl) - .setName(t('settings.testConnection.name')) - .setDesc(t('settings.testConnection.desc', { service: getServiceName(this.plugin.settings) })) - .addButton(button => button - .setButtonText(t('settings.testConnection.button')) - .onClick(async () => { - try { - const result = await this.plugin.testConnection(); - if (!result.repoOk) { - new Notice(t('settings.testConnection.failed', { reason: result.error ?? t('settings.testConnection.failed.unreachable') })); - } else if (!result.branchOk) { - new Notice( - t('settings.testConnection.branchNotFound.notice', { branch: this.plugin.settings.branch }), - 8000 - ); - } else { - new Notice(t('settings.testConnection.success', { service: getServiceName(this.plugin.settings) })); - } - } catch (e: unknown) { - const message = e instanceof Error ? e.message : String(e); - new Notice(t('settings.testConnection.failed', { reason: message })); - } - })); - - this.scheduleConnectionTest(); - } - - // Token fields are masked like a password input (with a toggle to reveal - // them) since they're secrets that shouldn't sit in plaintext on screen - // during screen shares, recordings, or shared machines. - private addTokenSetting(containerEl: HTMLElement, name: string, desc: string, getValue: () => string, onChange: (value: string) => void): void { - let textComponent: TextComponent; - new Setting(containerEl) - .setName(name) - .setDesc(desc) - .addText(text => { - textComponent = text; - text.inputEl.type = 'password'; - text.setPlaceholder(t('settings.token.placeholder')) - .setValue(getValue()) - .onChange(onChange); - }) - .addExtraButton(btn => { - btn.setIcon('eye') - .setTooltip(t('settings.token.show')) - .onClick(() => { - const revealing = textComponent.inputEl.type === 'password'; - textComponent.inputEl.type = revealing ? 'text' : 'password'; - btn.setIcon(revealing ? 'eye-off' : 'eye'); - btn.setTooltip(revealing ? t('settings.token.hide') : t('settings.token.show')); - }); - }); - } - - private displayGitLabSettings(containerEl: HTMLElement): void { - this.addTokenSetting( - containerEl, - t('settings.gitlab.token.name'), - t('settings.gitlab.token.desc'), - () => this.plugin.settings.gitlabToken, - (value) => { - this.plugin.settings.gitlabToken = value; - void this.plugin.saveSettings(); - this.plugin.initializeGitService(); - this.scheduleConnectionTest(); - } - ); - - new Setting(containerEl) - .setName(t('settings.gitlab.baseUrl.name')) - .setDesc(t('settings.gitlab.baseUrl.desc')) - .addText(text => text - .setPlaceholder('https://gitlab.com') - .setValue(this.plugin.settings.gitlabBaseUrl) - .onChange((value) => { - this.plugin.settings.gitlabBaseUrl = value || 'https://gitlab.com'; - void this.plugin.saveSettings(); - this.plugin.initializeGitService(); - this.scheduleConnectionTest(); - })); - - new Setting(containerEl) - .setName(t('settings.gitlab.projectId.name')) - .setDesc(t('settings.gitlab.projectId.desc')) - .addText(text => text - .setPlaceholder(t('settings.gitlab.projectId.placeholder')) - .setValue(this.plugin.settings.projectId) - .onChange((value) => { - this.plugin.settings.projectId = value; - void this.plugin.saveSettings(); - this.plugin.initializeGitService(); - this.scheduleConnectionTest(); - })); - } - - private displayGiteaSettings(containerEl: HTMLElement): void { - this.addTokenSetting( - containerEl, - t('settings.gitea.token.name'), - t('settings.gitea.token.desc'), - () => this.plugin.settings.giteaToken, - (value) => { - this.plugin.settings.giteaToken = value; - void this.plugin.saveSettings(); - this.plugin.initializeGitService(); - this.scheduleConnectionTest(); - } - ); - - new Setting(containerEl) - .setName(t('settings.gitea.baseUrl.name')) - .setDesc(t('settings.gitea.baseUrl.desc')) - .addText(text => text - .setPlaceholder('https://gitea.example.com') - .setValue(this.plugin.settings.giteaBaseUrl) - .onChange((value) => { - this.plugin.settings.giteaBaseUrl = value || 'https://gitea.example.com'; - void this.plugin.saveSettings(); - this.plugin.initializeGitService(); - this.scheduleConnectionTest(); - })); - - new Setting(containerEl) - .setName(t('settings.repoOwner.name')) - .setDesc(t('settings.repoOwner.desc.gitea')) - .addText(text => text - .setPlaceholder(t('settings.repoOwner.placeholder')) - .setValue(this.plugin.settings.giteaOwner) - .onChange((value) => { - this.plugin.settings.giteaOwner = value; - void this.plugin.saveSettings(); - this.plugin.initializeGitService(); - this.scheduleConnectionTest(); - })); - - new Setting(containerEl) - .setName(t('settings.repoName.name')) - .setDesc(t('settings.repoName.desc.gitea')) - .addText(text => text - .setPlaceholder(t('settings.repoName.placeholder')) - .setValue(this.plugin.settings.giteaRepo) - .onChange((value) => { - this.plugin.settings.giteaRepo = value; - void this.plugin.saveSettings(); - this.plugin.initializeGitService(); - this.scheduleConnectionTest(); - })); - } - - private displayGitHubSettings(containerEl: HTMLElement): void { - this.addTokenSetting( - containerEl, - t('settings.github.token.name'), - t('settings.github.token.desc'), - () => this.plugin.settings.githubToken, - (value) => { - this.plugin.settings.githubToken = value; - void this.plugin.saveSettings(); - this.plugin.initializeGitService(); - this.scheduleConnectionTest(); - } - ); - - new Setting(containerEl) - .setName(t('settings.repoOwner.name')) - .setDesc(t('settings.repoOwner.desc.github')) - .addText(text => text - .setPlaceholder(t('settings.repoOwner.placeholder')) - .setValue(this.plugin.settings.githubOwner) - .onChange((value) => { - this.plugin.settings.githubOwner = value; - void this.plugin.saveSettings(); - this.plugin.initializeGitService(); - this.scheduleConnectionTest(); - })); - - new Setting(containerEl) - .setName(t('settings.repoName.name')) - .setDesc(t('settings.repoName.desc.github')) - .addText(text => text - .setPlaceholder(t('settings.repoName.placeholder')) - .setValue(this.plugin.settings.githubRepo) - .onChange((value) => { - this.plugin.settings.githubRepo = value; - void this.plugin.saveSettings(); - this.plugin.initializeGitService(); - this.scheduleConnectionTest(); - })); - } -} diff --git a/src/settings.ts b/src/settings.ts index 995e4cc..f956517 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -1,8 +1,15 @@ -export * from './settings-implementation'; +// Public compatibility surface: re-exports the settings model/helpers and the +// settings UI so existing `from './settings'` / `from '../settings'` imports +// across the codebase keep working unchanged. See src/settings/ (model, +// helpers) and src/ui/settings/GitLabSyncSettingTab.ts for the actual +// implementations; nothing else should be added directly to this file. +export * from './settings/model'; +export * from './settings/helpers'; +export type { SettingsHost } from './ui/settings/GitLabSyncSettingTab'; import { GitLabSyncSettingTab as ImperativeGitLabSyncSettingTab, -} from './settings-implementation'; +} from './ui/settings/GitLabSyncSettingTab'; /** * Keep the existing imperative settings UI on Obsidian's display() lifecycle. diff --git a/src/settings/helpers.ts b/src/settings/helpers.ts new file mode 100644 index 0000000..fded38c --- /dev/null +++ b/src/settings/helpers.ts @@ -0,0 +1,28 @@ +import type { GitLabFilesPushSettings, SymlinkHandling, SyncMetadata } from './model'; + +/** + * Metadata written before `lastKnownPath` was introduced used its record key + * as the path. Keep that format eligible for rename reconciliation. + */ +export function isSyncMetadataAtPath(metadata: SyncMetadata | undefined, path: string): metadata is SyncMetadata { + return metadata !== undefined && (metadata.lastKnownPath === undefined || metadata.lastKnownPath === path); +} + +export function getServiceName(settings: GitLabFilesPushSettings): string { + if (settings.serviceType === 'gitlab') return 'GitLab'; + if (settings.serviceType === 'gitea') return 'Gitea'; + return 'GitHub'; +} + +/** + * Resolves the symlink behavior that actually applies. Only GitHub can create or + * push real symlinks (it has the Git Data API); on other providers "real" is not + * possible, so it is treated as "skip" to avoid silently turning links into + * ordinary files. + */ +export function getEffectiveSymlinkHandling(settings: GitLabFilesPushSettings): SymlinkHandling { + if (settings.symlinkHandling === 'real' && settings.serviceType !== 'github') { + return 'skip'; + } + return settings.symlinkHandling; +} diff --git a/src/settings/model.ts b/src/settings/model.ts new file mode 100644 index 0000000..ca91cfe --- /dev/null +++ b/src/settings/model.ts @@ -0,0 +1,78 @@ +import type { LanguageSetting } from '../i18n'; + +export interface SyncMetadata { + lastSyncedSha: string; + lastSyncedAt: number; + lastKnownPath?: string; + /** + * Set when the vault's 'rename' event moved this entry from another path + * and the move hasn't been pushed yet. Always the path still live on the + * remote — a chain of renames (A→B→C) collapses to this pointing at A, not + * the most recent hop, so pushing deletes the right remote path. + */ + renamedFrom?: string; +} + +export type GitServiceType = 'gitlab' | 'github' | 'gitea'; + +/** + * How symbolic links (Git blobs with mode 120000) are synced: + * - 'real': recreate a real OS symlink on desktop; on mobile (no symlink API) + * fall back to syncing the link target's content as a normal file. + * - 'follow': always sync the target file's content as a normal file. + * - 'skip': ignore symlinks entirely. + */ +export type SymlinkHandling = 'real' | 'follow' | 'skip'; + +export interface GitLabFilesPushSettings { + serviceType: GitServiceType; + gitlabToken: string; + gitlabBaseUrl: string; + projectId: string; + githubToken: string; + githubOwner: string; + githubRepo: string; + giteaToken: string; + giteaBaseUrl: string; + giteaOwner: string; + giteaRepo: string; + branch: string; + syncMetadata: Record; + rootPath: string; + vaultFolder: string; + symlinkHandling: SymlinkHandling; + /** Multi-line, .gitignore-style patterns applied locally, in addition to the remote repo's .gitignore rules. */ + ignorePatterns: string; + /** Plugin version last seen by this vault, used to show a "what's new" tip after an update. */ + lastSeenVersion: string; + /** Version whose "what's new" banner in the settings tab has been dismissed, if any. */ + bannerDismissedVersion: string; + /** UI language. 'system' follows Obsidian's display language, falling back to English if unsupported. */ + language: LanguageSetting; + /** Refresh the sync status automatically after Obsidian finishes loading. */ + autoRefreshOnStartup: boolean; +} + +export const DEFAULT_SETTINGS: GitLabFilesPushSettings = { + serviceType: 'gitlab', + gitlabToken: '', + gitlabBaseUrl: 'https://gitlab.com', + projectId: '', + githubToken: '', + githubOwner: '', + githubRepo: '', + giteaToken: '', + giteaBaseUrl: '', + giteaOwner: '', + giteaRepo: '', + rootPath: '', + branch: 'main', + syncMetadata: {}, + vaultFolder: '', + symlinkHandling: 'real', + ignorePatterns: '', + lastSeenVersion: '', + bannerDismissedVersion: '', + language: 'system', + autoRefreshOnStartup: true, +}; diff --git a/src/ui/SyncPlanModal.ts b/src/ui/SyncPlanModal.ts index f222219..e95f54e 100644 --- a/src/ui/SyncPlanModal.ts +++ b/src/ui/SyncPlanModal.ts @@ -96,7 +96,13 @@ export class SyncPlanModal extends Modal { const list = section.createEl('ul', { cls: 'sync-plan-file-list' }); for (const entry of entries) { const item = list.createEl('li', { cls: 'sync-plan-file-item' }); - item.createSpan({ cls: 'sync-plan-file-path', text: entry.path }); + // Mirrors the section heading's icon on each row — with a long + // mixed-direction plan (the merged Sync review), a section label + // scrolled out of view shouldn't leave a row's direction + // ambiguous. + const row = item.createDiv({ cls: 'sync-plan-file-row' }); + setIcon(row.createSpan({ cls: 'sync-plan-file-icon' }), icon); + row.createSpan({ cls: 'sync-plan-file-path', text: entry.path }); if (entry.movedFrom) { item.createSpan({ cls: 'sync-plan-file-moved-from', text: t('syncPlanModal.movedFrom', { path: entry.movedFrom }) }); } diff --git a/src/ui/components/DiffViewer.ts b/src/ui/components/DiffViewer.ts index 1d8e5f4..1f5587f 100644 --- a/src/ui/components/DiffViewer.ts +++ b/src/ui/components/DiffViewer.ts @@ -35,43 +35,75 @@ export function resetDiffLayoutMemoryForTests(): void { } export interface DiffViewerOptions { - remote: string; - local: string; + /** + * Diff content. Omit both when the content isn't loaded yet (e.g. an + * async fetch is in flight) — the viewer renders just the empty body, + * and the caller fills it in later via the returned handle's + * `setContent`. Passing only one of the two is not supported. + */ + remote?: string; + local?: string; layout: DiffLayout; /** * Where the split/unified toggle renders — typically the fixed header * region of the enclosing surface, so it stays reachable while a long * diff scrolls below. When omitted, no toggle is rendered and the viewer - * shows the given layout statically. + * shows the given layout statically. Also suppressed on phones (see + * `renderDiffViewer` doc) regardless of this option. */ toggleHost?: HTMLElement; /** State-sync callback so the owning surface can persist the layout across its own re-renders. */ onLayoutChange?: (next: DiffLayout) => void; } +/** Handle to the diff body a `renderDiffViewer` call created, for filling in content that wasn't ready yet at render time. */ +export interface DiffViewerHandle { + /** Replaces the body's content. Safe to call once the initial (possibly content-less) render has happened. */ + setContent(remote: string, local: string): void; +} + /** * The shared diff-viewer composition: layout toggle + body layout class + * diff panel. Every diff surface (desktop diff tab, conflict modal, mobile * detail) renders through this instead of reassembling DiffLayoutToggle and * DiffPanel — and never rebuilds its own "apply layout class + re-render - * toggle" dance. + * toggle" dance. This is also the single owner of the diff body element: + * callers that load content asynchronously must go through the returned + * handle's `setContent` rather than reaching into the DOM and calling + * `renderDiffPanel` themselves, which would append a second copy alongside + * whatever this function already rendered. + * + * Phones (`Platform.isPhone`) always render unified with no toggle — a + * split view is unreadable at phone width, and offering a toggle that + * produces two ~150px columns is worse than not offering it. Tablets and + * desktop keep the caller's requested layout and toggle. */ -export function renderDiffViewer(container: HTMLElement, options: DiffViewerOptions): void { - const body = container.createDiv({ cls: `scv-diff-tab-body scv-diff-layout-${options.layout}` }); - renderDiffPanel(body, options.remote, options.local); +export function renderDiffViewer(container: HTMLElement, options: DiffViewerOptions): DiffViewerHandle { + const layout: DiffLayout = Platform.isPhone ? 'unified' : options.layout; + const body = container.createDiv({ cls: `scv-diff-tab-body scv-diff-layout-${layout}` }); + if (options.remote !== undefined && options.local !== undefined) { + renderDiffPanel(body, options.remote, options.local); + } const toggleHost = options.toggleHost; - if (!toggleHost) return; + if (toggleHost && !Platform.isPhone) { + const renderToggle = (l: DiffLayout): void => { + toggleHost.empty(); + renderDiffLayoutToggle(toggleHost, l, next => { + applyLayoutClass(body, next); + options.onLayoutChange?.(next); + renderToggle(next); + }); + }; + renderToggle(layout); + } - const renderToggle = (layout: DiffLayout): void => { - toggleHost.empty(); - renderDiffLayoutToggle(toggleHost, layout, next => { - applyLayoutClass(body, next); - options.onLayoutChange?.(next); - renderToggle(next); - }); + return { + setContent(remote: string, local: string): void { + body.empty(); + renderDiffPanel(body, remote, local); + }, }; - renderToggle(options.layout); } function applyLayoutClass(body: HTMLElement, layout: DiffLayout): void { diff --git a/src/ui/components/icons.ts b/src/ui/components/icons.ts index e879edb..33bd8c7 100644 --- a/src/ui/components/icons.ts +++ b/src/ui/components/icons.ts @@ -35,4 +35,8 @@ export const ICONS = { // Repository changes view toggle viewTree: 'folder-tree', viewList: 'list', + // Row action menu (Repository Changes "⋯") + rowMenu: 'more-horizontal', + addToQueue: 'list-plus', + openRemote: 'external-link', } as const; diff --git a/src/ui/settings/GitLabSyncSettingTab.ts b/src/ui/settings/GitLabSyncSettingTab.ts new file mode 100644 index 0000000..462a5ba --- /dev/null +++ b/src/ui/settings/GitLabSyncSettingTab.ts @@ -0,0 +1,514 @@ +import { App, Plugin, PluginSettingTab, Setting, Notice, TextComponent, ButtonComponent } from 'obsidian'; +import type { ConnectionStatus } from '../../main'; +// Type-only: RemoteFolderSuggest.attach() still requires the concrete plugin +// class for its own gitService/settings reads. Widening SettingsHost to cover +// that unrelated widget's needs would leak scope into this PR; narrowing +// RemoteFolderSuggest itself is a separate cleanup, not part of this one. +import type GitLabFilesPush from '../../main'; +import type { ConnectionTestResult } from '../../services/git-service-interface'; +import { FolderSuggest } from '../FolderSuggest'; +import { RemoteFolderSuggest } from '../RemoteFolderSuggest'; +import { WhatsNewModal } from '../WhatsNewModal'; +import { t, setLanguageOverride, type LanguageSetting } from '../../i18n'; +import { CHANGELOG, entryText } from '../../changelog'; +import type { GitLabFilesPushSettings, GitServiceType, SymlinkHandling } from '../../settings/model'; +import { getServiceName, getEffectiveSymlinkHandling } from '../../settings/helpers'; + +// Minimal shape of Obsidian >= 1.13's SettingDefinitionItem. Declared locally so +// the plugin still type-checks against older Obsidian typings (minAppVersion +// 1.11.0), where this type does not exist. Obsidian only calls +// getSettingDefinitions() on versions that understand it. +interface SettingDefinitionItem { + name: string; + render: (setting: unknown, group: { listEl: HTMLElement }) => void; +} + +/** + * Narrow view of the plugin host this settings tab actually needs, so this UI + * layer depends on a small behavioral contract instead of the concrete + * `GitLabFilesPush` class -- keeps this file free to be tested against a + * plain stub and never creates a `settings UI -> main.ts` value dependency. + */ +export interface SettingsHost { + settings: GitLabFilesPushSettings; + manifest: { version: string }; + saveSettings(): Promise; + initializeGitService(): void; + testConnection(): Promise; + activateSourceControlView(): Promise; + onConnectionStatusChange(listener: (status: ConnectionStatus) => void): () => void; +} + +const CONNECTION_TEST_DEBOUNCE_MS = 800; + +export class GitLabSyncSettingTab extends PluginSettingTab { + private statusBadgeEl: HTMLElement | null = null; + private connectionTestTimer: number | null = null; + private unsubscribeConnectionStatus: (() => void) | null = null; + + /** + * `plugin` and `host` are almost always the same object; kept as separate + * parameters (rather than `Plugin & SettingsHost`) so `this.host`'s type + * only carries SettingsHost's own `settings` declaration -- an + * intersection with `Plugin` would also carry Plugin's version-gated + * `settings?: unknown` (Obsidian 1.13+) and trip this repo's + * `obsidianmd/no-unsupported-api` guard on every `this.host.settings` read. + */ + constructor(app: App, plugin: Plugin, private readonly host: SettingsHost) { + super(app, plugin); + } + + // The status badge mirrors the plugin's shared connection status (also + // driving the status bar item) instead of running its own test, so both + // stay in sync and don't race separate requests against the remote API. + hide(): void { + this.unsubscribeConnectionStatus?.(); + this.unsubscribeConnectionStatus = null; + if (this.connectionTestTimer) { + window.clearTimeout(this.connectionTestTimer); + this.connectionTestTimer = null; + } + } + + // Kept as a fallback for Obsidian < 1.13.0 (older than 1.13, down to + // minAppVersion 1.11.0), which don't know about getSettingDefinitions() + // and always call display(). + display(): void { + this.renderSettings(this.containerEl); + } + + getSettingDefinitions(): SettingDefinitionItem[] { + return [{ + name: '', + render: (_setting, group) => { + this.renderSettings(group.listEl); + } + }]; + } + + private refresh(): void { + // update() only exists on Obsidian >= 1.13. On older versions (down to + // minAppVersion 1.11.0) re-render manually instead. Accessed via a cast + // so this compiles against the 1.11 typings, which lack update(). + const maybeUpdate = (this as { update?: () => void }).update; + if (typeof maybeUpdate === 'function') { + maybeUpdate.call(this); + } else { + this.renderSettings(this.containerEl); + } + } + + // Persistent (until dismissed) banner surfacing the current version's notable + // highlights right at the top of the settings tab. Dismissing this only hides + // the attention banner; release history remains available from Settings. + private renderWhatsNewBanner(containerEl: HTMLElement): void { + const currentVersion = this.host.manifest.version; + if (this.host.settings.bannerDismissedVersion === currentVersion) return; + + const release = CHANGELOG.find(r => r.version === currentVersion); + const notableEntries = release?.entries.filter(entry => entry.notable) ?? []; + if (notableEntries.length === 0) return; + + // Onboarding releases already teach their mental model in the modal's + // step-by-step layout — keep the banner itself to a couple of highlights + // rather than repeating every notable entry. + const bannerEntries = release?.onboarding ? notableEntries.slice(0, 2) : notableEntries; + + const banner = containerEl.createDiv({ cls: 'gfs-whats-new-banner' }); + const textEl = banner.createDiv({ cls: 'gfs-whats-new-banner-text' }); + textEl.createEl('strong', { text: t('settings.whatsNewBanner.title', { version: currentVersion }) }); + const list = textEl.createEl('ul', { cls: 'gfs-whats-new-banner-list' }); + for (const entry of bannerEntries) { + list.createEl('li', { text: entryText(entry) }); + } + const viewBtn = new ButtonComponent(textEl) + .setButtonText(t('settings.whatsNewBanner.view')) + .onClick(() => { + new WhatsNewModal(this.app, CHANGELOG, () => void this.host.activateSourceControlView()).open(); + }); + viewBtn.buttonEl.addClass('gfs-whats-new-banner-view'); + + const dismissBtn = banner.createEl('button', { + cls: 'gfs-whats-new-banner-dismiss', + text: '×', + attr: { 'aria-label': t('settings.whatsNewBanner.dismiss') } + }); + dismissBtn.addEventListener('click', () => { + void (async () => { + this.host.settings.bannerDismissedVersion = currentVersion; + await this.host.saveSettings(); + this.refresh(); + })(); + }); + } + + private renderReleaseHistorySetting(containerEl: HTMLElement): void { + new Setting(containerEl) + .setName(t('settings.releaseHistory.name')) + .setDesc(t('settings.releaseHistory.desc')) + .addButton(button => button + .setButtonText(t('settings.releaseHistory.button')) + .onClick(() => { + new WhatsNewModal(this.app, CHANGELOG, () => void this.host.activateSourceControlView()).open(); + })); + } + + // Rebuilding the whole settings tab (renderSettings) to refresh the badge + // would empty and recreate every field, stealing focus mid-typing. The + // badge element is instead created once per renderSettings pass and + // updated in place by setStatusBadge(), driven by the plugin's shared + // connection status (see main.ts) so it stays in sync with the status bar. + private renderConnectionStatus(containerEl: HTMLElement): void { + this.statusBadgeEl = containerEl.createDiv({ cls: 'gfs-connection-status' }); + this.unsubscribeConnectionStatus?.(); + this.unsubscribeConnectionStatus = this.host.onConnectionStatusChange((status) => this.setStatusBadge(status)); + } + + private setStatusBadge(status: ConnectionStatus): void { + const badge = this.statusBadgeEl; + if (!badge) return; + + badge.removeClass('is-checking', 'is-connected', 'is-disconnected'); + badge.addClass(`is-${status.state}`); + + const labels: Record = { + checking: t('settings.connectionStatus.checking'), + connected: t('settings.connectionStatus.connected'), + disconnected: t('settings.connectionStatus.disconnected') + }; + const label = labels[status.state]; + badge.setText(status.detail ? t('settings.connectionStatus.withDetail', { label, detail: status.detail }) : label); + } + + // Debounced so token/branch fields (which call this on every keystroke) + // don't hit the remote API on every character typed. + private scheduleConnectionTest(): void { + if (this.connectionTestTimer) { + window.clearTimeout(this.connectionTestTimer); + } + this.connectionTestTimer = window.setTimeout(() => { + this.connectionTestTimer = null; + void this.host.testConnection(); + }, CONNECTION_TEST_DEBOUNCE_MS); + } + + private renderSettings(containerEl: HTMLElement): void { + containerEl.empty(); + + this.renderWhatsNewBanner(containerEl); + this.renderReleaseHistorySetting(containerEl); + this.renderConnectionStatus(containerEl); + + new Setting(containerEl) + .setName(t('settings.language.name')) + .setDesc(t('settings.language.desc')) + .addDropdown(dropdown => dropdown + .addOption('system', t('settings.language.option.system')) + .addOption('en', t('settings.language.option.en')) + .addOption('zh-tw', t('settings.language.option.zhTw')) + .addOption('zh-cn', t('settings.language.option.zhCn')) + .setValue(this.host.settings.language) + .onChange((value: string) => { + this.host.settings.language = value as LanguageSetting; + void this.host.saveSettings(); + setLanguageOverride(this.host.settings.language); + this.refresh(); + })); + + new Setting(containerEl) + .setName(t('settings.gitService.name')) + .setDesc(t('settings.gitService.desc')) + .addDropdown(dropdown => dropdown + .addOption('gitlab', 'GitLab') + .addOption('github', 'GitHub') + .addOption('gitea', 'Gitea') + .setValue(this.host.settings.serviceType) + .onChange((value: string) => { + this.host.settings.serviceType = value as GitServiceType; + void this.host.saveSettings(); + this.host.initializeGitService(); + this.refresh(); + })); + + new Setting(containerEl).setName('').setHeading(); + + if (this.host.settings.serviceType === 'gitlab') { + this.displayGitLabSettings(containerEl); + } else if (this.host.settings.serviceType === 'gitea') { + this.displayGiteaSettings(containerEl); + } else { + this.displayGitHubSettings(containerEl); + } + + new Setting(containerEl) + .setName(t('settings.branch.name')) + .setDesc(t('settings.branch.desc')) + .addText(text => text + .setPlaceholder(t('settings.branch.placeholder')) + .setValue(this.host.settings.branch) + .onChange((value) => { + this.host.settings.branch = value || 'main'; + void this.host.saveSettings(); + this.scheduleConnectionTest(); + })); + + new Setting(containerEl) + .setName(t('settings.rootPath.name')) + .setDesc(t('settings.rootPath.desc')) + .addText(text => { + text.setPlaceholder(t('settings.rootPath.placeholder')) + .setValue(this.host.settings.rootPath) + .onChange((value) => { + this.host.settings.rootPath = value.replace(/^\/|\/$/g, ''); + void this.host.saveSettings(); + this.host.initializeGitService(); + }); + RemoteFolderSuggest.attach(this.app, text.inputEl, this.host as unknown as GitLabFilesPush); + }); + + new Setting(containerEl) + .setName(t('settings.vaultFolder.name')) + .setDesc(t('settings.vaultFolder.desc')) + .addText(text => { + text.setPlaceholder(t('settings.vaultFolder.placeholder')) + .setValue(this.host.settings.vaultFolder) + .onChange((value) => { + this.host.settings.vaultFolder = value.replace(/^\/|\/$/g, ''); + void this.host.saveSettings(); + }); + FolderSuggest.attach(this.app, text.inputEl); + }); + + new Setting(containerEl) + .setName(t('settings.autoRefreshOnStartup.name')) + .setDesc(t('settings.autoRefreshOnStartup.desc')) + .addToggle(toggle => toggle + .setValue(this.host.settings.autoRefreshOnStartup) + .onChange((value) => { + this.host.settings.autoRefreshOnStartup = value; + void this.host.saveSettings(); + })); + + new Setting(containerEl) + .setName(t('settings.ignorePatterns.name')) + .setDesc(t('settings.ignorePatterns.desc')) + .addTextArea(text => { + text.setPlaceholder(`${this.app.vault.configDir}/\n*.tmp`) + .setValue(this.host.settings.ignorePatterns) + .onChange((value) => { + this.host.settings.ignorePatterns = value; + void this.host.saveSettings(); + }); + text.inputEl.rows = 4; + }); + + // "Real symlink" needs the Git Data API, which only GitHub offers. For + // other providers, offer follow/skip only so the option can't mislead. + const supportsRealSymlink = this.host.settings.serviceType === 'github'; + new Setting(containerEl) + .setName(t('settings.symlinks.name')) + .setDesc(supportsRealSymlink + ? t('settings.symlinks.desc.supported') + : t('settings.symlinks.desc.unsupported')) + .addDropdown(dropdown => { + if (supportsRealSymlink) dropdown.addOption('real', t('settings.symlinks.option.real')); + dropdown + .addOption('follow', t('settings.symlinks.option.follow')) + .addOption('skip', t('settings.symlinks.option.skip')) + .setValue(getEffectiveSymlinkHandling(this.host.settings)) + .onChange((value: string) => { + this.host.settings.symlinkHandling = value as SymlinkHandling; + void this.host.saveSettings(); + }); + }); + + new Setting(containerEl) + .setName(t('settings.testConnection.name')) + .setDesc(t('settings.testConnection.desc', { service: getServiceName(this.host.settings) })) + .addButton(button => button + .setButtonText(t('settings.testConnection.button')) + .onClick(async () => { + try { + const result = await this.host.testConnection(); + if (!result.repoOk) { + new Notice(t('settings.testConnection.failed', { reason: result.error ?? t('settings.testConnection.failed.unreachable') })); + } else if (!result.branchOk) { + new Notice( + t('settings.testConnection.branchNotFound.notice', { branch: this.host.settings.branch }), + 8000 + ); + } else { + new Notice(t('settings.testConnection.success', { service: getServiceName(this.host.settings) })); + } + } catch (e: unknown) { + const message = e instanceof Error ? e.message : String(e); + new Notice(t('settings.testConnection.failed', { reason: message })); + } + })); + + this.scheduleConnectionTest(); + } + + // Token fields are masked like a password input (with a toggle to reveal + // them) since they're secrets that shouldn't sit in plaintext on screen + // during screen shares, recordings, or shared machines. + private addTokenSetting(containerEl: HTMLElement, name: string, desc: string, getValue: () => string, onChange: (value: string) => void): void { + let textComponent: TextComponent; + new Setting(containerEl) + .setName(name) + .setDesc(desc) + .addText(text => { + textComponent = text; + text.inputEl.type = 'password'; + text.setPlaceholder(t('settings.token.placeholder')) + .setValue(getValue()) + .onChange(onChange); + }) + .addExtraButton(btn => { + btn.setIcon('eye') + .setTooltip(t('settings.token.show')) + .onClick(() => { + const revealing = textComponent.inputEl.type === 'password'; + textComponent.inputEl.type = revealing ? 'text' : 'password'; + btn.setIcon(revealing ? 'eye-off' : 'eye'); + btn.setTooltip(revealing ? t('settings.token.hide') : t('settings.token.show')); + }); + }); + } + + private displayGitLabSettings(containerEl: HTMLElement): void { + this.addTokenSetting( + containerEl, + t('settings.gitlab.token.name'), + t('settings.gitlab.token.desc'), + () => this.host.settings.gitlabToken, + (value) => { + this.host.settings.gitlabToken = value; + void this.host.saveSettings(); + this.host.initializeGitService(); + this.scheduleConnectionTest(); + } + ); + + new Setting(containerEl) + .setName(t('settings.gitlab.baseUrl.name')) + .setDesc(t('settings.gitlab.baseUrl.desc')) + .addText(text => text + .setPlaceholder('https://gitlab.com') + .setValue(this.host.settings.gitlabBaseUrl) + .onChange((value) => { + this.host.settings.gitlabBaseUrl = value || 'https://gitlab.com'; + void this.host.saveSettings(); + this.host.initializeGitService(); + this.scheduleConnectionTest(); + })); + + new Setting(containerEl) + .setName(t('settings.gitlab.projectId.name')) + .setDesc(t('settings.gitlab.projectId.desc')) + .addText(text => text + .setPlaceholder(t('settings.gitlab.projectId.placeholder')) + .setValue(this.host.settings.projectId) + .onChange((value) => { + this.host.settings.projectId = value; + void this.host.saveSettings(); + this.host.initializeGitService(); + this.scheduleConnectionTest(); + })); + } + + private displayGiteaSettings(containerEl: HTMLElement): void { + this.addTokenSetting( + containerEl, + t('settings.gitea.token.name'), + t('settings.gitea.token.desc'), + () => this.host.settings.giteaToken, + (value) => { + this.host.settings.giteaToken = value; + void this.host.saveSettings(); + this.host.initializeGitService(); + this.scheduleConnectionTest(); + } + ); + + new Setting(containerEl) + .setName(t('settings.gitea.baseUrl.name')) + .setDesc(t('settings.gitea.baseUrl.desc')) + .addText(text => text + .setPlaceholder('https://gitea.example.com') + .setValue(this.host.settings.giteaBaseUrl) + .onChange((value) => { + this.host.settings.giteaBaseUrl = value || 'https://gitea.example.com'; + void this.host.saveSettings(); + this.host.initializeGitService(); + this.scheduleConnectionTest(); + })); + + new Setting(containerEl) + .setName(t('settings.repoOwner.name')) + .setDesc(t('settings.repoOwner.desc.gitea')) + .addText(text => text + .setPlaceholder(t('settings.repoOwner.placeholder')) + .setValue(this.host.settings.giteaOwner) + .onChange((value) => { + this.host.settings.giteaOwner = value; + void this.host.saveSettings(); + this.host.initializeGitService(); + this.scheduleConnectionTest(); + })); + + new Setting(containerEl) + .setName(t('settings.repoName.name')) + .setDesc(t('settings.repoName.desc.gitea')) + .addText(text => text + .setPlaceholder(t('settings.repoName.placeholder')) + .setValue(this.host.settings.giteaRepo) + .onChange((value) => { + this.host.settings.giteaRepo = value; + void this.host.saveSettings(); + this.host.initializeGitService(); + this.scheduleConnectionTest(); + })); + } + + private displayGitHubSettings(containerEl: HTMLElement): void { + this.addTokenSetting( + containerEl, + t('settings.github.token.name'), + t('settings.github.token.desc'), + () => this.host.settings.githubToken, + (value) => { + this.host.settings.githubToken = value; + void this.host.saveSettings(); + this.host.initializeGitService(); + this.scheduleConnectionTest(); + } + ); + + new Setting(containerEl) + .setName(t('settings.repoOwner.name')) + .setDesc(t('settings.repoOwner.desc.github')) + .addText(text => text + .setPlaceholder(t('settings.repoOwner.placeholder')) + .setValue(this.host.settings.githubOwner) + .onChange((value) => { + this.host.settings.githubOwner = value; + void this.host.saveSettings(); + this.host.initializeGitService(); + this.scheduleConnectionTest(); + })); + + new Setting(containerEl) + .setName(t('settings.repoName.name')) + .setDesc(t('settings.repoName.desc.github')) + .addText(text => text + .setPlaceholder(t('settings.repoName.placeholder')) + .setValue(this.host.settings.githubRepo) + .onChange((value) => { + this.host.settings.githubRepo = value; + void this.host.saveSettings(); + this.host.initializeGitService(); + this.scheduleConnectionTest(); + })); + } +} diff --git a/src/ui/source-control/ChangeItem.ts b/src/ui/source-control/ChangeItem.ts index e7d5151..1be944d 100644 --- a/src/ui/source-control/ChangeItem.ts +++ b/src/ui/source-control/ChangeItem.ts @@ -1,11 +1,15 @@ -import { setIcon, setTooltip } from 'obsidian'; -import { t } from '../../i18n'; +import { Menu, setIcon, setTooltip } from 'obsidian'; +import { t, type TranslationKey } from '../../i18n'; import { ICONS } from '../components/icons'; import { renderOperationIndicator } from './OperationIndicator'; -import { presentChange, type ChangeStat } from './ChangePresentation'; -import { canDownload } from '../../logic/source-control/ChangeActionPolicy'; +import { presentChange } from './ChangePresentation'; +import type { ChangeStat } from '../../logic/sync/DiffStat'; +import { availableSyncActions, canDownload, type SyncAction } from '../../logic/source-control/ChangeActionPolicy'; import type { SourceControlItem } from '../../logic/source-control/SourceControlViewModel'; -import type { ChangeId } from '../../logic/source-control/types'; +import type { ChangeId, SyncChangeKind } from '../../logic/source-control/types'; + +/** An immediate (not queued) row action, invoked from the Repository Changes "⋯" menu. */ +export type RowActionKind = 'push' | 'pull' | 'delete-remote' | 'delete-local'; export interface ChangeItemCallbacks { onToggleSelect: (id: ChangeId, selected: boolean) => void; @@ -18,6 +22,23 @@ export interface ChangeItemCallbacks { * kinds, so the callback never has to re-classify. */ onDownload?: (item: SourceControlItem) => void; + /** + * Records the user's explicit action choice for a queued change (e.g. + * "Use remote" instead of the default push). Only wired for Sync Queue + * rows — see {@link ChangeItemOptions.showActionControl} — since + * Repository Changes rows don't carry a queue-scoped action to override. + */ + onChangeSyncAction?: (item: SourceControlItem, action: SyncAction) => void; + /** + * Runs an immediate push/pull/delete-remote/delete-local on a single + * Repository Changes row, from its "⋯" menu — distinct from queueing: + * this executes right away rather than waiting for the next Sync. Only + * rendered for kinds {@link rowMenuActions} has entries for; the row's + * own kind decides which `RowActionKind`s are actually offered. + */ + onRowAction?: (item: SourceControlItem, action: RowActionKind) => void; + /** Opens the change's file on the remote (in the browser) from the row menu — same destination as clicking a `remote-only` row. */ + onOpenRemote?: (item: SourceControlItem) => void; /** Looks up a cached diff stat for a row, if one has been computed. */ getDiffStat?: (id: ChangeId) => ChangeStat | undefined; } @@ -36,6 +57,13 @@ export interface ChangeItemOptions { * to fill the row, leaving room for {@link folderPath} on the right. */ listMode?: boolean; + /** + * Renders the compact action control (resolved action + menu to + * override/view diff/remove) instead of the plain inline Download + * button. Sync Queue rows only — Repository Changes rows stay + * unselector'd per row, matching the pre-existing layout. + */ + showActionControl?: boolean; } /** @@ -88,16 +116,26 @@ export function renderChangeItem( renderDiffStat(row, callbacks.getDiffStat?.(item.id)); - // A change with something to pull from remote (remote-only: add it - // locally; remote-modified: overwrite the local copy; local-deleted: - // restore it locally) carries a direct Download action so the user can - // pull it without first adding it to the Sync Queue. The button stops - // propagation so clicking it doesn't also trigger the row's - // open-diff/open-remote behavior. - if (canDownload(item.kind) && callbacks.onDownload) { + if (options.showActionControl && callbacks.onChangeSyncAction) { + // Sync Queue row: the resolved action plus a menu to override it, + // view the diff, or drop the row from the queue — supersedes the + // plain Download button below (its "use remote" case is one of the + // menu's options), so only one action affordance renders per row. + renderActionControl(row, item, callbacks); + } else if (canDownload(item.kind) && callbacks.onDownload) { + // A change with something to pull from remote (remote-only: add it + // locally; remote-modified: overwrite the local copy; local-deleted: + // restore it locally) carries a direct Download action so the user + // can pull it without first adding it to the Sync Queue. The button + // stops propagation so clicking it doesn't also trigger the row's + // open-diff/open-remote behavior. renderDownloadAction(row, item, callbacks.onDownload); } + if (!options.showActionControl && callbacks.onRowAction) { + renderRowMenuButton(row, item, callbacks); + } + renderOperationIndicator(row, item.operationStatus); row.addEventListener('click', (evt) => { @@ -124,6 +162,166 @@ function renderDownloadAction(row: HTMLElement, item: SourceControlItem, onDownl btn.addEventListener('click', (evt) => { evt.stopPropagation(); onDownload(item); }); } +/** Icon + label for each {@link SyncAction}, shared by the queue action control and its menu. */ +function actionIcon(action: SyncAction): string { + if (action === 'pull') return ICONS.pull; + if (action === 'delete-remote') return ICONS.delete; + return ICONS.push; +} + +function actionLabel(action: SyncAction): string { + if (action === 'pull') return t('sourceControl.queue.action.pull'); + if (action === 'delete-remote') return t('sourceControl.queue.action.deleteRemote'); + return t('sourceControl.queue.action.push'); +} + +/** + * The compact Sync Queue row action: shows the resolved action (icon + + * label on desktop, icon-only on phone — matching the row's own space + * constraints) and opens a menu to override it, view the diff, or remove the + * row from the queue. Only the actions {@link availableSyncActions} allows + * for the row's kind appear as choices, so the menu can never offer an + * illegal override. + */ +function renderActionControl(row: HTMLElement, item: SourceControlItem, callbacks: ChangeItemCallbacks): void { + const btn = row.createEl('button', { + cls: 'scv-change-action', + attr: { type: 'button' }, + }); + setIcon(btn.createSpan({ cls: 'scv-change-action-icon' }), actionIcon(item.syncAction)); + btn.createSpan({ cls: 'scv-change-action-label', text: actionLabel(item.syncAction) }); + setTooltip(btn, actionLabel(item.syncAction)); + + btn.addEventListener('click', (evt) => { + evt.stopPropagation(); + const menu = new Menu(); + for (const action of availableSyncActions(item.kind)) { + menu.addItem((menuItem) => { + menuItem + .setTitle(actionLabel(action)) + .setIcon(actionIcon(action)) + .setChecked(action === item.syncAction) + .onClick(() => callbacks.onChangeSyncAction?.(item, action)); + }); + } + menu.addSeparator(); + menu.addItem((menuItem) => { + menuItem + .setTitle(t('sourceControl.queue.menu.viewDiff')) + .setIcon(ICONS.diff) + .onClick(() => callbacks.onOpenDiff(item)); + }); + menu.addItem((menuItem) => { + menuItem + .setTitle(t('sourceControl.queue.menu.removeFromQueue')) + .setIcon(ICONS.clear) + .onClick(() => callbacks.onToggleSelect(item.id, false)); + }); + menu.showAtMouseEvent(evt); + }); +} + +/** One entry in a row's "⋯" menu: either an immediate {@link RowActionKind} or one of the fixed extras (view diff, queue, open remote). */ +type RowMenuEntry = + | { kind: 'action'; action: RowActionKind; labelKey: TranslationKey; icon: string } + | { kind: 'view-diff' } + | { kind: 'add-to-queue' } + | { kind: 'open-remote' }; + +/** + * The Repository Changes row menu's contents, per change kind. Deliberately + * hand-written per kind rather than derived from {@link availableSyncActions} + * — that table describes what the *Sync Queue* may resolve a change to, + * which isn't the same set as what's useful to run *immediately* from a + * single row (e.g. `local-only` has no remote counterpart to diff against, + * so it gets no "View diff" entry; `conflict` is excluded entirely since its + * resolution already has a dedicated UI — see the "conflict path" note on + * `SourceControlActionService`). + */ +function rowMenuActions(kind: SyncChangeKind): readonly RowMenuEntry[] { + switch (kind) { + case 'local-only': + return [ + { kind: 'action', action: 'push', labelKey: 'sourceControl.row.menu.pushLocal', icon: ICONS.push }, + { kind: 'add-to-queue' }, + { kind: 'action', action: 'delete-local', labelKey: 'sourceControl.row.menu.deleteLocalEllipsis', icon: ICONS.delete }, + ]; + case 'local-modified': + return [ + { kind: 'action', action: 'push', labelKey: 'sourceControl.row.menu.pushLocal', icon: ICONS.push }, + { kind: 'action', action: 'pull', labelKey: 'sourceControl.row.menu.useRemoteEllipsis', icon: ICONS.pull }, + { kind: 'view-diff' }, + { kind: 'add-to-queue' }, + { kind: 'action', action: 'delete-local', labelKey: 'sourceControl.row.menu.deleteLocalEllipsis', icon: ICONS.delete }, + ]; + case 'remote-modified': + return [ + { kind: 'action', action: 'pull', labelKey: 'sourceControl.row.menu.useRemote', icon: ICONS.pull }, + { kind: 'action', action: 'push', labelKey: 'sourceControl.row.menu.pushLocalEllipsis', icon: ICONS.push }, + { kind: 'view-diff' }, + { kind: 'add-to-queue' }, + ]; + case 'remote-only': + return [ + { kind: 'action', action: 'pull', labelKey: 'sourceControl.action.download', icon: ICONS.download }, + { kind: 'action', action: 'delete-remote', labelKey: 'sourceControl.row.menu.deleteRemoteEllipsis', icon: ICONS.delete }, + { kind: 'add-to-queue' }, + { kind: 'open-remote' }, + ]; + case 'local-deleted': + return [ + { kind: 'action', action: 'delete-remote', labelKey: 'sourceControl.row.menu.deleteRemote', icon: ICONS.delete }, + { kind: 'action', action: 'pull', labelKey: 'sourceControl.row.menu.restoreLocal', icon: ICONS.pull }, + ]; + case 'moved': + return [ + { kind: 'action', action: 'push', labelKey: 'sourceControl.row.menu.pushLocal', icon: ICONS.push }, + { kind: 'add-to-queue' }, + ]; + case 'conflict': + case 'synced': + return []; + } +} + +/** + * The Repository Changes row's "⋯" menu button: immediate push/pull/delete + * actions plus view-diff/queue/open-remote, scoped per kind by + * {@link rowMenuActions}. Rendered only when there's at least one entry for + * the row's kind (`conflict`/`synced` get none, so no empty menu appears). + */ +function renderRowMenuButton(row: HTMLElement, item: SourceControlItem, callbacks: ChangeItemCallbacks): void { + const entries = rowMenuActions(item.kind); + if (entries.length === 0) return; + + const btn = row.createEl('button', { cls: 'scv-change-menu', attr: { type: 'button' } }); + setIcon(btn, ICONS.rowMenu); + setTooltip(btn, t('sourceControl.row.menu.tooltip')); + + btn.addEventListener('click', (evt) => { + evt.stopPropagation(); + const menu = new Menu(); + for (const entry of entries) { + menu.addItem((menuItem) => { + if (entry.kind === 'action') { + menuItem.setTitle(t(entry.labelKey)).setIcon(entry.icon) + .onClick(() => callbacks.onRowAction?.(item, entry.action)); + } else if (entry.kind === 'view-diff') { + menuItem.setTitle(t('sourceControl.row.menu.viewDiff')).setIcon(ICONS.diff) + .onClick(() => callbacks.onOpenDiff(item)); + } else if (entry.kind === 'add-to-queue') { + menuItem.setTitle(t('sourceControl.row.menu.addToQueue')).setIcon(ICONS.addToQueue) + .onClick(() => callbacks.onToggleSelect(item.id, true)); + } else { + menuItem.setTitle(t('sourceControl.row.menu.openRemote')).setIcon(ICONS.openRemote) + .onClick(() => callbacks.onOpenRemote?.(item)); + } + }); + } + menu.showAtMouseEvent(evt); + }); +} + /** * Renders the +/- diff stat as two colored spans (green additions, red * deletions) so the magnitude and direction read at a glance. Nothing is diff --git a/src/ui/source-control/ChangePresentation.ts b/src/ui/source-control/ChangePresentation.ts index b051abe..c94ad1d 100644 --- a/src/ui/source-control/ChangePresentation.ts +++ b/src/ui/source-control/ChangePresentation.ts @@ -1,14 +1,7 @@ -import { computeSideBySideDiff } from '../../utils/diff'; import { t, type TranslationKey } from '../../i18n'; import type { SourceControlItem } from '../../logic/source-control/SourceControlViewModel'; import type { SyncChangeKind } from '../../logic/source-control/types'; -/** Additions/deletions for a single change's diff, the +/- stat a row shows. */ -export interface ChangeStat { - additions: number; - deletions: number; -} - /** * UI-only presentation of one change: the badge letter + class, a short * subtitle, the display name (with rename "from" separated out), and an @@ -74,53 +67,4 @@ export function presentChange(item: SourceControlItem, displayName: string): Cha if (item.kind === 'remote-only') view.tooltip = t('sourceControl.status.remoteAvailable.tooltip'); if (item.kind === 'local-deleted') view.tooltip = t('sourceControl.status.deletedLocally.tooltip'); return view; -} - -/** - * +/- stat for a two-sided diff (local-modified / remote-only / - * remote-modified / moved / conflict), reusing the existing LCS op logic in - * `utils/diff.ts`. Additions = added ops, deletions = removed ops. - */ -export function computeDiffStat(remote: string, local: string): ChangeStat { - const rows = computeSideBySideDiff(remote, local); - let additions = 0; - let deletions = 0; - for (const row of rows) { - if (row.right.type === 'added') additions++; - if (row.left.type === 'removed') deletions++; - } - return { additions, deletions }; -} - -/** - * Cheap stat for a `local-only` change: additions only (the local line - * count), no deletions and no remote/provider call. A trailing newline - * doesn't add a phantom line. - */ -export function cheapLocalStat(local: string): ChangeStat { - return { additions: countLines(local), deletions: 0 }; -} - -/** - * Stat for a one-sided change whose only content is the ADDED side: every - * line is an addition, no deletions. Used for `local-only` (A) and - * `remote-only` (↓) — both show +N, not the -N a content-vs-'' diff would - * produce for the download direction. - */ -export function addedContentStat(content: string): ChangeStat { - return { additions: countLines(content), deletions: 0 }; -} - -/** - * Stat for a one-sided DELETION: the content existed remotely and is gone - * locally, so every line is a deletion. Used for `local-deleted` (D). - */ -export function deletedContentStat(content: string): ChangeStat { - return { additions: 0, deletions: countLines(content) }; -} - -function countLines(s: string): number { - if (s === '') return 0; - const lines = s.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n'); - return lines[lines.length - 1] === '' ? lines.length - 1 : lines.length; } \ No newline at end of file diff --git a/src/ui/source-control/DiffStatProvider.ts b/src/ui/source-control/DiffStatProvider.ts index a5b0708..7b3dd78 100644 --- a/src/ui/source-control/DiffStatProvider.ts +++ b/src/ui/source-control/DiffStatProvider.ts @@ -1,19 +1,6 @@ -import type { ChangeStat } from './ChangePresentation'; +import type { ChangeStat, DiffStatLoadResult } from '../../logic/sync/DiffStat'; -/** - * What the loader resolved for one change row. The distinction matters - * because the cache treats the three outcomes differently: - * - `ready` — cached as a usable stat. - * - `unavailable` — permanent (binary, symlink, no two sides to diff); - * cached so the row is never retried. - * - `pending` — the backing content simply isn't in memory yet (e.g. a - * `local-only` row whose `localContent` hasn't been read). NOT cached: - * the next load pass retries the row, so a late-arriving stat still lands. - */ -export type DiffStatLoadResult = - | { status: 'ready'; stat: ChangeStat } - | { status: 'pending' } - | { status: 'unavailable' }; +export type { DiffStatLoadResult }; type DiffStatCacheEntry = | { state: 'ready'; stat: ChangeStat } diff --git a/src/ui/source-control/RepositoryChangesSection.ts b/src/ui/source-control/RepositoryChangesSection.ts new file mode 100644 index 0000000..1d3a679 --- /dev/null +++ b/src/ui/source-control/RepositoryChangesSection.ts @@ -0,0 +1,99 @@ +import { setIcon } from 'obsidian'; +import { t } from '../../i18n'; +import type { SourceControlItem } from '../../logic/source-control/SourceControlViewModel'; +import { ICONS } from '../components/icons'; +import { renderChangeTree, renderChangeList, type ChangeTreeCallbacks } from './ChangeTree'; + +/** Tree shaping so the change tree stays a compact change view, not a full Explorer. */ +const TREE_OPTIONS = { collapseSingleChild: true }; +/** Mobile tree: collapse single-child folders and cap depth so the tree stays flat on a phone. */ +const MOBILE_TREE_OPTIONS = { collapseSingleChild: true, maxDepth: 2 }; + +export interface RepositoryChangesSectionState { + /** Rows not currently in the Sync Queue (the queue and this tree stay disjoint). */ + items: readonly SourceControlItem[]; + collapsed: boolean; + viewMode: 'tree' | 'list'; + collapsedFolders: Set; + isMobile: boolean; +} + +export interface RepositoryChangesSectionCallbacks { + onToggleCollapsed: () => void; + onSetViewMode: (mode: 'tree' | 'list') => void; +} + +/** + * Renders the "Repository Changes (N)" region: a collapsible header with a + * Tree/List view toggle, above the change tree/list itself. A single role + * label (not the active filter name — the filter chips above already carry + * that) makes the section's job — "navigate the source I can pick from" — + * distinct from the Sync Queue's "what I'm about to push". + * + * Pure presentation: receives only state and callbacks, never `SyncWorkspace`, + * `SourceControlActionService`, or `SourceControlViewModel` directly. + */ +export function renderRepositoryChangesSection( + container: HTMLElement, + state: RepositoryChangesSectionState, + treeCallbacks: ChangeTreeCallbacks, + sectionCallbacks: RepositoryChangesSectionCallbacks, +): void { + renderRepositoryHeader(container, state, sectionCallbacks); + if (state.collapsed) return; + + const treeWrap = container.createDiv({ cls: 'scv-changes-tree' }); + if (state.items.length === 0) { + treeWrap.createDiv({ cls: 'scv-empty', text: t('sourceControl.empty') }); + } else if (state.viewMode === 'list') { + renderChangeList(treeWrap, state.items, treeCallbacks); + } else { + renderChangeTree(treeWrap, state.items, state.collapsedFolders, treeCallbacks, state.isMobile ? MOBILE_TREE_OPTIONS : TREE_OPTIONS); + } +} + +/** + * The header collapses/expands the region; the Tree/List view toggle on the + * right stops propagation so switching presentation doesn't also collapse + * the section. + */ +function renderRepositoryHeader( + container: HTMLElement, + state: RepositoryChangesSectionState, + callbacks: RepositoryChangesSectionCallbacks, +): void { + const header = container.createDiv({ cls: 'scv-repository-header scv-collapsible-header' }); + header.setAttr('role', 'button'); + header.setAttr('aria-expanded', String(!state.collapsed)); + header.createSpan({ cls: 'scv-section-toggle', text: state.collapsed ? '▶' : '▼' }); + header.createSpan({ cls: 'scv-repository-title', text: t('sourceControl.section.repositoryChanges') }); + header.createSpan({ cls: 'scv-repository-count', text: String(state.items.length) }); + header.addEventListener('click', () => callbacks.onToggleCollapsed()); + renderViewToggle(header, state, callbacks); +} + +/** + * Tree/List segmented toggle, scoped to the Repository Changes region only + * (the Sync Queue is always a flat list, so it gets no such toggle). The + * active mode is highlighted; clicks stop propagation so they don't also + * collapse the section via the title area. + */ +function renderViewToggle( + container: HTMLElement, + state: RepositoryChangesSectionState, + callbacks: RepositoryChangesSectionCallbacks, +): void { + const toggle = container.createDiv({ cls: 'scv-view-toggle' }); + toggle.setAttr('role', 'group'); + toggle.setAttr('aria-label', t('sourceControl.view.toggleLabel')); + for (const mode of ['tree', 'list'] as const) { + const active = state.viewMode === mode; + const btn = toggle.createEl('button', { cls: `scv-view-toggle-btn${active ? ' is-active' : ''}` }); + btn.setAttr('data-view', mode); + btn.setAttr('aria-pressed', String(active)); + btn.setAttr('title', mode === 'tree' ? t('sourceControl.view.tree') : t('sourceControl.view.list')); + setIcon(btn.createSpan({ cls: 'scv-view-toggle-icon' }), mode === 'tree' ? ICONS.viewTree : ICONS.viewList); + btn.createSpan({ cls: 'scv-view-toggle-label', text: mode === 'tree' ? t('sourceControl.view.tree') : t('sourceControl.view.list') }); + btn.addEventListener('click', (evt) => { evt.stopPropagation(); callbacks.onSetViewMode(mode); }); + } +} diff --git a/src/ui/source-control/SourceControlItemView.ts b/src/ui/source-control/SourceControlItemView.ts index a808987..b750f7e 100644 --- a/src/ui/source-control/SourceControlItemView.ts +++ b/src/ui/source-control/SourceControlItemView.ts @@ -3,11 +3,11 @@ import GitLabFilesPush from '../../main'; import { t } from '../../i18n'; import type { SourceControlItem } from '../../logic/source-control/SourceControlViewModel'; import type { FileStatus } from '../../logic/sync-status-service'; -import { toChangeId } from '../../logic/source-control/types'; +import { toChangeId, type ChangeId } from '../../logic/source-control/types'; import { SourceControlView, type SourceControlViewCallbacks } from './SourceControlView'; import type { SourceControlWorkspaceInfo } from './SourceControlHeader'; -import { addedContentStat, cheapLocalStat, computeDiffStat, deletedContentStat } from './ChangePresentation'; -import type { DiffStatLoadResult } from './DiffStatProvider'; +import { addedContentStat, cheapLocalStat, computeDiffStat, deletedContentStat, type DiffStatLoadResult } from '../../logic/sync/DiffStat'; +import { ConfirmModal } from '../ConfirmModal'; // Reuses the legacy sync-status view's registered type string so an already // open/pinned leaf from before this cutover resolves into the new view @@ -58,6 +58,14 @@ export class SourceControlItemView extends ItemView { onOpenDiff: (item) => { if (!Platform.isMobile) void this.openDesktopDiffTab(item); }, onOpenLocalFile: (item) => this.openLocalFile(item.path), onOpenRemoteFile: (item) => this.openRemoteFile(item.path), + onPush: (changeIds) => this.runAction(this.plugin.sourceControlActions.push(changeIds)), + onDeleteRemote: (changeIds) => this.runAction(this.confirmThenDeleteRemote(changeIds)), + onDeleteLocal: (changeIds) => this.runAction(this.plugin.sourceControlActions.deleteLocal(changeIds)), + onSelectForSync: (id) => this.plugin.sourceControlActions.selectForSync(id), + onDeselectFromSync: (id) => this.plugin.sourceControlActions.deselectFromSync(id), + onSelectMany: (ids) => this.plugin.sourceControlActions.selectMany(ids), + onDeselectMany: (ids) => this.plugin.sourceControlActions.deselectMany(ids), + onSetSyncAction: (id, action) => this.plugin.sourceControlActions.setSyncAction(id, action), }; this.view = new SourceControlView( this.plugin.sourceControlViewModel, @@ -113,19 +121,15 @@ export class SourceControlItemView extends ItemView { if (!openPath || openPath !== path) return; const requestId = ++this.diffTabRequestSeq; void (async () => { - // Project the repository row into the full item shape the diff - // loader consumes; the repo row dropped means the change is gone - // and the pane clears rather than showing contradictory sides. - const change = this.plugin.changeRepository.getById(toChangeId(path)); - if (!change) { + // ViewModel.getItem() is the single SourceControlItem projection + // owner; undefined means the change dropped out of the + // repository, and the pane clears rather than showing + // contradictory sides. + const item = this.plugin.sourceControlViewModel.getItem(toChangeId(path)); + if (!item) { await this.plugin.openDiffTab(path, null); return; } - const item: SourceControlItem = { - ...change, - isSelectedForSync: false, - operationStatus: 'idle', - }; const content = await this.plugin.sourceControlActions.loadDiffContent(item); if (requestId !== this.diffTabRequestSeq) return; await this.plugin.openDiffTab(path, content); @@ -149,6 +153,30 @@ export class SourceControlItemView extends ItemView { if (url) window.open(url, '_blank'); } + /** + * Confirms before running the row menu's one-off "Delete remote" — unlike + * `deleteLocal` (which goes through Obsidian's own trash), a remote + * deletion outside the Sync Plan's own confirm has no equivalent safety + * net, so this reuses the same `ConfirmModal` `main.ts` uses for its + * other immediate destructive actions. Resolves without deleting + * anything if the user cancels. + */ + private confirmThenDeleteRemote(changeIds: ChangeId[]): Promise { + const paths = changeIds + .map(id => this.plugin.changeRepository.getById(id)?.path) + .filter((path): path is string => path !== undefined); + if (paths.length === 0) return Promise.resolve(); + const message = t('sourceControl.row.confirmDeleteRemote', { path: paths.join(', ') }); + return new Promise((resolve) => { + new ConfirmModal( + this.app, + message, + () => { void this.plugin.sourceControlActions.deleteRemote(changeIds).then(resolve); }, + () => resolve(), + ).open(); + }); + } + /** * Resolves the +/- diff stat for a change row. `local-only` reads the * already-in-memory local content from `sync.status` (no I/O, no provider diff --git a/src/ui/source-control/SourceControlView.ts b/src/ui/source-control/SourceControlView.ts index fffc04b..684a832 100644 --- a/src/ui/source-control/SourceControlView.ts +++ b/src/ui/source-control/SourceControlView.ts @@ -2,16 +2,18 @@ import { debounce, Platform, setIcon, setTooltip } from 'obsidian'; import { t } from '../../i18n'; import type { SourceControlFilter } from '../../logic/source-control/SourceControlFilter'; import { SourceControlViewModel, type SourceControlItem } from '../../logic/source-control/SourceControlViewModel'; +import type { SyncIntentRequest } from '../../logic/source-control/SourceControlActionService'; +import type { SyncAction } from '../../logic/source-control/ChangeActionPolicy'; import type { ChangeId } from '../../logic/source-control/types'; -import { defaultSyncAction } from '../../logic/source-control/ChangeActionPolicy'; import { ICONS } from '../components/icons'; -import { renderDiffViewer, currentDiffLayout, rememberDiffLayout } from '../components/DiffViewer'; -import { renderDiffPanel } from '../components/DiffPanel'; -import { renderChangeTree, renderChangeList, type ChangeTreeCallbacks } from './ChangeTree'; -import { renderChangeItem } from './ChangeItem'; +import { renderDiffViewer, currentDiffLayout, rememberDiffLayout, type DiffViewerHandle } from '../components/DiffViewer'; +import type { ChangeTreeCallbacks } from './ChangeTree'; +import type { RowActionKind } from './ChangeItem'; import { DiffStatProvider, type DiffStatLoadResult } from './DiffStatProvider'; import { renderFilterMenu } from './FilterMenu'; import { renderSourceControlHeader, type SourceControlWorkspaceInfo } from './SourceControlHeader'; +import { renderSyncQueueSection } from './SyncQueueSection'; +import { renderRepositoryChangesSection } from './RepositoryChangesSection'; export interface SourceControlDiffContent { remote: string; @@ -27,15 +29,48 @@ export interface SourceControlViewCallbacks { * building, single confirm, and single commit all happen behind this * one call (`SourceControlActionService.sync()`). */ - onSync: (changeIds: ChangeId[]) => void | Promise; + onSync: (intents: SyncIntentRequest[]) => void | Promise; /** * Pulls one or more changes — used only by the inline per-row Download * button (a single `remote-only`/`local-deleted` row), not by the Sync * Queue button. */ onPull?: (changeIds: ChangeId[]) => void | Promise; + /** + * Pushes a single change immediately, bypassing the Sync Queue — the + * Repository Changes row menu's "Push local" on a kind that doesn't + * default there (e.g. `remote-modified`). + */ + onPush?: (changeIds: ChangeId[]) => void | Promise; + /** + * Deletes a single change from the remote only, immediately — the row + * menu's "Delete remote". Callers show their own confirm before invoking + * this; it does not confirm on its own. + */ + onDeleteRemote?: (changeIds: ChangeId[]) => void | Promise; + /** + * Deletes a single change from the local vault only, immediately — the + * row menu's "Delete local". Goes through Obsidian's own trash + * (`app.fileManager.trashFile`), so no separate confirm is shown here. + */ + onDeleteLocal?: (changeIds: ChangeId[]) => void | Promise; /** Triggers a view-wide refresh; the host wires this to the ViewModel's refresh delegate. */ onRefresh: () => void; + /** Adds one change to the Sync Queue — a Repository Changes row checkbox. */ + onSelectForSync: (id: ChangeId) => void; + /** Removes one change from the Sync Queue — a Sync Queue row checkbox. */ + onDeselectFromSync: (id: ChangeId) => void; + /** Adds several changes to the Sync Queue in one batch — a folder checkbox. */ + onSelectMany: (ids: readonly ChangeId[]) => void; + /** Removes several changes from the Sync Queue in one batch — a folder checkbox, or "Clear" on the queue. */ + onDeselectMany: (ids: readonly ChangeId[]) => void; + /** + * Records a Sync Queue row's explicit action override, chosen from its + * per-row action menu. Whether picking the kind's own default clears the + * override instead of storing it is decided behind this call + * (`SourceControlActionService.setSyncAction`), not by this view. + */ + onSetSyncAction: (id: ChangeId, action: SyncAction) => void; /** Notified when a change is selected for diff viewing, in addition to this view's own diff pane rendering. */ onOpenDiff?: (item: SourceControlItem) => void | Promise; /** Supplies diff content for the selected change; omit to leave the diff pane empty. */ @@ -67,11 +102,6 @@ export interface SourceControlViewCallbacks { loadDiffStat?: (item: SourceControlItem) => Promise; } -/** Tree shaping so the change tree stays a compact change view, not a full Explorer. */ -const TREE_OPTIONS = { collapseSingleChild: true }; -/** Mobile tree: collapse single-child folders and cap depth so the tree stays flat on a phone. */ -const MOBILE_TREE_OPTIONS = { collapseSingleChild: true, maxDepth: 2 }; - /** * Scroll positions of the main list's independently-scrolling regions, * persisted at View level so the mobile list → detail → Back round trip @@ -88,12 +118,11 @@ interface MainScrollState { * from `SourceControlViewModel` state, per * docs/source-control-refactor/phase-3-source-control-ui.md. * - * Pure presentation + wiring: push/diff intent is handed to injected - * callbacks rather than acted on directly here, so this layer never reaches - * past the ViewModel to `SyncManager`/a Git provider. Selection toggling goes - * through `viewModel.selection` (the `SyncSelectionStore`, exposed by the - * ViewModel) so the view holds no selection reference of its own and the - * batch ops (`toggle`/`toggleMany`) live on the store, not inline here. + * Pure presentation + wiring: push/diff intent, and selection/action-override + * mutation alike, are handed to injected callbacks rather than acted on + * directly here, so this layer never reaches past the ViewModel/callbacks to + * `SyncManager`, a Git provider, or `SyncSelectionStore` — it holds no + * selection reference of its own. * * Rendering semantics (status-grouping fix): * - Every filter chip renders a single flat tree (or list). "All" composes @@ -288,6 +317,9 @@ export class SourceControlView { onToggleFolderSelect: (ids, selected) => this.toggleFolderSelect(ids, selected), onOpenDiff: (item) => this.openDiff(item), onDownload: (item) => this.download(item), + onChangeSyncAction: (item, action) => this.changeSyncAction(item, action), + onRowAction: (item, action) => this.runRowAction(item, action), + onOpenRemote: (item) => { if (this.callbacks.onOpenRemoteFile) void this.callbacks.onOpenRemoteFile(item); }, getDiffStat: (id) => this.diffStat.get(id), }; @@ -321,22 +353,42 @@ export class SourceControlView { // tree instead of blowing out the layout under // `.scv-root { overflow: hidden }`. const body = container.createDiv({ cls: 'scv-body' }); - this.renderSelectedSection(body, state.syncQueue, treeCallbacks); + renderSyncQueueSection( + body, + { + syncQueue: state.syncQueue, + collapsed: this.collapsedSections.has('checkedChanges'), + mobileCollapsed: this.mobileQueueCollapsed, + isMobile, + }, + treeCallbacks, + { + onToggleCollapsed: () => { + if (isMobile) { this.mobileQueueCollapsed = !this.mobileQueueCollapsed; this.rerender(); } + else this.toggleSection('checkedChanges'); + }, + onClearSelection: (items) => this.clearSelection(items), + }, + ); // The Changes region is its own flex/scroll area so a tall tree // scrolls independently and never pushes the pinned Sync Queue // region above it out of view. const changesRegion = body.createDiv({ cls: 'scv-changes-region' }); - this.renderRepositoryHeader(changesRegion, unchecked.length); - if (!this.collapsedSections.has('changes')) { - const treeWrap = changesRegion.createDiv({ cls: 'scv-changes-tree' }); - if (unchecked.length === 0) { - treeWrap.createDiv({ cls: 'scv-empty', text: t('sourceControl.empty') }); - } else if (this.viewMode === 'list') { - renderChangeList(treeWrap, unchecked, treeCallbacks); - } else { - renderChangeTree(treeWrap, unchecked, this.collapsedFolders, treeCallbacks, isMobile ? MOBILE_TREE_OPTIONS : TREE_OPTIONS); - } - } + renderRepositoryChangesSection( + changesRegion, + { + items: unchecked, + collapsed: this.collapsedSections.has('changes'), + viewMode: this.viewMode, + collapsedFolders: this.collapsedFolders, + isMobile, + }, + treeCallbacks, + { + onToggleCollapsed: () => this.toggleSection('changes'), + onSetViewMode: (mode) => this.setViewMode(mode), + }, + ); // Only rendered rows background-load their stats: a collapsed // Repository Changes section renders no tree, so hidden rows must // not fire provider fetches; expanding the section re-renders and @@ -401,49 +453,6 @@ export class SourceControlView { if (cursor !== null) newInput.setSelectionRange(cursor, cursor); } - /** - * Renders the "Repository Changes (N)" header above the change tree/list. - * A single role label (not the active filter name — the filter chips above - * already carry that) makes the section's job — "navigate the source I can - * pick from" — distinct from the Sync Queue's "what I'm about to push". - * The whole header collapses/expands the region; the Tree/List view toggle - * on the right stops propagation so switching presentation doesn't also - * collapse the section. - */ - private renderRepositoryHeader(container: HTMLElement, count: number): void { - const collapsed = this.collapsedSections.has('changes'); - const header = container.createDiv({ cls: 'scv-repository-header scv-collapsible-header' }); - header.setAttr('role', 'button'); - header.setAttr('aria-expanded', String(!collapsed)); - header.createSpan({ cls: 'scv-section-toggle', text: collapsed ? '▶' : '▼' }); - header.createSpan({ cls: 'scv-repository-title', text: t('sourceControl.section.repositoryChanges') }); - header.createSpan({ cls: 'scv-repository-count', text: String(count) }); - header.addEventListener('click', () => this.toggleSection('changes')); - this.renderViewToggle(header); - } - - /** - * Tree/List segmented toggle, scoped to the Repository Changes region only - * (the Sync Queue is always a flat list, so it gets no such toggle). The - * active mode is highlighted; clicks stop propagation so they don't also - * collapse the section via the title area. - */ - private renderViewToggle(container: HTMLElement): void { - const toggle = container.createDiv({ cls: 'scv-view-toggle' }); - toggle.setAttr('role', 'group'); - toggle.setAttr('aria-label', t('sourceControl.view.toggleLabel')); - for (const mode of ['tree', 'list'] as const) { - const active = this.viewMode === mode; - const btn = toggle.createEl('button', { cls: `scv-view-toggle-btn${active ? ' is-active' : ''}` }); - btn.setAttr('data-view', mode); - btn.setAttr('aria-pressed', String(active)); - btn.setAttr('title', mode === 'tree' ? t('sourceControl.view.tree') : t('sourceControl.view.list')); - setIcon(btn.createSpan({ cls: 'scv-view-toggle-icon' }), mode === 'tree' ? ICONS.viewTree : ICONS.viewList); - btn.createSpan({ cls: 'scv-view-toggle-label', text: mode === 'tree' ? t('sourceControl.view.tree') : t('sourceControl.view.list') }); - btn.addEventListener('click', (evt) => { evt.stopPropagation(); this.setViewMode(mode); }); - } - } - private setViewMode(mode: 'tree' | 'list'): void { if (this.viewMode === mode) return; this.viewMode = mode; @@ -456,77 +465,9 @@ export class SourceControlView { this.rerender(); } - /** - * Renders the "SYNC QUEUE" region — the working push batch, a flat list - * of the changes selected for sync. Each queued change is a normal change - * row (badge + name + diff-stat) with its selection checkbox checked: - * unchecking it here moves the row back down into the repository tree, - * and checking a repository row moves it up here, so the queue and the - * tree stay disjoint. The set comes straight from the ViewModel's - * single-source `syncQueue` projection (same definition as the Sync - * button count), so the section and the button can never drift. - * - * On mobile the queue renders expanded by default (same as desktop) so - * the upcoming changes are directly visible without an extra tap; the - * repository tree's own scroll region absorbs the height. Tapping the - * header collapses it to a header bar (the bottom sync bar still carries - * the count). - */ - private renderSelectedSection( - container: HTMLElement, - syncQueue: readonly SourceControlItem[], - callbacks: ChangeTreeCallbacks, - ): void { - if (syncQueue.length === 0) return; - const isMobile = Platform.isMobile; - const collapsed = isMobile ? this.mobileQueueCollapsed : this.collapsedSections.has('checkedChanges'); - const section = container.createDiv({ cls: 'scv-selected-section' }); - const header = section.createDiv({ cls: 'scv-selected-section-header scv-collapsible-header' }); - header.setAttr('role', 'button'); - header.setAttr('aria-expanded', String(!collapsed)); - header.createSpan({ cls: 'scv-section-toggle', text: collapsed ? '▶' : '▼' }); - header.createSpan({ cls: 'scv-selected-section-title', text: t('sourceControl.section.selectedForSync') }); - - const clearBtn = header.createEl('button', { - cls: 'scv-selected-section-clear', - attr: { type: 'button' }, - }); - clearBtn.createSpan({ cls: 'scv-selected-section-clear-label', text: t('sourceControl.section.clearSelection') }); - setTooltip(clearBtn, t('sourceControl.section.clearSelection.tooltip')); - clearBtn.addEventListener('click', (evt) => { evt.stopPropagation(); this.clearSelection(syncQueue); }); - header.addEventListener('click', () => { - if (isMobile) { this.mobileQueueCollapsed = !this.mobileQueueCollapsed; this.rerender(); } - else this.toggleSection('checkedChanges'); - }); - - if (collapsed) return; - section.createDiv({ - cls: 'scv-selected-section-subtitle', - text: t('sourceControl.section.queueSubtitle', { count: syncQueue.length }), - }); - const list = section.createDiv({ cls: 'scv-selected-section-list' }); - // Group the queue by its default sync action so a mixed batch reads - // as what the Sync button will actually do (Upload / Download / - // Delete) rather than a flat list of ambiguous badges. Only surface - // group labels when more than one action is present in the batch — - // a single-action queue stays flat (no label noise) and matches the - // pre-categorization layout. - const upload = syncQueue.filter(item => defaultSyncAction(item.kind) === 'push'); - const download = syncQueue.filter(item => defaultSyncAction(item.kind) === 'pull'); - const deleteRemote = syncQueue.filter(item => defaultSyncAction(item.kind) === 'delete-remote'); - const groupCount = [upload, download, deleteRemote].filter(group => group.length > 0).length; - const mixed = groupCount > 1; - if (mixed && upload.length > 0) list.createDiv({ cls: 'scv-queue-group-label', text: t('sourceControl.queue.upload') }); - for (const item of upload) renderChangeItem(list, item, basename(item.path), callbacks); - if (mixed && download.length > 0) list.createDiv({ cls: 'scv-queue-group-label', text: t('sourceControl.queue.download') }); - for (const item of download) renderChangeItem(list, item, basename(item.path), callbacks); - if (mixed && deleteRemote.length > 0) list.createDiv({ cls: 'scv-queue-group-label', text: t('sourceControl.queue.delete') }); - for (const item of deleteRemote) renderChangeItem(list, item, basename(item.path), callbacks); - } - /** Unselects every change currently in the Sync Queue in one shot. */ private clearSelection(items: readonly SourceControlItem[]): void { - this.viewModel.selection.deselectMany(items.map(item => item.id)); + this.callbacks.onDeselectMany(items.map(item => item.id)); this.rerender(); } @@ -540,7 +481,10 @@ export class SourceControlView { */ private async runSync(queue: readonly SourceControlItem[]): Promise { if (queue.length === 0) return; - await this.callbacks.onSync(queue.map(item => item.id)); + await this.callbacks.onSync(queue.map(item => ({ + changeId: item.id, + action: item.hasActionOverride ? item.syncAction : undefined, + }))); } /** Pulls a single remote-only change into the vault — the inline Download button. */ @@ -548,6 +492,31 @@ export class SourceControlView { if (this.callbacks.onPull) void this.callbacks.onPull([item.id]); } + /** + * Runs a Repository Changes row's "⋯" menu action immediately (not + * queued) — see {@link ChangeItemCallbacks.onRowAction}. Just dispatches + * to the matching single-change callback; confirmation (delete-remote) + * and the actual `SourceControlActionService` calls live at the host + * (`SourceControlItemView`), not in this pure-projection view. + */ + private runRowAction(item: SourceControlItem, action: RowActionKind): void { + if (action === 'push' && this.callbacks.onPush) void this.callbacks.onPush([item.id]); + else if (action === 'pull' && this.callbacks.onPull) void this.callbacks.onPull([item.id]); + else if (action === 'delete-remote' && this.callbacks.onDeleteRemote) void this.callbacks.onDeleteRemote([item.id]); + else if (action === 'delete-local' && this.callbacks.onDeleteLocal) void this.callbacks.onDeleteLocal([item.id]); + } + + /** + * Records a Sync Queue row's explicit action override, chosen from its + * {@link ChangeItemCallbacks.onChangeSyncAction} menu. Whether that + * clears a default-matching override instead of storing it is decided by + * `SourceControlActionService.setSyncAction`, not here. + */ + private changeSyncAction(item: SourceControlItem, action: SyncAction): void { + this.callbacks.onSetSyncAction(item.id, action); + this.rerender(); + } + private renderDetail(root: HTMLElement): void { const detail = root.createDiv({ cls: 'scv-detail' }); const bar = detail.createDiv({ cls: 'scv-detail-bar' }); @@ -569,12 +538,11 @@ export class SourceControlView { const toggleSlot = bar.createDiv({ cls: 'scv-detail-bar-toggle' }); // Shared DiffViewer renders an empty placeholder body; the async - // load below fills it (stale-guarded) once the diff content is ready. - // The viewer appends the body directly to `detail`, so the legacy - // .scv-detail-diff wrapper's CSS is kept by styling the body itself. - renderDiffViewer(detail, { - remote: '', - local: '', + // load below fills it in (stale-guarded) via the returned handle, + // once the diff content is ready. The viewer appends the body + // directly to `detail`, so the legacy .scv-detail-diff wrapper's CSS + // is kept by styling the body itself. + const viewer = renderDiffViewer(detail, { layout: currentDiffLayout(), toggleHost: toggleSlot, onLayoutChange: (next) => { @@ -584,21 +552,20 @@ export class SourceControlView { }); const diffBody = detail.querySelector('.scv-diff-tab-body'); diffBody?.addClass('scv-detail-diff'); - if (diffBody && this.selectedChangeId) { - void this.loadAndRenderDiff(diffBody, this.selectedChangeId); + if (this.selectedChangeId) { + void this.loadAndRenderDiff(viewer, this.selectedChangeId); } } - private async loadAndRenderDiff(container: HTMLElement, changeId: ChangeId): Promise { + private async loadAndRenderDiff(viewer: DiffViewerHandle, changeId: ChangeId): Promise { if (!this.callbacks.loadDiffContent) return; - const item = this.viewModel.getState('all').items.find(i => i.id === changeId) - ?? this.viewModel.getState('synced', true).items.find(i => i.id === changeId); + const item = this.viewModel.getItem(changeId); if (!item) return; const content = await this.callbacks.loadDiffContent(item); // Stale response guard: the selection may have moved on while awaiting. if (!content || this.selectedChangeId !== changeId) return; - renderDiffPanel(container, content.remote, content.local); + viewer.setContent(content.remote, content.local); } private toggleFolder(path: string): void { @@ -608,14 +575,14 @@ export class SourceControlView { } private toggleSelect(id: ChangeId, selected: boolean): void { - if (selected) this.viewModel.selection.selectForSync(id); - else this.viewModel.selection.deselectFromSync(id); + if (selected) this.callbacks.onSelectForSync(id); + else this.callbacks.onDeselectFromSync(id); this.rerender(); } private toggleFolderSelect(ids: readonly ChangeId[], selected: boolean): void { - if (selected) this.viewModel.selection.selectMany(ids); - else this.viewModel.selection.deselectMany(ids); + if (selected) this.callbacks.onSelectMany(ids); + else this.callbacks.onDeselectMany(ids); this.rerender(); } @@ -664,12 +631,6 @@ export class SourceControlView { } } -/** Last path segment of a change path, for the Selected section's flat row labels. */ -function basename(path: string): string { - const slash = path.lastIndexOf('/'); - return slash === -1 ? path : path.slice(slash + 1); -} - /** Attribute-safe escaping for a ChangeId used inside a `[data-change-id="…"]` selector. */ function escapeChangeId(id: string): string { if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') return CSS.escape(id); diff --git a/src/ui/source-control/SyncQueueSection.ts b/src/ui/source-control/SyncQueueSection.ts new file mode 100644 index 0000000..424ebf5 --- /dev/null +++ b/src/ui/source-control/SyncQueueSection.ts @@ -0,0 +1,92 @@ +import { setTooltip } from 'obsidian'; +import { t } from '../../i18n'; +import type { SourceControlItem } from '../../logic/source-control/SourceControlViewModel'; +import { renderChangeItem } from './ChangeItem'; +import type { ChangeTreeCallbacks } from './ChangeTree'; + +export interface SyncQueueSectionState { + syncQueue: readonly SourceControlItem[]; + /** Desktop: the "Sync Queue" section's own collapse state. Mobile uses {@link mobileCollapsed} instead. */ + collapsed: boolean; + /** Mobile-only: the queue starts expanded by default, collapsed by tapping its header. */ + mobileCollapsed: boolean; + isMobile: boolean; +} + +export interface SyncQueueSectionCallbacks { + /** Toggles the section's collapse state — desktop `collapsedSections`, mobile `mobileQueueCollapsed`. */ + onToggleCollapsed: () => void; + /** Unselects every queued change in one shot. */ + onClearSelection: (items: readonly SourceControlItem[]) => void; +} + +/** + * Renders the "SYNC QUEUE" region — the working push batch, a flat list of + * the changes selected for sync. Each queued change is a normal change row + * (badge + name + diff-stat) with its selection checkbox checked: unchecking + * it here moves the row back down into the repository tree, and checking a + * repository row moves it up here, so the queue and the tree stay disjoint. + * + * On mobile the queue renders expanded by default (same as desktop) so the + * upcoming changes are directly visible without an extra tap; the repository + * tree's own scroll region absorbs the height. Tapping the header collapses + * it to a header bar (the bottom sync bar still carries the count). + * + * Pure presentation: receives only state and callbacks, never `SyncWorkspace`, + * `SourceControlActionService`, or `SourceControlViewModel` directly. + */ +export function renderSyncQueueSection( + container: HTMLElement, + state: SyncQueueSectionState, + treeCallbacks: ChangeTreeCallbacks, + sectionCallbacks: SyncQueueSectionCallbacks, +): void { + const { syncQueue } = state; + if (syncQueue.length === 0) return; + const collapsed = state.isMobile ? state.mobileCollapsed : state.collapsed; + const section = container.createDiv({ cls: 'scv-selected-section' }); + const header = section.createDiv({ cls: 'scv-selected-section-header scv-collapsible-header' }); + header.setAttr('role', 'button'); + header.setAttr('aria-expanded', String(!collapsed)); + header.createSpan({ cls: 'scv-section-toggle', text: collapsed ? '▶' : '▼' }); + header.createSpan({ cls: 'scv-selected-section-title', text: t('sourceControl.section.selectedForSync') }); + + const clearBtn = header.createEl('button', { + cls: 'scv-selected-section-clear', + attr: { type: 'button' }, + }); + clearBtn.createSpan({ cls: 'scv-selected-section-clear-label', text: t('sourceControl.section.clearSelection') }); + setTooltip(clearBtn, t('sourceControl.section.clearSelection.tooltip')); + clearBtn.addEventListener('click', (evt) => { evt.stopPropagation(); sectionCallbacks.onClearSelection(syncQueue); }); + header.addEventListener('click', () => sectionCallbacks.onToggleCollapsed()); + + if (collapsed) return; + section.createDiv({ + cls: 'scv-selected-section-subtitle', + text: t('sourceControl.section.queueSubtitle', { count: syncQueue.length }), + }); + const list = section.createDiv({ cls: 'scv-selected-section-list' }); + // Group the queue by its resolved sync action (the default, unless the + // user overrode it) so a mixed batch reads as what the Sync button will + // actually do (Upload / Download / Delete) rather than a flat list of + // ambiguous badges. Only surface group labels when more than one action + // is present in the batch — a single-action queue stays flat (no label + // noise) and matches the pre-categorization layout. + const upload = syncQueue.filter(item => item.syncAction === 'push'); + const download = syncQueue.filter(item => item.syncAction === 'pull'); + const deleteRemote = syncQueue.filter(item => item.syncAction === 'delete-remote'); + const groupCount = [upload, download, deleteRemote].filter(group => group.length > 0).length; + const mixed = groupCount > 1; + if (mixed && upload.length > 0) list.createDiv({ cls: 'scv-queue-group-label', text: t('sourceControl.queue.upload') }); + for (const item of upload) renderChangeItem(list, item, basename(item.path), treeCallbacks, { showActionControl: true }); + if (mixed && download.length > 0) list.createDiv({ cls: 'scv-queue-group-label', text: t('sourceControl.queue.download') }); + for (const item of download) renderChangeItem(list, item, basename(item.path), treeCallbacks, { showActionControl: true }); + if (mixed && deleteRemote.length > 0) list.createDiv({ cls: 'scv-queue-group-label', text: t('sourceControl.queue.delete') }); + for (const item of deleteRemote) renderChangeItem(list, item, basename(item.path), treeCallbacks, { showActionControl: true }); +} + +/** Last path segment of a change path, for the Sync Queue's flat row labels. */ +function basename(path: string): string { + const slash = path.lastIndexOf('/'); + return slash === -1 ? path : path.slice(slash + 1); +} diff --git a/styles.css b/styles.css index 2d782ee..756374f 100644 --- a/styles.css +++ b/styles.css @@ -672,6 +672,66 @@ body.is-mobile .scv-view-toggle-label { display: none; } .scv-change-download-label { white-space: nowrap; } +/* Phone: icon-only, so the button doesn't crowd out the filename at narrow widths. */ +body.is-mobile .scv-change-download-label { display: none; } +body.is-mobile .scv-change-download { padding: 4px; min-width: 28px; min-height: 28px; justify-content: center; } + +/* ── Sync Queue row action control (resolved action + override menu) ── */ +.scv-change-action { + display: inline-flex; + align-items: center; + gap: 3px; + margin-left: auto; + padding: 1px 6px; + background: transparent; + border: 1px solid var(--background-modifier-border); + border-radius: var(--radius-s); + color: var(--text-muted); + font-size: 0.72em; + line-height: 1.4; + cursor: pointer; + flex-shrink: 0; +} + +.scv-change-action:hover { + background: var(--background-modifier-hover); + color: var(--text-normal); + border-color: var(--interactive-accent); +} + +.scv-change-action .svg-icon, +.scv-change-action-icon .svg-icon { width: 12px; height: 12px; } + +.scv-change-action-label { white-space: nowrap; } + +/* Phone: icon-only, matching the row's tighter width budget. */ +body.is-mobile .scv-change-action-label { display: none; } +body.is-mobile .scv-change-action { padding: 4px; min-width: 28px; min-height: 28px; justify-content: center; } + +/* ── Repository Changes row "⋯" menu ───────────────────────────────── */ +.scv-change-menu { + display: inline-flex; + align-items: center; + justify-content: center; + margin-left: auto; + padding: 2px; + background: transparent; + border: none; + border-radius: var(--radius-s); + color: var(--text-muted); + cursor: pointer; + flex-shrink: 0; +} + +.scv-change-menu:hover { + background: var(--background-modifier-hover); + color: var(--text-normal); +} + +.scv-change-menu .svg-icon { width: 14px; height: 14px; } + +body.is-mobile .scv-change-menu { min-width: 28px; min-height: 28px; } + .scv-change-rename-from { color: var(--text-faint); text-decoration: line-through; @@ -749,6 +809,8 @@ body.is-mobile .scv-view-toggle-label { display: none; } display: flex; flex-direction: column; min-height: 0; + min-width: 0; + max-width: 100%; } /* Let the diff fill all the space this full tab gives it, instead of the @@ -792,6 +854,8 @@ body.is-mobile .scv-view-toggle-label { display: none; } display: flex; flex-direction: column; height: 100%; + min-width: 0; + max-width: 100%; } .scv-detail-bar { @@ -891,11 +955,13 @@ body.is-mobile .scv-view-toggle-label { display: none; } .ssv-diff-grid { display: grid; - grid-template-columns: 1fr 1fr; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); font-family: var(--font-monospace); font-size: 0.74em; max-height: 260px; - overflow-y: auto; + overflow: auto; + min-width: 0; + max-width: 100%; } .ssv-diff-hd { @@ -1033,7 +1099,7 @@ body.is-mobile .scv-view-toggle-label { display: none; } display: flex; align-items: center; gap: 10px; - padding: 8px 12px calc(8px + env(safe-area-inset-bottom)); + padding: 8px 12px; background: var(--background-primary); border-top: 1px solid var(--background-modifier-border); z-index: 1; @@ -1420,6 +1486,10 @@ body.is-mobile .scv-view-toggle-label { display: none; } border-bottom: 1px solid var(--background-modifier-border); } + .batch-conflict-row:last-child { + border-bottom: none; + } + .batch-conflict-row-actions { justify-content: flex-end; flex-wrap: nowrap; @@ -1465,6 +1535,10 @@ body.is-mobile .gfs-conflict-modal--batch .batch-conflict-row-actions { border-radius: 0; border-bottom: 1px solid var(--background-modifier-border); } + + .batch-conflict-row:last-child { + border-bottom: none; + } } /* Phone < 700px: full stacking — radios drop under actions so they never @@ -1563,9 +1637,26 @@ body.is-mobile .gfs-conflict-modal--batch .batch-conflict-row-actions { flex-direction: column; } +.sync-plan-file-row { + display: flex; + align-items: center; + gap: 6px; +} + +.sync-plan-file-icon { + display: flex; + flex-shrink: 0; + color: var(--text-faint); +} + +.sync-plan-file-icon .svg-icon { width: 11px; height: 11px; } + +.sync-plan-section.is-destructive .sync-plan-file-icon { color: var(--text-error); } + .sync-plan-file-moved-from { color: var(--text-muted); font-size: 0.9em; + padding-left: 17px; } .sync-plan-buttons { diff --git a/tests/ci-workflow.test.ts b/tests/ci-workflow.test.ts index ce85aea..4d3975f 100644 --- a/tests/ci-workflow.test.ts +++ b/tests/ci-workflow.test.ts @@ -7,6 +7,7 @@ const harness = readFileSync('scripts/e2e-harness.sh', 'utf8'); const runner = readFileSync('scripts/run-e2e.sh', 'utf8'); const vitestE2eConfig = readFileSync('vitest.e2e.config.ts', 'utf8'); const eslintConfig = readFileSync('eslint.config.mts', 'utf8'); +const syncManagerE2eSuite = readFileSync('e2e-tests/provider/suites/sync-manager.e2e.test.ts', 'utf8'); describe('CI workflow contracts', () => { it('retries transient provider failures three times', () => { @@ -131,4 +132,29 @@ describe('E2E scanner-boundary contracts (e2e-tests/provider, no runtime generat expect(eslintConfig).toContain('"e2e-tests/**/*.ts"'); expect(eslintConfig).not.toContain('"e2e/**/*.ts"'); }); + + it('still blocks src/ imports of the removed legacy sync-status presentation layer', () => { + // Architecture regression guard for the SyncStatusView -> Source + // Control migration (see docs/source-control.md): a future refactor + // must not silently drop this no-restricted-imports rule and let + // ui/sync-status or SyncStatusView get re-wired back in. + expect(eslintConfig).toContain('"**/ui/sync-status"'); + expect(eslintConfig).toContain('"**/ui/sync-status/*"'); + expect(eslintConfig).toContain('"**/SyncStatusView"'); + expect(eslintConfig).toContain('"**/ui/SyncStatusView"'); + expect(eslintConfig).toContain('no-restricted-imports'); + }); + + it('exercises remote delete through the Source Control application path, not a direct provider bypass', () => { + // The remote-delete E2E used to call `service.deleteFile()` directly, + // reproducing what the removed SyncStatusView UI used to do. The + // current production path is SourceControlActionService.deleteRemote() + // -> SyncWorkspace.deleteRemote() -> RemoteDeleteExecutor -> + // gitService.deleteFile() -- a future edit must keep exercising that + // chain instead of quietly reverting to the raw provider call. + expect(syncManagerE2eSuite).not.toMatch(/\bservice\.deleteFile\(/); + expect(syncManagerE2eSuite).toContain('actionService.deleteRemote('); + expect(syncManagerE2eSuite).toContain("from '../../../src/logic/source-control/SourceControlActionService'"); + expect(syncManagerE2eSuite).toContain("from '../../../src/logic/sync/SyncWorkspace'"); + }); }); diff --git a/tests/logic/source-control/ChangeActionPolicy.test.ts b/tests/logic/source-control/ChangeActionPolicy.test.ts index f958a84..31cdb97 100644 --- a/tests/logic/source-control/ChangeActionPolicy.test.ts +++ b/tests/logic/source-control/ChangeActionPolicy.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { canDownload, defaultSyncAction } from '../../../src/logic/source-control/ChangeActionPolicy'; +import { + availableSyncActions, + canDownload, + defaultSyncAction, + resolveSyncAction, +} from '../../../src/logic/source-control/ChangeActionPolicy'; import type { SyncChangeKind } from '../../../src/logic/source-control/types'; describe('defaultSyncAction', () => { @@ -64,3 +69,57 @@ describe('canDownload', () => { expect(canDownload('synced')).toBe(false); }); }); + +describe('availableSyncActions', () => { + it('lists the default first', () => { + expect(availableSyncActions('local-modified')[0]).toBe(defaultSyncAction('local-modified')); + expect(availableSyncActions('remote-only')[0]).toBe(defaultSyncAction('remote-only')); + expect(availableSyncActions('local-deleted')[0]).toBe(defaultSyncAction('local-deleted')); + }); + + it('allows push and pull for local-modified (push local, or use remote instead)', () => { + expect(availableSyncActions('local-modified')).toEqual(['push', 'pull']); + }); + + it('allows pull and delete-remote for remote-only (download, or delete it remotely)', () => { + expect(availableSyncActions('remote-only')).toEqual(['pull', 'delete-remote']); + }); + + it('allows pull and push for remote-modified (use remote, or overwrite with local)', () => { + expect(availableSyncActions('remote-modified')).toEqual(['pull', 'push']); + }); + + it('allows delete-remote and pull for local-deleted (mirror the delete, or restore it)', () => { + expect(availableSyncActions('local-deleted')).toEqual(['delete-remote', 'pull']); + }); + + it('only allows the default for local-only, moved, conflict, and synced', () => { + expect(availableSyncActions('local-only')).toEqual(['push']); + expect(availableSyncActions('moved')).toEqual(['push']); + expect(availableSyncActions('conflict')).toEqual(['push']); + expect(availableSyncActions('synced')).toEqual(['push']); + }); +}); + +describe('resolveSyncAction', () => { + it('returns the default when no override is given', () => { + expect(resolveSyncAction('local-modified')).toBe('push'); + expect(resolveSyncAction('remote-only')).toBe('pull'); + }); + + it('honors a legal override', () => { + expect(resolveSyncAction('local-modified', 'pull')).toBe('pull'); + expect(resolveSyncAction('remote-only', 'delete-remote')).toBe('delete-remote'); + }); + + it('falls back to the default when the override is no longer legal for the kind', () => { + // e.g. stored override was 'pull' while the change was local-modified, + // then it became local-only (remote copy deleted) — 'pull' can't apply anymore. + expect(resolveSyncAction('local-only', 'pull')).toBe('push'); + }); + + it('falls back to the default for kinds that only allow their default', () => { + expect(resolveSyncAction('conflict', 'pull')).toBe('push'); + expect(resolveSyncAction('moved', 'pull')).toBe('push'); + }); +}); diff --git a/tests/logic/source-control/SourceControlActionService.test.ts b/tests/logic/source-control/SourceControlActionService.test.ts index a273733..60b8ac9 100644 --- a/tests/logic/source-control/SourceControlActionService.test.ts +++ b/tests/logic/source-control/SourceControlActionService.test.ts @@ -1,13 +1,19 @@ import { describe, expect, it, vi } from 'vitest'; import { ChangeRepository } from '../../../src/logic/source-control/ChangeRepository'; import { OperationState } from '../../../src/logic/source-control/OperationState'; -import { SourceControlActionService } from '../../../src/logic/source-control/SourceControlActionService'; +import { SyncSelectionStore } from '../../../src/logic/source-control/SyncSelectionStore'; +import { SourceControlActionService, type SyncIntentRequest } from '../../../src/logic/source-control/SourceControlActionService'; import type { SyncExecutionResult, SyncResultNotificationPort } from '../../../src/logic/source-control/SyncResultNotifier'; import type { PlannedPushBatch } from '../../../src/logic/sync/PushCoordinator'; -import { toChangeId, type SyncChange } from '../../../src/logic/source-control/types'; +import { toChangeId, type ChangeId, type SyncChange } from '../../../src/logic/source-control/types'; import type { SyncWorkspace } from '../../../src/logic/sync/SyncWorkspace'; import type { BatchPushConflict, PushResults, SyncPlan, SyncResult } from '../../../src/logic/sync/types'; +/** Builds plain sync() intents (no explicit action override) from change ids, for tests that don't exercise overrides. */ +function intents(...changeIds: ChangeId[]): SyncIntentRequest[] { + return changeIds.map(changeId => ({ changeId })); +} + const keepRemoteConflict = (path: string, remoteSha = 'reviewed'): BatchPushConflict => ({ path, name: path, @@ -88,9 +94,10 @@ function buildService( ) { const repository = new ChangeRepository(); repository.replace(changes); + const selection = new SyncSelectionStore(); const operations = new OperationState(); - const service = new SourceControlActionService(repository, operations, workspace, notifier); - return { service, operations, notifier }; + const service = new SourceControlActionService(repository, selection, operations, workspace, notifier); + return { service, selection, operations, notifier }; } describe('SourceControlActionService', () => { @@ -179,7 +186,7 @@ describe('SourceControlActionService', () => { { notify }, ); - await service.sync([toChangeId('c-1'), toChangeId('c-2')]); + await service.sync(intents(toChangeId('c-1'), toChangeId('c-2'))); expect(commitResolvedBatch).toHaveBeenCalledTimes(1); expect(commitResolvedBatch).toHaveBeenCalledWith( @@ -208,7 +215,7 @@ describe('SourceControlActionService', () => { { notify }, ); - await service.sync([toChangeId('c-1')]); + await service.sync(intents(toChangeId('c-1'))); expect(planPush).not.toHaveBeenCalled(); expect(commitResolvedBatch).not.toHaveBeenCalled(); @@ -240,7 +247,7 @@ describe('SourceControlActionService', () => { { notify }, ); - await service.sync([toChangeId('update'), toChangeId('delete'), toChangeId('download')]); + await service.sync(intents(toChangeId('update'), toChangeId('delete'), toChangeId('download'))); expect(commitResolvedBatch).toHaveBeenCalledTimes(1); expect(applyPull).toHaveBeenCalledWith(['remote.md'], { notify: false }); @@ -262,7 +269,7 @@ describe('SourceControlActionService', () => { { notify }, ); - await service.sync([toChangeId('update')]); + await service.sync(intents(toChangeId('update'))); expect(operations.get(toChangeId('update'))).toBe('failed'); expect(notify).toHaveBeenCalledTimes(1); @@ -278,7 +285,7 @@ describe('SourceControlActionService', () => { { notify }, ); - await expect(service.sync([toChangeId('c-1')])).resolves.toBeUndefined(); + await expect(service.sync(intents(toChangeId('c-1')))).resolves.toBeUndefined(); expect(operations.get(toChangeId('c-1'))).toBe('failed'); expect(notify).toHaveBeenCalledTimes(1); @@ -298,7 +305,7 @@ describe('SourceControlActionService', () => { { notify }, ); - await expect(service.sync([toChangeId('c-1')])).resolves.toBeUndefined(); + await expect(service.sync(intents(toChangeId('c-1')))).resolves.toBeUndefined(); expect(operations.get(toChangeId('c-1'))).toBe('failed'); expect(notify).toHaveBeenCalledTimes(1); @@ -323,7 +330,7 @@ describe('SourceControlActionService', () => { { notify }, ); - await service.sync([toChangeId('push'), toChangeId('pull')]); + await service.sync(intents(toChangeId('push'), toChangeId('pull'))); expect(commitResolvedBatch).toHaveBeenCalledTimes(1); expect(operations.get(toChangeId('push'))).toBe('success'); @@ -344,7 +351,7 @@ describe('SourceControlActionService', () => { fakeWorkspace({ planPush, confirmPlan, commitResolvedBatch }), ); - await service.sync([toChangeId('c-1')]); + await service.sync(intents(toChangeId('c-1'))); expect(confirmPlan).toHaveBeenCalledWith(expect.any(Object), 'sync'); expect(commitResolvedBatch).not.toHaveBeenCalled(); @@ -360,7 +367,7 @@ describe('SourceControlActionService', () => { fakeWorkspace({ planPush, confirmPlan, commitResolvedBatch }), ); - await service.sync([toChangeId('c-1')]); + await service.sync(intents(toChangeId('c-1'))); expect(confirmPlan).not.toHaveBeenCalled(); expect(commitResolvedBatch).not.toHaveBeenCalled(); @@ -383,7 +390,7 @@ describe('SourceControlActionService', () => { { notify }, ); - await service.sync([toChangeId('c-1')]); + await service.sync(intents(toChangeId('c-1'))); expect(commitResolvedBatch).toHaveBeenCalledTimes(1); expect(commitResolvedBatch).toHaveBeenCalledWith( @@ -425,7 +432,7 @@ describe('SourceControlActionService', () => { { notify }, ); - await service.sync([toChangeId('c-1'), toChangeId('c-2')]); + await service.sync(intents(toChangeId('c-1'), toChangeId('c-2'))); expect(operations.get(toChangeId('c-1'))).toBe('success'); expect(operations.get(toChangeId('c-2'))).toBe('failed'); @@ -436,6 +443,108 @@ describe('SourceControlActionService', () => { downloaded: 0, })); }); + + describe('explicit action overrides', () => { + it('honors an explicit pull override on a local-modified change (routes to pull, not the push default)', async () => { + const planPush = vi.fn(); + const planPull = vi.fn().mockResolvedValue(emptySyncPlan({ modifications: [{ path: 'a.md', name: 'a.md' }] })); + const applyPull = vi.fn().mockResolvedValue(emptySyncResult({ added: 0, updated: 1, success: 1 })); + const { service, operations } = buildService( + [{ id: toChangeId('c-1'), path: 'a.md', kind: 'local-modified' }], + fakeWorkspace({ planPush, planPull, applyPull }), + ); + + await service.sync([{ changeId: toChangeId('c-1'), action: 'pull' }]); + + expect(planPush).not.toHaveBeenCalled(); + expect(applyPull).toHaveBeenCalledWith(['a.md'], { notify: false }); + expect(operations.get(toChangeId('c-1'))).toBe('success'); + }); + + it('honors an explicit push override on a remote-modified change (routes to push, not the pull default)', async () => { + const planPush = vi.fn().mockResolvedValue(emptyPlannedBatch({ + reviewPlan: emptySyncPlan({ modifications: [{ path: 'a.md', name: 'a.md' }] }), + pushes: [{ path: 'a.md', name: 'a.md', repoPath: 'a.md', content: 'local wins', existingSha: 'sha-a' }], + })); + const planPull = vi.fn(); + const commitResolvedBatch = vi.fn().mockResolvedValue(undefined); + const { service, operations } = buildService( + [{ id: toChangeId('c-1'), path: 'a.md', kind: 'remote-modified' }], + fakeWorkspace({ planPush, planPull, commitResolvedBatch }), + ); + + await service.sync([{ changeId: toChangeId('c-1'), action: 'push' }]); + + expect(planPull).not.toHaveBeenCalled(); + expect(commitResolvedBatch).toHaveBeenCalledTimes(1); + expect(operations.get(toChangeId('c-1'))).toBe('success'); + }); + + it('honors an explicit pull override on a local-deleted change (restores instead of mirroring the delete)', async () => { + const planPush = vi.fn(); + const planPull = vi.fn().mockResolvedValue(emptySyncPlan({ additions: [{ path: 'gone.md', name: 'gone.md' }] })); + const applyPull = vi.fn().mockResolvedValue(emptySyncResult({ added: 1, success: 1 })); + const commitResolvedBatch = vi.fn().mockResolvedValue(undefined); + const { service, operations } = buildService( + [{ id: toChangeId('c-1'), path: 'gone.md', kind: 'local-deleted' }], + fakeWorkspace({ planPush, planPull, applyPull, commitResolvedBatch }), + ); + + await service.sync([{ changeId: toChangeId('c-1'), action: 'pull' }]); + + expect(commitResolvedBatch).not.toHaveBeenCalled(); + expect(applyPull).toHaveBeenCalledWith(['gone.md'], { notify: false }); + expect(operations.get(toChangeId('c-1'))).toBe('success'); + }); + + it('falls back to the default when the override is no longer legal for the change\'s current kind', async () => { + // Caller's snapshot said local-modified + pull override; by the + // time sync() runs the repository already reports local-only + // (remote copy gone) — 'pull' isn't legal there, so it should + // fall back to push instead of being silently dropped or throwing. + const planPush = vi.fn().mockResolvedValue(emptyPlannedBatch({ + reviewPlan: emptySyncPlan({ additions: [{ path: 'a.md', name: 'a.md' }] }), + pushes: [{ path: 'a.md', name: 'a.md', repoPath: 'a.md', content: 'local', existingSha: undefined }], + })); + const commitResolvedBatch = vi.fn().mockResolvedValue(undefined); + const { service, operations } = buildService( + [{ id: toChangeId('c-1'), path: 'a.md', kind: 'local-only' }], + fakeWorkspace({ planPush, commitResolvedBatch }), + ); + + await service.sync([{ changeId: toChangeId('c-1'), action: 'pull' }]); + + expect(commitResolvedBatch).toHaveBeenCalledTimes(1); + expect(operations.get(toChangeId('c-1'))).toBe('success'); + }); + + it('merges mixed default and overridden intents into one plan and one remote commit', async () => { + const commitResolvedBatch = vi.fn().mockResolvedValue(undefined); + const planPush = vi.fn().mockResolvedValue(emptyPlannedBatch({ + reviewPlan: emptySyncPlan({ modifications: [{ path: 'push-me.md', name: 'push-me.md' }] }), + pushes: [{ path: 'push-me.md', name: 'push-me.md', repoPath: 'push-me.md', content: 'x', existingSha: 'sha' }], + })); + const planPull = vi.fn().mockResolvedValue(emptySyncPlan({ modifications: [{ path: 'pull-me.md', name: 'pull-me.md' }] })); + const applyPull = vi.fn().mockResolvedValue(emptySyncResult({ updated: 1, success: 1 })); + const { service, operations } = buildService( + [ + { id: toChangeId('push-default'), path: 'push-me.md', kind: 'local-modified' }, + { id: toChangeId('pull-override'), path: 'pull-me.md', kind: 'local-modified' }, + ], + fakeWorkspace({ planPush, planPull, applyPull, commitResolvedBatch }), + ); + + await service.sync([ + { changeId: toChangeId('push-default') }, + { changeId: toChangeId('pull-override'), action: 'pull' }, + ]); + + expect(commitResolvedBatch).toHaveBeenCalledTimes(1); + expect(applyPull).toHaveBeenCalledWith(['pull-me.md'], { notify: false }); + expect(operations.get(toChangeId('push-default'))).toBe('success'); + expect(operations.get(toChangeId('pull-override'))).toBe('success'); + }); + }); }); describe('pull', () => { @@ -658,6 +767,8 @@ describe('SourceControlActionService', () => { kind: 'local-modified', isSelectedForSync: false, operationStatus: 'idle', + syncAction: 'push', + hasActionOverride: false, }); expect(getDiff).toHaveBeenCalledWith('a.md'); @@ -682,9 +793,75 @@ describe('SourceControlActionService', () => { kind: 'local-modified', isSelectedForSync: false, operationStatus: 'idle', + syncAction: 'push', + hasActionOverride: false, }); expect(content).toBeNull(); }); }); + + describe('selection mutation', () => { + it('selectForSync / deselectFromSync toggle one change through SyncSelectionStore', () => { + const { service, selection } = buildService( + [{ id: toChangeId('c-1'), path: 'a.md', kind: 'local-only' }], + fakeWorkspace(), + ); + + service.selectForSync(toChangeId('c-1')); + expect(selection.isIncluded(toChangeId('c-1'))).toBe(true); + + service.deselectFromSync(toChangeId('c-1')); + expect(selection.isIncluded(toChangeId('c-1'))).toBe(false); + }); + + it('selectMany / deselectMany toggle a batch through SyncSelectionStore', () => { + const { service, selection } = buildService( + [ + { id: toChangeId('c-1'), path: 'a.md', kind: 'local-only' }, + { id: toChangeId('c-2'), path: 'b.md', kind: 'local-only' }, + ], + fakeWorkspace(), + ); + + service.selectMany([toChangeId('c-1'), toChangeId('c-2')]); + expect(selection.getSelectedChangeIds()).toEqual([toChangeId('c-1'), toChangeId('c-2')]); + + service.deselectMany([toChangeId('c-1'), toChangeId('c-2')]); + expect(selection.getSelectedChangeIds()).toEqual([]); + }); + + it('setSyncAction stores a non-default override, and clears it once it matches the kind default', () => { + const { service, selection } = buildService( + [{ id: toChangeId('c-1'), path: 'a.md', kind: 'local-modified' }], + fakeWorkspace(), + ); + + service.setSyncAction(toChangeId('c-1'), 'pull'); + expect(selection.getActionOverride(toChangeId('c-1'))).toBe('pull'); + + // 'push' is local-modified's own default, so setting it back clears the override. + service.setSyncAction(toChangeId('c-1'), 'push'); + expect(selection.getActionOverride(toChangeId('c-1'))).toBeUndefined(); + }); + + it('clearSyncAction removes an explicit override', () => { + const { service, selection } = buildService( + [{ id: toChangeId('c-1'), path: 'a.md', kind: 'local-modified' }], + fakeWorkspace(), + ); + + selection.setActionOverride(toChangeId('c-1'), 'pull'); + service.clearSyncAction(toChangeId('c-1')); + + expect(selection.getActionOverride(toChangeId('c-1'))).toBeUndefined(); + }); + + it('setSyncAction on a stale (already-removed) change id is a no-op, not a throw', () => { + const { service, selection } = buildService([], fakeWorkspace()); + + expect(() => service.setSyncAction(toChangeId('gone'), 'pull')).not.toThrow(); + expect(selection.getActionOverride(toChangeId('gone'))).toBeUndefined(); + }); + }); }); diff --git a/tests/logic/source-control/SourceControlViewModel.test.ts b/tests/logic/source-control/SourceControlViewModel.test.ts index cb10358..f41f162 100644 --- a/tests/logic/source-control/SourceControlViewModel.test.ts +++ b/tests/logic/source-control/SourceControlViewModel.test.ts @@ -198,4 +198,72 @@ describe('SourceControlViewModel', () => { await expect(viewModel.refresh()).rejects.toThrow('boom'); expect(refreshState.get()).toBe('failed'); }); + + describe('syncAction projection', () => { + it('defaults syncAction to the kind default with no override', () => { + const { viewModel } = buildViewModel([{ id: toChangeId('c-1'), path: 'a.md', kind: 'local-modified' }]); + + const item = viewModel.getState('all').items[0]; + expect(item?.syncAction).toBe('push'); + expect(item?.hasActionOverride).toBe(false); + }); + + it('resolves syncAction to a legal override and marks hasActionOverride', () => { + const { viewModel, selection } = buildViewModel([{ id: toChangeId('c-1'), path: 'a.md', kind: 'local-modified' }]); + selection.setActionOverride(toChangeId('c-1'), 'pull'); + + const item = viewModel.getState('all').items[0]; + expect(item?.syncAction).toBe('pull'); + expect(item?.hasActionOverride).toBe(true); + }); + + it('falls back to the default once a stale override is no longer legal for the current kind', () => { + // Reconciling a stale override against a ChangeRepository replacement is + // wired by createSyncRuntime, not by SourceControlViewModel (see + // tests/runtime/createSyncRuntime.test.ts). This only verifies the + // ViewModel's own projection once SyncSelectionStore has already + // dropped the override. + const { viewModel, selection } = buildViewModel([{ id: toChangeId('c-1'), path: 'a.md', kind: 'local-only' }]); + selection.setActionOverride(toChangeId('c-1'), 'pull'); + selection.reconcile([{ id: toChangeId('c-1'), path: 'a.md', kind: 'local-only' }]); + + const item = viewModel.getState('all').items[0]; + expect(item?.syncAction).toBe('push'); + expect(item?.hasActionOverride).toBe(false); + expect(selection.getActionOverride(toChangeId('c-1'))).toBeUndefined(); + }); + }); + + describe('getItem', () => { + it('projects a single change by id, independent of any filter', () => { + const synced: SyncChange = { id: toChangeId('c-1'), path: 'a.md', kind: 'synced' }; + const { viewModel, operations } = buildViewModel([synced]); + operations.start(toChangeId('c-1')); + + // 'synced' kind is excluded from getState('all')/('changes'), but + // getItem() is not a filtered view -- it's the single projection + // path any caller can use to resolve one row directly by id. + const item = viewModel.getItem(toChangeId('c-1')); + expect(item?.id).toBe(toChangeId('c-1')); + expect(item?.kind).toBe('synced'); + expect(item?.operationStatus).toBe('running'); + }); + + it('returns undefined once the change is no longer in the repository', () => { + const { viewModel } = buildViewModel([{ id: toChangeId('c-1'), path: 'a.md', kind: 'local-only' }]); + + expect(viewModel.getItem(toChangeId('gone'))).toBeUndefined(); + }); + + it('reflects selection and syncAction override state, same as getState()', () => { + const { viewModel, selection } = buildViewModel([{ id: toChangeId('c-1'), path: 'a.md', kind: 'local-modified' }]); + selection.selectForSync(toChangeId('c-1')); + selection.setActionOverride(toChangeId('c-1'), 'pull'); + + const item = viewModel.getItem(toChangeId('c-1')); + expect(item?.isSelectedForSync).toBe(true); + expect(item?.syncAction).toBe('pull'); + expect(item?.hasActionOverride).toBe(true); + }); + }); }); diff --git a/tests/logic/source-control/SyncSelectionStore.test.ts b/tests/logic/source-control/SyncSelectionStore.test.ts index 9b671bf..39f2fce 100644 --- a/tests/logic/source-control/SyncSelectionStore.test.ts +++ b/tests/logic/source-control/SyncSelectionStore.test.ts @@ -96,4 +96,74 @@ describe('SyncSelectionStore', () => { expect(store.getSelectedChangeIds()).toEqual([toChangeId('change-a')]); }); }); + + describe('action overrides', () => { + it('has no override by default', () => { + const store = new SyncSelectionStore(); + store.selectForSync(toChangeId('change-a')); + + expect(store.getActionOverride(toChangeId('change-a'))).toBeUndefined(); + }); + + it('records and returns an explicit override', () => { + const store = new SyncSelectionStore(); + store.selectForSync(toChangeId('change-a')); + + store.setActionOverride(toChangeId('change-a'), 'pull'); + + expect(store.getActionOverride(toChangeId('change-a'))).toBe('pull'); + }); + + it('clearActionOverride reverts to no override', () => { + const store = new SyncSelectionStore(); + store.selectForSync(toChangeId('change-a')); + store.setActionOverride(toChangeId('change-a'), 'pull'); + + store.clearActionOverride(toChangeId('change-a')); + + expect(store.getActionOverride(toChangeId('change-a'))).toBeUndefined(); + }); + + it('deselectFromSync clears the override along with the selection', () => { + const store = new SyncSelectionStore(); + store.selectForSync(toChangeId('change-a')); + store.setActionOverride(toChangeId('change-a'), 'pull'); + + store.deselectFromSync(toChangeId('change-a')); + + expect(store.getActionOverride(toChangeId('change-a'))).toBeUndefined(); + }); + + it('deselectMany clears overrides for all deselected ids', () => { + const store = new SyncSelectionStore(); + store.selectMany([toChangeId('change-a'), toChangeId('change-b')]); + store.setActionOverride(toChangeId('change-a'), 'pull'); + store.setActionOverride(toChangeId('change-b'), 'delete-remote'); + + store.deselectMany([toChangeId('change-a'), toChangeId('change-b')]); + + expect(store.getActionOverride(toChangeId('change-a'))).toBeUndefined(); + expect(store.getActionOverride(toChangeId('change-b'))).toBeUndefined(); + }); + + it('refresh clears the override for a change id that is no longer present', () => { + const store = new SyncSelectionStore(); + store.selectForSync(toChangeId('change-a')); + store.setActionOverride(toChangeId('change-a'), 'pull'); + + store.refresh([]); + + expect(store.getActionOverride(toChangeId('change-a'))).toBeUndefined(); + }); + + it('refresh keeps the override for a change id that is still present', () => { + const store = new SyncSelectionStore(); + store.selectForSync(toChangeId('change-a')); + store.setActionOverride(toChangeId('change-a'), 'pull'); + + store.refresh([toChangeId('change-a')]); + + expect(store.getActionOverride(toChangeId('change-a'))).toBe('pull'); + }); + }); }); diff --git a/tests/logic/source-control/SyncSelectionStoreReconcile.test.ts b/tests/logic/source-control/SyncSelectionStoreReconcile.test.ts new file mode 100644 index 0000000..6027946 --- /dev/null +++ b/tests/logic/source-control/SyncSelectionStoreReconcile.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { SyncSelectionStore } from '../../../src/logic/source-control/SyncSelectionStore'; +import { toChangeId, type SyncChange } from '../../../src/logic/source-control/types'; + +describe('SyncSelectionStore.reconcile', () => { + it('clears an action override that is no longer legal for the current change kind', () => { + const store = new SyncSelectionStore(); + const id = toChangeId('change-a'); + store.selectForSync(id); + store.setActionOverride(id, 'pull'); + + const current: SyncChange[] = [{ id, path: 'a.md', kind: 'local-only' }]; + store.reconcile(current); + + expect(store.isIncluded(id)).toBe(true); + expect(store.getActionOverride(id)).toBeUndefined(); + }); + + it('keeps a still-legal explicit override', () => { + const store = new SyncSelectionStore(); + const id = toChangeId('change-a'); + store.selectForSync(id); + store.setActionOverride(id, 'pull'); + + const current: SyncChange[] = [{ id, path: 'a.md', kind: 'local-modified' }]; + store.reconcile(current); + + expect(store.getActionOverride(id)).toBe('pull'); + }); + + it('drops both selection and override when the change disappeared', () => { + const store = new SyncSelectionStore(); + const id = toChangeId('change-a'); + store.selectForSync(id); + store.setActionOverride(id, 'pull'); + + store.reconcile([]); + + expect(store.isIncluded(id)).toBe(false); + expect(store.getActionOverride(id)).toBeUndefined(); + }); +}); diff --git a/tests/logic/sync/DiffStat.test.ts b/tests/logic/sync/DiffStat.test.ts new file mode 100644 index 0000000..088dee6 --- /dev/null +++ b/tests/logic/sync/DiffStat.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; +import { addedContentStat, cheapLocalStat, computeDiffStat, deletedContentStat } from '../../../src/logic/sync/DiffStat'; + +describe('computeDiffStat', () => { + it('counts additions and deletions from a two-sided diff', () => { + const remote = 'line1\nline2\nline3'; + const local = 'line1\nchanged\nline3\nline4'; + const stat = computeDiffStat(remote, local); + expect(stat.additions).toBe(2); + expect(stat.deletions).toBe(1); + }); + + it('reports zero for identical content', () => { + const stat = computeDiffStat('a\nb', 'a\nb'); + expect(stat).toEqual({ additions: 0, deletions: 0 }); + }); + + it('treats a pure addition as additions only', () => { + const stat = computeDiffStat('a', 'a\nb'); + expect(stat).toEqual({ additions: 1, deletions: 0 }); + }); + + it('treats a pure deletion as deletions only', () => { + const stat = computeDiffStat('a\nb', 'a'); + expect(stat).toEqual({ additions: 0, deletions: 1 }); + }); +}); + +describe('cheapLocalStat', () => { + it('counts local lines as additions with no deletions', () => { + expect(cheapLocalStat('a\nb\nc')).toEqual({ additions: 3, deletions: 0 }); + }); + + it('reports zero for empty content', () => { + expect(cheapLocalStat('')).toEqual({ additions: 0, deletions: 0 }); + }); + + it('does not count a trailing newline as a phantom line', () => { + expect(cheapLocalStat('a\nb\n')).toEqual({ additions: 2, deletions: 0 }); + }); + + it('normalizes CRLF line endings', () => { + expect(cheapLocalStat('a\r\nb\r\nc')).toEqual({ additions: 3, deletions: 0 }); + }); +}); + +describe('addedContentStat', () => { + it('counts every line as an addition for a one-sided +N change', () => { + expect(addedContentStat('line1\nline2')).toEqual({ additions: 2, deletions: 0 }); + }); + + it('reports zero for empty content', () => { + expect(addedContentStat('')).toEqual({ additions: 0, deletions: 0 }); + }); + + it('does not count a trailing newline as a phantom line', () => { + expect(addedContentStat('line1\nline2\n')).toEqual({ additions: 2, deletions: 0 }); + }); +}); + +describe('deletedContentStat', () => { + it('counts every line as a deletion for a one-sided -N change', () => { + expect(deletedContentStat('line1\nline2')).toEqual({ additions: 0, deletions: 2 }); + }); + + it('reports zero for empty content', () => { + expect(deletedContentStat('')).toEqual({ additions: 0, deletions: 0 }); + }); + + it('does not count a trailing newline as a phantom line', () => { + expect(deletedContentStat('line1\nline2\n')).toEqual({ additions: 0, deletions: 2 }); + }); +}); diff --git a/tests/logic/sync/PullCoordinator.test.ts b/tests/logic/sync/PullCoordinator.test.ts new file mode 100644 index 0000000..515e37b --- /dev/null +++ b/tests/logic/sync/PullCoordinator.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it, vi } from 'vitest'; +import { PullCoordinator, type PullCoordinatorDependencies } from '../../../src/logic/sync/PullCoordinator'; +import { gitBlobSha } from '../../../src/utils/git-blob-sha'; +import type { GitFile } from '../../../src/services/git-service-interface'; +import type { GitLabFilesPushSettings } from '../../../src/settings'; + +function buildDependencies(overrides: Partial = {}): PullCoordinatorDependencies { + const settings = { + serviceType: 'gitlab', + syncMetadata: {}, + branch: 'main', + vaultFolder: '', + rootPath: '', + } as unknown as GitLabFilesPushSettings; + + return { + gitService: () => ({}) as never, + settings, + scanner: { + fileInfo: (fileOrPath: string) => ({ path: fileOrPath, name: fileOrPath, isString: true }), + toRepoPath: (path: string) => path, + toTreePath: (path: string) => path, + pathExists: vi.fn().mockResolvedValue(false), + indexedFileExists: vi.fn().mockReturnValue(false), + readContent: vi.fn().mockResolvedValue(''), + } as unknown as PullCoordinatorDependencies['scanner'], + executor: { pull: vi.fn().mockResolvedValue(undefined) } as unknown as PullCoordinatorDependencies['executor'], + confirmPlan: vi.fn().mockResolvedValue(true), + updateMetadata: vi.fn().mockResolvedValue(undefined), + migrateBaseline: vi.fn().mockResolvedValue(undefined), + saveSettings: vi.fn().mockResolvedValue(undefined), + notify: vi.fn(), + serviceName: () => 'GitLab', + ...overrides, + }; +} + +describe('PullCoordinator.planSingleFile', () => { + it('plans an addition when the file does not exist locally', async () => { + const deps = buildDependencies(); + const coordinator = new PullCoordinator(deps); + const remote: GitFile = { content: 'remote content', sha: 'remote-sha' }; + + const decision = await coordinator.planSingleFile('new.md', remote); + + expect(decision.action).toBe('pull-create'); + }); + + it('plans none (already up to date) once local content and baseline match the remote blob', async () => { + const localContent = 'same content'; + const sha = await gitBlobSha(localContent); + const deps = buildDependencies({ + settings: { + serviceType: 'gitlab', + syncMetadata: { 'a.md': { lastSyncedSha: sha, lastSyncedAt: 0 } }, + branch: 'main', + vaultFolder: '', + rootPath: '', + } as unknown as GitLabFilesPushSettings, + scanner: { + fileInfo: (fileOrPath: string) => ({ path: fileOrPath, name: fileOrPath, isString: true }), + toRepoPath: (path: string) => path, + toTreePath: (path: string) => path, + pathExists: vi.fn().mockResolvedValue(true), + indexedFileExists: vi.fn().mockReturnValue(true), + readContent: vi.fn().mockResolvedValue(localContent), + } as unknown as PullCoordinatorDependencies['scanner'], + }); + const coordinator = new PullCoordinator(deps); + const remote: GitFile = { content: localContent, sha }; + + const decision = await coordinator.planSingleFile('a.md', remote); + + expect(decision.action).toBe('none'); + }); + + it('resolves a legacy GitLab baseline keyed by revision, the same correction SyncManager.pullFile() used to duplicate', async () => { + // Old GitLab metadata stored the file's `revision` (last_commit_id) as + // lastSyncedSha rather than a blob sha. When the current remote fetch's + // revision still matches that stored value, the true baseline blob is + // the remote's own current sha -- so an unmodified file classifies as + // 'none', not a false-positive conflict/modification. + const content = 'unchanged content'; + const sha = await gitBlobSha(content); + const legacyRevision = 'legacy-commit-id'; + const deps = buildDependencies({ + settings: { + serviceType: 'gitlab', + syncMetadata: { 'a.md': { lastSyncedSha: legacyRevision, lastSyncedAt: 0 } }, + branch: 'main', + vaultFolder: '', + rootPath: '', + } as unknown as GitLabFilesPushSettings, + scanner: { + fileInfo: (fileOrPath: string) => ({ path: fileOrPath, name: fileOrPath, isString: true }), + toRepoPath: (path: string) => path, + toTreePath: (path: string) => path, + pathExists: vi.fn().mockResolvedValue(true), + indexedFileExists: vi.fn().mockReturnValue(true), + readContent: vi.fn().mockResolvedValue(content), + } as unknown as PullCoordinatorDependencies['scanner'], + }); + const coordinator = new PullCoordinator(deps); + const remote: GitFile = { content, sha, revision: legacyRevision }; + + const decision = await coordinator.planSingleFile('a.md', remote); + + expect(decision.action).toBe('none'); + }); + + it('plans resolve-conflict when both sides changed since the baseline', async () => { + const deps = buildDependencies({ + settings: { + serviceType: 'gitlab', + syncMetadata: { 'a.md': { lastSyncedSha: 'base-sha', lastSyncedAt: 0 } }, + branch: 'main', + vaultFolder: '', + rootPath: '', + } as unknown as GitLabFilesPushSettings, + scanner: { + fileInfo: (fileOrPath: string) => ({ path: fileOrPath, name: fileOrPath, isString: true }), + toRepoPath: (path: string) => path, + toTreePath: (path: string) => path, + pathExists: vi.fn().mockResolvedValue(true), + indexedFileExists: vi.fn().mockReturnValue(true), + readContent: vi.fn().mockResolvedValue('local edit'), + } as unknown as PullCoordinatorDependencies['scanner'], + }); + const coordinator = new PullCoordinator(deps); + const remote: GitFile = { content: 'remote edit', sha: 'remote-sha' }; + + const decision = await coordinator.planSingleFile('a.md', remote); + + expect(decision.action).toBe('resolve-conflict'); + }); +}); diff --git a/tests/logic/sync/RenameReconciler.test.ts b/tests/logic/sync/RenameReconciler.test.ts new file mode 100644 index 0000000..c8d0252 --- /dev/null +++ b/tests/logic/sync/RenameReconciler.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it, vi } from 'vitest'; +import { RenameReconciler } from '../../../src/logic/sync/RenameReconciler'; +import type { RenameReconcilerDependencies } from '../../../src/logic/sync/RenameReconciler'; +import { SyncStatusService } from '../../../src/logic/sync-status-service'; +import { gitBlobSha } from '../../../src/utils/git-blob-sha'; + +function buildReconciler(statuses: SyncStatusService, deps: Partial = {}): { + reconciler: RenameReconciler; + trackRename: ReturnType; + refreshFileStatus: ReturnType; +} { + const trackRename = vi.fn().mockResolvedValue(undefined); + const refreshFileStatus = vi.fn().mockResolvedValue(undefined); + const base: RenameReconcilerDependencies = { + settings: () => ({ syncMetadata: {} }) as never, + syncManager: () => ({ trackRename }) as never, + refreshFileStatus, + }; + return { reconciler: new RenameReconciler({ ...base, ...deps }, statuses), trackRename, refreshFileStatus }; +} + +describe('RenameReconciler', () => { + describe('reconcileOutOfBandMoves', () => { + it('tracks a rename when exactly one orphaned tracked path matches exactly one unsynced local file by blob sha', async () => { + const statuses = new SyncStatusService(); + const content = 'moved content'; + const sha = await gitBlobSha(content); + statuses.set({ path: 'old.md', status: 'local-deleted' }); + statuses.set({ path: 'new.md', status: 'unsynced', localContent: content }); + const remoteMap = new Map([ + ['old.md', { path: 'old.md', sha, symlink: false }], + ]); + const { reconciler, trackRename, refreshFileStatus } = buildReconciler(statuses, { + settings: () => ({ syncMetadata: { 'old.md': { lastSyncedSha: sha, lastSyncedAt: 1 } } }) as never, + }); + + await reconciler.reconcileOutOfBandMoves(remoteMap); + + expect(trackRename).toHaveBeenCalledWith('new.md', 'old.md'); + expect(statuses.has('old.md')).toBe(false); + expect(refreshFileStatus).toHaveBeenCalledWith('new.md', undefined); + }); + + it('does nothing when a sha has more than one orphaned candidate (ambiguous match)', async () => { + const statuses = new SyncStatusService(); + const content = 'moved content'; + const sha = await gitBlobSha(content); + statuses.set({ path: 'old-a.md', status: 'local-deleted' }); + statuses.set({ path: 'old-b.md', status: 'local-deleted' }); + statuses.set({ path: 'new.md', status: 'unsynced', localContent: content }); + const remoteMap = new Map([ + ['old-a.md', { path: 'old-a.md', sha, symlink: false }], + ['old-b.md', { path: 'old-b.md', sha, symlink: false }], + ]); + const { reconciler, trackRename } = buildReconciler(statuses, { + settings: () => ({ + syncMetadata: { + 'old-a.md': { lastSyncedSha: sha, lastSyncedAt: 1 }, + 'old-b.md': { lastSyncedSha: sha, lastSyncedAt: 1 }, + }, + }) as never, + }); + + await reconciler.reconcileOutOfBandMoves(remoteMap); + + expect(trackRename).not.toHaveBeenCalled(); + }); + + it('ignores an orphan candidate that is itself a pending move source (renamedFrom set)', async () => { + const statuses = new SyncStatusService(); + const content = 'content'; + const sha = await gitBlobSha(content); + statuses.set({ path: 'old.md', status: 'local-deleted' }); + statuses.set({ path: 'new.md', status: 'unsynced', localContent: content }); + const remoteMap = new Map([ + ['old.md', { path: 'old.md', sha, symlink: false }], + ]); + const { reconciler, trackRename } = buildReconciler(statuses, { + settings: () => ({ + syncMetadata: { 'old.md': { lastSyncedSha: sha, lastSyncedAt: 1, renamedFrom: 'older.md' } }, + }) as never, + }); + + await reconciler.reconcileOutOfBandMoves(remoteMap); + + expect(trackRename).not.toHaveBeenCalled(); + }); + }); + + describe('pendingMoveOldPaths', () => { + it('collects every renamedFrom source path currently on record', () => { + const statuses = new SyncStatusService(); + const { reconciler } = buildReconciler(statuses, { + settings: () => ({ + syncMetadata: { + 'a.md': { lastSyncedSha: 'x', lastSyncedAt: 1, renamedFrom: 'a-old.md' }, + 'b.md': { lastSyncedSha: 'y', lastSyncedAt: 1 }, + }, + }) as never, + }); + + expect(reconciler.pendingMoveOldPaths()).toEqual(new Set(['a-old.md'])); + }); + }); +}); diff --git a/tests/logic/sync/SyncFileDiscovery.test.ts b/tests/logic/sync/SyncFileDiscovery.test.ts new file mode 100644 index 0000000..59b7499 --- /dev/null +++ b/tests/logic/sync/SyncFileDiscovery.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it, vi } from 'vitest'; +import { TFile } from 'obsidian'; +import { SyncFileDiscovery } from '../../../src/logic/sync/SyncFileDiscovery'; +import type { SyncFileDiscoveryDependencies } from '../../../src/logic/sync/SyncFileDiscovery'; +import { SyncStatusService } from '../../../src/logic/sync-status-service'; + +vi.mock('obsidian'); + +function buildDiscovery(statuses: SyncStatusService, deps: Partial = {}): SyncFileDiscovery { + const base: SyncFileDiscoveryDependencies = { + app: { + vault: { + adapter: { stat: vi.fn().mockResolvedValue(null) }, + getAbstractFileByPath: vi.fn().mockReturnValue(null), + }, + } as never, + settings: () => ({ syncMetadata: {}, vaultFolder: '', rootPath: '' }) as never, + gitService: () => ({}) as never, + gitignoreManager: () => ({ isIgnored: () => false }) as never, + filterFilesByVaultFolder: files => files, + filterPathByVaultFolder: () => true, + getNormalizedPath: path => path, + getVaultPath: path => path, + }; + return new SyncFileDiscovery({ ...base, ...deps }, statuses); +} + +describe('SyncFileDiscovery', () => { + describe('identifyExtraFiles local-deleted classification', () => { + it('classifies a previously-tracked removed file as local-deleted', async () => { + const statuses = new SyncStatusService(); + const remoteMap = new Map([['note.md', { path: 'note.md', sha: 'abc', symlink: false }]]); + const discovery = buildDiscovery(statuses, { + settings: () => ({ + syncMetadata: { 'note.md': { sha: 'abc', lastSyncedAt: 1, renamedFrom: undefined } }, + vaultFolder: '', + rootPath: '', + }) as never, + }); + + await discovery.identifyExtraFiles(remoteMap, new Set(), new Map()); + + expect(statuses.get('note.md')?.status).toBe('local-deleted'); + }); + + it('classifies a never-tracked remote-only file as remote-only', async () => { + const statuses = new SyncStatusService(); + const remoteMap = new Map([['remote.md', { path: 'remote.md', sha: 'abc', symlink: false }]]); + const discovery = buildDiscovery(statuses, { + settings: () => ({ syncMetadata: {}, vaultFolder: '', rootPath: '' }) as never, + }); + + await discovery.identifyExtraFiles(remoteMap, new Set(), new Map()); + + expect(statuses.get('remote.md')?.status).toBe('remote-only'); + }); + + it('treats a path with a pending rename (renamedFrom) as remote-only, not local-deleted', async () => { + const statuses = new SyncStatusService(); + const remoteMap = new Map([['note.md', { path: 'note.md', sha: 'abc', symlink: false }]]); + const discovery = buildDiscovery(statuses, { + settings: () => ({ + syncMetadata: { 'note.md': { sha: 'abc', lastSyncedAt: 1, renamedFrom: 'old.md' } }, + vaultFolder: '', + rootPath: '', + }) as never, + }); + + await discovery.identifyExtraFiles(remoteMap, new Set(), new Map()); + + expect(statuses.get('note.md')?.status).toBe('remote-only'); + }); + + it('leaves an in-scope local file alone (returned as an extra candidate, not classified)', async () => { + const statuses = new SyncStatusService(); + const file = new TFile(); + file.path = 'note.md'; + const remoteMap = new Map([['note.md', { path: 'note.md', sha: 'abc', symlink: false }]]); + const discovery = buildDiscovery(statuses); + + const extra = await discovery.identifyExtraFiles(remoteMap, new Set(), new Map([['note.md', file]])); + + expect(extra).toEqual([file]); + expect(statuses.has('note.md')).toBe(false); + }); + }); + + describe('discoverFiles', () => { + it('excludes gitignored local and remote paths and normalizes remote paths under rootPath', async () => { + const statuses = new SyncStatusService(); + const localFile = new TFile(); + localFile.path = 'keep.md'; + const ignoredFile = new TFile(); + ignoredFile.path = 'ignored.md'; + const discovery = buildDiscovery(statuses, { + app: { + vault: { + getFiles: () => [localFile, ignoredFile], + adapter: { list: vi.fn().mockRejectedValue(new Error('no raw listing')) }, + }, + } as never, + settings: () => ({ syncMetadata: {}, vaultFolder: '', rootPath: 'vault' }) as never, + gitService: () => ({ + listFilesDetailed: vi.fn().mockResolvedValue([ + { path: 'vault/keep.md', sha: 'a', symlink: false }, + { path: 'other/outside.md', sha: 'b', symlink: false }, + ]), + }) as never, + gitignoreManager: () => ({ + loadGitignores: vi.fn().mockResolvedValue(undefined), + isIgnored: (path: string) => path === 'ignored.md', + }) as never, + filterFilesByVaultFolder: files => files, + }); + + const result = await discovery.discoverFiles(); + + expect(result.local.map(f => f.path)).toEqual(['keep.md']); + expect(result.remoteMap.has('keep.md')).toBe(true); + // Remote path outside rootPath is dropped entirely (getNormalizedRemotePath -> null). + expect(result.remoteMap.size).toBe(1); + }); + }); +}); diff --git a/tests/logic/sync/SyncStatusRefreshService.test.ts b/tests/logic/sync/SyncStatusRefreshService.test.ts index a4dcd95..7fdd934 100644 --- a/tests/logic/sync/SyncStatusRefreshService.test.ts +++ b/tests/logic/sync/SyncStatusRefreshService.test.ts @@ -378,50 +378,4 @@ describe('SyncStatusRefreshService local-change handlers', () => { expect(statuses.get('note.md')?.status).toBe('remote-modified'); }); }); - - describe('identifyExtraFiles local-deleted classification', () => { - it('classifies a previously-tracked removed file as local-deleted', async () => { - const statuses = new SyncStatusService(); - const remoteMap = new Map([['note.md', { path: 'note.md', sha: 'abc', symlink: false }]]); - const service = buildService(statuses, { - settings: () => ({ - syncMetadata: { 'note.md': { sha: 'abc', lastSyncedAt: 1, renamedFrom: undefined } }, - vaultFolder: '', - rootPath: '', - }) as never, - }); - - await service.identifyExtraFiles(remoteMap, new Set(), new Map()); - - expect(statuses.get('note.md')?.status).toBe('local-deleted'); - }); - - it('classifies a never-tracked remote-only file as remote-only', async () => { - const statuses = new SyncStatusService(); - const remoteMap = new Map([['remote.md', { path: 'remote.md', sha: 'abc', symlink: false }]]); - const service = buildService(statuses, { - settings: () => ({ syncMetadata: {}, vaultFolder: '', rootPath: '' }) as never, - }); - - await service.identifyExtraFiles(remoteMap, new Set(), new Map()); - - expect(statuses.get('remote.md')?.status).toBe('remote-only'); - }); - - it('treats a path with a pending rename (renamedFrom) as remote-only, not local-deleted', async () => { - const statuses = new SyncStatusService(); - const remoteMap = new Map([['note.md', { path: 'note.md', sha: 'abc', symlink: false }]]); - const service = buildService(statuses, { - settings: () => ({ - syncMetadata: { 'note.md': { sha: 'abc', lastSyncedAt: 1, renamedFrom: 'old.md' } }, - vaultFolder: '', - rootPath: '', - }) as never, - }); - - await service.identifyExtraFiles(remoteMap, new Set(), new Map()); - - expect(statuses.get('note.md')?.status).toBe('remote-only'); - }); - }); }); \ No newline at end of file diff --git a/tests/logic/sync/SyncStatusResolver.test.ts b/tests/logic/sync/SyncStatusResolver.test.ts new file mode 100644 index 0000000..8a8efdb --- /dev/null +++ b/tests/logic/sync/SyncStatusResolver.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it, vi } from 'vitest'; +import { TFile } from 'obsidian'; +import { SyncStatusResolver } from '../../../src/logic/sync/SyncStatusResolver'; +import type { SyncStatusResolverDependencies } from '../../../src/logic/sync/SyncStatusResolver'; +import { SyncStatusService } from '../../../src/logic/sync-status-service'; +import { gitBlobSha } from '../../../src/utils/git-blob-sha'; + +vi.mock('obsidian'); + +function buildResolver(statuses: SyncStatusService, deps: Partial = {}): SyncStatusResolver { + const base: SyncStatusResolverDependencies = { + app: { + vault: { + read: vi.fn().mockResolvedValue(''), + readBinary: vi.fn(), + adapter: { read: vi.fn(), readBinary: vi.fn() }, + }, + } as never, + settings: () => ({ syncMetadata: {}, vaultFolder: '', rootPath: '', branch: 'main' }) as never, + gitService: () => ({}) as never, + syncManager: () => ({ updateMetadata: vi.fn().mockResolvedValue(undefined) }) as never, + getNormalizedPath: path => path, + }; + return new SyncStatusResolver({ ...base, ...deps }, statuses); +} + +function makeFile(path: string): TFile { + const file = new TFile(); + file.path = path; + return file; +} + +describe('SyncStatusResolver', () => { + describe('refreshFileStatusBySha', () => { + it('classifies synced when local content hashes to the remote sha, and updates metadata', async () => { + const statuses = new SyncStatusService(); + const content = 'hello world'; + const sha = await gitBlobSha(content); + const updateMetadata = vi.fn().mockResolvedValue(undefined); + const resolver = buildResolver(statuses, { + app: { vault: { read: vi.fn().mockResolvedValue(content), readBinary: vi.fn(), adapter: {} } } as never, + syncManager: () => ({ updateMetadata }) as never, + }); + const file = makeFile('note.md'); + + await resolver.refreshFileStatusBySha(file, { path: 'note.md', sha, symlink: false }); + + expect(statuses.get('note.md')?.status).toBe('synced'); + expect(updateMetadata).toHaveBeenCalledWith('note.md', sha); + }); + + it('classifies modified when local content differs from remote and there is no baseline sha on record', async () => { + const statuses = new SyncStatusService(); + const resolver = buildResolver(statuses, { + app: { vault: { read: vi.fn().mockResolvedValue('local content'), readBinary: vi.fn(), adapter: {} } } as never, + }); + const file = makeFile('note.md'); + + await resolver.refreshFileStatusBySha(file, { path: 'note.md', sha: 'b'.repeat(40), symlink: false }); + + expect(statuses.get('note.md')?.status).toBe('modified'); + }); + + it('classifies remote-modified when local content still matches the last-synced baseline but the remote sha moved', async () => { + const statuses = new SyncStatusService(); + const baselineContent = 'baseline content'; + const baselineSha = await gitBlobSha(baselineContent); + const resolver = buildResolver(statuses, { + app: { vault: { read: vi.fn().mockResolvedValue(baselineContent), readBinary: vi.fn(), adapter: {} } } as never, + settings: () => ({ + syncMetadata: { 'note.md': { lastSyncedSha: baselineSha, lastSyncedAt: 1 } }, + vaultFolder: '', + rootPath: '', + branch: 'main', + }) as never, + }); + const file = makeFile('note.md'); + + await resolver.refreshFileStatusBySha(file, { path: 'note.md', sha: 'c'.repeat(40), symlink: false }); + + expect(statuses.get('note.md')?.status).toBe('remote-modified'); + }); + }); + + describe('refreshFileStatusByContent', () => { + it('falls back to gitService.getFile content comparison when the remote entry has no sha', async () => { + const statuses = new SyncStatusService(); + const resolver = buildResolver(statuses, { + app: { vault: { read: vi.fn().mockResolvedValue('same content'), readBinary: vi.fn(), adapter: {} } } as never, + gitService: () => ({ + getFile: vi.fn().mockResolvedValue({ content: 'same content', sha: 'z'.repeat(40) }), + }) as never, + }); + const file = makeFile('note.md'); + + await resolver.refreshFileStatusByContent(file); + + expect(statuses.get('note.md')?.status).toBe('synced'); + }); + + it('classifies unsynced when the remote file does not exist (no sha)', async () => { + const statuses = new SyncStatusService(); + const resolver = buildResolver(statuses, { + app: { vault: { read: vi.fn().mockResolvedValue('content'), readBinary: vi.fn(), adapter: {} } } as never, + gitService: () => ({ getFile: vi.fn().mockResolvedValue({ content: undefined, sha: undefined }) }) as never, + }); + const file = makeFile('note.md'); + + await resolver.refreshFileStatusByContent(file); + + expect(statuses.get('note.md')?.status).toBe('unsynced'); + }); + }); + + describe('diffDirection', () => { + it('returns no direction facts when there is no baseline sha on record', () => { + const statuses = new SyncStatusService(); + const resolver = buildResolver(statuses, { settings: () => ({ syncMetadata: {}, vaultFolder: '', rootPath: '' }) as never }); + + expect(resolver.diffDirection('note.md', 'local-sha', 'remote-sha')).toEqual({}); + }); + + it('reports local/remote changed facts relative to the last-synced baseline', () => { + const statuses = new SyncStatusService(); + const resolver = buildResolver(statuses, { + settings: () => ({ + syncMetadata: { 'note.md': { lastSyncedSha: 'base-sha', lastSyncedAt: 1 } }, + vaultFolder: '', + rootPath: '', + }) as never, + }); + + expect(resolver.diffDirection('note.md', 'local-sha', 'base-sha')).toEqual({ localChanged: true, remoteChanged: false }); + }); + }); +}); diff --git a/tests/runtime/createSyncRuntime.test.ts b/tests/runtime/createSyncRuntime.test.ts new file mode 100644 index 0000000..f6eb8e3 --- /dev/null +++ b/tests/runtime/createSyncRuntime.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { App, DataAdapter } from 'obsidian'; +import { createSyncRuntime } from '../../src/runtime/createSyncRuntime'; +import type { SyncRuntimeDependencies } from '../../src/runtime/createSyncRuntime'; +import type { GitLabFilesPushSettings } from '../../src/settings'; +import type { GitServiceInterface } from '../../src/services/git-service-interface'; + +vi.mock('obsidian'); + +function buildDeps(overrides: Partial = {}): SyncRuntimeDependencies { + const mockAdapter = { list: vi.fn().mockResolvedValue({ files: [], folders: [] }) } as unknown as DataAdapter; + const mockApp = { + vault: { + getFiles: () => [], + adapter: mockAdapter, + }, + } as unknown as App; + const mockGitService = { + listFilesDetailed: vi.fn().mockResolvedValue([]), + getBranchHead: vi.fn().mockResolvedValue(undefined), + } as unknown as GitServiceInterface; + const mockSettings = { + serviceType: 'github', + branch: 'main', + syncMetadata: {}, + vaultFolder: '', + rootPath: '', + } as unknown as GitLabFilesPushSettings; + const mockGitignoreManager = { isIgnored: () => false, loadGitignores: vi.fn().mockResolvedValue(undefined) } as never; + + return { + app: mockApp, + gitService: mockGitService, + getGitService: () => mockGitService, + settings: mockSettings, + getSettings: () => mockSettings, + saveSettings: vi.fn().mockResolvedValue(undefined), + getGitignoreManager: () => mockGitignoreManager, + isIgnored: () => false, + filterFilesByVaultFolder: files => files, + filterPathByVaultFolder: () => true, + getNormalizedPath: path => path, + getVaultPath: path => path, + notify: vi.fn(), + ...overrides, + }; +} + +describe('createSyncRuntime', () => { + it('wires every collaborator on top of the same SyncManager/SyncStatusService pair', () => { + const runtime = createSyncRuntime(buildDeps()); + + expect(runtime.sync).toBeDefined(); + expect(runtime.syncStatusRefresh).toBeDefined(); + expect(runtime.syncDiffService).toBeDefined(); + expect(runtime.syncWorkspace).toBeDefined(); + expect(runtime.changeRepository).toBeDefined(); + expect(runtime.syncSelectionStore).toBeDefined(); + expect(runtime.operationState).toBeDefined(); + expect(runtime.refreshState).toBeDefined(); + expect(runtime.sourceControlViewModel).toBeDefined(); + expect(runtime.sourceControlActions).toBeDefined(); + }); + + it('keeps ChangeRepository in sync with the shared SyncStatusService until disposed', () => { + const runtime = createSyncRuntime(buildDeps()); + + runtime.sync.status.set({ path: 'note.md', status: 'synced' }); + expect(runtime.changeRepository.getById('note.md' as never)).toBeDefined(); + + runtime.dispose(); + runtime.sync.status.set({ path: 'other.md', status: 'unsynced' }); + // Disposed: the second publish must not reach ChangeRepository. + expect(runtime.changeRepository.getById('other.md' as never)).toBeUndefined(); + }); + + it('reconciles SyncSelectionStore against every ChangeRepository replacement, including stale overrides', () => { + const runtime = createSyncRuntime(buildDeps()); + + runtime.sync.status.set({ path: 'note.md', status: 'modified' }); + const noteId = runtime.changeRepository.getAll()[0]?.id; + expect(noteId).toBeDefined(); + if (!noteId) return; + + runtime.syncSelectionStore.selectForSync(noteId); + runtime.syncSelectionStore.setActionOverride(noteId, 'pull'); + expect(runtime.syncSelectionStore.isIncluded(noteId)).toBe(true); + + // Republishing without note.md at all drops the selection entirely. + runtime.sync.status.delete('note.md'); + + expect(runtime.syncSelectionStore.isIncluded(noteId)).toBe(false); + expect(runtime.syncSelectionStore.getActionOverride(noteId)).toBeUndefined(); + }); + + it('stops reconciling SyncSelectionStore once disposed', () => { + const runtime = createSyncRuntime(buildDeps()); + + runtime.sync.status.set({ path: 'note.md', status: 'modified' }); + const noteId = runtime.changeRepository.getAll()[0]?.id; + expect(noteId).toBeDefined(); + if (!noteId) return; + runtime.syncSelectionStore.selectForSync(noteId); + + runtime.dispose(); + // Calling ChangeRepository.replace() directly (bypassing sync.status) + // isolates the selection-reconciliation subscription specifically: + // after dispose(), it must no longer reach SyncSelectionStore. + runtime.changeRepository.replace([]); + + expect(runtime.syncSelectionStore.isIncluded(noteId)).toBe(true); + }); + + it('routes SourceControlActionService notifications through the injected notify callback', async () => { + const notify = vi.fn(); + const runtime = createSyncRuntime(buildDeps({ notify })); + + // pull() with no matching changes resolves with an empty batch and no notification; + // this only asserts the runtime wired SourceControlActionService with our notifier, + // not any specific sync outcome. + await runtime.sourceControlActions.pull([]); + + expect(notify).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/settings.test.ts b/tests/settings.test.ts new file mode 100644 index 0000000..3fcce73 --- /dev/null +++ b/tests/settings.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; +import * as settingsCompat from '../src/settings'; +import * as settingsModel from '../src/settings/model'; +import * as settingsHelpers from '../src/settings/helpers'; + +describe('settings module split', () => { + it('re-exports the model and helpers from src/settings.ts unchanged', () => { + expect(settingsCompat.DEFAULT_SETTINGS).toBe(settingsModel.DEFAULT_SETTINGS); + expect(settingsCompat.getServiceName).toBe(settingsHelpers.getServiceName); + expect(settingsCompat.getEffectiveSymlinkHandling).toBe(settingsHelpers.getEffectiveSymlinkHandling); + expect(settingsCompat.isSyncMetadataAtPath).toBe(settingsHelpers.isSyncMetadataAtPath); + }); + + it('keeps DEFAULT_SETTINGS shape/values unchanged by the split', () => { + expect(settingsModel.DEFAULT_SETTINGS).toEqual({ + serviceType: 'gitlab', + gitlabToken: '', + gitlabBaseUrl: 'https://gitlab.com', + projectId: '', + githubToken: '', + githubOwner: '', + githubRepo: '', + giteaToken: '', + giteaBaseUrl: '', + giteaOwner: '', + giteaRepo: '', + rootPath: '', + branch: 'main', + syncMetadata: {}, + vaultFolder: '', + symlinkHandling: 'real', + ignorePatterns: '', + lastSeenVersion: '', + bannerDismissedVersion: '', + language: 'system', + autoRefreshOnStartup: true, + }); + }); + + it('getServiceName still maps every GitServiceType to its display name', () => { + expect(settingsHelpers.getServiceName({ ...settingsModel.DEFAULT_SETTINGS, serviceType: 'gitlab' })).toBe('GitLab'); + expect(settingsHelpers.getServiceName({ ...settingsModel.DEFAULT_SETTINGS, serviceType: 'github' })).toBe('GitHub'); + expect(settingsHelpers.getServiceName({ ...settingsModel.DEFAULT_SETTINGS, serviceType: 'gitea' })).toBe('Gitea'); + }); + + it('getEffectiveSymlinkHandling still downgrades "real" to "skip" on non-GitHub providers', () => { + const base = { ...settingsModel.DEFAULT_SETTINGS, symlinkHandling: 'real' as const }; + expect(settingsHelpers.getEffectiveSymlinkHandling({ ...base, serviceType: 'github' })).toBe('real'); + expect(settingsHelpers.getEffectiveSymlinkHandling({ ...base, serviceType: 'gitlab' })).toBe('skip'); + expect(settingsHelpers.getEffectiveSymlinkHandling({ ...base, serviceType: 'gitea' })).toBe('skip'); + }); + + it('isSyncMetadataAtPath still accepts legacy (keyed-by-path, no lastKnownPath) metadata', () => { + expect(settingsHelpers.isSyncMetadataAtPath({ lastSyncedSha: 'sha', lastSyncedAt: 0 }, 'a.md')).toBe(true); + expect(settingsHelpers.isSyncMetadataAtPath({ lastSyncedSha: 'sha', lastSyncedAt: 0, lastKnownPath: 'b.md' }, 'a.md')).toBe(false); + expect(settingsHelpers.isSyncMetadataAtPath(undefined, 'a.md')).toBe(false); + }); +}); diff --git a/tests/setup.ts b/tests/setup.ts index 6cb2ec0..63c8eb3 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -210,6 +210,10 @@ export const Modal = class { } open() { + // Real Modal appends modalEl to document.body on open(), inside a + // '.modal-container' wrapper; mirrored here so tests can find/click into + // it via document queries like any other rendered control. + document.body.appendChild(this.modalEl); const withOnOpen = this as unknown as { onOpen?: () => void }; if (typeof withOnOpen.onOpen === 'function') { withOnOpen.onOpen(); @@ -217,6 +221,7 @@ export const Modal = class { } close() { + this.modalEl.remove(); const withOnClose = this as unknown as { onClose?: () => void }; if (typeof withOnClose.onClose === 'function') { withOnClose.onClose(); @@ -297,11 +302,84 @@ export const debounce = (cb: (...args: T) => unknown, timeo return fn; }; // Mutable so tests can exercise both the desktop and mobile branches. -export const Platform = { isDesktopApp: true, isMobile: false }; +export const Platform = { isDesktopApp: true, isMobile: false, isPhone: false, isTablet: false }; export const FileSystemAdapter = class { getBasePath() { return '/mock/path'; } }; +// Real MenuItem builds a DOM row inside its owning Menu's popover; mirrored +// here (rather than a plain recorder) so tests can query/click menu items +// the same way they interact with any other rendered control. +export class MenuItem { + el: HTMLElement; + titleEl: HTMLElement; + private clickCb?: (evt: MouseEvent | KeyboardEvent) => void; + + constructor() { + this.el = document.createElement('div'); + this.el.className = 'menu-item'; + this.titleEl = document.createElement('div'); + this.titleEl.className = 'menu-item-title'; + this.el.appendChild(this.titleEl); + this.el.addEventListener('click', (evt) => this.clickCb?.(evt)); + } + + setTitle(title: string) { + this.titleEl.textContent = title; + this.el.setAttribute('data-title', title); + return this; + } + + setIcon(icon: string | null) { + if (icon) this.el.setAttribute('data-icon', icon); + return this; + } + + setChecked(checked: boolean | null) { + this.el.setAttribute('data-checked', String(checked)); + this.el.classList.toggle('is-checked', checked === true); + return this; + } + + onClick(cb: (evt: MouseEvent | KeyboardEvent) => unknown) { + this.clickCb = cb; + return this; + } +} + +// Real Menu shows a native/DOM popover on showAtMouseEvent/showAtPosition; +// mocked here as a plain element appended to document.body so tests can find +// and click its items instead of reaching into internal state. +export class Menu { + menuEl: HTMLElement; + + constructor() { + this.menuEl = document.createElement('div'); + this.menuEl.className = 'menu'; + } + + addItem(cb: (item: MenuItem) => unknown) { + const item = new MenuItem(); + cb(item); + this.menuEl.appendChild(item.el); + return this; + } + + addSeparator() { + this.menuEl.appendChild(Object.assign(document.createElement('div'), { className: 'menu-separator' })); + return this; + } + + setNoIcon() { return this; } + setUseNativeMenu() { return this; } + setParentElement() { return this; } + showAtMouseEvent() { document.body.appendChild(this.menuEl); return this; } + showAtPosition() { document.body.appendChild(this.menuEl); return this; } + hide() { this.menuEl.remove(); return this; } + close() { this.menuEl.remove(); } + onHide() {} +} + vi.mock('obsidian', () => ({ Plugin, PluginSettingTab, @@ -326,4 +404,6 @@ vi.mock('obsidian', () => ({ setIcon, Platform, FileSystemAdapter, + Menu, + MenuItem, })); diff --git a/tests/ui/SettingsConnectionStatus.test.ts b/tests/ui/SettingsConnectionStatus.test.ts index 1e40b51..8508b41 100644 --- a/tests/ui/SettingsConnectionStatus.test.ts +++ b/tests/ui/SettingsConnectionStatus.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vite import { App } from 'obsidian'; import { DEFAULT_SETTINGS, GitLabSyncSettingTab } from '../../src/settings'; import GitLabFilesPush from '../../src/main'; -import type { ConnectionTestResult } from '../../src/services/git-service-base'; +import type { ConnectionTestResult } from '../../src/services/git-service-interface'; import { createContainer, setupObsidianDOM } from './setup-dom'; vi.mock('../../src/main', () => ({ @@ -58,7 +58,8 @@ describe('GitLabSyncSettingTab connection status badge', () => { it('shows checking then connected after opening the tab', async () => { const testConnection = vi.fn().mockResolvedValue({ repoOk: true, branchOk: true }); - const tab = new GitLabSyncSettingTab(new App(), createPluginStub(testConnection)); + const plugin = createPluginStub(testConnection); + const tab = new GitLabSyncSettingTab(new App(), plugin, plugin); tab.containerEl = createContainer(); tab.display(); @@ -78,7 +79,7 @@ describe('GitLabSyncSettingTab connection status badge', () => { it('debounces repeated field edits into a single connection test', async () => { const testConnection = vi.fn().mockResolvedValue({ repoOk: false, branchOk: false, error: 'bad token' }); const plugin = createPluginStub(testConnection); - const tab = new GitLabSyncSettingTab(new App(), plugin); + const tab = new GitLabSyncSettingTab(new App(), plugin, plugin); tab.containerEl = createContainer(); tab.display(); @@ -108,7 +109,7 @@ describe('GitLabSyncSettingTab ignore patterns setting', () => { it('renders a textarea seeded with the saved ignorePatterns value', async () => { const plugin = createPluginStub(vi.fn().mockResolvedValue({ repoOk: true, branchOk: true })); plugin.settings.ignorePatterns = 'draft/\n*.tmp'; - const tab = new GitLabSyncSettingTab(new App(), plugin); + const tab = new GitLabSyncSettingTab(new App(), plugin, plugin); tab.containerEl = createContainer(); vi.useFakeTimers(); @@ -127,7 +128,7 @@ describe('GitLabSyncSettingTab release history', () => { const plugin = createPluginStub(vi.fn().mockResolvedValue({ repoOk: true, branchOk: true })); plugin.manifest = { version: '1.5.0' } as GitLabFilesPush['manifest']; plugin.settings.bannerDismissedVersion = '1.5.0'; - const tab = new GitLabSyncSettingTab(new App(), plugin); + const tab = new GitLabSyncSettingTab(new App(), plugin, plugin); tab.containerEl = createContainer(); try { @@ -149,7 +150,7 @@ describe('GitLabSyncSettingTab what\'s new banner', () => { const plugin = createPluginStub(vi.fn().mockResolvedValue({ repoOk: true, branchOk: true })); plugin.manifest = { version } as GitLabFilesPush['manifest']; plugin.settings.bannerDismissedVersion = bannerDismissedVersion; - const tab = new GitLabSyncSettingTab(new App(), plugin); + const tab = new GitLabSyncSettingTab(new App(), plugin, plugin); tab.containerEl = createContainer(); tab.display(); return tab; diff --git a/tests/ui/SettingsObsidian113Compatibility.test.ts b/tests/ui/SettingsObsidian113Compatibility.test.ts index ef3a850..0cc24b7 100644 --- a/tests/ui/SettingsObsidian113Compatibility.test.ts +++ b/tests/ui/SettingsObsidian113Compatibility.test.ts @@ -40,14 +40,16 @@ function renderAsObsidian113(tab: GitLabSyncSettingTab): void { describe('GitLabSyncSettingTab on Obsidian 1.13+', () => { it('returns no declarative definitions until the tab is fully migrated', () => { - const tab = new GitLabSyncSettingTab(new App(), createPluginStub()); + const plugin = createPluginStub(); + const tab = new GitLabSyncSettingTab(new App(), plugin, plugin); expect(tab.getSettingDefinitions()).toEqual([]); }); it('falls back to display() and renders settings instead of a blank page', () => { vi.useFakeTimers(); - const tab = new GitLabSyncSettingTab(new App(), createPluginStub()); + const plugin = createPluginStub(); + const tab = new GitLabSyncSettingTab(new App(), plugin, plugin); tab.containerEl = createContainer(); const displaySpy = vi.spyOn(tab, 'display'); diff --git a/tests/ui/SyncPlanModal.test.ts b/tests/ui/SyncPlanModal.test.ts index ede7b7a..d1ad62c 100644 --- a/tests/ui/SyncPlanModal.test.ts +++ b/tests/ui/SyncPlanModal.test.ts @@ -28,6 +28,26 @@ describe('SyncPlanModal', () => { expect(modal.contentEl.querySelector('.sync-plan-section.is-destructive')).toBeNull(); }); + it('prefixes every file row with a direction icon, so a row reads unambiguously even with its section heading scrolled out of view', () => { + const plan: SyncPlan = { + ...emptyPlan(), + modifications: [{ path: 'push-me.md', name: 'push-me.md' }], + downloads: [{ path: 'pull-me.md', name: 'pull-me.md' }], + deletions: [{ path: 'gone.md', name: 'gone.md' }], + }; + const modal = new SyncPlanModal(new App(), plan, 'sync', vi.fn()); + modal.contentEl = createContainer(); + + modal.onOpen(); + + const rows = Array.from(modal.contentEl.querySelectorAll('.sync-plan-file-row')); + expect(rows.length).toBe(3); + for (const row of rows) { + expect(row.querySelector('.sync-plan-file-icon')).not.toBeNull(); + expect(row.querySelector('.sync-plan-file-path')).not.toBeNull(); + } + }); + it('shows the destructive deletion warning when the plan includes deletions', () => { const plan: SyncPlan = { ...emptyPlan(), deletions: [{ path: 'gone.md', name: 'gone.md' }] }; const modal = new SyncPlanModal(new App(), plan, 'delete', vi.fn()); diff --git a/tests/ui/components/DiffViewer.test.ts b/tests/ui/components/DiffViewer.test.ts index c63b50b..cc49092 100644 --- a/tests/ui/components/DiffViewer.test.ts +++ b/tests/ui/components/DiffViewer.test.ts @@ -1,4 +1,5 @@ -import { beforeAll, describe, expect, it } from 'vitest'; +import { afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { Platform } from 'obsidian'; import { renderDiffViewer } from '../../../src/ui/components/DiffViewer'; import { setupObsidianDOM, createContainer } from '../setup-dom'; @@ -52,4 +53,53 @@ describe('renderDiffViewer', () => { expect(layouts).toEqual(['split', 'unified']); expect(body?.classList.contains('scv-diff-layout-unified')).toBe(true); }); + + describe('content-less initial render (async load in flight)', () => { + it('renders no diff panel when remote/local are omitted', () => { + const container = createContainer(); + + renderDiffViewer(container, { layout: 'split' }); + + expect(container.querySelector('.ssv-diff-split')).toBeNull(); + expect(container.querySelector('.ssv-diff-unified')).toBeNull(); + }); + + it('fills in the diff panel exactly once via the handle, replacing any prior content', () => { + const container = createContainer(); + + const viewer = renderDiffViewer(container, { layout: 'split' }); + viewer.setContent('remote text', 'local text'); + viewer.setContent('remote text 2', 'local text 2'); + + expect(container.querySelectorAll('.ssv-diff-split')).toHaveLength(1); + expect(container.querySelectorAll('.ssv-diff-unified')).toHaveLength(1); + expect(container.textContent).toContain('remote text 2'); + expect(container.textContent).not.toContain('remote text\n'); + }); + }); + + describe('phone layout policy', () => { + afterEach(() => { Platform.isPhone = false; }); + + it('forces unified and ignores the requested split layout', () => { + Platform.isPhone = true; + const container = createContainer(); + + renderDiffViewer(container, { remote: 'r', local: 'l', layout: 'split' }); + + const body = container.querySelector('.scv-diff-tab-body'); + expect(body?.classList.contains('scv-diff-layout-unified')).toBe(true); + expect(body?.classList.contains('scv-diff-layout-split')).toBe(false); + }); + + it('renders no layout toggle even when a toggleHost is given', () => { + Platform.isPhone = true; + const container = createContainer(); + const toggleHost = createContainer(); + + renderDiffViewer(container, { remote: 'r', local: 'l', layout: 'split', toggleHost }); + + expect(toggleHost.querySelector('.scv-diff-layout-toggle')).toBeNull(); + }); + }); }); \ No newline at end of file diff --git a/tests/ui/source-control/ChangePresentation.test.ts b/tests/ui/source-control/ChangePresentation.test.ts index d3d47f0..e8810d0 100644 --- a/tests/ui/source-control/ChangePresentation.test.ts +++ b/tests/ui/source-control/ChangePresentation.test.ts @@ -1,13 +1,20 @@ import { describe, expect, it, beforeAll } from 'vitest'; -import { addedContentStat, cheapLocalStat, computeDiffStat, deletedContentStat, presentChange } from '../../../src/ui/source-control/ChangePresentation'; +import { presentChange } from '../../../src/ui/source-control/ChangePresentation'; import type { SourceControlItem } from '../../../src/logic/source-control/SourceControlViewModel'; +import { resolveSyncAction } from '../../../src/logic/source-control/ChangeActionPolicy'; import { toChangeId } from '../../../src/logic/source-control/types'; import { setupObsidianDOM } from '../setup-dom'; beforeAll(() => { setupObsidianDOM(); }); function item(overrides: Partial & Pick): SourceControlItem { - return { isSelectedForSync: false, operationStatus: 'idle', ...overrides }; + return { + isSelectedForSync: false, + operationStatus: 'idle', + syncAction: resolveSyncAction(overrides.kind), + hasActionOverride: false, + ...overrides, + }; } describe('presentChange', () => { @@ -77,73 +84,3 @@ describe('presentChange', () => { expect(view.renameFrom).toBe('old.md'); }); }); - -describe('computeDiffStat', () => { - it('counts additions and deletions from a two-sided diff', () => { - const remote = 'line1\nline2\nline3'; - const local = 'line1\nchanged\nline3\nline4'; - const stat = computeDiffStat(remote, local); - expect(stat.additions).toBe(2); - expect(stat.deletions).toBe(1); - }); - - it('reports zero for identical content', () => { - const stat = computeDiffStat('a\nb', 'a\nb'); - expect(stat).toEqual({ additions: 0, deletions: 0 }); - }); - - it('treats a pure addition as additions only', () => { - const stat = computeDiffStat('a', 'a\nb'); - expect(stat).toEqual({ additions: 1, deletions: 0 }); - }); - - it('treats a pure deletion as deletions only', () => { - const stat = computeDiffStat('a\nb', 'a'); - expect(stat).toEqual({ additions: 0, deletions: 1 }); - }); -}); - -describe('cheapLocalStat', () => { - it('counts local lines as additions with no deletions', () => { - expect(cheapLocalStat('a\nb\nc')).toEqual({ additions: 3, deletions: 0 }); - }); - - it('reports zero for empty content', () => { - expect(cheapLocalStat('')).toEqual({ additions: 0, deletions: 0 }); - }); - - it('does not count a trailing newline as a phantom line', () => { - expect(cheapLocalStat('a\nb\n')).toEqual({ additions: 2, deletions: 0 }); - }); - - it('normalizes CRLF line endings', () => { - expect(cheapLocalStat('a\r\nb\r\nc')).toEqual({ additions: 3, deletions: 0 }); - }); -}); -describe('addedContentStat', () => { - it('counts every line as an addition for a one-sided +N change', () => { - expect(addedContentStat('line1\nline2')).toEqual({ additions: 2, deletions: 0 }); - }); - - it('reports zero for empty content', () => { - expect(addedContentStat('')).toEqual({ additions: 0, deletions: 0 }); - }); - - it('does not count a trailing newline as a phantom line', () => { - expect(addedContentStat('line1\nline2\n')).toEqual({ additions: 2, deletions: 0 }); - }); -}); - -describe('deletedContentStat', () => { - it('counts every line as a deletion for a one-sided -N change', () => { - expect(deletedContentStat('line1\nline2')).toEqual({ additions: 0, deletions: 2 }); - }); - - it('reports zero for empty content', () => { - expect(deletedContentStat('')).toEqual({ additions: 0, deletions: 0 }); - }); - - it('does not count a trailing newline as a phantom line', () => { - expect(deletedContentStat('line1\nline2\n')).toEqual({ additions: 0, deletions: 2 }); - }); -}); diff --git a/tests/ui/source-control/ChangeTree.test.ts b/tests/ui/source-control/ChangeTree.test.ts index d910f91..b0f9aa6 100644 --- a/tests/ui/source-control/ChangeTree.test.ts +++ b/tests/ui/source-control/ChangeTree.test.ts @@ -1,13 +1,20 @@ import { describe, expect, it, vi, beforeAll, beforeEach } from 'vitest'; import { renderChangeTree, type ChangeTreeCallbacks } from '../../../src/ui/source-control/ChangeTree'; import type { SourceControlItem } from '../../../src/logic/source-control/SourceControlViewModel'; +import { resolveSyncAction } from '../../../src/logic/source-control/ChangeActionPolicy'; import { toChangeId } from '../../../src/logic/source-control/types'; import { setupObsidianDOM, createContainer } from '../setup-dom'; beforeAll(() => { setupObsidianDOM(); }); function item(overrides: Partial & Pick): SourceControlItem { - return { isSelectedForSync: false, operationStatus: 'idle', ...overrides }; + return { + isSelectedForSync: false, + operationStatus: 'idle', + syncAction: resolveSyncAction(overrides.kind), + hasActionOverride: false, + ...overrides, + }; } describe('renderChangeTree', () => { diff --git a/tests/ui/source-control/DiffStatProvider.test.ts b/tests/ui/source-control/DiffStatProvider.test.ts index 0c07963..be258cc 100644 --- a/tests/ui/source-control/DiffStatProvider.test.ts +++ b/tests/ui/source-control/DiffStatProvider.test.ts @@ -2,11 +2,20 @@ import { describe, expect, it, vi } from 'vitest'; import { DiffStatProvider } from '../../../src/ui/source-control/DiffStatProvider'; import type { DiffStatLoadResult } from '../../../src/ui/source-control/DiffStatProvider'; import type { SourceControlItem } from '../../../src/logic/source-control/SourceControlViewModel'; -import type { ChangeStat } from '../../../src/ui/source-control/ChangePresentation'; +import { resolveSyncAction } from '../../../src/logic/source-control/ChangeActionPolicy'; +import type { ChangeStat } from '../../../src/logic/sync/DiffStat'; import { toChangeId } from '../../../src/logic/source-control/types'; function item(id: string, kind: SourceControlItem['kind'] = 'local-only'): SourceControlItem { - return { id: toChangeId(id), path: `${id}.md`, kind, isSelectedForSync: false, operationStatus: 'idle' }; + return { + id: toChangeId(id), + path: `${id}.md`, + kind, + isSelectedForSync: false, + operationStatus: 'idle', + syncAction: resolveSyncAction(kind), + hasActionOverride: false, + }; } function ready(stat: ChangeStat): DiffStatLoadResult { diff --git a/tests/ui/source-control/SourceControlItemView.test.ts b/tests/ui/source-control/SourceControlItemView.test.ts index ccbfb12..5f1584e 100644 --- a/tests/ui/source-control/SourceControlItemView.test.ts +++ b/tests/ui/source-control/SourceControlItemView.test.ts @@ -1,4 +1,4 @@ -import { beforeAll, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import { TFile, WorkspaceLeaf } from 'obsidian'; import { SourceControlItemView, SOURCE_CONTROL_VIEW_TYPE } from '../../../src/ui/source-control/SourceControlItemView'; import { ChangeRepository } from '../../../src/logic/source-control/ChangeRepository'; @@ -24,6 +24,7 @@ function buildPlugin(kind: SyncChangeKind = 'local-only') { const push = vi.fn().mockResolvedValue(undefined); const pull = vi.fn().mockResolvedValue(undefined); const deleteRemote = vi.fn().mockResolvedValue(undefined); + const deleteLocal = vi.fn().mockResolvedValue(undefined); const loadDiffContent = vi.fn().mockResolvedValue({ remote: 'remote text', local: 'local text' }); const openDiffTab = vi.fn().mockResolvedValue(undefined); const getRemoteFileUrl = vi.fn().mockReturnValue('https://github.com/owner/repo/blob/main/a.md'); @@ -35,7 +36,7 @@ function buildPlugin(kind: SyncChangeKind = 'local-only') { pushSelectionStore: selection, operationState: operations, sourceControlViewModel: viewModel, - sourceControlActions: { sync, push, pull, deleteRemote, loadDiffContent }, + sourceControlActions: { sync, push, pull, deleteRemote, deleteLocal, loadDiffContent }, sync: { status }, syncWorkspace: { getInfo: () => ({ serviceName: 'GitHub', branch: 'main', vaultFolder: '' }), getRemoteFileUrl }, settings: { syncMetadata: {} }, @@ -44,7 +45,7 @@ function buildPlugin(kind: SyncChangeKind = 'local-only') { diffTabPath, } as unknown as GitLabFilesPush; - return { plugin, repository, selection, sync, push, pull, deleteRemote, loadDiffContent, openDiffTab, getRemoteFileUrl, status, diffTabPath }; + return { plugin, repository, selection, operations, sync, push, pull, deleteRemote, deleteLocal, loadDiffContent, openDiffTab, getRemoteFileUrl, status, diffTabPath }; } function buildLeaf() { @@ -84,7 +85,7 @@ describe('SourceControlItemView', () => { const container = view.containerEl.children[1] as HTMLElement; (container.querySelector('.scv-push-btn') as HTMLButtonElement).click(); - expect(sync).toHaveBeenCalledWith([toChangeId('a.md')]); + expect(sync).toHaveBeenCalledWith([{ changeId: toChangeId('a.md'), action: undefined }]); }); it('waits for sync to settle through the production runAction wiring before re-rendering (regression: runAction used to discard the action promise)', async () => { @@ -104,7 +105,7 @@ describe('SourceControlItemView', () => { const container = view.containerEl.children[1] as HTMLElement; (container.querySelector('.scv-push-btn') as HTMLButtonElement).click(); - expect(sync).toHaveBeenCalledWith([toChangeId('a.md'), toChangeId('b.md')]); + expect(sync).toHaveBeenCalledWith([{ changeId: toChangeId('a.md'), action: undefined }, { changeId: toChangeId('b.md'), action: undefined }]); resolveSync(); await new Promise(resolve => window.setTimeout(resolve, 0)); @@ -362,4 +363,73 @@ describe('SourceControlItemView', () => { expect(openDiffTab).toHaveBeenCalledWith('a.md', null); }); + + it('refreshes the open diff tab using the ViewModel\'s real selection/operation projection, not hardcoded defaults', async () => { + const { plugin, selection, operations, loadDiffContent, diffTabPath } = buildPlugin('local-modified'); + (diffTabPath as ReturnType).mockReturnValue('a.md'); + // Prior to the getItem() refactor this path hardcoded isSelectedForSync: + // false / operationStatus: 'idle' regardless of actual state. + selection.selectForSync(toChangeId('a.md')); + operations.start(toChangeId('a.md')); + const view = new SourceControlItemView({} as WorkspaceLeaf, plugin); + await view.onOpen(); + + const status = (plugin as unknown as { sync: { status: SyncStatusService } }).sync.status; + status.set({ path: 'a.md', status: 'modified', localContent: 'local', remoteContent: 'remote' }); + await new Promise(resolve => window.setTimeout(resolve, 200)); + loadDiffContent.mockClear(); + + status.set({ path: 'a.md', status: 'synced', localContent: 'remote', remoteContent: 'remote', remoteSha: 'new-sha' }); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + expect(loadDiffContent).toHaveBeenCalledWith(expect.objectContaining({ + isSelectedForSync: true, + operationStatus: 'running', + })); + }); + + describe('row menu delete-remote confirmation', () => { + afterEach(() => { + document.querySelectorAll('.menu').forEach(el => el.remove()); + }); + + it('confirms before calling sourceControlActions.deleteRemote, and does not call it if cancelled', async () => { + const { plugin, deleteRemote } = buildPlugin('remote-only'); + const view = new SourceControlItemView({} as WorkspaceLeaf, plugin); + await view.onOpen(); + + const container = view.containerEl.children[1] as HTMLElement; + (container.querySelector('.scv-change-menu') as HTMLButtonElement).click(); + (Array.from(document.querySelectorAll('.menu .menu-item')) + .find(el => el.getAttribute('data-title') === 'Delete remote…') as HTMLElement).click(); + + // Confirm modal is now open; deleteRemote must not run until confirmed. + expect(deleteRemote).not.toHaveBeenCalled(); + const buttons = Array.from(document.querySelectorAll('.ssv-confirm-buttons button')); + expect(buttons.length).toBe(2); + + // Cancel: still not called. + buttons[0]?.click(); + expect(deleteRemote).not.toHaveBeenCalled(); + }); + + it('calls sourceControlActions.deleteRemote with the row id once confirmed', async () => { + const { plugin, deleteRemote } = buildPlugin('remote-only'); + const view = new SourceControlItemView({} as WorkspaceLeaf, plugin); + await view.onOpen(); + + const container = view.containerEl.children[1] as HTMLElement; + (container.querySelector('.scv-change-menu') as HTMLButtonElement).click(); + (Array.from(document.querySelectorAll('.menu .menu-item')) + .find(el => el.getAttribute('data-title') === 'Delete remote…') as HTMLElement).click(); + + const buttons = Array.from(document.querySelectorAll('.ssv-confirm-buttons button')); + buttons[1]?.click(); + await Promise.resolve(); + + expect(deleteRemote).toHaveBeenCalledWith([toChangeId('a.md')]); + }); + }); }); diff --git a/tests/ui/source-control/SourceControlView.test.ts b/tests/ui/source-control/SourceControlView.test.ts index 29a9892..df0fa0e 100644 --- a/tests/ui/source-control/SourceControlView.test.ts +++ b/tests/ui/source-control/SourceControlView.test.ts @@ -6,11 +6,34 @@ import { OperationState } from '../../../src/logic/source-control/OperationState import { RefreshState } from '../../../src/logic/source-control/RefreshState'; import { SyncSelectionStore } from '../../../src/logic/source-control/SyncSelectionStore'; import { SourceControlViewModel, type SourceControlItem } from '../../../src/logic/source-control/SourceControlViewModel'; -import { toChangeId, type SyncChange } from '../../../src/logic/source-control/types'; +import { defaultSyncAction } from '../../../src/logic/source-control/ChangeActionPolicy'; +import { toChangeId, type ChangeId, type SyncChange } from '../../../src/logic/source-control/types'; import { setupObsidianDOM, createContainer } from '../setup-dom'; beforeAll(() => { setupObsidianDOM(); }); +/** + * Selection-mutation callbacks the view relies on, wired directly to a test + * SyncSelectionStore/ChangeRepository — a stand-in for what + * SourceControlActionService does against the real store in production. + */ +function selectionCallbacks( + repository: ChangeRepository, + selection: SyncSelectionStore, +): Pick { + return { + onSelectForSync: (id) => selection.selectForSync(id), + onDeselectFromSync: (id) => selection.deselectFromSync(id), + onSelectMany: (ids) => selection.selectMany(ids), + onDeselectMany: (ids) => selection.deselectMany(ids), + onSetSyncAction: (id: ChangeId, action) => { + const change = repository.getById(id); + if (change && action === defaultSyncAction(change.kind)) selection.clearActionOverride(id); + else selection.setActionOverride(id, action); + }, + }; +} + function buildView(changes: SyncChange[], callbacks: Partial = {}) { const repository = new ChangeRepository(); repository.replace(changes); @@ -21,7 +44,7 @@ function buildView(changes: SyncChange[], callbacks: Partial ({ + const view = new SourceControlView(viewModel, { onSync, onRefresh, ...selectionCallbacks(repository, selection), ...callbacks }, () => ({ serviceName: 'GitHub', branch: 'main', vaultFolder: '', @@ -41,7 +64,7 @@ function buildViewWithRepository(changes: SyncChange[], callbacks: Partial ({ + const view = new SourceControlView(viewModel, { onSync, onRefresh, ...selectionCallbacks(repository, selection), ...callbacks }, () => ({ serviceName: 'GitHub', branch: 'main', vaultFolder: '', @@ -432,7 +455,7 @@ describe('SourceControlView', () => { (container.querySelector('.scv-push-btn') as HTMLButtonElement).click(); - expect(onSync).toHaveBeenCalledWith([toChangeId('c-1'), toChangeId('c-2')]); + expect(onSync).toHaveBeenCalledWith([{ changeId: toChangeId('c-1'), action: undefined }, { changeId: toChangeId('c-2'), action: undefined }]); }); it('disables the push button when nothing is selected', () => { @@ -460,7 +483,7 @@ describe('SourceControlView', () => { (container.querySelector('.scv-push-btn') as HTMLButtonElement).click(); expect(onSync).toHaveBeenCalledOnce(); - expect(onSync).toHaveBeenCalledWith([toChangeId('c-1'), toChangeId('c-2')]); + expect(onSync).toHaveBeenCalledWith([{ changeId: toChangeId('c-1'), action: undefined }, { changeId: toChangeId('c-2'), action: undefined }]); }); it('hands a download-only Sync Queue to onSync too', () => { @@ -474,7 +497,7 @@ describe('SourceControlView', () => { (container.querySelector('.scv-push-btn') as HTMLButtonElement).click(); - expect(onSync).toHaveBeenCalledWith([toChangeId('c-1')]); + expect(onSync).toHaveBeenCalledWith([{ changeId: toChangeId('c-1'), action: undefined }]); }); it('hands a local-deleted change in the Sync Queue to onSync, not a separate delete callback', () => { @@ -488,7 +511,7 @@ describe('SourceControlView', () => { (container.querySelector('.scv-push-btn') as HTMLButtonElement).click(); - expect(onSync).toHaveBeenCalledWith([toChangeId('c-1')]); + expect(onSync).toHaveBeenCalledWith([{ changeId: toChangeId('c-1'), action: undefined }]); }); it('shows Upload / Download group labels in the Sync Queue when the queue is mixed', () => { @@ -519,6 +542,123 @@ describe('SourceControlView', () => { expect(container.querySelector('.scv-queue-group-label')).toBeNull(); }); + + it('groups by the resolved action, not the kind default, when the user overrides it', () => { + const { view, selection } = buildView( + [ + { id: toChangeId('c-1'), path: 'a.md', kind: 'local-modified' }, + { id: toChangeId('c-2'), path: 'b.md', kind: 'local-modified' }, + ], + ); + selection.selectForSync(toChangeId('c-1')); + selection.selectForSync(toChangeId('c-2')); + // Both default to push; overriding one to pull should move it into + // Download despite sharing the same change kind as the other. + selection.setActionOverride(toChangeId('c-2'), 'pull'); + view.render(container); + + const labels = Array.from(container.querySelectorAll('.scv-queue-group-label')).map(el => el.textContent); + expect(labels).toEqual(['Upload', 'Download']); + }); + }); + + describe('queue row action control', () => { + // Menu popovers append to document.body (outside `container`), so + // each test's menu must be cleared before the next opens one. + afterEach(() => { document.querySelectorAll('.menu').forEach(el => el.remove()); }); + + it('renders the action control on a Sync Queue row but not on a Repository Changes row', () => { + const { view, selection } = buildView([ + { id: toChangeId('c-1'), path: 'a.md', kind: 'local-modified' }, + { id: toChangeId('c-2'), path: 'b.md', kind: 'local-modified' }, + ]); + selection.selectForSync(toChangeId('c-1')); + view.render(container); + + const queueSection = container.querySelector('.scv-selected-section') as HTMLElement; + const treeSection = container.querySelector('.scv-changes-tree') as HTMLElement; + expect(queueSection.querySelector('.scv-change-action')).toBeTruthy(); + expect(treeSection.querySelector('.scv-change-action')).toBeNull(); + }); + + it('opening the menu offers only the actions legal for the row\'s kind, checking the resolved one', () => { + const { view, selection } = buildView([ + { id: toChangeId('c-1'), path: 'a.md', kind: 'local-modified' }, + ]); + selection.selectForSync(toChangeId('c-1')); + view.render(container); + + const btn = container.querySelector('.scv-selected-section .scv-change-action') as HTMLButtonElement; + btn.click(); + + const items = Array.from(document.querySelectorAll('.menu .menu-item')); + expect(items.map(el => el.getAttribute('data-title'))).toEqual( + expect.arrayContaining(['Push local', 'Use remote', 'View diff', 'Remove from Sync Queue']), + ); + const pushItem = items.find(el => el.getAttribute('data-title') === 'Push local'); + expect(pushItem?.getAttribute('data-checked')).toBe('true'); + // delete-remote isn't legal for local-modified, so it must not appear. + expect(items.some(el => el.getAttribute('data-title') === 'Delete remote')).toBe(false); + }); + + it('choosing "Use remote" from the menu sets an override that survives to the queue grouping', () => { + const { view, selection } = buildView([ + { id: toChangeId('c-1'), path: 'a.md', kind: 'local-modified' }, + ]); + selection.selectForSync(toChangeId('c-1')); + view.render(container); + + (container.querySelector('.scv-selected-section .scv-change-action') as HTMLButtonElement).click(); + const useRemote = Array.from(document.querySelectorAll('.menu .menu-item')) + .find(el => el.getAttribute('data-title') === 'Use remote') as HTMLElement; + useRemote.click(); + + expect(selection.getActionOverride(toChangeId('c-1'))).toBe('pull'); + }); + + it('choosing "Remove from Sync Queue" deselects the row', () => { + const { view, selection } = buildView([ + { id: toChangeId('c-1'), path: 'a.md', kind: 'local-modified' }, + ]); + selection.selectForSync(toChangeId('c-1')); + view.render(container); + + (container.querySelector('.scv-selected-section .scv-change-action') as HTMLButtonElement).click(); + const remove = Array.from(document.querySelectorAll('.menu .menu-item')) + .find(el => el.getAttribute('data-title') === 'Remove from Sync Queue') as HTMLElement; + remove.click(); + + expect(selection.isIncluded(toChangeId('c-1'))).toBe(false); + }); + + it('choosing "View diff" from the menu opens the diff instead of changing the action', () => { + const onOpenDiff = vi.fn(); + const { view, selection } = buildView( + [{ id: toChangeId('c-1'), path: 'a.md', kind: 'local-modified' }], + { onOpenDiff }, + ); + selection.selectForSync(toChangeId('c-1')); + view.render(container); + + (container.querySelector('.scv-selected-section .scv-change-action') as HTMLButtonElement).click(); + const viewDiff = Array.from(document.querySelectorAll('.menu .menu-item')) + .find(el => el.getAttribute('data-title') === 'View diff') as HTMLElement; + viewDiff.click(); + + expect(onOpenDiff).toHaveBeenCalledWith(expect.objectContaining({ id: toChangeId('c-1') })); + }); + + it('does not render a plain Download button on a Sync Queue row (the action control supersedes it)', () => { + const { view, selection } = buildView([ + { id: toChangeId('c-1'), path: 'remote.md', kind: 'remote-only' }, + ]); + selection.selectForSync(toChangeId('c-1')); + view.render(container); + + const queueSection = container.querySelector('.scv-selected-section') as HTMLElement; + expect(queueSection.querySelector('.scv-change-download')).toBeNull(); + expect(queueSection.querySelector('.scv-change-action')).toBeTruthy(); + }); }); describe('inline download action', () => { @@ -548,6 +688,112 @@ describe('SourceControlView', () => { }); }); + describe('repository row menu', () => { + afterEach(() => { document.querySelectorAll('.menu').forEach(el => el.remove()); }); + + it('renders no row menu button for a conflict or synced row', () => { + const { view } = buildView([ + { id: toChangeId('c-1'), path: 'a.md', kind: 'conflict' }, + ]); + view.render(container); + + expect(container.querySelector('.scv-changes-tree .scv-change-menu')).toBeNull(); + }); + + it('offers Push local, Use remote…, View diff, Add to Sync Queue, and Delete local… for a local-modified row', () => { + const { view } = buildView([ + { id: toChangeId('c-1'), path: 'a.md', kind: 'local-modified' }, + ]); + view.render(container); + + (container.querySelector('.scv-changes-tree .scv-change-menu') as HTMLButtonElement).click(); + + const titles = Array.from(document.querySelectorAll('.menu .menu-item')).map(el => el.getAttribute('data-title')); + expect(titles).toEqual(['Push local', 'Use remote…', 'View diff', 'Add to Sync Queue', 'Delete local…']); + }); + + it('offers Download, Delete remote…, Add to Sync Queue, and Open remote for a remote-only row', () => { + const { view } = buildView([ + { id: toChangeId('c-1'), path: 'a.md', kind: 'remote-only' }, + ]); + view.render(container); + + (container.querySelector('.scv-changes-tree .scv-change-menu') as HTMLButtonElement).click(); + + const titles = Array.from(document.querySelectorAll('.menu .menu-item')).map(el => el.getAttribute('data-title')); + expect(titles).toEqual(['Download', 'Delete remote…', 'Add to Sync Queue', 'Open remote']); + }); + + it('routes "Push local" to onPush with just that row\'s id', () => { + const onPush = vi.fn(); + const { view } = buildView( + [{ id: toChangeId('c-1'), path: 'a.md', kind: 'local-modified' }], + { onPush }, + ); + view.render(container); + + (container.querySelector('.scv-changes-tree .scv-change-menu') as HTMLButtonElement).click(); + (Array.from(document.querySelectorAll('.menu .menu-item')) + .find(el => el.getAttribute('data-title') === 'Push local') as HTMLElement).click(); + + expect(onPush).toHaveBeenCalledWith([toChangeId('c-1')]); + }); + + it('routes "Delete remote…" to onDeleteRemote', () => { + const onDeleteRemote = vi.fn(); + const { view } = buildView( + [{ id: toChangeId('c-1'), path: 'a.md', kind: 'remote-only' }], + { onDeleteRemote }, + ); + view.render(container); + + (container.querySelector('.scv-changes-tree .scv-change-menu') as HTMLButtonElement).click(); + (Array.from(document.querySelectorAll('.menu .menu-item')) + .find(el => el.getAttribute('data-title') === 'Delete remote…') as HTMLElement).click(); + + expect(onDeleteRemote).toHaveBeenCalledWith([toChangeId('c-1')]); + }); + + it('routes "Add to Sync Queue" to selection instead of any immediate action', () => { + const { view, selection } = buildView([ + { id: toChangeId('c-1'), path: 'a.md', kind: 'local-modified' }, + ]); + view.render(container); + + (container.querySelector('.scv-changes-tree .scv-change-menu') as HTMLButtonElement).click(); + (Array.from(document.querySelectorAll('.menu .menu-item')) + .find(el => el.getAttribute('data-title') === 'Add to Sync Queue') as HTMLElement).click(); + + expect(selection.isIncluded(toChangeId('c-1'))).toBe(true); + }); + + it('routes "Open remote" to onOpenRemoteFile', () => { + const onOpenRemoteFile = vi.fn(); + const { view } = buildView( + [{ id: toChangeId('c-1'), path: 'a.md', kind: 'remote-only' }], + { onOpenRemoteFile }, + ); + view.render(container); + + (container.querySelector('.scv-changes-tree .scv-change-menu') as HTMLButtonElement).click(); + (Array.from(document.querySelectorAll('.menu .menu-item')) + .find(el => el.getAttribute('data-title') === 'Open remote') as HTMLElement).click(); + + expect(onOpenRemoteFile).toHaveBeenCalledWith(expect.objectContaining({ id: toChangeId('c-1') })); + }); + + it('does not render the row menu on a Sync Queue row (the compact action control supersedes it)', () => { + const { view, selection } = buildView([ + { id: toChangeId('c-1'), path: 'a.md', kind: 'local-modified' }, + ]); + selection.selectForSync(toChangeId('c-1')); + view.render(container); + + const queueSection = container.querySelector('.scv-selected-section') as HTMLElement; + expect(queueSection.querySelector('.scv-change-menu')).toBeNull(); + }); + }); + describe('operation status', () => { it('renders the running indicator for a change with an in-flight operation', () => { const { view, operations } = buildView([{ id: toChangeId('c-1'), path: 'a.md', kind: 'local-only' }]); @@ -643,7 +889,7 @@ describe('SourceControlView', () => { // onOpenDiff, and the host (SourceControlItemView) opens a main-area // tab. Only the mobile full-screen detail view still loads/renders // diff content inside SourceControlView itself. - afterEach(() => { Platform.isMobile = false; }); + afterEach(() => { Platform.isMobile = false; Platform.isPhone = false; }); it('loads and renders diff content in the mobile detail view for the clicked change', async () => { Platform.isMobile = true; @@ -664,6 +910,83 @@ describe('SourceControlView', () => { expect(container.querySelector('.ssv-diff-split')).not.toBeNull(); }); + it('renders no diff panel before the async load resolves, and exactly one once it does', async () => { + Platform.isMobile = true; + let resolveLoad: (value: { remote: string; local: string }) => void = () => {}; + const loadDiffContent = vi.fn().mockReturnValue(new Promise(resolve => { resolveLoad = resolve; })); + const { view } = buildView( + [{ id: toChangeId('c-1'), path: 'a.md', kind: 'local-modified' }], + { loadDiffContent }, + ); + view.render(container); + + (container.querySelector('.scv-change-item') as HTMLElement).click(); + await Promise.resolve(); + + expect(container.querySelector('.ssv-diff-split')).toBeNull(); + expect(container.querySelector('.ssv-diff-unified')).toBeNull(); + + resolveLoad({ remote: 'remote text', local: 'local text' }); + await Promise.resolve(); + await Promise.resolve(); + + expect(container.querySelectorAll('.ssv-diff-split')).toHaveLength(1); + expect(container.querySelectorAll('.ssv-diff-unified')).toHaveLength(1); + expect(container.querySelectorAll('.ssv-diff-hd')).toHaveLength(2); + }); + + it('does not let a stale async result render into a reopened detail view', async () => { + Platform.isMobile = true; + let resolveFirst: (value: { remote: string; local: string }) => void = () => {}; + const loadDiffContent = vi.fn() + .mockReturnValueOnce(new Promise(resolve => { resolveFirst = resolve; })) + .mockResolvedValueOnce({ remote: 'second remote', local: 'second local' }); + const { view } = buildView( + [ + { id: toChangeId('c-1'), path: 'a.md', kind: 'local-modified' }, + { id: toChangeId('c-2'), path: 'b.md', kind: 'local-modified' }, + ], + { loadDiffContent }, + ); + view.render(container); + + (container.querySelectorAll('.scv-change-item')[0] as HTMLElement).click(); + await Promise.resolve(); + (container.querySelector('.scv-detail-back') as HTMLElement).click(); + (container.querySelectorAll('.scv-change-item')[1] as HTMLElement).click(); + await Promise.resolve(); + await Promise.resolve(); + + resolveFirst({ remote: 'first remote', local: 'first local' }); + await Promise.resolve(); + await Promise.resolve(); + + expect(container.textContent).not.toContain('first remote'); + expect(container.textContent).toContain('second remote'); + expect(container.querySelectorAll('.ssv-diff-split')).toHaveLength(1); + }); + + it('forces unified with no toggle on a phone regardless of session split preference', async () => { + Platform.isMobile = true; + Platform.isPhone = true; + const loadDiffContent = vi.fn().mockResolvedValue({ remote: 'remote text', local: 'local text' }); + const { view } = buildView( + [{ id: toChangeId('c-1'), path: 'a.md', kind: 'local-modified' }], + { loadDiffContent }, + ); + view.render(container); + + (container.querySelector('.scv-change-item') as HTMLElement).click(); + await Promise.resolve(); + await Promise.resolve(); + + const diffContainer = container.querySelector('.scv-detail-diff'); + expect(diffContainer?.classList.contains('scv-diff-layout-unified')).toBe(true); + expect(container.querySelector('.scv-diff-layout-toggle')).toBeNull(); + + Platform.isPhone = false; + }); + it('does not render an inline diff pane on desktop -- only notifies onOpenDiff', () => { const { view } = buildView([{ id: toChangeId('c-1'), path: 'a.md', kind: 'local-modified' }]); view.render(container); @@ -1263,7 +1586,7 @@ describe('SourceControlView', () => { (container.querySelector('.scv-mobile-sync-btn') as HTMLButtonElement).click(); - expect(onSync).toHaveBeenCalledWith([toChangeId('c-1')]); + expect(onSync).toHaveBeenCalledWith([{ changeId: toChangeId('c-1'), action: undefined }]); }); }); @@ -1281,7 +1604,15 @@ describe('SourceControlView', () => { ); const view = new SourceControlView( viewModel, - { onSync: vi.fn(), onRefresh: vi.fn() }, + { + onSync: vi.fn(), + onRefresh: vi.fn(), + onSelectForSync: vi.fn(), + onDeselectFromSync: vi.fn(), + onSelectMany: vi.fn(), + onDeselectMany: vi.fn(), + onSetSyncAction: vi.fn(), + }, () => ({ serviceName: 'GitHub', branch: 'main', vaultFolder: '', lastCheckedAt }), ); return { view, refreshState };