Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
44 changes: 44 additions & 0 deletions docs/source-control.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Source Control — Current Architecture

The Source Control side panel is the plugin's only sync UI. There is no
separate "sync status" view; `docs/source-control-refactor/` describes the
historical migration into this architecture and is not current guidance.

## Call chain

```
SourceControlItemView (src/ui/source-control/SourceControlItemView.ts)
└─ SourceControlView (src/ui/source-control/SourceControlView.ts)
└─ SourceControlActionService (src/logic/source-control/SourceControlActionService.ts)
└─ SyncWorkspace (src/logic/sync/SyncWorkspace.ts)
└─ SyncManager + executors (src/logic/sync/, e.g. PushExecutor,
PullExecutor, RemoteDeleteExecutor)
```

- `SourceControlItemView` is the `ItemView` Obsidian mounts; it owns no
rendering logic itself and delegates to `SourceControlView`.
- `SourceControlView` renders the change tree, Sync Queue, and diff surfaces
(`src/ui/components/`, `src/ui/source-control/DiffTabView.ts`), and turns
clicks into calls on `SourceControlActionService`.
- `SourceControlActionService` converts Source Control intent (push / pull /
delete-remote / delete-local / resolve-conflict) into `SyncWorkspace` calls
and reports outcome via `OperationState`. It never talks to a git provider
directly.
- `SyncWorkspace` is the execution boundary: it drives the real `SyncManager`
and provider-mutating executors (`PushExecutor`, `PullExecutor`,
`RemoteDeleteExecutor`, etc.), which in turn call `GitServiceInterface`
(`src/services/`).

## Compatibility identifiers (do not remove)

- `SOURCE_CONTROL_VIEW_TYPE = 'sync-status-view'` — kept so pinned leaves and
saved workspace layouts from before the Source Control migration resolve to
the current `SourceControlItemView` instead of breaking.
- The `open-sync-status` command id — same reason; it already routes to
`activateSourceControlView()`.

## Legacy surface (removed, do not reintroduce)

`SyncStatusView` and `ui/sync-status/*` were the pre-migration UI and no
longer exist in `src/`. An ESLint `no-restricted-imports` rule
(`eslint.config.*`) blocks reintroducing imports from those paths.
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
64 changes: 48 additions & 16 deletions src/ui/components/DiffViewer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
22 changes: 10 additions & 12 deletions src/ui/source-control/SourceControlView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,7 @@ import { SourceControlViewModel, type SourceControlItem } from '../../logic/sour
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 { renderDiffViewer, currentDiffLayout, rememberDiffLayout, type DiffViewerHandle } from '../components/DiffViewer';
import { renderChangeTree, renderChangeList, type ChangeTreeCallbacks } from './ChangeTree';
import { renderChangeItem } from './ChangeItem';
import { DiffStatProvider, type DiffStatLoadResult } from './DiffStatProvider';
Expand Down Expand Up @@ -569,12 +568,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) => {
Expand All @@ -584,12 +582,12 @@ export class SourceControlView {
});
const diffBody = detail.querySelector<HTMLElement>('.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<void> {
private async loadAndRenderDiff(viewer: DiffViewerHandle, changeId: ChangeId): Promise<void> {
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);
Expand All @@ -598,7 +596,7 @@ export class SourceControlView {
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 {
Expand Down
10 changes: 8 additions & 2 deletions styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -749,6 +749,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
Expand Down Expand Up @@ -792,6 +794,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 {
Expand Down Expand Up @@ -891,11 +895,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 {
Expand Down
Loading
Loading