Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
- **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.
- **UI**: the production Source Control surface is `SourceControlItemView` (`src/ui/source-control/SourceControlItemView.ts`), which renders `SourceControlView` (`src/ui/source-control/SourceControlView.ts`). User intent (push/pull/delete-remote/resolve-conflict) flows through `SourceControlActionService` (`src/logic/source-control/SourceControlActionService.ts`) into `SyncWorkspace` (`src/logic/sync/SyncWorkspace.ts`), which drives `SyncManager` and its executors (`PushExecutor`, `PullExecutor`, `RemoteDeleteExecutor`, etc. in `src/logic/sync/`). `src/ui/components/` holds shared diff/change presentation pieces used by this surface.
- Do not reintroduce `SyncStatusView` or `ui/sync-status/*` — that legacy presentation layer was replaced by the Source Control surface above 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.

Expand Down
3 changes: 3 additions & 0 deletions docs/source-control-refactor/phase-1-viewmodel-foundation.md
Original file line number Diff line number Diff line change
@@ -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。
Expand Down
3 changes: 3 additions & 0 deletions docs/source-control-refactor/phase-2-action-unification.md
Original file line number Diff line number Diff line change
@@ -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。
Expand Down
3 changes: 3 additions & 0 deletions docs/source-control-refactor/phase-3-source-control-ui.md
Original file line number Diff line number Diff line change
@@ -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。
Expand Down
3 changes: 3 additions & 0 deletions docs/source-control-refactor/phase-4-legacy-cleanup.md
Original file line number Diff line number Diff line change
@@ -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,保留同步核心能力。
Expand Down
8 changes: 6 additions & 2 deletions docs/source-control-refactor/roadmap.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
57 changes: 57 additions & 0 deletions docs/source-control.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Source Control — Current Architecture

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.

## 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.

## 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.
4 changes: 2 additions & 2 deletions e2e-tests/provider/suites/source-control-flows.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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');
Expand Down
38 changes: 33 additions & 5 deletions e2e-tests/provider/suites/sync-manager.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,20 @@ 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 { 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';
Expand Down Expand Up @@ -222,9 +230,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');
Expand All @@ -235,9 +248,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, 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();
});
Expand Down
4 changes: 2 additions & 2 deletions e2e-tests/provider/support/two-client-sync-scenario.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,8 +189,8 @@ export class TwoClient {
*/
async sync(): Promise<void> {
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). */
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 13 additions & 5 deletions progress.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ 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:** restore explicit per-file sync actions (no tracked issue number). All 7 planned commits landed and each individually passes lint/tests/build.
**Branch / PR:** `claude/fix-source-control-explicit-sync-intent`, based on `claude/fix-mobile-diff-rendering-and-responsive-layout` (itself 1 commit ahead of `main`). Not yet pushed or opened as a PR.

**Scope:** E2E fixtures, verifier helpers, and tests only; production `SyncManager` and provider batching behavior remain unchanged.
**Scope:** `SyncSelectionStore`/`ChangeActionPolicy` (per-change action overrides + resolution), `SourceControlViewModel` (resolved `syncAction`/`hasActionOverride` projection), Sync Queue grouping/row controls, `SourceControlActionService.sync()` (now takes `SyncIntentRequest[]`), a new Repository Changes row "⋯" menu, and `SyncPlanModal` per-row direction icons. Deliberately did not touch `DiffViewer.ts`, mobile diff lifecycle, or E2E cleanup — that's the base branch's prior work.

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:** push the branch and open the PR (title `fix(source-control): restore explicit per-file sync actions`); no further planned work outstanding.

Below that: the previous "Outstanding Items"/"Verification Evidence" entries track separate, still-open work on PR #129 / `claude/source-control-foundation` and Issue #143 — not superseded by this entry, carried over from the base branch history.

## Outstanding Items

Expand All @@ -19,6 +21,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.
Expand Down
21 changes: 18 additions & 3 deletions src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,6 @@ const en = {
'sourceControl.filter.changes': 'Changes',
'sourceControl.filter.local': 'Local',
'sourceControl.filter.remote': 'Incoming',
'sourceControl.filter.conflict': 'Conflict',
'sourceControl.filter.readyToPush': 'Ready to Push',
'sourceControl.filter.remoteChanges': 'Incoming',
'sourceControl.filter.conflicts': 'Conflicts',
Expand Down Expand Up @@ -212,14 +211,30 @@ 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',
'sourceControl.mobile.sync': 'Sync',
'sourceControl.info.lastSync': 'Last sync: {time}',
'sourceControl.info.lastChecked': 'Last checked: {time}',
'sourceControl.info.justChecked': 'Last checked: just now',
'sourceControl.info.neverSynced': 'Never synced',
'sourceControl.search.placeholder': 'Filter by path…',
'sourceControl.search.clear': 'Clear filter',
'sourceControl.folder.selectAll': 'Select all in folder',
Expand Down
21 changes: 18 additions & 3 deletions src/i18n/locales/zh-cn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,6 @@ const zhCn: Partial<Record<TranslationKey, string>> = {
'sourceControl.filter.changes': '更改',
'sourceControl.filter.local': '本地',
'sourceControl.filter.remote': '传入',
'sourceControl.filter.conflict': '冲突',
'sourceControl.filter.readyToPush': '待推送',
'sourceControl.filter.remoteChanges': '传入',
'sourceControl.filter.conflicts': '冲突',
Expand Down Expand Up @@ -214,14 +213,30 @@ const zhCn: Partial<Record<TranslationKey, string>> = {
'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} 个文件',
'sourceControl.mobile.sync': '同步',
'sourceControl.info.lastSync': '上次同步:{time}',
'sourceControl.info.lastChecked': '上次检查:{time}',
'sourceControl.info.justChecked': '上次检查:刚刚',
'sourceControl.info.neverSynced': '尚未同步',
'sourceControl.search.placeholder': '按路径过滤…',
'sourceControl.search.clear': '清除过滤',
'sourceControl.folder.selectAll': '选取文件夹内全部项目',
Expand Down
Loading
Loading