From 2d7202247810e71d6e4bf83932151fa3d0e07391 Mon Sep 17 00:00:00 2001 From: tianyao Date: Fri, 21 Aug 2026 14:42:19 +0000 Subject: [PATCH 001/104] feat(source-control): add push-selection and operation-state foundation Closes #128 --- src/logic/source-control/OperationState.ts | 33 +++++++++++ .../source-control/PushSelectionStore.ts | 34 +++++++++++ .../source-control/OperationState.test.ts | 55 ++++++++++++++++++ .../source-control/PushSelectionStore.test.ts | 56 +++++++++++++++++++ 4 files changed, 178 insertions(+) create mode 100644 src/logic/source-control/OperationState.ts create mode 100644 src/logic/source-control/PushSelectionStore.ts create mode 100644 tests/logic/source-control/OperationState.test.ts create mode 100644 tests/logic/source-control/PushSelectionStore.test.ts diff --git a/src/logic/source-control/OperationState.ts b/src/logic/source-control/OperationState.ts new file mode 100644 index 0000000..7aebeeb --- /dev/null +++ b/src/logic/source-control/OperationState.ts @@ -0,0 +1,33 @@ +export type OperationStatus = 'idle' | 'running' | 'success' | 'failed'; + +/** + * Tracks in-flight per-change operation status, independent of both the + * change model and push selection. + */ +export class OperationState { + private readonly status = new Map(); + + start(path: string): void { + this.status.set(path, 'running'); + } + + succeed(path: string): void { + this.status.set(path, 'success'); + } + + fail(path: string): void { + this.status.set(path, 'failed'); + } + + reset(path: string): void { + this.status.delete(path); + } + + get(path: string): OperationStatus { + return this.status.get(path) ?? 'idle'; + } + + clear(): void { + this.status.clear(); + } +} diff --git a/src/logic/source-control/PushSelectionStore.ts b/src/logic/source-control/PushSelectionStore.ts new file mode 100644 index 0000000..380d877 --- /dev/null +++ b/src/logic/source-control/PushSelectionStore.ts @@ -0,0 +1,34 @@ +/** + * Tracks which pending sync changes are "Ready to Push" — independent of the + * underlying change/plan model and of any UI. Deliberately avoids VCS + * stage/unstage terminology since this isn't a staging area. + */ +export class PushSelectionStore { + private readonly selected = new Set(); + + includeForPush(path: string): void { + this.selected.add(path); + } + + excludeFromPush(path: string): void { + this.selected.delete(path); + } + + isIncluded(path: string): boolean { + return this.selected.has(path); + } + + getSelectedPaths(): string[] { + return [...this.selected]; + } + + /** Drops selections for paths that are no longer present, keeping the rest. */ + refresh(currentPaths: readonly string[]): void { + const present = new Set(currentPaths); + for (const path of this.selected) { + if (!present.has(path)) { + this.selected.delete(path); + } + } + } +} diff --git a/tests/logic/source-control/OperationState.test.ts b/tests/logic/source-control/OperationState.test.ts new file mode 100644 index 0000000..c60bd4d --- /dev/null +++ b/tests/logic/source-control/OperationState.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; +import { OperationState } from '../../../src/logic/source-control/OperationState'; + +describe('OperationState', () => { + it('defaults to idle for an untracked path', () => { + const state = new OperationState(); + + expect(state.get('a.md')).toBe('idle'); + }); + + it('moves through running, success, and failed', () => { + const state = new OperationState(); + + state.start('a.md'); + expect(state.get('a.md')).toBe('running'); + + state.succeed('a.md'); + expect(state.get('a.md')).toBe('success'); + + state.start('a.md'); + state.fail('a.md'); + expect(state.get('a.md')).toBe('failed'); + }); + + it('tracks multiple paths independently', () => { + const state = new OperationState(); + + state.start('a.md'); + state.succeed('b.md'); + + expect(state.get('a.md')).toBe('running'); + expect(state.get('b.md')).toBe('success'); + expect(state.get('c.md')).toBe('idle'); + }); + + it('resets a single path back to idle', () => { + const state = new OperationState(); + state.start('a.md'); + + state.reset('a.md'); + + expect(state.get('a.md')).toBe('idle'); + }); + + it('clears all tracked state', () => { + const state = new OperationState(); + state.start('a.md'); + state.succeed('b.md'); + + state.clear(); + + expect(state.get('a.md')).toBe('idle'); + expect(state.get('b.md')).toBe('idle'); + }); +}); diff --git a/tests/logic/source-control/PushSelectionStore.test.ts b/tests/logic/source-control/PushSelectionStore.test.ts new file mode 100644 index 0000000..3fb97f0 --- /dev/null +++ b/tests/logic/source-control/PushSelectionStore.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import { PushSelectionStore } from '../../../src/logic/source-control/PushSelectionStore'; + +describe('PushSelectionStore', () => { + it('includes a change for push', () => { + const store = new PushSelectionStore(); + + store.includeForPush('a.md'); + + expect(store.isIncluded('a.md')).toBe(true); + expect(store.getSelectedPaths()).toEqual(['a.md']); + }); + + it('excludes a change from push', () => { + const store = new PushSelectionStore(); + store.includeForPush('a.md'); + + store.excludeFromPush('a.md'); + + expect(store.isIncluded('a.md')).toBe(false); + expect(store.getSelectedPaths()).toEqual([]); + }); + + it('tracks multiple changes independently', () => { + const store = new PushSelectionStore(); + + store.includeForPush('a.md'); + store.includeForPush('b.md'); + store.excludeFromPush('a.md'); + + expect(store.isIncluded('a.md')).toBe(false); + expect(store.isIncluded('b.md')).toBe(true); + expect(store.getSelectedPaths()).toEqual(['b.md']); + }); + + it('keeps selection across a refresh when the change is still present', () => { + const store = new PushSelectionStore(); + store.includeForPush('a.md'); + + store.refresh(['a.md', 'b.md']); + + expect(store.isIncluded('a.md')).toBe(true); + }); + + it('clears selection for a change removed by refresh', () => { + const store = new PushSelectionStore(); + store.includeForPush('a.md'); + store.includeForPush('b.md'); + + store.refresh(['b.md']); + + expect(store.isIncluded('a.md')).toBe(false); + expect(store.isIncluded('b.md')).toBe(true); + expect(store.getSelectedPaths()).toEqual(['b.md']); + }); +}); From 4b09425e75438a7ca17465876d0260897a2cde35 Mon Sep 17 00:00:00 2001 From: tianyao Date: Sat, 22 Aug 2026 04:28:31 +0000 Subject: [PATCH 002/104] fix(source-control): key selection and operation state by ChangeId PushSelectionStore and OperationState were keyed by file path, so a rename/move would silently drop a pending selection or in-flight operation status. Introduce a branded ChangeId type and rekey both stores on it so state stores are keyed by SyncChange identity instead of file path, preserving user intent across rename/move. Co-Authored-By: Claude Sonnet 5 --- src/logic/source-control/OperationState.ts | 28 +++++---- .../source-control/PushSelectionStore.ts | 33 ++++++----- src/logic/source-control/types.ts | 16 +++++ .../source-control/OperationState.test.ts | 59 +++++++++++-------- .../source-control/PushSelectionStore.test.ts | 55 ++++++++++------- 5 files changed, 121 insertions(+), 70 deletions(-) create mode 100644 src/logic/source-control/types.ts diff --git a/src/logic/source-control/OperationState.ts b/src/logic/source-control/OperationState.ts index 7aebeeb..d03869e 100644 --- a/src/logic/source-control/OperationState.ts +++ b/src/logic/source-control/OperationState.ts @@ -1,30 +1,36 @@ +import type { ChangeId } from './types'; + export type OperationStatus = 'idle' | 'running' | 'success' | 'failed'; /** * Tracks in-flight per-change operation status, independent of both the * change model and push selection. + * + * Keyed by ChangeId rather than path so a rename/move doesn't lose in-flight + * status, and so two different changes that happen to share a path (e.g. a + * delete followed by a re-add) don't cross-contaminate each other's state. */ export class OperationState { - private readonly status = new Map(); + private readonly status = new Map(); - start(path: string): void { - this.status.set(path, 'running'); + start(changeId: ChangeId): void { + this.status.set(changeId, 'running'); } - succeed(path: string): void { - this.status.set(path, 'success'); + succeed(changeId: ChangeId): void { + this.status.set(changeId, 'success'); } - fail(path: string): void { - this.status.set(path, 'failed'); + fail(changeId: ChangeId): void { + this.status.set(changeId, 'failed'); } - reset(path: string): void { - this.status.delete(path); + reset(changeId: ChangeId): void { + this.status.delete(changeId); } - get(path: string): OperationStatus { - return this.status.get(path) ?? 'idle'; + get(changeId: ChangeId): OperationStatus { + return this.status.get(changeId) ?? 'idle'; } clear(): void { diff --git a/src/logic/source-control/PushSelectionStore.ts b/src/logic/source-control/PushSelectionStore.ts index 380d877..658a8c3 100644 --- a/src/logic/source-control/PushSelectionStore.ts +++ b/src/logic/source-control/PushSelectionStore.ts @@ -1,33 +1,38 @@ +import type { ChangeId } from './types'; + /** * Tracks which pending sync changes are "Ready to Push" — independent of the * underlying change/plan model and of any UI. Deliberately avoids VCS * stage/unstage terminology since this isn't a staging area. + * + * Keyed by ChangeId rather than path so a rename/move doesn't drop the + * selection. */ export class PushSelectionStore { - private readonly selected = new Set(); + private readonly selected = new Set(); - includeForPush(path: string): void { - this.selected.add(path); + includeForPush(changeId: ChangeId): void { + this.selected.add(changeId); } - excludeFromPush(path: string): void { - this.selected.delete(path); + excludeFromPush(changeId: ChangeId): void { + this.selected.delete(changeId); } - isIncluded(path: string): boolean { - return this.selected.has(path); + isIncluded(changeId: ChangeId): boolean { + return this.selected.has(changeId); } - getSelectedPaths(): string[] { + getSelectedChangeIds(): ChangeId[] { return [...this.selected]; } - /** Drops selections for paths that are no longer present, keeping the rest. */ - refresh(currentPaths: readonly string[]): void { - const present = new Set(currentPaths); - for (const path of this.selected) { - if (!present.has(path)) { - this.selected.delete(path); + /** Drops selections for change 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); } } } diff --git a/src/logic/source-control/types.ts b/src/logic/source-control/types.ts new file mode 100644 index 0000000..fa8724e --- /dev/null +++ b/src/logic/source-control/types.ts @@ -0,0 +1,16 @@ +declare const changeIdBrand: unique symbol; + +/** + * Stable identity for a pending sync change, independent of its current file + * path. Using this instead of a path lets selection and operation state + * survive rename/move without losing the user's intent. + * + * Branded (rather than a plain `string` alias) so callers can't pass a raw + * file path where a ChangeId is expected. + */ +export type ChangeId = string & { readonly [changeIdBrand]: never }; + +/** Wraps a raw id string as a ChangeId at the one place it's minted. */ +export function toChangeId(id: string): ChangeId { + return id as ChangeId; +} diff --git a/tests/logic/source-control/OperationState.test.ts b/tests/logic/source-control/OperationState.test.ts index c60bd4d..f34aa92 100644 --- a/tests/logic/source-control/OperationState.test.ts +++ b/tests/logic/source-control/OperationState.test.ts @@ -1,55 +1,68 @@ import { describe, expect, it } from 'vitest'; import { OperationState } from '../../../src/logic/source-control/OperationState'; +import { toChangeId } from '../../../src/logic/source-control/types'; describe('OperationState', () => { - it('defaults to idle for an untracked path', () => { + it('defaults to idle for an untracked change', () => { const state = new OperationState(); - expect(state.get('a.md')).toBe('idle'); + expect(state.get(toChangeId('change-a'))).toBe('idle'); }); it('moves through running, success, and failed', () => { const state = new OperationState(); - state.start('a.md'); - expect(state.get('a.md')).toBe('running'); + state.start(toChangeId('change-a')); + expect(state.get(toChangeId('change-a'))).toBe('running'); - state.succeed('a.md'); - expect(state.get('a.md')).toBe('success'); + state.succeed(toChangeId('change-a')); + expect(state.get(toChangeId('change-a'))).toBe('success'); - state.start('a.md'); - state.fail('a.md'); - expect(state.get('a.md')).toBe('failed'); + state.start(toChangeId('change-a')); + state.fail(toChangeId('change-a')); + expect(state.get(toChangeId('change-a'))).toBe('failed'); }); - it('tracks multiple paths independently', () => { + it('tracks multiple changes independently', () => { const state = new OperationState(); - state.start('a.md'); - state.succeed('b.md'); + state.start(toChangeId('change-a')); + state.succeed(toChangeId('change-b')); - expect(state.get('a.md')).toBe('running'); - expect(state.get('b.md')).toBe('success'); - expect(state.get('c.md')).toBe('idle'); + expect(state.get(toChangeId('change-a'))).toBe('running'); + expect(state.get(toChangeId('change-b'))).toBe('success'); + expect(state.get(toChangeId('change-c'))).toBe('idle'); }); - it('resets a single path back to idle', () => { + it('resets a single change back to idle', () => { const state = new OperationState(); - state.start('a.md'); + state.start(toChangeId('change-a')); - state.reset('a.md'); + state.reset(toChangeId('change-a')); - expect(state.get('a.md')).toBe('idle'); + expect(state.get(toChangeId('change-a'))).toBe('idle'); }); it('clears all tracked state', () => { const state = new OperationState(); - state.start('a.md'); - state.succeed('b.md'); + state.start(toChangeId('change-a')); + state.succeed(toChangeId('change-b')); state.clear(); - expect(state.get('a.md')).toBe('idle'); - expect(state.get('b.md')).toBe('idle'); + expect(state.get(toChangeId('change-a'))).toBe('idle'); + expect(state.get(toChangeId('change-b'))).toBe('idle'); + }); + + it('does not cross-contaminate two changes that share a path', () => { + const state = new OperationState(); + + // change-1 and change-2 both happen to touch a.md (e.g. delete + re-add) + state.start(toChangeId('change-1')); + state.succeed(toChangeId('change-1')); + state.start(toChangeId('change-2')); + + expect(state.get(toChangeId('change-1'))).toBe('success'); + expect(state.get(toChangeId('change-2'))).toBe('running'); }); }); diff --git a/tests/logic/source-control/PushSelectionStore.test.ts b/tests/logic/source-control/PushSelectionStore.test.ts index 3fb97f0..9b382da 100644 --- a/tests/logic/source-control/PushSelectionStore.test.ts +++ b/tests/logic/source-control/PushSelectionStore.test.ts @@ -1,56 +1,67 @@ import { describe, expect, it } from 'vitest'; import { PushSelectionStore } from '../../../src/logic/source-control/PushSelectionStore'; +import { toChangeId } from '../../../src/logic/source-control/types'; describe('PushSelectionStore', () => { it('includes a change for push', () => { const store = new PushSelectionStore(); - store.includeForPush('a.md'); + store.includeForPush(toChangeId('change-a')); - expect(store.isIncluded('a.md')).toBe(true); - expect(store.getSelectedPaths()).toEqual(['a.md']); + expect(store.isIncluded(toChangeId('change-a'))).toBe(true); + expect(store.getSelectedChangeIds()).toEqual([toChangeId('change-a')]); }); it('excludes a change from push', () => { const store = new PushSelectionStore(); - store.includeForPush('a.md'); + store.includeForPush(toChangeId('change-a')); - store.excludeFromPush('a.md'); + store.excludeFromPush(toChangeId('change-a')); - expect(store.isIncluded('a.md')).toBe(false); - expect(store.getSelectedPaths()).toEqual([]); + expect(store.isIncluded(toChangeId('change-a'))).toBe(false); + expect(store.getSelectedChangeIds()).toEqual([]); }); it('tracks multiple changes independently', () => { const store = new PushSelectionStore(); - store.includeForPush('a.md'); - store.includeForPush('b.md'); - store.excludeFromPush('a.md'); + store.includeForPush(toChangeId('change-a')); + store.includeForPush(toChangeId('change-b')); + store.excludeFromPush(toChangeId('change-a')); - expect(store.isIncluded('a.md')).toBe(false); - expect(store.isIncluded('b.md')).toBe(true); - expect(store.getSelectedPaths()).toEqual(['b.md']); + expect(store.isIncluded(toChangeId('change-a'))).toBe(false); + expect(store.isIncluded(toChangeId('change-b'))).toBe(true); + expect(store.getSelectedChangeIds()).toEqual([toChangeId('change-b')]); }); it('keeps selection across a refresh when the change is still present', () => { const store = new PushSelectionStore(); - store.includeForPush('a.md'); + store.includeForPush(toChangeId('change-a')); - store.refresh(['a.md', 'b.md']); + store.refresh([toChangeId('change-a'), toChangeId('change-b')]); - expect(store.isIncluded('a.md')).toBe(true); + expect(store.isIncluded(toChangeId('change-a'))).toBe(true); }); it('clears selection for a change removed by refresh', () => { const store = new PushSelectionStore(); - store.includeForPush('a.md'); - store.includeForPush('b.md'); + store.includeForPush(toChangeId('change-a')); + store.includeForPush(toChangeId('change-b')); - store.refresh(['b.md']); + store.refresh([toChangeId('change-b')]); - expect(store.isIncluded('a.md')).toBe(false); - expect(store.isIncluded('b.md')).toBe(true); - expect(store.getSelectedPaths()).toEqual(['b.md']); + expect(store.isIncluded(toChangeId('change-a'))).toBe(false); + expect(store.isIncluded(toChangeId('change-b'))).toBe(true); + expect(store.getSelectedChangeIds()).toEqual([toChangeId('change-b')]); + }); + + it('keeps selection when path changes but change id stays', () => { + const store = new PushSelectionStore(); + store.includeForPush(toChangeId('change-1')); + + // old.md renamed to new.md, but the change id is stable + store.refresh([toChangeId('change-1')]); + + expect(store.isIncluded(toChangeId('change-1'))).toBe(true); }); }); From 5d5645e6aac60d13ec5bf7e63e996e2c56fac272 Mon Sep 17 00:00:00 2001 From: ClaudiaFang Date: Sat, 22 Aug 2026 12:56:22 +0800 Subject: [PATCH 003/104] docs: add source control refactor roadmap --- .../phase-1-viewmodel-foundation.md | 65 +++++++++++++++ .../phase-2-action-unification.md | 72 ++++++++++++++++ .../phase-3-source-control-ui.md | 82 +++++++++++++++++++ .../phase-4-legacy-cleanup.md | 58 +++++++++++++ 4 files changed, 277 insertions(+) create mode 100644 docs/source-control-refactor/phase-1-viewmodel-foundation.md create mode 100644 docs/source-control-refactor/phase-2-action-unification.md create mode 100644 docs/source-control-refactor/phase-3-source-control-ui.md create mode 100644 docs/source-control-refactor/phase-4-legacy-cleanup.md diff --git a/docs/source-control-refactor/phase-1-viewmodel-foundation.md b/docs/source-control-refactor/phase-1-viewmodel-foundation.md new file mode 100644 index 0000000..ca14c94 --- /dev/null +++ b/docs/source-control-refactor/phase-1-viewmodel-foundation.md @@ -0,0 +1,65 @@ +# Phase 1 — Source Control ViewModel Foundation + +## Goal + +建立 Source Control UI 與 Sync domain 之間的 ViewModel layer。 + +本階段不修改同步行為,只整理資料流。 + +## Scope + +- ChangeRepository +- SourceControlFilter +- SourceControlViewModel +- ChangeTreeBuilder + +## Architecture + +``` +UI + | +SourceControlViewModel + | +SyncManager +``` + +## Modules + +``` +src/logic/source-control/ +├── ChangeRepository.ts +├── SourceControlFilter.ts +├── SourceControlViewModel.ts +└── ChangeTreeBuilder.ts +``` + +## Filter + +Supported: + +- all +- changes +- ready-to-push +- remote-changes +- conflicts +- synced + +## Rules + +UI components consume ViewModel only. + +No direct SyncManager access from UI. + +## Tests + +- ChangeRepository +- SourceControlViewModel +- ChangeTreeBuilder + +Cases: + +- local changes +- remote changes +- conflicts +- ready to push +- rename keeps ChangeId diff --git a/docs/source-control-refactor/phase-2-action-unification.md b/docs/source-control-refactor/phase-2-action-unification.md new file mode 100644 index 0000000..f9623bd --- /dev/null +++ b/docs/source-control-refactor/phase-2-action-unification.md @@ -0,0 +1,72 @@ +# Phase 2 — Sync Action Unification + +## Goal + +統一 Source Control、Context Menu、Single File 操作的 pipeline。 + +## Architecture + +``` +User Action + | +SourceControlActionService + | +SyncPlan + | +SyncExecutor + | +Git Provider +``` + +## New Module + +``` +src/logic/source-control/ +└── SourceControlActionService.ts +``` + +## Actions + +- Push +- Pull +- Delete Remote +- Delete Local +- Resolve Conflict + +## Rules + +ActionService: + +DO: +- convert user intent to SyncPlan + +DO NOT: +- execute git operation +- classify changes + +## Flows + +Single file: + +``` +changeId + -> ActionService + -> SyncPlan + -> Executor +``` + +Batch: + +``` +changeIds + -> ActionService + -> SyncPlan +``` + +## Tests + +- single push +- batch push +- pull +- conflict resolution +- invalid ChangeId diff --git a/docs/source-control-refactor/phase-3-source-control-ui.md b/docs/source-control-refactor/phase-3-source-control-ui.md new file mode 100644 index 0000000..f929917 --- /dev/null +++ b/docs/source-control-refactor/phase-3-source-control-ui.md @@ -0,0 +1,82 @@ +# Phase 3 — Source Control UI + +## Goal + +建立 VS Code style Source Control workflow。 + +## Layout + +``` +SourceControlView + | + + Header + + Filter + + ChangeTree + + DiffPanel +``` + +## Sections + +- READY TO PUSH +- CHANGES +- REMOTE CHANGES +- CONFLICTS +- SYNCED + +## Filter + +``` +All +Changes +Ready to Push +Remote Changes +Conflicts +Synced +``` + +## Tree View + +Example: + +``` +▼ notes + M daily.md + A idea.md + +▼ projects + ! settings.md +``` + +## Components + +``` +SourceControlView +SourceControlHeader +FilterMenu +ChangeTree +ChangeItem +ChangeSection +PushButton +OperationIndicator +``` + +## Responsive + +Desktop: +- Tree + Diff + +Mobile: +- List + Detail + +## Tests + +- SourceControlView +- ChangeTree +- FilterMenu + +Cases: + +- filter switching +- selection +- push action +- operation status diff --git a/docs/source-control-refactor/phase-4-legacy-cleanup.md b/docs/source-control-refactor/phase-4-legacy-cleanup.md new file mode 100644 index 0000000..a1da710 --- /dev/null +++ b/docs/source-control-refactor/phase-4-legacy-cleanup.md @@ -0,0 +1,58 @@ +# Phase 4 — Legacy Cleanup + +## Goal + +移除舊 Source Control orchestration,保留同步核心能力。 + +## Remove + +- old status mapping +- duplicated action handling +- legacy SyncStatusView logic + +## Final Architecture + +``` +UI + | +ViewModel + | +ActionService + | +SyncPlan + | +Executor + | +Provider +``` + +## SyncManager + +Before: + +- UI state +- classification +- execution + +After: + +- sync facade + +## Test Cleanup + +Remove: + +- duplicated implementation tests + +Keep: + +- sync integration tests +- provider tests +- conflict tests + +## Acceptance + +- UI has no sync logic +- no duplicate action pipeline +- existing behavior preserved +- architecture docs updated From 76db0825758785bb3f93be651e4c46f9741c715b Mon Sep 17 00:00:00 2001 From: tianyao Date: Sat, 22 Aug 2026 05:11:17 +0000 Subject: [PATCH 004/104] feat(source-control): add ViewModel foundation layer Phase 1 of the Source Control refactor: ChangeRepository, SourceControlFilter, SourceControlViewModel, and ChangeTreeBuilder. Combines SyncChange[], PushSelectionStore, and OperationState into UI-ready state, keyed by ChangeId so renames/moves keep identity. Does not touch SyncManager/SyncPlanner/SyncExecutor or add UI. Co-Authored-By: Claude Sonnet 5 --- src/logic/source-control/ChangeRepository.ts | 37 ++++++ src/logic/source-control/ChangeTreeBuilder.ts | 68 +++++++++++ .../source-control/SourceControlFilter.ts | 26 +++++ .../source-control/SourceControlViewModel.ts | 66 +++++++++++ src/logic/source-control/types.ts | 29 +++++ .../source-control/ChangeRepository.test.ts | 61 ++++++++++ .../source-control/ChangeTreeBuilder.test.ts | 94 +++++++++++++++ .../SourceControlViewModel.test.ts | 108 ++++++++++++++++++ 8 files changed, 489 insertions(+) create mode 100644 src/logic/source-control/ChangeRepository.ts create mode 100644 src/logic/source-control/ChangeTreeBuilder.ts create mode 100644 src/logic/source-control/SourceControlFilter.ts create mode 100644 src/logic/source-control/SourceControlViewModel.ts create mode 100644 tests/logic/source-control/ChangeRepository.test.ts create mode 100644 tests/logic/source-control/ChangeTreeBuilder.test.ts create mode 100644 tests/logic/source-control/SourceControlViewModel.test.ts diff --git a/src/logic/source-control/ChangeRepository.ts b/src/logic/source-control/ChangeRepository.ts new file mode 100644 index 0000000..2463e33 --- /dev/null +++ b/src/logic/source-control/ChangeRepository.ts @@ -0,0 +1,37 @@ +import type { ChangeId, SyncChange } from './types'; + +/** + * 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. + */ +export class ChangeRepository { + private changes: SyncChange[] = []; + private readonly byId = new Map(); + private readonly byPath = new Map(); + + /** Replaces the full change set, e.g. after a status refresh. */ + replace(changes: readonly SyncChange[]): void { + this.changes = [...changes]; + this.byId.clear(); + this.byPath.clear(); + for (const change of this.changes) { + this.byId.set(change.id, change); + this.byPath.set(change.path, change); + } + } + + getAll(): SyncChange[] { + return [...this.changes]; + } + + getById(id: ChangeId): SyncChange | undefined { + return this.byId.get(id); + } + + getByPath(path: string): SyncChange | undefined { + return this.byPath.get(path); + } +} diff --git a/src/logic/source-control/ChangeTreeBuilder.ts b/src/logic/source-control/ChangeTreeBuilder.ts new file mode 100644 index 0000000..b8cdc07 --- /dev/null +++ b/src/logic/source-control/ChangeTreeBuilder.ts @@ -0,0 +1,68 @@ +import type { ChangeId, SyncChange, SyncChangeKind } from './types'; + +export interface ChangeTreeFileNode { + type: 'file'; + id: ChangeId; + name: string; + path: string; + previousPath?: string; + kind: SyncChangeKind; +} + +export interface ChangeTreeFolderNode { + type: 'folder'; + name: string; + path: string; + children: ChangeTreeNode[]; +} + +export type ChangeTreeNode = ChangeTreeFileNode | ChangeTreeFolderNode; + +/** + * Turns a flat `SyncChange[]` into a folder/file tree for rendering. + * A renamed/moved file is placed at its *current* path — `previousPath` + * travels with the file node purely for display (e.g. "old → new"), it does + * not create a second tree entry. + */ +export class ChangeTreeBuilder { + build(changes: readonly SyncChange[]): ChangeTreeNode[] { + const root: ChangeTreeFolderNode = { type: 'folder', name: '', path: '', children: [] }; + for (const change of changes) { + this.insert(root, change); + } + return root.children; + } + + private insert(root: ChangeTreeFolderNode, change: SyncChange): void { + const segments = change.path.split('/').filter(Boolean); + const fileName = segments.pop(); + if (!fileName) return; + + let folder = root; + let accumulatedPath = ''; + for (const segment of segments) { + accumulatedPath = accumulatedPath ? `${accumulatedPath}/${segment}` : segment; + folder = this.getOrCreateFolder(folder, segment, accumulatedPath); + } + + folder.children.push({ + type: 'file', + id: change.id, + name: fileName, + path: change.path, + previousPath: change.previousPath, + kind: change.kind, + }); + } + + private getOrCreateFolder(parent: ChangeTreeFolderNode, name: string, path: string): ChangeTreeFolderNode { + const existing = parent.children.find( + (node): node is ChangeTreeFolderNode => node.type === 'folder' && node.name === name, + ); + if (existing) return existing; + + const created: ChangeTreeFolderNode = { type: 'folder', name, path, children: [] }; + parent.children.push(created); + return created; + } +} diff --git a/src/logic/source-control/SourceControlFilter.ts b/src/logic/source-control/SourceControlFilter.ts new file mode 100644 index 0000000..3229ec2 --- /dev/null +++ b/src/logic/source-control/SourceControlFilter.ts @@ -0,0 +1,26 @@ +import type { PushSelectionStore } from './PushSelectionStore'; +import type { SyncChange } from './types'; + +export type SourceControlFilter = + | 'all' + | 'changes' + | 'ready-to-push' + | 'remote-changes' + | 'conflicts' + | 'synced'; + +/** + * Whether `change` belongs under `filter`. `ready-to-push` is defined purely + * by `PushSelectionStore` membership — it's a user selection, not a fact + * derivable from the change's kind alone. + */ +export function matchesFilter(change: SyncChange, filter: SourceControlFilter, selection: PushSelectionStore): boolean { + switch (filter) { + case 'all': return true; + case 'changes': return change.kind !== 'synced'; + case 'ready-to-push': return selection.isIncluded(change.id); + case 'remote-changes': return change.kind === 'remote-only' || change.kind === 'remote-modified'; + case 'conflicts': return change.kind === 'conflict'; + case 'synced': return change.kind === 'synced'; + } +} diff --git a/src/logic/source-control/SourceControlViewModel.ts b/src/logic/source-control/SourceControlViewModel.ts new file mode 100644 index 0000000..317a285 --- /dev/null +++ b/src/logic/source-control/SourceControlViewModel.ts @@ -0,0 +1,66 @@ +import type { ChangeRepository } from './ChangeRepository'; +import type { OperationState, OperationStatus } from './OperationState'; +import type { PushSelectionStore } from './PushSelectionStore'; +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. */ +export interface SourceControlItem { + id: ChangeId; + path: string; + previousPath?: string; + kind: SyncChangeKind; + isReadyToPush: boolean; + operationStatus: OperationStatus; +} + +/** The complete state the Source Control UI needs to render for a given filter. */ +export interface SourceControlViewState { + filter: SourceControlFilter; + items: SourceControlItem[]; + counts: Record; +} + +const ALL_FILTERS: SourceControlFilter[] = ['all', 'changes', 'ready-to-push', 'remote-changes', 'conflicts', 'synced']; + +/** + * Combines `SyncChange[]` (via `ChangeRepository`), `PushSelectionStore`, 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. + */ +export class SourceControlViewModel { + constructor( + private readonly changes: ChangeRepository, + private readonly selection: PushSelectionStore, + private readonly operations: OperationState, + ) {} + + getState(filter: SourceControlFilter = 'all'): SourceControlViewState { + const all = this.changes.getAll(); + const items = all + .filter(change => matchesFilter(change, filter, this.selection)) + .map(change => this.toItem(change)); + const counts = this.countByFilter(all); + return { filter, items, counts }; + } + + private toItem(change: SyncChange): SourceControlItem { + return { + id: change.id, + path: change.path, + previousPath: change.previousPath, + kind: change.kind, + isReadyToPush: this.selection.isIncluded(change.id), + operationStatus: this.operations.get(change.id), + }; + } + + private countByFilter(changes: readonly SyncChange[]): Record { + const counts = {} as Record; + for (const filter of ALL_FILTERS) { + counts[filter] = changes.filter(change => matchesFilter(change, filter, this.selection)).length; + } + return counts; + } +} diff --git a/src/logic/source-control/types.ts b/src/logic/source-control/types.ts index fa8724e..33054d9 100644 --- a/src/logic/source-control/types.ts +++ b/src/logic/source-control/types.ts @@ -14,3 +14,32 @@ export type ChangeId = string & { readonly [changeIdBrand]: never }; export function toChangeId(id: string): ChangeId { return id as ChangeId; } + +/** + * How a pending change relates local and remote state, independent of any + * push/pull selection or in-flight operation. Mirrors `SyncClassification` + * from the sync domain plus `moved`, since a tracked rename/move is a + * distinct case the Source Control UI must render differently. + */ +export type SyncChangeKind = + | 'local-only' + | 'local-modified' + | 'remote-only' + | 'remote-modified' + | 'moved' + | 'conflict' + | 'synced'; + +/** + * A single pending sync change as consumed by the Source Control ViewModel + * layer. Deliberately decoupled from `PlannedFileAction`/`FileStatus` in the + * sync domain: this is the read-only projection the UI layer works with, keyed + * by the stable `ChangeId` rather than path. + */ +export interface SyncChange { + id: ChangeId; + path: string; + /** Present when this change is a tracked rename/move, for display only. */ + previousPath?: string; + kind: SyncChangeKind; +} diff --git a/tests/logic/source-control/ChangeRepository.test.ts b/tests/logic/source-control/ChangeRepository.test.ts new file mode 100644 index 0000000..2308c00 --- /dev/null +++ b/tests/logic/source-control/ChangeRepository.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; +import { ChangeRepository } from '../../../src/logic/source-control/ChangeRepository'; +import { toChangeId, type SyncChange } from '../../../src/logic/source-control/types'; + +function change(overrides: Partial & Pick): SyncChange { + return { ...overrides }; +} + +describe('ChangeRepository', () => { + it('looks up a change by id', () => { + const repo = new ChangeRepository(); + const local = change({ id: toChangeId('change-a'), path: 'a.md', kind: 'local-only' }); + repo.replace([local]); + + expect(repo.getById(toChangeId('change-a'))).toEqual(local); + expect(repo.getById(toChangeId('missing'))).toBeUndefined(); + }); + + it('looks up a change by path', () => { + const repo = new ChangeRepository(); + const remote = change({ id: toChangeId('change-b'), path: 'b.md', kind: 'remote-only' }); + repo.replace([remote]); + + expect(repo.getByPath('b.md')).toEqual(remote); + expect(repo.getByPath('missing.md')).toBeUndefined(); + }); + + it('exposes the current changes collection', () => { + const repo = new ChangeRepository(); + const a = change({ id: toChangeId('change-a'), path: 'a.md', kind: 'local-only' }); + const b = change({ id: toChangeId('change-b'), path: 'b.md', kind: 'remote-only' }); + repo.replace([a, b]); + + expect(repo.getAll()).toEqual([a, b]); + }); + + it('drops stale entries when replaced', () => { + const repo = new ChangeRepository(); + repo.replace([change({ id: toChangeId('change-a'), path: 'a.md', kind: 'local-only' })]); + + repo.replace([change({ id: toChangeId('change-b'), path: 'b.md', kind: 'remote-only' })]); + + expect(repo.getById(toChangeId('change-a'))).toBeUndefined(); + expect(repo.getByPath('a.md')).toBeUndefined(); + expect(repo.getAll()).toHaveLength(1); + }); + + it('keeps ChangeId stable across a rename, looked up by the new path', () => { + const repo = new ChangeRepository(); + const renamed = change({ + id: toChangeId('change-1'), + path: 'new.md', + previousPath: 'old.md', + kind: 'moved', + }); + repo.replace([renamed]); + + expect(repo.getByPath('new.md')?.id).toBe(toChangeId('change-1')); + expect(repo.getByPath('old.md')).toBeUndefined(); + }); +}); diff --git a/tests/logic/source-control/ChangeTreeBuilder.test.ts b/tests/logic/source-control/ChangeTreeBuilder.test.ts new file mode 100644 index 0000000..5e417e3 --- /dev/null +++ b/tests/logic/source-control/ChangeTreeBuilder.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest'; +import { ChangeTreeBuilder, type ChangeTreeFolderNode } from '../../../src/logic/source-control/ChangeTreeBuilder'; +import { toChangeId, type SyncChange } from '../../../src/logic/source-control/types'; + +describe('ChangeTreeBuilder', () => { + it('maps a local change as a top-level file node', () => { + const builder = new ChangeTreeBuilder(); + const change: SyncChange = { id: toChangeId('c-1'), path: 'a.md', kind: 'local-only' }; + + const tree = builder.build([change]); + + expect(tree).toEqual([ + { type: 'file', id: toChangeId('c-1'), name: 'a.md', path: 'a.md', previousPath: undefined, kind: 'local-only' }, + ]); + }); + + it('maps a remote change the same way as a local one', () => { + const builder = new ChangeTreeBuilder(); + const change: SyncChange = { id: toChangeId('c-1'), path: 'notes/b.md', kind: 'remote-only' }; + + const tree = builder.build([change]); + const folder = tree[0] as ChangeTreeFolderNode; + + expect(folder).toMatchObject({ type: 'folder', name: 'notes', path: 'notes' }); + expect(folder.children).toEqual([ + { type: 'file', id: toChangeId('c-1'), name: 'b.md', path: 'notes/b.md', previousPath: undefined, kind: 'remote-only' }, + ]); + }); + + it('maps a conflict change preserving its ChangeId', () => { + const builder = new ChangeTreeBuilder(); + const change: SyncChange = { id: toChangeId('c-conflict'), path: 'c.md', kind: 'conflict' }; + + const tree = builder.build([change]); + + expect(tree[0]).toMatchObject({ id: toChangeId('c-conflict'), kind: 'conflict' }); + }); + + it('groups files ready to push under the same folder hierarchy', () => { + const builder = new ChangeTreeBuilder(); + const changes: SyncChange[] = [ + { id: toChangeId('c-1'), path: 'notes/a.md', kind: 'local-only' }, + { id: toChangeId('c-2'), path: 'notes/b.md', kind: 'local-modified' }, + ]; + + const tree = builder.build(changes); + const folder = tree[0] as ChangeTreeFolderNode; + + expect(tree).toHaveLength(1); + expect(folder.children.map(child => child.name)).toEqual(['a.md', 'b.md']); + }); + + it('keeps ChangeId stable for a rename and carries previousPath for display', () => { + const builder = new ChangeTreeBuilder(); + const change: SyncChange = { + id: toChangeId('c-1'), + path: 'folder/new.md', + previousPath: 'folder/old.md', + kind: 'moved', + }; + + const tree = builder.build([change]); + const folder = tree[0] as ChangeTreeFolderNode; + const file = folder.children[0]; + + expect(file).toEqual({ + type: 'file', + id: toChangeId('c-1'), + name: 'new.md', + path: 'folder/new.md', + previousPath: 'folder/old.md', + kind: 'moved', + }); + }); + + it('builds nested folder hierarchy for deeply nested paths', () => { + const builder = new ChangeTreeBuilder(); + const changes: SyncChange[] = [ + { id: toChangeId('c-1'), path: 'a/b/c/d.md', kind: 'local-only' }, + ]; + + const tree = builder.build(changes); + const a = tree[0] as ChangeTreeFolderNode; + const b = a.children[0] as ChangeTreeFolderNode; + const c = b.children[0] as ChangeTreeFolderNode; + + expect(a).toMatchObject({ type: 'folder', name: 'a', path: 'a' }); + expect(b).toMatchObject({ type: 'folder', name: 'b', path: 'a/b' }); + expect(c).toMatchObject({ type: 'folder', name: 'c', path: 'a/b/c' }); + expect(c.children).toEqual([ + { type: 'file', id: toChangeId('c-1'), name: 'd.md', path: 'a/b/c/d.md', previousPath: undefined, kind: 'local-only' }, + ]); + }); +}); diff --git a/tests/logic/source-control/SourceControlViewModel.test.ts b/tests/logic/source-control/SourceControlViewModel.test.ts new file mode 100644 index 0000000..eba3edc --- /dev/null +++ b/tests/logic/source-control/SourceControlViewModel.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest'; +import { ChangeRepository } from '../../../src/logic/source-control/ChangeRepository'; +import { OperationState } from '../../../src/logic/source-control/OperationState'; +import { PushSelectionStore } from '../../../src/logic/source-control/PushSelectionStore'; +import { SourceControlViewModel } from '../../../src/logic/source-control/SourceControlViewModel'; +import { toChangeId, type SyncChange } from '../../../src/logic/source-control/types'; + +function buildViewModel(changes: SyncChange[]) { + const repository = new ChangeRepository(); + repository.replace(changes); + const selection = new PushSelectionStore(); + const operations = new OperationState(); + const viewModel = new SourceControlViewModel(repository, selection, operations); + return { viewModel, selection, operations }; +} + +describe('SourceControlViewModel', () => { + it('maps a local change under "changes" and "all"', () => { + const localOnly: SyncChange = { id: toChangeId('c-1'), path: 'a.md', kind: 'local-only' }; + const { viewModel } = buildViewModel([localOnly]); + + expect(viewModel.getState('all').items.map(i => i.id)).toEqual([toChangeId('c-1')]); + expect(viewModel.getState('changes').items.map(i => i.id)).toEqual([toChangeId('c-1')]); + }); + + it('maps a remote change under "remote-changes"', () => { + const remoteOnly: SyncChange = { id: toChangeId('c-1'), path: 'a.md', kind: 'remote-only' }; + const { viewModel } = buildViewModel([remoteOnly]); + + const state = viewModel.getState('remote-changes'); + expect(state.items.map(i => i.id)).toEqual([toChangeId('c-1')]); + expect(viewModel.getState('conflicts').items).toEqual([]); + }); + + it('maps a conflict under "conflicts"', () => { + const conflict: SyncChange = { id: toChangeId('c-1'), path: 'a.md', kind: 'conflict' }; + const { viewModel } = buildViewModel([conflict]); + + expect(viewModel.getState('conflicts').items.map(i => i.id)).toEqual([toChangeId('c-1')]); + expect(viewModel.getState('remote-changes').items).toEqual([]); + }); + + it('maps a change to "ready-to-push" only once selected in PushSelectionStore', () => { + const localOnly: SyncChange = { id: toChangeId('c-1'), path: 'a.md', kind: 'local-only' }; + const { viewModel, selection } = buildViewModel([localOnly]); + + expect(viewModel.getState('ready-to-push').items).toEqual([]); + + selection.includeForPush(toChangeId('c-1')); + + const state = viewModel.getState('ready-to-push'); + expect(state.items.map(i => i.id)).toEqual([toChangeId('c-1')]); + expect(state.items[0]?.isReadyToPush).toBe(true); + }); + + it('reflects OperationState on the item', () => { + const localOnly: SyncChange = { id: toChangeId('c-1'), path: 'a.md', kind: 'local-only' }; + const { viewModel, operations } = buildViewModel([localOnly]); + + operations.start(toChangeId('c-1')); + + expect(viewModel.getState('all').items[0]?.operationStatus).toBe('running'); + }); + + it('excludes synced changes from "changes" but keeps them in "synced" and "all"', () => { + const synced: SyncChange = { id: toChangeId('c-1'), path: 'a.md', kind: 'synced' }; + const { viewModel } = buildViewModel([synced]); + + expect(viewModel.getState('changes').items).toEqual([]); + expect(viewModel.getState('synced').items.map(i => i.id)).toEqual([toChangeId('c-1')]); + expect(viewModel.getState('all').items.map(i => i.id)).toEqual([toChangeId('c-1')]); + }); + + it('counts every filter bucket regardless of the active filter', () => { + const changes: SyncChange[] = [ + { id: toChangeId('c-1'), path: 'a.md', kind: 'local-only' }, + { id: toChangeId('c-2'), path: 'b.md', kind: 'remote-only' }, + { id: toChangeId('c-3'), path: 'c.md', kind: 'conflict' }, + { id: toChangeId('c-4'), path: 'd.md', kind: 'synced' }, + ]; + const { viewModel } = buildViewModel(changes); + + const { counts } = viewModel.getState('all'); + expect(counts).toEqual({ + all: 4, + changes: 3, + 'ready-to-push': 0, + 'remote-changes': 1, + conflicts: 1, + synced: 1, + }); + }); + + it('keeps ChangeId stable across a rename', () => { + const renamed: SyncChange = { + id: toChangeId('c-1'), + path: 'new.md', + previousPath: 'old.md', + kind: 'moved', + }; + const { viewModel } = buildViewModel([renamed]); + + const item = viewModel.getState('all').items[0]; + expect(item?.id).toBe(toChangeId('c-1')); + expect(item?.path).toBe('new.md'); + expect(item?.previousPath).toBe('old.md'); + }); +}); From 7cec661d136b1e73d3ddbc8c54144ac98d15ef81 Mon Sep 17 00:00:00 2001 From: tianyao Date: Sat, 22 Aug 2026 07:47:51 +0000 Subject: [PATCH 005/104] feat(source-control): add Phase 3 source control UI Add SourceControlView and its component tree (ChangeTree, FilterMenu, ChangeSection, ChangeItem, PushButton, OperationIndicator, SourceControlHeader) built on top of the Phase 1 SourceControlViewModel. Reuses the existing DiffPanel for diff rendering. Not yet registered in main.ts; push/diff actions are injected via callbacks pending Phase 2. Co-Authored-By: Claude Sonnet 5 --- src/i18n/locales/en.ts | 18 ++ src/i18n/locales/zh-cn.ts | 18 ++ src/i18n/locales/zh-tw.ts | 18 ++ src/ui/components/icons.ts | 4 + src/ui/source-control/ChangeItem.ts | 64 ++++++ src/ui/source-control/ChangeSection.ts | 41 ++++ src/ui/source-control/ChangeTree.ts | 81 +++++++ src/ui/source-control/FilterMenu.ts | 33 +++ src/ui/source-control/OperationIndicator.ts | 22 ++ src/ui/source-control/PushButton.ts | 14 ++ src/ui/source-control/SourceControlHeader.ts | 21 ++ src/ui/source-control/SourceControlView.ts | 204 ++++++++++++++++++ tests/ui/source-control/ChangeTree.test.ts | 128 +++++++++++ tests/ui/source-control/FilterMenu.test.ts | 58 +++++ .../source-control/SourceControlView.test.ts | 194 +++++++++++++++++ 15 files changed, 918 insertions(+) create mode 100644 src/ui/source-control/ChangeItem.ts create mode 100644 src/ui/source-control/ChangeSection.ts create mode 100644 src/ui/source-control/ChangeTree.ts create mode 100644 src/ui/source-control/FilterMenu.ts create mode 100644 src/ui/source-control/OperationIndicator.ts create mode 100644 src/ui/source-control/PushButton.ts create mode 100644 src/ui/source-control/SourceControlHeader.ts create mode 100644 src/ui/source-control/SourceControlView.ts create mode 100644 tests/ui/source-control/ChangeTree.test.ts create mode 100644 tests/ui/source-control/FilterMenu.test.ts create mode 100644 tests/ui/source-control/SourceControlView.test.ts diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index a5e5355..e0417b9 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -249,6 +249,24 @@ const en = { 'batchConflictModal.continue': 'Continue', 'batchConflictModal.cancel': 'Cancel', 'batchConflictModal.unresolvedWarning': 'Choose a resolution for every conflict before continuing.', + + 'sourceControl.viewTitle': 'Source Control', + 'sourceControl.filter.all': 'All', + 'sourceControl.filter.changes': 'Changes', + 'sourceControl.filter.readyToPush': 'Ready to Push', + 'sourceControl.filter.remoteChanges': 'Remote Changes', + 'sourceControl.filter.conflicts': 'Conflicts', + 'sourceControl.filter.synced': 'Synced', + 'sourceControl.section.readyToPush': 'READY TO PUSH', + 'sourceControl.section.changes': 'CHANGES', + 'sourceControl.section.remoteChanges': 'REMOTE CHANGES', + 'sourceControl.section.conflicts': 'CONFLICTS', + 'sourceControl.section.synced': 'SYNCED', + 'sourceControl.push': ' Push ({count})', + 'sourceControl.push.tooltip': 'Push {count} ready file(s)', + 'sourceControl.empty': 'No changes', + 'sourceControl.diff.selectPrompt': 'Select a change to see its diff.', + 'sourceControl.detail.back': ' Back', }; export default en; diff --git a/src/i18n/locales/zh-cn.ts b/src/i18n/locales/zh-cn.ts index a471d49..c6d3c9f 100644 --- a/src/i18n/locales/zh-cn.ts +++ b/src/i18n/locales/zh-cn.ts @@ -251,6 +251,24 @@ const zhCn: Partial> = { 'batchConflictModal.continue': '继续', 'batchConflictModal.cancel': '取消', 'batchConflictModal.unresolvedWarning': '请先为每个冲突选择解决方式,才能继续。', + + 'sourceControl.viewTitle': '源代码管理', + 'sourceControl.filter.all': '全部', + 'sourceControl.filter.changes': '更改', + 'sourceControl.filter.readyToPush': '待推送', + 'sourceControl.filter.remoteChanges': '远程更改', + 'sourceControl.filter.conflicts': '冲突', + 'sourceControl.filter.synced': '已同步', + 'sourceControl.section.readyToPush': '待推送', + 'sourceControl.section.changes': '更改', + 'sourceControl.section.remoteChanges': '远程更改', + 'sourceControl.section.conflicts': '冲突', + 'sourceControl.section.synced': '已同步', + 'sourceControl.push': ' 推送 ({count})', + 'sourceControl.push.tooltip': '推送 {count} 个已就绪的文件', + 'sourceControl.empty': '没有更改', + 'sourceControl.diff.selectPrompt': '选择一项更改以查看差异。', + 'sourceControl.detail.back': ' 返回', }; export default zhCn; diff --git a/src/i18n/locales/zh-tw.ts b/src/i18n/locales/zh-tw.ts index f56d5cd..6d63b0c 100644 --- a/src/i18n/locales/zh-tw.ts +++ b/src/i18n/locales/zh-tw.ts @@ -251,6 +251,24 @@ const zhTw: Partial> = { 'batchConflictModal.continue': '繼續', 'batchConflictModal.cancel': '取消', 'batchConflictModal.unresolvedWarning': '請先為每個衝突選擇解決方式,才能繼續。', + + 'sourceControl.viewTitle': '原始碼控制', + 'sourceControl.filter.all': '全部', + 'sourceControl.filter.changes': '變更', + 'sourceControl.filter.readyToPush': '待推送', + 'sourceControl.filter.remoteChanges': '遠端變更', + 'sourceControl.filter.conflicts': '衝突', + 'sourceControl.filter.synced': '已同步', + 'sourceControl.section.readyToPush': '待推送', + 'sourceControl.section.changes': '變更', + 'sourceControl.section.remoteChanges': '遠端變更', + 'sourceControl.section.conflicts': '衝突', + 'sourceControl.section.synced': '已同步', + 'sourceControl.push': ' 推送 ({count})', + 'sourceControl.push.tooltip': '推送 {count} 個已就緒的檔案', + 'sourceControl.empty': '沒有變更', + 'sourceControl.diff.selectPrompt': '選擇一項變更以檢視差異。', + 'sourceControl.detail.back': ' 返回', }; export default zhTw; diff --git a/src/ui/components/icons.ts b/src/ui/components/icons.ts index 67337bb..512aa02 100644 --- a/src/ui/components/icons.ts +++ b/src/ui/components/icons.ts @@ -18,6 +18,10 @@ export const ICONS = { diffOpen: 'chevron-up', moved: 'move', revert: 'undo-2', + error: 'alert-triangle', + chevronRight: 'chevron-right', + chevronDown: 'chevron-down', + back: 'arrow-left', // Search filter search: 'search', clear: 'x', diff --git a/src/ui/source-control/ChangeItem.ts b/src/ui/source-control/ChangeItem.ts new file mode 100644 index 0000000..bbb312c --- /dev/null +++ b/src/ui/source-control/ChangeItem.ts @@ -0,0 +1,64 @@ +import { setIcon } from 'obsidian'; +import { ICONS } from '../components/icons'; +import { renderOperationIndicator } from './OperationIndicator'; +import type { SourceControlItem } from '../../logic/source-control/SourceControlViewModel'; +import type { ChangeId, SyncChangeKind } from '../../logic/source-control/types'; + +export interface ChangeItemCallbacks { + onToggleSelect: (id: ChangeId, selected: boolean) => void; + onOpenDiff: (item: SourceControlItem) => void; +} + +interface KindBadge { + letter: string; + cls: string; +} + +/** + * Single-letter status badge per change kind, matching the VS Code style + * tree example in the Phase 3 spec (`M daily.md`, `A idea.md`, `! settings.md`). + */ +const KIND_BADGE: Record = { + 'local-only': { letter: 'A', cls: 'local-only' }, + 'local-modified': { letter: 'M', cls: 'local-modified' }, + 'remote-only': { letter: 'A', cls: 'remote-only' }, + 'remote-modified': { letter: 'M', cls: 'remote-modified' }, + moved: { letter: 'R', cls: 'moved' }, + conflict: { letter: '!', cls: 'conflict' }, + synced: { letter: 'S', cls: 'synced' }, +}; + +/** Renders a single change row: selection checkbox, status badge, name, operation indicator. */ +export function renderChangeItem( + container: HTMLElement, + item: SourceControlItem, + displayName: string, + callbacks: ChangeItemCallbacks, +): HTMLElement { + const row = container.createDiv({ cls: `scv-change-item scv-kind-${item.kind}` }); + row.setAttr('data-change-id', item.id); + + const checkbox = row.createEl('input', { type: 'checkbox', cls: 'scv-change-select' }); + checkbox.checked = item.isReadyToPush; + checkbox.addEventListener('change', () => callbacks.onToggleSelect(item.id, checkbox.checked)); + + const badge = KIND_BADGE[item.kind]; + row.createSpan({ cls: `scv-badge scv-badge-${badge.cls}`, text: badge.letter }); + + const label = row.createDiv({ cls: 'scv-change-name' }); + if (item.previousPath) { + const previousName = item.previousPath.split('/').pop() ?? item.previousPath; + label.createSpan({ cls: 'scv-change-rename-from', text: previousName }); + setIcon(label.createSpan({ cls: 'scv-change-rename-arrow' }), ICONS.moved); + } + label.createSpan({ cls: 'scv-change-name-text', text: displayName }); + + renderOperationIndicator(row, item.operationStatus); + + row.addEventListener('click', (evt) => { + if (evt.target === checkbox) return; + callbacks.onOpenDiff(item); + }); + + return row; +} diff --git a/src/ui/source-control/ChangeSection.ts b/src/ui/source-control/ChangeSection.ts new file mode 100644 index 0000000..8d52dd8 --- /dev/null +++ b/src/ui/source-control/ChangeSection.ts @@ -0,0 +1,41 @@ +import type { SourceControlFilter } from '../../logic/source-control/SourceControlFilter'; +import type { SourceControlItem } from '../../logic/source-control/SourceControlViewModel'; +import { renderChangeTree, type ChangeTreeCallbacks } from './ChangeTree'; + +export interface ChangeSectionProps { + /** One of the section filters (not 'all' — the "All" filter renders every section). */ + id: Exclude; + title: string; + items: readonly SourceControlItem[]; + collapsed: boolean; + collapsedFolders: ReadonlySet; +} + +export interface ChangeSectionCallbacks extends ChangeTreeCallbacks { + onToggleSection: (id: Exclude) => void; +} + +/** Renders one of the five Source Control sections: a collapsible header + its change tree. */ +export function renderChangeSection( + container: HTMLElement, + props: ChangeSectionProps, + callbacks: ChangeSectionCallbacks, +): HTMLElement { + const sectionEl = container.createDiv({ cls: `scv-section scv-section-${props.id}` }); + const header = sectionEl.createDiv({ cls: 'scv-section-header' }); + + const toggle = header.createEl('button', { cls: 'scv-section-toggle' }); + toggle.setAttr('aria-expanded', String(!props.collapsed)); + toggle.setText(props.collapsed ? '▶' : '▼'); + toggle.addEventListener('click', () => callbacks.onToggleSection(props.id)); + + header.createSpan({ cls: 'scv-section-title', text: props.title }); + header.createSpan({ cls: 'scv-section-count', text: String(props.items.length) }); + + if (!props.collapsed) { + const body = sectionEl.createDiv({ cls: 'scv-section-body' }); + renderChangeTree(body, props.items, props.collapsedFolders, callbacks); + } + + return sectionEl; +} diff --git a/src/ui/source-control/ChangeTree.ts b/src/ui/source-control/ChangeTree.ts new file mode 100644 index 0000000..cc4a202 --- /dev/null +++ b/src/ui/source-control/ChangeTree.ts @@ -0,0 +1,81 @@ +import { + ChangeTreeBuilder, + type ChangeTreeFileNode, + type ChangeTreeFolderNode, + type ChangeTreeNode, +} from '../../logic/source-control/ChangeTreeBuilder'; +import type { SourceControlItem } from '../../logic/source-control/SourceControlViewModel'; +import type { ChangeId } from '../../logic/source-control/types'; +import { renderChangeItem, type ChangeItemCallbacks } from './ChangeItem'; + +export interface ChangeTreeCallbacks extends ChangeItemCallbacks { + onToggleFolder: (path: string) => void; +} + +const builder = new ChangeTreeBuilder(); + +/** + * Renders `items` as a folder/file tree, reusing `ChangeTreeBuilder` (Phase 1) + * for the grouping algorithm. `SourceControlItem` is a structural superset of + * `SyncChange`, so the builder's output only carries `id`/`path`/`kind`; a + * by-id lookup restores `isReadyToPush`/`operationStatus` at render time + * instead of duplicating the tree-building logic. + */ +export function renderChangeTree( + container: HTMLElement, + items: readonly SourceControlItem[], + collapsedFolders: ReadonlySet, + callbacks: ChangeTreeCallbacks, +): void { + const byId = new Map(items.map(item => [item.id, item])); + const nodes = builder.build(items); + renderNodes(container, nodes, byId, collapsedFolders, callbacks); +} + +function renderNodes( + container: HTMLElement, + nodes: readonly ChangeTreeNode[], + byId: ReadonlyMap, + collapsedFolders: ReadonlySet, + callbacks: ChangeTreeCallbacks, +): void { + for (const node of nodes) { + if (node.type === 'folder') renderFolder(container, node, byId, collapsedFolders, callbacks); + else renderFile(container, node, byId, callbacks); + } +} + +function renderFolder( + container: HTMLElement, + folder: ChangeTreeFolderNode, + byId: ReadonlyMap, + collapsedFolders: ReadonlySet, + callbacks: ChangeTreeCallbacks, +): void { + const collapsed = collapsedFolders.has(folder.path); + const folderEl = container.createDiv({ cls: 'scv-tree-folder' }); + const row = folderEl.createDiv({ cls: 'scv-tree-folder-row' }); + + const toggle = row.createEl('button', { cls: 'scv-tree-folder-toggle' }); + toggle.setAttr('aria-expanded', String(!collapsed)); + toggle.setText(collapsed ? '▶' : '▼'); + toggle.addEventListener('click', () => callbacks.onToggleFolder(folder.path)); + + row.createSpan({ cls: 'scv-tree-folder-name', text: folder.name }); + + if (!collapsed) { + const childrenEl = folderEl.createDiv({ cls: 'scv-tree-children' }); + renderNodes(childrenEl, folder.children, byId, collapsedFolders, callbacks); + } +} + +function renderFile( + container: HTMLElement, + file: ChangeTreeFileNode, + byId: ReadonlyMap, + callbacks: ChangeTreeCallbacks, +): void { + const item = byId.get(file.id); + if (!item) return; + renderChangeItem(container, item, file.name, callbacks); +} diff --git a/src/ui/source-control/FilterMenu.ts b/src/ui/source-control/FilterMenu.ts new file mode 100644 index 0000000..3c193b3 --- /dev/null +++ b/src/ui/source-control/FilterMenu.ts @@ -0,0 +1,33 @@ +import { t, type TranslationKey } from '../../i18n'; +import type { SourceControlFilter } from '../../logic/source-control/SourceControlFilter'; + +/** Order and labels match the Phase 3 spec's Filter section exactly. */ +const FILTER_ORDER: SourceControlFilter[] = ['all', 'changes', 'ready-to-push', 'remote-changes', 'conflicts', 'synced']; + +const FILTER_LABEL_KEYS: Record = { + all: 'sourceControl.filter.all', + changes: 'sourceControl.filter.changes', + 'ready-to-push': 'sourceControl.filter.readyToPush', + 'remote-changes': 'sourceControl.filter.remoteChanges', + conflicts: 'sourceControl.filter.conflicts', + synced: 'sourceControl.filter.synced', +}; + +/** Renders the six-way Source Control filter switch, with per-filter counts from the ViewModel. */ +export function renderFilterMenu( + container: HTMLElement, + current: SourceControlFilter, + counts: Record, + onChange: (filter: SourceControlFilter) => void, +): void { + const menu = container.createDiv({ cls: 'scv-filter-menu' }); + for (const value of FILTER_ORDER) { + const isActive = value === current; + const btn = menu.createEl('button', { cls: `scv-filter-option${isActive ? ' is-active' : ''}` }); + btn.setAttr('data-filter', value); + btn.setAttr('aria-pressed', String(isActive)); + btn.createSpan({ cls: 'scv-filter-label', text: t(FILTER_LABEL_KEYS[value]) }); + btn.createSpan({ cls: 'scv-filter-count', text: String(counts[value] ?? 0) }); + btn.addEventListener('click', () => onChange(value)); + } +} diff --git a/src/ui/source-control/OperationIndicator.ts b/src/ui/source-control/OperationIndicator.ts new file mode 100644 index 0000000..8785db8 --- /dev/null +++ b/src/ui/source-control/OperationIndicator.ts @@ -0,0 +1,22 @@ +import { setIcon } from 'obsidian'; +import { ICONS } from '../components/icons'; +import type { OperationStatus } from '../../logic/source-control/OperationState'; + +/** + * Renders a small per-change status indicator for an in-flight operation. + * Renders nothing for 'idle' — the common case — so rows stay quiet until an + * operation is actually running/finished. + */ +export function renderOperationIndicator(container: HTMLElement, status: OperationStatus): HTMLElement | undefined { + if (status === 'idle') return undefined; + + const el = container.createSpan({ cls: `scv-op-indicator scv-op-${status}` }); + setIcon(el, operationIcon(status)); + return el; +} + +function operationIcon(status: Exclude): string { + if (status === 'running') return ICONS.checking; + if (status === 'success') return ICONS.synced; + return ICONS.error; +} diff --git a/src/ui/source-control/PushButton.ts b/src/ui/source-control/PushButton.ts new file mode 100644 index 0000000..13dd9c9 --- /dev/null +++ b/src/ui/source-control/PushButton.ts @@ -0,0 +1,14 @@ +import { setIcon, setTooltip } from 'obsidian'; +import { ICONS } from '../components/icons'; +import { t } from '../../i18n'; + +/** Renders the "Push (N)" button; disabled when there's nothing selected for push. */ +export function renderPushButton(container: HTMLElement, readyToPushCount: number, onPush: () => void): HTMLButtonElement { + const btn = container.createEl('button', { cls: 'scv-push-btn' }); + setIcon(btn.createSpan(), ICONS.push); + btn.createSpan({ cls: 'scv-push-btn-label', text: t('sourceControl.push', { count: readyToPushCount }) }); + btn.disabled = readyToPushCount === 0; + setTooltip(btn, t('sourceControl.push.tooltip', { count: readyToPushCount })); + btn.addEventListener('click', onPush); + return btn; +} diff --git a/src/ui/source-control/SourceControlHeader.ts b/src/ui/source-control/SourceControlHeader.ts new file mode 100644 index 0000000..a21d05b --- /dev/null +++ b/src/ui/source-control/SourceControlHeader.ts @@ -0,0 +1,21 @@ +import { t } from '../../i18n'; +import { renderPushButton } from './PushButton'; + +export interface SourceControlHeaderProps { + readyToPushCount: number; +} + +export interface SourceControlHeaderCallbacks { + onPush: () => void; +} + +/** Renders the Source Control view title and its Push button. */ +export function renderSourceControlHeader( + container: HTMLElement, + props: SourceControlHeaderProps, + callbacks: SourceControlHeaderCallbacks, +): void { + const header = container.createDiv({ cls: 'scv-header' }); + header.createSpan({ cls: 'scv-header-title', text: t('sourceControl.viewTitle') }); + renderPushButton(header, props.readyToPushCount, callbacks.onPush); +} diff --git a/src/ui/source-control/SourceControlView.ts b/src/ui/source-control/SourceControlView.ts new file mode 100644 index 0000000..0010a90 --- /dev/null +++ b/src/ui/source-control/SourceControlView.ts @@ -0,0 +1,204 @@ +import { Platform } from 'obsidian'; +import { t, type TranslationKey } from '../../i18n'; +import type { PushSelectionStore } from '../../logic/source-control/PushSelectionStore'; +import type { SourceControlFilter } from '../../logic/source-control/SourceControlFilter'; +import { SourceControlViewModel, type SourceControlItem } from '../../logic/source-control/SourceControlViewModel'; +import type { ChangeId } from '../../logic/source-control/types'; +import { renderDiffPanel } from '../components/DiffPanel'; +import { renderChangeSection } from './ChangeSection'; +import { renderChangeTree, type ChangeTreeCallbacks } from './ChangeTree'; +import { renderFilterMenu } from './FilterMenu'; +import { renderSourceControlHeader } from './SourceControlHeader'; + +export interface SourceControlDiffContent { + remote: string; + local: string; +} + +export interface SourceControlViewCallbacks { + /** Hands push intent off to whatever wires this view to the sync pipeline; never called by the UI directly against a Git provider. */ + onPush: (changeIds: ChangeId[]) => void | Promise; + /** 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. */ + loadDiffContent?: (item: SourceControlItem) => Promise; +} + +type SectionFilter = Exclude; + +/** The five Source Control sections, in the order the spec lists them. */ +const SECTION_FILTERS: SectionFilter[] = ['ready-to-push', 'changes', 'remote-changes', 'conflicts', 'synced']; + +const SECTION_TITLE_KEYS: Record = { + 'ready-to-push': 'sourceControl.section.readyToPush', + changes: 'sourceControl.section.changes', + 'remote-changes': 'sourceControl.section.remoteChanges', + conflicts: 'sourceControl.section.conflicts', + synced: 'sourceControl.section.synced', +}; + +/** + * Composes the Source Control UI (Header, Filter, ChangeTree/sections, Diff + * panel) 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 is + * the one exception — it goes straight to `PushSelectionStore` (Phase 1 + * state), since "ready to push" is just a set membership change, not a sync + * action. + */ +export class SourceControlView { + private filter: SourceControlFilter = 'all'; + private readonly collapsedSections = new Set(); + private readonly collapsedFolders = new Set(); + private selectedChangeId: ChangeId | null = null; + private container?: HTMLElement; + + constructor( + private readonly viewModel: SourceControlViewModel, + private readonly selection: PushSelectionStore, + private readonly callbacks: SourceControlViewCallbacks, + ) {} + + render(container: HTMLElement): void { + this.container = container; + container.empty(); + container.addClass('scv-root'); + + const isMobile = Platform.isMobile; + container.toggleClass('scv-mobile', isMobile); + container.toggleClass('scv-desktop', !isMobile); + + if (isMobile && this.selectedChangeId !== null) { + this.renderDetail(container); + return; + } + + const main = container.createDiv({ cls: 'scv-main' }); + this.renderMain(main); + + if (!isMobile) { + const diffPane = container.createDiv({ cls: 'scv-diff' }); + this.renderDiffPane(diffPane); + } + } + + getFilter(): SourceControlFilter { return this.filter; } + getSelectedChangeId(): ChangeId | null { return this.selectedChangeId; } + + private rerender(): void { + if (this.container) this.render(this.container); + } + + private renderMain(container: HTMLElement): void { + const state = this.viewModel.getState(this.filter); + + renderSourceControlHeader( + container, + { readyToPushCount: state.counts['ready-to-push'] }, + { onPush: () => { void this.callbacks.onPush(this.selection.getSelectedChangeIds()); } }, + ); + + renderFilterMenu(container, this.filter, state.counts, (filter) => { + this.filter = filter; + this.rerender(); + }); + + const body = container.createDiv({ cls: 'scv-body' }); + if (state.items.length === 0) { + body.createDiv({ cls: 'scv-empty', text: t('sourceControl.empty') }); + return; + } + + const treeCallbacks: ChangeTreeCallbacks = { + onToggleFolder: (path) => this.toggleFolder(path), + onToggleSelect: (id, selected) => this.toggleSelect(id, selected), + onOpenDiff: (item) => this.openDiff(item), + }; + + if (this.filter === 'all') { + this.renderSections(body, treeCallbacks); + } else { + renderChangeTree(body, state.items, this.collapsedFolders, treeCallbacks); + } + } + + private renderSections(body: HTMLElement, treeCallbacks: ChangeTreeCallbacks): void { + for (const sectionFilter of SECTION_FILTERS) { + const items = this.viewModel.getState(sectionFilter).items; + if (items.length === 0) continue; + + renderChangeSection( + body, + { + id: sectionFilter, + title: t(SECTION_TITLE_KEYS[sectionFilter]), + items, + collapsed: this.collapsedSections.has(sectionFilter), + collapsedFolders: this.collapsedFolders, + }, + { + ...treeCallbacks, + onToggleSection: (id) => this.toggleSection(id), + }, + ); + } + } + + private renderDiffPane(container: HTMLElement): void { + if (!this.selectedChangeId) { + container.createDiv({ cls: 'scv-diff-empty', text: t('sourceControl.diff.selectPrompt') }); + return; + } + void this.loadAndRenderDiff(container, this.selectedChangeId); + } + + private renderDetail(root: HTMLElement): void { + const detail = root.createDiv({ cls: 'scv-detail' }); + const backBtn = detail.createEl('button', { cls: 'scv-detail-back', text: t('sourceControl.detail.back') }); + backBtn.addEventListener('click', () => { + this.selectedChangeId = null; + this.rerender(); + }); + + const diffContainer = detail.createDiv({ cls: 'scv-detail-diff' }); + if (this.selectedChangeId) void this.loadAndRenderDiff(diffContainer, this.selectedChangeId); + } + + private async loadAndRenderDiff(container: HTMLElement, changeId: ChangeId): Promise { + if (!this.callbacks.loadDiffContent) return; + const item = this.viewModel.getState('all').items.find(i => i.id === 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); + } + + private toggleSection(id: SectionFilter): void { + if (this.collapsedSections.has(id)) this.collapsedSections.delete(id); + else this.collapsedSections.add(id); + this.rerender(); + } + + private toggleFolder(path: string): void { + if (this.collapsedFolders.has(path)) this.collapsedFolders.delete(path); + else this.collapsedFolders.add(path); + this.rerender(); + } + + private toggleSelect(id: ChangeId, selected: boolean): void { + if (selected) this.selection.includeForPush(id); + else this.selection.excludeFromPush(id); + this.rerender(); + } + + private openDiff(item: SourceControlItem): void { + this.selectedChangeId = item.id; + if (this.callbacks.onOpenDiff) void this.callbacks.onOpenDiff(item); + this.rerender(); + } +} diff --git a/tests/ui/source-control/ChangeTree.test.ts b/tests/ui/source-control/ChangeTree.test.ts new file mode 100644 index 0000000..63d120f --- /dev/null +++ b/tests/ui/source-control/ChangeTree.test.ts @@ -0,0 +1,128 @@ +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 { toChangeId } from '../../../src/logic/source-control/types'; +import { setupObsidianDOM, createContainer } from '../setup-dom'; + +beforeAll(() => { setupObsidianDOM(); }); + +function item(overrides: Partial & Pick): SourceControlItem { + return { isReadyToPush: false, operationStatus: 'idle', ...overrides }; +} + +describe('renderChangeTree', () => { + let container: HTMLElement; + let callbacks: ChangeTreeCallbacks; + + beforeEach(() => { + container = createContainer(); + callbacks = { + onToggleFolder: vi.fn(), + onToggleSelect: vi.fn(), + onOpenDiff: vi.fn(), + }; + }); + + it('groups changes into nested folders', () => { + const items = [ + item({ id: toChangeId('c-1'), path: 'notes/daily.md', kind: 'local-modified' }), + item({ id: toChangeId('c-2'), path: 'notes/idea.md', kind: 'local-only' }), + item({ id: toChangeId('c-3'), path: 'projects/settings.md', kind: 'conflict' }), + ]; + renderChangeTree(container, items, new Set(), callbacks); + + const folders = container.querySelectorAll('.scv-tree-folder-name'); + expect(Array.from(folders).map(f => f.textContent)).toEqual(['notes', 'projects']); + expect(container.querySelectorAll('.scv-change-item')).toHaveLength(3); + }); + + it('renders the kind badge letter matching the spec example (M / A / !)', () => { + const items = [ + item({ id: toChangeId('c-1'), path: 'daily.md', kind: 'local-modified' }), + item({ id: toChangeId('c-2'), path: 'idea.md', kind: 'local-only' }), + item({ id: toChangeId('c-3'), path: 'settings.md', kind: 'conflict' }), + ]; + renderChangeTree(container, items, new Set(), callbacks); + + const badges = Array.from(container.querySelectorAll('.scv-badge')).map(b => b.textContent); + expect(badges).toEqual(['M', 'A', '!']); + }); + + it('shows the previous path for a rename, keyed by the stable ChangeId', () => { + const items = [ + item({ id: toChangeId('c-1'), path: 'new-name.md', previousPath: 'old-name.md', kind: 'moved' }), + ]; + renderChangeTree(container, items, new Set(), callbacks); + + const row = container.querySelector('.scv-change-item') as HTMLElement; + expect(row.getAttribute('data-change-id')).toBe('c-1'); + expect(row.querySelector('.scv-change-rename-from')?.textContent).toBe('old-name.md'); + expect(row.querySelector('.scv-change-name-text')?.textContent).toBe('new-name.md'); + }); + + it('reflects isReadyToPush on the selection checkbox', () => { + const items = [item({ id: toChangeId('c-1'), path: 'a.md', kind: 'local-only', isReadyToPush: true })]; + renderChangeTree(container, items, new Set(), callbacks); + + const checkbox = container.querySelector('.scv-change-select') as HTMLInputElement; + expect(checkbox.checked).toBe(true); + }); + + it('calls onToggleSelect with the ChangeId when the checkbox changes', () => { + const items = [item({ id: toChangeId('c-1'), path: 'a.md', kind: 'local-only' })]; + renderChangeTree(container, items, new Set(), callbacks); + + const checkbox = container.querySelector('.scv-change-select') as HTMLInputElement; + checkbox.checked = true; + checkbox.dispatchEvent(new Event('change')); + + expect(callbacks.onToggleSelect).toHaveBeenCalledWith(toChangeId('c-1'), true); + }); + + it('calls onOpenDiff when the row (not the checkbox) is clicked', () => { + const changeItem = item({ id: toChangeId('c-1'), path: 'a.md', kind: 'local-only' }); + renderChangeTree(container, [changeItem], new Set(), callbacks); + + (container.querySelector('.scv-change-item') as HTMLElement).click(); + + expect(callbacks.onOpenDiff).toHaveBeenCalledWith(changeItem); + }); + + it('does not call onOpenDiff when the checkbox itself is clicked', () => { + const items = [item({ id: toChangeId('c-1'), path: 'a.md', kind: 'local-only' })]; + renderChangeTree(container, items, new Set(), callbacks); + + (container.querySelector('.scv-change-select') as HTMLElement).click(); + + expect(callbacks.onOpenDiff).not.toHaveBeenCalled(); + }); + + it('shows an operation indicator only when the operation is not idle', () => { + const items = [ + item({ id: toChangeId('c-1'), path: 'a.md', kind: 'local-only', operationStatus: 'running' }), + item({ id: toChangeId('c-2'), path: 'b.md', kind: 'local-only', operationStatus: 'idle' }), + ]; + renderChangeTree(container, items, new Set(), callbacks); + + const indicators = container.querySelectorAll('.scv-op-indicator'); + expect(indicators).toHaveLength(1); + expect(indicators[0]?.classList.contains('scv-op-running')).toBe(true); + }); + + it('collapses a folder\'s children when its path is in collapsedFolders', () => { + const items = [item({ id: toChangeId('c-1'), path: 'notes/daily.md', kind: 'local-modified' })]; + renderChangeTree(container, items, new Set(['notes']), callbacks); + + expect(container.querySelector('.scv-tree-children')).toBeNull(); + expect(container.querySelector('.scv-change-item')).toBeNull(); + }); + + it('calls onToggleFolder with the folder path when the disclosure button is clicked', () => { + const items = [item({ id: toChangeId('c-1'), path: 'notes/daily.md', kind: 'local-modified' })]; + renderChangeTree(container, items, new Set(), callbacks); + + (container.querySelector('.scv-tree-folder-toggle') as HTMLButtonElement).click(); + + expect(callbacks.onToggleFolder).toHaveBeenCalledWith('notes'); + }); +}); diff --git a/tests/ui/source-control/FilterMenu.test.ts b/tests/ui/source-control/FilterMenu.test.ts new file mode 100644 index 0000000..69410f6 --- /dev/null +++ b/tests/ui/source-control/FilterMenu.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it, vi, beforeAll, beforeEach } from 'vitest'; +import { renderFilterMenu } from '../../../src/ui/source-control/FilterMenu'; +import type { SourceControlFilter } from '../../../src/logic/source-control/SourceControlFilter'; +import { setupObsidianDOM, createContainer } from '../setup-dom'; + +beforeAll(() => { setupObsidianDOM(); }); + +const zeroCounts: Record = { + all: 0, changes: 0, 'ready-to-push': 0, 'remote-changes': 0, conflicts: 0, synced: 0, +}; + +describe('renderFilterMenu', () => { + let container: HTMLElement; + let onChange: (filter: SourceControlFilter) => void; + + beforeEach(() => { + container = createContainer(); + onChange = vi.fn(); + }); + + it('renders all six filters in spec order', () => { + renderFilterMenu(container, 'all', zeroCounts, onChange); + + const filters = Array.from(container.querySelectorAll('.scv-filter-option')).map(el => el.getAttribute('data-filter')); + expect(filters).toEqual(['all', 'changes', 'ready-to-push', 'remote-changes', 'conflicts', 'synced']); + }); + + it('marks the current filter as active', () => { + renderFilterMenu(container, 'conflicts', zeroCounts, onChange); + + const active = container.querySelector('.scv-filter-option.is-active'); + expect(active?.getAttribute('data-filter')).toBe('conflicts'); + }); + + it('shows the per-filter count from the ViewModel', () => { + renderFilterMenu(container, 'all', { ...zeroCounts, conflicts: 3 }, onChange); + + const conflictsOption = container.querySelector('.scv-filter-option[data-filter="conflicts"]'); + expect(conflictsOption?.querySelector('.scv-filter-count')?.textContent).toBe('3'); + }); + + it('calls onChange with the clicked filter value (filter switching)', () => { + renderFilterMenu(container, 'all', zeroCounts, onChange); + + (container.querySelector('.scv-filter-option[data-filter="remote-changes"]') as HTMLButtonElement).click(); + + expect(onChange).toHaveBeenCalledWith('remote-changes'); + }); + + it('does not call onChange for filters that were not clicked', () => { + renderFilterMenu(container, 'all', zeroCounts, onChange); + + (container.querySelector('.scv-filter-option[data-filter="synced"]') as HTMLButtonElement).click(); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith('synced'); + }); +}); diff --git a/tests/ui/source-control/SourceControlView.test.ts b/tests/ui/source-control/SourceControlView.test.ts new file mode 100644 index 0000000..5110067 --- /dev/null +++ b/tests/ui/source-control/SourceControlView.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, it, vi, beforeAll, beforeEach } from 'vitest'; +import { SourceControlView, type SourceControlViewCallbacks } from '../../../src/ui/source-control/SourceControlView'; +import { ChangeRepository } from '../../../src/logic/source-control/ChangeRepository'; +import { OperationState } from '../../../src/logic/source-control/OperationState'; +import { PushSelectionStore } from '../../../src/logic/source-control/PushSelectionStore'; +import { SourceControlViewModel } from '../../../src/logic/source-control/SourceControlViewModel'; +import { toChangeId, type SyncChange } from '../../../src/logic/source-control/types'; +import { setupObsidianDOM, createContainer } from '../setup-dom'; + +beforeAll(() => { setupObsidianDOM(); }); + +function buildView(changes: SyncChange[], callbacks: Partial = {}) { + const repository = new ChangeRepository(); + repository.replace(changes); + const selection = new PushSelectionStore(); + const operations = new OperationState(); + const viewModel = new SourceControlViewModel(repository, selection, operations); + const onPush = callbacks.onPush ?? vi.fn(); + const view = new SourceControlView(viewModel, selection, { onPush, ...callbacks }); + return { view, selection, operations, onPush }; +} + +describe('SourceControlView', () => { + let container: HTMLElement; + + beforeEach(() => { + container = createContainer(); + }); + + describe('filter switching', () => { + it('groups changes into their sections under the "all" filter', () => { + const { view } = buildView([ + { id: toChangeId('c-1'), path: 'a.md', kind: 'local-only' }, + { id: toChangeId('c-2'), path: 'b.md', kind: 'remote-only' }, + { id: toChangeId('c-3'), path: 'c.md', kind: 'conflict' }, + { id: toChangeId('c-4'), path: 'd.md', kind: 'synced' }, + ]); + view.render(container); + + const sectionTitles = Array.from(container.querySelectorAll('.scv-section-title')).map(el => el.textContent); + expect(sectionTitles).toEqual(['CHANGES', 'REMOTE CHANGES', 'CONFLICTS', 'SYNCED']); + }); + + it('shows a flat tree (no sections) once a specific filter is selected', () => { + const { view } = buildView([ + { id: toChangeId('c-1'), path: 'a.md', kind: 'conflict' }, + { id: toChangeId('c-2'), path: 'b.md', kind: 'local-only' }, + ]); + view.render(container); + + (container.querySelector('.scv-filter-option[data-filter="conflicts"]') as HTMLButtonElement).click(); + + expect(container.querySelectorAll('.scv-section')).toHaveLength(0); + expect(container.querySelectorAll('.scv-change-item')).toHaveLength(1); + expect(view.getFilter()).toBe('conflicts'); + }); + + it('shows the empty state when the active filter has no items', () => { + const { view } = buildView([{ id: toChangeId('c-1'), path: 'a.md', kind: 'local-only' }]); + view.render(container); + + (container.querySelector('.scv-filter-option[data-filter="conflicts"]') as HTMLButtonElement).click(); + + expect(container.querySelector('.scv-empty')).not.toBeNull(); + }); + }); + + describe('selection', () => { + it('moves a change into "ready to push" and updates the push button count', () => { + const { view, selection } = buildView([{ id: toChangeId('c-1'), path: 'a.md', kind: 'local-only' }]); + view.render(container); + + const checkbox = container.querySelector('.scv-change-select') as HTMLInputElement; + checkbox.checked = true; + checkbox.dispatchEvent(new Event('change')); + + expect(selection.isIncluded(toChangeId('c-1'))).toBe(true); + const pushLabel = container.querySelector('.scv-push-btn-label')?.textContent ?? ''; + expect(pushLabel).toContain('1'); + }); + + it('deselecting removes the change from PushSelectionStore', () => { + const { view, selection } = buildView([{ id: toChangeId('c-1'), path: 'a.md', kind: 'local-only' }]); + selection.includeForPush(toChangeId('c-1')); + view.render(container); + + const checkbox = container.querySelector('.scv-change-select') as HTMLInputElement; + checkbox.checked = false; + checkbox.dispatchEvent(new Event('change')); + + expect(selection.isIncluded(toChangeId('c-1'))).toBe(false); + }); + }); + + describe('push action', () => { + it('calls onPush with every selected ChangeId, without touching the Git provider itself', () => { + const onPush = vi.fn(); + const { view, selection } = buildView( + [ + { id: toChangeId('c-1'), path: 'a.md', kind: 'local-only' }, + { id: toChangeId('c-2'), path: 'b.md', kind: 'local-modified' }, + ], + { onPush }, + ); + selection.includeForPush(toChangeId('c-1')); + selection.includeForPush(toChangeId('c-2')); + view.render(container); + + (container.querySelector('.scv-push-btn') as HTMLButtonElement).click(); + + expect(onPush).toHaveBeenCalledWith([toChangeId('c-1'), toChangeId('c-2')]); + }); + + it('disables the push button when nothing is selected', () => { + const { view } = buildView([{ id: toChangeId('c-1'), path: 'a.md', kind: 'local-only' }]); + view.render(container); + + expect((container.querySelector('.scv-push-btn') as HTMLButtonElement).disabled).toBe(true); + }); + }); + + 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' }]); + operations.start(toChangeId('c-1')); + view.render(container); + + const indicator = container.querySelector('.scv-op-indicator'); + expect(indicator?.classList.contains('scv-op-running')).toBe(true); + }); + + it('shows no indicator once the operation is idle again', () => { + const { view, operations } = buildView([{ id: toChangeId('c-1'), path: 'a.md', kind: 'local-only' }]); + operations.start(toChangeId('c-1')); + operations.reset(toChangeId('c-1')); + view.render(container); + + expect(container.querySelector('.scv-op-indicator')).toBeNull(); + }); + }); + + describe('diff selection', () => { + it('loads and renders diff content for the clicked change', async () => { + 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(); + + expect(loadDiffContent).toHaveBeenCalledWith(expect.objectContaining({ id: toChangeId('c-1') })); + // Reuses the existing diff panel renderer (Phase 3 spec: don't rewrite diff UI), which uses its own 'ssv-' class prefix. + expect(container.querySelector('.ssv-diff-split')).not.toBeNull(); + }); + + it('notifies onOpenDiff with the selected item', () => { + const onOpenDiff = vi.fn(); + const { view } = buildView( + [{ id: toChangeId('c-1'), path: 'a.md', kind: 'local-modified' }], + { onOpenDiff }, + ); + view.render(container); + + (container.querySelector('.scv-change-item') as HTMLElement).click(); + + expect(onOpenDiff).toHaveBeenCalledWith(expect.objectContaining({ id: toChangeId('c-1'), path: 'a.md' })); + }); + }); + + describe('rename stability', () => { + it('keeps the selected ChangeId set after a rename changes the path', () => { + const { view } = buildView([ + { id: toChangeId('c-1'), path: 'old.md', kind: 'local-modified' }, + ]); + view.render(container); + (container.querySelector('.scv-change-item') as HTMLElement).click(); + expect(view.getSelectedChangeId()).toBe(toChangeId('c-1')); + + // Simulate a rename being reflected in a fresh ViewModel snapshot for the same ChangeId. + const { view: renamedView } = buildView([ + { id: toChangeId('c-1'), path: 'new.md', previousPath: 'old.md', kind: 'moved' }, + ]); + renamedView.render(container); + (container.querySelector('.scv-change-item') as HTMLElement).click(); + + expect(renamedView.getSelectedChangeId()).toBe(toChangeId('c-1')); + expect(container.querySelector('.scv-change-rename-from')?.textContent).toBe('old.md'); + }); + }); +}); From 70f6c9e953d062e0423629922c3c735213315954 Mon Sep 17 00:00:00 2001 From: tianyao Date: Sat, 22 Aug 2026 07:48:10 +0000 Subject: [PATCH 006/104] feat(source-control): add Phase 2 action service Add SourceControlActionService wrapping the existing SyncWorkspace facade to unify push, pull, delete-remote, delete-local, and resolve-conflict actions behind a single ChangeId-keyed API. Resolves ChangeId to SyncChange via the Phase 1 ChangeRepository and reports per-change outcomes through OperationState. push()/loadDiffContent() are directly assignable to Phase 3's SourceControlView callback types. Co-Authored-By: Claude Sonnet 5 --- .../SourceControlActionService.ts | 158 +++++++++ .../SourceControlActionService.test.ts | 306 ++++++++++++++++++ 2 files changed, 464 insertions(+) create mode 100644 src/logic/source-control/SourceControlActionService.ts create mode 100644 tests/logic/source-control/SourceControlActionService.test.ts diff --git a/src/logic/source-control/SourceControlActionService.ts b/src/logic/source-control/SourceControlActionService.ts new file mode 100644 index 0000000..567d364 --- /dev/null +++ b/src/logic/source-control/SourceControlActionService.ts @@ -0,0 +1,158 @@ +import type { SyncWorkspace } from '../sync/SyncWorkspace'; +import type { ChangeRepository } from './ChangeRepository'; +import type { OperationState } from './OperationState'; +import type { SourceControlItem } from './SourceControlViewModel'; +import type { ChangeId, SyncChange } from './types'; + +/** Which side wins when resolving a change in the 'conflict' state. */ +export type ConflictResolution = 'local' | 'remote'; + +/** Diff payload the Source Control diff pane can render directly (text-only; binary/symlink changes resolve to `null`). */ +export interface SourceControlDiffContent { + remote: string; + local: string; +} + +/** + * 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. + * + * 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. + */ +export class SourceControlActionService { + constructor( + private readonly changes: ChangeRepository, + private readonly operations: OperationState, + private readonly workspace: SyncWorkspace, + ) {} + + /** Pushes one or more changes (single push and batch push share this path). */ + async push(changeIds: readonly ChangeId[]): Promise { + const targets = this.resolve(changeIds); + if (targets.length === 0) return; + + this.startAll(targets); + try { + const results = await this.workspace.push(targets.map(target => target.path)); + const failed = new Set(results.errors.map(error => error.file)); + this.finishAll(targets, path => (failed.has(path) ? 'failed' : 'success')); + } catch { + this.failAll(targets); + } + } + + /** Pulls one or more changes. */ + async pull(changeIds: readonly ChangeId[]): Promise { + const targets = this.resolve(changeIds); + if (targets.length === 0) return; + + this.startAll(targets); + try { + const results = await this.workspace.pull(targets.map(target => target.path)); + const failed = new Set(results.errors.map(error => error.file)); + this.finishAll(targets, path => (failed.has(path) ? 'failed' : 'success')); + } catch { + this.failAll(targets); + } + } + + /** Deletes one or more changes from the remote only. */ + async deleteRemote(changeIds: readonly ChangeId[]): Promise { + const targets = this.resolve(changeIds); + if (targets.length === 0) return; + + this.startAll(targets); + try { + const result = await this.workspace.deleteRemote(targets.map(target => target.path)); + const failed = new Set(result.errors.map(error => error.path)); + this.finishAll(targets, path => (failed.has(path) ? 'failed' : 'success')); + } catch { + this.failAll(targets); + } + } + + /** 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. */ + async deleteLocal(changeIds: readonly ChangeId[]): Promise { + const targets = this.resolve(changeIds); + for (const target of targets) { + this.operations.start(target.id); + try { + await this.workspace.deleteLocal(target.path); + this.operations.succeed(target.id); + } catch { + this.operations.fail(target.id); + } + } + } + + /** + * Resolves a single change in the 'conflict' state by pushing the local + * copy (local wins) or pulling the remote copy (remote wins) — the same + * two primitives every other action uses, so no separate conflict-apply + * pathway is introduced. + */ + async resolveConflict(changeId: ChangeId, resolution: ConflictResolution): Promise { + const change = this.changes.getById(changeId); + if (!change) return; + + this.operations.start(changeId); + try { + if (resolution === 'local') { + await this.workspace.push([change.path]); + } else { + await this.workspace.pullOne(change.path); + } + this.operations.succeed(changeId); + } catch { + this.operations.fail(changeId); + } + } + + /** + * 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. + */ + 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. */ + private resolve(changeIds: readonly ChangeId[]): SyncChange[] { + const targets: SyncChange[] = []; + for (const id of changeIds) { + const change = this.changes.getById(id); + if (change) targets.push(change); + } + return targets; + } + + 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); + } +} diff --git a/tests/logic/source-control/SourceControlActionService.test.ts b/tests/logic/source-control/SourceControlActionService.test.ts new file mode 100644 index 0000000..6931d36 --- /dev/null +++ b/tests/logic/source-control/SourceControlActionService.test.ts @@ -0,0 +1,306 @@ +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 { toChangeId, type SyncChange } from '../../../src/logic/source-control/types'; +import type { SyncWorkspace } from '../../../src/logic/sync/SyncWorkspace'; +import type { FileDiff, PushResults, SyncResult } from '../../../src/logic/sync/types'; +import type { RemoteDeleteResult } from '../../../src/logic/sync/RemoteDeleteExecutor'; + +function emptyPushResults(overrides: Partial = {}): PushResults { + return { + success: 0, + failed: 0, + conflicts: 0, + resolvedConflicts: 0, + skippedConflicts: 0, + errors: [], + syncedPaths: [], + ...overrides, + }; +} + +function emptySyncResult(overrides: Partial = {}): SyncResult { + return { success: 0, failed: 0, conflicts: 0, errors: [], ...overrides }; +} + +function fakeWorkspace(overrides: Partial = {}): SyncWorkspace { + return { + getStatuses: () => [], + getInfo: () => ({ serviceName: 'GitHub', branch: 'main', vaultFolder: '' }), + getRemoteFileUrl: () => null, + refresh: vi.fn(), + push: vi.fn().mockResolvedValue(emptyPushResults()), + pull: vi.fn().mockResolvedValue(emptySyncResult()), + pullOne: vi.fn().mockResolvedValue(undefined), + deleteRemote: vi.fn().mockResolvedValue({ deletedPaths: [], errors: [] } as RemoteDeleteResult), + deleteLocal: vi.fn().mockResolvedValue(undefined), + moveLocal: vi.fn(), + clearMetadata: vi.fn(), + trackRename: vi.fn(), + getDiff: vi.fn().mockResolvedValue({ path: 'a.md', kind: 'text' } as FileDiff), + ...overrides, + } as SyncWorkspace; +} + +function buildService(changes: SyncChange[], workspace: SyncWorkspace) { + const repository = new ChangeRepository(); + repository.replace(changes); + const operations = new OperationState(); + const service = new SourceControlActionService(repository, operations, workspace); + return { service, operations }; +} + +describe('SourceControlActionService', () => { + describe('push', () => { + it('pushes a single change and marks it running then success', async () => { + const push = vi.fn().mockResolvedValue(emptyPushResults({ syncedPaths: [{ path: 'a.md', sha: 'sha-1' }] })); + const { service, operations } = buildService( + [{ id: toChangeId('c-1'), path: 'a.md', kind: 'local-only' }], + fakeWorkspace({ push }), + ); + + const promise = service.push([toChangeId('c-1')]); + expect(operations.get(toChangeId('c-1'))).toBe('running'); + await promise; + + expect(push).toHaveBeenCalledWith(['a.md']); + expect(operations.get(toChangeId('c-1'))).toBe('success'); + }); + + it('pushes a batch of changes together in one SyncWorkspace call', async () => { + const push = vi.fn().mockResolvedValue(emptyPushResults({ + syncedPaths: [{ path: 'a.md' }, { path: 'b.md' }], + })); + const { service, operations } = buildService( + [ + { id: toChangeId('c-1'), path: 'a.md', kind: 'local-only' }, + { id: toChangeId('c-2'), path: 'b.md', kind: 'local-modified' }, + ], + fakeWorkspace({ push }), + ); + + await service.push([toChangeId('c-1'), toChangeId('c-2')]); + + expect(push).toHaveBeenCalledTimes(1); + expect(push).toHaveBeenCalledWith(['a.md', 'b.md']); + expect(operations.get(toChangeId('c-1'))).toBe('success'); + expect(operations.get(toChangeId('c-2'))).toBe('success'); + }); + + it('marks only the failed change as failed when the batch partially errors', async () => { + const push = vi.fn().mockResolvedValue(emptyPushResults({ + syncedPaths: [{ path: 'a.md' }], + errors: [{ file: 'b.md', error: 'boom' }], + })); + const { service, operations } = buildService( + [ + { id: toChangeId('c-1'), path: 'a.md', kind: 'local-only' }, + { id: toChangeId('c-2'), path: 'b.md', kind: 'local-modified' }, + ], + fakeWorkspace({ push }), + ); + + await service.push([toChangeId('c-1'), toChangeId('c-2')]); + + expect(operations.get(toChangeId('c-1'))).toBe('success'); + expect(operations.get(toChangeId('c-2'))).toBe('failed'); + }); + + it('fails every targeted change when SyncWorkspace throws', async () => { + const push = vi.fn().mockRejectedValue(new Error('network down')); + const { service, operations } = buildService( + [{ id: toChangeId('c-1'), path: 'a.md', kind: 'local-only' }], + fakeWorkspace({ push }), + ); + + await service.push([toChangeId('c-1')]); + + expect(operations.get(toChangeId('c-1'))).toBe('failed'); + }); + }); + + describe('pull', () => { + it('pulls the given changes through SyncWorkspace.pull', async () => { + const pull = vi.fn().mockResolvedValue(emptySyncResult()); + const { service, operations } = buildService( + [{ id: toChangeId('c-1'), path: 'a.md', kind: 'remote-only' }], + fakeWorkspace({ pull }), + ); + + await service.pull([toChangeId('c-1')]); + + expect(pull).toHaveBeenCalledWith(['a.md']); + expect(operations.get(toChangeId('c-1'))).toBe('success'); + }); + + it('marks a change failed when it appears in the pull error list', async () => { + const pull = vi.fn().mockResolvedValue(emptySyncResult({ errors: [{ file: 'a.md', error: 'conflict' }] })); + const { service, operations } = buildService( + [{ id: toChangeId('c-1'), path: 'a.md', kind: 'remote-only' }], + fakeWorkspace({ pull }), + ); + + await service.pull([toChangeId('c-1')]); + + expect(operations.get(toChangeId('c-1'))).toBe('failed'); + }); + }); + + describe('deleteRemote / deleteLocal', () => { + it('deletes selected changes from the remote', async () => { + const deleteRemote = vi.fn().mockResolvedValue({ deletedPaths: ['a.md'], errors: [] } as RemoteDeleteResult); + const { service, operations } = buildService( + [{ id: toChangeId('c-1'), path: 'a.md', kind: 'remote-only' }], + fakeWorkspace({ deleteRemote }), + ); + + await service.deleteRemote([toChangeId('c-1')]); + + expect(deleteRemote).toHaveBeenCalledWith(['a.md']); + expect(operations.get(toChangeId('c-1'))).toBe('success'); + }); + + it('deletes selected changes locally, one at a time, independent of each other', async () => { + const deleteLocal = vi.fn() + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error('locked')); + const { service, operations } = buildService( + [ + { id: toChangeId('c-1'), path: 'a.md', kind: 'local-only' }, + { id: toChangeId('c-2'), path: 'b.md', kind: 'local-only' }, + ], + fakeWorkspace({ deleteLocal }), + ); + + await service.deleteLocal([toChangeId('c-1'), toChangeId('c-2')]); + + expect(deleteLocal).toHaveBeenCalledTimes(2); + expect(operations.get(toChangeId('c-1'))).toBe('success'); + expect(operations.get(toChangeId('c-2'))).toBe('failed'); + }); + }); + + describe('resolveConflict', () => { + it('pushes the local copy when resolution is "local"', async () => { + const push = vi.fn().mockResolvedValue(emptyPushResults()); + const { service, operations } = buildService( + [{ id: toChangeId('c-1'), path: 'a.md', kind: 'conflict' }], + fakeWorkspace({ push }), + ); + + await service.resolveConflict(toChangeId('c-1'), 'local'); + + expect(push).toHaveBeenCalledWith(['a.md']); + expect(operations.get(toChangeId('c-1'))).toBe('success'); + }); + + it('pulls the remote copy when resolution is "remote"', async () => { + const pullOne = vi.fn().mockResolvedValue(undefined); + const { service, operations } = buildService( + [{ id: toChangeId('c-1'), path: 'a.md', kind: 'conflict' }], + fakeWorkspace({ pullOne }), + ); + + await service.resolveConflict(toChangeId('c-1'), 'remote'); + + expect(pullOne).toHaveBeenCalledWith('a.md'); + expect(operations.get(toChangeId('c-1'))).toBe('success'); + }); + + it('marks the change failed when the resolution attempt throws', async () => { + const pullOne = vi.fn().mockRejectedValue(new Error('boom')); + const { service, operations } = buildService( + [{ id: toChangeId('c-1'), path: 'a.md', kind: 'conflict' }], + fakeWorkspace({ pullOne }), + ); + + await service.resolveConflict(toChangeId('c-1'), 'remote'); + + expect(operations.get(toChangeId('c-1'))).toBe('failed'); + }); + }); + + describe('invalid ChangeId', () => { + it('push is a no-op and never calls SyncWorkspace for an unknown ChangeId', async () => { + const push = vi.fn(); + const { service } = buildService([], fakeWorkspace({ push })); + + await service.push([toChangeId('does-not-exist')]); + + expect(push).not.toHaveBeenCalled(); + }); + + it('skips unknown ids in a mixed batch but still acts on the known ones', async () => { + const push = vi.fn().mockResolvedValue(emptyPushResults({ syncedPaths: [{ path: 'a.md' }] })); + const { service, operations } = buildService( + [{ id: toChangeId('c-1'), path: 'a.md', kind: 'local-only' }], + fakeWorkspace({ push }), + ); + + await service.push([toChangeId('c-1'), toChangeId('ghost')]); + + expect(push).toHaveBeenCalledWith(['a.md']); + expect(operations.get(toChangeId('c-1'))).toBe('success'); + expect(operations.get(toChangeId('ghost'))).toBe('idle'); + }); + + it('resolveConflict is a no-op for an unknown ChangeId', async () => { + const pullOne = vi.fn(); + const { service } = buildService([], fakeWorkspace({ pullOne })); + + await service.resolveConflict(toChangeId('does-not-exist'), 'remote'); + + expect(pullOne).not.toHaveBeenCalled(); + }); + }); + + describe('loadDiffContent', () => { + it('returns text diff content when both sides are strings', async () => { + const getDiff = vi.fn().mockResolvedValue({ + path: 'a.md', + localContent: 'local text', + remoteContent: 'remote text', + kind: 'text', + } as FileDiff); + const { service } = buildService( + [{ id: toChangeId('c-1'), path: 'a.md', kind: 'local-modified' }], + fakeWorkspace({ getDiff }), + ); + + const content = await service.loadDiffContent({ + id: toChangeId('c-1'), + path: 'a.md', + kind: 'local-modified', + isReadyToPush: false, + operationStatus: 'idle', + }); + + expect(getDiff).toHaveBeenCalledWith('a.md'); + expect(content).toEqual({ remote: 'remote text', local: 'local text' }); + }); + + it('returns null for a binary/symlink diff that cannot render as text', async () => { + const getDiff = vi.fn().mockResolvedValue({ + path: 'a.png', + localContent: undefined, + remoteContent: undefined, + kind: 'binary', + } as FileDiff); + const { service } = buildService( + [{ id: toChangeId('c-1'), path: 'a.png', kind: 'local-modified' }], + fakeWorkspace({ getDiff }), + ); + + const content = await service.loadDiffContent({ + id: toChangeId('c-1'), + path: 'a.png', + kind: 'local-modified', + isReadyToPush: false, + operationStatus: 'idle', + }); + + expect(content).toBeNull(); + }); + }); +}); From ec44025442fb755f6b096e62ec528d5b3aee83e2 Mon Sep 17 00:00:00 2001 From: Tianyao Date: Sat, 22 Aug 2026 15:51:19 +0800 Subject: [PATCH 007/104] chore: add deploy script to sync build output to local Obsidian vault Co-Authored-By: Claude Sonnet 5 --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 0f9d809..e460210 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "version": "node version-bump.mjs && git add manifest.json versions.json", "lint": "eslint .", "test": "vitest run", + "deploy": "npm run build && mkdir -p ~/Obsidian/MyPKM/.obsidian/plugins/git-file-sync && cp main.js manifest.json styles.css ~/Obsidian/MyPKM/.obsidian/plugins/git-file-sync/", "test:ui": "vitest --ui", "test:e2e": "bash scripts/run-e2e.sh", "prepare": "husky", From 4e647fb32a892de39bda8dd84009699eee1f7f7c Mon Sep 17 00:00:00 2001 From: tianyao Date: Sat, 22 Aug 2026 08:58:08 +0000 Subject: [PATCH 008/104] feat(source-control): wire Source Control view as the entry and remove legacy UI Phase A integration + Phase E cleanup. The new SourceControlView (Phase 3) was built but never registered; this makes it the sole entry point and deletes the legacy SyncStatusView UI it replaces. Wiring (src/main.ts): - Register SourceControlItemView under the legacy view type string 'sync-status-view' so already-open/pinned leaves resolve into the new view instead of an 'unrecognized view type' placeholder. - Ribbon icon, 'open-sync-status' command, and startup refresh now route through activateSourceControlView(). - Construct ChangeRepository / PushSelectionStore / OperationState / SourceControlViewModel / SourceControlActionService on the plugin; the ribbon/command no longer reach GitService directly. - Subscribe sync.status -> ChangeRepository.replace(toSyncChanges(...)) so the Source Control tree stays in sync with the same SyncStatusService the sync domain already publishes to (no separate refresh/polling path); unsubscribe in onunload. - File modify/rename events now go through syncStatusRefresh (shared service republishes to the open view) instead of per-view notifySyncStatusViews. New: - src/ui/source-control/SourceControlItemView.ts: thin ItemView host that delegates rendering to SourceControlView and routes onPush/loadDiffContent to plugin.sourceControlActions. - src/logic/source-control/FileStatusAdapter.ts: toSyncChanges() projects FileStatus[] -> SyncChange[] for ChangeRepository. Documents two pre-existing FileStatus limits it inherits (not introduces): 'modified' can't tell which side changed (maps to local-modified), and FileStatus never yields 'conflict' (conflicts are detected only during push via SyncPlanner, not pre-computed for display) -- so the CONFLICTS section won't populate from the status map alone until Phase B. Removed (legacy UI replaced by the Source Control layer): - src/ui/SyncStatusView.ts, src/ui/DiffView.ts, src/ui/components/* (4), src/ui/sync-status/* (7), and their tests; styles.css trimmed -547/+174. Verification: npx eslint . -- 0 errors; npm run build -- PASS incl. Obsidian 1.11.0 compatibility; npx vitest run -- 55 files / 531 tests. Manual Obsidian verification in a real vault remains (DoD for UI surfaces): ribbon opens the new panel, tree/filter/push render, live modify+rename refresh, pinned-leaf migration to the new view type, onunload cleanup. Plan: docs/source-control-refactor/roadmap.md (Phases A-E). --- docs/source-control-refactor/roadmap.md | 206 ++++ src/logic/source-control/FileStatusAdapter.ts | 57 ++ src/main.ts | 87 +- src/ui/DiffView.ts | 79 -- src/ui/SyncStatusView.ts | 1 - src/ui/components/ActionBar.ts | 82 -- src/ui/components/FileListItem.ts | 246 ----- src/ui/components/FolderTreeItem.ts | 61 -- src/ui/components/StatusTree.ts | 69 -- .../source-control/SourceControlItemView.ts | 78 ++ src/ui/sync-status/SyncStatusComposition.ts | 70 -- src/ui/sync-status/SyncStatusController.ts | 57 -- src/ui/sync-status/SyncStatusNavigator.ts | 59 -- src/ui/sync-status/SyncStatusOperations.ts | 306 ------ src/ui/sync-status/SyncStatusRenderer.ts | 373 ------- src/ui/sync-status/SyncStatusSelectors.ts | 126 --- src/ui/sync-status/SyncStatusView.ts | 251 ----- src/ui/sync-status/SyncStatusViewState.ts | 92 -- styles.css | 721 ++++---------- .../source-control/FileStatusAdapter.test.ts | 39 + tests/main.test.ts | 7 +- tests/ui/ActionBar.test.ts | 158 --- tests/ui/DiffView.test.ts | 165 ---- tests/ui/FileListItem.test.ts | 281 ------ tests/ui/FolderTreeItem.test.ts | 62 -- tests/ui/StatusTree.test.ts | 41 - tests/ui/SyncStatusView.openFile.test.ts | 152 --- tests/ui/SyncStatusView.search.test.ts | 335 ------- tests/ui/SyncStatusView.test.ts | 917 ------------------ .../SourceControlItemView.test.ts | 99 ++ .../sync-status/SyncStatusController.test.ts | 71 -- .../sync-status/SyncStatusSelectors.test.ts | 75 -- .../sync-status/SyncStatusView.wiring.test.ts | 55 -- .../sync-status/SyncStatusViewState.test.ts | 46 - 34 files changed, 713 insertions(+), 4811 deletions(-) create mode 100644 docs/source-control-refactor/roadmap.md create mode 100644 src/logic/source-control/FileStatusAdapter.ts delete mode 100644 src/ui/DiffView.ts delete mode 100644 src/ui/SyncStatusView.ts delete mode 100644 src/ui/components/ActionBar.ts delete mode 100644 src/ui/components/FileListItem.ts delete mode 100644 src/ui/components/FolderTreeItem.ts delete mode 100644 src/ui/components/StatusTree.ts create mode 100644 src/ui/source-control/SourceControlItemView.ts delete mode 100644 src/ui/sync-status/SyncStatusComposition.ts delete mode 100644 src/ui/sync-status/SyncStatusController.ts delete mode 100644 src/ui/sync-status/SyncStatusNavigator.ts delete mode 100644 src/ui/sync-status/SyncStatusOperations.ts delete mode 100644 src/ui/sync-status/SyncStatusRenderer.ts delete mode 100644 src/ui/sync-status/SyncStatusSelectors.ts delete mode 100644 src/ui/sync-status/SyncStatusView.ts delete mode 100644 src/ui/sync-status/SyncStatusViewState.ts create mode 100644 tests/logic/source-control/FileStatusAdapter.test.ts delete mode 100644 tests/ui/ActionBar.test.ts delete mode 100644 tests/ui/DiffView.test.ts delete mode 100644 tests/ui/FileListItem.test.ts delete mode 100644 tests/ui/FolderTreeItem.test.ts delete mode 100644 tests/ui/StatusTree.test.ts delete mode 100644 tests/ui/SyncStatusView.openFile.test.ts delete mode 100644 tests/ui/SyncStatusView.search.test.ts delete mode 100644 tests/ui/SyncStatusView.test.ts create mode 100644 tests/ui/source-control/SourceControlItemView.test.ts delete mode 100644 tests/ui/sync-status/SyncStatusController.test.ts delete mode 100644 tests/ui/sync-status/SyncStatusSelectors.test.ts delete mode 100644 tests/ui/sync-status/SyncStatusView.wiring.test.ts delete mode 100644 tests/ui/sync-status/SyncStatusViewState.test.ts diff --git a/docs/source-control-refactor/roadmap.md b/docs/source-control-refactor/roadmap.md new file mode 100644 index 0000000..a928ff3 --- /dev/null +++ b/docs/source-control-refactor/roadmap.md @@ -0,0 +1,206 @@ +# Source Control Refactor — Roadmap (v2) + +> 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. + +## Where we actually are + +The committed branch `claude/source-control-foundation` (7 commits, 34 files, ++2378) delivered the **foundation** in three commits: + +- ✅ Phase 1 — ViewModel foundation: `ChangeRepository`, `SourceControlFilter`, + `SourceControlViewModel`, `ChangeTreeBuilder` (`76db082`) +- ✅ Phase 2 — Action unification: `SourceControlActionService` over + `SyncWorkspace` (`70f6c9e`) +- ✅ Phase 3 — Source Control UI skeleton: `SourceControlView` + components + (`7cec661`) + +On top of that, the **active agent worktree** carries uncommitted WIP that +already performs **Phase A (wire new view as the only entry) and Phase E +(delete the legacy UI) together**, and it is verified green: + +``` +npx eslint . -> 0 errors +npm run build -> PASS (tsc + Obsidian 1.11.0 compat + esbuild) +npx vitest run -> 55 files / 531 tests PASS +``` + +WIP contents (all uncommitted): + +- `src/main.ts`: registers `SourceControlItemView` under the **legacy** view + type string `sync-status-view` (so pinned leaves migrate cleanly), rewires + ribbon + `open-sync-status` command + startup refresh to + `activateSourceControlView()`, constructs `ChangeRepository` / + `PushSelectionStore` / `OperationState` / `SourceControlViewModel` / + `SourceControlActionService` on the plugin, subscribes + `sync.status` → `ChangeRepository.replace(toSyncChanges(...))`, and + unsubscribes in `onunload`. +- `src/ui/source-control/SourceControlItemView.ts` (new, 78 lines): thin + `ItemView` host that delegates rendering to `SourceControlView` and routes + `onPush` / `loadDiffContent` to `plugin.sourceControlActions`. +- `src/logic/source-control/FileStatusAdapter.ts` (new, 57 lines): + `toSyncChanges(statuses)` — the adapter from the existing + `SyncStatusService` status map into `SyncChange[]` for `ChangeRepository`. +- Deletes: `src/ui/SyncStatusView.ts`, `src/ui/DiffView.ts`, all + `src/ui/components/{ActionBar,FileListItem,FolderTreeItem,StatusTree}.ts`, + all `src/ui/sync-status/*.ts`, and their tests. +- `styles.css`: −547 / +174 (legacy tree styles removed). + +**Consequence:** the next agent must NOT redo Phase A or Phase E. They exist +as green WIP. The next agent's job is to (1) land that WIP with manual Obsidian +verification, then (2) move to Phase B. + +## Architecture (verified against source) + +``` +SyncChange ── FileStatusAdapter ──▶ ChangeRepository + │ + SourceControlViewModel ◀── PushSelectionStore + │ OperationState + ┌───────────────┴────────────────┐ + Filter Selection + └───────────────┬────────────────┘ + ▼ + SourceControlItemView (ItemView host, 78 lines) + │ delegates render + ▼ + SourceControlView (render, 204 lines) + │ callbacks + ▼ + SourceControlActionService + │ + ▼ + SyncWorkspace (push/pull/delete/diff) + │ + ▼ + SyncManager → Provider +``` + +Entry wiring (Phase A, done as WIP): ribbon + command + startup → +`activateSourceControlView()` → `SOURCE_CONTROL_VIEW_TYPE` leaf → +`SourceControlItemView`. + +## Phase A — Wire existing UI entry ✅ DONE (uncommitted, green WIP) + +See WIP contents above. Acceptance already met at the automated level: +new view is the sole registered entry; ribbon/command/startup all route +through it; old UI deleted. + +**Remaining for "done" per DoD:** manual Obsidian verification in a real vault +(ribbon opens the new panel, tree/filter/push render, live modify/rename +refresh, pinned leaf migration, `onunload` cleanup). Then commit the WIP. + +## Phase E — Legacy cleanup ✅ DONE (same WIP as Phase A) + +Old `SyncStatusView`, `DiffView`, `components/*`, `sync-status/*` and their +tests deleted; `styles.css` trimmed. No duplicate action handlers remain +(commands go through `SourceControlActionService`). Lands together with +Phase A. + +## Phase B — Surface conflict as domain state ◀ NEXT (real gap) + +This is the largest real gap and the user's risk #2/#3. The conflict model +**already exists** in the executor layer — it must be *surfaced*, not +recreated: + +- `src/logic/sync/types.ts`: `PushResults` already carries + `conflicts`, `resolvedConflicts`, `skippedConflicts`, `conflictedPaths`, + `errors`; `SyncResult` carries `conflicts` count. +- `src/logic/sync/ConflictResolver.ts`: `BatchPushConflict`, + `findStale`, `applyRemote` — full conflict lifecycle. +- `src/logic/sync/PullCoordinator.ts`: `BatchOutcome = 'done' | 'unchanged' | 'conflict'`. + +The gap is entirely in the Source Control layer: + +1. **`OperationState`** (`src/logic/source-control/OperationState.ts`) only has + `OperationStatus = 'idle' | 'running' | 'success' | 'failed'`. Add + `'conflict'` (a.k.a. needs-resolution) — a **different lifecycle** from + `'failed'` (resolvable, not an error). +2. **`SourceControlActionService.push/pull`** currently does + `finishAll(targets, path => failed.has(path) ? 'failed' : 'success')` + reading only `results.errors`. It must instead read + `results.conflictedPaths` (and/or `results.conflicts > 0`) and mark those + `'conflict'`, leaving genuine errors as `'failed'`. Reuse the executor's + conflict semantics — do **not** create a parallel `ConflictState.ts`. +3. **`ExecutionResult`** (new, thin projection — *not* a new executor): batch + push/pull return `{ completed: ChangeId[]; conflicts: ChangeId[]; failed: + ChangeId[] }` so the UI can show "7 success, 3 conflict" instead of just + success/failed. This is a projection of `PushResults`/`SyncResult`, derived + in `SourceControlActionService`, not a new sync-domain type. +4. **`SourceControlViewModel`** surfaces conflict count + the conflict item + list; `SourceControlFilter` already has a `'conflicts'` filter value — wire + it to the new `'conflict'` operation status. +5. UI: a `CONFLICTS (n)` section listing conflicted changes with a + `[Resolve All]` entry point (resolution UX is Phase C). + +Tests first (TDD): `OperationState` conflict status; `ActionService` maps +`conflictedPaths` → `'conflict'` and returns `ExecutionResult` counts; +`ViewModel` exposes conflict list/count; filter `'conflicts'` resolves to the +new status. + +## Phase C — Diff / conflict resolution UX + +Reuses the existing `SyncWorkspace.getDiff` / `SyncDiffService` path that +`SourceControlActionService.loadDiffContent` already calls — no new diff +logic, only layout + resolution actions. + +New UI: + +- `src/ui/source-control/ConflictPanel.ts` — the `CONFLICTS (n)` list + + per-item actions. +- `src/ui/source-control/DiffLayoutSelector.ts` — Desktop: `Tree | Diff` + split; Mobile: `List → Diff` stack. + +Actions (route through `SourceControlActionService.resolveConflict`, which +already exists for `'local' | 'remote'`): + +- Accept Local → `resolveConflict(id, 'local')` (push local) +- Accept Remote → `resolveConflict(id, 'remote')` (pull remote) +- Manual Merge → opens an editor merge path (new; scope TBD). + +## Phase D — Context menu migration + +Currently no context menu in the new UI (verified: no `contextmenu` / +`addMenu` references in `src/ui/source-control/`). Unify right-click on a +change row: + +``` +Right-click on change row + → changeId + → SourceControlActionService.{push|pull|deleteRemote|deleteLocal|resolveConflict|loadDiffContent} +``` + +Menu items: Push, Pull, Open Diff, Delete Remote, Delete Local, Resolve +Conflict. No direct `SyncWorkspace`/`GitService` access from the menu — only +through `SourceControlActionService`. + +## Ordering & risk notes + +``` +PR #127 foundation (merged) + │ + ▼ +A + E ── land the green WIP: commit + manual Obsidian verify ◀ do first + │ + ▼ +B ── surface executor conflict state via OperationState + ExecutionResult + │ + ▼ +C ── diff / conflict resolution UX (reuses existing diff path) + │ + ▼ +D ── context menu → ActionService +``` + +Risk notes from the review, confirmed against source: + +1. **`SourceControlView.ts` is 204 lines** — but the WIP already split the + `ItemView` host (`SourceControlItemView`, 78 lines) from the render logic. + Do not grow `SourceControlView` further; keep it a pure renderer over the + ViewModel. +2. **Conflict ≠ failed.** `OperationState` must distinguish `'conflict'` + (needs-resolution, resolvable) from `'failed'` (error). Different + lifecycle. Phase B. +3. **Batch needs `ExecutionResult`.** Without it the UI can only show + success/failed, not "7 success, 3 conflict". Phase B. \ No newline at end of file diff --git a/src/logic/source-control/FileStatusAdapter.ts b/src/logic/source-control/FileStatusAdapter.ts new file mode 100644 index 0000000..b54efdf --- /dev/null +++ b/src/logic/source-control/FileStatusAdapter.ts @@ -0,0 +1,57 @@ +import type { FileStatus, SyncStatus } from '../sync-status-service'; +import { toChangeId, type SyncChange, type SyncChangeKind } from './types'; + +const KIND_BY_STATUS: Record = { + synced: 'synced', + modified: 'local-modified', + unsynced: 'local-only', + 'remote-only': 'remote-only', + moved: 'moved', +}; + +/** + * Projects `FileStatus[]` (the existing sync-status domain's flat status map, + * as exposed by `SyncWorkspace.getStatuses()`) into `SyncChange[]` for the + * Source Control `ChangeRepository` / `SourceControlViewModel` layer added in + * Phase 1. + * + * Two known gaps versus the full `SyncChangeKind` model, both pre-existing + * limits of `FileStatus` rather than anything introduced here: + * + * - `FileStatus.status` never distinguishes which side changed for a + * two-sided diff (`SyncStatusService.classify` collapses both directions + * into `'modified'`), so `'modified'` maps to `'local-modified'` as a + * best-effort approximation. This mirrors the legacy SyncStatusView, whose + * "modified" rows already offered both push and pull regardless of which + * side actually changed. + * - No `FileStatus` value ever produces `'conflict'`: conflicts are only + * detected during `SyncManager.pushFiles` (via `SyncPlanner.classify` + * against a stored base sha) and resolved interactively through + * `ObsidianSyncInteraction`, not pre-computed for display. The legacy UI + * had the same limitation. Widening this is out of scope for a UI/wiring + * cutover -- it would mean adding new sync classification behavior, not + * just rewiring existing behavior. + * + * `'checking'` rows (status still being resolved) are omitted rather than + * mapped to a placeholder kind, so they don't flash into a section and back + * out once resolved. + * + * `ChangeId` is derived from the current path: `FileStatus` itself has no + * rename-stable identity (`SyncStatusRefreshService.handleFileRenamed` + * re-keys its map to the new path), so a change's id also changes when the + * file is renamed. That's an existing limit of the underlying data, not a + * regression -- the legacy status map re-keyed on rename the same way. + */ +export function toSyncChanges(statuses: readonly FileStatus[]): SyncChange[] { + const changes: SyncChange[] = []; + for (const status of statuses) { + if (status.status === 'checking') continue; + changes.push({ + id: toChangeId(status.path), + path: status.path, + previousPath: status.movedFrom, + kind: KIND_BY_STATUS[status.status], + }); + } + return changes; +} diff --git a/src/main.ts b/src/main.ts index bea24f8..f2af4a3 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,8 +6,7 @@ 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 { SyncStatusView, SYNC_STATUS_VIEW_TYPE } from './ui/SyncStatusView'; -import { DiffView, SYNC_DIFF_VIEW_TYPE } from './ui/DiffView'; +import { SourceControlItemView, SOURCE_CONTROL_VIEW_TYPE } from './ui/source-control/SourceControlItemView'; import { GitignoreManager } from './logic/gitignore-manager'; import { logger } from './utils/logger'; import { ConfirmModal } from './ui/ConfirmModal'; @@ -19,6 +18,12 @@ 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 { PushSelectionStore } from './logic/source-control/PushSelectionStore'; +import { SourceControlViewModel } from './logic/source-control/SourceControlViewModel'; +import { SourceControlActionService } from './logic/source-control/SourceControlActionService'; +import { toSyncChanges } from './logic/source-control/FileStatusAdapter'; export type ConnectionStatusState = 'checking' | 'connected' | 'disconnected'; @@ -34,6 +39,12 @@ export default class GitLabFilesPush extends Plugin { syncWorkspace: SyncWorkspace; syncStatusRefresh: SyncStatusRefreshService; gitignoreManager: GitignoreManager; + changeRepository: ChangeRepository; + pushSelectionStore: PushSelectionStore; + operationState: OperationState; + sourceControlViewModel: SourceControlViewModel; + sourceControlActions: SourceControlActionService; + private unsubscribeChangeRepository?: () => void; private gitignoreConfigKey = ''; private pushRibbonEl: HTMLElement; private statusBarEl: HTMLElement; @@ -46,26 +57,19 @@ export default class GitLabFilesPush extends Plugin { this.addSettingTab(new GitLabSyncSettingTab(this.app, this)); this.registerView( - SYNC_STATUS_VIEW_TYPE, - (leaf) => new SyncStatusView(leaf, this) - ); - - // Desktop shows diffs here instead of inline in the sidebar, where a - // side-by-side view has no room. The sync panel opens and reuses it. - this.registerView( - SYNC_DIFF_VIEW_TYPE, - (leaf) => new DiffView(leaf) + SOURCE_CONTROL_VIEW_TYPE, + (leaf) => new SourceControlItemView(leaf, this) ); this.addRibbonIcon('git-compare', t('main.ribbon.openSyncStatus'), async () => { - await this.activateSyncStatusView(); + await this.activateSourceControlView(); }); this.addCommand({ id: 'open-sync-status', name: t('main.command.openSyncStatus'), callback: async () => { - await this.activateSyncStatusView(); + await this.activateSourceControlView(); } }); @@ -101,6 +105,28 @@ export default class GitLabFilesPush extends Plugin { app: this.app, }); + this.changeRepository = new ChangeRepository(); + this.pushSelectionStore = new PushSelectionStore(); + this.operationState = new OperationState(); + this.sourceControlViewModel = new SourceControlViewModel( + this.changeRepository, + this.pushSelectionStore, + this.operationState, + ); + this.sourceControlActions = new SourceControlActionService( + this.changeRepository, + this.operationState, + this.syncWorkspace, + ); + // 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.pushSelectionStore.refresh(changes.map(change => change.id)); + }); + this.statusBarEl = this.addStatusBarItem(); this.statusBarEl.addClass('gfs-status-bar-connection'); setTooltip(this.statusBarEl, t('settings.connectionStatus.checking')); @@ -207,7 +233,7 @@ export default class GitLabFilesPush extends Plugin { this.app.vault.on('rename', (file, oldPath) => { if (file instanceof TFile) { void this.sync.trackRename(file.path, oldPath).then(() => { - this.notifySyncStatusViews(view => view.handleFileRenamed(file, oldPath)); + this.syncStatusRefresh.handleFileRenamed(file, oldPath); }); } else if (file instanceof TFolder) { void this.trackFolderRename(file, oldPath); @@ -217,12 +243,14 @@ export default class GitLabFilesPush extends Plugin { // A saved edit inside the configured vault folder should update that // row's status live rather than leaving it stale until the next manual - // refresh. Reuses whatever sync panel views are currently open; no-op - // when the panel isn't open or the file isn't in scope. + // refresh. This updates the shared SyncStatusService directly, which + // republishes to any open Source Control view (and to + // ChangeRepository) via the subscription set up above; no-op when the + // file isn't in scope. this.registerEvent( this.app.vault.on('modify', (file) => { if (file instanceof TFile && this.filterPathByVaultFolder(file.path)) { - this.notifySyncStatusViews(view => void view.handleFileModified(file)); + void this.syncStatusRefresh.handleFileModified(file); } }) ); @@ -235,15 +263,8 @@ export default class GitLabFilesPush extends Plugin { } private async refreshSyncStatusOnStartup(): Promise { - await this.activateSyncStatusView(); - const leaf = this.app.workspace.getLeavesOfType(SYNC_STATUS_VIEW_TYPE)[0]; - if (leaf?.view instanceof SyncStatusView) await leaf.view.refreshAllStatuses(); - } - - private notifySyncStatusViews(callback: (view: SyncStatusView) => void): void { - for (const leaf of this.app.workspace.getLeavesOfType(SYNC_STATUS_VIEW_TYPE)) { - if (leaf.view instanceof SyncStatusView) callback(leaf.view); - } + await this.activateSourceControlView(); + await this.syncWorkspace.refresh(); } /** @@ -261,7 +282,7 @@ export default class GitLabFilesPush extends Plugin { for (const file of files) { const oldPath = oldPrefix + file.path.slice(newPrefix.length); await this.sync.trackRename(file.path, oldPath); - this.notifySyncStatusViews(view => view.handleFileRenamed(file, oldPath)); + this.syncStatusRefresh.handleFileRenamed(file, oldPath); } } @@ -372,16 +393,16 @@ export default class GitLabFilesPush extends Plugin { if (this.pushRibbonEl) setTooltip(this.pushRibbonEl, this.pushRibbonLabel()); } - async activateSyncStatusView(): Promise { + async activateSourceControlView(): Promise { const { workspace } = this.app; - let leaf = workspace.getLeavesOfType(SYNC_STATUS_VIEW_TYPE)[0]; + let leaf = workspace.getLeavesOfType(SOURCE_CONTROL_VIEW_TYPE)[0]; if (!leaf) { const rightLeaf = workspace.getRightLeaf(false); if (rightLeaf) { await rightLeaf.setViewState({ - type: SYNC_STATUS_VIEW_TYPE, + type: SOURCE_CONTROL_VIEW_TYPE, active: true, }); leaf = rightLeaf; @@ -565,7 +586,11 @@ export default class GitLabFilesPush extends Plugin { } onunload() { - // Cleanup is handled by Obsidian for registered components + // 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; } async loadSettings() { diff --git a/src/ui/DiffView.ts b/src/ui/DiffView.ts deleted file mode 100644 index 1f213b8..0000000 --- a/src/ui/DiffView.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { ItemView, WorkspaceLeaf } from 'obsidian'; -import { renderDiffPanel } from './components/DiffPanel'; -import { type FileDiff } from '../logic/sync/types'; -import { t } from '../i18n'; - -export const SYNC_DIFF_VIEW_TYPE = 'sync-diff-view'; - -/** - * Shows one file's diff in a workspace pane, which is where a wide side-by-side - * view has room to exist — the sync panel lives in a sidebar and the diff's - * split/unified switch is a container query against its own width, so the same - * markup renders side-by-side here without any style of its own. - * - * Only one of these is ever open: the sync panel reuses the existing leaf so - * opening a second file's diff replaces the content rather than stacking panes. - */ -export class DiffView extends ItemView { - private path: string | null = null; - private remoteContent?: string | ArrayBuffer; - private localContent?: string | ArrayBuffer; - private kind: FileDiff['kind'] = 'text'; - - constructor(leaf: WorkspaceLeaf) { - super(leaf); - } - - getViewType(): string { return SYNC_DIFF_VIEW_TYPE; } - getIcon(): string { return 'file-diff'; } - - getDisplayText(): string { - return this.path - ? t('diffView.titleWithFile', { path: this.path }) - : t('diffView.title'); - } - - /** The file currently on screen, so the caller can tell when it goes stale. */ - getPath(): string | null { return this.path; } - - setDiff(diff: FileDiff): void { - this.path = diff.path; - this.remoteContent = diff.remoteContent; - this.localContent = diff.localContent; - this.kind = diff.kind; - // Obsidian reads the title from getDisplayText(); nudge it to re-read. - this.leaf.setViewState({ type: SYNC_DIFF_VIEW_TYPE, active: true }).catch(() => { /* title only */ }); - this.render(); - } - - onOpen(): Promise { - this.render(); - return Promise.resolve(); - } - - private render(): void { - const container = this.containerEl.children[1] as HTMLElement | null; - if (!container) return; - - container.empty(); - container.addClass('sync-diff-view'); - - if (!this.path) { - container.createDiv({ cls: 'ssv-empty', text: t('diffView.empty') }); - return; - } - - container.createDiv({ cls: 'ssv-diff-pane-path', text: this.path }); - const body = container.createDiv({ cls: 'ssv-diff-pane' }); - - if (this.kind === 'symlink') { - body.createDiv({ cls: 'ssv-diff-binary', text: t('fileListItem.diff.symlinkChanged') }); - return; - } - if (typeof this.remoteContent === 'string' && typeof this.localContent === 'string') { - renderDiffPanel(body, this.remoteContent, this.localContent); - return; - } - body.createDiv({ cls: 'ssv-diff-binary', text: t('fileListItem.diff.binaryChanged') }); - } -} diff --git a/src/ui/SyncStatusView.ts b/src/ui/SyncStatusView.ts deleted file mode 100644 index e4ba558..0000000 --- a/src/ui/SyncStatusView.ts +++ /dev/null @@ -1 +0,0 @@ -export { SyncStatusView, SYNC_STATUS_VIEW_TYPE } from './sync-status/SyncStatusView'; diff --git a/src/ui/components/ActionBar.ts b/src/ui/components/ActionBar.ts deleted file mode 100644 index 8e3b567..0000000 --- a/src/ui/components/ActionBar.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { setIcon, setTooltip } from 'obsidian'; -import { ICONS } from './icons'; -import { t } from '../../i18n'; - -export interface ActionBarProps { - hasFiles: boolean; - allSelected: boolean; - indeterminate: boolean; - canPush: number; - canPull: number; - canDelete: number; - treeViewEnabled: boolean; - showSynced: boolean; -} - -export interface ActionBarCallbacks { - onRefresh: () => void; - onSelectAll: (select: boolean) => void; - onPush: () => void; - onPull: () => void; - onDelete: () => void; - onTreeViewChange: (enabled: boolean) => void; - onShowSyncedChange: (show: boolean) => void; -} - -export function renderActionBar(container: HTMLElement, props: ActionBarProps, callbacks: ActionBarCallbacks): void { - const bar = container.createDiv({ cls: 'ssv-action-bar' }); - const actions = bar.createDiv({ cls: 'ssv-action-bar-row' }); - renderRefreshButton(actions, callbacks.onRefresh); - - if (props.hasFiles) { - actions.createDiv({ cls: 'ssv-bar-spacer' }); - renderSelectAllRow(actions, props.allSelected, props.indeterminate, callbacks.onSelectAll); - renderLargeButton(actions, ICONS.push, t('actionBar.pushCount', { count: props.canPush }), t('actionBar.pushFiles', { count: props.canPush }), callbacks.onPush, 'push', props.canPush === 0); - renderLargeButton(actions, ICONS.pull, t('actionBar.pullCount', { count: props.canPull }), t('actionBar.pullFiles', { count: props.canPull }), callbacks.onPull, 'pull', props.canPull === 0); - renderLargeButton(actions, ICONS.delete, t('actionBar.deleteCount', { count: props.canDelete }), t('actionBar.deleteFiles', { count: props.canDelete }), callbacks.onDelete, 'danger', props.canDelete === 0); - } - - renderTreeOptions(bar, props, callbacks); -} - -function renderTreeOptions(bar: HTMLElement, props: ActionBarProps, callbacks: ActionBarCallbacks): void { - const options = bar.createDiv({ cls: 'ssv-tree-options' }); - renderCheckboxOption(options, 'ssv-tree-view-toggle', t('syncStatus.treeView'), props.treeViewEnabled, callbacks.onTreeViewChange); - if (props.treeViewEnabled) { - renderCheckboxOption(options, 'ssv-show-synced-toggle', t('syncStatus.showSynced'), props.showSynced, callbacks.onShowSyncedChange); - } -} - -function renderCheckboxOption(container: HTMLElement, checkboxClass: string, labelText: string, checked: boolean, onChange: (checked: boolean) => void): void { - const label = container.createEl('label', { cls: 'ssv-tree-option' }); - const checkbox = label.createEl('input', { type: 'checkbox', cls: checkboxClass }); - checkbox.checked = checked; - label.createSpan({ text: labelText }); - checkbox.addEventListener('change', () => onChange(checkbox.checked)); -} - -function renderRefreshButton(bar: HTMLElement, onRefresh: () => void): void { - const btn = bar.createEl('button', { cls: 'ssv-btn ssv-btn-refresh' }); - setIcon(btn.createSpan(), ICONS.refresh); - btn.createSpan({ cls: 'ssv-btn-label', text: t('actionBar.refresh') }); - setTooltip(btn, t('actionBar.refreshAll')); - btn.addEventListener('click', onRefresh); -} - -function renderSelectAllRow(bar: HTMLElement, allSelected: boolean, indeterminate: boolean, onSelectAll: (select: boolean) => void): void { - const selectRow = bar.createDiv({ cls: 'ssv-select-row' }); - const cb = selectRow.createEl('input', { type: 'checkbox' }); - cb.checked = allSelected; - cb.indeterminate = indeterminate; - selectRow.createSpan({ cls: 'ssv-select-label', text: t('actionBar.select') }); - cb.addEventListener('change', () => onSelectAll(cb.checked)); -} - -function renderLargeButton(container: HTMLElement, icon: string, label: string, tooltip: string, onClick: () => void, cls: string, disabled: boolean): void { - const btn = container.createEl('button', { cls: `ssv-btn ssv-btn-${cls}` }); - setIcon(btn.createSpan(), icon); - btn.createSpan({ cls: 'ssv-btn-label', text: label }); - btn.disabled = disabled; - setTooltip(btn, tooltip); - btn.addEventListener('click', onClick); -} diff --git a/src/ui/components/FileListItem.ts b/src/ui/components/FileListItem.ts deleted file mode 100644 index fc9c8b6..0000000 --- a/src/ui/components/FileListItem.ts +++ /dev/null @@ -1,246 +0,0 @@ -import { Keymap, Platform, setIcon, setTooltip } from 'obsidian'; -import { type FileStatus } from '../types'; -import { renderDiffPanel } from './DiffPanel'; -import { ICONS } from './icons'; -import { t } from '../../i18n'; - -export interface FileItemCallbacks { - onSelect: (path: string, selected: boolean) => void; - onPush: (fileStatus: FileStatus) => void; - onPull: (fileStatus: FileStatus) => void; - onDelete: (fileStatus: FileStatus) => void; - /** - * Opens the file where it actually lives — in the vault when there's a - * local copy, otherwise on the provider's site in a browser. Returns false - * when neither is possible (a hidden path Obsidian can't open, or provider - * settings that don't identify a web URL), in which case the path renders - * as plain text rather than a link that goes nowhere. - */ - onOpen: (fileStatus: FileStatus, newLeaf: boolean) => boolean; - /** Whether onOpen would succeed, so the path can be rendered accordingly. */ - canOpen: (fileStatus: FileStatus) => boolean; - /** - * Called the first time a modified file's diff is expanded and its remote - * content hasn't been fetched yet. Must fetch the content, mutate the - * fileStatus object in place (remoteContent, localContent as needed), and - * resolve once it's ready to render. - */ - onExpandDiff: (fileStatus: FileStatus) => Promise; - /** - * Desktop only: shows the diff in its own workspace pane instead of inline. - * The inline panel is stuck at sidebar width, where the side-by-side view - * can't fit; a pane gives it room. Mobile keeps the inline panel. - */ - onOpenDiffPane: (fileStatus: FileStatus) => void; - /** Undoes a pending move: moves the local file back to fileStatus.movedFrom. */ - onRevertMove: (fileStatus: FileStatus) => void; -} - -// `icon` is a Lucide icon id (rendered via Obsidian's setIcon) so every status -// uses the same icon set and renders consistently across platforms. -export function statusMeta(status: FileStatus['status']) { - switch (status) { - case 'synced': return { icon: ICONS.synced, label: t('syncStatus.tab.synced'), iconCls: 'ssv-icon-synced', badgeCls: 'ssv-badge-synced', fileCls: 'status-synced' }; - case 'modified': return { icon: ICONS.modified, label: t('syncStatus.tab.modified'), iconCls: 'ssv-icon-modified', badgeCls: 'ssv-badge-modified', fileCls: 'status-modified' }; - case 'unsynced': return { icon: ICONS.push, label: t('syncStatus.tab.unsynced'), iconCls: 'ssv-icon-unsynced', badgeCls: 'ssv-badge-unsynced', fileCls: 'status-unsynced' }; - case 'remote-only': return { icon: ICONS.pull, label: t('syncStatus.tab.remote-only'), iconCls: 'ssv-icon-remote', badgeCls: 'ssv-badge-remote', fileCls: 'status-remote' }; - case 'moved': return { icon: ICONS.moved, label: t('syncStatus.tab.moved'), iconCls: 'ssv-icon-moved', badgeCls: 'ssv-badge-moved', fileCls: 'status-moved' }; - default: return { icon: ICONS.checking, label: t('syncStatus.status.checking'), iconCls: 'ssv-icon-checking', badgeCls: 'ssv-badge-checking', fileCls: 'status-checking' }; - } -} - -export function renderFileItem( - container: HTMLElement, - fileStatus: FileStatus, - isSelected: boolean, - callbacks: FileItemCallbacks -): void { - const { icon, label, iconCls, badgeCls, fileCls } = statusMeta(fileStatus.status); - const fileEl = container.createDiv({ cls: `ssv-file ${fileCls}` }); - const row = fileEl.createDiv({ cls: 'ssv-file-row' }); - - const cb = row.createEl('input', { type: 'checkbox', cls: 'ssv-file-checkbox' }); - cb.checked = isSelected; - cb.addEventListener('change', () => callbacks.onSelect(fileStatus.path, cb.checked)); - - setIcon(row.createSpan({ cls: `ssv-file-icon ${iconCls}` }), icon); - renderFilePath(row, fileStatus, callbacks); - row.createSpan({ cls: `ssv-status-badge ${badgeCls}`, text: label }); - - if (fileStatus.status === 'moved' && fileStatus.movedFrom) { - fileEl.createDiv({ cls: 'ssv-moved-from', text: fileStatus.movedFrom }); - } - - if (fileStatus.status !== 'synced' && fileStatus.status !== 'checking') { - renderFileActions(fileEl, fileStatus, callbacks); - } -} - -/** - * The path opens the file; the rest of the row is left alone. Rows the caller - * can't open stay plain text so there's never a link that does nothing — - * `remote-only` rows are exactly the ones users are most curious about, so a - * dead link there would be worse than none. - */ -function renderFilePath(row: HTMLElement, fileStatus: FileStatus, callbacks: FileItemCallbacks): void { - if (!callbacks.canOpen(fileStatus)) { - row.createSpan({ cls: 'ssv-file-path', text: fileStatus.path }); - return; - } - - const pathEl = row.createSpan({ cls: 'ssv-file-path ssv-file-path-link', text: fileStatus.path }); - pathEl.setAttr('role', 'link'); - pathEl.setAttr('tabindex', '0'); - setTooltip(pathEl, fileStatus.status === 'remote-only' - ? t('fileListItem.tooltip.openRemote') - : t('fileListItem.tooltip.openFile')); - - pathEl.addEventListener('click', (evt) => { - evt.preventDefault(); - // Obsidian's convention: a modifier opens in a new tab or split. - callbacks.onOpen(fileStatus, Keymap.isModEvent(evt) !== false); - }); - pathEl.addEventListener('keydown', (evt) => { - if (evt.key !== 'Enter' && evt.key !== ' ') return; - evt.preventDefault(); - callbacks.onOpen(fileStatus, false); - }); -} - -function renderFileActions(fileEl: HTMLElement, fileStatus: FileStatus, callbacks: FileItemCallbacks): void { - const actions = fileEl.createDiv({ cls: 'ssv-file-actions' }); - - if (fileStatus.status === 'modified' || (fileStatus.status === 'moved' && fileStatus.remoteSha !== undefined)) { - // One entry point per platform, never both: two buttons rendering the - // same diff differently just invites "what's the difference?". - if (Platform.isMobile) renderDiffToggleButton(actions, fileEl, fileStatus, callbacks); - else renderDiffPaneButton(actions, fileStatus, callbacks); - } - - if (fileStatus.status === 'modified' || fileStatus.status === 'unsynced' || fileStatus.status === 'moved') { - renderActionBtn(actions, ICONS.push, t('fileListItem.action.push'), t('fileListItem.tooltip.pushToRemote'), () => callbacks.onPush(fileStatus), 'push'); - } - - if (fileStatus.status === 'modified' || fileStatus.status === 'remote-only') { - renderActionBtn(actions, ICONS.pull, t('fileListItem.action.pull'), t('fileListItem.tooltip.pullFromRemote'), () => callbacks.onPull(fileStatus), 'pull'); - } - - if (fileStatus.status === 'unsynced') { - renderActionBtn(actions, ICONS.delete, t('fileListItem.action.remove'), t('fileListItem.tooltip.deleteLocalFile'), () => callbacks.onDelete(fileStatus), 'danger'); - } - - // Pull has no meaning on a moved row (it would silently undo the move); - // revert is the explicit, confirmed equivalent. - if (fileStatus.status === 'moved') { - renderActionBtn(actions, ICONS.revert, t('fileListItem.action.revert'), t('fileListItem.tooltip.revertMove'), () => callbacks.onRevertMove(fileStatus), 'danger'); - } -} - -function renderDiffPaneButton(actions: HTMLElement, fileStatus: FileStatus, callbacks: FileItemCallbacks): void { - renderActionBtn( - actions, ICONS.diff, t('fileListItem.action.diff'), t('fileListItem.tooltip.openDiffPane'), - () => callbacks.onOpenDiffPane(fileStatus), 'diff' - ); -} - -function renderDiffToggleButton(actions: HTMLElement, fileEl: HTMLElement, fileStatus: FileStatus, callbacks: FileItemCallbacks): void { - const diffBtn = actions.createEl('button', { cls: 'ssv-action-btn diff' }); - const iconEl = diffBtn.createSpan(); - setIcon(iconEl, ICONS.diff); - const btnLabel = diffBtn.createSpan({ cls: 'ssv-btn-label', text: t('fileListItem.action.diff') }); - - const diffEl = fileEl.createDiv({ cls: 'ssv-diff' }); - renderDiffBody(diffEl, fileStatus); - - setTooltip(diffBtn, t('fileListItem.tooltip.toggleDiff')); - diffBtn.addEventListener('click', () => { - const open = diffEl.hasClass('visible'); - if (!open && needsContentFetch(fileStatus)) { - diffEl.empty(); - diffEl.createDiv({ cls: 'ssv-diff-loading', text: t('fileListItem.diff.loading') }); - void callbacks.onExpandDiff(fileStatus).then(() => renderDiffBody(diffEl, fileStatus)); - } - diffEl.toggleClass('visible', !open); - btnLabel.setText(open ? t('fileListItem.action.diff') : t('fileListItem.action.hide')); - setIcon(iconEl, open ? ICONS.diff : ICONS.diffOpen); - }); -} - -function needsContentFetch(fileStatus: FileStatus): boolean { - return !fileStatus.isSymlink && fileStatus.remoteContent === undefined; -} - -function renderDiffBody(diffEl: HTMLElement, fileStatus: FileStatus): void { - diffEl.empty(); - if (fileStatus.isSymlink) { - diffEl.createDiv({ cls: 'ssv-diff-binary', text: t('fileListItem.diff.symlinkChanged') }); - } else if (typeof fileStatus.remoteContent === 'string' && typeof fileStatus.localContent === 'string') { - renderDiffPanel(diffEl, fileStatus.remoteContent, fileStatus.localContent); - } else if (fileStatus.remoteContent === undefined) { - diffEl.createDiv({ cls: 'ssv-diff-loading', text: t('fileListItem.diff.clickToLoad') }); - } else { - diffEl.createDiv({ cls: 'ssv-diff-binary', text: t('fileListItem.diff.binaryChanged') }); - } -} - -function renderActionBtn(actions: HTMLElement, icon: string, label: string, tooltip: string, onClick: () => void, cls: string): void { - const btn = actions.createEl('button', { cls: `ssv-action-btn ${cls}` }); - setIcon(btn.createSpan(), icon); - btn.createSpan({ cls: 'ssv-btn-label', text: label }); - setTooltip(btn, tooltip); - btn.addEventListener('click', onClick); -} - -export interface MoveGroupCallbacks { - onSelect: (members: FileStatus[], selected: boolean) => void; - onPush: (members: FileStatus[]) => void; - onRevertMove: (members: FileStatus[]) => void; - onToggleExpand: (key: string) => void; -} - -/** - * A whole-folder move collapsed to one row: "Archive/Projects/" with the - * struck-through old prefix beneath it, same visual language as a single - * moved row (FileListItem's .ssv-moved-from) but for a prefix instead of one - * path. Expanding lists the members as read-only sub-rows — "move half a - * folder" isn't a thing the user means from this row, so children get no - * individual checkboxes. - */ -export function renderMoveGroupItem( - container: HTMLElement, - key: string, - oldPrefix: string, - newPrefix: string, - members: FileStatus[], - isSelected: boolean, - isExpanded: boolean, - callbacks: MoveGroupCallbacks -): void { - const fileEl = container.createDiv({ cls: 'ssv-file status-moved ssv-move-group' }); - const row = fileEl.createDiv({ cls: 'ssv-file-row' }); - - const cb = row.createEl('input', { type: 'checkbox', cls: 'ssv-file-checkbox' }); - cb.checked = isSelected; - cb.addEventListener('change', () => callbacks.onSelect(members, cb.checked)); - - setIcon(row.createSpan({ cls: 'ssv-file-icon ssv-icon-moved' }), ICONS.moved); - row.createSpan({ cls: 'ssv-file-path', text: `${newPrefix}/` }); - row.createSpan({ cls: 'ssv-status-badge ssv-badge-moved', text: t('fileListItem.movedGroup.badge', { count: members.length }) }); - - fileEl.createDiv({ cls: 'ssv-moved-from', text: `${oldPrefix}/` }); - - const actions = fileEl.createDiv({ cls: 'ssv-file-actions' }); - renderActionBtn(actions, ICONS.push, t('fileListItem.action.push'), t('fileListItem.tooltip.pushToRemote'), () => callbacks.onPush(members), 'push'); - - const expandLabel = isExpanded ? t('fileListItem.movedGroup.hide') : t('fileListItem.movedGroup.show', { count: members.length }); - renderActionBtn(actions, isExpanded ? ICONS.diffOpen : ICONS.diff, expandLabel, expandLabel, () => callbacks.onToggleExpand(key), 'diff'); - - renderActionBtn(actions, ICONS.revert, t('fileListItem.action.revert'), t('fileListItem.tooltip.revertMove'), () => callbacks.onRevertMove(members), 'danger'); - - if (isExpanded) { - const childList = fileEl.createDiv({ cls: 'ssv-move-group-children' }); - for (const member of members) { - childList.createDiv({ cls: 'ssv-move-group-child', text: member.path }); - } - } -} diff --git a/src/ui/components/FolderTreeItem.ts b/src/ui/components/FolderTreeItem.ts deleted file mode 100644 index 8b83824..0000000 --- a/src/ui/components/FolderTreeItem.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { setIcon } from 'obsidian'; -import { ICONS } from './icons'; -import type { StatusTreeFolder, StatusTreeNode } from './StatusTree'; - -export interface FolderTreeItemCallbacks { - onSelect: (paths: string[], selected: boolean) => void; - onToggle: (path: string) => void; -} - -export function renderFolderItem( - container: HTMLElement, - folder: StatusTreeFolder, - selectedPaths: ReadonlySet, - isExpanded: boolean, - callbacks: FolderTreeItemCallbacks, -): HTMLElement | undefined { - const paths = descendantFilePaths(folder); - const selectedCount = paths.filter(path => selectedPaths.has(path)).length; - const folderEl = container.createDiv({ cls: 'ssv-tree-folder' }); - const row = folderEl.createDiv({ cls: 'ssv-tree-folder-row' }); - renderDisclosureButton(row, folder, isExpanded, callbacks); - renderFolderCheckbox(row, paths, selectedCount, callbacks); - setIcon(row.createSpan({ cls: 'ssv-tree-folder-icon' }), ICONS.folder); - row.createSpan({ cls: 'ssv-tree-folder-name', text: folder.name }); - - return isExpanded ? folderEl.createDiv({ cls: 'ssv-tree-children' }) : undefined; -} - -function renderDisclosureButton( - row: HTMLElement, - folder: StatusTreeFolder, - isExpanded: boolean, - callbacks: FolderTreeItemCallbacks, -): void { - const button = row.createEl('button', { - cls: 'ssv-folder-toggle', - attr: { 'aria-expanded': String(isExpanded) }, - }); - button.setText(isExpanded ? '−' : '+'); - button.addEventListener('click', () => callbacks.onToggle(folder.path)); -} - -function renderFolderCheckbox( - row: HTMLElement, - paths: string[], - selectedCount: number, - callbacks: FolderTreeItemCallbacks, -): void { - const checkbox = row.createEl('input', { type: 'checkbox', cls: 'ssv-folder-checkbox' }); - checkbox.checked = paths.length > 0 && selectedCount === paths.length; - checkbox.indeterminate = selectedCount > 0 && selectedCount < paths.length; - checkbox.addEventListener('change', () => callbacks.onSelect(paths, checkbox.checked)); -} - -export function descendantFilePaths(folder: StatusTreeFolder): string[] { - return folder.children.flatMap(descendantPaths); -} - -function descendantPaths(node: StatusTreeNode): string[] { - return node.kind === 'file' ? [node.status.path] : descendantFilePaths(node); -} diff --git a/src/ui/components/StatusTree.ts b/src/ui/components/StatusTree.ts deleted file mode 100644 index e10ca42..0000000 --- a/src/ui/components/StatusTree.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type { FileStatus } from '../types'; - -export type StatusTreeNode = StatusTreeFolder | StatusTreeFile; - -export interface StatusTreeFolder { - kind: 'folder'; - name: string; - path: string; - children: StatusTreeNode[]; -} - -export interface StatusTreeFile { - kind: 'file'; - name: string; - status: FileStatus; -} - -type MutableFolder = StatusTreeFolder & { folders: Map }; - -/** Builds a presentation-only tree; sync state remains keyed by file path. */ -export function buildStatusTree(statuses: FileStatus[]): StatusTreeFolder { - const root = createFolder('', ''); - for (const status of statuses) addStatus(root, status); - finalizeFolder(root); - return root; -} - -function createFolder(name: string, path: string): MutableFolder { - return { kind: 'folder', name, path, children: [], folders: new Map() }; -} - -function addStatus(root: MutableFolder, status: FileStatus): void { - const segments = status.path.split('/'); - const fileName = segments.pop(); - if (!fileName) return; - - let folder = root; - for (const segment of segments) folder = getOrCreateFolder(folder, segment); - folder.children.push({ kind: 'file', name: fileName, status }); -} - -function getOrCreateFolder(parent: MutableFolder, name: string): MutableFolder { - const existing = parent.folders.get(name); - if (existing) return existing; - - const path = parent.path === '' ? name : `${parent.path}/${name}`; - const folder = createFolder(name, path); - parent.folders.set(name, folder); - parent.children.push(folder); - return folder; -} - -function finalizeFolder(folder: MutableFolder): void { - for (const child of folder.children) if (child.kind === 'folder') finalizeFolder(child as MutableFolder); - folder.children.sort(compareTreeNodes); - delete (folder as Partial).folders; -} - -function compareTreeNodes(left: StatusTreeNode, right: StatusTreeNode): number { - const attention = Number(hasAttention(right)) - Number(hasAttention(left)); - if (attention !== 0) return attention; - return left.name.localeCompare(right.name); -} - -function hasAttention(node: StatusTreeNode): boolean { - return node.kind === 'file' - ? node.status.status !== 'synced' - : node.children.some(hasAttention); -} diff --git a/src/ui/source-control/SourceControlItemView.ts b/src/ui/source-control/SourceControlItemView.ts new file mode 100644 index 0000000..90178e7 --- /dev/null +++ b/src/ui/source-control/SourceControlItemView.ts @@ -0,0 +1,78 @@ +import { ItemView, WorkspaceLeaf, debounce } from 'obsidian'; +import GitLabFilesPush from '../../main'; +import { t } from '../../i18n'; +import type { SourceControlItem } from '../../logic/source-control/SourceControlViewModel'; +import { SourceControlView, type SourceControlViewCallbacks } from './SourceControlView'; + +// 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 +// instead of Obsidian showing an "unrecognized view type" placeholder. +export const SOURCE_CONTROL_VIEW_TYPE = 'sync-status-view'; + +/** + * Obsidian `ItemView` host for `SourceControlView` (Phase 3). Owns nothing + * beyond render lifecycle and live-refresh wiring -- all sync state and + * action handling live in `SourceControlViewModel` / `SourceControlActionService` + * (Phase 1/2), reached only through `plugin.sourceControl*`, per + * docs/source-control-refactor/phase-4-legacy-cleanup.md. + */ +export class SourceControlItemView extends ItemView { + private static readonly RENDER_THROTTLE_MS = 150; + private readonly view: SourceControlView; + private unsubscribeStatuses?: () => void; + private readonly renderOnStatusChange = debounce( + () => this.renderView(), + SourceControlItemView.RENDER_THROTTLE_MS, + false, + ); + + constructor(leaf: WorkspaceLeaf, private readonly plugin: GitLabFilesPush) { + super(leaf); + const callbacks: SourceControlViewCallbacks = { + onPush: (changeIds) => this.runAction(this.plugin.sourceControlActions.push(changeIds)), + loadDiffContent: (item: SourceControlItem) => this.plugin.sourceControlActions.loadDiffContent(item), + }; + this.view = new SourceControlView( + this.plugin.sourceControlViewModel, + this.plugin.pushSelectionStore, + callbacks, + ); + } + + getViewType(): string { return SOURCE_CONTROL_VIEW_TYPE; } + getDisplayText(): string { return t('sourceControl.viewTitle'); } + getIcon(): string { return 'git-compare'; } + + onOpen(): Promise { + this.unsubscribeStatuses = this.plugin.sync.status.subscribe(() => this.renderOnStatusChange()); + this.renderView(); + return Promise.resolve(); + } + + onClose(): Promise { + this.unsubscribeStatuses?.(); + this.unsubscribeStatuses = undefined; + return Promise.resolve(); + } + + private renderView(): void { + const container = this.containerEl.children[1] as HTMLElement | null; + if (container) this.view.render(container); + } + + /** + * `SourceControlActionService` marks each targeted change 'running' + * synchronously before its first internal `await` (see + * `SourceControlActionService.startAll`), so by the time the promise it + * returns has been constructed, that state is already visible to the + * next render -- render immediately to reflect it, then again once the + * operation settles. A successful push/pull also updates + * `plugin.sync.status` (via `SyncMetadataStore.update`), which re-renders + * through the subscription above; the explicit re-render here is what + * covers the failure path, where nothing else republishes status. + */ + private runAction(action: Promise): void { + this.renderView(); + void action.finally(() => this.renderView()); + } +} diff --git a/src/ui/sync-status/SyncStatusComposition.ts b/src/ui/sync-status/SyncStatusComposition.ts deleted file mode 100644 index dcf9f3d..0000000 --- a/src/ui/sync-status/SyncStatusComposition.ts +++ /dev/null @@ -1,70 +0,0 @@ -import type { App } from 'obsidian'; -import type GitLabFilesPush from '../../main'; -import type { SyncStatusService } from '../../logic/sync-status-service'; -import { SyncStatusRefreshService } from '../../logic/sync/SyncStatusRefreshService'; -import { ensureSyncWorkspaceRuntime } from '../../logic/sync/SyncWorkspace'; -import type { SyncWorkspace } from '../../logic/sync/SyncWorkspace'; -import { SyncStatusController } from './SyncStatusController'; -import { SyncStatusNavigator } from './SyncStatusNavigator'; -import { SyncStatusOperations } from './SyncStatusOperations'; -import { SyncStatusRenderer } from './SyncStatusRenderer'; -import type { SyncStatusViewState } from './SyncStatusViewState'; - -export interface SyncStatusComposition { - controller: SyncStatusController; - navigator: SyncStatusNavigator; - operations: SyncStatusOperations; - renderer: SyncStatusRenderer; - statusRefresh: SyncStatusRefreshService; - workspace: SyncWorkspace; -} - -export interface SyncStatusCompositionCallbacks { - render(): void; - refresh(): Promise; - refreshStatuses(): Promise; -} - -/** Composition root for the sync-status UI and its domain-facing adapters. */ -export function createSyncStatusComposition( - app: App, - plugin: GitLabFilesPush, - state: SyncStatusViewState, - statuses: SyncStatusService, - callbacks: SyncStatusCompositionCallbacks, - providedController?: SyncStatusController, -): SyncStatusComposition { - const runtime = ensureSyncWorkspaceRuntime(app, plugin, statuses); - const statusRefresh = runtime.refreshService; - const navigator = new SyncStatusNavigator(app, runtime.workspace); - const operations = new SyncStatusOperations( - app, - runtime.workspace, - statuses, - state, - statusRefresh, - navigator, - () => callbacks.render(), - () => callbacks.refresh(), - ); - const controller = providedController ?? new SyncStatusController({ - refresh: () => callbacks.refreshStatuses(), - push: paths => operations.runPaths(paths, 'push'), - pull: paths => operations.runPaths(paths, 'pull'), - delete: paths => operations.deletePaths(paths), - openDiff: path => navigator.openDiff(path), - pushOne: status => operations.runSingle(status, 'push'), - pullOne: status => operations.runSingle(status, 'pull'), - deleteLocal: status => operations.deleteLocal(status), - loadDiff: path => navigator.loadDiff(path), - openFile: (status, newLeaf) => navigator.openFile(status, newLeaf), - canOpen: status => navigator.targetFor(status) !== null, - revertMove: status => operations.revertMove(status), - pushMoveGroup: members => operations.pushMoveGroup(members), - revertMoveGroup: members => operations.revertMoveGroup(members), - pushAllModified: () => operations.runBatch('modified', 'push'), - pullAllModified: () => operations.runBatch('modified', 'pull'), - }); - const renderer = new SyncStatusRenderer(() => runtime.workspace.getInfo(), state, statuses, controller, () => callbacks.render()); - return { controller, navigator, operations, renderer, statusRefresh, workspace: runtime.workspace }; -} diff --git a/src/ui/sync-status/SyncStatusController.ts b/src/ui/sync-status/SyncStatusController.ts deleted file mode 100644 index 7310e7c..0000000 --- a/src/ui/sync-status/SyncStatusController.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { FileStatus } from '../../logic/sync-status-service'; - -export interface SyncStatusCommandPort { - refresh(): Promise; - push(paths: readonly string[]): Promise; - pull(paths: readonly string[]): Promise; - delete(paths: readonly string[]): Promise; - openDiff(path: string): Promise; - pushOne(status: FileStatus): Promise; - pullOne(status: FileStatus): Promise; - deleteLocal(status: FileStatus): Promise; - loadDiff(path: string): Promise; - openFile(status: FileStatus, newLeaf: boolean): boolean; - canOpen(status: FileStatus): boolean; - revertMove(status: FileStatus): Promise; - pushMoveGroup(members: FileStatus[]): Promise; - revertMoveGroup(members: FileStatus[]): Promise; - pushAllModified(): Promise; - pullAllModified(): Promise; -} - -/** Converts view events into path-only workspace commands. */ -export class SyncStatusController { - constructor(private readonly commands: SyncStatusCommandPort) {} - - refresh(): Promise { - return this.commands.refresh(); - } - - push(paths: readonly string[]): Promise { - return this.commands.push(paths); - } - - pull(paths: readonly string[]): Promise { - return this.commands.pull(paths); - } - - delete(paths: readonly string[]): Promise { - return this.commands.delete(paths); - } - - openDiff(path: string): Promise { - return this.commands.openDiff(path); - } - - pushOne(status: FileStatus): Promise { return this.commands.pushOne(status); } - pullOne(status: FileStatus): Promise { return this.commands.pullOne(status); } - deleteLocal(status: FileStatus): Promise { return this.commands.deleteLocal(status); } - loadDiff(path: string): Promise { return this.commands.loadDiff(path); } - openFile(status: FileStatus, newLeaf: boolean): boolean { return this.commands.openFile(status, newLeaf); } - canOpen(status: FileStatus): boolean { return this.commands.canOpen(status); } - revertMove(status: FileStatus): Promise { return this.commands.revertMove(status); } - pushMoveGroup(members: FileStatus[]): Promise { return this.commands.pushMoveGroup(members); } - revertMoveGroup(members: FileStatus[]): Promise { return this.commands.revertMoveGroup(members); } - pushAllModified(): Promise { return this.commands.pushAllModified(); } - pullAllModified(): Promise { return this.commands.pullAllModified(); } -} diff --git a/src/ui/sync-status/SyncStatusNavigator.ts b/src/ui/sync-status/SyncStatusNavigator.ts deleted file mode 100644 index 54ced6e..0000000 --- a/src/ui/sync-status/SyncStatusNavigator.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { type App, TFile } from 'obsidian'; -import type { FileDiff } from '../../logic/sync/types'; -import type { FileStatus } from '../../logic/sync-status-service'; -import { DiffView, SYNC_DIFF_VIEW_TYPE } from '../DiffView'; - -export type SyncStatusOpenTarget = - | { kind: 'local'; file: TFile } - | { kind: 'remote'; url: string }; - -export interface DiffWorkspace { - getDiff(path: string): Promise; - getRemoteFileUrl(path: string): string | null; -} - -/** Owns Obsidian navigation and diff-pane presentation for sync-status rows. */ -export class SyncStatusNavigator { - constructor( - private readonly app: App, - private readonly workspace: DiffWorkspace, - ) {} - - targetFor(status: FileStatus): SyncStatusOpenTarget | null { - if (status.status === 'remote-only') { - const url = this.workspace.getRemoteFileUrl(status.path); - return url ? { kind: 'remote', url } : null; - } - const file = status.file ?? this.app.vault.getFileByPath(status.path); - return file instanceof TFile ? { kind: 'local', file } : null; - } - - openFile(status: FileStatus, newLeaf: boolean): boolean { - const target = this.targetFor(status); - if (!target) return false; - if (target.kind === 'local') void this.app.workspace.getLeaf(newLeaf).openFile(target.file); - else window.open(target.url, '_blank'); - return true; - } - - async loadDiff(path: string): Promise { - await this.workspace.getDiff(path); - } - - async openDiff(path: string): Promise { - const diff = await this.workspace.getDiff(path); - const existing = this.app.workspace.getLeavesOfType(SYNC_DIFF_VIEW_TYPE)[0]; - const leaf = existing ?? this.app.workspace.getLeaf('tab'); - if (!existing) await leaf.setViewState({ type: SYNC_DIFF_VIEW_TYPE, active: true }); - if (leaf.view instanceof DiffView) leaf.view.setDiff(diff); - await this.app.workspace.revealLeaf(leaf); - } - - closeDiffFor(paths: Iterable): void { - const changed = new Set(paths); - for (const leaf of this.app.workspace.getLeavesOfType(SYNC_DIFF_VIEW_TYPE)) { - const shown = leaf.view instanceof DiffView ? leaf.view.getPath() : null; - if (shown !== null && changed.has(shown)) leaf.detach(); - } - } -} diff --git a/src/ui/sync-status/SyncStatusOperations.ts b/src/ui/sync-status/SyncStatusOperations.ts deleted file mode 100644 index 96f78ab..0000000 --- a/src/ui/sync-status/SyncStatusOperations.ts +++ /dev/null @@ -1,306 +0,0 @@ -import { type App, Notice } from 'obsidian'; -import { t, type TranslationKey } from '../../i18n'; -import type { PushResults, SyncPlan } from '../../logic/sync/types'; -import type { SyncWorkspace } from '../../logic/sync/SyncWorkspace'; -import type { FileStatus, SyncStatusService } from '../../logic/sync-status-service'; -import { logger } from '../../utils/logger'; -import { ConfirmModal } from '../ConfirmModal'; -import { SyncPlanModal } from '../SyncPlanModal'; -import type { SyncStatusRefreshService } from '../../logic/sync/SyncStatusRefreshService'; -import type { SyncStatusViewState } from './SyncStatusViewState'; -import type { SyncStatusNavigator } from './SyncStatusNavigator'; - -type BatchFilter = 'modified' | 'selected'; -type SyncOperation = 'push' | 'pull'; - -const NO_RUNNABLE_FILES_KEYS: Record> = { - push: { selected: 'syncStatus.notice.noPushableFiles.selected', found: 'syncStatus.notice.noPushableFiles.found' }, - pull: { selected: 'syncStatus.notice.noPullableFiles.selected', found: 'syncStatus.notice.noPullableFiles.found' }, -}; - -/** Orchestrates sync-status commands while the View only forwards UI events. */ -export class SyncStatusOperations { - constructor( - private readonly app: App, - private readonly workspace: SyncWorkspace, - private readonly statuses: SyncStatusService, - private readonly state: SyncStatusViewState, - private readonly statusRefresh: SyncStatusRefreshService, - private readonly navigator: SyncStatusNavigator, - private readonly render: () => void, - private readonly refresh: () => Promise, - ) {} - - async revertMove(status: FileStatus): Promise { - if (!status.movedFrom) return; - const confirmed = await this.confirm(t('syncStatus.confirmRevertMove', { from: status.path, to: status.movedFrom })); - if (!confirmed) return; - try { - await this.moveBack(status); - new Notice(t('syncStatus.notice.moveReverted', { path: status.movedFrom })); - await this.refresh(); - } catch (error) { - new Notice(t('syncStatus.notice.revertFailed', { message: this.errorMessage(error) })); - } - } - - async pushMoveGroup(members: FileStatus[]): Promise { - try { - const results = await this.workspace.push(members.map(member => member.path)); - this.markSynced(results.syncedPaths); - this.render(); - } catch (error) { - new Notice(t('syncStatus.notice.opFailed', { verb: t('main.verb.push'), message: this.errorMessage(error) })); - } - } - - async revertMoveGroup(members: FileStatus[]): Promise { - if (!await this.confirm(t('syncStatus.confirmRevertMoveGroup', { count: members.length }))) return; - for (const member of members) { - if (!member.movedFrom) continue; - try { - await this.moveBack(member); - } catch (error) { - logger.warn(`Failed to revert move for ${member.path}`, error); - } - } - new Notice(t('syncStatus.notice.moveReverted', { path: `${members.length} file(s)` })); - await this.refresh(); - } - - async deleteLocal(status: FileStatus): Promise { - if (!await this.confirm(t('syncStatus.confirmDeleteLocal', { path: status.path }))) return; - try { - await this.workspace.deleteLocal(status.path); - new Notice(t('syncStatus.notice.deleted', { path: status.path })); - this.statuses.delete(status.path); - this.render(); - } catch (error) { - new Notice(t('syncStatus.notice.deleteFailed', { message: this.errorMessage(error) })); - } - } - - async runSingle(status: FileStatus, operation: SyncOperation): Promise { - const runVerb = operation === 'push' ? t('main.verb.pushing') : t('main.verb.pulling'); - const progress = new Notice(t('syncStatus.notice.opStarted', { verb: runVerb, name: status.path }), 0); - try { - this.statuses.set({ ...status, status: 'checking' }); - this.navigator.closeDiffFor([status.path]); - this.render(); - await this.executeSingle(status, operation); - progress.hide(); - this.render(); - } catch (error) { - progress.hide(); - const verb = operation === 'push' ? t('main.verb.push') : t('main.verb.pull'); - new Notice(t('syncStatus.notice.opFailed', { verb, message: this.errorMessage(error) })); - await this.statusRefresh.refreshFileStatusByContent(status.file || status.path); - this.render(); - } - } - - async runBatch(filter: BatchFilter, operation: SyncOperation): Promise { - const targets = this.runnableStatuses(operation, filter === 'selected' ? this.state.selectedFiles : undefined); - if (targets.length === 0) { - const scope = filter === 'selected' ? 'selected' : 'found'; - new Notice(t(NO_RUNNABLE_FILES_KEYS[operation][scope])); - return; - } - const files = targets.map(status => status.path); - if (!await this.confirmBatch(operation, files.length)) return; - await this.executeBatch(filter, operation, files); - } - - async runPaths(paths: readonly string[], operation: SyncOperation): Promise { - const targets = this.runnableStatuses(operation, new Set(paths)); - if (targets.length === 0) { - new Notice(t(NO_RUNNABLE_FILES_KEYS[operation].selected)); - return; - } - const files = targets.map(status => status.path); - if (!await this.confirmBatch(operation, files.length)) return; - await this.executeBatch('selected', operation, files); - } - - async executeBatch(filter: BatchFilter, operation: SyncOperation, files: string[]): Promise { - const runVerb = operation === 'push' ? t('main.verb.pushing') : t('main.verb.pulling'); - const progress = new Notice(t('main.progress.running', { verb: runVerb, total: files.length }), 0); - this.navigator.closeDiffFor(files); - try { - const results = operation === 'push' - ? await this.workspace.push(files, (current, total, name) => progress.setMessage(t('syncStatus.progress.pushing', { current, total, name }))) - : await this.workspace.pull(files, (current, total, name) => progress.setMessage(t('syncStatus.progress.pulling', { current, total, name }))); - progress.hide(); - if (results.errors.length > 0) logger.error(`${operation} errors:`, results.errors); - if (filter === 'selected') this.state.clearSelection(); - const doneVerb = operation === 'push' ? t('main.verb.push') : t('main.verb.pull'); - new Notice(t('syncStatus.notice.opCompleted', { verb: doneVerb })); - if (operation === 'push') { - this.markSynced((results as PushResults).syncedPaths); - this.render(); - } else { - await this.refresh(); - } - } catch (error) { - progress.hide(); - const verb = operation === 'push' ? t('main.verb.push') : t('main.verb.pull'); - new Notice(t('syncStatus.notice.opFailed', { verb, message: this.errorMessage(error) })); - } - } - - async deletePaths(paths: readonly string[]): Promise { - const targets = [...new Set(paths)] - .map(path => this.statuses.get(path)) - .filter((status): status is FileStatus => status !== undefined); - if (targets.length === 0) { - if (this.state.selectedFiles.size === 0) new Notice(t('syncStatus.notice.noFilesSelected')); - return; - } - const { local, remote } = this.partitionTargets(targets); - if (local.length === 0 && remote.length === 0) { - new Notice(t('syncStatus.notice.nothingToDelete')); - return; - } - if (!await this.confirmDeletion(local, remote)) return; - - const total = local.length + remote.length; - const progress = new Notice(t('syncStatus.progress.deleting', { total }), 0); - const errors: Array<{ path: string; message: string }> = []; - await this.performLocalDeletion(local, total, progress, errors); - await this.performRemoteDeletion(remote, total, local.length, progress, errors); - progress.hide(); - this.notifyDeleteResult(total, errors); - this.render(); - } - - async confirmDeletion(local: FileStatus[], remote: FileStatus[]): Promise { - if (remote.length === 0) return this.confirm(t('syncStatus.confirmDelete.localOnly', { local: local.length })); - const plan: SyncPlan = { - additions: [], - modifications: [], - moves: [], - deletions: remote.map(status => ({ - path: status.path, - name: status.file?.name ?? status.path.split('/').pop() ?? status.path, - })), - }; - const description = local.length > 0 ? t('syncStatus.confirmDelete.alsoLocal', { local: local.length }) : undefined; - return new Promise(resolve => { - new SyncPlanModal(this.app, plan, 'delete', () => resolve(true), () => resolve(false), description).open(); - }); - } - - async performRemoteDeletion( - remote: FileStatus[], - total: number, - localCount: number, - progress: Notice, - errors: Array<{ path: string; message: string }>, - ): Promise { - if (remote.length === 0) return; - const result = await this.workspace.deleteRemote( - remote.map(status => status.path), - (current, path) => progress.setMessage(t('syncStatus.progress.deletingRemote', { - current: localCount + current, - total, - path, - })), - ); - errors.push(...result.errors); - for (const path of result.deletedPaths) { - this.statuses.delete(path); - this.state.deselect(path); - } - } - - private async executeSingle(status: FileStatus, operation: SyncOperation): Promise { - const file = status.file || status.path; - if (operation === 'pull') { - await this.workspace.pullOne(status.path); - await this.statusRefresh.refreshFileStatusByContent(file); - return; - } - const results = await this.workspace.push([status.path]); - const synced = results.syncedPaths.find(path => path.path === status.path); - if (synced) this.markSynced([synced]); - else await this.statusRefresh.refreshFileStatusByContent(file); - } - - private runnableStatuses(operation: SyncOperation, paths?: ReadonlySet): FileStatus[] { - return Array.from(this.statuses.values()).filter(status => { - if (paths && !paths.has(status.path)) return false; - return operation === 'push' - ? ['modified', 'unsynced', 'moved'].includes(status.status) - : ['modified', 'remote-only'].includes(status.status); - }); - } - - private async confirmBatch(operation: SyncOperation, count: number): Promise { - const service = this.workspace.getInfo().serviceName; - const message = operation === 'push' - ? t('syncStatus.confirm.pushSelected', { count, service }) - : t('syncStatus.confirm.pullSelected', { count, service }); - return this.confirm(message); - } - - private markSynced(paths: Array<{ path: string; sha?: string }>): void { - for (const { path, sha } of paths) this.statuses.markSynced(path, sha); - } - - private async moveBack(status: FileStatus): Promise { - const target = status.movedFrom; - if (!target) return; - await this.workspace.moveLocal(status.path, target); - } - - private partitionTargets(targets: FileStatus[]): { local: FileStatus[]; remote: FileStatus[] } { - return { - local: targets.filter(status => status.status !== 'remote-only' && status.status !== 'moved'), - remote: targets.filter(status => status.status === 'remote-only'), - }; - } - - private async performLocalDeletion( - local: FileStatus[], - total: number, - progress: Notice, - errors: Array<{ path: string; message: string }>, - ): Promise { - let current = 0; - for (const status of local) { - current += 1; - progress.setMessage(t('syncStatus.progress.deletingLocal', { current, total, path: status.path })); - try { - await this.workspace.deleteLocal(status.path); - this.statuses.delete(status.path); - this.state.deselect(status.path); - } catch (error) { - errors.push({ path: status.path, message: this.errorMessage(error) }); - } - } - } - - private notifyDeleteResult(total: number, errors: Array<{ path: string; message: string }>): void { - if (errors.length === 0) { - new Notice(t('syncStatus.notice.deleteResult.success', { total })); - return; - } - logger.error('Delete errors:', errors); - new Notice(t('syncStatus.notice.deleteResult.partialWithMessage', { - succeeded: total - errors.length, - total, - failed: errors.length, - message: errors.map(error => error.message).join('; '), - })); - } - - private confirm(message: string): Promise { - return new Promise(resolve => { - new ConfirmModal(this.app, message, () => resolve(true), () => resolve(false)).open(); - }); - } - - private errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); - } -} diff --git a/src/ui/sync-status/SyncStatusRenderer.ts b/src/ui/sync-status/SyncStatusRenderer.ts deleted file mode 100644 index 5624458..0000000 --- a/src/ui/sync-status/SyncStatusRenderer.ts +++ /dev/null @@ -1,373 +0,0 @@ -import { Platform, debounce, setIcon, setTooltip } from 'obsidian'; -import type { SyncWorkspaceInfo } from '../../logic/sync/SyncWorkspace'; -import type { FileStatus, SyncStatusService } from '../../logic/sync-status-service'; -import { t } from '../../i18n'; -import { renderActionBar } from '../components/ActionBar'; -import { - renderFileItem, - renderMoveGroupItem, - statusMeta, - type FileItemCallbacks, - type MoveGroupCallbacks, -} from '../components/FileListItem'; -import { renderFolderItem, type FolderTreeItemCallbacks } from '../components/FolderTreeItem'; -import { buildStatusTree, type StatusTreeNode } from '../components/StatusTree'; -import { ICONS } from '../components/icons'; -import type { FilterValue } from '../types'; -import type { SyncStatusController } from './SyncStatusController'; -import type { SyncStatusViewState } from './SyncStatusViewState'; -import { - collapsibleMoveGroups, - isMoveGroupExpanded, - isTreeFolderExpanded, - moveGroupKey, - pruneSelection, - searchedStatuses, - sortStatuses, - visibleStatuses, -} from './SyncStatusSelectors'; - -type MoveGroups = Map; - -/** Renders sync-status presentation from state and domain DTOs only. */ -export class SyncStatusRenderer { - constructor( - private readonly workspaceInfo: () => SyncWorkspaceInfo, - private readonly state: SyncStatusViewState, - private readonly statuses: SyncStatusService, - private readonly controller: SyncStatusController, - private readonly rerender: () => void, - ) {} - - render(info: HTMLElement, body: HTMLElement): void { - const scrollTop = body.querySelector('.ssv-list')?.scrollTop ?? 0; - info.empty(); - this.renderInfoStrip(info); - body.empty(); - this.renderTabs(body); - this.renderActionBar(body); - const list = body.createDiv({ cls: 'ssv-list' }); - if (this.state.refreshState.isRefreshing) { - this.renderProgress(list); - this.renderCheckedFiles(list); - } else if (this.statuses.size === 0) { - list.createDiv({ cls: 'ssv-empty', text: t('syncStatus.emptyPrompt') }); - } else { - this.renderFileList(list); - } - list.scrollTop = scrollTop; - } - - renderSearchBox(container: HTMLElement): void { - const row = container.createDiv({ cls: 'ssv-search' }); - setIcon(row.createSpan({ cls: 'ssv-search-icon' }), ICONS.search); - const input = row.createEl('input', { - type: 'text', - cls: 'ssv-search-input', - attr: { placeholder: t('syncStatus.search.placeholder'), spellcheck: 'false' }, - }); - const clear = row.createEl('button', { cls: 'ssv-search-clear' }); - setIcon(clear, ICONS.clear); - setTooltip(clear, t('syncStatus.search.clear')); - const apply = (value: string): void => { - const next = value.trim(); - if (next === this.state.searchQuery) return; - this.state.setSearchQuery(next); - this.pruneSelection(); - row.toggleClass('has-query', next.length > 0); - this.rerender(); - }; - const applyDebounced = debounce(apply, 150, false); - input.addEventListener('input', () => applyDebounced(input.value)); - input.addEventListener('keydown', event => { - if (event.key !== 'Escape' || input.value === '') return; - event.preventDefault(); - input.value = ''; - apply(''); - }); - clear.addEventListener('click', () => { - input.value = ''; - apply(''); - input.focus(); - }); - } - - searchedStatuses(): FileStatus[] { - return searchedStatuses(this.state, Array.from(this.statuses.values())); - } - - visibleStatuses(): FileStatus[] { - return visibleStatuses(this.state, Array.from(this.statuses.values())); - } - - sortStatuses(statuses: FileStatus[]): FileStatus[] { - return sortStatuses(statuses); - } - - pruneSelection(): void { - this.state.retainSelected(pruneSelection(this.state.selectedFiles, this.visibleStatuses())); - } - - renderTabs(container: HTMLElement): void { - const all = this.searchedStatuses(); - const counts: Record = { - all: !this.state.treeViewEnabled || this.state.showSyncedInAll ? all.length : all.filter(status => status.status !== 'synced').length, - synced: all.filter(status => status.status === 'synced').length, - modified: all.filter(status => status.status === 'modified').length, - unsynced: all.filter(status => status.status === 'unsynced').length, - 'remote-only': all.filter(status => status.status === 'remote-only').length, - moved: this.movedRowCount(all), - }; - const tabs: Array<{ value: FilterValue; label: string }> = [ - { value: 'all', label: t('syncStatus.tab.all') }, - { value: 'modified', label: t('syncStatus.tab.modified') }, - { value: 'unsynced', label: t('syncStatus.tab.unsynced') }, - { value: 'remote-only', label: t('syncStatus.tab.remote-only') }, - ...(counts.moved > 0 ? [{ value: 'moved' as const, label: t('syncStatus.tab.moved') }] : []), - { value: 'synced', label: t('syncStatus.tab.synced') }, - ]; - if (Platform.isMobile) { - this.renderMobileFilter(container, tabs, counts); - return; - } - const tabsElement = container.createDiv({ cls: 'ssv-tabs' }); - for (const tab of tabs) { - const button = tabsElement.createEl('button', { cls: `ssv-tab${this.state.statusFilter === tab.value ? ' active' : ''}` }); - if (tab.value !== 'all') setIcon(button.createSpan(), statusMeta(tab.value).icon); - button.createSpan({ cls: 'ssv-tab-label', text: ` ${tab.label}` }); - if (tab.value === 'all' || counts[tab.value] > 0) button.createSpan({ cls: 'ssv-tab-count', text: String(counts[tab.value]) }); - setTooltip(button, tab.label); - button.addEventListener('click', () => this.applyFilter(tab.value)); - } - } - - movedRowCount(statuses: FileStatus[]): number { - const groups = this.collapsibleMoveGroups(statuses); - const groupedPaths = new Set(); - for (const group of groups.values()) for (const member of group.members) groupedPaths.add(member.path); - return statuses.filter(status => status.status === 'moved' && !groupedPaths.has(status.path)).length + groups.size; - } - - collapsibleMoveGroups(displayed: FileStatus[]): MoveGroups { - return collapsibleMoveGroups(displayed, Array.from(this.statuses.values())); - } - - private renderProgress(container: HTMLElement): void { - const { current, total } = this.state.refreshState; - const percentage = total > 0 ? Math.round((current / total) * 100) : 0; - const progress = container.createDiv({ cls: 'ssv-progress' }); - progress.createDiv({ - cls: 'ssv-progress-text', - text: total > 0 - ? t('syncStatus.progress.checkingWithCount', { current, total, pct: percentage }) - : t('syncStatus.progress.checking'), - }); - const bar = progress.createDiv({ cls: 'ssv-progress-bar' }); - bar.createDiv({ cls: 'ssv-progress-fill' }).setAttr('style', `width: ${percentage}%`); - } - - private renderCheckedFiles(container: HTMLElement): void { - const checked = this.visibleStatuses().filter(status => status.status !== 'checking'); - if (checked.length === 0) return; - const list = container.createDiv({ cls: 'ssv-list-checked' }); - const callbacks = this.fileCallbacks(); - for (const status of checked) renderFileItem(list, status, this.state.selectedFiles.has(status.path), callbacks); - } - - private renderInfoStrip(container: HTMLElement): void { - const infoModel = this.workspaceInfo(); - const info = container.createDiv({ cls: 'ssv-info' }); - info.createSpan({ cls: 'ssv-info-item', text: infoModel.serviceName }); - if (!Platform.isMobile) { - info.createSpan({ cls: 'ssv-info-sep', text: '·' }); - const branch = info.createSpan({ cls: 'ssv-info-item' }); - setIcon(branch.createSpan({ cls: 'ssv-info-icon' }), ICONS.branch); - branch.createSpan({ text: ` ${infoModel.branch}` }); - } - if (infoModel.vaultFolder) { - info.createSpan({ cls: 'ssv-info-sep', text: '·' }); - const folder = info.createSpan({ cls: 'ssv-info-item' }); - setIcon(folder.createSpan({ cls: 'ssv-info-icon' }), ICONS.folder); - folder.createSpan({ text: ` ${infoModel.vaultFolder}` }); - } - if (this.state.refreshState.lastSyncTime > 0) { - info.createSpan({ cls: 'ssv-info-sep', text: '·' }); - const date = new Date(this.state.refreshState.lastSyncTime); - info.createSpan({ - cls: 'ssv-info-time', - text: Platform.isMobile - ? date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) - : t('syncStatus.lastSync', { time: date.toLocaleTimeString() }), - }); - } - } - - private renderMobileFilter(container: HTMLElement, tabs: Array<{ value: FilterValue; label: string }>, counts: Record): void { - const select = container.createEl('select', { cls: 'ssv-filter-select', attr: { 'aria-label': t('syncStatus.filterByStatus') } }); - for (const tab of tabs) select.createEl('option', { text: `${tab.label} (${counts[tab.value]})`, value: tab.value }); - select.value = this.state.statusFilter; - select.addEventListener('change', () => this.applyFilter(select.value as FilterValue)); - } - - private applyFilter(filter: FilterValue): void { - this.state.setStatusFilter(filter); - this.pruneSelection(); - this.rerender(); - } - - private renderActionBar(container: HTMLElement): void { - const visible = this.visibleStatuses(); - const selected = Array.from(this.state.selectedFiles) - .map(path => this.statuses.get(path)) - .filter((status): status is FileStatus => status !== undefined); - const allSelected = visible.length > 0 && visible.every(status => this.state.selectedFiles.has(status.path)); - renderActionBar(container, { - hasFiles: this.statuses.size > 0, - allSelected, - indeterminate: this.state.selectedFiles.size > 0 && !allSelected, - canPush: selected.filter(status => ['modified', 'unsynced', 'moved'].includes(status.status)).length, - canPull: selected.filter(status => ['modified', 'remote-only'].includes(status.status)).length, - canDelete: selected.filter(status => status.status !== 'moved').length, - treeViewEnabled: this.state.treeViewEnabled, - showSynced: this.state.showSyncedInAll, - }, { - onRefresh: () => void this.controller.refresh(), - onSelectAll: select => { - for (const status of visible) { - if (select) this.state.select(status.path); - else this.state.deselect(status.path); - } - this.rerender(); - }, - onPush: () => void this.controller.push([...this.state.selectedFiles]), - onPull: () => void this.controller.pull([...this.state.selectedFiles]), - onDelete: () => void this.controller.delete([...this.state.selectedFiles]), - onTreeViewChange: enabled => { - this.state.setTreeViewEnabled(enabled); - this.pruneSelection(); - this.rerender(); - }, - onShowSyncedChange: show => { - this.state.setShowSyncedInAll(show); - this.pruneSelection(); - this.rerender(); - }, - }); - } - - private renderFileList(container: HTMLElement): void { - const statuses = this.visibleStatuses(); - if (statuses.length === 0) { - const text = this.state.searchQuery !== '' - ? t('syncStatus.noFilesForSearch', { query: this.state.searchQuery }) - : t('syncStatus.noFilesForFilter', { - filter: this.state.statusFilter === 'all' ? t('syncStatus.tab.all') : statusMeta(this.state.statusFilter).label, - }); - container.createDiv({ cls: 'ssv-empty', text }); - return; - } - if (this.state.treeViewEnabled) this.renderTreeNodes(container, buildStatusTree(statuses).children); - else this.renderFlatList(container, statuses); - } - - private renderFlatList(container: HTMLElement, statuses: FileStatus[]): void { - const groups = this.collapsibleMoveGroups(statuses); - const groupedPaths = new Set(); - for (const group of groups.values()) for (const member of group.members) groupedPaths.add(member.path); - const callbacks = this.fileCallbacks(); - const renderedGroups = new Set(); - for (const status of statuses) { - if (groupedPaths.has(status.path)) this.renderGroupOnce(container, status, groups, renderedGroups); - else renderFileItem(container, status, this.state.selectedFiles.has(status.path), callbacks); - } - } - - private renderTreeNodes(container: HTMLElement, nodes: StatusTreeNode[]): void { - const fileCallbacks = this.fileCallbacks(); - const folderCallbacks = this.folderCallbacks(); - for (const node of nodes) { - if (node.kind === 'file') { - renderFileItem(container, node.status, this.state.selectedFiles.has(node.status.path), fileCallbacks); - continue; - } - const children = renderFolderItem( - container, - node, - this.state.selectedFiles, - isTreeFolderExpanded(this.state.collapsedFolders, node.path), - folderCallbacks, - ); - if (children) this.renderTreeNodes(children, node.children); - } - } - - private renderGroupOnce(container: HTMLElement, status: FileStatus, groups: MoveGroups, rendered: Set): void { - const key = moveGroupKey(status); - if (key === null || rendered.has(key)) return; - rendered.add(key); - const group = groups.get(key); - if (!group) return; - renderMoveGroupItem( - container, - key, - group.oldPrefix, - group.newPrefix, - group.members, - group.members.every(member => this.state.selectedFiles.has(member.path)), - isMoveGroupExpanded(this.state.expandedMoveGroups, key), - this.moveGroupCallbacks(), - ); - } - - fileCallbacks(): FileItemCallbacks { - return { - onSelect: (path, selected) => { - if (selected) this.state.select(path); - else this.state.deselect(path); - this.rerender(); - }, - onPush: status => void this.controller.pushOne(status), - onPull: status => void this.controller.pullOne(status), - onDelete: status => void this.controller.deleteLocal(status), - onExpandDiff: status => this.controller.loadDiff(status.path), - onOpen: (status, newLeaf) => this.controller.openFile(status, newLeaf), - canOpen: status => this.controller.canOpen(status), - onOpenDiffPane: status => void this.controller.openDiff(status.path), - onRevertMove: status => void this.controller.revertMove(status), - }; - } - - private folderCallbacks(): FolderTreeItemCallbacks { - return { - onSelect: (paths, selected) => { - for (const path of paths) { - if (selected) this.state.select(path); - else this.state.deselect(path); - } - this.rerender(); - }, - onToggle: path => { - this.state.toggleCollapsedFolder(path); - this.rerender(); - }, - }; - } - - private moveGroupCallbacks(): MoveGroupCallbacks { - return { - onSelect: (members, selected) => { - for (const member of members) { - if (selected) this.state.select(member.path); - else this.state.deselect(member.path); - } - this.rerender(); - }, - onPush: members => void this.controller.pushMoveGroup(members), - onRevertMove: members => void this.controller.revertMoveGroup(members), - onToggleExpand: key => { - this.state.toggleExpandedMoveGroup(key); - this.rerender(); - }, - }; - } -} diff --git a/src/ui/sync-status/SyncStatusSelectors.ts b/src/ui/sync-status/SyncStatusSelectors.ts deleted file mode 100644 index c7fb865..0000000 --- a/src/ui/sync-status/SyncStatusSelectors.ts +++ /dev/null @@ -1,126 +0,0 @@ -import type { FileStatus, FilterValue } from '../types'; - -export interface SyncStatusSelectionState { - readonly statusFilter: FilterValue; - readonly treeViewEnabled: boolean; - readonly showSyncedInAll: boolean; - readonly searchQuery: string; - readonly selectedFiles: ReadonlySet; -} - -export interface MoveGroup { - oldPrefix: string; - newPrefix: string; - members: FileStatus[]; -} - -export function searchedStatuses( - state: Pick, - statuses: readonly FileStatus[], -): FileStatus[] { - if (state.searchQuery === '') return [...statuses]; - const query = state.searchQuery.toLowerCase(); - return statuses.filter(status => status.path.toLowerCase().includes(query)); -} - -export function visibleStatuses( - state: Pick, - statuses: readonly FileStatus[], -): FileStatus[] { - const searched = searchedStatuses(state, statuses); - if (state.statusFilter !== 'all') { - return searched.filter(status => status.status === state.statusFilter); - } - if (!state.treeViewEnabled) return sortStatuses(searched); - return state.showSyncedInAll - ? searched - : searched.filter(status => status.status !== 'synced'); -} - -/** Keeps completed rows at the end of the legacy flat view. */ -export function sortStatuses(statuses: readonly FileStatus[]): FileStatus[] { - return [...statuses].sort((left, right) => Number(left.status === 'synced') - Number(right.status === 'synced')); -} - -export function selectedVisibleFiles( - state: Pick, - visible: readonly FileStatus[], -): FileStatus[] { - return visible.filter(status => state.selectedFiles.has(status.path)); -} - -export function pruneSelection( - selectedFiles: ReadonlySet, - visible: readonly FileStatus[], -): Set { - const visiblePaths = new Set(visible.map(status => status.path)); - return new Set([...selectedFiles].filter(path => visiblePaths.has(path))); -} - -export function isTreeFolderExpanded(collapsedFolders: ReadonlySet, path: string): boolean { - return !collapsedFolders.has(path); -} - -export function isMoveGroupExpanded(expandedMoveGroups: ReadonlySet, key: string): boolean { - return expandedMoveGroups.has(key); -} - -export function moveGroupPrefixes(status: FileStatus): { oldPrefix: string; newPrefix: string } | null { - if (!status.movedFrom) return null; - const oldSegments = status.movedFrom.split('/'); - const newSegments = status.path.split('/'); - let oldIndex = oldSegments.length - 1; - let newIndex = newSegments.length - 1; - while ( - oldIndex >= 1 - && newIndex >= 1 - && oldSegments[oldIndex] === newSegments[newIndex] - ) { - oldIndex -= 1; - newIndex -= 1; - } - return { - oldPrefix: oldSegments.slice(0, oldIndex + 1).join('/'), - newPrefix: newSegments.slice(0, newIndex + 1).join('/'), - }; -} - -export function moveGroupKey(status: FileStatus): string | null { - const prefixes = moveGroupPrefixes(status); - return prefixes ? JSON.stringify(prefixes) : null; -} - -export function collapsibleMoveGroups( - statuses: readonly FileStatus[], - allStatuses: readonly FileStatus[], -): Map { - const candidates = collectMoveGroups(statuses); - const collapsible = new Map(); - for (const [key, group] of candidates) { - if (group.members.length < 2 || isPartialMove(group.oldPrefix, allStatuses)) continue; - collapsible.set(key, group); - } - return collapsible; -} - -function collectMoveGroups(statuses: readonly FileStatus[]): Map { - const groups = new Map(); - for (const status of statuses) { - if (status.status !== 'moved') continue; - const prefixes = moveGroupPrefixes(status); - if (!prefixes) continue; - const key = JSON.stringify(prefixes); - const existing = groups.get(key); - if (existing) existing.members.push(status); - else groups.set(key, { ...prefixes, members: [status] }); - } - return groups; -} - -function isPartialMove(oldPrefix: string, allStatuses: readonly FileStatus[]): boolean { - const childPrefix = `${oldPrefix}/`; - return allStatuses.some(status => ( - status.status !== 'moved' - && (status.path === oldPrefix || status.path.startsWith(childPrefix)) - )); -} diff --git a/src/ui/sync-status/SyncStatusView.ts b/src/ui/sync-status/SyncStatusView.ts deleted file mode 100644 index df28478..0000000 --- a/src/ui/sync-status/SyncStatusView.ts +++ /dev/null @@ -1,251 +0,0 @@ -import { ItemView, WorkspaceLeaf, TFile, Notice, debounce } from 'obsidian'; -import GitLabFilesPush from '../../main'; -import { logger } from '../../utils/logger'; -import { type FileStatus, type FilterValue } from '../types'; -import type { FileItemCallbacks } from '../components/FileListItem'; -import { t } from '../../i18n'; -import { SyncStatusService } from '../../logic/sync-status-service'; -import { SyncStatusRefreshService } from '../../logic/sync/SyncStatusRefreshService'; -import type { SyncWorkspace } from '../../logic/sync/SyncWorkspace'; -import { SyncStatusViewState } from './SyncStatusViewState'; -import { SyncStatusController } from './SyncStatusController'; -import { SyncStatusNavigator, type SyncStatusOpenTarget } from './SyncStatusNavigator'; -import { SyncStatusOperations } from './SyncStatusOperations'; -import { SyncStatusRenderer } from './SyncStatusRenderer'; -import { createSyncStatusComposition } from './SyncStatusComposition'; -import { - moveGroupKey, - moveGroupPrefixes, -} from './SyncStatusSelectors'; - -export const SYNC_STATUS_VIEW_TYPE = 'sync-status-view'; - -export class SyncStatusView extends ItemView { - private static readonly RENDER_THROTTLE_MS = 150; - plugin: GitLabFilesPush; - private readonly viewState = new SyncStatusViewState(); - private readonly controller: SyncStatusController; - private readonly statusRefresh: SyncStatusRefreshService; - private readonly navigator: SyncStatusNavigator; - private readonly operations: SyncStatusOperations; - private readonly renderer: SyncStatusRenderer; - private readonly workspace: SyncWorkspace; - private unsubscribeStatuses?: () => void; - private readonly detachedStatusService = new SyncStatusService(); - private readonly renderStatusChanges = debounce( - () => this.renderView(), - SyncStatusView.RENDER_THROTTLE_MS, - false, - ); - private infoEl?: HTMLElement; - private bodyEl?: HTMLElement; - - constructor(leaf: WorkspaceLeaf, plugin: GitLabFilesPush, controller?: SyncStatusController) { - super(leaf); - this.plugin = plugin; - const composition = createSyncStatusComposition( - this.app, - this.plugin, - this.viewState, - this.fileStatuses, - { - render: () => this.renderView(), - refresh: () => this.refreshAllStatuses(), - refreshStatuses: () => this.refreshStatuses(), - }, - controller, - ); - this.statusRefresh = composition.statusRefresh; - this.navigator = composition.navigator; - this.operations = composition.operations; - this.controller = composition.controller; - this.renderer = composition.renderer; - this.workspace = composition.workspace; - } - - private get isRefreshing(): boolean { return this.viewState.refreshState.isRefreshing; } - private set isRefreshing(value: boolean) { this.viewState.refreshState.isRefreshing = value; } - private get refreshProgress(): { current: number; total: number } { return this.viewState.refreshState; } - private set refreshProgress(value: { current: number; total: number }) { - this.viewState.updateRefreshProgress(value.current, value.total); - } - private get statusFilter(): FilterValue { return this.viewState.statusFilter; } - private set statusFilter(value: FilterValue) { this.viewState.setStatusFilter(value); } - private get treeViewEnabled(): boolean { return this.viewState.treeViewEnabled; } - private set treeViewEnabled(value: boolean) { this.viewState.setTreeViewEnabled(value); } - private get showSyncedInAll(): boolean { return this.viewState.showSyncedInAll; } - private set showSyncedInAll(value: boolean) { this.viewState.setShowSyncedInAll(value); } - private get searchQuery(): string { return this.viewState.searchQuery; } - private set searchQuery(value: string) { this.viewState.setSearchQuery(value); } - private get selectedFiles(): Set { return this.viewState.selectedFiles; } - private get collapsedFolders(): Set { return this.viewState.collapsedFolders; } - private get expandedMoveGroups(): Set { return this.viewState.expandedMoveGroups; } - private get lastSyncTime(): number { return this.viewState.refreshState.lastSyncTime; } - private set lastSyncTime(value: number) { this.viewState.refreshState.lastSyncTime = value; } - - private get fileStatuses(): SyncStatusService { - return this.plugin.sync?.status ?? this.detachedStatusService; - } - - getViewType(): string { return SYNC_STATUS_VIEW_TYPE; } - getDisplayText(): string { return t('syncStatus.viewTitle'); } - getIcon(): string { return 'git-compare'; } - - onOpen(): Promise { - const container = this.containerEl.children[1] as HTMLElement | null; - if (!container) return Promise.resolve(); - container.empty(); - container.addClass('sync-status-view'); - const header = container.createDiv({ cls: 'ssv-header' }); - this.infoEl = header.createDiv({ cls: 'ssv-info-slot' }); - this.renderer.renderSearchBox(header); - this.bodyEl = container.createDiv({ cls: 'ssv-body' }); - this.unsubscribeStatuses = this.fileStatuses.subscribe(() => this.renderStatusChanges()); - this.renderView(); - return Promise.resolve(); - } - - private renderView(): void { - if (this.infoEl && this.bodyEl) this.renderer.render(this.infoEl, this.bodyEl); - } - - private searchedStatuses(): FileStatus[] { return this.renderer.searchedStatuses(); } - private visibleStatuses(): FileStatus[] { return this.renderer.visibleStatuses(); } - private sortAllStatuses(statuses: FileStatus[]): FileStatus[] { return this.renderer.sortStatuses(statuses); } - private movedRowCount(statuses: FileStatus[]): number { return this.renderer.movedRowCount(statuses); } - private fileItemCallbacks(): FileItemCallbacks { return this.renderer.fileCallbacks(); } - - async refreshAllStatuses(): Promise { await this.controller.refresh(); } - - private async refreshStatuses(): Promise { - if (this.isRefreshing) { - new Notice(t('syncStatus.notice.alreadyRefreshing')); - return; - } - this.viewState.startRefresh(); - this.renderView(); - try { - const result = await this.workspace.refresh(({ current, total }) => { - this.viewState.updateRefreshProgress(current, total); - this.renderStatusChanges(); - }); - this.viewState.finishRefresh(Date.now()); - this.renderView(); - new Notice(t('syncStatus.notice.refreshed', { local: result.localCount, remote: result.remoteCount })); - } catch (error) { - this.viewState.finishRefresh(); - this.renderView(); - new Notice(t('syncStatus.notice.refreshFailed', { message: error instanceof Error ? error.message : String(error) })); - } - } - - private async pushMoveGroup(members: FileStatus[]): Promise { await this.operations.pushMoveGroup(members); } - private async revertMoveGroup(members: FileStatus[]): Promise { await this.operations.revertMoveGroup(members); } - private async handleLocalDelete(status: FileStatus): Promise { await this.operations.deleteLocal(status); } - private async runSingleFile(status: FileStatus, operation: 'push' | 'pull'): Promise { - await this.operations.runSingle(status, operation); - } - - private pruneSelectionToVisible(): void { - this.renderer.pruneSelection(); - } - - - private renderTabs(container: HTMLElement): void { - this.renderer.renderTabs(container); - } - - private async revertMove(fileStatus: FileStatus): Promise { - await this.operations.revertMove(fileStatus); - } - - private async openDiffPane(fileStatus: FileStatus): Promise { - if (!this.fileStatuses.has(fileStatus.path)) this.fileStatuses.set(fileStatus); - await this.navigator.openDiff(fileStatus.path); - } - - private async openDiffPath(path: string): Promise { - if (this.fileStatuses.has(path)) await this.navigator.openDiff(path); - } - - private closeDiffPaneFor(paths: Iterable): void { - this.navigator.closeDiffFor(paths); - } - - private openTargetFor(fileStatus: FileStatus): SyncStatusOpenTarget | null { - return this.navigator.targetFor(fileStatus); - } - - private openFileFromRow(fileStatus: FileStatus, newLeaf: boolean): boolean { - return this.navigator.openFile(fileStatus, newLeaf); - } - - private async loadDiffContent(fileStatus: FileStatus): Promise { - try { - if (!this.fileStatuses.has(fileStatus.path)) this.fileStatuses.set(fileStatus); - await this.navigator.loadDiff(fileStatus.path); - } catch (e) { - logger.warn(`Failed to load diff content for ${fileStatus.path}`, e); - } - } - - private groupKey(fs: FileStatus): string | null { - return moveGroupKey(fs); - } - - private groupPrefixes(fs: FileStatus): { oldPrefix: string; newPrefix: string } | null { - return moveGroupPrefixes(fs); - } - - private collapsibleMoveGroups(statuses: FileStatus[]): Map { - return this.renderer.collapsibleMoveGroups(statuses); - } - - async handleFileModified(file: TFile): Promise { - if (await this.statusRefresh.handleFileModified(file)) this.renderView(); - } - - handleFileRenamed(file: TFile, oldPath: string): void { - if (this.statusRefresh.handleFileRenamed(file, oldPath)) this.renderView(); - } - - async pushAllModified(): Promise { await this.controller.pushAllModified(); } - async pullAllModified(): Promise { await this.controller.pullAllModified(); } - async pushSelected(): Promise { await this.controller.push([...this.selectedFiles]); } - async pullSelected(): Promise { await this.controller.pull([...this.selectedFiles]); } - - private async runBatchOperation(filter: 'modified' | 'selected', op: 'push' | 'pull'): Promise { - await this.operations.runBatch(filter, op); - } - - private async runPathBatchOperation(paths: readonly string[], op: 'push' | 'pull'): Promise { - await this.operations.runPaths(paths, op); - } - - private async executeBatchOperation(filter: 'modified' | 'selected', op: 'push' | 'pull', files: Array): Promise { - await this.operations.executeBatch(filter, op, files.map(file => typeof file === 'string' ? file : file.path)); - } - - async deleteSelected(): Promise { - await this.controller.delete([...this.selectedFiles]); - } - - private async deletePaths(paths: readonly string[]): Promise { - await this.operations.deletePaths(paths); - } - - private async confirmDeletion(local: FileStatus[], remote: FileStatus[]): Promise { - return this.operations.confirmDeletion(local, remote); - } - - private async performRemoteDeletion(remote: FileStatus[], total: number, localCount: number, prog: Notice, errors: { path: string, message: string }[]): Promise { - await this.operations.performRemoteDeletion(remote, total, localCount, prog, errors); - } - - onClose(): Promise { - this.unsubscribeStatuses?.(); - this.unsubscribeStatuses = undefined; - return Promise.resolve(); - } - -} diff --git a/src/ui/sync-status/SyncStatusViewState.ts b/src/ui/sync-status/SyncStatusViewState.ts deleted file mode 100644 index 8afd12d..0000000 --- a/src/ui/sync-status/SyncStatusViewState.ts +++ /dev/null @@ -1,92 +0,0 @@ -import type { FilterValue } from '../types'; - -export interface SyncStatusRefreshState { - isRefreshing: boolean; - current: number; - total: number; - lastSyncTime: number; -} - -/** Mutable presentation state for SyncStatusView. Domain file state lives elsewhere. */ -export class SyncStatusViewState { - statusFilter: FilterValue = 'all'; - treeViewEnabled = true; - showSyncedInAll = false; - searchQuery = ''; - readonly selectedFiles = new Set(); - readonly collapsedFolders = new Set(); - readonly expandedMoveGroups = new Set(); - readonly refreshState: SyncStatusRefreshState = { - isRefreshing: false, - current: 0, - total: 0, - lastSyncTime: 0, - }; - - setStatusFilter(filter: FilterValue): void { - this.statusFilter = filter; - } - - setTreeViewEnabled(enabled: boolean): void { - this.treeViewEnabled = enabled; - } - - setShowSyncedInAll(show: boolean): void { - this.showSyncedInAll = show; - } - - setSearchQuery(query: string): void { - this.searchQuery = query.trim(); - } - - select(path: string): void { - this.selectedFiles.add(path); - } - - deselect(path: string): void { - this.selectedFiles.delete(path); - } - - retainSelected(visiblePaths: ReadonlySet): void { - for (const path of this.selectedFiles) { - if (!visiblePaths.has(path)) this.selectedFiles.delete(path); - } - } - - clearSelection(): void { - this.selectedFiles.clear(); - } - - toggleCollapsedFolder(path: string): void { - this.toggleSetValue(this.collapsedFolders, path); - } - - toggleExpandedMoveGroup(key: string): void { - this.toggleSetValue(this.expandedMoveGroups, key); - } - - startRefresh(): void { - this.refreshState.isRefreshing = true; - this.refreshState.current = 0; - this.refreshState.total = 0; - } - - updateRefreshProgress(current: number, total: number): void { - this.refreshState.current = current; - this.refreshState.total = total; - } - - incrementRefreshProgress(): void { - this.refreshState.current += 1; - } - - finishRefresh(lastSyncTime = this.refreshState.lastSyncTime): void { - this.refreshState.isRefreshing = false; - this.refreshState.lastSyncTime = lastSyncTime; - } - - private toggleSetValue(values: Set, value: string): void { - if (values.has(value)) values.delete(value); - else values.add(value); - } -} diff --git a/styles.css b/styles.css index 147cf1d..b9355e7 100644 --- a/styles.css +++ b/styles.css @@ -1,7 +1,6 @@ -/* ── VaultBridge – Sync Status View ────────────────────────────── */ +/* ── VaultBridge – Source Control View ────────────────────────────── */ -/* Full-height flex column so the list can scroll independently */ -.sync-status-view { +.scv-root { display: flex; flex-direction: column; height: 100%; @@ -10,135 +9,50 @@ container-type: inline-size; } - -/* Header holds the search input, which must never be re-rendered (it would - lose focus mid-typing); the body is what renderView() rebuilds. */ -.ssv-header { +.scv-header { flex-shrink: 0; + padding: 8px 12px; + border-bottom: 1px solid var(--background-modifier-border); +} + +.scv-header-title { + font-weight: 600; + font-size: 0.9em; } -.ssv-body { +.scv-main { display: flex; flex-direction: column; flex: 1; min-height: 0; } -/* ── Search filter ──────────────────────────────────────────────── */ -.ssv-search { - display: flex; - align-items: center; - gap: 6px; - padding: 6px 10px; - border-bottom: 1px solid var(--background-modifier-border); - flex-shrink: 0; +.scv-desktop .scv-main { + flex-direction: row; } -.ssv-search-icon { +.scv-body { display: flex; - align-items: center; - color: var(--text-faint); - flex-shrink: 0; -} - -.ssv-search-icon svg { - width: 14px; - height: 14px; -} - -.ssv-search-input { + flex-direction: column; flex: 1; - min-width: 0; - height: 26px; - padding: 0 6px; - font-size: 0.82em; - background: var(--background-modifier-form-field); - border: 1px solid var(--background-modifier-border); - border-radius: 4px; - color: var(--text-normal); -} - -.ssv-search-clear { - display: none; - align-items: center; - justify-content: center; - padding: 2px; - height: 22px; - width: 22px; - flex-shrink: 0; - background: transparent; - border: none; - box-shadow: none; - color: var(--text-muted); - cursor: pointer; -} - -.ssv-search.has-query .ssv-search-clear { display: flex; } - -.ssv-search-clear:hover { color: var(--text-normal); } - -.ssv-search-clear svg { - width: 14px; - height: 14px; -} - -/* ── Info strip ─────────────────────────────────────────────────── */ -.ssv-info { - display: flex; - align-items: center; - gap: 8px; - padding: 6px 12px; - background: var(--background-secondary); - border-bottom: 1px solid var(--background-modifier-border); - font-size: 0.75em; - color: var(--text-muted); - flex-wrap: wrap; - flex-shrink: 0; -} - -@container (max-width: 400px) { - .ssv-info { gap: 4px; padding: 4px 8px; } - .ssv-info-sep { display: none; } -} - -.ssv-info-item { - display: flex; - align-items: center; - gap: 3px; - font-weight: 500; - color: var(--text-normal); -} - -.ssv-info-sep { color: var(--background-modifier-border); } - -.ssv-info-time { - font-weight: 400; - color: var(--text-muted); + min-height: 0; + overflow-y: auto; } -/* ── Filter tabs ────────────────────────────────────────────────── */ -.ssv-tabs { +/* ── Filter menu ───────────────────────────────────────────────── */ +.scv-filter-menu { display: flex; gap: 4px; padding: 8px 10px; border-bottom: 1px solid var(--background-modifier-border); overflow-x: auto; - -webkit-overflow-scrolling: touch; scrollbar-width: none; flex-shrink: 0; - /* When labels are still shown (container wide enough that the - icon-only fallback below hasn't kicked in) six-plus tabs can still - overflow before wrapping/hiding text. The scrollbar itself is - hidden for a cleaner look, so without this the cut-off edge reads - as "broken" rather than "scroll for more" — a soft fade signals - there's more to scroll to. */ - -webkit-mask-image: linear-gradient(to right, transparent, black 14px, black calc(100% - 14px), transparent); - mask-image: linear-gradient(to right, transparent, black 14px, black calc(100% - 14px), transparent); } -.ssv-tabs::-webkit-scrollbar { display: none; } +.scv-filter-menu::-webkit-scrollbar { display: none; } -.ssv-tab { +.scv-filter-option { display: flex; align-items: center; gap: 5px; @@ -155,23 +69,18 @@ min-height: 28px; } -@container (max-width: 400px) { - .ssv-tab-label { display: none; } - .ssv-tab { padding: 4px 8px; min-width: 32px; justify-content: center; } -} - -.ssv-tab:hover { +.scv-filter-option:hover { background: var(--background-modifier-hover); color: var(--text-normal); } -.ssv-tab.active { +.scv-filter-option.is-active { background: var(--interactive-accent); color: var(--text-on-accent); border-color: var(--interactive-accent); } -.ssv-tab-count { +.scv-filter-count { background: rgba(0, 0, 0, 0.12); border-radius: 10px; padding: 1px 6px; @@ -179,226 +88,105 @@ min-width: 18px; text-align: center; } -.is-mobile .ssv-tabs { - padding: 10px; - gap: 8px; - flex-wrap: wrap; - overflow-x: visible; - /* Mobile wraps to a second row instead of scrolling, so the edge fade - (meant for the horizontal-scroll case) would just clip the wrapped - rows for no reason — turn it off. */ - -webkit-mask-image: none; - mask-image: none; -} -.is-mobile .ssv-tab { - padding: 6px 14px; - font-size: 0.9em; - min-height: 36px; - border-radius: 10px; - flex: 1 0 auto; - justify-content: center; -} - -.ssv-tab.active .ssv-tab-count { +.scv-filter-option.is-active .scv-filter-count { background: rgba(255, 255, 255, 0.22); } -.ssv-filter-select { display: none; } - -.is-mobile .ssv-filter-select { - display: block; - width: calc(100% - 20px); - min-height: 36px; - margin: 10px; -} - -/* ── Action bar ─────────────────────────────────────────────────── */ -.ssv-action-bar { - display: flex; - flex-direction: column; - gap: 6px; - padding: 6px 10px; - border-bottom: 1px solid var(--background-modifier-border); - flex-shrink: 0; - background: var(--background-primary); -} - -.ssv-action-bar-row { +/* ── Push button ───────────────────────────────────────────────── */ +.scv-push-btn { display: flex; align-items: center; - gap: 4px; - width: 100%; -} - -.ssv-tree-options { - display: flex; - align-items: center; - gap: 14px; - width: 100%; - padding-left: 4px; -} - -.ssv-tree-option { - display: inline-flex; - align-items: center; - gap: 6px; - color: var(--text-muted); - font-size: 0.80em; - cursor: pointer; -} - -.ssv-tree-option input { margin: 0; cursor: pointer; } - -@container (max-width: 450px) { - .ssv-btn-label { display: none; } - .ssv-btn { padding: 5px 8px; min-width: 34px; justify-content: center; } - .ssv-select-label { display: none; } - .ssv-bar-spacer { display: none; } -} - -/* ── Mobile specific overrides ─────────────────────────────────── */ - -.is-mobile .ssv-btn { - padding: 8px 14px; - font-size: 0.9em; - min-height: 38px; - flex: 1 0 auto; justify-content: center; -} - -.is-mobile .ssv-action-bar { - gap: 8px; - padding: 10px; -} - -.is-mobile .ssv-action-bar-row { flex-wrap: wrap; gap: 8px; } - -.is-mobile .ssv-bar-spacer { - display: none; -} - - -.ssv-bar-spacer { flex: 1; } - -.ssv-btn { - display: flex; - align-items: center; - gap: 4px; - padding: 6px 11px; + gap: 6px; + width: calc(100% - 20px); + margin: 8px 10px; + padding: 7px 11px; border-radius: 5px; - font-size: 0.82em; + font-size: 0.85em; font-weight: 500; cursor: pointer; - white-space: nowrap; border: 1px solid transparent; - min-height: 30px; - transition: opacity 0.12s, background 0.12s; -} - -.ssv-btn:disabled { - opacity: 0.38; - cursor: not-allowed; -} - -.ssv-btn:not(:disabled):hover { opacity: 0.82; } - -.ssv-btn-refresh { - background: var(--background-secondary); - border-color: var(--background-modifier-border); - color: var(--text-normal); -} - -.ssv-btn-push { background: var(--color-green); color: white; + min-height: 32px; + flex-shrink: 0; + transition: opacity 0.12s; } -.ssv-btn-pull { - background: var(--color-blue); - color: white; +.scv-push-btn:disabled { + opacity: 0.38; + cursor: not-allowed; } -.ssv-btn-delete { - background: var(--color-red); - color: white; -} +.scv-push-btn:not(:disabled):hover { opacity: 0.85; } -/* Indeterminate "select all" checkbox row */ -.ssv-select-row { +/* ── Change sections & tree ───────────────────────────────────────── */ +.scv-section-header { display: flex; align-items: center; gap: 6px; - font-size: 0.80em; - color: var(--text-muted); + padding: 6px 12px 6px 8px; cursor: pointer; - min-height: 30px; - padding: 0 4px; + color: var(--text-muted); + font-size: 0.78em; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.02em; } -.ssv-select-row input[type="checkbox"] { - width: 15px; - height: 15px; +.scv-section-header:hover { background: var(--background-modifier-hover); } + +.scv-section-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + padding: 0; + border: 0; + background: transparent; + color: var(--text-muted); cursor: pointer; } -/* ── Scrollable file list ───────────────────────────────────────── */ -.ssv-list { - flex: 1; - overflow-y: auto; - overflow-x: hidden; -} +.scv-section-title { flex: 1; } -/* ── Tree folders ──────────────────────────────────────────────── */ -.ssv-tree-children { - margin-left: 16px; - border-left: 1px solid var(--background-modifier-border); +.scv-section-count { + background: var(--background-modifier-border); + border-radius: 10px; + padding: 1px 6px; + font-size: 0.9em; + min-width: 18px; + text-align: center; } -.ssv-tree-folder-row { +.scv-tree-folder-row { display: flex; align-items: center; gap: 7px; - min-height: 34px; - padding: 6px 12px 6px 5px; - border-bottom: 1px solid var(--background-modifier-border-hover); + min-height: 32px; + padding: 4px 12px 4px 8px; color: var(--text-normal); + cursor: pointer; } -.ssv-tree-folder-row:hover { background: var(--background-modifier-hover); } +.scv-tree-folder-row:hover { background: var(--background-modifier-hover); } -.ssv-folder-toggle { +.scv-tree-folder-toggle { display: inline-flex; align-items: center; justify-content: center; - width: 20px; - height: 20px; + width: 18px; + height: 18px; padding: 0; border: 0; background: transparent; color: var(--text-muted); cursor: pointer; - font-family: var(--font-monospace); - font-size: 18px; - line-height: 1; } -.ssv-folder-checkbox { - width: 16px; - height: 16px; - margin: 0; - flex-shrink: 0; - cursor: pointer; -} - -.ssv-tree-folder-icon { - display: flex; - color: var(--text-warning); -} - -.ssv-tree-folder-icon .svg-icon { width: 16px; height: 16px; } - -.ssv-tree-folder-name { +.scv-tree-folder-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -406,297 +194,161 @@ font-weight: 600; } -/* ── Empty / loading states ─────────────────────────────────────── */ -.ssv-empty { - padding: 36px 20px; - text-align: center; - color: var(--text-muted); - font-size: 0.88em; -} - -/* ── Progress bar ───────────────────────────────────────────────── */ -.ssv-progress { - padding: 24px 16px; - text-align: center; -} - -.ssv-progress-text { - font-size: 0.83em; - color: var(--text-muted); - margin-bottom: 10px; -} - -.ssv-progress-bar { - width: 100%; - height: 4px; - background: var(--background-secondary); - border-radius: 2px; - overflow: hidden; -} - -.ssv-progress-fill { - height: 100%; - background: var(--interactive-accent); - transition: width 0.2s ease; - border-radius: 2px; -} - -/* ── File item ──────────────────────────────────────────────────── */ -.ssv-file { - padding: 9px 12px; - border-bottom: 1px solid var(--background-modifier-border-hover); - border-left: 3px solid transparent; - transition: background 0.1s; +.scv-tree-children { + margin-left: 13px; + border-left: 1px solid var(--background-modifier-border); } -.ssv-file:hover { background: var(--background-modifier-hover); } - -.ssv-file.status-synced { border-left-color: var(--color-green); } -.ssv-file.status-modified { border-left-color: var(--text-warning); } -.ssv-file.status-unsynced { border-left-color: var(--color-red); } -.ssv-file.status-remote { border-left-color: var(--color-blue); } -.ssv-file.status-moved { border-left-color: var(--color-purple); } -.ssv-file.status-checking { border-left-color: var(--background-modifier-border); } - -.ssv-file-row { +/* ── Change item row ───────────────────────────────────────────── */ +.scv-change-item { display: flex; align-items: center; gap: 7px; min-height: 30px; + padding: 5px 12px 5px 8px; + cursor: pointer; + border-left: 3px solid transparent; + transition: background 0.1s; } -.ssv-file-checkbox { - width: 16px; - height: 16px; +.scv-change-item:hover { background: var(--background-modifier-hover); } + +.scv-change-item.scv-kind-local-only { border-left-color: var(--color-green); } +.scv-change-item.scv-kind-local-modified { border-left-color: var(--text-warning); } +.scv-change-item.scv-kind-remote-only { border-left-color: var(--color-blue); } +.scv-change-item.scv-kind-remote-modified { border-left-color: var(--color-blue); } +.scv-change-item.scv-kind-moved { border-left-color: var(--color-purple); } +.scv-change-item.scv-kind-conflict { border-left-color: var(--color-red); } +.scv-change-item.scv-kind-synced { border-left-color: var(--background-modifier-border); } + +.scv-change-select { + width: 15px; + height: 15px; flex-shrink: 0; cursor: pointer; } -.ssv-file-icon { - display: flex; +.scv-badge { + display: inline-flex; align-items: center; justify-content: center; - width: 18px; + width: 16px; + height: 16px; flex-shrink: 0; + font-size: 0.68em; + font-weight: 700; + border-radius: 3px; + border: 1px solid currentColor; + opacity: 0.85; } -.ssv-icon-synced { color: var(--color-green); } -.ssv-icon-modified { color: var(--text-warning); } -.ssv-icon-unsynced { color: var(--color-red); } -.ssv-icon-remote { color: var(--color-blue); } -.ssv-icon-moved { color: var(--color-purple); } -.ssv-icon-checking { color: var(--text-muted); } +.scv-badge-local-only, +.scv-badge-remote-only { color: var(--color-green); } +.scv-badge-local-modified, +.scv-badge-remote-modified { color: var(--text-warning); } +.scv-badge-moved { color: var(--color-purple); } +.scv-badge-conflict { color: var(--color-red); } +.scv-badge-synced { color: var(--text-muted); } -.ssv-file-path { +.scv-change-name { flex: 1; + min-width: 0; + display: flex; + align-items: center; + gap: 5px; font-family: var(--font-monospace); font-size: 0.80em; color: var(--text-normal); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - min-width: 0; -} - -/* Only the path is clickable, so it has to read as a link — the rest of the - row keeps its existing behaviour. Declared after .ssv-file-path so the - accent colour wins at equal specificity. */ -.ssv-file-path-link { - cursor: pointer; - color: var(--text-accent); -} - -.ssv-file-path-link:hover { - text-decoration: underline; } -.ssv-status-badge { - font-size: 0.70em; - font-weight: 600; - padding: 2px 8px; - border-radius: 10px; - white-space: nowrap; - flex-shrink: 0; - border: 1px solid currentColor; - opacity: 0.85; -} - -.ssv-badge-synced { color: var(--color-green); } -.ssv-badge-modified { color: var(--text-warning); } -.ssv-badge-unsynced { color: var(--color-red); } -.ssv-badge-remote { color: var(--color-blue); } -.ssv-badge-moved { color: var(--color-purple); } -.ssv-badge-checking { color: var(--text-muted); } - -/* Old path of a pending move, shown struck-through beneath the new one. */ -.ssv-moved-from { - margin-left: 25px; - font-family: var(--font-monospace); - font-size: 0.72em; - color: var(--text-faint); - text-decoration: line-through; +.scv-change-name-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -/* ── Collapsed folder-move row ──────────────────────────────────── */ -.ssv-move-group-children { - margin-top: 6px; - margin-left: 41px; /* aligns under path, same as .ssv-file-actions */ - border-left: 2px solid var(--background-modifier-border); - padding-left: 10px; -} - -.ssv-move-group-child { - font-family: var(--font-monospace); - font-size: 0.74em; - color: var(--text-muted); - padding: 2px 0; +.scv-change-rename-from { + color: var(--text-faint); + text-decoration: line-through; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + flex-shrink: 1; + min-width: 0; } -/* ── Per-file action row ────────────────────────────────────────── */ -.ssv-file-actions { - display: flex; - gap: 5px; - margin-top: 7px; - padding-left: 41px; /* aligns under path */ - flex-wrap: wrap; -} - -.ssv-action-btn { +.scv-change-rename-arrow { display: inline-flex; align-items: center; - gap: 4px; - padding: 4px 10px; - font-size: 0.76em; - border-radius: 4px; - cursor: pointer; - border: 1px solid var(--background-modifier-border); - background: transparent; - color: var(--text-normal); - white-space: nowrap; - min-height: 26px; - transition: background 0.1s, opacity 0.1s; -} - -/* Consistent sizing for all Lucide icons used in the view */ -.ssv-file-icon .svg-icon { - width: 16px; - height: 16px; + color: var(--text-faint); + flex-shrink: 0; } -.ssv-btn .svg-icon, -.ssv-tab .svg-icon, -.ssv-action-btn .svg-icon { - width: 15px; - height: 15px; -} +.scv-change-rename-arrow .svg-icon { width: 12px; height: 12px; } -.ssv-info-icon { +/* ── Operation indicator ──────────────────────────────────────────── */ +.scv-op-indicator { display: inline-flex; align-items: center; + flex-shrink: 0; } -.ssv-info-icon .svg-icon { - width: 13px; - height: 13px; -} - -.is-mobile .ssv-action-btn { - padding: 8px 15px; - font-size: 0.85em; - min-height: 40px; -} - - - -.ssv-action-btn:hover { - background: var(--background-modifier-hover); -} - -.ssv-action-btn.push { - border-color: var(--color-green); - color: var(--color-green); -} - -.ssv-action-btn.pull { - border-color: var(--color-blue); - color: var(--color-blue); -} +.scv-op-indicator .svg-icon { width: 14px; height: 14px; } -.ssv-action-btn.danger { - border-color: var(--color-red); - color: var(--color-red); -} +.scv-op-running { color: var(--text-muted); } +.scv-op-success { color: var(--color-green); } +.scv-op-failed { color: var(--color-red); } -.ssv-action-btn.diff { - border-color: var(--background-modifier-border); +/* ── Empty state ───────────────────────────────────────────────── */ +.scv-empty { + padding: 36px 20px; + text-align: center; color: var(--text-muted); + font-size: 0.88em; } -/* ── Diff panel ─────────────────────────────────────────────────── */ -.ssv-diff { - display: none; - margin-top: 6px; - padding-left: 41px; +/* ── Inline diff pane (desktop side-by-side) ──────────────────────── */ +.scv-diff { + flex: 1; + min-width: 0; + border-left: 1px solid var(--background-modifier-border); + overflow-y: auto; + padding: 10px 12px; container-type: inline-size; } -.ssv-diff.visible { display: block; } +.scv-diff-empty { + padding: 36px 20px; + text-align: center; + color: var(--text-muted); + font-size: 0.88em; +} -/* Diff shown in its own workspace pane (desktop). It carries its own - container-type so the split/unified container query below resolves against - the pane's width — which is the entire point of moving it out of the - sidebar. */ -.sync-diff-view { - padding: 10px 14px; - overflow: hidden; +/* ── Mobile detail view (list/detail with back button) ────────────── */ +.scv-detail { display: flex; flex-direction: column; height: 100%; } -.ssv-diff-pane { - container-type: inline-size; -} - -/* Desktop pane: let the diff fill the tab's full height instead of the - sidebar's capped max-height (mobile keeps the cap — see below). */ -.sync-diff-view .ssv-diff-pane-path { +.scv-detail-back { flex-shrink: 0; + margin: 8px 10px; + padding: 6px 12px; + align-self: flex-start; + border-radius: 5px; + border: 1px solid var(--background-modifier-border); + background: var(--background-secondary); + color: var(--text-normal); + cursor: pointer; + font-size: 0.85em; } -.sync-diff-view .ssv-diff-pane { - flex: 1; - display: flex; - flex-direction: column; - min-height: 0; -} - -.sync-diff-view .ssv-diff-split { - flex: 1; - display: flex; - flex-direction: column; - min-height: 0; -} - -.sync-diff-view .ssv-diff-grid, -.sync-diff-view .ssv-diff-unified { +.scv-detail-diff { flex: 1; - max-height: none; - min-height: 0; -} - -.ssv-diff-pane-path { - font-family: var(--font-monospace); - font-size: 0.82em; - color: var(--text-muted); - margin-bottom: 8px; - overflow-wrap: anywhere; + overflow-y: auto; + padding: 0 12px 12px; + container-type: inline-size; } /* ── Side-by-side diff (default for wide panels) ──────────────────── */ @@ -812,35 +464,10 @@ /* ── Mobile adjustments ─────────────────────────────────────────── */ @container (max-width: 480px) { - .ssv-info { padding: 5px 10px; gap: 8px; } - - .ssv-tabs { padding: 6px 8px; gap: 3px; } - .ssv-tab { padding: 4px 8px; font-size: 0.76em; } - .ssv-tab-label { display: none; } - - .ssv-action-bar { padding: 6px 8px; gap: 5px; } - .ssv-btn { padding: 5px 9px; font-size: 0.76em; } - .ssv-btn-label { display: none; } - - .ssv-file { padding: 8px 10px; } - .ssv-file-path { font-size: 0.76em; } - .ssv-status-badge { display: none; } - - .ssv-file-actions { padding-left: 0; margin-top: 8px; } - .ssv-action-btn { flex: 1 1 auto; text-align: center; min-height: 32px; } - .ssv-action-btn .ssv-btn-label { display: none; } - - .ssv-diff { padding-left: 0; } -} - -/* Mobile label overrides — placed after container queries so source order wins */ -.is-mobile .ssv-btn-label, -.is-mobile .ssv-tab-label { - display: inline; -} - -.is-mobile .ssv-action-btn .ssv-btn-label { - display: inline; + .scv-filter-menu { padding: 6px 8px; gap: 3px; } + .scv-filter-option { padding: 4px 8px; font-size: 0.76em; } + .scv-change-item { padding: 6px 10px; } + .scv-change-name { font-size: 0.76em; } } /* ── Settings connection status badge ──────────────────────────── */ diff --git a/tests/logic/source-control/FileStatusAdapter.test.ts b/tests/logic/source-control/FileStatusAdapter.test.ts new file mode 100644 index 0000000..4cf5062 --- /dev/null +++ b/tests/logic/source-control/FileStatusAdapter.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { toSyncChanges } from '../../../src/logic/source-control/FileStatusAdapter'; +import { toChangeId } from '../../../src/logic/source-control/types'; +import type { FileStatus } from '../../../src/logic/sync-status-service'; + +describe('toSyncChanges', () => { + it('maps each FileStatus kind to its SyncChangeKind', () => { + const statuses: FileStatus[] = [ + { path: 'synced.md', status: 'synced' }, + { path: 'modified.md', status: 'modified' }, + { path: 'unsynced.md', status: 'unsynced' }, + { path: 'remote.md', status: 'remote-only' }, + { path: 'new.md', status: 'moved', movedFrom: 'old.md' }, + ]; + + expect(toSyncChanges(statuses)).toEqual([ + { id: toChangeId('synced.md'), path: 'synced.md', previousPath: undefined, kind: 'synced' }, + { id: toChangeId('modified.md'), path: 'modified.md', previousPath: undefined, kind: 'local-modified' }, + { id: toChangeId('unsynced.md'), path: 'unsynced.md', previousPath: undefined, kind: 'local-only' }, + { id: toChangeId('remote.md'), path: 'remote.md', previousPath: undefined, kind: 'remote-only' }, + { id: toChangeId('new.md'), path: 'new.md', previousPath: 'old.md', kind: 'moved' }, + ]); + }); + + it('omits rows still in the "checking" state', () => { + const statuses: FileStatus[] = [ + { path: 'pending.md', status: 'checking' }, + { path: 'settled.md', status: 'synced' }, + ]; + + expect(toSyncChanges(statuses)).toEqual([ + { id: toChangeId('settled.md'), path: 'settled.md', previousPath: undefined, kind: 'synced' }, + ]); + }); + + it('returns an empty array for an empty input', () => { + expect(toSyncChanges([])).toEqual([]); + }); +}); diff --git a/tests/main.test.ts b/tests/main.test.ts index 6947ba4..aa7e14a 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -16,10 +16,11 @@ describe('GitLabFilesPush.trackFolderRename', () => { Object.assign(new TFile(), { path: 'Elsewhere/c.md' }), ]; const trackRename = vi.fn().mockResolvedValue(undefined); + const handleFileRenamed = vi.fn(); const fakePlugin = { app: { vault: { getFiles: () => files } }, sync: { trackRename }, - notifySyncStatusViews: vi.fn(), + syncStatusRefresh: { handleFileRenamed }, }; const folder = Object.assign(new TFolder(), { path: 'Archive/Projects' }); @@ -33,7 +34,7 @@ describe('GitLabFilesPush.trackFolderRename', () => { expect(trackRename).toHaveBeenCalledWith('Archive/Projects/sub/b.md', 'Notes/Projects/sub/b.md'); // The sync panel is notified per file too, so a folder drag updates it // live instead of leaving every affected row stale until a manual refresh. - expect(fakePlugin.notifySyncStatusViews).toHaveBeenCalledTimes(2); + expect(handleFileRenamed).toHaveBeenCalledTimes(2); }); it('does nothing when no files live under the moved folder', async () => { @@ -41,7 +42,7 @@ describe('GitLabFilesPush.trackFolderRename', () => { const fakePlugin = { app: { vault: { getFiles: () => [] } }, sync: { trackRename }, - notifySyncStatusViews: vi.fn(), + syncStatusRefresh: { handleFileRenamed: vi.fn() }, }; const folder = Object.assign(new TFolder(), { path: 'Empty' }); diff --git a/tests/ui/ActionBar.test.ts b/tests/ui/ActionBar.test.ts deleted file mode 100644 index 28d1d62..0000000 --- a/tests/ui/ActionBar.test.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest'; -import { renderActionBar, type ActionBarProps, type ActionBarCallbacks } from '../../src/ui/components/ActionBar'; -import { setupObsidianDOM, createContainer } from './setup-dom'; - -beforeAll(() => { setupObsidianDOM(); }); - -const baseProps = (overrides?: Partial): ActionBarProps => ({ - hasFiles: true, allSelected: false, indeterminate: false, - canPush: 1, canPull: 1, canDelete: 1, treeViewEnabled: true, showSynced: false, - ...overrides, -}); - -describe('renderActionBar', () => { - let container: HTMLElement; - let callbacks: ActionBarCallbacks; - - beforeEach(() => { - container = createContainer(); - callbacks = { - onRefresh: vi.fn(), - onSelectAll: vi.fn(), - onPush: vi.fn(), - onPull: vi.fn(), - onDelete: vi.fn(), - onTreeViewChange: vi.fn(), - onShowSyncedChange: vi.fn(), - }; - }); - - describe('refresh button', () => { - it('always renders when hasFiles is false', () => { - renderActionBar(container, baseProps({ hasFiles: false }), callbacks); - expect(container.querySelector('.ssv-btn-refresh')).not.toBeNull(); - }); - - it('calls onRefresh when clicked', () => { - renderActionBar(container, baseProps({ hasFiles: false }), callbacks); - (container.querySelector('.ssv-btn-refresh') as HTMLButtonElement).click(); - expect(callbacks.onRefresh).toHaveBeenCalledOnce(); - }); - }); - - describe('when hasFiles is false', () => { - it('does not render push / pull / delete buttons', () => { - renderActionBar(container, baseProps({ hasFiles: false }), callbacks); - expect(container.querySelector('.ssv-btn-push')).toBeNull(); - expect(container.querySelector('.ssv-btn-pull')).toBeNull(); - expect(container.querySelector('.ssv-btn-danger')).toBeNull(); - }); - - it('does not render select-all row', () => { - renderActionBar(container, baseProps({ hasFiles: false }), callbacks); - expect(container.querySelector('.ssv-select-row')).toBeNull(); - }); - }); - - describe('when hasFiles is true', () => { - it('renders tree options below the action row', () => { - renderActionBar(container, baseProps(), callbacks); - - expect(container.querySelector('.ssv-tree-options')).not.toBeNull(); - expect(container.querySelector('.ssv-tree-options .ssv-tree-view-toggle')).not.toBeNull(); - expect(container.querySelector('.ssv-tree-options .ssv-show-synced-toggle')).not.toBeNull(); - }); - - it('only shows the synced control while tree view is enabled', () => { - renderActionBar(container, baseProps({ treeViewEnabled: false }), callbacks); - - expect(container.querySelector('.ssv-show-synced-toggle')).toBeNull(); - }); - - it('reports tree and synced toggle changes', () => { - renderActionBar(container, baseProps(), callbacks); - const treeView = container.querySelector('.ssv-tree-view-toggle')!; - const showSynced = container.querySelector('.ssv-show-synced-toggle')!; - - treeView.checked = false; - treeView.dispatchEvent(new Event('change')); - showSynced.checked = true; - showSynced.dispatchEvent(new Event('change')); - - expect(callbacks.onTreeViewChange).toHaveBeenCalledWith(false); - expect(callbacks.onShowSyncedChange).toHaveBeenCalledWith(true); - }); - - it('renders push, pull, and delete buttons', () => { - renderActionBar(container, baseProps(), callbacks); - expect(container.querySelector('.ssv-btn-push')).not.toBeNull(); - expect(container.querySelector('.ssv-btn-pull')).not.toBeNull(); - expect(container.querySelector('.ssv-btn-danger')).not.toBeNull(); - }); - - it('renders select-all checkbox', () => { - renderActionBar(container, baseProps(), callbacks); - expect(container.querySelector('.ssv-select-row input[type="checkbox"]')).not.toBeNull(); - }); - - it('calls onPush when push button clicked', () => { - renderActionBar(container, baseProps(), callbacks); - (container.querySelector('.ssv-btn-push') as HTMLButtonElement).click(); - expect(callbacks.onPush).toHaveBeenCalledOnce(); - }); - - it('calls onPull when pull button clicked', () => { - renderActionBar(container, baseProps(), callbacks); - (container.querySelector('.ssv-btn-pull') as HTMLButtonElement).click(); - expect(callbacks.onPull).toHaveBeenCalledOnce(); - }); - - it('calls onDelete when delete button clicked', () => { - renderActionBar(container, baseProps(), callbacks); - (container.querySelector('.ssv-btn-danger') as HTMLButtonElement).click(); - expect(callbacks.onDelete).toHaveBeenCalledOnce(); - }); - - it('push button is disabled when canPush is 0', () => { - renderActionBar(container, baseProps({ canPush: 0 }), callbacks); - expect((container.querySelector('.ssv-btn-push') as HTMLButtonElement).disabled).toBe(true); - }); - - it('pull button is disabled when canPull is 0', () => { - renderActionBar(container, baseProps({ canPull: 0 }), callbacks); - expect((container.querySelector('.ssv-btn-pull') as HTMLButtonElement).disabled).toBe(true); - }); - - it('delete button is disabled when canDelete is 0', () => { - renderActionBar(container, baseProps({ canDelete: 0 }), callbacks); - expect((container.querySelector('.ssv-btn-danger') as HTMLButtonElement).disabled).toBe(true); - }); - - it('push button is enabled when canPush > 0', () => { - renderActionBar(container, baseProps({ canPush: 3 }), callbacks); - expect((container.querySelector('.ssv-btn-push') as HTMLButtonElement).disabled).toBe(false); - }); - - it('select-all checkbox reflects allSelected prop', () => { - renderActionBar(container, baseProps({ allSelected: true }), callbacks); - const cb = container.querySelector('.ssv-select-row input') as HTMLInputElement; - expect(cb.checked).toBe(true); - }); - - it('calls onSelectAll(true) when checkbox is checked', () => { - renderActionBar(container, baseProps(), callbacks); - const cb = container.querySelector('.ssv-select-row input') as HTMLInputElement; - cb.checked = true; - cb.dispatchEvent(new Event('change')); - expect(callbacks.onSelectAll).toHaveBeenCalledWith(true); - }); - - it('calls onSelectAll(false) when checkbox is unchecked', () => { - renderActionBar(container, baseProps({ allSelected: true }), callbacks); - const cb = container.querySelector('.ssv-select-row input') as HTMLInputElement; - cb.checked = false; - cb.dispatchEvent(new Event('change')); - expect(callbacks.onSelectAll).toHaveBeenCalledWith(false); - }); - }); -}); diff --git a/tests/ui/DiffView.test.ts b/tests/ui/DiffView.test.ts deleted file mode 100644 index fa126b9..0000000 --- a/tests/ui/DiffView.test.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { describe, it, expect, vi, beforeAll } from 'vitest'; -import { DiffView, SYNC_DIFF_VIEW_TYPE } from '../../src/ui/DiffView'; -import { SyncStatusView } from '../../src/ui/SyncStatusView'; -import { WorkspaceLeaf } from 'obsidian'; -import type GitLabFilesPush from '../../src/main'; -import { setupObsidianDOM } from './setup-dom'; -import type { FileStatus } from '../../src/ui/types'; - -function makeDiffView(): DiffView { - const leaf = { setViewState: vi.fn().mockResolvedValue(undefined) } as unknown as WorkspaceLeaf; - return new DiffView(leaf); -} - -function body(view: DiffView): HTMLElement { - return view.containerEl.children[1] as HTMLElement; -} - -describe('DiffView', () => { - beforeAll(() => { setupObsidianDOM(); }); - - it('shows an empty state before any file is set', async () => { - const view = makeDiffView(); - await view.onOpen(); - - expect(body(view).querySelector('.ssv-empty')).not.toBeNull(); - expect(view.getPath()).toBeNull(); - }); - - it('renders the side-by-side grid for a text diff', async () => { - const view = makeDiffView(); - await view.onOpen(); - view.setDiff({ path: 'notes/todo.md', kind: 'text', remoteContent: 'a', localContent: 'b' }); - - expect(view.getPath()).toBe('notes/todo.md'); - expect(body(view).querySelector('.ssv-diff-grid')).not.toBeNull(); - }); - - // The pane carries its own container-type so the split/unified container - // query resolves against the pane's width rather than the sidebar's. - it('wraps the diff in its own query container', async () => { - const view = makeDiffView(); - await view.onOpen(); - view.setDiff({ path: 'a.md', kind: 'text', remoteContent: 'a', localContent: 'b' }); - - expect(body(view).querySelector('.ssv-diff-pane')).not.toBeNull(); - }); - - it('shows a symlink message instead of a text diff', async () => { - const view = makeDiffView(); - await view.onOpen(); - view.setDiff({ path: 'link', kind: 'symlink' }); - - expect(body(view).querySelector('.ssv-diff-binary')?.textContent).toBe('Symlink target changed'); - }); - - it('titles the tab with the file it is showing', async () => { - const view = makeDiffView(); - await view.onOpen(); - expect(view.getDisplayText()).toBe('Diff'); - - view.setDiff({ path: 'notes/todo.md', kind: 'text', remoteContent: 'a', localContent: 'b' }); - expect(view.getDisplayText()).toBe('Diff: notes/todo.md'); - }); - - it('replaces the previous file rather than appending to it', async () => { - const view = makeDiffView(); - await view.onOpen(); - view.setDiff({ path: 'a.md', kind: 'text', remoteContent: 'a', localContent: 'b' }); - view.setDiff({ path: 'b.md', kind: 'text', remoteContent: 'c', localContent: 'd' }); - - expect(view.getPath()).toBe('b.md'); - expect(body(view).querySelectorAll('.ssv-diff-pane')).toHaveLength(1); - }); -}); - -describe('SyncStatusView diff pane', () => { - beforeAll(() => { setupObsidianDOM(); }); - - function makeView(openPanes: DiffView[] = []) { - const leaves = openPanes.map(v => ({ view: v, detach: vi.fn() })); - const getLeavesOfType = vi.fn().mockImplementation((type: string) => - type === SYNC_DIFF_VIEW_TYPE ? leaves : []); - const newLeaf = { setViewState: vi.fn().mockResolvedValue(undefined), view: makeDiffView() }; - const getLeaf = vi.fn().mockReturnValue(newLeaf); - const revealLeaf = vi.fn().mockResolvedValue(undefined); - - const plugin = { - settings: { branch: 'main', vaultFolder: '', rootPath: '' }, - gitService: {}, - getNormalizedPath: (p: string) => p, - } as unknown as GitLabFilesPush; - - const leaf = { - app: { - workspace: { getLeavesOfType, getLeaf, revealLeaf }, - vault: { getFileByPath: vi.fn().mockReturnValue(null), adapter: { exists: vi.fn() } }, - }, - } as unknown as WorkspaceLeaf; - - return { view: new SyncStatusView(leaf, plugin), leaves, getLeaf, newLeaf, revealLeaf }; - } - - type Internals = { - openDiffPane(fs: FileStatus): Promise; - closeDiffPaneFor(paths: Iterable): void; - }; - const internals = (v: SyncStatusView): Internals => v as unknown as Internals; - - const modified = (path: string): FileStatus => - ({ path, status: 'modified', remoteContent: 'a', localContent: 'b' }); - - it('opens a new tab when no pane exists yet', async () => { - const { view, getLeaf, newLeaf } = makeView(); - - await internals(view).openDiffPane(modified('a.md')); - - expect(getLeaf).toHaveBeenCalledWith('tab'); - expect(newLeaf.setViewState).toHaveBeenCalledWith({ type: SYNC_DIFF_VIEW_TYPE, active: true }); - }); - - // Reuse is what keeps the pane wherever the user dragged it, and stops a - // pane piling up per file. - it('reuses the existing pane instead of opening another', async () => { - const existing = makeDiffView(); - const { view, getLeaf } = makeView([existing]); - - await internals(view).openDiffPane(modified('b.md')); - - expect(getLeaf).not.toHaveBeenCalled(); - expect(existing.getPath()).toBe('b.md'); - }); - - it('loads a moved file\'s old remote path for comparison', async () => { - const { view } = makeView(); - const getBlob = vi.fn().mockResolvedValue({ content: 'before move' }); - view.plugin.gitService.getBlob = getBlob; - const moved = { path: 'new.md', status: 'moved' as const, movedFrom: 'old.md', remoteSha: 'old-sha', localContent: 'after move' }; - - await internals(view).openDiffPane(moved); - - expect(getBlob).toHaveBeenCalledWith('old-sha', 'old.md'); - }); - - it('closes the pane when the file it shows is pushed', async () => { - const existing = makeDiffView(); - await existing.onOpen(); - existing.setDiff({ ...modified('a.md'), kind: 'text' }); - const { view, leaves } = makeView([existing]); - - internals(view).closeDiffPaneFor(['a.md']); - - expect(leaves[0]?.detach).toHaveBeenCalled(); - }); - - it('leaves a pane showing an unrelated file alone', async () => { - const existing = makeDiffView(); - await existing.onOpen(); - existing.setDiff({ ...modified('a.md'), kind: 'text' }); - const { view, leaves } = makeView([existing]); - - internals(view).closeDiffPaneFor(['other.md']); - - expect(leaves[0]?.detach).not.toHaveBeenCalled(); - }); -}); diff --git a/tests/ui/FileListItem.test.ts b/tests/ui/FileListItem.test.ts deleted file mode 100644 index cef2c4e..0000000 --- a/tests/ui/FileListItem.test.ts +++ /dev/null @@ -1,281 +0,0 @@ -import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from 'vitest'; -import { renderFileItem, statusMeta, type FileItemCallbacks } from '../../src/ui/components/FileListItem'; -import type { FileStatus } from '../../src/ui/types'; -import { TFile, Platform } from 'obsidian'; -import { setupObsidianDOM, createContainer } from './setup-dom'; - -beforeAll(() => { setupObsidianDOM(); }); - -const mockFile = Object.assign(new TFile(), { path: 'docs/test.md' }); - -function makeFileStatus(status: FileStatus['status'], overrides?: Partial): FileStatus { - return { path: 'docs/test.md', status, ...overrides }; -} - -describe('statusMeta', () => { - it.each([ - ['synced', 'check', 'Synced', 'status-synced'], - ['modified', 'pencil', 'Changed', 'status-modified'], - ['unsynced', 'arrow-up', 'Local only', 'status-unsynced'], - ['remote-only', 'arrow-down', 'Remote', 'status-remote'], - ['checking', 'refresh-cw', 'Checking', 'status-checking'], - ] as const)('%s: returns correct icon, label, and fileCls', (status, icon, label, fileCls) => { - const meta = statusMeta(status); - expect(meta.icon).toBe(icon); - expect(meta.label).toBe(label); - expect(meta.fileCls).toBe(fileCls); - }); - - it('returns distinct CSS classes for each status', () => { - const statuses = ['synced', 'modified', 'unsynced', 'remote-only', 'checking'] as const; - const badgeCls = statuses.map(s => statusMeta(s).badgeCls); - expect(new Set(badgeCls).size).toBe(statuses.length); - }); -}); - -describe('renderFileItem', () => { - let container: HTMLElement; - let callbacks: FileItemCallbacks; - - beforeEach(() => { - container = createContainer(); - callbacks = { - onSelect: vi.fn(), - onPush: vi.fn(), - onPull: vi.fn(), - onDelete: vi.fn(), - onExpandDiff: vi.fn().mockResolvedValue(undefined), - onOpen: vi.fn().mockReturnValue(true), - canOpen: vi.fn().mockReturnValue(true), - onOpenDiffPane: vi.fn(), - onRevertMove: vi.fn(), - }; - }); - - it('renders file path', () => { - renderFileItem(container, makeFileStatus('synced'), false, callbacks); - expect(container.querySelector('.ssv-file-path')?.textContent).toBe('docs/test.md'); - }); - - it('renders status badge with correct label', () => { - renderFileItem(container, makeFileStatus('modified'), false, callbacks); - expect(container.querySelector('.ssv-status-badge')?.textContent).toBe('Changed'); - }); - - it('checkbox reflects isSelected=true', () => { - renderFileItem(container, makeFileStatus('synced'), true, callbacks); - expect((container.querySelector('.ssv-file-checkbox') as HTMLInputElement).checked).toBe(true); - }); - - it('checkbox reflects isSelected=false', () => { - renderFileItem(container, makeFileStatus('synced'), false, callbacks); - expect((container.querySelector('.ssv-file-checkbox') as HTMLInputElement).checked).toBe(false); - }); - - it('calls onSelect(path, true) when checkbox checked', () => { - renderFileItem(container, makeFileStatus('synced'), false, callbacks); - const cb = container.querySelector('.ssv-file-checkbox') as HTMLInputElement; - cb.checked = true; - cb.dispatchEvent(new Event('change')); - expect(callbacks.onSelect).toHaveBeenCalledWith('docs/test.md', true); - }); - - it('calls onSelect(path, false) when checkbox unchecked', () => { - renderFileItem(container, makeFileStatus('synced'), true, callbacks); - const cb = container.querySelector('.ssv-file-checkbox') as HTMLInputElement; - cb.checked = false; - cb.dispatchEvent(new Event('change')); - expect(callbacks.onSelect).toHaveBeenCalledWith('docs/test.md', false); - }); - - describe('synced file', () => { - it('renders no action buttons', () => { - renderFileItem(container, makeFileStatus('synced'), false, callbacks); - expect(container.querySelector('.ssv-file-actions')).toBeNull(); - }); - }); - - describe('checking file', () => { - it('renders no action buttons', () => { - renderFileItem(container, makeFileStatus('checking'), false, callbacks); - expect(container.querySelector('.ssv-file-actions')).toBeNull(); - }); - }); - - describe('modified file', () => { - it('renders push and pull buttons when file exists', () => { - const fs = makeFileStatus('modified', { file: mockFile }); - renderFileItem(container, fs, false, callbacks); - expect(container.querySelector('.ssv-action-btn.push')).not.toBeNull(); - expect(container.querySelector('.ssv-action-btn.pull')).not.toBeNull(); - }); - - it('calls onPush with fileStatus when push clicked', () => { - const fs = makeFileStatus('modified', { file: mockFile }); - renderFileItem(container, fs, false, callbacks); - (container.querySelector('.ssv-action-btn.push') as HTMLButtonElement).click(); - expect(callbacks.onPush).toHaveBeenCalledWith(fs); - }); - - it('calls onPull with fileStatus when pull clicked', () => { - const fs = makeFileStatus('modified', { file: mockFile }); - renderFileItem(container, fs, false, callbacks); - (container.querySelector('.ssv-action-btn.pull') as HTMLButtonElement).click(); - expect(callbacks.onPull).toHaveBeenCalledWith(fs); - }); - - it('renders a diff button for any modified file, even without preloaded content', () => { - const fs = makeFileStatus('modified', { file: mockFile }); - renderFileItem(container, fs, false, callbacks); - expect(container.querySelector('.ssv-action-btn.diff')).not.toBeNull(); - }); - }); - - describe('moved file with content changes', () => { - it('renders a diff button so the moved file can be compared with its old remote path', () => { - const fs = makeFileStatus('moved', { - file: mockFile, - movedFrom: 'docs/old-name.md', - remoteSha: 'old-content-sha', - }); - - renderFileItem(container, fs, false, callbacks); - - expect(container.querySelector('.ssv-action-btn.diff')).not.toBeNull(); - }); - }); - - // The inline panel is stuck at sidebar width, so desktop sends the diff to - // its own pane instead and never renders the inline one. - describe('modified file: desktop diff pane', () => { - it('asks for a diff pane instead of expanding inline', () => { - const fs = makeFileStatus('modified', { localContent: 'b', remoteContent: 'a' }); - renderFileItem(container, fs, false, callbacks); - (container.querySelector('.ssv-action-btn.diff') as HTMLButtonElement).click(); - expect(callbacks.onOpenDiffPane).toHaveBeenCalledWith(fs); - }); - - it('renders no inline diff panel', () => { - const fs = makeFileStatus('modified', { localContent: 'b', remoteContent: 'a' }); - renderFileItem(container, fs, false, callbacks); - expect(container.querySelector('.ssv-diff')).toBeNull(); - }); - }); - - describe('modified file: mobile inline diff', () => { - beforeEach(() => { Platform.isMobile = true; }); - afterEach(() => { Platform.isMobile = false; }); - - it('does not ask for a diff pane', () => { - const fs = makeFileStatus('modified', { localContent: 'b', remoteContent: 'a' }); - renderFileItem(container, fs, false, callbacks); - (container.querySelector('.ssv-action-btn.diff') as HTMLButtonElement).click(); - expect(callbacks.onOpenDiffPane).not.toHaveBeenCalled(); - }); - - it('diff panel is not visible before toggle', () => { - const fs = makeFileStatus('modified', { localContent: 'b', remoteContent: 'a' }); - renderFileItem(container, fs, false, callbacks); - expect(container.querySelector('.ssv-diff')?.classList.contains('visible')).toBe(false); - }); - - it('diff panel becomes visible on first click', () => { - const fs = makeFileStatus('modified', { localContent: 'b', remoteContent: 'a' }); - renderFileItem(container, fs, false, callbacks); - (container.querySelector('.ssv-action-btn.diff') as HTMLButtonElement).click(); - expect(container.querySelector('.ssv-diff')?.classList.contains('visible')).toBe(true); - }); - - it('diff panel hides on second click', () => { - const fs = makeFileStatus('modified', { localContent: 'b', remoteContent: 'a' }); - renderFileItem(container, fs, false, callbacks); - const btn = container.querySelector('.ssv-action-btn.diff') as HTMLButtonElement; - btn.click(); - btn.click(); - expect(container.querySelector('.ssv-diff')?.classList.contains('visible')).toBe(false); - }); - - it('diff button label toggles between " Diff" and " Hide"', () => { - const fs = makeFileStatus('modified', { localContent: 'b', remoteContent: 'a' }); - renderFileItem(container, fs, false, callbacks); - const btn = container.querySelector('.ssv-action-btn.diff') as HTMLButtonElement; - const label = btn.querySelector('.ssv-btn-label') as HTMLElement; - expect(label.textContent).toBe(' Diff'); - btn.click(); - expect(label.textContent).toBe(' Hide'); - btn.click(); - expect(label.textContent).toBe(' Diff'); - }); - - it('renders preloaded diff content immediately without fetching', () => { - const fs = makeFileStatus('modified', { localContent: 'b', remoteContent: 'a' }); - renderFileItem(container, fs, false, callbacks); - (container.querySelector('.ssv-action-btn.diff') as HTMLButtonElement).click(); - expect(container.querySelector('.ssv-diff-grid')).not.toBeNull(); - expect(callbacks.onExpandDiff).not.toHaveBeenCalled(); - }); - - it('shows a loading placeholder and fetches content on demand when not preloaded', () => { - const fs = makeFileStatus('modified', { file: mockFile, remoteSha: 'abc123' }); - renderFileItem(container, fs, false, callbacks); - (container.querySelector('.ssv-action-btn.diff') as HTMLButtonElement).click(); - expect(callbacks.onExpandDiff).toHaveBeenCalledWith(fs); - }); - - it('shows a symlink message instead of a text diff for symlink entries', async () => { - const fs = makeFileStatus('modified', { file: mockFile, remoteSha: 'abc123', isSymlink: true }); - renderFileItem(container, fs, false, callbacks); - (container.querySelector('.ssv-action-btn.diff') as HTMLButtonElement).click(); - expect(container.querySelector('.ssv-diff-binary')?.textContent).toBe('Symlink target changed'); - expect(callbacks.onExpandDiff).not.toHaveBeenCalled(); - }); - }); - - describe('unsynced file', () => { - it('renders push button when file exists', () => { - const fs = makeFileStatus('unsynced', { file: mockFile }); - renderFileItem(container, fs, false, callbacks); - expect(container.querySelector('.ssv-action-btn.push')).not.toBeNull(); - }); - - it('renders delete button when file exists', () => { - const fs = makeFileStatus('unsynced', { file: mockFile }); - renderFileItem(container, fs, false, callbacks); - expect(container.querySelector('.ssv-action-btn.danger')).not.toBeNull(); - }); - - it('does not render pull button', () => { - const fs = makeFileStatus('unsynced', { file: mockFile }); - renderFileItem(container, fs, false, callbacks); - expect(container.querySelector('.ssv-action-btn.pull')).toBeNull(); - }); - - it('calls onDelete with fileStatus when delete clicked', () => { - const fs = makeFileStatus('unsynced', { file: mockFile }); - renderFileItem(container, fs, false, callbacks); - (container.querySelector('.ssv-action-btn.danger') as HTMLButtonElement).click(); - expect(callbacks.onDelete).toHaveBeenCalledWith(fs); - }); - }); - - describe('remote-only file', () => { - it('renders pull button', () => { - const fs = makeFileStatus('remote-only'); - renderFileItem(container, fs, false, callbacks); - expect(container.querySelector('.ssv-action-btn.pull')).not.toBeNull(); - }); - - it('does not render push button', () => { - const fs = makeFileStatus('remote-only'); - renderFileItem(container, fs, false, callbacks); - expect(container.querySelector('.ssv-action-btn.push')).toBeNull(); - }); - - it('calls onPull with fileStatus when pull clicked', () => { - const fs = makeFileStatus('remote-only'); - renderFileItem(container, fs, false, callbacks); - (container.querySelector('.ssv-action-btn.pull') as HTMLButtonElement).click(); - expect(callbacks.onPull).toHaveBeenCalledWith(fs); - }); - }); -}); diff --git a/tests/ui/FolderTreeItem.test.ts b/tests/ui/FolderTreeItem.test.ts deleted file mode 100644 index 8486dfc..0000000 --- a/tests/ui/FolderTreeItem.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; -import { renderFolderItem, type FolderTreeItemCallbacks } from '../../src/ui/components/FolderTreeItem'; -import type { StatusTreeFolder } from '../../src/ui/components/StatusTree'; -import { createContainer, setupObsidianDOM } from './setup-dom'; - -const folder: StatusTreeFolder = { - kind: 'folder', - name: 'Projects', - path: 'Projects', - children: [ - { kind: 'file', name: 'one.md', status: { path: 'Projects/one.md', status: 'modified' } }, - { kind: 'file', name: 'two.md', status: { path: 'Projects/two.md', status: 'synced' } }, - ], -}; - -describe('renderFolderItem', () => { - beforeAll(() => { setupObsidianDOM(); }); - - let container: HTMLElement; - let callbacks: FolderTreeItemCallbacks & { - onSelect: ReturnType void>>; - onToggle: ReturnType void>>; - }; - - beforeEach(() => { - container = createContainer(); - callbacks = { onSelect: vi.fn<(paths: string[], selected: boolean) => void>(), onToggle: vi.fn<(path: string) => void>() }; - }); - - it('renders a folder checkbox as indeterminate for a partial selection', () => { - renderFolderItem(container, folder, new Set(['Projects/one.md']), true, callbacks); - - const checkbox = container.querySelector('.ssv-folder-checkbox')!; - expect(checkbox.checked).toBe(false); - expect(checkbox.indeterminate).toBe(true); - }); - - it('selects every descendant file from its checkbox', () => { - renderFolderItem(container, folder, new Set(), true, callbacks); - - const checkbox = container.querySelector('.ssv-folder-checkbox')!; - checkbox.checked = true; - checkbox.dispatchEvent(new Event('change')); - - expect(callbacks.onSelect).toHaveBeenCalledWith(['Projects/one.md', 'Projects/two.md'], true); - }); - - it('toggles its children from the disclosure button', () => { - renderFolderItem(container, folder, new Set(), true, callbacks); - - (container.querySelector('.ssv-folder-toggle') as HTMLButtonElement).click(); - - expect(callbacks.onToggle).toHaveBeenCalledWith('Projects'); - }); - - it('uses a plain minus sign for an expanded folder', () => { - renderFolderItem(container, folder, new Set(), true, callbacks); - - expect(container.querySelector('.ssv-folder-toggle')?.textContent).toBe('−'); - expect(container.querySelector('.ssv-folder-toggle svg')).toBeNull(); - }); -}); diff --git a/tests/ui/StatusTree.test.ts b/tests/ui/StatusTree.test.ts deleted file mode 100644 index a8ee9fd..0000000 --- a/tests/ui/StatusTree.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { buildStatusTree, type StatusTreeFolder } from '../../src/ui/components/StatusTree'; -import type { FileStatus } from '../../src/ui/types'; - -function folderNames(folder: StatusTreeFolder): string[] { - return folder.children.map(child => child.name); -} - -const statuses: FileStatus[] = [ - { path: 'Archive/readme.md', status: 'synced' }, - { path: 'Projects/notes/done.md', status: 'synced' }, - { path: 'Projects/notes/today.md', status: 'modified' }, - { path: 'Projects/inbox.md', status: 'unsynced' }, - { path: 'zebra.md', status: 'synced' }, -]; - -describe('buildStatusTree', () => { - it('creates nested folders from file paths', () => { - const root = buildStatusTree(statuses); - const projects = root.children.find(child => child.kind === 'folder' && child.name === 'Projects'); - - expect(projects).toMatchObject({ kind: 'folder', path: 'Projects' }); - expect(folderNames(projects as StatusTreeFolder)).toEqual(['inbox.md', 'notes']); - }); - - it('keeps folders together while putting attention items before synced items', () => { - const root = buildStatusTree(statuses); - const projects = root.children.find(child => child.kind === 'folder' && child.name === 'Projects') as StatusTreeFolder; - const notes = projects.children.find(child => child.kind === 'folder' && child.name === 'notes') as StatusTreeFolder; - - expect(folderNames(root)).toEqual(['Projects', 'Archive', 'zebra.md']); - expect(folderNames(projects)).toEqual(['inbox.md', 'notes']); - expect(folderNames(notes)).toEqual(['today.md', 'done.md']); - }); - - it('omits synced files when the caller does not supply them', () => { - const root = buildStatusTree(statuses.filter(status => status.status !== 'synced')); - - expect(folderNames(root)).toEqual(['Projects']); - }); -}); diff --git a/tests/ui/SyncStatusView.openFile.test.ts b/tests/ui/SyncStatusView.openFile.test.ts deleted file mode 100644 index e4c305f..0000000 --- a/tests/ui/SyncStatusView.openFile.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from 'vitest'; -import { SyncStatusView } from '../../src/ui/SyncStatusView'; -import { WorkspaceLeaf, TFile } from 'obsidian'; -import type GitLabFilesPush from '../../src/main'; -import { setupObsidianDOM } from './setup-dom'; -import type { FileStatus } from '../../src/ui/types'; -import type { GitLabFilesPushSettings } from '../../src/settings'; -import { renderFileItem, type FileItemCallbacks } from '../../src/ui/components/FileListItem'; - -function makeSettings(overrides: Partial = {}): GitLabFilesPushSettings { - return { - serviceType: 'github', - gitlabToken: '', gitlabBaseUrl: 'https://gitlab.com', projectId: '', - githubToken: '', githubOwner: 'firstsun-dev', githubRepo: 'git-files-sync', - giteaToken: '', giteaBaseUrl: '', giteaOwner: '', giteaRepo: '', - branch: 'main', syncMetadata: {}, rootPath: '', vaultFolder: '', - symlinkHandling: 'follow', ignorePatterns: '', - lastSeenVersion: '', bannerDismissedVersion: '', language: 'en', - ...overrides, - } as GitLabFilesPushSettings; -} - -function makeView(settings = makeSettings(), getFileByPath = vi.fn().mockReturnValue(null)) { - const openFile = vi.fn().mockResolvedValue(undefined); - const getLeaf = vi.fn().mockReturnValue({ openFile }); - - const plugin = { - settings, - gitService: {}, - getNormalizedPath(path: string): string { - const folder = settings.vaultFolder; - if (!folder) return path; - const prefix = `${folder}/`; - return path.startsWith(prefix) ? path.substring(prefix.length) : path; - }, - } as unknown as GitLabFilesPush; - - const leaf = { - app: { - workspace: { getLeaf }, - vault: { getFileByPath, adapter: { exists: vi.fn().mockResolvedValue(false) } }, - }, - } as unknown as WorkspaceLeaf; - - return { view: new SyncStatusView(leaf, plugin), getLeaf, openFile, getFileByPath }; -} - -type Internals = { - openTargetFor(fs: FileStatus): { kind: 'local' | 'remote' } | null; - openFileFromRow(fs: FileStatus, newLeaf: boolean): boolean; - fileItemCallbacks(): FileItemCallbacks; -}; -const internals = (view: SyncStatusView): Internals => view as unknown as Internals; - -describe('SyncStatusView path open target', () => { - beforeAll(() => { setupObsidianDOM(); }); - - let windowOpen: ReturnType; - beforeEach(() => { - windowOpen = vi.fn(); - (globalThis as unknown as { window: { open: unknown } }).window.open = windowOpen; - }); - afterEach(() => { vi.restoreAllMocks(); }); - - it('opens a local file in the vault', () => { - const file = new TFile(); - const { view, getLeaf, openFile } = makeView(); - const fs: FileStatus = { path: 'notes/todo.md', status: 'modified', file }; - - expect(internals(view).openFileFromRow(fs, false)).toBe(true); - expect(getLeaf).toHaveBeenCalledWith(false); - expect(openFile).toHaveBeenCalledWith(file); - expect(windowOpen).not.toHaveBeenCalled(); - }); - - it('honours a modifier by requesting a new leaf', () => { - const { view, getLeaf } = makeView(); - const fs: FileStatus = { path: 'notes/todo.md', status: 'modified', file: new TFile() }; - - internals(view).openFileFromRow(fs, true); - - expect(getLeaf).toHaveBeenCalledWith(true); - }); - - it('falls back to the vault index when the status carries no TFile', () => { - const file = new TFile(); - const { view, openFile } = makeView(makeSettings(), vi.fn().mockReturnValue(file)); - - expect(internals(view).openFileFromRow({ path: 'notes/todo.md', status: 'unsynced' }, false)).toBe(true); - expect(openFile).toHaveBeenCalledWith(file); - }); - - it('opens a remote-only file on the provider instead of the vault', () => { - const { view, getLeaf } = makeView(); - - expect(internals(view).openFileFromRow({ path: 'notes/todo.md', status: 'remote-only' }, false)).toBe(true); - expect(windowOpen).toHaveBeenCalledWith( - 'https://github.com/firstsun-dev/git-files-sync/blob/main/notes/todo.md', '_blank'); - expect(getLeaf).not.toHaveBeenCalled(); - }); - - it('strips the vaultFolder prefix before building the remote URL', () => { - const { view } = makeView(makeSettings({ vaultFolder: '02_Areas/blog' })); - - internals(view).openFileFromRow({ path: '02_Areas/blog/notes/todo.md', status: 'remote-only' }, false); - - expect(windowOpen).toHaveBeenCalledWith( - 'https://github.com/firstsun-dev/git-files-sync/blob/main/notes/todo.md', '_blank'); - }); - - // A local-only file isn't on the remote, so there is nothing to fall back - // to — it must not silently open a URL that 404s. - it('reports no target for a local path Obsidian cannot open', () => { - const { view } = makeView(); - const fs: FileStatus = { path: '.hidden/data.json', status: 'unsynced' }; - - expect(internals(view).openTargetFor(fs)).toBeNull(); - expect(internals(view).openFileFromRow(fs, false)).toBe(false); - expect(windowOpen).not.toHaveBeenCalled(); - }); - - it('reports no target when the provider settings yield no web URL', () => { - const { view } = makeView(makeSettings({ serviceType: 'gitlab', projectId: '12345678' })); - - expect(internals(view).openTargetFor({ path: 'a.md', status: 'remote-only' })).toBeNull(); - }); -}); - -describe('file row path rendering', () => { - beforeAll(() => { setupObsidianDOM(); }); - - function renderRow(view: SyncStatusView, fs: FileStatus): HTMLElement { - const container = document.createElement('div'); - renderFileItem(container, fs, false, internals(view).fileItemCallbacks()); - return container; - } - - it('renders an openable path as a link', () => { - const { view } = makeView(); - const container = renderRow(view, { path: 'notes/todo.md', status: 'modified', file: new TFile() }); - - expect(container.querySelector('.ssv-file-path-link')).not.toBeNull(); - }); - - it('renders a path with no target as plain text', () => { - const { view } = makeView(); - const container = renderRow(view, { path: '.hidden/data.json', status: 'unsynced' }); - - expect(container.querySelector('.ssv-file-path')).not.toBeNull(); - expect(container.querySelector('.ssv-file-path-link')).toBeNull(); - }); -}); diff --git a/tests/ui/SyncStatusView.search.test.ts b/tests/ui/SyncStatusView.search.test.ts deleted file mode 100644 index 2fb48ab..0000000 --- a/tests/ui/SyncStatusView.search.test.ts +++ /dev/null @@ -1,335 +0,0 @@ -import { describe, it, expect, vi, beforeAll } from 'vitest'; -import { SyncStatusView } from '../../src/ui/SyncStatusView'; -import { Platform, WorkspaceLeaf } from 'obsidian'; -import type GitLabFilesPush from '../../src/main'; -import { setupObsidianDOM } from './setup-dom'; -import type { FileStatus, FilterValue } from '../../src/ui/types'; - -function makeView(statuses: FileStatus[]): SyncStatusView { - const plugin = { - settings: { branch: 'main', vaultFolder: '', rootPath: '' }, - gitService: {}, - getNormalizedPath: (p: string) => p, - } as unknown as GitLabFilesPush; - const leaf = { - app: { - workspace: { getLeaf: vi.fn().mockReturnValue({ openFile: vi.fn() }) }, - vault: { - getFileByPath: vi.fn().mockReturnValue(null), - adapter: { exists: vi.fn().mockResolvedValue(false) }, - }, - }, - } as unknown as WorkspaceLeaf; - - const view = new SyncStatusView(leaf, plugin); - const map = (view as unknown as { fileStatuses: Map }).fileStatuses; - for (const s of statuses) map.set(s.path, s); - return view; -} - -type Internals = { - searchQuery: string; - statusFilter: FilterValue; - treeViewEnabled: boolean; - showSyncedInAll: boolean; - selectedFiles: Set; - searchedStatuses(): FileStatus[]; - visibleStatuses(): FileStatus[]; - renderTabs(container: HTMLElement): void; -}; - -const internals = (view: SyncStatusView): Internals => view as unknown as Internals; - -const SAMPLE: FileStatus[] = [ - { path: 'Notes/Projects/alpha.md', status: 'modified' }, - { path: 'Notes/Projects/beta.md', status: 'unsynced' }, - { path: 'Notes/daily.md', status: 'synced' }, - { path: 'Archive/PROJECT-old.md', status: 'remote-only' }, - { path: 'readme.md', status: 'synced' }, -]; - -describe('SyncStatusView search filter', () => { - beforeAll(() => { setupObsidianDOM(); }); - - it('returns everything when the query is empty', () => { - const view = makeView(SAMPLE); - expect(internals(view).searchedStatuses()).toHaveLength(SAMPLE.length); - }); - - it('hides synced files from All until requested', () => { - const view = makeView(SAMPLE); - - expect(internals(view).visibleStatuses().map(s => s.path)).toEqual([ - 'Notes/Projects/alpha.md', - 'Notes/Projects/beta.md', - 'Archive/PROJECT-old.md', - ]); - }); - - it('includes synced files in All when the show-synced checkbox is enabled', () => { - const view = makeView(SAMPLE); - internals(view).showSyncedInAll = true; - - expect(internals(view).visibleStatuses()).toHaveLength(SAMPLE.length); - }); - - it('restores the flat All view with synced files when tree view is disabled', () => { - const view = makeView(SAMPLE); - internals(view).treeViewEnabled = false; - - expect(internals(view).visibleStatuses()).toHaveLength(SAMPLE.length); - }); - - it('renders the Synced tab last', () => { - const view = makeView(SAMPLE); - const tabs = document.createElement('div'); - - internals(view).renderTabs(tabs); - - expect(Array.from(tabs.querySelectorAll('.ssv-tab-label')).map(el => el.textContent?.trim())).toEqual([ - 'All', 'Changed', 'Local only', 'Remote', 'Synced', - ]); - }); - - it('uses a status dropdown on mobile while keeping desktop tabs', () => { - const view = makeView(SAMPLE); - const filter = document.createElement('div'); - Platform.isMobile = true; - - internals(view).renderTabs(filter); - - const select = filter.querySelector('.ssv-filter-select'); - expect(select).toBeTruthy(); - expect(filter.querySelector('.ssv-tabs')).toBeNull(); - expect(Array.from(select!.options).map(option => option.text)).toEqual([ - 'All (3)', 'Changed (1)', 'Local only (1)', 'Remote (1)', 'Synced (2)', - ]); - - Platform.isMobile = false; - }); - - it('matches a case-insensitive substring of the path', () => { - const view = makeView(SAMPLE); - internals(view).searchQuery = 'project'; - - expect(internals(view).searchedStatuses().map(s => s.path)).toEqual([ - 'Notes/Projects/alpha.md', - 'Notes/Projects/beta.md', - 'Archive/PROJECT-old.md', - ]); - }); - - // Matching the full path rather than the basename is what makes a folder - // prefix usable as a folder filter. - it('treats a folder prefix as a folder filter', () => { - const view = makeView(SAMPLE); - internals(view).searchQuery = 'Notes/Projects/'; - - expect(internals(view).searchedStatuses().map(s => s.path)).toEqual([ - 'Notes/Projects/alpha.md', - 'Notes/Projects/beta.md', - ]); - }); - - it('does not match on a subsequence the way fuzzy matching would', () => { - const view = makeView(SAMPLE); - internals(view).searchQuery = 'npa'; - - expect(internals(view).searchedStatuses()).toEqual([]); - }); - - it('applies the search and the status tab together', () => { - const view = makeView(SAMPLE); - internals(view).searchQuery = 'project'; - internals(view).statusFilter = 'unsynced'; - - expect(internals(view).visibleStatuses().map(s => s.path)).toEqual(['Notes/Projects/beta.md']); - }); - - it('narrows visible rows to the search even on the all tab', () => { - const view = makeView(SAMPLE); - internals(view).statusFilter = 'all'; - internals(view).showSyncedInAll = true; - internals(view).searchQuery = 'readme'; - - expect(internals(view).visibleStatuses().map(s => s.path)).toEqual(['readme.md']); - }); -}); - -describe('SyncStatusView search box wiring', () => { - beforeAll(() => { setupObsidianDOM(); }); - - async function openWithSearch(statuses: FileStatus[]): Promise<{ - view: SyncStatusView; - input: HTMLInputElement; - root: HTMLElement; - }> { - const view = makeView(statuses); - await view.onOpen(); - const root = view.containerEl.children[1] as HTMLElement; - const input = root.querySelector('.ssv-search-input') as HTMLInputElement; - return { view, input, root }; - } - - function type(input: HTMLInputElement, value: string): void { - input.value = value; - input.dispatchEvent(new Event('input')); - } - - it('keeps the focused search input alive across a re-render', async () => { - const { view, input, root } = await openWithSearch(SAMPLE); - expect(input).toBeTruthy(); - - document.body.appendChild(view.containerEl); - input.focus(); - expect(document.activeElement).toBe(input); - - // renderView() rebuilds the body on every interaction; the input lives - // in the header precisely so it is not destroyed and does not lose - // focus after a single character. - (view as unknown as { renderView(): void }).renderView(); - - expect(root.querySelector('.ssv-search-input')).toBe(input); - expect(document.activeElement).toBe(input); - }); - - it('applies the typed query to the visible rows after the debounce', async () => { - vi.useFakeTimers(); - try { - const { view, input } = await openWithSearch(SAMPLE); - type(input, 'daily'); - - expect(internals(view).searchQuery).toBe(''); - vi.advanceTimersByTime(200); - - expect(internals(view).searchQuery).toBe('daily'); - expect(internals(view).visibleStatuses()).toEqual([]); - } finally { - vi.useRealTimers(); - } - }); - - it('shows synced search matches after opting in from All', async () => { - const { root, view } = await openWithSearch(SAMPLE); - const checkbox = root.querySelector('.ssv-show-synced-toggle')!; - - expect(checkbox).toBeTruthy(); - checkbox.checked = true; - checkbox.dispatchEvent(new Event('change')); - - expect(internals(view).showSyncedInAll).toBe(true); - expect(internals(view).visibleStatuses()).toHaveLength(SAMPLE.length); - }); - - it('can switch back to the flat list from the tree options row', async () => { - const { root, view } = await openWithSearch(SAMPLE); - const checkbox = root.querySelector('.ssv-tree-view-toggle')!; - - checkbox.checked = false; - checkbox.dispatchEvent(new Event('change')); - - expect(internals(view).treeViewEnabled).toBe(false); - expect(root.querySelector('.ssv-tree-folder')).toBeNull(); - expect(root.querySelector('.ssv-show-synced-toggle')).toBeNull(); - }); - - it('renders paths as a tree and selects the visible files in a folder', async () => { - const { root, view } = await openWithSearch(SAMPLE); - const folderName = Array.from(root.querySelectorAll('.ssv-tree-folder-name')) - .find(element => element.textContent === 'Notes')!; - const folder = folderName.closest('.ssv-tree-folder')!; - const checkbox = folder.querySelector('.ssv-folder-checkbox')!; - - expect(folderName).toBeTruthy(); - expect(root.querySelector('.ssv-tree-children .ssv-tree-folder-name')?.textContent).toBe('Projects'); - - checkbox.checked = true; - checkbox.dispatchEvent(new Event('change')); - - expect([...internals(view).selectedFiles]).toEqual([ - 'Notes/Projects/alpha.md', - 'Notes/Projects/beta.md', - ]); - }); - - // The selection must never hold anything the current filter hides: Push, - // Pull and Delete all act on it, and all three are irreversible. - it('drops selected files the new query hides', async () => { - vi.useFakeTimers(); - try { - const { view, input } = await openWithSearch(SAMPLE); - internals(view).selectedFiles.add('Notes/daily.md'); - internals(view).selectedFiles.add('readme.md'); - - type(input, 'project'); - vi.advanceTimersByTime(200); - - expect(internals(view).selectedFiles.size).toBe(0); - } finally { - vi.useRealTimers(); - } - }); - - it('keeps selected files the new query still matches', async () => { - vi.useFakeTimers(); - try { - const { view, input } = await openWithSearch(SAMPLE); - internals(view).selectedFiles.add('Notes/Projects/alpha.md'); - internals(view).selectedFiles.add('readme.md'); - - type(input, 'project'); - vi.advanceTimersByTime(200); - - // alpha.md still matches, so ticking it then refining the search - // doesn't throw that tick away — the original bug was clearing - // everything unconditionally. - expect([...internals(view).selectedFiles]).toEqual(['Notes/Projects/alpha.md']); - } finally { - vi.useRealTimers(); - } - }); - - it('keeps selected files that the status tab still shows', async () => { - const { view } = await openWithSearch(SAMPLE); - internals(view).selectedFiles.add('Notes/Projects/beta.md'); // unsynced - internals(view).selectedFiles.add('Notes/daily.md'); // synced - - internals(view).statusFilter = 'unsynced'; - (view as unknown as { pruneSelectionToVisible(): void }).pruneSelectionToVisible(); - - expect([...internals(view).selectedFiles]).toEqual(['Notes/Projects/beta.md']); - }); - - it('resets the filter on Escape', async () => { - vi.useFakeTimers(); - try { - const { view, input } = await openWithSearch(SAMPLE); - type(input, 'project'); - vi.advanceTimersByTime(200); - expect(internals(view).searchQuery).toBe('project'); - - input.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Escape' })); - - expect(input.value).toBe(''); - expect(internals(view).searchQuery).toBe(''); - } finally { - vi.useRealTimers(); - } - }); - - it('shows the clear button only while a query is active', async () => { - vi.useFakeTimers(); - try { - const { input, root } = await openWithSearch(SAMPLE); - const row = root.querySelector('.ssv-search') as HTMLElement; - expect(row.classList.contains('has-query')).toBe(false); - - type(input, 'project'); - vi.advanceTimersByTime(200); - - expect(row.classList.contains('has-query')).toBe(true); - } finally { - vi.useRealTimers(); - } - }); -}); diff --git a/tests/ui/SyncStatusView.test.ts b/tests/ui/SyncStatusView.test.ts deleted file mode 100644 index 5a325f5..0000000 --- a/tests/ui/SyncStatusView.test.ts +++ /dev/null @@ -1,917 +0,0 @@ -/* eslint-disable @typescript-eslint/unbound-method */ -import { describe, it, expect, vi, beforeAll } from 'vitest'; -import { SyncStatusView } from '../../src/ui/SyncStatusView'; -import { WorkspaceLeaf, Notice, TFile } from 'obsidian'; -import type GitLabFilesPush from '../../src/main'; -import { setupObsidianDOM } from './setup-dom'; -import type { FileStatus } from '../../src/ui/types'; -import type { GitTreeEntry } from '../../src/services/git-service-interface'; -import { SyncPlanModal } from '../../src/ui/SyncPlanModal'; -import { ConfirmModal } from '../../src/ui/ConfirmModal'; -import { gitBlobSha } from '../../src/utils/git-blob-sha'; -import type { SyncStatusRefreshService } from '../../src/logic/sync/SyncStatusRefreshService'; - -function refreshService(view: SyncStatusView): SyncStatusRefreshService { - return (view as unknown as { statusRefresh: SyncStatusRefreshService }).statusRefresh; -} - -// The diff pane is a separate view; none of these fixtures open one, so the -// stale-pane cleanup just finds nothing. -function noDiffPanes(): { getLeavesOfType: () => unknown[] } { - return { getLeavesOfType: (): unknown[] => [] }; -} - -// Minimal fake plugin: only the surface these tests actually exercise. -function makePlugin(overrides: { - vaultFolder?: string; - deleteFile?: ReturnType; - deleteBatch?: ReturnType; - adapterExists?: ReturnType; - adapterStat?: ReturnType; - adapterRead?: ReturnType; - getAbstractFileByPath?: ReturnType; -} = {}): { plugin: GitLabFilesPush; leaf: WorkspaceLeaf; deleteFile: ReturnType } { - const vaultFolder = overrides.vaultFolder ?? ''; - const deleteFile = overrides.deleteFile ?? vi.fn().mockResolvedValue(undefined); - - const app = { - workspace: noDiffPanes(), - vault: { - adapter: { - exists: overrides.adapterExists ?? vi.fn().mockResolvedValue(false), - stat: overrides.adapterStat ?? vi.fn().mockResolvedValue(null), - read: overrides.adapterRead ?? vi.fn().mockResolvedValue(''), - }, - getAbstractFileByPath: overrides.getAbstractFileByPath ?? vi.fn().mockReturnValue(null), - }, - }; - - const settings: { branch: string; vaultFolder: string; syncMetadata?: Record } = { branch: 'main', vaultFolder }; - const plugin = { - settings, - gitService: { deleteFile, deleteBatch: overrides.deleteBatch }, - sync: { - // Mirrors SyncManager.trackRename closely enough for these tests: - // moves the metadata entry to the new path and records renamedFrom. - async trackRename(newPath: string, oldPath: string): Promise { - const meta = settings.syncMetadata?.[oldPath]; - if (!meta) return; - delete settings.syncMetadata![oldPath]; - const remotePath = meta.renamedFrom ?? oldPath; - settings.syncMetadata![newPath] = { - ...meta, - lastKnownPath: newPath, - ...(newPath === remotePath ? {} : { renamedFrom: remotePath }), - }; - }, - // Mirrors SyncManager.updateMetadata. - async updateMetadata(path: string, sha: string): Promise { - settings.syncMetadata = settings.syncMetadata ?? {}; - settings.syncMetadata[path] = { lastSyncedSha: sha, lastSyncedAt: 0, lastKnownPath: path }; - }, - }, - getNormalizedPath(path: string): string { - if (!vaultFolder) return path; - const prefix = `${vaultFolder}/`; - if (path.startsWith(prefix)) return path.substring(prefix.length); - if (path === vaultFolder) return ''; - return path; - }, - filterPathByVaultFolder(path: string): boolean { - if (!vaultFolder) return true; - const prefix = `${vaultFolder}/`; - return path.startsWith(prefix) || path === vaultFolder; - }, - } as unknown as GitLabFilesPush; - - const leaf = { app } as unknown as WorkspaceLeaf; - return { plugin, leaf, deleteFile }; -} - -describe('SyncStatusView remote deletion', () => { - beforeAll(() => { setupObsidianDOM(); }); - - // Regression test for the bug where deleteFile() received the vault-relative - // path (carrying the vaultFolder prefix) instead of the repo-relative path, - // causing a spurious "file was not found on branch main" for files the UI - // itself listed as remote-only. - it('strips the vaultFolder prefix before calling gitService.deleteFile', async () => { - const { plugin, leaf, deleteFile } = makePlugin({ vaultFolder: '02_Areas/blog' }); - const view = new SyncStatusView(leaf, plugin); - - const fileStatus: FileStatus = { path: '02_Areas/blog/notes/todo.md', status: 'remote-only' }; - const errors: { path: string, message: string }[] = []; - const prog = new Notice('', 0); - - // performRemoteDeletion is private; called directly to isolate it from - // the confirmation dialog and higher-level orchestration in deleteSelected(). - await (view as unknown as { - performRemoteDeletion(remote: FileStatus[], total: number, localCount: number, prog: Notice, errors: { path: string, message: string }[]): Promise - }).performRemoteDeletion([fileStatus], 1, 0, prog, errors); - - expect(deleteFile).toHaveBeenCalledWith('notes/todo.md', 'main', expect.any(String)); - expect(errors).toHaveLength(0); - }); - - it('passes the path unchanged when no vaultFolder is configured', async () => { - const { plugin, leaf, deleteFile } = makePlugin(); - const view = new SyncStatusView(leaf, plugin); - - const fileStatus: FileStatus = { path: 'notes/todo.md', status: 'remote-only' }; - const errors: { path: string, message: string }[] = []; - const prog = new Notice('', 0); - - await (view as unknown as { - performRemoteDeletion(remote: FileStatus[], total: number, localCount: number, prog: Notice, errors: { path: string, message: string }[]): Promise - }).performRemoteDeletion([fileStatus], 1, 0, prog, errors); - - expect(deleteFile).toHaveBeenCalledWith('notes/todo.md', 'main', expect.any(String)); - }); - - it('records the real error message instead of swallowing it', async () => { - const deleteFile = vi.fn().mockRejectedValue(new Error('Cannot delete "notes/todo.md": file was not found on branch "main".')); - const { plugin, leaf } = makePlugin({ deleteFile }); - const view = new SyncStatusView(leaf, plugin); - - const fileStatus: FileStatus = { path: 'notes/todo.md', status: 'remote-only' }; - const errors: { path: string, message: string }[] = []; - const prog = new Notice('', 0); - - await (view as unknown as { - performRemoteDeletion(remote: FileStatus[], total: number, localCount: number, prog: Notice, errors: { path: string, message: string }[]): Promise - }).performRemoteDeletion([fileStatus], 1, 0, prog, errors); - - expect(errors).toEqual([{ path: 'notes/todo.md', message: 'Cannot delete "notes/todo.md": file was not found on branch "main".' }]); - }); - - it('groups all remote-only deletes into one gitService.deleteBatch call when the provider supports it', async () => { - const deleteBatch = vi.fn().mockResolvedValue(undefined); - const { plugin, leaf, deleteFile } = makePlugin({ deleteBatch }); - const view = new SyncStatusView(leaf, plugin); - - const targets: FileStatus[] = [ - { path: 'a.md', status: 'remote-only' }, - { path: 'b.md', status: 'remote-only' }, - ]; - const errors: { path: string, message: string }[] = []; - const prog = new Notice('', 0); - - await (view as unknown as { - performRemoteDeletion(remote: FileStatus[], total: number, localCount: number, prog: Notice, errors: { path: string, message: string }[]): Promise - }).performRemoteDeletion(targets, 2, 0, prog, errors); - - expect(deleteBatch).toHaveBeenCalledTimes(1); - expect(deleteBatch).toHaveBeenCalledWith(['a.md', 'b.md'], 'main', expect.any(String)); - expect(deleteFile).not.toHaveBeenCalled(); - expect(errors).toHaveLength(0); - }); - - it('marks every path in a failed deleteBatch chunk as failed, not dropped', async () => { - const deleteBatch = vi.fn().mockRejectedValue(new Error('commit failed')); - const { plugin, leaf } = makePlugin({ deleteBatch }); - const view = new SyncStatusView(leaf, plugin); - - const targets: FileStatus[] = [ - { path: 'a.md', status: 'remote-only' }, - { path: 'b.md', status: 'remote-only' }, - ]; - const errors: { path: string, message: string }[] = []; - const prog = new Notice('', 0); - - await (view as unknown as { - performRemoteDeletion(remote: FileStatus[], total: number, localCount: number, prog: Notice, errors: { path: string, message: string }[]): Promise - }).performRemoteDeletion(targets, 2, 0, prog, errors); - - expect(errors).toEqual([ - { path: 'a.md', message: 'commit failed' }, - { path: 'b.md', message: 'commit failed' }, - ]); - }); - - it('falls back to the sequential deleteFile loop when the provider has no deleteBatch', async () => { - const { plugin, leaf, deleteFile } = makePlugin(); - const view = new SyncStatusView(leaf, plugin); - - const targets: FileStatus[] = [ - { path: 'a.md', status: 'remote-only' }, - { path: 'b.md', status: 'remote-only' }, - ]; - const errors: { path: string, message: string }[] = []; - const prog = new Notice('', 0); - - await (view as unknown as { - performRemoteDeletion(remote: FileStatus[], total: number, localCount: number, prog: Notice, errors: { path: string, message: string }[]): Promise - }).performRemoteDeletion(targets, 2, 0, prog, errors); - - expect(deleteFile).toHaveBeenCalledTimes(2); - expect(deleteFile).toHaveBeenCalledWith('a.md', 'main', expect.any(String)); - expect(deleteFile).toHaveBeenCalledWith('b.md', 'main', expect.any(String)); - expect(errors).toHaveLength(0); - }); - - // The modal is opened internally by confirmDeletion, so there's no - // reference to it up front; wrap `open` to capture `this` (the real - // instance, still rendered for real) as it's constructed. - function captureNextSyncPlanModal(): { contentEl: HTMLElement } { - const captured: { contentEl: HTMLElement } = { contentEl: undefined as unknown as HTMLElement }; - const original = SyncPlanModal.prototype.open; - vi.spyOn(SyncPlanModal.prototype, 'open').mockImplementationOnce(function (this: SyncPlanModal & { contentEl: HTMLElement }) { - captured.contentEl = this.contentEl; - return original.call(this); - }); - return captured; - } - - it('shows the plan-review modal (not a plain confirm) before any remote deletion', async () => { - const { plugin, leaf } = makePlugin(); - const view = new SyncStatusView(leaf, plugin); - const captured = captureNextSyncPlanModal(); - - const confirmPromise = (view as unknown as { - confirmDeletion(local: FileStatus[], remote: FileStatus[]): Promise - }).confirmDeletion([], [{ path: 'gone.md', status: 'remote-only' }]); - - const deletionPath = captured.contentEl.querySelector('.sync-plan-section.is-destructive .sync-plan-file-path'); - expect(deletionPath?.textContent).toBe('gone.md'); - - const applyBtn = Array.from(captured.contentEl.querySelectorAll('button')).find(b => b.textContent === 'Apply'); - applyBtn?.dispatchEvent(new Event('click')); - - expect(await confirmPromise).toBe(true); - }); - - it('resolves false when the remote-deletion plan is cancelled', async () => { - const { plugin, leaf } = makePlugin(); - const view = new SyncStatusView(leaf, plugin); - const captured = captureNextSyncPlanModal(); - - const confirmPromise = (view as unknown as { - confirmDeletion(local: FileStatus[], remote: FileStatus[]): Promise - }).confirmDeletion([], [{ path: 'gone.md', status: 'remote-only' }]); - - const cancelBtn = Array.from(captured.contentEl.querySelectorAll('button')).find(b => b.textContent === 'Cancel'); - cancelBtn?.dispatchEvent(new Event('click')); - - expect(await confirmPromise).toBe(false); - }); - - it('uses the plain confirm dialog (no plan) for a local-only deletion', async () => { - const { plugin, leaf } = makePlugin(); - const view = new SyncStatusView(leaf, plugin); - const openSpy = vi.spyOn(SyncPlanModal.prototype, 'open'); - openSpy.mockClear(); - - const originalConfirmOpen = ConfirmModal.prototype.open; - let confirmContentEl: HTMLElement | undefined; - vi.spyOn(ConfirmModal.prototype, 'open').mockImplementationOnce(function (this: ConfirmModal & { contentEl: HTMLElement }) { - confirmContentEl = this.contentEl; - return originalConfirmOpen.call(this); - }); - - const confirmPromise = (view as unknown as { - confirmDeletion(local: FileStatus[], remote: FileStatus[]): Promise - }).confirmDeletion([{ path: 'local.md', status: 'synced' }], []); - - expect(openSpy).not.toHaveBeenCalled(); - - const confirmBtn = Array.from(confirmContentEl!.querySelectorAll('button')).find(b => b.textContent === 'Confirm'); - confirmBtn?.dispatchEvent(new Event('click')); - - expect(await confirmPromise).toBe(true); - }); -}); - -describe('SyncStatusView.identifyExtraFiles folder/remote-record collisions', () => { - beforeAll(() => { setupObsidianDOM(); }); - - // Regression test: a local real directory (or a symlink to one) can share a - // path with a stale remote record (e.g. a folder that used to be a pushed - // symlink). Treating it as a readable file crashes adapter.read() with EISDIR; - // it should be classified remote-only instead. - it('treats a path that exists locally as a folder as remote-only, not a readable file', async () => { - const adapterStat = vi.fn().mockResolvedValue({ type: 'folder' }); - const adapterExists = vi.fn().mockResolvedValue(true); - const { plugin, leaf } = makePlugin({ adapterStat, adapterExists }); - const view = new SyncStatusView(leaf, plugin); - - const remoteMap = new Map([ - ['.claude/skills/polish-blog', { path: '.claude/skills/polish-blog', symlink: false }], - ]); - - const extra = await refreshService(view).identifyExtraFiles(remoteMap, new Set(), new Map()); - - expect(extra).toEqual([]); - const statuses = (view as unknown as { fileStatuses: Map }).fileStatuses; - expect(statuses.get('.claude/skills/polish-blog')).toEqual({ path: '.claude/skills/polish-blog', status: 'remote-only' }); - }); - - it('still treats a genuine local file as extra/checkable', async () => { - const adapterStat = vi.fn().mockResolvedValue({ type: 'file' }); - const adapterExists = vi.fn().mockResolvedValue(true); - const { plugin, leaf } = makePlugin({ adapterStat, adapterExists }); - const view = new SyncStatusView(leaf, plugin); - - const remoteMap = new Map([ - ['notes/hidden.md', { path: 'notes/hidden.md', symlink: false }], - ]); - - const extra = await refreshService(view).identifyExtraFiles(remoteMap, new Set(), new Map()); - - expect(extra).toEqual(['notes/hidden.md']); - }); - - // The old path of a pending move is represented by the 'moved' row at its - // new path, not a separate remote-only row — otherwise every move would - // show a stale row whose most prominent button (Pull) undoes the move. - it('skips a remote-only row for a path that is the old side of a pending move', async () => { - const { plugin, leaf } = makePlugin(); - plugin.settings.syncMetadata = { - 'notes/new.md': { lastSyncedSha: 'sha', lastSyncedAt: 0, lastKnownPath: 'notes/new.md', renamedFrom: 'notes/old.md' }, - }; - const view = new SyncStatusView(leaf, plugin); - - const remoteMap = new Map([ - ['notes/old.md', { path: 'notes/old.md', symlink: false, sha: 'sha' }], - ]); - - const extra = await refreshService(view).identifyExtraFiles(remoteMap, new Set(), new Map(), new Set(['notes/old.md'])); - - expect(extra).toEqual([]); - const statuses = (view as unknown as { fileStatuses: Map }).fileStatuses; - expect(statuses.has('notes/old.md')).toBe(false); - }); -}); - -describe('SyncStatusView local-only status', () => { - beforeAll(() => { setupObsidianDOM(); }); - - it('does not probe Contents API when the remote tree confirms a file is absent', async () => { - const getFile = vi.fn().mockResolvedValue({ content: '', sha: '' }); - const plugin = { - settings: { branch: 'main', vaultFolder: '', rootPath: '' }, - gitService: { getFile }, - getNormalizedPath: (path: string) => path, - } as unknown as GitLabFilesPush; - const leaf = { app: { workspace: noDiffPanes(), vault: { adapter: { read: vi.fn().mockResolvedValue('new content') } } } } as unknown as WorkspaceLeaf; - const view = new SyncStatusView(leaf, plugin); - - await refreshService(view).refreshFileStatus('new.md', undefined); - - expect(getFile).not.toHaveBeenCalled(); - const statuses = (view as unknown as { fileStatuses: Map }).fileStatuses; - expect(statuses.get('new.md')).toMatchObject({ path: 'new.md', status: 'unsynced', localContent: 'new content' }); - }); - - // A tree entry that exists but carries no sha (providers whose listing omits - // it) still needs the content fetch — that path must stay intact. - it('still fetches content for a tree entry without a sha', async () => { - const getFile = vi.fn().mockResolvedValue({ content: 'remote content', sha: 'remote-sha' }); - const { plugin, leaf } = makePlugin({ - adapterExists: vi.fn().mockResolvedValue(true), - adapterRead: vi.fn().mockResolvedValue('remote content'), - }); - (plugin.gitService as unknown as { getFile: typeof getFile }).getFile = getFile; - - const view = new SyncStatusView(leaf, plugin); - - await refreshService(view).refreshFileStatus('notes/existing.md', { path: 'notes/existing.md', symlink: false }); - - expect(getFile).toHaveBeenCalledWith('notes/existing.md', 'main'); - const statuses = (view as unknown as { fileStatuses: Map }).fileStatuses; - expect(statuses.get('notes/existing.md')?.status).toBe('synced'); - }); - - // Root cause of a real report: a file whose content already matches the - // remote (e.g. never pushed/pulled through this plugin -- cloned in, or - // coincidentally identical) showed 'synced' in the panel but had no - // syncMetadata entry. Renaming/moving it then found no metadata at the old - // path, so SyncManager.trackRename silently no-opped and the move showed - // as a stray remote-only + unsynced pair instead of 'moved'. Classifying a - // file as 'synced' must backfill syncMetadata so a later move is tracked. - it('backfills syncMetadata when a sha-based comparison finds a file already synced', async () => { - const { plugin, leaf } = makePlugin({ adapterRead: vi.fn().mockResolvedValue('same content') }); - const view = new SyncStatusView(leaf, plugin); - - await refreshService(view).refreshFileStatusBySha('notes/pre-existing.md', { path: 'notes/pre-existing.md', symlink: false, sha: await gitBlobSha('same content') }); - - expect(plugin.settings.syncMetadata?.['notes/pre-existing.md']).toMatchObject({ lastKnownPath: 'notes/pre-existing.md' }); - }); - - it('backfills syncMetadata when a content-based comparison finds a file already synced', async () => { - const getFile = vi.fn().mockResolvedValue({ content: 'same content', sha: 'remote-sha' }); - const { plugin, leaf } = makePlugin({ - adapterExists: vi.fn().mockResolvedValue(true), - adapterRead: vi.fn().mockResolvedValue('same content'), - }); - (plugin.gitService as unknown as { getFile: typeof getFile }).getFile = getFile; - - const view = new SyncStatusView(leaf, plugin); - - await refreshService(view).refreshFileStatusByContent('notes/pre-existing.md'); - - expect(plugin.settings.syncMetadata?.['notes/pre-existing.md']).toMatchObject({ lastSyncedSha: 'remote-sha' }); - }); - - it('end-to-end: a rename right after a sha-based synced classification is tracked as moved, not a stray remote-only + unsynced pair', async () => { - const { plugin, leaf } = makePlugin({ adapterRead: vi.fn().mockResolvedValue('same content') }); - const view = new SyncStatusView(leaf, plugin); - const sha = await gitBlobSha('same content'); - - // First refresh: the file was never pushed/pulled through the plugin, - // but its content already matches remote -- classified 'synced' from a - // clean slate, same as a freshly opened vault. - await refreshService(view).refreshFileStatusBySha('notes/old.md', { path: 'notes/old.md', symlink: false, sha }); - - // Then the user renames it inside Obsidian -- mirrors main.ts's rename handler. - await plugin.sync.trackRename('notes/new.md', 'notes/old.md'); - - expect(plugin.settings.syncMetadata?.['notes/old.md']).toBeUndefined(); - expect(plugin.settings.syncMetadata?.['notes/new.md']).toMatchObject({ renamedFrom: 'notes/old.md' }); - }); - - it('classifies a tracked pending move as "moved" from metadata alone, with no tree/content lookup', async () => { - const getFile = vi.fn(); - const { plugin, leaf } = makePlugin(); - plugin.settings.syncMetadata = { - 'notes/new.md': { lastSyncedSha: 'sha', lastSyncedAt: 0, lastKnownPath: 'notes/new.md', renamedFrom: 'notes/old.md' }, - }; - (plugin.gitService as unknown as { getFile: typeof getFile }).getFile = getFile; - const view = new SyncStatusView(leaf, plugin); - - await refreshService(view).refreshFileStatus('notes/new.md', { path: 'notes/new.md', symlink: false, sha: 'irrelevant' }); - - expect(getFile).not.toHaveBeenCalled(); - const statuses = (view as unknown as { fileStatuses: Map }).fileStatuses; - expect(statuses.get('notes/new.md')).toMatchObject({ path: 'notes/new.md', status: 'moved', movedFrom: 'notes/old.md' }); - }); -}); - -// A move that happens while the plugin isn't observing the vault's live -// 'rename' event (Obsidian was closed, the move came from another device/OS -// tool, or the plugin hadn't loaded yet) leaves no `renamedFrom` in metadata. -// The status refresh path has no fallback for this: identifyExtraFiles only -// treats a remote path as the old side of a move via pendingMoveOldPaths, -// which is built purely from live-tracked `renamedFrom` entries — never from -// comparing content. So the old path is misclassified 'remote-only' and the -// new path 'unsynced', instead of both being recognized as a 'moved' pair. -describe('SyncStatusView move detection without a live rename event', () => { - beforeAll(() => { setupObsidianDOM(); }); - - it('still classifies an out-of-band folder move as moved, not remote-only + unsynced', async () => { - const { gitBlobSha } = await import('../../src/utils/git-blob-sha'); - const content = 'same content, moved without the plugin watching'; - const sha = await gitBlobSha(content); - - const adapterRead = vi.fn().mockResolvedValue(content); - const { plugin, leaf } = makePlugin({ adapterRead }); - // Sync metadata still points at the old path — no renamedFrom, because - // the vault 'rename' event never fired for this move. - plugin.settings.syncMetadata = { - 'Notes/Projects/a.md': { lastSyncedSha: sha, lastSyncedAt: 0, lastKnownPath: 'Notes/Projects/a.md' }, - }; - const view = new SyncStatusView(leaf, plugin); - - const remoteMap = new Map([ - ['Notes/Projects/a.md', { path: 'Notes/Projects/a.md', symlink: false, sha }], - ]); - - // No pendingMoveOldPaths, since none was ever live-tracked. - const extra = await refreshService(view).identifyExtraFiles(remoteMap, new Set(), new Map(), new Set()); - - // The file now lives at Archive/Projects/a.md locally, with no remote entry yet. - await refreshService(view).refreshFileStatus('Archive/Projects/a.md', undefined); - - await refreshService(view).reconcileOutOfBandMoves(remoteMap); - - expect(extra).toEqual([]); - const statuses = (view as unknown as { fileStatuses: Map }).fileStatuses; - expect(statuses.get('Archive/Projects/a.md')).toMatchObject({ - status: 'moved', - movedFrom: 'Notes/Projects/a.md', - }); - expect(statuses.has('Notes/Projects/a.md')).toBe(false); - }); - - it('recognizes an out-of-band move from legacy metadata without lastKnownPath after restart', async () => { - const { gitBlobSha } = await import('../../src/utils/git-blob-sha'); - const content = 'same content, moved after a plugin restart'; - const sha = await gitBlobSha(content); - - const adapterRead = vi.fn().mockResolvedValue(content); - const { plugin, leaf } = makePlugin({ adapterRead }); - // Metadata written before lastKnownPath was introduced remains after a - // restart. Its object key is the only reliable legacy path. - plugin.settings.syncMetadata = { - 'Notes/old-name.md': { lastSyncedSha: sha, lastSyncedAt: 0 }, - }; - const view = new SyncStatusView(leaf, plugin); - const remoteMap = new Map([ - ['Notes/old-name.md', { path: 'Notes/old-name.md', symlink: false, sha }], - ]); - - await refreshService(view).identifyExtraFiles(remoteMap, new Set(), new Map(), new Set()); - await refreshService(view).refreshFileStatus('Archive/new-name.md', undefined); - await refreshService(view).reconcileOutOfBandMoves(remoteMap); - - const statuses = (view as unknown as { fileStatuses: Map }).fileStatuses; - expect(statuses.get('Archive/new-name.md')).toMatchObject({ - status: 'moved', - movedFrom: 'Notes/old-name.md', - }); - }); - - // Regression test for the hazard behind the out-of-band fix above: an - // external move often reaches Obsidian's vault watcher as a bare delete of - // the old path (no correlated rename), so any code path that reacts to - // that delete by wiping syncMetadata[oldPath] destroys the exact evidence - // this reconciler needs. If that race wins, the move degenerates back into - // the original #66 bug: a permanent 'remote-only' ghost plus a plain - // 'unsynced' new file, never paired as 'moved'. - it('cannot recognize an out-of-band move once its old-path metadata has already been cleared', async () => { - const { gitBlobSha } = await import('../../src/utils/git-blob-sha'); - const content = 'moved while a delete handler raced ahead and cleared metadata first'; - const sha = await gitBlobSha(content); - - const adapterRead = vi.fn().mockResolvedValue(content); - const { plugin, leaf } = makePlugin({ adapterRead }); - plugin.settings.syncMetadata = { - 'Notes/Projects/a.md': { lastSyncedSha: sha, lastSyncedAt: 0, lastKnownPath: 'Notes/Projects/a.md' }, - }; - const view = new SyncStatusView(leaf, plugin); - - const remoteMap = new Map([ - ['Notes/Projects/a.md', { path: 'Notes/Projects/a.md', symlink: false, sha }], - ]); - - await refreshService(view).identifyExtraFiles(remoteMap, new Set(), new Map(), new Set()); - - await refreshService(view).refreshFileStatus('Archive/Projects/a.md', undefined); - - // Simulates a vault 'delete' handler firing for the old path before - // this refresh's reconciliation pass gets to run. - delete plugin.settings.syncMetadata['Notes/Projects/a.md']; - - await refreshService(view).reconcileOutOfBandMoves(remoteMap); - - const statuses = (view as unknown as { fileStatuses: Map }).fileStatuses; - expect(statuses.get('Notes/Projects/a.md')).toMatchObject({ status: 'remote-only' }); - expect(statuses.get('Archive/Projects/a.md')).not.toMatchObject({ status: 'moved' }); - }); -}); - -describe('SyncStatusView.handleFileModified', () => { - beforeAll(() => { setupObsidianDOM(); }); - - it('flips a synced row to modified when the edited content no longer matches the known remote sha', async () => { - const adapterRead = vi.fn().mockResolvedValue('edited content'); - const { plugin, leaf } = makePlugin({ adapterRead }); - const view = new SyncStatusView(leaf, plugin); - const statuses = (view as unknown as { fileStatuses: Map }).fileStatuses; - statuses.set('note.md', { path: 'note.md', status: 'synced', localContent: 'old content', remoteSha: 'sha-of-old-content' }); - - const file = Object.assign(new TFile(), { path: 'note.md' }); - await view.handleFileModified(file); - - expect(statuses.get('note.md')).toMatchObject({ status: 'modified', localContent: 'edited content' }); - }); - - it('keeps a moved row while refreshing its local content for a diff', async () => { - const adapterRead = vi.fn().mockResolvedValue('edited content'); - const { plugin, leaf } = makePlugin({ adapterRead }); - const view = new SyncStatusView(leaf, plugin); - const statuses = (view as unknown as { fileStatuses: Map }).fileStatuses; - statuses.set('new.md', { path: 'new.md', status: 'moved', movedFrom: 'old.md', remoteSha: 'sha-1' }); - - const file = Object.assign(new TFile(), { path: 'new.md' }); - await view.handleFileModified(file); - - expect(adapterRead).toHaveBeenCalledWith('new.md'); - expect(statuses.get('new.md')).toMatchObject({ status: 'moved', movedFrom: 'old.md', remoteSha: 'sha-1', localContent: 'edited content' }); - }); - - it('leaves a remote-only row alone -- there is no local file for it to have changed', async () => { - const adapterRead = vi.fn().mockResolvedValue('content'); - const { plugin, leaf } = makePlugin({ adapterRead }); - const view = new SyncStatusView(leaf, plugin); - const statuses = (view as unknown as { fileStatuses: Map }).fileStatuses; - statuses.set('remote-only.md', { path: 'remote-only.md', status: 'remote-only' }); - - const file = Object.assign(new TFile(), { path: 'remote-only.md' }); - await view.handleFileModified(file); - - expect(adapterRead).not.toHaveBeenCalled(); - expect(statuses.get('remote-only.md')).toMatchObject({ status: 'remote-only' }); - }); - - it('ignores a path the panel is not currently tracking', async () => { - const adapterRead = vi.fn().mockResolvedValue('content'); - const { plugin, leaf } = makePlugin({ adapterRead }); - const view = new SyncStatusView(leaf, plugin); - - const file = Object.assign(new TFile(), { path: 'untracked.md' }); - await view.handleFileModified(file); - - expect(adapterRead).not.toHaveBeenCalled(); - const statuses = (view as unknown as { fileStatuses: Map }).fileStatuses; - expect(statuses.has('untracked.md')).toBe(false); - }); -}); - -describe('SyncStatusView.handleFileRenamed', () => { - beforeAll(() => { setupObsidianDOM(); }); - - it('moves a synced row to the new path as \'moved\', reading the renamedFrom trackRename just recorded', () => { - const { plugin, leaf } = makePlugin(); - plugin.settings.syncMetadata = { 'old.md': { lastSyncedSha: 'sha-1', lastSyncedAt: 0, lastKnownPath: 'old.md' } }; - const view = new SyncStatusView(leaf, plugin); - const statuses = (view as unknown as { fileStatuses: Map }).fileStatuses; - statuses.set('old.md', { path: 'old.md', status: 'synced', remoteSha: 'sha-1' }); - - // Mirrors what main.ts does: SyncManager.trackRename runs first (moving - // the metadata entry and setting renamedFrom), then the view is notified. - void plugin.sync.trackRename('new.md', 'old.md'); - const file = Object.assign(new TFile(), { path: 'new.md' }); - view.handleFileRenamed(file, 'old.md'); - - expect(statuses.has('old.md')).toBe(false); - expect(statuses.get('new.md')).toMatchObject({ status: 'moved', movedFrom: 'old.md', remoteSha: 'sha-1' }); - }); - - it('keeps a never-pushed file local-only after its rename records no metadata', async () => { - const { plugin, leaf } = makePlugin(); - const view = new SyncStatusView(leaf, plugin); - const statuses = (view as unknown as { fileStatuses: Map }).fileStatuses; - statuses.set('draft-old.md', { path: 'draft-old.md', status: 'unsynced', localContent: 'draft' }); - - // main.ts always asks SyncManager to track a vault rename first. A - // never-pushed file has no sync metadata, so this must stay a no-op: - // treating its rename as a move would later delete an unrelated remote - // path if one happened to exist. - await plugin.sync.trackRename('draft-new.md', 'draft-old.md'); - expect(plugin.settings.syncMetadata).toBeUndefined(); - - const file = Object.assign(new TFile(), { path: 'draft-new.md' }); - view.handleFileRenamed(file, 'draft-old.md'); - - expect(statuses.has('draft-old.md')).toBe(false); - const renamed = statuses.get('draft-new.md'); - expect(renamed).toMatchObject({ status: 'unsynced', localContent: 'draft' }); - expect(renamed).not.toHaveProperty('movedFrom'); - }); - - it('drops the row entirely when the rename moves the file out of the configured vault folder', () => { - const { plugin, leaf } = makePlugin({ vaultFolder: 'scoped' }); - const view = new SyncStatusView(leaf, plugin); - const statuses = (view as unknown as { fileStatuses: Map }).fileStatuses; - statuses.set('scoped/old.md', { path: 'scoped/old.md', status: 'synced', remoteSha: 'sha-1' }); - - const file = Object.assign(new TFile(), { path: 'outside/new.md' }); - view.handleFileRenamed(file, 'scoped/old.md'); - - expect(statuses.has('scoped/old.md')).toBe(false); - expect(statuses.has('outside/new.md')).toBe(false); - }); - - it('ignores a rename mid-refresh -- the in-flight refresh will settle it', () => { - const { plugin, leaf } = makePlugin(); - const view = new SyncStatusView(leaf, plugin); - const statuses = (view as unknown as { fileStatuses: Map }).fileStatuses; - statuses.set('old.md', { path: 'old.md', status: 'checking' }); - - const file = Object.assign(new TFile(), { path: 'new.md' }); - view.handleFileRenamed(file, 'old.md'); - - expect(statuses.get('old.md')).toMatchObject({ status: 'checking' }); - expect(statuses.has('new.md')).toBe(false); - }); - - it('ignores a rename the panel is not currently tracking', () => { - const { plugin, leaf } = makePlugin(); - const view = new SyncStatusView(leaf, plugin); - const statuses = (view as unknown as { fileStatuses: Map }).fileStatuses; - - const file = Object.assign(new TFile(), { path: 'new.md' }); - view.handleFileRenamed(file, 'untracked-old.md'); - - expect(statuses.has('new.md')).toBe(false); - }); -}); - -describe('SyncStatusView moved diff data', () => { - beforeAll(() => { setupObsidianDOM(); }); - - it('retains the old remote blob SHA and current local content after a refresh', async () => { - const adapterRead = vi.fn().mockResolvedValue('edited after move'); - const { plugin, leaf } = makePlugin({ adapterRead }); - plugin.settings.syncMetadata = { - 'new.md': { lastSyncedSha: 'old-sha', lastSyncedAt: 0, lastKnownPath: 'new.md', renamedFrom: 'old.md' }, - }; - const view = new SyncStatusView(leaf, plugin); - const file = Object.assign(new TFile(), { path: 'new.md' }); - const remoteMap = new Map([['old.md', { path: 'old.md', sha: 'old-sha', symlink: false }]]); - - await refreshService(view).refreshFileStatus(file, undefined, remoteMap); - - const statuses = (view as unknown as { fileStatuses: Map }).fileStatuses; - expect(statuses.get('new.md')).toMatchObject({ - status: 'moved', movedFrom: 'old.md', remoteSha: 'old-sha', localContent: 'edited after move', - }); - }); -}); - -describe('SyncStatusView post-push status update', () => { - beforeAll(() => { setupObsidianDOM(); }); - - // Regression test: GitHub's tree-by-branch-name read can lag a moment behind - // a just-completed write (GraphQL createCommitOnBranch or otherwise), so - // re-fetching the remote tree immediately after a push can misreport a file - // that was just pushed correctly as still "modified". The fix marks - // successfully-pushed paths 'synced' directly from the push result instead - // of trusting an immediate remote re-read. - it('marks pushed files synced from the push result instead of re-fetching the remote tree', async () => { - const pushFiles = vi.fn().mockResolvedValue({ - success: 2, failed: 0, conflicts: 0, errors: [], - syncedPaths: [{ path: 'a.md', sha: 'sha-a' }, { path: 'b.md', sha: 'sha-b' }], - }); - - const plugin = { - settings: { branch: 'main', vaultFolder: '' }, - gitService: {}, - sync: { pushFiles }, - } as unknown as GitLabFilesPush; - const app = { workspace: noDiffPanes(), vault: { adapter: { exists: vi.fn().mockResolvedValue(false) } } }; - const leaf = { app } as unknown as WorkspaceLeaf; - const view = new SyncStatusView(leaf, plugin); - - const statuses = (view as unknown as { fileStatuses: Map }).fileStatuses; - statuses.set('a.md', { path: 'a.md', status: 'modified', localContent: '' }); - statuses.set('b.md', { path: 'b.md', status: 'modified', localContent: '' }); - - const refreshSpy = vi.spyOn(view, 'refreshAllStatuses').mockResolvedValue(undefined); - - await (view as unknown as { - executeBatchOperation(filter: 'modified' | 'selected', op: 'push' | 'pull', files: Array): Promise - }).executeBatchOperation('modified', 'push', ['a.md', 'b.md']); - - expect(pushFiles).toHaveBeenCalledTimes(1); - // The fix: no remote tree re-fetch right after push (that read is what - // can lag GitHub's write and misreport the file as still modified). - expect(refreshSpy).not.toHaveBeenCalled(); - expect(statuses.get('a.md')).toEqual({ path: 'a.md', status: 'synced', localContent: '', remoteSha: 'sha-a' }); - expect(statuses.get('b.md')).toEqual({ path: 'b.md', status: 'synced', localContent: '', remoteSha: 'sha-b' }); - }); - - // Regression test: runSingleFile used to call refreshFileStatus(file, undefined) - // after a successful push. Passing `undefined` as the remoteEntry means "this - // path isn't on the remote at all", which forces status back to 'unsynced' - // right after a successful push. The fix applies the same optimistic-sync - // approach as the batch path above instead of re-deriving status from a - // (misleading) "not on remote" signal. - it('marks a single pushed file synced from the push result instead of forcing unsynced', async () => { - const pushFiles = vi.fn().mockResolvedValue({ - success: 1, failed: 0, conflicts: 0, errors: [], syncedPaths: [{ path: 'note.md', sha: 'new-sha' }], - }); - const getFile = vi.fn(); - - const plugin = { - settings: { branch: 'main', vaultFolder: '' }, - gitService: { getFile }, - sync: { pushFiles }, - } as unknown as GitLabFilesPush; - const app = { workspace: noDiffPanes(), vault: { adapter: { exists: vi.fn().mockResolvedValue(false) } } }; - const leaf = { app } as unknown as WorkspaceLeaf; - const view = new SyncStatusView(leaf, plugin); - - const statuses = (view as unknown as { fileStatuses: Map }).fileStatuses; - const fileStatus: FileStatus = { path: 'note.md', status: 'modified', localContent: 'x' }; - statuses.set('note.md', fileStatus); - - await (view as unknown as { - runSingleFile(fileStatus: FileStatus, op: 'push' | 'pull'): Promise - }).runSingleFile(fileStatus, 'push'); - - expect(pushFiles).toHaveBeenCalledTimes(1); - expect(pushFiles).toHaveBeenCalledWith(['note.md']); - // No live remote re-check when the push result already confirms sync. - expect(getFile).not.toHaveBeenCalled(); - expect(statuses.get('note.md')).toMatchObject({ path: 'note.md', status: 'synced', remoteSha: 'new-sha' }); - }); - - it('still does a full remote refresh after a pull (unaffected by this fix)', async () => { - const pullAllFiles = vi.fn().mockResolvedValue({ success: 1, failed: 0, conflicts: 0, errors: [] }); - - const plugin = { - settings: { branch: 'main', vaultFolder: '' }, - gitService: {}, - sync: { pullAllFiles }, - } as unknown as GitLabFilesPush; - const app = { workspace: noDiffPanes(), vault: { adapter: { exists: vi.fn().mockResolvedValue(false) } } }; - const leaf = { app } as unknown as WorkspaceLeaf; - const view = new SyncStatusView(leaf, plugin); - - const refreshSpy = vi.spyOn(view, 'refreshAllStatuses').mockResolvedValue(undefined); - - await (view as unknown as { - executeBatchOperation(filter: 'modified' | 'selected', op: 'push' | 'pull', files: Array): Promise - }).executeBatchOperation('modified', 'pull', ['a.md']); - - expect(pullAllFiles).toHaveBeenCalledTimes(1); - expect(refreshSpy).toHaveBeenCalledTimes(1); - }); -}); - -describe('SyncStatusView folder-move collapsing (#67)', () => { - beforeAll(() => { setupObsidianDOM(); }); - - type CollapsibleGroups = Map; - - function movedStatus(path: string, movedFrom: string): FileStatus { - return { path, status: 'moved', movedFrom }; - } - - it('collapses every file of a fully-moved folder into a single group', () => { - const { plugin, leaf } = makePlugin(); - const view = new SyncStatusView(leaf, plugin); - const statuses = [ - movedStatus('Archive/Projects/a.md', 'Notes/Projects/a.md'), - movedStatus('Archive/Projects/b.md', 'Notes/Projects/b.md'), - movedStatus('Archive/Projects/sub/c.md', 'Notes/Projects/sub/c.md'), - ]; - - const groups = (view as unknown as { - collapsibleMoveGroups(statuses: FileStatus[]): CollapsibleGroups - }).collapsibleMoveGroups(statuses); - - expect(groups.size).toBe(1); - const [group] = [...groups.values()]; - // The differing segment alone: everything after "Notes"/"Archive" - // (including nested "sub/") matches, so that's the common suffix. - expect(group).toMatchObject({ oldPrefix: 'Notes', newPrefix: 'Archive' }); - expect(group?.members).toHaveLength(3); - }); - - it('does not collapse a partial move — a file left behind under the old prefix keeps the group expanded', () => { - const { plugin, leaf } = makePlugin(); - const view = new SyncStatusView(leaf, plugin); - const statusStore = (view as unknown as { fileStatuses: Map }).fileStatuses; - statusStore.set('Archive/Projects/a.md', movedStatus('Archive/Projects/a.md', 'Notes/Projects/a.md')); - statusStore.set('Archive/Projects/b.md', movedStatus('Archive/Projects/b.md', 'Notes/Projects/b.md')); - // Left behind: still at the old prefix, never moved. - statusStore.set('Notes/Projects/c.md', { path: 'Notes/Projects/c.md', status: 'synced' }); - const statuses = [...statusStore.values()]; - - const groups = (view as unknown as { - collapsibleMoveGroups(statuses: FileStatus[]): CollapsibleGroups - }).collapsibleMoveGroups(statuses); - - expect(groups.size).toBe(0); - }); - - it('does not collapse a single moved file — a group of one stays a plain moved row', () => { - const { plugin, leaf } = makePlugin(); - const view = new SyncStatusView(leaf, plugin); - const statuses = [movedStatus('Archive/a.md', 'Notes/a.md')]; - - const groups = (view as unknown as { - collapsibleMoveGroups(statuses: FileStatus[]): CollapsibleGroups - }).collapsibleMoveGroups(statuses); - - expect(groups.size).toBe(0); - }); - - it('does not merge a file that was renamed as well as moved into the folder group', () => { - const { plugin, leaf } = makePlugin(); - const view = new SyncStatusView(leaf, plugin); - const statuses = [ - movedStatus('Archive/Projects/a.md', 'Notes/Projects/a.md'), - movedStatus('Archive/Projects/b.md', 'Notes/Projects/b.md'), - // Same folder move, but this file's own name also changed. - movedStatus('Archive/Projects/renamed.md', 'Notes/Projects/original.md'), - ]; - - const groups = (view as unknown as { - collapsibleMoveGroups(statuses: FileStatus[]): CollapsibleGroups - }).collapsibleMoveGroups(statuses); - - expect(groups.size).toBe(1); - const [group] = [...groups.values()]; - expect(group?.members.map(m => m.path).sort()).toEqual(['Archive/Projects/a.md', 'Archive/Projects/b.md']); - }); - - it('counts a collapsed group as one row in the moved tab count, not one per file', () => { - const { plugin, leaf } = makePlugin(); - const view = new SyncStatusView(leaf, plugin); - const statuses = [ - movedStatus('Archive/Projects/a.md', 'Notes/Projects/a.md'), - movedStatus('Archive/Projects/b.md', 'Notes/Projects/b.md'), - movedStatus('Elsewhere/solo.md', 'Somewhere/solo.md'), - ]; - - const count = (view as unknown as { - movedRowCount(statuses: FileStatus[]): number - }).movedRowCount(statuses); - - // The 2-file folder group is 1 row, plus 1 ungrouped moved row = 2. - expect(count).toBe(2); - }); -}); diff --git a/tests/ui/source-control/SourceControlItemView.test.ts b/tests/ui/source-control/SourceControlItemView.test.ts new file mode 100644 index 0000000..1701a55 --- /dev/null +++ b/tests/ui/source-control/SourceControlItemView.test.ts @@ -0,0 +1,99 @@ +import { beforeAll, describe, expect, it, vi } from 'vitest'; +import { WorkspaceLeaf } from 'obsidian'; +import { SourceControlItemView, SOURCE_CONTROL_VIEW_TYPE } from '../../../src/ui/source-control/SourceControlItemView'; +import { ChangeRepository } from '../../../src/logic/source-control/ChangeRepository'; +import { OperationState } from '../../../src/logic/source-control/OperationState'; +import { PushSelectionStore } from '../../../src/logic/source-control/PushSelectionStore'; +import { SourceControlViewModel } from '../../../src/logic/source-control/SourceControlViewModel'; +import { toChangeId } from '../../../src/logic/source-control/types'; +import { SyncStatusService } from '../../../src/logic/sync-status-service'; +import type GitLabFilesPush from '../../../src/main'; +import { setupObsidianDOM } from '../setup-dom'; + +beforeAll(() => { setupObsidianDOM(); }); + +function buildPlugin() { + const repository = new ChangeRepository(); + repository.replace([{ id: toChangeId('a.md'), path: 'a.md', kind: 'local-only' }]); + const selection = new PushSelectionStore(); + const operations = new OperationState(); + const viewModel = new SourceControlViewModel(repository, selection, operations); + const push = vi.fn().mockResolvedValue(undefined); + const loadDiffContent = vi.fn().mockResolvedValue(null); + const status = new SyncStatusService(); + + const plugin = { + changeRepository: repository, + pushSelectionStore: selection, + operationState: operations, + sourceControlViewModel: viewModel, + sourceControlActions: { push, loadDiffContent }, + sync: { status }, + } as unknown as GitLabFilesPush; + + return { plugin, repository, selection, push, status }; +} + +describe('SourceControlItemView', () => { + it('registers under the legacy sync-status-view type so existing saved leaves resolve', () => { + const { plugin } = buildPlugin(); + const view = new SourceControlItemView({} as WorkspaceLeaf, plugin); + + expect(view.getViewType()).toBe(SOURCE_CONTROL_VIEW_TYPE); + expect(SOURCE_CONTROL_VIEW_TYPE).toBe('sync-status-view'); + }); + + it('renders the Source Control tree on open', async () => { + const { plugin } = buildPlugin(); + const view = new SourceControlItemView({} as WorkspaceLeaf, plugin); + + await view.onOpen(); + + const container = view.containerEl.children[1] as HTMLElement; + expect(container.querySelector('.scv-change-item')).not.toBeNull(); + }); + + it('forwards push clicks to SourceControlActionService.push, never touching a Git provider directly', async () => { + const { plugin, selection, push } = buildPlugin(); + selection.includeForPush(toChangeId('a.md')); + const view = new SourceControlItemView({} as WorkspaceLeaf, plugin); + await view.onOpen(); + + const container = view.containerEl.children[1] as HTMLElement; + (container.querySelector('.scv-push-btn') as HTMLButtonElement).click(); + + expect(push).toHaveBeenCalledWith([toChangeId('a.md')]); + }); + + it('re-renders when the shared SyncStatusService publishes a change', async () => { + const { plugin, repository, status } = buildPlugin(); + const view = new SourceControlItemView({} as WorkspaceLeaf, plugin); + await view.onOpen(); + + repository.replace([ + { id: toChangeId('a.md'), path: 'a.md', kind: 'local-only' }, + { id: toChangeId('b.md'), path: 'b.md', kind: 'remote-only' }, + ]); + status.set({ path: 'b.md', status: 'remote-only' }); + // Render is debounced (150ms) to match the previous sync-status view's throttle. + await new Promise(resolve => setTimeout(resolve, 200)); + + // The "all" filter groups every change into every section it matches + // (e.g. a remote-only change appears under both CHANGES and REMOTE + // CHANGES), so assert distinct ids rather than raw row count. + const container = view.containerEl.children[1] as HTMLElement; + const ids = new Set( + Array.from(container.querySelectorAll('.scv-change-item')).map(el => el.getAttribute('data-change-id')), + ); + expect(ids).toEqual(new Set(['a.md', 'b.md'])); + }); + + it('stops re-rendering once closed', async () => { + const { plugin, status } = buildPlugin(); + const view = new SourceControlItemView({} as WorkspaceLeaf, plugin); + await view.onOpen(); + await view.onClose(); + + expect(() => status.set({ path: 'z.md', status: 'synced' })).not.toThrow(); + }); +}); diff --git a/tests/ui/sync-status/SyncStatusController.test.ts b/tests/ui/sync-status/SyncStatusController.test.ts deleted file mode 100644 index 44b9363..0000000 --- a/tests/ui/sync-status/SyncStatusController.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -/* eslint-disable @typescript-eslint/unbound-method */ -import { describe, expect, it, vi } from 'vitest'; -import { SyncStatusController, type SyncStatusCommandPort } from '../../../src/ui/sync-status/SyncStatusController'; -import type { FileStatus } from '../../../src/logic/sync-status-service'; - -function setup() { - const commands: SyncStatusCommandPort = { - refresh: vi.fn().mockResolvedValue(undefined), - push: vi.fn().mockResolvedValue(undefined), - pull: vi.fn().mockResolvedValue(undefined), - delete: vi.fn().mockResolvedValue(undefined), - openDiff: vi.fn().mockResolvedValue(undefined), - pushOne: vi.fn().mockResolvedValue(undefined), - pullOne: vi.fn().mockResolvedValue(undefined), - deleteLocal: vi.fn().mockResolvedValue(undefined), - loadDiff: vi.fn().mockResolvedValue(undefined), - openFile: vi.fn().mockReturnValue(true), - canOpen: vi.fn().mockReturnValue(true), - revertMove: vi.fn().mockResolvedValue(undefined), - pushMoveGroup: vi.fn().mockResolvedValue(undefined), - revertMoveGroup: vi.fn().mockResolvedValue(undefined), - pushAllModified: vi.fn().mockResolvedValue(undefined), - pullAllModified: vi.fn().mockResolvedValue(undefined), - }; - return { commands, controller: new SyncStatusController(commands) }; -} - -describe('SyncStatusController', () => { - it('forwards refresh to the workspace command boundary', async () => { - const { commands, controller } = setup(); - await controller.refresh(); - expect(commands.refresh).toHaveBeenCalledOnce(); - }); - - it.each(['push', 'pull', 'delete'] as const)('forwards selected paths to %s unchanged', async command => { - const { commands, controller } = setup(); - await controller[command](['a.md', 'Folder/b.md']); - expect(commands[command]).toHaveBeenCalledWith(['a.md', 'Folder/b.md']); - }); - - it('opens a diff by path without exposing provider details', async () => { - const { commands, controller } = setup(); - await controller.openDiff('a.md'); - expect(commands.openDiff).toHaveBeenCalledWith('a.md'); - }); - - it.each([ - ['pushOne', 'pushOne'], - ['pullOne', 'pullOne'], - ['deleteLocal', 'deleteLocal'], - ['revertMove', 'revertMove'], - ] as const)('forwards a row to %s', async (controllerMethod, portMethod) => { - const { commands, controller } = setup(); - const status: FileStatus = { path: 'a.md', status: 'modified' }; - - await controller[controllerMethod](status); - - expect(commands[portMethod]).toHaveBeenCalledWith(status); - }); - - it('forwards move groups without converting them to provider objects', async () => { - const { commands, controller } = setup(); - const members: FileStatus[] = [{ path: 'new/a.md', movedFrom: 'old/a.md', status: 'moved' }]; - - await controller.pushMoveGroup(members); - await controller.revertMoveGroup(members); - - expect(commands.pushMoveGroup).toHaveBeenCalledWith(members); - expect(commands.revertMoveGroup).toHaveBeenCalledWith(members); - }); -}); diff --git a/tests/ui/sync-status/SyncStatusSelectors.test.ts b/tests/ui/sync-status/SyncStatusSelectors.test.ts deleted file mode 100644 index 4a9a9ba..0000000 --- a/tests/ui/sync-status/SyncStatusSelectors.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import type { FileStatus } from '../../../src/ui/types'; -import { - collapsibleMoveGroups, - pruneSelection, - searchedStatuses, - selectedVisibleFiles, - visibleStatuses, -} from '../../../src/ui/sync-status/SyncStatusSelectors'; -import { SyncStatusViewState } from '../../../src/ui/sync-status/SyncStatusViewState'; - -const STATUSES: FileStatus[] = [ - { path: 'Notes/alpha.md', status: 'modified' }, - { path: 'Notes/beta.md', status: 'unsynced' }, - { path: 'Notes/daily.md', status: 'synced' }, - { path: 'Remote/readme.md', status: 'remote-only' }, -]; - -describe('SyncStatusSelectors', () => { - it.each([ - { query: '', filter: 'all' as const, expected: ['Notes/alpha.md', 'Notes/beta.md', 'Remote/readme.md'] }, - { query: 'notes', filter: 'all' as const, expected: ['Notes/alpha.md', 'Notes/beta.md'] }, - { query: 'notes', filter: 'unsynced' as const, expected: ['Notes/beta.md'] }, - { query: 'REMOTE', filter: 'remote-only' as const, expected: ['Remote/readme.md'] }, - ])('combines search and filter: $query / $filter', ({ query, filter, expected }) => { - const state = new SyncStatusViewState(); - state.setSearchQuery(query); - state.setStatusFilter(filter); - - expect(visibleStatuses(state, STATUSES).map(status => status.path)).toEqual(expected); - }); - - it('shows synced rows in flat mode or when explicitly enabled', () => { - const state = new SyncStatusViewState(); - state.setTreeViewEnabled(false); - - expect(visibleStatuses(state, STATUSES).map(status => status.status)).toEqual([ - 'modified', 'unsynced', 'remote-only', 'synced', - ]); - - state.setTreeViewEnabled(true); - state.setShowSyncedInAll(true); - expect(visibleStatuses(state, STATUSES)).toEqual(STATUSES); - }); - - it('searches full folder paths case-insensitively', () => { - const state = new SyncStatusViewState(); - state.setSearchQuery('notes/'); - - expect(searchedStatuses(state, STATUSES).map(status => status.path)).toEqual([ - 'Notes/alpha.md', 'Notes/beta.md', 'Notes/daily.md', - ]); - }); - - it('returns selected visible files and a pruned selection without mutation', () => { - const state = new SyncStatusViewState(); - state.select('Notes/alpha.md'); - state.select('Notes/daily.md'); - const visible = visibleStatuses(state, STATUSES); - - expect(selectedVisibleFiles(state, visible).map(status => status.path)).toEqual(['Notes/alpha.md']); - expect([...pruneSelection(state.selectedFiles, visible)]).toEqual(['Notes/alpha.md']); - expect([...state.selectedFiles]).toEqual(['Notes/alpha.md', 'Notes/daily.md']); - }); - - it('groups complete folder moves but leaves partial moves visible', () => { - const moved: FileStatus[] = [ - { path: 'New/a.md', movedFrom: 'Old/a.md', status: 'moved' }, - { path: 'New/b.md', movedFrom: 'Old/b.md', status: 'moved' }, - ]; - - expect(collapsibleMoveGroups(moved, moved).size).toBe(1); - expect(collapsibleMoveGroups(moved, [...moved, { path: 'Old/left.md', status: 'synced' }]).size).toBe(0); - }); -}); diff --git a/tests/ui/sync-status/SyncStatusView.wiring.test.ts b/tests/ui/sync-status/SyncStatusView.wiring.test.ts deleted file mode 100644 index 8e2e847..0000000 --- a/tests/ui/sync-status/SyncStatusView.wiring.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { beforeAll, describe, expect, it, vi } from 'vitest'; -import { WorkspaceLeaf } from 'obsidian'; -import { SyncStatusView } from '../../../src/ui/SyncStatusView'; -import { SyncStatusController } from '../../../src/ui/sync-status/SyncStatusController'; -import { SyncStatusService } from '../../../src/logic/sync-status-service'; -import type GitLabFilesPush from '../../../src/main'; -import { setupObsidianDOM } from '../setup-dom'; - -describe('SyncStatusView controller wiring', () => { - beforeAll(() => setupObsidianDOM()); - - it('routes refresh and selected batch actions through path-only controller commands', async () => { - const status = new SyncStatusService(); - status.set({ path: 'a.md', status: 'modified' }); - const commands = { - refresh: vi.fn().mockResolvedValue(undefined), - push: vi.fn().mockResolvedValue(undefined), - pull: vi.fn().mockResolvedValue(undefined), - delete: vi.fn().mockResolvedValue(undefined), - openDiff: vi.fn().mockResolvedValue(undefined), - pushOne: vi.fn().mockResolvedValue(undefined), - pullOne: vi.fn().mockResolvedValue(undefined), - deleteLocal: vi.fn().mockResolvedValue(undefined), - loadDiff: vi.fn().mockResolvedValue(undefined), - openFile: vi.fn().mockReturnValue(true), - canOpen: vi.fn().mockReturnValue(true), - revertMove: vi.fn().mockResolvedValue(undefined), - pushMoveGroup: vi.fn().mockResolvedValue(undefined), - revertMoveGroup: vi.fn().mockResolvedValue(undefined), - pushAllModified: vi.fn().mockResolvedValue(undefined), - pullAllModified: vi.fn().mockResolvedValue(undefined), - }; - const plugin = { - settings: { branch: 'main', vaultFolder: '', rootPath: '' }, - sync: { status }, - } as unknown as GitLabFilesPush; - const leaf = { - app: { vault: { getFileByPath: vi.fn().mockReturnValue(null) }, workspace: {} }, - } as unknown as WorkspaceLeaf; - const view = new SyncStatusView(leaf, plugin, new SyncStatusController(commands)); - (view as unknown as { selectedFiles: Set }).selectedFiles.add('a.md'); - - await view.onOpen(); - const root = view.containerEl.children[1] as HTMLElement; - root.querySelector('.ssv-btn-refresh')!.click(); - root.querySelector('.ssv-btn-push')!.click(); - root.querySelector('.ssv-btn-pull')!.click(); - root.querySelector('.ssv-btn-danger')!.click(); - - expect(commands.refresh).toHaveBeenCalledOnce(); - expect(commands.push).toHaveBeenCalledWith(['a.md']); - expect(commands.pull).toHaveBeenCalledWith(['a.md']); - expect(commands.delete).toHaveBeenCalledWith(['a.md']); - }); -}); diff --git a/tests/ui/sync-status/SyncStatusViewState.test.ts b/tests/ui/sync-status/SyncStatusViewState.test.ts deleted file mode 100644 index 118b2b1..0000000 --- a/tests/ui/sync-status/SyncStatusViewState.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { SyncStatusViewState } from '../../../src/ui/sync-status/SyncStatusViewState'; - -describe('SyncStatusViewState', () => { - it('owns presentation defaults independently of domain state', () => { - const state = new SyncStatusViewState(); - - expect(state.statusFilter).toBe('all'); - expect(state.treeViewEnabled).toBe(true); - expect(state.showSyncedInAll).toBe(false); - expect(state.searchQuery).toBe(''); - expect(state.selectedFiles.size).toBe(0); - expect(state.refreshState).toEqual({ isRefreshing: false, current: 0, total: 0, lastSyncTime: 0 }); - }); - - it('normalizes search queries and transitions refresh state', () => { - const state = new SyncStatusViewState(); - - state.setSearchQuery(' Notes/Daily '); - state.startRefresh(); - state.updateRefreshProgress(2, 5); - state.finishRefresh(1234); - - expect(state.searchQuery).toBe('Notes/Daily'); - expect(state.refreshState).toEqual({ isRefreshing: false, current: 2, total: 5, lastSyncTime: 1234 }); - }); - - it('encapsulates selection, folder, and move-group transitions', () => { - const state = new SyncStatusViewState(); - - state.select('a.md'); - state.select('b.md'); - state.toggleCollapsedFolder('Notes'); - state.toggleExpandedMoveGroup('move-key'); - state.retainSelected(new Set(['b.md'])); - - expect([...state.selectedFiles]).toEqual(['b.md']); - expect(state.collapsedFolders.has('Notes')).toBe(true); - expect(state.expandedMoveGroups.has('move-key')).toBe(true); - - state.toggleCollapsedFolder('Notes'); - state.toggleExpandedMoveGroup('move-key'); - expect(state.collapsedFolders.size).toBe(0); - expect(state.expandedMoveGroups.size).toBe(0); - }); -}); From 0bcc8007a4b2f77850a5d3341ceeee216d26c51e Mon Sep 17 00:00:00 2001 From: tianyao Date: Sat, 22 Aug 2026 10:26:31 +0000 Subject: [PATCH 009/104] fix(source-control): correct status grouping and filter semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a presentation layer so the UI no longer reads Git status directly: SourceControlSummary is the single source for every count (all/changes/remote-changes/ready-to-push/conflicts/synced) and the ViewModel only forwards its counts. Filter semantics: - all = actionable (kind !== 'synced') so All no longer duplicates the Synced bucket. - changes = local-side only (local-only/local-modified/moved). - ready-to-push excludes synced. Rendering: - Every filter (including All) renders one flat tree + an active-filter header; the old section breakdown under All is removed, so SYNCED never leaks into All. - Synced hidden by default behind a Show synced toggle; the synced chip only surfaces when opted in, and hiding it while on synced falls back to All. Tree grouping: - ChangeTreeBuilder gains TreeDisplayOptions { maxDepth, collapseSingleChild }; the view enables collapseSingleChild so single-child folder chains collapse to one path node instead of an Explorer-like deep nest. ChangeSection.ts deleted (no longer used). i18n keys + styles added. Verification: npx eslint . 0 errors; npm run build PASS (tsc + Obsidian 1.11.0 compat + esbuild); npx vitest run 56 files / 547 tests. Manual Obsidian verification in a real vault remains. Scope excludes diff viewer, conflict resolution UI, push/pull pipeline, and view migration per the fix plan's后续順序. --- src/i18n/locales/en.ts | 2 + src/i18n/locales/zh-cn.ts | 2 + src/i18n/locales/zh-tw.ts | 2 + src/logic/source-control/ChangeTreeBuilder.ts | 121 +++++++++++++++++- .../source-control/SourceControlFilter.ts | 31 +++-- .../source-control/SourceControlSummary.ts | 89 +++++++++++++ .../source-control/SourceControlViewModel.ts | 39 +++--- src/ui/source-control/ChangeSection.ts | 41 ------ src/ui/source-control/ChangeTree.ts | 16 ++- src/ui/source-control/FilterMenu.ts | 46 +++++-- src/ui/source-control/SourceControlView.ts | 101 +++++++-------- styles.css | 45 +++++++ .../source-control/ChangeTreeBuilder.test.ts | 46 +++++++ .../SourceControlSummary.test.ts | 111 ++++++++++++++++ .../SourceControlViewModel.test.ts | 21 ++- tests/ui/source-control/FilterMenu.test.ts | 49 ++++--- .../source-control/SourceControlView.test.ts | 73 ++++++++++- 17 files changed, 677 insertions(+), 158 deletions(-) create mode 100644 src/logic/source-control/SourceControlSummary.ts delete mode 100644 src/ui/source-control/ChangeSection.ts create mode 100644 tests/logic/source-control/SourceControlSummary.test.ts diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index e0417b9..efde946 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -257,6 +257,8 @@ const en = { 'sourceControl.filter.remoteChanges': 'Remote Changes', 'sourceControl.filter.conflicts': 'Conflicts', 'sourceControl.filter.synced': 'Synced', + 'sourceControl.filter.showSynced': 'Show synced', + 'sourceControl.section.all': 'ALL', 'sourceControl.section.readyToPush': 'READY TO PUSH', 'sourceControl.section.changes': 'CHANGES', 'sourceControl.section.remoteChanges': 'REMOTE CHANGES', diff --git a/src/i18n/locales/zh-cn.ts b/src/i18n/locales/zh-cn.ts index c6d3c9f..145d004 100644 --- a/src/i18n/locales/zh-cn.ts +++ b/src/i18n/locales/zh-cn.ts @@ -259,6 +259,8 @@ const zhCn: Partial> = { 'sourceControl.filter.remoteChanges': '远程更改', 'sourceControl.filter.conflicts': '冲突', 'sourceControl.filter.synced': '已同步', + 'sourceControl.filter.showSynced': '显示已同步', + 'sourceControl.section.all': '全部', 'sourceControl.section.readyToPush': '待推送', 'sourceControl.section.changes': '更改', 'sourceControl.section.remoteChanges': '远程更改', diff --git a/src/i18n/locales/zh-tw.ts b/src/i18n/locales/zh-tw.ts index 6d63b0c..8fe3130 100644 --- a/src/i18n/locales/zh-tw.ts +++ b/src/i18n/locales/zh-tw.ts @@ -259,6 +259,8 @@ const zhTw: Partial> = { 'sourceControl.filter.remoteChanges': '遠端變更', 'sourceControl.filter.conflicts': '衝突', 'sourceControl.filter.synced': '已同步', + 'sourceControl.filter.showSynced': '顯示已同步', + 'sourceControl.section.all': '全部', 'sourceControl.section.readyToPush': '待推送', 'sourceControl.section.changes': '變更', 'sourceControl.section.remoteChanges': '遠端變更', diff --git a/src/logic/source-control/ChangeTreeBuilder.ts b/src/logic/source-control/ChangeTreeBuilder.ts index b8cdc07..1a9152b 100644 --- a/src/logic/source-control/ChangeTreeBuilder.ts +++ b/src/logic/source-control/ChangeTreeBuilder.ts @@ -18,6 +18,31 @@ export interface ChangeTreeFolderNode { export type ChangeTreeNode = ChangeTreeFileNode | ChangeTreeFolderNode; +/** + * Presentation-only controls for tree rendering, so the Source Control tree + * stays a compact change view rather than reproducing the full file Explorer. + * + * - `maxDepth`: the maximum number of folder nesting levels rendered as + * separate, collapsible nodes. Deeper folders are folded into a single + * flattened path segment (e.g. `02_Areas/blog/_pixnet/zh-tw/tech`) instead of + * five nested expandable rows. Files always render at their real depth; only + * intermediate folders are flattened. Defaults to unlimited depth (legacy + * behavior) when omitted. + * - `collapseSingleChild`: when true, a folder that contains exactly one + * child folder (no files) is merged with that child into one combined folder + * node, reducing pointless single-step nesting like `tech › tech › tech`. + * Defaults to false to preserve the existing rendering when omitted. + */ +export interface TreeDisplayOptions { + maxDepth?: number; + collapseSingleChild?: boolean; +} + +const DEFAULT_OPTIONS: Required> = { + maxDepth: Number.POSITIVE_INFINITY, + collapseSingleChild: false, +}; + /** * Turns a flat `SyncChange[]` into a folder/file tree for rendering. * A renamed/moved file is placed at its *current* path — `previousPath` @@ -25,12 +50,14 @@ export type ChangeTreeNode = ChangeTreeFileNode | ChangeTreeFolderNode; * not create a second tree entry. */ export class ChangeTreeBuilder { - build(changes: readonly SyncChange[]): ChangeTreeNode[] { + build(changes: readonly SyncChange[], options: TreeDisplayOptions = {}): ChangeTreeNode[] { + const opts = { ...DEFAULT_OPTIONS, ...options }; const root: ChangeTreeFolderNode = { type: 'folder', name: '', path: '', children: [] }; for (const change of changes) { this.insert(root, change); } - return root.children; + const nodes = this.collapseAndLimit(root.children, opts, 0); + return nodes; } private insert(root: ChangeTreeFolderNode, change: SyncChange): void { @@ -65,4 +92,92 @@ export class ChangeTreeBuilder { parent.children.push(created); return created; } -} + + /** + * Applies `collapseSingleChild` and `maxDepth` to a depth's children. + * + * `collapseSingleChild` merges a folder whose only child is a single folder + * (no file siblings) into one combined node, joining names/paths with `/`. + * The merge repeats along a run of single-child folders so + * `a/b/c/d.md` collapses to `a/b/c` (one node) when every level has only one + * child folder. Files break the run, so `a/x.md` + `a/b/c/y.md` keeps `a` + * separate from the collapsed `b/c`. + * + * `maxDepth` flattens any folder nesting deeper than the limit into a + * single path-labelled node whose children are the files/subfolders at that + * point (no further nesting is rendered). + */ + private collapseAndLimit( + nodes: ChangeTreeNode[], + opts: Required>, + depth: number, + ): ChangeTreeNode[] { + const result: ChangeTreeNode[] = []; + for (const node of nodes) { + if (node.type === 'file') { + result.push(node); + continue; + } + + const collapsed = this.collapseSingleChildRun(node, opts); + const atDepthLimit = depth >= opts.maxDepth; + + if (atDepthLimit) { + // Flatten deeper structure into one folder node holding all descendants' files. + result.push(this.flattenFolder(collapsed)); + continue; + } + + collapsed.children = this.collapseAndLimit(collapsed.children, opts, depth + 1); + result.push(collapsed); + } + return result; + } + + private collapseSingleChildRun( + folder: ChangeTreeFolderNode, + opts: Required>, + ): ChangeTreeFolderNode { + if (!opts.collapseSingleChild) return folder; + + let current = folder; + // Walk down while the current folder has exactly one child and it is a folder. + let onlyChild = current.children[0]; + while (current.children.length === 1 && onlyChild && onlyChild.type === 'folder') { + current = this.mergeFolders(current, onlyChild); + onlyChild = current.children[0]; + } + return current; + } + + private mergeFolders(parent: ChangeTreeFolderNode, child: ChangeTreeFolderNode): ChangeTreeFolderNode { + return { + type: 'folder', + name: `${parent.name}/${child.name}`, + path: child.path, + children: child.children, + }; + } + + private flattenFolder(folder: ChangeTreeFolderNode): ChangeTreeFolderNode { + const files = this.collectFiles(folder); + return { + type: 'folder', + name: folder.name, + path: folder.path, + children: files, + }; + } + + private collectFiles(folder: ChangeTreeFolderNode): ChangeTreeNode[] { + const files: ChangeTreeNode[] = []; + for (const child of folder.children) { + if (child.type === 'file') { + files.push(child); + } else { + files.push(...this.collectFiles(child)); + } + } + return files; + } +} \ No newline at end of file diff --git a/src/logic/source-control/SourceControlFilter.ts b/src/logic/source-control/SourceControlFilter.ts index 3229ec2..db67d93 100644 --- a/src/logic/source-control/SourceControlFilter.ts +++ b/src/logic/source-control/SourceControlFilter.ts @@ -1,5 +1,5 @@ import type { PushSelectionStore } from './PushSelectionStore'; -import type { SyncChange } from './types'; +import type { SyncChange, SyncChangeKind } from './types'; export type SourceControlFilter = | 'all' @@ -9,18 +9,31 @@ export type SourceControlFilter = | 'conflicts' | 'synced'; +const LOCAL_KINDS: ReadonlySet = new Set(['local-only', 'local-modified', 'moved']); +const REMOTE_KINDS: ReadonlySet = new Set(['remote-only', 'remote-modified']); + /** - * Whether `change` belongs under `filter`. `ready-to-push` is defined purely - * by `PushSelectionStore` membership — it's a user selection, not a fact - * derivable from the change's kind alone. + * Whether `change` belongs under `filter`. Filters are user-facing *action* + * semantics, not a raw mirror of Git status: + * + * - `all` — *actionable* changes only (everything except synced). A synced + * file needs no action, so it never appears under All. This keeps All from + * duplicating the Synced bucket. + * - `changes` — local-side changes only (local-only, local-modified, moved). + * Remote-only/conflict rows belong to their own filters, not Changes. + * - `ready-to-push` — defined purely by {@link PushSelectionStore} membership; + * it's a user selection, not a fact derivable from the change's kind alone. + * - `remote-changes` — remote-only / remote-modified. + * - `conflicts` — conflict. + * - `synced` — synced (only surfaced when the user opts in via "Show synced"). */ export function matchesFilter(change: SyncChange, filter: SourceControlFilter, selection: PushSelectionStore): boolean { switch (filter) { - case 'all': return true; - case 'changes': return change.kind !== 'synced'; - case 'ready-to-push': return selection.isIncluded(change.id); - case 'remote-changes': return change.kind === 'remote-only' || change.kind === 'remote-modified'; + case 'all': return change.kind !== 'synced'; + case 'changes': return LOCAL_KINDS.has(change.kind); + case 'ready-to-push': return selection.isIncluded(change.id) && change.kind !== 'synced'; + case 'remote-changes': return REMOTE_KINDS.has(change.kind); case 'conflicts': return change.kind === 'conflict'; case 'synced': return change.kind === 'synced'; } -} +} \ No newline at end of file diff --git a/src/logic/source-control/SourceControlSummary.ts b/src/logic/source-control/SourceControlSummary.ts new file mode 100644 index 0000000..ffd3140 --- /dev/null +++ b/src/logic/source-control/SourceControlSummary.ts @@ -0,0 +1,89 @@ +import type { PushSelectionStore } from './PushSelectionStore'; +import type { SourceControlFilter } from './SourceControlFilter'; +import type { ChangeId, SyncChange, SyncChangeKind } from './types'; + +/** + * Per-filter counts, keyed by the same {@link SourceControlFilter} values the + * filter menu renders. This is the single source of truth for every count the + * UI shows — the ViewModel passes it through unchanged and the view layer + * never recomputes a count itself. + * + * `synced` is the *rendered* count: it is `0` when synced changes are hidden + * (showSynced = false) so the UI can't display a synced count the user has + * asked to suppress. The raw synced bucket is still available on + * {@link SourceControlSummary.synced} for callers that need the actual figure. + */ +export type SourceControlCounts = Record; + +/** + * The complete presentation projection of a pending-change set: the raw + * buckets the UI renders from, plus the single {@link counts} object every + * count label reads from. + * + * Buckets are disjoint and exhaustive over {@link SyncChangeKind}: + * - {@link localChanges}: local-only, local-modified, moved + * - {@link remoteChanges}: remote-only, remote-modified + * - {@link conflicts}: conflict + * - {@link synced}: synced + * - {@link all}: the union of the three actionable buckets (everything except + * synced) — "All" means *actionable*, not "every row", so a synced file never + * appears under All. + * - {@link readyToPush}: the subset of actionable changes the user has selected + * for push (membership in {@link PushSelectionStore}); it overlaps the other + * actionable buckets by design, since "ready to push" is a selection, not a + * change kind. + */ +export interface SourceControlSummary { + all: SyncChange[]; + localChanges: SyncChange[]; + remoteChanges: SyncChange[]; + readyToPush: SyncChange[]; + conflicts: SyncChange[]; + synced: SyncChange[]; + counts: SourceControlCounts; +} + +const LOCAL_KINDS: ReadonlySet = new Set(['local-only', 'local-modified', 'moved']); +const REMOTE_KINDS: ReadonlySet = new Set(['remote-only', 'remote-modified']); + +function isLocal(change: SyncChange): boolean { return LOCAL_KINDS.has(change.kind); } +function isRemote(change: SyncChange): boolean { return REMOTE_KINDS.has(change.kind); } +function isConflict(change: SyncChange): boolean { return change.kind === 'conflict'; } +function isSynced(change: SyncChange): boolean { return change.kind === 'synced'; } +function isActionable(change: SyncChange): boolean { return change.kind !== 'synced'; } + +/** + * Builds the single presentation projection the Source Control UI consumes. + * Pure: given the same `changes` + `selection` + `showSynced` it always + * produces the same {@link SourceControlSummary}, with no side effects on the + * store. Callers (the ViewModel) hold no count logic of their own. + * + * @param showSynced when false, {@link SourceControlCounts.synced} is reported + * as `0` (the UI hides the synced bucket) while {@link SourceControlSummary.synced} + * still holds the raw synced changes. + */ +export function buildSummary( + changes: readonly SyncChange[], + selection: PushSelectionStore, + showSynced: boolean, +): SourceControlSummary { + const localChanges = changes.filter(isLocal); + const remoteChanges = changes.filter(isRemote); + const conflicts = changes.filter(isConflict); + const synced = changes.filter(isSynced); + const all = changes.filter(isActionable); + + const selectedIds = new Set(selection.getSelectedChangeIds()); + const readyToPush = all.filter(change => selectedIds.has(change.id)); + + const counts: SourceControlCounts = { + all: all.length, + changes: localChanges.length, + 'ready-to-push': readyToPush.length, + 'remote-changes': remoteChanges.length, + conflicts: conflicts.length, + synced: showSynced ? synced.length : 0, + }; + + return { all, localChanges, remoteChanges, readyToPush, conflicts, synced, counts }; +} \ No newline at end of file diff --git a/src/logic/source-control/SourceControlViewModel.ts b/src/logic/source-control/SourceControlViewModel.ts index 317a285..c880a70 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 { buildSummary, type SourceControlCounts } from './SourceControlSummary'; import type { OperationState, OperationStatus } from './OperationState'; import type { PushSelectionStore } from './PushSelectionStore'; import { matchesFilter, type SourceControlFilter } from './SourceControlFilter'; @@ -18,16 +19,24 @@ export interface SourceControlItem { export interface SourceControlViewState { filter: SourceControlFilter; items: SourceControlItem[]; - counts: Record; + /** Single-source counts from {@link buildSummary} — the view never recomputes these. */ + counts: SourceControlCounts; } -const ALL_FILTERS: SourceControlFilter[] = ['all', 'changes', 'ready-to-push', 'remote-changes', 'conflicts', 'synced']; - /** * Combines `SyncChange[]` (via `ChangeRepository`), `PushSelectionStore`, 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). */ export class SourceControlViewModel { constructor( @@ -36,13 +45,21 @@ export class SourceControlViewModel { private readonly operations: OperationState, ) {} - getState(filter: SourceControlFilter = 'all'): SourceControlViewState { + getState(filter: SourceControlFilter = 'all', showSynced = false): SourceControlViewState { const all = this.changes.getAll(); + const summary = buildSummary(all, this.selection, showSynced); const items = all .filter(change => matchesFilter(change, filter, this.selection)) + .filter(() => this.isRenderable(filter, showSynced)) .map(change => this.toItem(change)); - const counts = this.countByFilter(all); - return { filter, items, counts }; + return { filter, items, counts: summary.counts }; + } + + 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 { @@ -55,12 +72,4 @@ export class SourceControlViewModel { operationStatus: this.operations.get(change.id), }; } - - private countByFilter(changes: readonly SyncChange[]): Record { - const counts = {} as Record; - for (const filter of ALL_FILTERS) { - counts[filter] = changes.filter(change => matchesFilter(change, filter, this.selection)).length; - } - return counts; - } -} +} \ No newline at end of file diff --git a/src/ui/source-control/ChangeSection.ts b/src/ui/source-control/ChangeSection.ts deleted file mode 100644 index 8d52dd8..0000000 --- a/src/ui/source-control/ChangeSection.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { SourceControlFilter } from '../../logic/source-control/SourceControlFilter'; -import type { SourceControlItem } from '../../logic/source-control/SourceControlViewModel'; -import { renderChangeTree, type ChangeTreeCallbacks } from './ChangeTree'; - -export interface ChangeSectionProps { - /** One of the section filters (not 'all' — the "All" filter renders every section). */ - id: Exclude; - title: string; - items: readonly SourceControlItem[]; - collapsed: boolean; - collapsedFolders: ReadonlySet; -} - -export interface ChangeSectionCallbacks extends ChangeTreeCallbacks { - onToggleSection: (id: Exclude) => void; -} - -/** Renders one of the five Source Control sections: a collapsible header + its change tree. */ -export function renderChangeSection( - container: HTMLElement, - props: ChangeSectionProps, - callbacks: ChangeSectionCallbacks, -): HTMLElement { - const sectionEl = container.createDiv({ cls: `scv-section scv-section-${props.id}` }); - const header = sectionEl.createDiv({ cls: 'scv-section-header' }); - - const toggle = header.createEl('button', { cls: 'scv-section-toggle' }); - toggle.setAttr('aria-expanded', String(!props.collapsed)); - toggle.setText(props.collapsed ? '▶' : '▼'); - toggle.addEventListener('click', () => callbacks.onToggleSection(props.id)); - - header.createSpan({ cls: 'scv-section-title', text: props.title }); - header.createSpan({ cls: 'scv-section-count', text: String(props.items.length) }); - - if (!props.collapsed) { - const body = sectionEl.createDiv({ cls: 'scv-section-body' }); - renderChangeTree(body, props.items, props.collapsedFolders, callbacks); - } - - return sectionEl; -} diff --git a/src/ui/source-control/ChangeTree.ts b/src/ui/source-control/ChangeTree.ts index cc4a202..478f38f 100644 --- a/src/ui/source-control/ChangeTree.ts +++ b/src/ui/source-control/ChangeTree.ts @@ -3,6 +3,7 @@ import { type ChangeTreeFileNode, type ChangeTreeFolderNode, type ChangeTreeNode, + type TreeDisplayOptions, } from '../../logic/source-control/ChangeTreeBuilder'; import type { SourceControlItem } from '../../logic/source-control/SourceControlViewModel'; import type { ChangeId } from '../../logic/source-control/types'; @@ -20,16 +21,21 @@ const builder = new ChangeTreeBuilder(); * `SyncChange`, so the builder's output only carries `id`/`path`/`kind`; a * by-id lookup restores `isReadyToPush`/`operationStatus` at render time * instead of duplicating the tree-building logic. + * + * `options` controls presentation-only tree shaping (single-child folder + * collapse, depth limit) so the Source Control tree stays a compact change + * view rather than reproducing the full file Explorer. */ export function renderChangeTree( container: HTMLElement, items: readonly SourceControlItem[], collapsedFolders: ReadonlySet, callbacks: ChangeTreeCallbacks, + options: TreeDisplayOptions = {}, ): void { const byId = new Map(items.map(item => [item.id, item])); - const nodes = builder.build(items); - renderNodes(container, nodes, byId, collapsedFolders, callbacks); + const nodes = builder.build(items, options); + renderNodes(container, nodes, byId, collapsedFolders, callbacks, options); } function renderNodes( @@ -38,9 +44,10 @@ function renderNodes( byId: ReadonlyMap, collapsedFolders: ReadonlySet, callbacks: ChangeTreeCallbacks, + options: TreeDisplayOptions, ): void { for (const node of nodes) { - if (node.type === 'folder') renderFolder(container, node, byId, collapsedFolders, callbacks); + if (node.type === 'folder') renderFolder(container, node, byId, collapsedFolders, callbacks, options); else renderFile(container, node, byId, callbacks); } } @@ -51,6 +58,7 @@ function renderFolder( byId: ReadonlyMap, collapsedFolders: ReadonlySet, callbacks: ChangeTreeCallbacks, + options: TreeDisplayOptions, ): void { const collapsed = collapsedFolders.has(folder.path); const folderEl = container.createDiv({ cls: 'scv-tree-folder' }); @@ -65,7 +73,7 @@ function renderFolder( if (!collapsed) { const childrenEl = folderEl.createDiv({ cls: 'scv-tree-children' }); - renderNodes(childrenEl, folder.children, byId, collapsedFolders, callbacks); + renderNodes(childrenEl, folder.children, byId, collapsedFolders, callbacks, options); } } diff --git a/src/ui/source-control/FilterMenu.ts b/src/ui/source-control/FilterMenu.ts index 3c193b3..f7bd306 100644 --- a/src/ui/source-control/FilterMenu.ts +++ b/src/ui/source-control/FilterMenu.ts @@ -1,8 +1,12 @@ import { t, type TranslationKey } from '../../i18n'; import type { SourceControlFilter } from '../../logic/source-control/SourceControlFilter'; -/** Order and labels match the Phase 3 spec's Filter section exactly. */ -const FILTER_ORDER: SourceControlFilter[] = ['all', 'changes', 'ready-to-push', 'remote-changes', 'conflicts', 'synced']; +/** + * Action filter chips, in spec order. `synced` is deliberately NOT a permanent + * chip — it surfaces only when the user opts in via the "Show synced" toggle, + * so a quiet workspace isn't dominated by a large synced count. + */ +const ACTION_FILTERS: SourceControlFilter[] = ['all', 'changes', 'ready-to-push', 'remote-changes', 'conflicts']; const FILTER_LABEL_KEYS: Record = { all: 'sourceControl.filter.all', @@ -13,21 +17,47 @@ const FILTER_LABEL_KEYS: Record = { synced: 'sourceControl.filter.synced', }; -/** Renders the six-way Source Control filter switch, with per-filter counts from the ViewModel. */ +export interface FilterMenuCallbacks { + /** Switches the active filter chip. */ + onFilterChange: (filter: SourceControlFilter) => void; + /** Toggles whether synced changes are surfaced at all (the "Show synced" switch). */ + onToggleShowSynced: (show: boolean) => void; +} + +/** + * Renders the Source Control filter row: the five action chips (All, Changes, + * Ready to Push, Remote Changes, Conflicts) followed by a "Show synced" + * toggle. The `synced` chip is appended only when `showSynced` is on, so a + * hidden synced bucket contributes no chip and no count to the row. + * + * Per-filter counts come straight from the ViewModel's single-source counts; + * the menu never recomputes one. + */ export function renderFilterMenu( container: HTMLElement, current: SourceControlFilter, counts: Record, - onChange: (filter: SourceControlFilter) => void, + showSynced: boolean, + callbacks: FilterMenuCallbacks, ): void { const menu = container.createDiv({ cls: 'scv-filter-menu' }); - for (const value of FILTER_ORDER) { + + const renderChip = (value: SourceControlFilter): void => { const isActive = value === current; const btn = menu.createEl('button', { cls: `scv-filter-option${isActive ? ' is-active' : ''}` }); btn.setAttr('data-filter', value); btn.setAttr('aria-pressed', String(isActive)); btn.createSpan({ cls: 'scv-filter-label', text: t(FILTER_LABEL_KEYS[value]) }); btn.createSpan({ cls: 'scv-filter-count', text: String(counts[value] ?? 0) }); - btn.addEventListener('click', () => onChange(value)); - } -} + btn.addEventListener('click', () => callbacks.onFilterChange(value)); + }; + + for (const value of ACTION_FILTERS) renderChip(value); + if (showSynced) renderChip('synced'); + + const toggle = menu.createEl('label', { cls: 'scv-filter-show-synced' }); + const checkbox = toggle.createEl('input', { type: 'checkbox', cls: 'scv-filter-show-synced-checkbox' }); + checkbox.checked = showSynced; + checkbox.addEventListener('change', () => callbacks.onToggleShowSynced(checkbox.checked)); + toggle.createSpan({ cls: 'scv-filter-show-synced-label', text: t('sourceControl.filter.showSynced') }); +} \ No newline at end of file diff --git a/src/ui/source-control/SourceControlView.ts b/src/ui/source-control/SourceControlView.ts index 0010a90..a1afc0d 100644 --- a/src/ui/source-control/SourceControlView.ts +++ b/src/ui/source-control/SourceControlView.ts @@ -5,7 +5,6 @@ import type { SourceControlFilter } from '../../logic/source-control/SourceContr import { SourceControlViewModel, type SourceControlItem } from '../../logic/source-control/SourceControlViewModel'; import type { ChangeId } from '../../logic/source-control/types'; import { renderDiffPanel } from '../components/DiffPanel'; -import { renderChangeSection } from './ChangeSection'; import { renderChangeTree, type ChangeTreeCallbacks } from './ChangeTree'; import { renderFilterMenu } from './FilterMenu'; import { renderSourceControlHeader } from './SourceControlHeader'; @@ -24,22 +23,22 @@ export interface SourceControlViewCallbacks { loadDiffContent?: (item: SourceControlItem) => Promise; } -type SectionFilter = Exclude; - -/** The five Source Control sections, in the order the spec lists them. */ -const SECTION_FILTERS: SectionFilter[] = ['ready-to-push', 'changes', 'remote-changes', 'conflicts', 'synced']; - -const SECTION_TITLE_KEYS: Record = { - 'ready-to-push': 'sourceControl.section.readyToPush', - changes: 'sourceControl.section.changes', - 'remote-changes': 'sourceControl.section.remoteChanges', - conflicts: 'sourceControl.section.conflicts', - synced: 'sourceControl.section.synced', +/** Active-filter header title keys. Every filter renders one header + a flat tree (no section breakdown). */ +const FILTER_HEADER_KEYS: Record = { + all: 'sourceControl.section.all', + changes: 'sourceControl.section.changes', + 'ready-to-push': 'sourceControl.section.readyToPush', + 'remote-changes': 'sourceControl.section.remoteChanges', + conflicts: 'sourceControl.section.conflicts', + synced: 'sourceControl.section.synced', }; +/** Tree shaping so the change tree stays a compact change view, not a full Explorer. */ +const TREE_OPTIONS = { collapseSingleChild: true }; + /** - * Composes the Source Control UI (Header, Filter, ChangeTree/sections, Diff - * panel) from `SourceControlViewModel` state, per + * Composes the Source Control UI (Header, Filter, change tree, Diff panel) + * from `SourceControlViewModel` state, per * docs/source-control-refactor/phase-3-source-control-ui.md. * * Pure presentation + wiring: push/diff intent is handed to injected @@ -48,10 +47,17 @@ const SECTION_TITLE_KEYS: Record = { * the one exception — it goes straight to `PushSelectionStore` (Phase 1 * state), since "ready to push" is just a set membership change, not a sync * action. + * + * Rendering semantics (status-grouping fix): + * - Every filter — including "All" — renders a single flat tree. "All" no + * longer breaks the view into CHANGES / REMOTE CHANGES / SYNCED sections, so + * a change never appears twice and SYNCED never leaks into All. + * - Synced is hidden by default (`showSynced = false`): the `synced` chip is + * absent and synced rows render nowhere. The "Show synced" toggle opts in. */ export class SourceControlView { private filter: SourceControlFilter = 'all'; - private readonly collapsedSections = new Set(); + private showSynced = false; private readonly collapsedFolders = new Set(); private selectedChangeId: ChangeId | null = null; private container?: HTMLElement; @@ -86,6 +92,7 @@ export class SourceControlView { } getFilter(): SourceControlFilter { return this.filter; } + getShowSynced(): boolean { return this.showSynced; } getSelectedChangeId(): ChangeId | null { return this.selectedChangeId; } private rerender(): void { @@ -93,7 +100,7 @@ export class SourceControlView { } private renderMain(container: HTMLElement): void { - const state = this.viewModel.getState(this.filter); + const state = this.viewModel.getState(this.filter, this.showSynced); renderSourceControlHeader( container, @@ -101,12 +108,24 @@ export class SourceControlView { { onPush: () => { void this.callbacks.onPush(this.selection.getSelectedChangeIds()); } }, ); - renderFilterMenu(container, this.filter, state.counts, (filter) => { - this.filter = filter; - this.rerender(); - }); + renderFilterMenu( + container, + this.filter, + state.counts, + this.showSynced, + { + onFilterChange: (filter) => { this.filter = filter; this.rerender(); }, + onToggleShowSynced: (show) => { + this.showSynced = show; + // If the user hid synced while viewing it, fall back to All. + if (!show && this.filter === 'synced') this.filter = 'all'; + this.rerender(); + }, + }, + ); const body = container.createDiv({ cls: 'scv-body' }); + this.renderActiveFilterHeader(body, state.filter, state.items.length); if (state.items.length === 0) { body.createDiv({ cls: 'scv-empty', text: t('sourceControl.empty') }); return; @@ -118,33 +137,14 @@ export class SourceControlView { onOpenDiff: (item) => this.openDiff(item), }; - if (this.filter === 'all') { - this.renderSections(body, treeCallbacks); - } else { - renderChangeTree(body, state.items, this.collapsedFolders, treeCallbacks); - } + renderChangeTree(body, state.items, this.collapsedFolders, treeCallbacks, TREE_OPTIONS); } - private renderSections(body: HTMLElement, treeCallbacks: ChangeTreeCallbacks): void { - for (const sectionFilter of SECTION_FILTERS) { - const items = this.viewModel.getState(sectionFilter).items; - if (items.length === 0) continue; - - renderChangeSection( - body, - { - id: sectionFilter, - title: t(SECTION_TITLE_KEYS[sectionFilter]), - items, - collapsed: this.collapsedSections.has(sectionFilter), - collapsedFolders: this.collapsedFolders, - }, - { - ...treeCallbacks, - onToggleSection: (id) => this.toggleSection(id), - }, - ); - } + /** Renders the single active-filter header (e.g. "ALL (132)") above the flat tree. */ + private renderActiveFilterHeader(container: HTMLElement, filter: SourceControlFilter, count: number): void { + const header = container.createDiv({ cls: 'scv-active-filter-header' }); + header.createSpan({ cls: 'scv-active-filter-title', text: t(FILTER_HEADER_KEYS[filter]) }); + header.createSpan({ cls: 'scv-active-filter-count', text: String(count) }); } private renderDiffPane(container: HTMLElement): void { @@ -169,7 +169,8 @@ export class SourceControlView { private async loadAndRenderDiff(container: HTMLElement, changeId: ChangeId): Promise { if (!this.callbacks.loadDiffContent) return; - const item = this.viewModel.getState('all').items.find(i => i.id === changeId); + const item = this.viewModel.getState('all', this.showSynced).items.find(i => i.id === changeId) + ?? this.viewModel.getState('synced', this.showSynced).items.find(i => i.id === changeId); if (!item) return; const content = await this.callbacks.loadDiffContent(item); @@ -178,12 +179,6 @@ export class SourceControlView { renderDiffPanel(container, content.remote, content.local); } - private toggleSection(id: SectionFilter): void { - if (this.collapsedSections.has(id)) this.collapsedSections.delete(id); - else this.collapsedSections.add(id); - this.rerender(); - } - private toggleFolder(path: string): void { if (this.collapsedFolders.has(path)) this.collapsedFolders.delete(path); else this.collapsedFolders.add(path); @@ -201,4 +196,4 @@ export class SourceControlView { if (this.callbacks.onOpenDiff) void this.callbacks.onOpenDiff(item); this.rerender(); } -} +} \ No newline at end of file diff --git a/styles.css b/styles.css index b9355e7..e494def 100644 --- a/styles.css +++ b/styles.css @@ -93,6 +93,51 @@ background: rgba(255, 255, 255, 0.22); } +/* ── Show synced toggle ───────────────────────────────────────── */ +.scv-filter-show-synced { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + font-size: 0.78em; + color: var(--text-muted); + cursor: pointer; + white-space: nowrap; + margin-left: auto; +} + +.scv-filter-show-synced-checkbox { + margin: 0; + cursor: pointer; +} + +.scv-filter-show-synced-label { + text-transform: none; + letter-spacing: 0; +} + +/* ── Active filter header ─────────────────────────────────────── */ +.scv-active-filter-header { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px 4px 12px; + color: var(--text-muted); + font-size: 0.78em; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.02em; +} + +.scv-active-filter-count { + background: var(--background-modifier-border); + border-radius: 10px; + padding: 1px 6px; + font-size: 0.9em; + min-width: 18px; + text-align: center; +} + /* ── Push button ───────────────────────────────────────────────── */ .scv-push-btn { display: flex; diff --git a/tests/logic/source-control/ChangeTreeBuilder.test.ts b/tests/logic/source-control/ChangeTreeBuilder.test.ts index 5e417e3..67ba0b2 100644 --- a/tests/logic/source-control/ChangeTreeBuilder.test.ts +++ b/tests/logic/source-control/ChangeTreeBuilder.test.ts @@ -91,4 +91,50 @@ describe('ChangeTreeBuilder', () => { { type: 'file', id: toChangeId('c-1'), name: 'd.md', path: 'a/b/c/d.md', previousPath: undefined, kind: 'local-only' }, ]); }); + + describe('TreeDisplayOptions', () => { + it('collapses single-child folder chains into one combined path node', () => { + const builder = new ChangeTreeBuilder(); + const changes: SyncChange[] = [ + { id: toChangeId('c-1'), path: '02_Areas/blog/_pixnet/zh-tw/tech/pixnet-xxx.md', kind: 'local-only' }, + { id: toChangeId('c-2'), path: '02_Areas/blog/_pixnet/zh-tw/tech/pixnet-yyy.md', kind: 'local-only' }, + ]; + + const tree = builder.build(changes, { collapseSingleChild: true }); + const folder = tree[0] as ChangeTreeFolderNode; + + // The single-child chain 02_Areas/.../tech collapses to one folder node + // holding both files, instead of five nested expandable rows. + expect(tree).toHaveLength(1); + expect(folder.name).toBe('02_Areas/blog/_pixnet/zh-tw/tech'); + expect(folder.children.map(child => child.name)).toEqual(['pixnet-xxx.md', 'pixnet-yyy.md']); + }); + + it('keeps sibling files from breaking out of their shared folder under collapseSingleChild', () => { + const builder = new ChangeTreeBuilder(); + const changes: SyncChange[] = [ + { id: toChangeId('c-1'), path: 'notes/a.md', kind: 'local-only' }, + { id: toChangeId('c-2'), path: 'notes/inner/b.md', kind: 'local-only' }, + ]; + + const tree = builder.build(changes, { collapseSingleChild: true }); + const notes = tree[0] as ChangeTreeFolderNode; + + // `notes` has a file sibling (a.md) alongside the inner folder, so it is + // not merged away; only the single-child `inner` run would collapse if + // it had no file siblings of its own. + expect(notes.name).toBe('notes'); + expect(notes.children.some(c => c.type === 'file')).toBe(true); + }); + + it('leaves full nesting intact by default (no options)', () => { + const builder = new ChangeTreeBuilder(); + const changes: SyncChange[] = [ + { id: toChangeId('c-1'), path: 'a/b/c/d.md', kind: 'local-only' }, + ]; + + const tree = builder.build(changes); + expect((tree[0] as ChangeTreeFolderNode).name).toBe('a'); + }); + }); }); diff --git a/tests/logic/source-control/SourceControlSummary.test.ts b/tests/logic/source-control/SourceControlSummary.test.ts new file mode 100644 index 0000000..74cb68a --- /dev/null +++ b/tests/logic/source-control/SourceControlSummary.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest'; +import { buildSummary } from '../../../src/logic/source-control/SourceControlSummary'; +import { PushSelectionStore } from '../../../src/logic/source-control/PushSelectionStore'; +import { toChangeId, type SyncChange } from '../../../src/logic/source-control/types'; + +function local(id: string, path = `${id}.md`): SyncChange { + return { id: toChangeId(id), path, kind: 'local-only' }; +} + +function remote(id: string, path = `${id}.md`): SyncChange { + return { id: toChangeId(id), path, kind: 'remote-only' }; +} + +function synced(id: string, path = `${id}.md`): SyncChange { + return { id: toChangeId(id), path, kind: 'synced' }; +} + +/** Builds a change set with `nLocal` local, `nRemote` remote, and `nSynced` synced changes. */ +function changes(nLocal: number, nRemote: number, nSynced: number): SyncChange[] { + const out: SyncChange[] = []; + for (let i = 0; i < nLocal; i++) out.push(local(`local-${i}`)); + for (let i = 0; i < nRemote; i++) out.push(remote(`remote-${i}`)); + for (let i = 0; i < nSynced; i++) out.push(synced(`synced-${i}`)); + return out; +} + +describe('SourceControlSummary', () => { + describe('Case 1: actionable All excludes synced', () => { + it('reports all = local + remote (132) and synced = 36 for 115/17/36', () => { + const selection = new PushSelectionStore(); + const summary = buildSummary(changes(115, 17, 36), selection, true); + + expect(summary.counts.all).toBe(132); + expect(summary.counts.changes).toBe(115); + expect(summary.counts['remote-changes']).toBe(17); + expect(summary.counts.synced).toBe(36); + expect(summary.synced).toHaveLength(36); + }); + + it('keeps synced changes out of the actionable all bucket', () => { + const selection = new PushSelectionStore(); + const summary = buildSummary(changes(115, 17, 36), selection, true); + + expect(summary.all.every(change => change.kind !== 'synced')).toBe(true); + expect(summary.all).toHaveLength(132); + }); + }); + + describe('Case 2: synced hidden (showSynced = false)', () => { + it('renders a synced count of 0 while the raw synced bucket still holds 36', () => { + const selection = new PushSelectionStore(); + const summary = buildSummary(changes(115, 17, 36), selection, false); + + expect(summary.counts.synced).toBe(0); + expect(summary.synced).toHaveLength(36); + // render count (0) differs from the actual synced count (36) + expect(summary.counts.synced).not.toBe(summary.synced.length); + }); + + it('does not affect the actionable All count when synced is hidden', () => { + const selection = new PushSelectionStore(); + const hidden = buildSummary(changes(115, 17, 36), selection, false); + const shown = buildSummary(changes(115, 17, 36), selection, true); + + expect(hidden.counts.all).toBe(132); + expect(hidden.counts.all).toBe(shown.counts.all); + }); + }); + + describe('Case 3: All filter never surfaces a synced bucket', () => { + it('contains no synced change in the actionable all bucket', () => { + const selection = new PushSelectionStore(); + const summary = buildSummary(changes(10, 5, 20), selection, false); + + expect(summary.all.filter(change => change.kind === 'synced')).toEqual([]); + }); + + it('partitions kinds into disjoint actionable buckets plus synced', () => { + const input: SyncChange[] = [ + local('l1'), { id: toChangeId('l2'), path: 'l2.md', kind: 'local-modified' }, + { id: toChangeId('l3'), path: 'l3.md', kind: 'moved' }, + remote('r1'), { id: toChangeId('r2'), path: 'r2.md', kind: 'remote-modified' }, + { id: toChangeId('cf'), path: 'cf.md', kind: 'conflict' }, + synced('s1'), + ]; + const summary = buildSummary(input, new PushSelectionStore(), true); + + expect(summary.localChanges.map(c => c.id)).toEqual([toChangeId('l1'), toChangeId('l2'), toChangeId('l3')]); + expect(summary.remoteChanges.map(c => c.id)).toEqual([toChangeId('r1'), toChangeId('r2')]); + expect(summary.conflicts.map(c => c.id)).toEqual([toChangeId('cf')]); + expect(summary.synced.map(c => c.id)).toEqual([toChangeId('s1')]); + expect(summary.all.map(c => c.id)).toEqual([ + toChangeId('l1'), toChangeId('l2'), toChangeId('l3'), + toChangeId('r1'), toChangeId('r2'), toChangeId('cf'), + ]); + }); + }); + + describe('ready-to-push selection', () => { + it('counts only selected actionable changes, ignoring synced selections', () => { + const selection = new PushSelectionStore(); + selection.includeForPush(toChangeId('local-0')); + selection.includeForPush(toChangeId('synced-0')); + const summary = buildSummary(changes(2, 1, 2), selection, true); + + // synced-0 was selected but is not actionable, so it is excluded. + expect(summary.counts['ready-to-push']).toBe(1); + expect(summary.readyToPush.map(c => c.id)).toEqual([toChangeId('local-0')]); + }); + }); +}); \ No newline at end of file diff --git a/tests/logic/source-control/SourceControlViewModel.test.ts b/tests/logic/source-control/SourceControlViewModel.test.ts index eba3edc..cd9b1d1 100644 --- a/tests/logic/source-control/SourceControlViewModel.test.ts +++ b/tests/logic/source-control/SourceControlViewModel.test.ts @@ -62,16 +62,19 @@ describe('SourceControlViewModel', () => { expect(viewModel.getState('all').items[0]?.operationStatus).toBe('running'); }); - it('excludes synced changes from "changes" but keeps them in "synced" and "all"', () => { + it('excludes synced changes from "changes" and "all", surfacing them only via "synced" + showSynced', () => { const synced: SyncChange = { id: toChangeId('c-1'), path: 'a.md', kind: 'synced' }; const { viewModel } = buildViewModel([synced]); + // Synced is not actionable: it never appears under All or Changes. + expect(viewModel.getState('all').items).toEqual([]); expect(viewModel.getState('changes').items).toEqual([]); - expect(viewModel.getState('synced').items.map(i => i.id)).toEqual([toChangeId('c-1')]); - expect(viewModel.getState('all').items.map(i => i.id)).toEqual([toChangeId('c-1')]); + // Hidden by default: the synced filter yields nothing until the user opts in. + expect(viewModel.getState('synced').items).toEqual([]); + expect(viewModel.getState('synced', true).items.map(i => i.id)).toEqual([toChangeId('c-1')]); }); - it('counts every filter bucket regardless of the active filter', () => { + it('counts every filter bucket from the single-source summary, regardless of the active filter', () => { const changes: SyncChange[] = [ { id: toChangeId('c-1'), path: 'a.md', kind: 'local-only' }, { id: toChangeId('c-2'), path: 'b.md', kind: 'remote-only' }, @@ -80,15 +83,19 @@ describe('SourceControlViewModel', () => { ]; const { viewModel } = buildViewModel(changes); + // showSynced = false (default): synced contributes 0 to counts and is absent from All. const { counts } = viewModel.getState('all'); expect(counts).toEqual({ - all: 4, - changes: 3, + all: 3, + changes: 1, 'ready-to-push': 0, 'remote-changes': 1, conflicts: 1, - synced: 1, + synced: 0, }); + + // showSynced = true: the raw synced count (1) surfaces. + expect(viewModel.getState('all', true).counts.synced).toBe(1); }); it('keeps ChangeId stable across a rename', () => { diff --git a/tests/ui/source-control/FilterMenu.test.ts b/tests/ui/source-control/FilterMenu.test.ts index 69410f6..dc6858f 100644 --- a/tests/ui/source-control/FilterMenu.test.ts +++ b/tests/ui/source-control/FilterMenu.test.ts @@ -11,48 +11,65 @@ const zeroCounts: Record = { describe('renderFilterMenu', () => { let container: HTMLElement; - let onChange: (filter: SourceControlFilter) => void; + let callbacks: { onFilterChange: (f: SourceControlFilter) => void; onToggleShowSynced: (s: boolean) => void }; beforeEach(() => { container = createContainer(); - onChange = vi.fn(); + callbacks = { onFilterChange: vi.fn(), onToggleShowSynced: vi.fn() }; }); - it('renders all six filters in spec order', () => { - renderFilterMenu(container, 'all', zeroCounts, onChange); + it('renders the five action chips (no synced chip) when showSynced is false', () => { + renderFilterMenu(container, 'all', zeroCounts, false, callbacks); + + const filters = Array.from(container.querySelectorAll('.scv-filter-option')).map(el => el.getAttribute('data-filter')); + expect(filters).toEqual(['all', 'changes', 'ready-to-push', 'remote-changes', 'conflicts']); + }); + + it('appends the synced chip when showSynced is true', () => { + renderFilterMenu(container, 'all', { ...zeroCounts, synced: 7 }, true, callbacks); const filters = Array.from(container.querySelectorAll('.scv-filter-option')).map(el => el.getAttribute('data-filter')); expect(filters).toEqual(['all', 'changes', 'ready-to-push', 'remote-changes', 'conflicts', 'synced']); + const syncedOption = container.querySelector('.scv-filter-option[data-filter="synced"]'); + expect(syncedOption?.querySelector('.scv-filter-count')?.textContent).toBe('7'); }); - it('marks the current filter as active', () => { - renderFilterMenu(container, 'conflicts', zeroCounts, onChange); + it('marks the current filter chip as active', () => { + renderFilterMenu(container, 'conflicts', zeroCounts, false, callbacks); const active = container.querySelector('.scv-filter-option.is-active'); expect(active?.getAttribute('data-filter')).toBe('conflicts'); }); it('shows the per-filter count from the ViewModel', () => { - renderFilterMenu(container, 'all', { ...zeroCounts, conflicts: 3 }, onChange); + renderFilterMenu(container, 'all', { ...zeroCounts, conflicts: 3 }, false, callbacks); const conflictsOption = container.querySelector('.scv-filter-option[data-filter="conflicts"]'); expect(conflictsOption?.querySelector('.scv-filter-count')?.textContent).toBe('3'); }); - it('calls onChange with the clicked filter value (filter switching)', () => { - renderFilterMenu(container, 'all', zeroCounts, onChange); + it('calls onFilterChange with the clicked filter value', () => { + renderFilterMenu(container, 'all', zeroCounts, false, callbacks); (container.querySelector('.scv-filter-option[data-filter="remote-changes"]') as HTMLButtonElement).click(); - expect(onChange).toHaveBeenCalledWith('remote-changes'); + expect(callbacks.onFilterChange).toHaveBeenCalledWith('remote-changes'); + }); + + it('renders the Show synced toggle reflecting the showSynced state', () => { + renderFilterMenu(container, 'all', zeroCounts, false, callbacks); + const checkbox = container.querySelector('.scv-filter-show-synced-checkbox') as HTMLInputElement; + expect(checkbox).not.toBeNull(); + expect(checkbox.checked).toBe(false); }); - it('does not call onChange for filters that were not clicked', () => { - renderFilterMenu(container, 'all', zeroCounts, onChange); + it('calls onToggleShowSynced when the Show synced checkbox changes', () => { + renderFilterMenu(container, 'all', zeroCounts, false, callbacks); - (container.querySelector('.scv-filter-option[data-filter="synced"]') as HTMLButtonElement).click(); + const checkbox = container.querySelector('.scv-filter-show-synced-checkbox') as HTMLInputElement; + checkbox.checked = true; + checkbox.dispatchEvent(new Event('change')); - expect(onChange).toHaveBeenCalledTimes(1); - expect(onChange).toHaveBeenCalledWith('synced'); + expect(callbacks.onToggleShowSynced).toHaveBeenCalledWith(true); }); -}); +}); \ No newline at end of file diff --git a/tests/ui/source-control/SourceControlView.test.ts b/tests/ui/source-control/SourceControlView.test.ts index 5110067..493da34 100644 --- a/tests/ui/source-control/SourceControlView.test.ts +++ b/tests/ui/source-control/SourceControlView.test.ts @@ -28,7 +28,7 @@ describe('SourceControlView', () => { }); describe('filter switching', () => { - it('groups changes into their sections under the "all" filter', () => { + it('renders "All" as a single flat tree (no section breakdown) and excludes synced', () => { const { view } = buildView([ { id: toChangeId('c-1'), path: 'a.md', kind: 'local-only' }, { id: toChangeId('c-2'), path: 'b.md', kind: 'remote-only' }, @@ -37,8 +37,27 @@ describe('SourceControlView', () => { ]); view.render(container); + // No section grouping under All — every filter renders one flat tree. + expect(container.querySelectorAll('.scv-section')).toHaveLength(0); + // Active-filter header reads "ALL". + expect(container.querySelector('.scv-active-filter-title')?.textContent).toBe('ALL'); + expect(container.querySelector('.scv-active-filter-count')?.textContent).toBe('3'); + // Actionable items only: synced is absent from All. + const kinds = Array.from(container.querySelectorAll('.scv-change-item')).map(el => el.getAttribute('class')); + expect(kinds.some(c => c?.includes('scv-kind-synced'))).toBe(false); + expect(container.querySelectorAll('.scv-change-item')).toHaveLength(3); + }); + + it('does not render a SYNCED section under the All filter (status-grouping fix)', () => { + const { view } = buildView([ + { id: toChangeId('c-1'), path: 'a.md', kind: 'local-only' }, + { id: toChangeId('c-2'), path: 'd.md', kind: 'synced' }, + ]); + view.render(container); + const sectionTitles = Array.from(container.querySelectorAll('.scv-section-title')).map(el => el.textContent); - expect(sectionTitles).toEqual(['CHANGES', 'REMOTE CHANGES', 'CONFLICTS', 'SYNCED']); + expect(sectionTitles).not.toContain('SYNCED'); + expect(container.querySelectorAll('.scv-section')).toHaveLength(0); }); it('shows a flat tree (no sections) once a specific filter is selected', () => { @@ -65,6 +84,56 @@ describe('SourceControlView', () => { }); }); + describe('show synced toggle', () => { + it('hides the synced chip and synced rows by default', () => { + const { view } = buildView([ + { id: toChangeId('c-1'), path: 'a.md', kind: 'local-only' }, + { id: toChangeId('c-2'), path: 'd.md', kind: 'synced' }, + ]); + view.render(container); + + expect(container.querySelector('.scv-filter-option[data-filter="synced"]')).toBeNull(); + expect(view.getShowSynced()).toBe(false); + }); + + it('reveals the synced chip and renders synced rows when toggled on', () => { + const { view } = buildView([ + { id: toChangeId('c-1'), path: 'a.md', kind: 'local-only' }, + { id: toChangeId('c-2'), path: 'd.md', kind: 'synced' }, + ]); + view.render(container); + + const checkbox = container.querySelector('.scv-filter-show-synced-checkbox') as HTMLInputElement; + checkbox.checked = true; + checkbox.dispatchEvent(new Event('change')); + + expect(view.getShowSynced()).toBe(true); + const syncedChip = container.querySelector('.scv-filter-option[data-filter="synced"]'); + expect(syncedChip).not.toBeNull(); + expect(syncedChip?.querySelector('.scv-filter-count')?.textContent).toBe('1'); + }); + + it('falls back to All when synced is hidden while viewing the synced filter', () => { + const { view } = buildView([ + { id: toChangeId('c-1'), path: 'a.md', kind: 'local-only' }, + { id: toChangeId('c-2'), path: 'd.md', kind: 'synced' }, + ]); + view.render(container); + + // Opt in and switch to the synced filter. + const toggle = container.querySelector('.scv-filter-show-synced-checkbox') as HTMLInputElement; + toggle.checked = true; + toggle.dispatchEvent(new Event('change')); + (container.querySelector('.scv-filter-option[data-filter="synced"]') as HTMLButtonElement).click(); + expect(view.getFilter()).toBe('synced'); + + // Opt back out: filter snaps back to All. + toggle.checked = false; + toggle.dispatchEvent(new Event('change')); + expect(view.getFilter()).toBe('all'); + }); + }); + describe('selection', () => { it('moves a change into "ready to push" and updates the push button count', () => { const { view, selection } = buildView([{ id: toChangeId('c-1'), path: 'a.md', kind: 'local-only' }]); From 8f130b31cc94c756b9d69bdf81a8e75dd5a62382 Mon Sep 17 00:00:00 2001 From: ClaudiaFang Date: Sat, 22 Aug 2026 18:40:53 +0800 Subject: [PATCH 010/104] test: add source control filter coverage --- .../SourceControlFilter.test.ts | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 tests/logic/source-control/SourceControlFilter.test.ts diff --git a/tests/logic/source-control/SourceControlFilter.test.ts b/tests/logic/source-control/SourceControlFilter.test.ts new file mode 100644 index 0000000..c35e303 --- /dev/null +++ b/tests/logic/source-control/SourceControlFilter.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest'; +import { PushSelectionStore } from '../../../src/logic/source-control/PushSelectionStore'; +import { matchesFilter, type SourceControlFilter } from '../../../src/logic/source-control/SourceControlFilter'; +import { toChangeId, type SyncChange, type SyncChangeKind } from '../../../src/logic/source-control/types'; + +function change(id: string, kind: SyncChangeKind): SyncChange { + return { id: toChangeId(id), path: `${id}.md`, kind }; +} + +const FILTERS: SourceControlFilter[] = [ + 'all', + 'changes', + 'ready-to-push', + 'remote-changes', + 'conflicts', + 'synced', +]; + +describe('SourceControlFilter', () => { + it.each([ + ['local-only', ['all', 'changes']], + ['local-modified', ['all', 'changes']], + ['moved', ['all', 'changes']], + ['remote-only', ['all', 'remote-changes']], + ['remote-modified', ['all', 'remote-changes']], + ['conflict', ['all', 'conflicts']], + ['synced', ['synced']], + ] as const)('maps %s into the expected non-selection filters', (kind, expectedFilters) => { + const selection = new PushSelectionStore(); + const item = change(`change-${kind}`, kind); + + const matched = FILTERS.filter(filter => matchesFilter(item, filter, selection)); + + expect(matched).toEqual(expectedFilters); + }); + + it('puts a selected actionable change into ready-to-push without changing its status bucket', () => { + const selection = new PushSelectionStore(); + const item = change('local', 'local-modified'); + selection.includeForPush(item.id); + + expect(matchesFilter(item, 'ready-to-push', selection)).toBe(true); + expect(matchesFilter(item, 'changes', selection)).toBe(true); + expect(matchesFilter(item, 'all', selection)).toBe(true); + }); + + it('does not put an unselected actionable change into ready-to-push', () => { + const selection = new PushSelectionStore(); + const item = change('remote', 'remote-modified'); + + expect(matchesFilter(item, 'ready-to-push', selection)).toBe(false); + expect(matchesFilter(item, 'remote-changes', selection)).toBe(true); + }); + + it('never treats a selected synced change as ready-to-push or actionable', () => { + const selection = new PushSelectionStore(); + const item = change('synced', 'synced'); + selection.includeForPush(item.id); + + expect(matchesFilter(item, 'ready-to-push', selection)).toBe(false); + expect(matchesFilter(item, 'all', selection)).toBe(false); + expect(matchesFilter(item, 'synced', selection)).toBe(true); + }); + + it('keeps conflicts distinct from local and remote status filters', () => { + const selection = new PushSelectionStore(); + const item = change('conflict', 'conflict'); + + expect(matchesFilter(item, 'conflicts', selection)).toBe(true); + expect(matchesFilter(item, 'changes', selection)).toBe(false); + expect(matchesFilter(item, 'remote-changes', selection)).toBe(false); + }); + + it('preserves ready-to-push membership across a move because selection is keyed by ChangeId', () => { + const selection = new PushSelectionStore(); + const id = toChangeId('move-1'); + selection.includeForPush(id); + + const moved: SyncChange = { + id, + path: 'archive/a.md', + previousPath: 'folder/a.md', + kind: 'moved', + }; + + expect(matchesFilter(moved, 'ready-to-push', selection)).toBe(true); + expect(matchesFilter(moved, 'changes', selection)).toBe(true); + }); +}); From 8ed5df940ffdfbc07095090141559cb021f66364 Mon Sep 17 00:00:00 2001 From: tianyao Date: Sat, 22 Aug 2026 10:52:20 +0000 Subject: [PATCH 011/104] test: extract reusable source control E2E fixtures Add e2e/support/sync-manager-fixture.ts (real-provider service + verifier + TFile shim + auto-confirming plan/conflict modals, steered by a per-test conflict resolver) and e2e/support/source-control- scenarios.ts (high-level seed/modify/assert verbs + the Source Control selection stack wiring), so workflow suites read as seed -> modify -> push -> expect instead of 50 lines of setup per test. Add a non-breaking removeLocal to FakeVault for delete-local conflict scenarios. No production code touched; existing provider/SyncManager E2E unchanged. --- e2e/shim/fake-vault.ts | 5 + e2e/support/source-control-scenarios.ts | 207 ++++++++++++++++++++++++ e2e/support/sync-manager-fixture.ts | 142 ++++++++++++++++ 3 files changed, 354 insertions(+) create mode 100644 e2e/support/source-control-scenarios.ts create mode 100644 e2e/support/sync-manager-fixture.ts diff --git a/e2e/shim/fake-vault.ts b/e2e/shim/fake-vault.ts index 553b4b4..afb5753 100644 --- a/e2e/shim/fake-vault.ts +++ b/e2e/shim/fake-vault.ts @@ -40,6 +40,11 @@ export class FakeVault { this.files.set(newPath, content); } + /** Removes a local file, mirroring Obsidian's vault delete. */ + removeLocal(path: string): void { + this.files.delete(path); + } + /** Constructs a real TFile handle for a path already in this vault. */ fileAt(path: string): TFileLike { return new this.TFile(path); diff --git a/e2e/support/source-control-scenarios.ts b/e2e/support/source-control-scenarios.ts new file mode 100644 index 0000000..d2e3d0e --- /dev/null +++ b/e2e/support/source-control-scenarios.ts @@ -0,0 +1,207 @@ +import { expect } from 'vitest'; +import type { TFile } from 'obsidian'; +import type { GitServiceInterface } from '../../src/services/git-service-interface'; +import type { SyncManager } from '../../src/logic/sync-manager'; +import type { BatchPushConflict, ConflictResolution, PushResults } from '../../src/logic/sync/types'; +import type { GitLabFilesPushSettings } from '../../src/settings'; +import type { FakeVault, TFileLike } from '../shim/fake-vault'; +import type { SyncManagerFixture } from './sync-manager-fixture'; +import type { GitVerifier as GitVerifierType } from '../verifier-runtime-types'; +import { ChangeRepository } from '../../src/logic/source-control/ChangeRepository'; +import { OperationState } from '../../src/logic/source-control/OperationState'; +import { PushSelectionStore } from '../../src/logic/source-control/PushSelectionStore'; +import { SourceControlActionService } from '../../src/logic/source-control/SourceControlActionService'; +import { BoundarySyncWorkspace } from '../../src/logic/sync/SyncWorkspace'; +import { toChangeId, type SyncChange } from '../../src/logic/source-control/types'; +import type { SyncStatusRefreshResult } from '../../src/logic/sync/SyncStatusRefreshService'; +import type { RemoteDeleteResult } from '../../src/logic/sync/RemoteDeleteExecutor'; +import type { FileDiff } from '../../src/logic/sync/types'; +import type { GitTreeEntry } from '../../src/services/git-service-interface'; + +/** + * High-level scenario wrapper around a {@link SyncManagerFixture}: owns one + * FakeVault + settings + real SyncManager for a test, and exposes the + * seed/modify/assert verbs the source-control-flow suites use, so a test reads + * as `seed → modify local → modify remote → push → expect` instead of 50 lines + * of setup. Remote assertions always go through the fixture's independent + * git-CLI verifier, never the service under test. + * + * One scenario per test; paths are supplied by the caller (via + * `fixture.path`) so two scenarios can share a remote path when a test needs a + * fresh manager against pre-seeded remote state. + */ +export class SourceControlScenario { + readonly vault: FakeVault; + readonly settings: GitLabFilesPushSettings; + readonly manager: SyncManager; + private readonly service: GitServiceInterface; + private readonly verifier: GitVerifierType; + private readonly branch: string; + + constructor(fixture: SyncManagerFixture) { + this.vault = fixture.createVault(); + this.settings = fixture.makeSettings(); + this.manager = fixture.newManager(this.vault, this.settings); + this.service = fixture.service; + this.verifier = fixture.verifier; + this.branch = fixture.branch; + } + + // --- local vault ops ------------------------------------------------- + + writeLocal(path: string, content: string | ArrayBuffer): void { + this.vault.writeLocal(path, content); + } + + deleteLocal(path: string): void { + this.vault.removeLocal(path); + } + + renameLocal(oldPath: string, newPath: string): void { + this.vault.renameLocal(oldPath, newPath); + } + + /** Real TFile handle for a path in this vault (needed so push rename-detection runs). */ + tfile(path: string): TFileLike { + return this.vault.fileAt(path); + } + + localExists(path: string): boolean { + return this.vault.has(path); + } + + async readLocal(path: string): Promise { + return this.vault.adapter.read(path); + } + + // --- remote ops (via the real production service) -------------------- + + /** Seeds the remote directly, bypassing SyncManager — no local file, no metadata. */ + async seedRemote(path: string, content: string | ArrayBuffer): Promise { + await this.service.pushFile(path, content, this.branch, 'e2e: seed remote'); + } + + /** Overwrites the remote path with new content, reading the current sha first (like another client pushing). */ + async modifyRemote(path: string, content: string | ArrayBuffer): Promise { + const current = await this.verifier.getFile(path, this.branch); + await this.service.pushFile(path, content, this.branch, 'e2e: modify remote', current?.sha); + } + + async deleteRemoteFile(path: string): Promise { + await this.service.deleteFile(path, this.branch, 'e2e: delete remote'); + } + + // --- baseline (push through the manager to establish synced metadata) --- + + /** Writes locally and pushes via the manager, establishing a synced baseline (local == remote + metadata). */ + async baseline(path: string, content: string | ArrayBuffer): Promise { + this.writeLocal(path, content); + return this.manager.pushFiles([path]); + } + + // --- sync actions ---------------------------------------------------- + + /** Pushes via the real manager. Accepts TFile handles (for rename detection) or plain paths. */ + async push(files: (TFileLike | string)[]): Promise { + return this.manager.pushFiles(files as unknown as (TFile | string)[]); + } + + async pullFile(path: string): Promise { + await this.manager.pullFile(path); + } + + // --- independent remote assertions (via the git-CLI verifier) -------- + + async remoteContent(path: string): Promise<{ content: string; sha: string } | null> { + return this.verifier.getFile(path, this.branch); + } + + async expectRemoteContent(path: string, expected: string): Promise { + const remote = await this.verifier.getFile(path, this.branch); + expect(remote?.content, `remote content for ${path}`).toBe(expected); + } + + async expectRemoteMissing(path: string): Promise { + expect(await this.verifier.fileMissing(path, this.branch), `expected ${path} missing on remote`).toBe(true); + } + + async expectRemoteExists(path: string): Promise { + expect(await this.verifier.fileMissing(path, this.branch), `expected ${path} present on remote`).toBe(false); + } + + /** Current branch tip sha. */ + async head(): Promise { + const [tip] = await this.verifier.listCommitShas(this.branch, 1); + return tip!; + } + + /** Asserts exactly one new commit landed since `headBefore` (the new commit's parent is `headBefore`). */ + async expectSingleCommitSince(headBefore: string): Promise { + const [headAfter, headAfterParent] = await this.verifier.listCommitShas(this.branch, 2); + expect(headAfter, 'expected a new commit on the branch').not.toBe(headBefore); + expect(headAfterParent, 'expected exactly one new commit since baseline').toBe(headBefore); + } + + /** Asserts no new commit landed since `headBefore`. */ + async expectNoCommitSince(headBefore: string): Promise { + expect(await this.head(), 'expected no new commit').toBe(headBefore); + } + + async commitMessage(sha: string): Promise { + return this.verifier.getCommitMessage(sha); + } + + // --- metadata -------------------------------------------------------- + + metadata(path: string) { + return this.settings.syncMetadata[path]; + } + + metadataSha(path: string): string | undefined { + return this.settings.syncMetadata[path]?.lastSyncedSha; + } + + // --- Source Control selection stack (Phase 6) ----------------------- + + /** + * Wires the real Source Control selection layer (ChangeRepository + + * PushSelectionStore + OperationState + SourceControlActionService) on top + * of this scenario's real SyncManager, via the thin BoundarySyncWorkspace. + * `push`/`pull`/`deleteRemote` go through the real manager/provider; the + * selection filter (ChangeId -> path -> workspace call) is the real + * production code under test. + */ + selectionStack(changes: SyncChange[]): SelectionStack { + const repository = new ChangeRepository(); + repository.replace(changes); + const selection = new PushSelectionStore(); + const operations = new OperationState(); + const workspace = new BoundarySyncWorkspace( + () => this.manager, + { + refresh: (): Promise => Promise.resolve({ + localCount: 0, remoteCount: 0, remoteEntries: [] as GitTreeEntry[], + }), + deleteRemote: (): Promise => Promise.resolve({ deletedPaths: [], errors: [] }), + getDiff: (): Promise => Promise.resolve({ path: '', kind: 'text' } as FileDiff), + }, + ); + const actionService = new SourceControlActionService(repository, operations, workspace); + return { repository, selection, operations, actionService, workspace }; + } +} + +export interface SelectionStack { + readonly repository: ChangeRepository; + readonly selection: PushSelectionStore; + readonly operations: OperationState; + readonly actionService: SourceControlActionService; + readonly workspace: BoundarySyncWorkspace; +} + +/** Builds a SyncChange with a path-derived ChangeId (mirrors FileStatusAdapter). */ +export function change(path: string, kind: SyncChange['kind'], previousPath?: string): SyncChange { + return { id: toChangeId(path), path, kind, previousPath }; +} + +export type { ConflictResolution, BatchPushConflict }; \ No newline at end of file diff --git a/e2e/support/sync-manager-fixture.ts b/e2e/support/sync-manager-fixture.ts new file mode 100644 index 0000000..7cb3ca5 --- /dev/null +++ b/e2e/support/sync-manager-fixture.ts @@ -0,0 +1,142 @@ +import { vi } from 'vitest'; +import { SyncManager } from '../../src/logic/sync-manager'; +import type { BatchPushConflict, ConflictResolution, PushResults } from '../../src/logic/sync/types'; +import { SyncPlanModal, type SyncPlanDirection } from '../../src/ui/SyncPlanModal'; +import { BatchConflictResolutionModal } from '../../src/ui/BatchConflictResolutionModal'; +import { ObsidianSyncInteraction } from '../../src/ui/ObsidianSyncInteraction'; +// `import type` deliberately: settings.ts re-exports the settings-tab UI +// (GitLabSyncSettingTab -> FolderSuggest -> AbstractInputSuggest) which pulls +// in far more of `obsidian` than this suite's generated shim provides. A +// type-only import is erased entirely, so none of that module ever loads. +import type { GitLabFilesPushSettings } from '../../src/settings'; +import { FakeVault, fakeApp, type TFileCtor } from '../shim/fake-vault'; +import { currentProvider, contextFor, runtimeDir } from '../config/env'; +import type { GitVerifier as GitVerifierType } from '../verifier-runtime-types'; +import type { GitServiceInterface } from '../../src/services/git-service-interface'; + +/** + * Reusable real-provider E2E fixture for SyncManager workflows. Owns the + * once-per-suite wiring the old `e2e/suites/sync-manager.e2e.test.ts` kept in + * its `beforeAll`: resolving the real production provider service + isolated + * branch, loading the generated git-CLI verifier + TFile shim, and installing + * plan-review/conflict modals that auto-confirm (so a push can proceed without + * a human clicking through). Per-test conflict outcomes are steered through + * {@link setConflictResolver}. + * + * Only the Obsidian filesystem boundary is faked (e2e/shim/fake-vault.ts); + * everything else — SyncManager, PushCoordinator, the provider service — is + * the real production code path against a real Git server. + */ +export interface SyncManagerFixture { + /** Real production provider service for the selected `E2E_PROVIDER`. */ + readonly service: GitServiceInterface; + /** Isolated branch `scripts/e2e-harness.sh provision` created for this run. */ + readonly branch: string; + /** Independent git-CLI verifier (generated at runtime, never committed). */ + readonly verifier: GitVerifierType; + /** The exact TFile class the vitest-runtime `obsidian` alias resolves to. */ + readonly TFile: TFileCtor; + /** Per-suite run id, so every test's remote paths are namespaced apart. */ + readonly runId: string; + /** Namespaced remote path: `path('note.md') -> e2e-sc-/note.md`. */ + path(name: string): string; + /** Fresh settings object pointing at the isolated branch, empty metadata. */ + makeSettings(branch?: string): GitLabFilesPushSettings; + /** A fresh in-memory vault (the only faked boundary). */ + createVault(): FakeVault; + /** A real SyncManager wired to `vault` + `settings` + the real service. */ + newManager(vault: FakeVault, settings: GitLabFilesPushSettings): SyncManager; + /** Steers how the auto-confirming conflict modal resolves each conflict. */ + setConflictResolver(resolver: (conflict: BatchPushConflict) => ConflictResolution): void; +} + +export async function createSyncManagerFixture(): Promise { + const provider = currentProvider(); + const ctx = contextFor(provider); + const service = ctx.service; + const branch = ctx.branch; + + const dir = runtimeDir(); + const { GitVerifier } = await import(/* @vite-ignore */ `${dir}/verifier/git-verifier.ts`) as { GitVerifier: new () => GitVerifierType }; + const obsidianShim = await import(/* @vite-ignore */ `${dir}/obsidian-request-url.ts`) as { TFile: TFileCtor }; + const verifier = new GitVerifier(); + const TFile = obsidianShim.TFile; + + let conflictResolver: (conflict: BatchPushConflict) => ConflictResolution = () => 'skip'; + + // Auto-confirm the plan-review modal (production shows it before every + // push/pull). Same pattern as tests/logic/sync-manager-batch.test.ts. + vi.mocked(SyncPlanModal).mockImplementation(function ( + this: SyncPlanModal, _app: unknown, _plan: unknown, _direction: SyncPlanDirection, onConfirm: () => void + ) { + onConfirm(); + return this; + } as never); + + // Every push-side content conflict goes through BatchConflictResolutionModal + // (even a single-file batch). Auto-resolve using the current resolver. + vi.mocked(BatchConflictResolutionModal).mockImplementation(function ( + this: BatchConflictResolutionModal, + _app: unknown, + _gitService: unknown, + conflicts: BatchPushConflict[], + _totalFiles: number, + _safeCount: number, + onResolve: () => void, + _onCancel: () => void, + ) { + for (const conflict of conflicts) conflict.resolution = conflictResolver(conflict); + onResolve(); + return this; + } as never); + + const runId = Math.random().toString(36).slice(2, 10); + + function path(name: string): string { + return `e2e-sc-${runId}/${name}`; + } + + function makeSettings(branchOverride?: string): GitLabFilesPushSettings { + return { + serviceType: 'gitea', + gitlabToken: '', gitlabBaseUrl: '', projectId: '', + githubToken: '', githubOwner: '', githubRepo: '', + giteaToken: '', giteaBaseUrl: '', giteaOwner: '', giteaRepo: '', + branch: branchOverride ?? branch, + syncMetadata: {}, + rootPath: '', + vaultFolder: '', + symlinkHandling: 'skip', + ignorePatterns: '', + lastSeenVersion: '', + bannerDismissedVersion: '', + language: 'system', + autoRefreshOnStartup: true, + }; + } + + function createVault(): FakeVault { + return new FakeVault(TFile); + } + + function newManager(vault: FakeVault, settings: GitLabFilesPushSettings): SyncManager { + const app = fakeApp(vault); + return new SyncManager(app, service, settings, undefined, () => false, undefined, new ObsidianSyncInteraction(app)); + } + + return { + service, + branch, + verifier, + TFile, + runId, + path, + makeSettings, + createVault, + newManager, + setConflictResolver: (resolver) => { conflictResolver = resolver; }, + }; +} + +export { describePushResult } from './push-result-diagnostic'; +export type { PushResults }; \ No newline at end of file From df9dea96e6b2293a62e64e65775a71cd531deb88 Mon Sep 17 00:00:00 2001 From: tianyao Date: Sat, 22 Aug 2026 10:54:31 +0000 Subject: [PATCH 012/104] test: cover complex rename and move workflows Add e2e/suites/source-control-flows.e2e.test.ts and wire it into scripts/run-e2e.sh. Phase 2 covers: rename+modify (one commit, metadata moved to the new path, old path metadata cleared), multi-rename+modify batch (two moves in one commit), and the Extended nested-directory move and A->B->C rename-chain collapse (GitHub only, since they exercise SyncManager rename tracking rather than provider APIs). --- e2e/suites/source-control-flows.e2e.test.ts | 140 ++++++++++++++++++++ scripts/run-e2e.sh | 15 ++- 2 files changed, 150 insertions(+), 5 deletions(-) create mode 100644 e2e/suites/source-control-flows.e2e.test.ts diff --git a/e2e/suites/source-control-flows.e2e.test.ts b/e2e/suites/source-control-flows.e2e.test.ts new file mode 100644 index 0000000..a5821b2 --- /dev/null +++ b/e2e/suites/source-control-flows.e2e.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect, beforeAll, vi } from 'vitest'; +import { createSyncManagerFixture, describePushResult, type SyncManagerFixture } from '../support/sync-manager-fixture'; +import { SourceControlScenario } from '../support/source-control-scenarios'; +import { timeouts } from '../config/env'; + +// Auto-confirm the plan-review + conflict modals so a push can proceed +// without a human. vi.mock is hoisted above the fixture import, so the fixture +// receives the mocked modules and installs their mockImplementation. Pull-side +// SyncConflictModal stays the bare automock default (does nothing, matching +// production: pullFile returns before the conflict modal resolves). +vi.mock('../../src/ui/SyncPlanModal'); +vi.mock('../../src/ui/SyncConflictModal'); +vi.mock('../../src/ui/BatchConflictResolutionModal'); + +// Provider matrix: Core scenarios run on every provider; Extended scenarios +// (rename chains, unicode, batch-scale, etc.) exercise SyncManager/model +// behavior that's provider-agnostic, so they run on GitHub only to keep +// real-API CI fast and stable. +const isGitHub = process.env.E2E_PROVIDER === 'github'; + +describe('Source Control Flows E2E', () => { + let fixture: SyncManagerFixture; + + beforeAll(async () => { + fixture = await createSyncManagerFixture(); + }, timeouts.containerReadyMs + 30_000); + + const path = (name: string): string => fixture.path(name); + const scenario = (): SourceControlScenario => new SourceControlScenario(fixture); + + // ------------------------------------------------------------------ + // Phase 2 — Rename / Move workflows + // ------------------------------------------------------------------ + describe('rename and move workflows', () => { + it('renames and modifies a file in one commit, moving metadata to the new path', async () => { + const s = scenario(); + const oldP = path('rename-modify/a.md'); + const newP = path('rename-modify/archive/a.md'); + await s.baseline(oldP, 'v1'); + expect(s.metadataSha(oldP), 'baseline metadata at old path').toBeTruthy(); + + s.renameLocal(oldP, newP); + s.writeLocal(newP, 'v2'); + await s.manager.trackRename(newP, oldP); + + const headBefore = await s.head(); + const result = await s.push([s.tfile(newP)]); + expect(result.success, describePushResult(result)).toBe(1); + expect(result.failed, describePushResult(result)).toBe(0); + + await s.expectRemoteMissing(oldP); + await s.expectRemoteContent(newP, 'v2'); + await s.expectSingleCommitSince(headBefore); + expect(s.metadataSha(newP), 'metadata moved to new path').toBeTruthy(); + expect(s.metadata(oldP), 'old path metadata removed').toBeUndefined(); + }); + + it('renames and modifies multiple files in one batch push (one commit)', async () => { + const s = scenario(); + const oldA = path('multi-rename/folder/a.md'); + const oldB = path('multi-rename/folder/b.md'); + const newA = path('multi-rename/archive/a.md'); + const newB = path('multi-rename/archive/b.md'); + await s.baseline(oldA, 'a-v1'); + await s.baseline(oldB, 'b-v1'); + + s.renameLocal(oldA, newA); + s.writeLocal(newA, 'a-v2'); + s.renameLocal(oldB, newB); + s.writeLocal(newB, 'b-v2'); + await s.manager.trackRename(newA, oldA); + await s.manager.trackRename(newB, oldB); + + const headBefore = await s.head(); + const result = await s.push([s.tfile(newA), s.tfile(newB)]); + expect(result.success, describePushResult(result)).toBe(2); + expect(result.failed, describePushResult(result)).toBe(0); + + await s.expectRemoteMissing(oldA); + await s.expectRemoteMissing(oldB); + await s.expectRemoteContent(newA, 'a-v2'); + await s.expectRemoteContent(newB, 'b-v2'); + await s.expectSingleCommitSince(headBefore); + }); + + // Extended: nested move + rename chain (SyncManager/model behavior, + // provider-agnostic) — GitHub only. + it.skipIf(!isGitHub)('moves files across nested directories in one commit', async () => { + const s = scenario(); + const oldFlat = path('nested-move/folder/a.md'); + const oldNested = path('nested-move/folder/nested/b.md'); + const newFlat = path('nested-move/archive/a.md'); + const newNested = path('nested-move/archive/nested/b.md'); + await s.baseline(oldFlat, 'flat'); + await s.baseline(oldNested, 'nested'); + + s.renameLocal(oldFlat, newFlat); + s.renameLocal(oldNested, newNested); + await s.manager.trackRename(newFlat, oldFlat); + await s.manager.trackRename(newNested, oldNested); + + const headBefore = await s.head(); + const result = await s.push([s.tfile(newFlat), s.tfile(newNested)]); + expect(result.success, describePushResult(result)).toBe(2); + expect(result.failed, describePushResult(result)).toBe(0); + + await s.expectRemoteMissing(oldFlat); + await s.expectRemoteMissing(oldNested); + await s.expectRemoteContent(newFlat, 'flat'); + await s.expectRemoteContent(newNested, 'nested'); + await s.expectSingleCommitSince(headBefore); + }); + + it.skipIf(!isGitHub)('collapses a rename chain (A->B->C) into a single move of the original path', async () => { + const s = scenario(); + const a = path('rename-chain/a.md'); + const b = path('rename-chain/b.md'); + const c = path('rename-chain/c.md'); + await s.baseline(a, 'chain'); + + s.renameLocal(a, b); + await s.manager.trackRename(b, a); + s.renameLocal(b, c); + await s.manager.trackRename(c, b); + + const headBefore = await s.head(); + const result = await s.push([s.tfile(c)]); + expect(result.success, describePushResult(result)).toBe(1); + expect(result.failed, describePushResult(result)).toBe(0); + + await s.expectRemoteMissing(a); + await s.expectRemoteMissing(b); + await s.expectRemoteContent(c, 'chain'); + await s.expectSingleCommitSince(headBefore); + expect(s.metadata(a), 'no stale metadata at intermediate path A').toBeUndefined(); + expect(s.metadata(b), 'no stale metadata at intermediate path B').toBeUndefined(); + expect(s.metadataSha(c), 'metadata landed at final path').toBeTruthy(); + }); + }); +}); \ No newline at end of file diff --git a/scripts/run-e2e.sh b/scripts/run-e2e.sh index 247502c..7629f83 100755 --- a/scripts/run-e2e.sh +++ b/scripts/run-e2e.sh @@ -36,8 +36,13 @@ scripts/e2e-harness.sh provision set -a; source "$E2E_WORKDIR/e2e.env"; [ -f "$E2E_WORKDIR/e2e.secrets.env" ] && source "$E2E_WORKDIR/e2e.secrets.env"; set +a scripts/e2e-harness.sh seed -# Only this provider's contract suite + the shared SyncManager suite -- -# vitest.e2e.config.ts's `include` matches every e2e/suites/*.e2e.test.ts -# file, and the other two providers' suites would otherwise also try to run -# (and fail on missing credentials) regardless of --provider. -npx vitest run -c vitest.e2e.config.ts "e2e/suites/${provider}.e2e.test.ts" e2e/suites/sync-manager.e2e.test.ts +# Only this provider's contract suite + the shared SyncManager/source-control +# workflow suites -- vitest.e2e.config.ts's `include` matches every +# e2e/suites/*.e2e.test.ts file, and the other two providers' suites would +# otherwise also try to run (and fail on missing credentials) regardless of +# --provider. source-control-flows gates its Extended scenarios to GitHub only +# (and 1000-file stress to E2E_STRESS=1) in-file. +npx vitest run -c vitest.e2e.config.ts \ + "e2e/suites/${provider}.e2e.test.ts" \ + e2e/suites/sync-manager.e2e.test.ts \ + e2e/suites/source-control-flows.e2e.test.ts From 726c54aafdc9fd1d825af799e8131019be92d4ef Mon Sep 17 00:00:00 2001 From: tianyao Date: Sat, 22 Aug 2026 10:55:32 +0000 Subject: [PATCH 013/104] test: expand conflict state transition coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 locks the current SyncPlanner conflict contract: modify/modify with a stored baseline IS a conflict (asserted with strengthened side-effect checks — both sides + baseline + HEAD untouched on skip); delete/modify (unrelated push leaves the modified remote intact, metadata not advanced), modify/delete (blind re-create from local), rename with a remotely-edited source (move drops the old-path edit), and no-baseline add/add (local overwrites remote) are NOT conflicts today and are locked as such, so a future change to surface them as conflicts is an intentional, test-updating decision. No production behavior changed. --- e2e/suites/source-control-flows.e2e.test.ts | 129 ++++++++++++++++++++ 1 file changed, 129 insertions(+) diff --git a/e2e/suites/source-control-flows.e2e.test.ts b/e2e/suites/source-control-flows.e2e.test.ts index a5821b2..f1fa5e5 100644 --- a/e2e/suites/source-control-flows.e2e.test.ts +++ b/e2e/suites/source-control-flows.e2e.test.ts @@ -137,4 +137,133 @@ describe('Source Control Flows E2E', () => { expect(s.metadataSha(c), 'metadata landed at final path').toBeTruthy(); }); }); + + // ------------------------------------------------------------------ + // Phase 3 — Conflict state transitions + // + // The current SyncPlanner only surfaces a conflict on a push when both + // sides diverged from a *stored* baseline (modify/modify with a base + // sha). No-baseline add/add, delete-side divergence, and a move whose + // *source* was remotely edited are NOT conflicts today — they resolve to + // local-wins / blind-recreate / move-drops-old-edit. These tests lock + // that current contract (per the agreed scope: no production behavior + // changed to satisfy tests) so a future change to surface those as + // conflicts is an intentional, test-updating decision. The one real + // conflict (modify/modify with baseline) is asserted as a conflict. + // ------------------------------------------------------------------ + describe('conflict state transitions', () => { + it('detects a modify/modify conflict and leaves both sides + baseline untouched on skip', async () => { + const s = scenario(); + const p = path('conflict-modify-modify/a.md'); + await s.baseline(p, 'baseline'); + const baselineMeta = s.metadata(p); + + s.writeLocal(p, 'local edit'); + await s.modifyRemote(p, 'remote edit'); + + fixture.setConflictResolver(() => 'skip'); + const headBefore = await s.head(); + const result = await s.push([p]); + + expect(result.skippedConflicts, describePushResult(result)).toBeGreaterThanOrEqual(1); + expect(result.success, describePushResult(result)).toBe(0); + expect(result.failed, describePushResult(result)).toBe(0); + await s.expectRemoteContent(p, 'remote edit'); + expect(await s.readLocal(p)).toBe('local edit'); + expect(s.metadata(p)).toEqual(baselineMeta); + await s.expectNoCommitSince(headBefore); + }); + + it('does not auto-delete a remotely-modified file when its local copy is gone (current push contract)', async () => { + const s = scenario(); + const gone = path('conflict-delete-modify/a.md'); + const other = path('conflict-delete-modify/b.md'); + await s.baseline(gone, 'baseline'); + await s.baseline(other, 'other-baseline'); + const baselineSha = s.metadataSha(gone); + + s.deleteLocal(gone); + await s.modifyRemote(gone, 'remote edit'); + s.writeLocal(other, 'other-modified'); + + const headBefore = await s.head(); + const result = await s.push([other]); + expect(result.success, describePushResult(result)).toBe(1); + expect(result.failed, describePushResult(result)).toBe(0); + + // pushFiles never propagates a local deletion, so the + // remotely-modified file survives and its baseline metadata is + // not advanced. No conflict is surfaced for delete/modify today. + await s.expectRemoteContent(gone, 'remote edit'); + expect(s.metadataSha(gone), 'metadata not falsely advanced').toBe(baselineSha); + await s.expectRemoteContent(other, 'other-modified'); + await s.expectSingleCommitSince(headBefore); + }); + + it('re-creates a remotely-deleted file from a modified local copy (current push contract)', async () => { + const s = scenario(); + const p = path('conflict-modify-delete/a.md'); + await s.baseline(p, 'baseline'); + const baselineSha = s.metadataSha(p); + + s.writeLocal(p, 'local edit'); + await s.deleteRemoteFile(p); + + const headBefore = await s.head(); + const result = await s.push([p]); + expect(result.success, describePushResult(result)).toBe(1); + expect(result.failed, describePushResult(result)).toBe(0); + + // A remote deletion + local modification classifies as + // 'local-only' (push-create): the remote is blindly re-created + // with local content and metadata advances. No conflict today. + await s.expectRemoteContent(p, 'local edit'); + await s.expectSingleCommitSince(headBefore); + expect(s.metadataSha(p), 'metadata advanced to new sha').not.toBe(baselineSha); + expect(s.metadataSha(p)).toBeTruthy(); + }); + + it.skipIf(!isGitHub)('a move whose source was remotely edited proceeds, dropping the old-path edit (current contract)', async () => { + const s = scenario(); + const oldP = path('conflict-rename-modify/a.md'); + const newP = path('conflict-rename-modify/archive/a.md'); + await s.baseline(oldP, 'v1'); + + s.renameLocal(oldP, newP); + await s.manager.trackRename(newP, oldP); + await s.modifyRemote(oldP, 'remote edit on old path'); + + const headBefore = await s.head(); + const result = await s.push([s.tfile(newP)]); + expect(result.success, describePushResult(result)).toBe(1); + expect(result.failed, describePushResult(result)).toBe(0); + + // planMove only flags a conflict when the DESTINATION is occupied. + // A diverged source (old path remotely edited) is a plain move, so + // the old-path edit is dropped (old path deleted, new path created + // with local content). Locked here as the current contract. + await s.expectRemoteMissing(oldP); + await s.expectRemoteContent(newP, 'v1'); + await s.expectSingleCommitSince(headBefore); + }); + + it.skipIf(!isGitHub)('overwrites a remotely-created file with local content on a no-baseline add/add (current contract)', async () => { + const s = scenario(); + const p = path('conflict-add-add/a.md'); + await s.seedRemote(p, 'remote'); + s.writeLocal(p, 'local'); + + const headBefore = await s.head(); + const result = await s.push([p]); + expect(result.success, describePushResult(result)).toBe(1); + expect(result.failed, describePushResult(result)).toBe(0); + expect(result.skippedConflicts, describePushResult(result)).toBe(0); + + // A no-baseline two-sided diff downgrades to 'local-modified' on + // push (classifyForOperation), so local overwrites remote with no + // conflict surfaced. Locked here as the current contract. + await s.expectRemoteContent(p, 'local'); + await s.expectSingleCommitSince(headBefore); + }); + }); }); \ No newline at end of file From e4a3ff09ec07d4112fc828a7bd20bb8963c9da99 Mon Sep 17 00:00:00 2001 From: tianyao Date: Sat, 22 Aug 2026 10:56:46 +0000 Subject: [PATCH 014/104] test: cover conflict resolution workflows Phase 4 verifies the end-to-end resolution paths: keep-local pushes local content over the remote in one commit and advances metadata to the new sha; keep-remote pulls the remote blob into the vault (no remote mutation, no new commit) and updates metadata; skip is retained as a regression lock confirming local, remote, baseline metadata, and HEAD are all untouched. --- e2e/suites/source-control-flows.e2e.test.ts | 71 +++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/e2e/suites/source-control-flows.e2e.test.ts b/e2e/suites/source-control-flows.e2e.test.ts index f1fa5e5..a623cec 100644 --- a/e2e/suites/source-control-flows.e2e.test.ts +++ b/e2e/suites/source-control-flows.e2e.test.ts @@ -266,4 +266,75 @@ describe('Source Control Flows E2E', () => { await s.expectSingleCommitSince(headBefore); }); }); + + // ------------------------------------------------------------------ + // Phase 4 — Conflict resolution workflows + // ------------------------------------------------------------------ + describe('conflict resolution workflows', () => { + it('resolves a modify/modify conflict with keep-local: remote becomes local, metadata advances', async () => { + const s = scenario(); + const p = path('resolve-keep-local/a.md'); + await s.baseline(p, 'baseline'); + s.writeLocal(p, 'local edit'); + await s.modifyRemote(p, 'remote edit'); + + fixture.setConflictResolver(() => 'keep-local'); + const headBefore = await s.head(); + const result = await s.push([p]); + expect(result.success, describePushResult(result)).toBe(1); + expect(result.resolvedConflicts, describePushResult(result)).toBe(1); + expect(result.skippedConflicts, describePushResult(result)).toBe(0); + expect(result.failed, describePushResult(result)).toBe(0); + + await s.expectRemoteContent(p, 'local edit'); + expect(await s.readLocal(p)).toBe('local edit'); + const remote = await s.remoteContent(p); + expect(s.metadataSha(p), 'metadata = new remote sha').toBe(remote?.sha); + await s.expectSingleCommitSince(headBefore); + }); + + it('resolves a modify/modify conflict with keep-remote: local becomes remote, no remote mutation', async () => { + const s = scenario(); + const p = path('resolve-keep-remote/a.md'); + await s.baseline(p, 'baseline'); + s.writeLocal(p, 'local edit'); + await s.modifyRemote(p, 'remote edit'); + + fixture.setConflictResolver(() => 'keep-remote'); + const headBefore = await s.head(); + const result = await s.push([p]); + expect(result.failed, describePushResult(result)).toBe(0); + expect(result.resolvedConflicts, describePushResult(result)).toBe(1); + expect(result.skippedConflicts, describePushResult(result)).toBe(0); + + await s.expectRemoteContent(p, 'remote edit'); + expect(await s.readLocal(p)).toBe('remote edit'); + const remote = await s.remoteContent(p); + expect(s.metadataSha(p), 'metadata = remote sha').toBe(remote?.sha); + // keep-remote is a pull, not a push — no new commit on the branch. + await s.expectNoCommitSince(headBefore); + }); + + it('regression: skip leaves local, remote, baseline metadata, and HEAD all untouched', async () => { + const s = scenario(); + const p = path('resolve-skip/a.md'); + await s.baseline(p, 'baseline'); + const baselineMeta = s.metadata(p); + + s.writeLocal(p, 'local edit'); + await s.modifyRemote(p, 'remote edit'); + + fixture.setConflictResolver(() => 'skip'); + const headBefore = await s.head(); + const result = await s.push([p]); + + expect(result.skippedConflicts, describePushResult(result)).toBeGreaterThanOrEqual(1); + expect(result.success, describePushResult(result)).toBe(0); + expect(result.failed, describePushResult(result)).toBe(0); + await s.expectRemoteContent(p, 'remote edit'); + expect(await s.readLocal(p)).toBe('local edit'); + expect(s.metadata(p)).toEqual(baselineMeta); + await s.expectNoCommitSince(headBefore); + }); + }); }); \ No newline at end of file From b8f50b244e89ea79326bfa12ce9bcb2c23758903 Mon Sep 17 00:00:00 2001 From: tianyao Date: Sat, 22 Aug 2026 10:57:55 +0000 Subject: [PATCH 015/104] test: cover mixed batch operations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 5: create+modify+rename in one commit (GitHub only), a full create+modify+pure-rename+rename-with-modify lifecycle batch in one commit (all providers), and a safe+conflict batch that locks the current non-atomic contract — safe files land in one commit while the conflict is skipped and the remote stays on the remote side. --- e2e/suites/source-control-flows.e2e.test.ts | 94 +++++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/e2e/suites/source-control-flows.e2e.test.ts b/e2e/suites/source-control-flows.e2e.test.ts index a623cec..ab3b898 100644 --- a/e2e/suites/source-control-flows.e2e.test.ts +++ b/e2e/suites/source-control-flows.e2e.test.ts @@ -337,4 +337,98 @@ describe('Source Control Flows E2E', () => { await s.expectNoCommitSince(headBefore); }); }); + + // ------------------------------------------------------------------ + // Phase 5 — Mixed batch operations + // ------------------------------------------------------------------ + describe('mixed batch operations', () => { + it.skipIf(!isGitHub)('pushes a create + modify + rename in one commit', async () => { + const s = scenario(); + const create = path('mixed-cmr/create.md'); + const modify = path('mixed-cmr/modify.md'); + const oldMove = path('mixed-cmr/old.md'); + const newMove = path('mixed-cmr/moved.md'); + await s.baseline(modify, 'm-v1'); + await s.baseline(oldMove, 'move-me'); + + s.writeLocal(create, 'create content'); + s.writeLocal(modify, 'm-v2'); + s.renameLocal(oldMove, newMove); + await s.manager.trackRename(newMove, oldMove); + + const headBefore = await s.head(); + const result = await s.push([create, modify, s.tfile(newMove)]); + expect(result.success, describePushResult(result)).toBe(3); + expect(result.failed, describePushResult(result)).toBe(0); + + await s.expectRemoteContent(create, 'create content'); + await s.expectRemoteContent(modify, 'm-v2'); + await s.expectRemoteMissing(oldMove); + await s.expectRemoteContent(newMove, 'move-me'); + await s.expectSingleCommitSince(headBefore); + }); + + it('pushes create + modify + pure rename + rename-with-modify in one commit', async () => { + const s = scenario(); + const create = path('mixed-lifecycle/create.md'); + const modify = path('mixed-lifecycle/modify.md'); + const renameOld = path('mixed-lifecycle/rename-old.md'); + const renameNew = path('mixed-lifecycle/rename-new.md'); + const moveOld = path('mixed-lifecycle/move-old.md'); + const moveNew = path('mixed-lifecycle/move-new.md'); + await s.baseline(modify, 'm-v1'); + await s.baseline(renameOld, 'r-v1'); + await s.baseline(moveOld, 'mv-v1'); + + s.writeLocal(create, 'create content'); + s.writeLocal(modify, 'm-v2'); + s.renameLocal(renameOld, renameNew); + await s.manager.trackRename(renameNew, renameOld); + s.renameLocal(moveOld, moveNew); + s.writeLocal(moveNew, 'mv-v2'); + await s.manager.trackRename(moveNew, moveOld); + + const headBefore = await s.head(); + const result = await s.push([create, modify, s.tfile(renameNew), s.tfile(moveNew)]); + expect(result.success, describePushResult(result)).toBe(4); + expect(result.failed, describePushResult(result)).toBe(0); + + await s.expectRemoteContent(create, 'create content'); + await s.expectRemoteContent(modify, 'm-v2'); + await s.expectRemoteMissing(renameOld); + await s.expectRemoteContent(renameNew, 'r-v1'); + await s.expectRemoteMissing(moveOld); + await s.expectRemoteContent(moveNew, 'mv-v2'); + await s.expectSingleCommitSince(headBefore); + }); + + it('locks the current contract for a safe + conflict batch (safe files commit, conflict skipped)', async () => { + const s = scenario(); + const safe = path('mixed-safe-conflict/a.md'); + const conflict = path('mixed-safe-conflict/b.md'); + const created = path('mixed-safe-conflict/c.md'); + await s.baseline(safe, 'a-v1'); + await s.baseline(conflict, 'b-v1'); + + s.writeLocal(safe, 'a-v2'); + s.writeLocal(conflict, 'b-local'); + await s.modifyRemote(conflict, 'b-remote'); + s.writeLocal(created, 'c-new'); + + fixture.setConflictResolver(() => 'skip'); + const headBefore = await s.head(); + const result = await s.push([safe, conflict, created]); + expect(result.success, describePushResult(result)).toBe(2); + expect(result.failed, describePushResult(result)).toBe(0); + expect(result.conflicts, describePushResult(result)).toBe(1); + expect(result.skippedConflicts, describePushResult(result)).toBe(1); + + // Current contract: safe files land in one commit; the conflict is + // skipped (remote stays 'b-remote'), not atomic. Locked here. + await s.expectRemoteContent(safe, 'a-v2'); + await s.expectRemoteContent(conflict, 'b-remote'); + await s.expectRemoteContent(created, 'c-new'); + await s.expectSingleCommitSince(headBefore); + }); + }); }); \ No newline at end of file From 2e293f3249456c442fba69a46adae8db5c8d1828 Mon Sep 17 00:00:00 2001 From: tianyao Date: Sat, 22 Aug 2026 10:59:14 +0000 Subject: [PATCH 016/104] test: cover source control selection workflows Phase 6 drives the real SourceControlActionService + PushSelectionStore + ChangeRepository over the real SyncManager (via BoundarySyncWorkspace): selected-subset push leaves unselected files untouched (core); subset- then-remaining push yields two separate commits (GitHub only); and a rename yields a path-derived ChangeId so selecting the new path's change pushes the move (GitHub only), locking the current status-model assumption. Add listCommitShas to the scenario helper. --- e2e/suites/source-control-flows.e2e.test.ts | 114 +++++++++++++++++++- e2e/support/source-control-scenarios.ts | 5 + 2 files changed, 118 insertions(+), 1 deletion(-) diff --git a/e2e/suites/source-control-flows.e2e.test.ts b/e2e/suites/source-control-flows.e2e.test.ts index ab3b898..fa579ae 100644 --- a/e2e/suites/source-control-flows.e2e.test.ts +++ b/e2e/suites/source-control-flows.e2e.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeAll, vi } from 'vitest'; import { createSyncManagerFixture, describePushResult, type SyncManagerFixture } from '../support/sync-manager-fixture'; -import { SourceControlScenario } from '../support/source-control-scenarios'; +import { SourceControlScenario, change } from '../support/source-control-scenarios'; import { timeouts } from '../config/env'; // Auto-confirm the plan-review + conflict modals so a push can proceed @@ -431,4 +431,116 @@ describe('Source Control Flows E2E', () => { await s.expectSingleCommitSince(headBefore); }); }); + + // ------------------------------------------------------------------ + // Phase 6 — Source Control selection workflows + // + // Drives the real SourceControlActionService + PushSelectionStore + + // ChangeRepository on top of the real SyncManager (via the thin + // BoundarySyncWorkspace), so the ChangeId -> path -> workspace.push + // selection filter is the real production code, not a mock. + // ------------------------------------------------------------------ + describe('selection workflows', () => { + it('pushes only the selected subset, leaving unselected files untouched', async () => { + const s = scenario(); + const a = path('subset/a.md'); + const b = path('subset/b.md'); + const c = path('subset/c.md'); + await s.baseline(a, 'a-v1'); + await s.baseline(b, 'b-v1'); + await s.baseline(c, 'c-v1'); + s.writeLocal(a, 'a-v2'); + s.writeLocal(b, 'b-v2'); + s.writeLocal(c, 'c-v2'); + + const ca = change(a, 'local-modified'); + const cb = change(b, 'local-modified'); + const cc = change(c, 'local-modified'); + const { selection, actionService, operations } = s.selectionStack([ca, cb, cc]); + selection.includeForPush(ca.id); + selection.includeForPush(cc.id); + + const headBefore = await s.head(); + await actionService.push([ca.id, cc.id]); + + expect(operations.get(ca.id)).toBe('success'); + expect(operations.get(cc.id)).toBe('success'); + expect(operations.get(cb.id), 'unselected change stays idle').toBe('idle'); + await s.expectRemoteContent(a, 'a-v2'); + await s.expectRemoteContent(c, 'c-v2'); + await s.expectRemoteContent(b, 'b-v1'); + await s.expectSingleCommitSince(headBefore); + // Current contract: the action service marks operations but does + // not clear selection or refresh the repository, so the selection + // is retained (locked here). + expect(selection.isIncluded(ca.id)).toBe(true); + expect(selection.isIncluded(cc.id)).toBe(true); + }); + + it.skipIf(!isGitHub)('pushes a subset then the remaining subset as two separate commits', async () => { + const s = scenario(); + const a = path('subset-then-rest/a.md'); + const b = path('subset-then-rest/b.md'); + const c = path('subset-then-rest/c.md'); + await s.baseline(a, 'a-v1'); + await s.baseline(b, 'b-v1'); + await s.baseline(c, 'c-v1'); + s.writeLocal(a, 'a-v2'); + s.writeLocal(b, 'b-v2'); + s.writeLocal(c, 'c-v2'); + + const ca = change(a, 'local-modified'); + const cb = change(b, 'local-modified'); + const cc = change(c, 'local-modified'); + const { selection, actionService, operations } = s.selectionStack([ca, cb, cc]); + + const head0 = await s.head(); + selection.includeForPush(ca.id); + selection.includeForPush(cc.id); + await actionService.push([ca.id, cc.id]); + const head1 = await s.head(); + await s.expectSingleCommitSince(head0); + + selection.includeForPush(cb.id); + await actionService.push([cb.id]); + const head2 = await s.head(); + expect(head2, 'second push is a separate commit').not.toBe(head1); + const [, head2Parent] = await s.listCommitShas(2); + expect(head2Parent).toBe(head1); + + expect(operations.get(ca.id)).toBe('success'); + expect(operations.get(cb.id)).toBe('success'); + expect(operations.get(cc.id)).toBe('success'); + await s.expectRemoteContent(a, 'a-v2'); + await s.expectRemoteContent(b, 'b-v2'); + await s.expectRemoteContent(c, 'c-v2'); + }); + + it.skipIf(!isGitHub)('rename yields a path-derived ChangeId; selecting the new id pushes the move', async () => { + const s = scenario(); + const oldP = path('selection-rename/a.md'); + const newP = path('selection-rename/archive/a.md'); + await s.baseline(oldP, 'v1'); + + s.renameLocal(oldP, newP); + await s.manager.trackRename(newP, oldP); + + // Current model: ChangeId is path-derived, so the moved change + // carries a NEW id (the new path) with previousPath set; the old + // path's id is gone. Locking this assumption protects the status + // model against an accidental path->identity regression. + const moved = change(newP, 'moved', oldP); + const { selection, actionService, operations } = s.selectionStack([moved]); + selection.refresh([moved.id]); + selection.includeForPush(moved.id); + + const headBefore = await s.head(); + await actionService.push([moved.id]); + + expect(operations.get(moved.id)).toBe('success'); + await s.expectRemoteMissing(oldP); + await s.expectRemoteContent(newP, 'v1'); + await s.expectSingleCommitSince(headBefore); + }); + }); }); \ No newline at end of file diff --git a/e2e/support/source-control-scenarios.ts b/e2e/support/source-control-scenarios.ts index d2e3d0e..c1edbb4 100644 --- a/e2e/support/source-control-scenarios.ts +++ b/e2e/support/source-control-scenarios.ts @@ -135,6 +135,11 @@ export class SourceControlScenario { return tip!; } + /** Newest-first commit shas on the branch (independent of the service). */ + async listCommitShas(count: number): Promise { + return this.verifier.listCommitShas(this.branch, count); + } + /** Asserts exactly one new commit landed since `headBefore` (the new commit's parent is `headBefore`). */ async expectSingleCommitSince(headBefore: string): Promise { const [headAfter, headAfterParent] = await this.verifier.listCommitShas(this.branch, 2); From 119ba030afe9e2c17ed5695eb940036f7b253725 Mon Sep 17 00:00:00 2001 From: tianyao Date: Sat, 22 Aug 2026 11:00:32 +0000 Subject: [PATCH 017/104] test: cover divergence and idempotency flows Phase 7: remote-ahead pull advances metadata (core); a remote-ahead change and an unrelated local change coexist without cross-contamination (GitHub); a concurrent remote write surfaces as a conflict then reconciles with no lost update (GitHub); an all-unchanged batch reports zero work and zero commits (core); repeating a push makes no second mutation and corrupts no metadata (GitHub); and a skipped conflict can be resolved then re-synced cleanly with no stale operation state (GitHub). --- e2e/suites/source-control-flows.e2e.test.ts | 122 ++++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/e2e/suites/source-control-flows.e2e.test.ts b/e2e/suites/source-control-flows.e2e.test.ts index fa579ae..0227a5d 100644 --- a/e2e/suites/source-control-flows.e2e.test.ts +++ b/e2e/suites/source-control-flows.e2e.test.ts @@ -543,4 +543,126 @@ describe('Source Control Flows E2E', () => { await s.expectSingleCommitSince(headBefore); }); }); + + // ------------------------------------------------------------------ + // Phase 7 — Remote divergence + idempotency + // ------------------------------------------------------------------ + describe('divergence and idempotency flows', () => { + it('pulls a remote-ahead update into a synced-baseline local, advancing metadata', async () => { + const s = scenario(); + const p = path('remote-ahead/a.md'); + await s.baseline(p, 'A'); + await s.modifyRemote(p, 'B'); + + await s.pullFile(p); + + expect(await s.readLocal(p)).toBe('B'); + const remote = await s.remoteContent(p); + expect(s.metadataSha(p), 'metadata moves to the remote sha').toBe(remote?.sha); + }); + + it.skipIf(!isGitHub)('a remote-ahead change and an unrelated local change coexist', async () => { + const s = scenario(); + const a = path('coexist/a.md'); + const b = path('coexist/b.md'); + await s.baseline(a, 'A'); + await s.baseline(b, 'B'); + + await s.modifyRemote(a, 'A-remote'); + s.writeLocal(b, 'B-local'); + + await s.pullFile(a); + expect(await s.readLocal(a)).toBe('A-remote'); + // The local change on b survives the pull of a — no cross-contamination. + expect(await s.readLocal(b)).toBe('B-local'); + await s.expectRemoteContent(b, 'B'); + }); + + it.skipIf(!isGitHub)('a concurrent remote write surfaces as a conflict, then reconciles with no lost update', async () => { + const s = scenario(); + const p = path('concurrent/a.md'); + await s.baseline(p, 'v1'); + s.writeLocal(p, 'local-v2'); + await s.modifyRemote(p, 'concurrent-v2'); + + fixture.setConflictResolver(() => 'skip'); + const skipped = await s.push([p]); + expect(skipped.skippedConflicts, describePushResult(skipped)).toBeGreaterThanOrEqual(1); + await s.expectRemoteContent(p, 'concurrent-v2'); + + // Reconcile: accept the concurrent remote, then push a fresh local edit. + fixture.setConflictResolver(() => 'keep-remote'); + await s.push([p]); + expect(await s.readLocal(p)).toBe('concurrent-v2'); + + s.writeLocal(p, 'final'); + const headBefore = await s.head(); + const finalResult = await s.push([p]); + expect(finalResult.success, describePushResult(finalResult)).toBe(1); + await s.expectRemoteContent(p, 'final'); + await s.expectSingleCommitSince(headBefore); + }); + + it('an all-unchanged batch reports no work and creates zero commits', async () => { + const s = scenario(); + const a = path('noop-batch/a.md'); + const b = path('noop-batch/b.md'); + const c = path('noop-batch/c.md'); + await s.baseline(a, 'a'); + await s.baseline(b, 'b'); + await s.baseline(c, 'c'); + + const headBefore = await s.head(); + const result = await s.push([a, b, c]); + expect(result.success, describePushResult(result)).toBe(0); + expect(result.failed, describePushResult(result)).toBe(0); + expect(result.skippedConflicts, describePushResult(result)).toBe(0); + await s.expectNoCommitSince(headBefore); + }); + + it.skipIf(!isGitHub)('repeating the same push twice makes no second mutation and corrupts no metadata', async () => { + const s = scenario(); + const p = path('repeat-push/a.md'); + await s.baseline(p, 'v1'); + s.writeLocal(p, 'v2'); + + const headAfterFirst = await s.head(); + const first = await s.push([p]); + expect(first.success, describePushResult(first)).toBe(1); + await s.expectRemoteContent(p, 'v2'); + const shaAfterFirst = s.metadataSha(p); + expect(shaAfterFirst).toBeTruthy(); + + const second = await s.push([p]); + expect(second.success, describePushResult(second)).toBe(0); + expect(second.failed, describePushResult(second)).toBe(0); + await s.expectNoCommitSince(headAfterFirst); + expect(s.metadataSha(p), 'metadata not corrupted by the no-op repeat').toBe(shaAfterFirst); + }); + + it.skipIf(!isGitHub)('re-syncs cleanly after a skipped conflict (no stale operation state)', async () => { + const s = scenario(); + const p = path('retry-after-skip/a.md'); + await s.baseline(p, 'v1'); + s.writeLocal(p, 'local'); + await s.modifyRemote(p, 'remote'); + + fixture.setConflictResolver(() => 'skip'); + const skipped = await s.push([p]); + expect(skipped.skippedConflicts, describePushResult(skipped)).toBeGreaterThanOrEqual(1); + + // Resolve the skipped conflict (keep-remote), then push a fresh edit. + fixture.setConflictResolver(() => 'keep-remote'); + await s.push([p]); + expect(await s.readLocal(p)).toBe('remote'); + + s.writeLocal(p, 'reconciled'); + const headBefore = await s.head(); + const result = await s.push([p]); + expect(result.success, describePushResult(result)).toBe(1); + expect(result.failed, describePushResult(result)).toBe(0); + await s.expectRemoteContent(p, 'reconciled'); + await s.expectSingleCommitSince(headBefore); + }); + }); }); \ No newline at end of file From f036888b08af2464c3badc2d3a951648d4a354c3 Mon Sep 17 00:00:00 2001 From: tianyao Date: Sat, 22 Aug 2026 11:01:48 +0000 Subject: [PATCH 018/104] test: add path and batch-scale regression cases Phase 8: unicode filename create+modify+rename, spaces-and-symbols create+modify, deeply-nested move+modify (GitHub only); 100-file batch create in one commit and a 100-file mixed modify+create+rename batch in one commit (GitHub only); plus an opt-in 1000-file stress create behind E2E_STRESS=1 (never a required CI check). --- e2e/suites/source-control-flows.e2e.test.ts | 118 +++++++++++++++++++- 1 file changed, 117 insertions(+), 1 deletion(-) diff --git a/e2e/suites/source-control-flows.e2e.test.ts b/e2e/suites/source-control-flows.e2e.test.ts index 0227a5d..88a28a8 100644 --- a/e2e/suites/source-control-flows.e2e.test.ts +++ b/e2e/suites/source-control-flows.e2e.test.ts @@ -15,8 +15,9 @@ vi.mock('../../src/ui/BatchConflictResolutionModal'); // Provider matrix: Core scenarios run on every provider; Extended scenarios // (rename chains, unicode, batch-scale, etc.) exercise SyncManager/model // behavior that's provider-agnostic, so they run on GitHub only to keep -// real-API CI fast and stable. +// real-API CI fast and stable. Stress (1000-file) is opt-in via E2E_STRESS=1. const isGitHub = process.env.E2E_PROVIDER === 'github'; +const isStress = process.env.E2E_STRESS === '1'; describe('Source Control Flows E2E', () => { let fixture: SyncManagerFixture; @@ -665,4 +666,119 @@ describe('Source Control Flows E2E', () => { await s.expectSingleCommitSince(headBefore); }); }); + + // ------------------------------------------------------------------ + // Phase 8 — Path edge cases + batch scale + // ------------------------------------------------------------------ + describe('path edge cases and batch scale', () => { + it.skipIf(!isGitHub)('creates, modifies, and renames a unicode-named file', async () => { + const s = scenario(); + const original = path('unicode/筆記/測試文件.md'); + const archived = path('unicode/筆記/已歸檔.md'); + await s.baseline(original, 'unicode-v1'); + + s.writeLocal(original, 'unicode-v2'); + let headBefore = await s.head(); + let result = await s.push([original]); + expect(result.success, describePushResult(result)).toBe(1); + await s.expectRemoteContent(original, 'unicode-v2'); + await s.expectSingleCommitSince(headBefore); + + s.renameLocal(original, archived); + await s.manager.trackRename(archived, original); + headBefore = await s.head(); + result = await s.push([s.tfile(archived)]); + expect(result.success, describePushResult(result)).toBe(1); + await s.expectRemoteMissing(original); + await s.expectRemoteContent(archived, 'unicode-v2'); + await s.expectSingleCommitSince(headBefore); + }); + + it.skipIf(!isGitHub)('creates and modifies a file with spaces and symbols', async () => { + const s = scenario(); + const p = path('spaces/folder/my note (draft).md'); + await s.baseline(p, 'draft-v1'); + + s.writeLocal(p, 'draft-v2'); + const headBefore = await s.head(); + const result = await s.push([p]); + expect(result.success, describePushResult(result)).toBe(1); + await s.expectRemoteContent(p, 'draft-v2'); + await s.expectSingleCommitSince(headBefore); + }); + + it.skipIf(!isGitHub)('moves and modifies a deeply nested file', async () => { + const s = scenario(); + const oldP = path('deep/a/b/c/d/e/note.md'); + const newP = path('deep/archive/x/y/z/w/note.md'); + await s.baseline(oldP, 'deep-v1'); + + s.renameLocal(oldP, newP); + s.writeLocal(newP, 'deep-v2'); + await s.manager.trackRename(newP, oldP); + + const headBefore = await s.head(); + const result = await s.push([s.tfile(newP)]); + expect(result.success, describePushResult(result)).toBe(1); + await s.expectRemoteMissing(oldP); + await s.expectRemoteContent(newP, 'deep-v2'); + await s.expectSingleCommitSince(headBefore); + }); + + it.skipIf(!isGitHub)('creates 100 files in one commit', async () => { + const s = scenario(); + const paths = Array.from({ length: 100 }, (_, i) => path(`batch-100/${String(i).padStart(3, '0')}.md`)); + for (const p of paths) s.writeLocal(p, `content ${p}`); + + const headBefore = await s.head(); + const result = await s.push(paths); + expect(result.success, describePushResult(result)).toBe(100); + expect(result.failed, describePushResult(result)).toBe(0); + await s.expectSingleCommitSince(headBefore); + await s.expectRemoteContent(paths[0]!, `content ${paths[0]}`); + await s.expectRemoteContent(paths[50]!, `content ${paths[50]}`); + await s.expectRemoteContent(paths[99]!, `content ${paths[99]}`); + }); + + it.skipIf(!isGitHub)('pushes a 100-file mixed batch (modify + create + rename) in one commit', async () => { + const s = scenario(); + const modifyPaths = Array.from({ length: 40 }, (_, i) => path(`mixed-100/modify/${i}.md`)); + const createPaths = Array.from({ length: 30 }, (_, i) => path(`mixed-100/create/${i}.md`)); + const renameOld = Array.from({ length: 30 }, (_, i) => path(`mixed-100/rename-old/${i}.md`)); + const renameNew = Array.from({ length: 30 }, (_, i) => path(`mixed-100/rename-new/${i}.md`)); + + for (const p of modifyPaths) await s.baseline(p, 'v1'); + for (const p of renameOld) await s.baseline(p, 'r-v1'); + for (const p of modifyPaths) s.writeLocal(p, 'v2'); + for (const p of createPaths) s.writeLocal(p, 'new'); + for (let i = 0; i < renameOld.length; i++) { + s.renameLocal(renameOld[i]!, renameNew[i]!); + await s.manager.trackRename(renameNew[i]!, renameOld[i]!); + } + + const headBefore = await s.head(); + const all = [...modifyPaths, ...createPaths, ...renameNew.map(p => s.tfile(p))]; + const result = await s.push(all); + expect(result.success, describePushResult(result)).toBe(100); + expect(result.failed, describePushResult(result)).toBe(0); + await s.expectSingleCommitSince(headBefore); + + await s.expectRemoteContent(modifyPaths[0]!, 'v2'); + await s.expectRemoteContent(createPaths[0]!, 'new'); + await s.expectRemoteMissing(renameOld[0]!); + await s.expectRemoteContent(renameNew[0]!, 'r-v1'); + }); + + it.skipIf(!isStress || !isGitHub)('stress: creates 1000 files', async () => { + const s = scenario(); + const paths = Array.from({ length: 1000 }, (_, i) => path(`batch-1000/${String(i).padStart(4, '0')}.md`)); + for (const p of paths) s.writeLocal(p, `content ${p}`); + + const result = await s.push(paths); + expect(result.success, describePushResult(result)).toBe(1000); + expect(result.failed, describePushResult(result)).toBe(0); + await s.expectRemoteContent(paths[0]!, `content ${paths[0]}`); + await s.expectRemoteContent(paths[999]!, `content ${paths[999]}`); + }, 300_000); + }); }); \ No newline at end of file From e9f0d28ffc174e028869f6f20a1c72bafc55ca19 Mon Sep 17 00:00:00 2001 From: tianyao Date: Sat, 22 Aug 2026 11:19:50 +0000 Subject: [PATCH 019/104] fix(ci): run E2E suites through shared runner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The provider-e2e job hard-coded the vitest suite list and omitted source-control-flows.e2e.test.ts, so the new suite never ran in CI (a "fake green" — the job passed without exercising the new coverage). Make scripts/e2e-suites.txt the single source of truth: scripts/run-e2e.sh reads it, expands ${provider}, and runs the listed suites; CI now calls scripts/run-e2e.sh --provider (same command local dev uses), collapsing the separate provision/seed/vitest/verify steps into the one retry-wrapped entry point. Adding a shared suite now only requires editing scripts/e2e-suites.txt. Also make the Gitea-disabled state explicit: the gate step emits a notice and a step-summary ("Gitea E2E: disabled — runner Docker networking") so a green gitea leg is never mistaken for three-provider coverage. --- .github/workflows/ci.yml | 56 +++++++++++++++++----------------------- scripts/e2e-suites.txt | 10 +++++++ scripts/run-e2e.sh | 34 ++++++++++++++---------- 3 files changed, 55 insertions(+), 45 deletions(-) create mode 100644 scripts/e2e-suites.txt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 049dbd2..ae1202a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -156,6 +156,15 @@ jobs: # fork PRs get no E2E coverage at all. if [ "${{ matrix.provider }}" = "gitea" ]; then run=false + # Make the disabled state explicit in the run log + summary so a + # green "E2E / gitea" job is never mistaken for "Gitea E2E passed". + echo "::notice::Gitea E2E is disabled in CI (runner Docker networking — see TODO below). Suite/harness code passes locally; re-enable by removing this block." + { + echo "### Gitea E2E: disabled" + echo "Reason: runner Docker networking — container provisioning against this runner fleet needs investigation (bridge-IP reachability, health-check timing)." + echo "Suite/harness code is untouched and passes locally (\`npm run test:e2e -- --provider gitea\`). The Gitea infrastructure fix is tracked separately; do not infer three-provider coverage from a green gitea leg." + echo "Re-enable by removing the gitea block in the \"Determine whether this provider leg should run\" step." + } >> "$GITHUB_STEP_SUMMARY" fi if [ "${{ github.event_name }}" = "pull_request" ] \ && [ "${{ matrix.provider }}" != "gitea" ] \ @@ -184,29 +193,24 @@ jobs: - run: npm ci --ignore-scripts if: steps.gate.outputs.run == 'true' - # Arrange/Assert/cleanup are Shell + Git (scripts/e2e-harness.sh); Act - # stays production TypeScript (npx vitest). E2E_WORKDIR/E2E_PR_NUMBER/ - # E2E_SOURCE_BRANCH are set once at job level (see the job `env:` - # above) so all steps below share the same run state/identity. - - name: Provision isolated branch/container - if: steps.gate.outputs.run == 'true' - env: - E2E_PROVIDER: ${{ matrix.provider }} - run: scripts/e2e-harness.sh provision - - - name: Seed baseline fixture - if: steps.gate.outputs.run == 'true' - env: - E2E_PROVIDER: ${{ matrix.provider }} - run: scripts/e2e-harness.sh seed - + # One entry point for the whole real-provider E2E flow: scripts/run-e2e.sh + # provisions the isolated branch/container, seeds the baseline fixture, + # runs the suites listed in scripts/e2e-suites.txt (the single source of + # truth — CI and local run the same command, so the suite list is never + # duplicated here), and cleans up via its EXIT trap. New suites are added + # in scripts/e2e-suites.txt only; scripts/check-e2e-suite-registration.mjs + # (wired into `npm run lint`) fails CI if a suite file isn't registered. + # E2E_WORKDIR is set by the "Compute run-scoped workdir" step above; the + # job `env:` supplies the provider secrets and run identity + # (E2E_PR_NUMBER/E2E_SOURCE_BRANCH) that run-e2e.sh/e2e-harness.sh consume. + # # Retried (not just run once): observed failures against the real # providers include transient runner-network blips unrelated to the # suite/product code (e.g. a bare `getaddrinfo ENOTFOUND gitlab.com` # mid-test on 2026-08-14, run 31770197590) that a same-attempt rerun - # simply doesn't reproduce. Safe to retry the whole step from scratch: - # each suite's `runId`/branch paths are randomized per vitest process - # (see e.g. e2e/suites/sync-manager.e2e.test.ts), so a failed + # simply doesn't reproduce. Safe to retry from scratch: run-e2e.sh + # re-provisions a fresh isolated branch each attempt and every suite's + # runId/branch paths are randomized per vitest process, so a failed # attempt's partial remote state never collides with the retry -- a # genuine product/test bug still fails identically every attempt and # exhausts the retries. @@ -219,19 +223,7 @@ jobs: timeout_minutes: 15 max_attempts: 3 retry_wait_seconds: 15 - command: | - set -a - # shellcheck disable=SC1091 - source "$E2E_WORKDIR/e2e.env" - [ -f "$E2E_WORKDIR/e2e.secrets.env" ] && source "$E2E_WORKDIR/e2e.secrets.env" - set +a - npx vitest run -c vitest.e2e.config.ts "e2e/suites/${{ matrix.provider }}.e2e.test.ts" e2e/suites/sync-manager.e2e.test.ts - - - name: Independent verification - if: steps.gate.outputs.run == 'true' - env: - E2E_PROVIDER: ${{ matrix.provider }} - run: scripts/e2e-harness.sh verify + command: scripts/run-e2e.sh --provider "${{ matrix.provider }}" # `if: always()` -- cleanup is best-effort, never a prerequisite for # the next run (see scripts/e2e-harness.sh's cmd_cleanup and diff --git a/scripts/e2e-suites.txt b/scripts/e2e-suites.txt new file mode 100644 index 0000000..b6f588f --- /dev/null +++ b/scripts/e2e-suites.txt @@ -0,0 +1,10 @@ +# E2E suite manifest — the single source of truth for which vitest suites run +# per provider. scripts/run-e2e.sh reads this, expands ${provider}, and runs +# them; CI calls run-e2e.sh so this list is never duplicated in the workflow. +# scripts/check-e2e-suite-registration.mjs enforces that every +# e2e/suites/*.e2e.test.ts is registered here: the ${provider} line covers the +# provider-specific suites (github/gitlab/gitea); every other shared suite +# must be listed explicitly, or CI fails. +e2e/suites/${provider}.e2e.test.ts +e2e/suites/sync-manager.e2e.test.ts +e2e/suites/source-control-flows.e2e.test.ts \ No newline at end of file diff --git a/scripts/run-e2e.sh b/scripts/run-e2e.sh index 7629f83..0c85aab 100755 --- a/scripts/run-e2e.sh +++ b/scripts/run-e2e.sh @@ -1,9 +1,11 @@ #!/usr/bin/env bash # Thin local-dev orchestration around scripts/e2e-harness.sh: provision the # isolated branch/container, seed a baseline fixture, run the provider's -# vitest suite + the SyncManager suite, then clean up (even on failure). CI -# drives the same four steps directly from .github/workflows/ci.yml instead, -# so each shows up as its own job step. +# vitest suites, then clean up (even on failure). CI calls this same script +# (see .github/workflows/ci.yml), so the suite list lives in exactly one +# place: scripts/e2e-suites.txt. Add a new shared suite there and both local +# and CI pick it up; scripts/check-e2e-suite-registration.mjs fails CI if a +# suite file isn't registered. set -euo pipefail provider="" @@ -36,13 +38,19 @@ scripts/e2e-harness.sh provision set -a; source "$E2E_WORKDIR/e2e.env"; [ -f "$E2E_WORKDIR/e2e.secrets.env" ] && source "$E2E_WORKDIR/e2e.secrets.env"; set +a scripts/e2e-harness.sh seed -# Only this provider's contract suite + the shared SyncManager/source-control -# workflow suites -- vitest.e2e.config.ts's `include` matches every -# e2e/suites/*.e2e.test.ts file, and the other two providers' suites would -# otherwise also try to run (and fail on missing credentials) regardless of -# --provider. source-control-flows gates its Extended scenarios to GitHub only -# (and 1000-file stress to E2E_STRESS=1) in-file. -npx vitest run -c vitest.e2e.config.ts \ - "e2e/suites/${provider}.e2e.test.ts" \ - e2e/suites/sync-manager.e2e.test.ts \ - e2e/suites/source-control-flows.e2e.test.ts + +# Suite manifest: scripts/e2e-suites.txt (single source of truth). ${provider} +# expands to the active provider's contract suite; the rest are shared suites. +# `|| [ -n "$line" ]` keeps the last line even without a trailing newline. +SUITES=() +while IFS= read -r line || [ -n "$line" ]; do + case "$line" in ''|\#*) continue;; esac + SUITES+=("$(printf '%s' "$line" | sed "s/\${provider}/$provider/g")") +done < scripts/e2e-suites.txt + +# vitest.e2e.config.ts's `include` matches every e2e/suites/*.e2e.test.ts, so +# the other two providers' suites would also try to run (and fail on missing +# credentials) if not explicitly limited to this list. source-control-flows +# gates its Extended scenarios to GitHub only (and 1000-file stress to +# E2E_STRESS=1) in-file. +npx vitest run -c vitest.e2e.config.ts "${SUITES[@]}" From 220f2d520c3416d85a8c2c3873b7781da8df8b97 Mon Sep 17 00:00:00 2001 From: tianyao Date: Sat, 22 Aug 2026 11:20:08 +0000 Subject: [PATCH 020/104] test(ci): guard E2E suite registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add scripts/check-e2e-suite-registration.mjs and wire it into `npm run lint` (both the husky pre-commit hook and CI). It fails when an e2e/suites/*.e2e.test.ts file exists but isn't registered in scripts/e2e-suites.txt — provider-specific suites (github/gitlab/gitea) are covered by the ${provider} line; every other shared suite must be listed explicitly. So adding a suite without wiring CI now breaks the build instead of silently passing (the original fake-green failure mode). --- package.json | 2 +- scripts/check-e2e-suite-registration.mjs | 101 +++++++++++++++++++++++ 2 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 scripts/check-e2e-suite-registration.mjs diff --git a/package.json b/package.json index e460210..3ad313d 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "build": "tsc -noEmit -skipLibCheck && npm run typecheck:compat && node esbuild.config.mjs production", "typecheck:compat": "node scripts/typecheck-compat.mjs", "version": "node version-bump.mjs && git add manifest.json versions.json", - "lint": "eslint .", + "lint": "eslint . && node scripts/check-e2e-suite-registration.mjs", "test": "vitest run", "deploy": "npm run build && mkdir -p ~/Obsidian/MyPKM/.obsidian/plugins/git-file-sync && cp main.js manifest.json styles.css ~/Obsidian/MyPKM/.obsidian/plugins/git-file-sync/", "test:ui": "vitest --ui", diff --git a/scripts/check-e2e-suite-registration.mjs b/scripts/check-e2e-suite-registration.mjs new file mode 100644 index 0000000..ae43700 --- /dev/null +++ b/scripts/check-e2e-suite-registration.mjs @@ -0,0 +1,101 @@ +#!/usr/bin/env node +/* + * Guards against the "fake green" failure mode: a new e2e/suites/*.e2e.test.ts + * file that isn't wired into CI. The suite manifest has one source of truth — + * scripts/e2e-suites.txt, consumed by scripts/run-e2e.sh (which CI calls). This + * check fails (non-zero) when a suite file exists on disk but isn't registered + * there, so adding a suite without registering it breaks CI instead of + * silently passing. + * + * Rules: + * - Provider-specific suites (github/gitlab/gitea.e2e.test.ts) are covered by + * a manifest line containing ${provider}; they must NOT also need an + * explicit static line. + * - Every other e2e/suites/*.e2e.test.ts is a shared suite and MUST be listed + * explicitly in the manifest. + * - Every static manifest line must point to a file that exists (catches + * typos / deleted suites). + * + * Wired into `npm run lint` so both the husky pre-commit hook and CI enforce it. + */ +import { readFileSync, readdirSync, existsSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const root = resolve(here, '..'); +const manifestPath = join(root, 'scripts', 'e2e-suites.txt'); +const suitesDir = join(root, 'e2e', 'suites'); + +const PROVIDERS = ['github', 'gitlab', 'gitea']; + +function readManifest() { + const raw = readFileSync(manifestPath, 'utf-8'); + const lines = raw.split('\n').map(l => { + const hash = l.indexOf('#'); + return (hash >= 0 ? l.slice(0, hash) : l).trim(); + }); + const staticSuites = []; + let hasDynamic = false; + for (const line of lines) { + if (!line) continue; + if (line.includes('${provider}')) { + hasDynamic = true; + continue; + } + staticSuites.push(line); + } + return { staticSuites, hasDynamic }; +} + +function listSuiteFiles() { + return readdirSync(suitesDir) + .filter(f => f.endsWith('.e2e.test.ts')) + .sort(); +} + +function fail(message) { + console.error(`check-e2e-suite-registration: ${message}`); + process.exit(1); +} + +const { staticSuites, hasDynamic } = readManifest(); +const suiteFiles = listSuiteFiles(); +const staticSet = new Set(staticSuites.map(s => s.replace(/^\.\//, ''))); +const providerSuites = new Set(PROVIDERS.map(p => `e2e/suites/${p}.e2e.test.ts`)); + +// 1. Every static manifest line must reference an existing file. +for (const entry of staticSuites) { + const rel = entry.replace(/^\.\//, ''); + if (!existsSync(join(root, rel))) { + fail(`manifest "${entry}" does not match any existing file under the repo root.`); + } +} + +// 2. A ${provider} line must expand to all three provider contract suites. +if (!hasDynamic) { + fail('manifest is missing a ${provider} line — the provider-specific suites (github/gitlab/gitea) would not run.'); +} + +// 3. Every suite file on disk must be registered. +const unregistered = []; +for (const file of suiteFiles) { + const rel = `e2e/suites/${file}`; + if (providerSuites.has(rel)) { + if (!hasDynamic) unregistered.push(`${rel} (needs a \${provider} manifest line)`); + continue; + } + if (!staticSet.has(rel)) { + unregistered.push(`${rel} (add it to scripts/e2e-suites.txt)`); + } +} + +if (unregistered.length > 0) { + fail( + `unregistered suite file(s):\n ${unregistered.join('\n ')}\n` + + `Every e2e/suites/*.e2e.test.ts must be listed in scripts/e2e-suites.txt ` + + `(provider-specific suites via the \${provider} line) or CI will not run them.`, + ); +} + +console.log(`check-e2e-suite-registration: OK — ${suiteFiles.length} suite file(s), ${staticSuites.length} static + ${hasDynamic ? '1 dynamic' : '0 dynamic'} manifest line(s).`); \ No newline at end of file From c2bfeb07dd7bf9500cb28ddb48e9f76dfb16985a Mon Sep 17 00:00:00 2001 From: tianyao Date: Sat, 22 Aug 2026 11:50:36 +0000 Subject: [PATCH 021/104] fix(ci): fold E2E suite registration check into run-e2e.sh Replace the standalone check-e2e-suite-registration.mjs (wired into `npm run lint`) with forward/reverse checks inside scripts/run-e2e.sh itself, so suite manifest validation lives in the same script CI already calls instead of a separate Node checker. Also harden GitVerifier.git() to surface stderr on unexpected git failures while keeping expected missing-path lookups silent. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/ci.yml | 4 +- package.json | 2 +- scripts/check-e2e-suite-registration.mjs | 101 ----------------------- scripts/e2e-harness.sh | 19 ++++- scripts/e2e-suites.txt | 4 +- scripts/run-e2e.sh | 62 +++++++++++++- 6 files changed, 81 insertions(+), 111 deletions(-) delete mode 100644 scripts/check-e2e-suite-registration.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ae1202a..bd6a6e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -198,8 +198,8 @@ jobs: # runs the suites listed in scripts/e2e-suites.txt (the single source of # truth — CI and local run the same command, so the suite list is never # duplicated here), and cleans up via its EXIT trap. New suites are added - # in scripts/e2e-suites.txt only; scripts/check-e2e-suite-registration.mjs - # (wired into `npm run lint`) fails CI if a suite file isn't registered. + # in scripts/e2e-suites.txt only; run-e2e.sh's own forward/reverse checks + # fail the run if a suite file isn't registered (or vice versa). # E2E_WORKDIR is set by the "Compute run-scoped workdir" step above; the # job `env:` supplies the provider secrets and run identity # (E2E_PR_NUMBER/E2E_SOURCE_BRANCH) that run-e2e.sh/e2e-harness.sh consume. diff --git a/package.json b/package.json index 3ad313d..e460210 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "build": "tsc -noEmit -skipLibCheck && npm run typecheck:compat && node esbuild.config.mjs production", "typecheck:compat": "node scripts/typecheck-compat.mjs", "version": "node version-bump.mjs && git add manifest.json versions.json", - "lint": "eslint . && node scripts/check-e2e-suite-registration.mjs", + "lint": "eslint .", "test": "vitest run", "deploy": "npm run build && mkdir -p ~/Obsidian/MyPKM/.obsidian/plugins/git-file-sync && cp main.js manifest.json styles.css ~/Obsidian/MyPKM/.obsidian/plugins/git-file-sync/", "test:ui": "vitest --ui", diff --git a/scripts/check-e2e-suite-registration.mjs b/scripts/check-e2e-suite-registration.mjs deleted file mode 100644 index ae43700..0000000 --- a/scripts/check-e2e-suite-registration.mjs +++ /dev/null @@ -1,101 +0,0 @@ -#!/usr/bin/env node -/* - * Guards against the "fake green" failure mode: a new e2e/suites/*.e2e.test.ts - * file that isn't wired into CI. The suite manifest has one source of truth — - * scripts/e2e-suites.txt, consumed by scripts/run-e2e.sh (which CI calls). This - * check fails (non-zero) when a suite file exists on disk but isn't registered - * there, so adding a suite without registering it breaks CI instead of - * silently passing. - * - * Rules: - * - Provider-specific suites (github/gitlab/gitea.e2e.test.ts) are covered by - * a manifest line containing ${provider}; they must NOT also need an - * explicit static line. - * - Every other e2e/suites/*.e2e.test.ts is a shared suite and MUST be listed - * explicitly in the manifest. - * - Every static manifest line must point to a file that exists (catches - * typos / deleted suites). - * - * Wired into `npm run lint` so both the husky pre-commit hook and CI enforce it. - */ -import { readFileSync, readdirSync, existsSync } from 'node:fs'; -import { dirname, join, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const here = dirname(fileURLToPath(import.meta.url)); -const root = resolve(here, '..'); -const manifestPath = join(root, 'scripts', 'e2e-suites.txt'); -const suitesDir = join(root, 'e2e', 'suites'); - -const PROVIDERS = ['github', 'gitlab', 'gitea']; - -function readManifest() { - const raw = readFileSync(manifestPath, 'utf-8'); - const lines = raw.split('\n').map(l => { - const hash = l.indexOf('#'); - return (hash >= 0 ? l.slice(0, hash) : l).trim(); - }); - const staticSuites = []; - let hasDynamic = false; - for (const line of lines) { - if (!line) continue; - if (line.includes('${provider}')) { - hasDynamic = true; - continue; - } - staticSuites.push(line); - } - return { staticSuites, hasDynamic }; -} - -function listSuiteFiles() { - return readdirSync(suitesDir) - .filter(f => f.endsWith('.e2e.test.ts')) - .sort(); -} - -function fail(message) { - console.error(`check-e2e-suite-registration: ${message}`); - process.exit(1); -} - -const { staticSuites, hasDynamic } = readManifest(); -const suiteFiles = listSuiteFiles(); -const staticSet = new Set(staticSuites.map(s => s.replace(/^\.\//, ''))); -const providerSuites = new Set(PROVIDERS.map(p => `e2e/suites/${p}.e2e.test.ts`)); - -// 1. Every static manifest line must reference an existing file. -for (const entry of staticSuites) { - const rel = entry.replace(/^\.\//, ''); - if (!existsSync(join(root, rel))) { - fail(`manifest "${entry}" does not match any existing file under the repo root.`); - } -} - -// 2. A ${provider} line must expand to all three provider contract suites. -if (!hasDynamic) { - fail('manifest is missing a ${provider} line — the provider-specific suites (github/gitlab/gitea) would not run.'); -} - -// 3. Every suite file on disk must be registered. -const unregistered = []; -for (const file of suiteFiles) { - const rel = `e2e/suites/${file}`; - if (providerSuites.has(rel)) { - if (!hasDynamic) unregistered.push(`${rel} (needs a \${provider} manifest line)`); - continue; - } - if (!staticSet.has(rel)) { - unregistered.push(`${rel} (add it to scripts/e2e-suites.txt)`); - } -} - -if (unregistered.length > 0) { - fail( - `unregistered suite file(s):\n ${unregistered.join('\n ')}\n` + - `Every e2e/suites/*.e2e.test.ts must be listed in scripts/e2e-suites.txt ` + - `(provider-specific suites via the \${provider} line) or CI will not run them.`, - ); -} - -console.log(`check-e2e-suite-registration: OK — ${suiteFiles.length} suite file(s), ${staticSuites.length} static + ${hasDynamic ? '1 dynamic' : '0 dynamic'} manifest line(s).`); \ No newline at end of file diff --git a/scripts/e2e-harness.sh b/scripts/e2e-harness.sh index 46464c4..c19ca5c 100755 --- a/scripts/e2e-harness.sh +++ b/scripts/e2e-harness.sh @@ -314,7 +314,24 @@ export class GitVerifier { constructor(private readonly repoDir: string = ${repo_dir@Q}) {} private git(args: string[]): string { - return execFileSync('git', ['-C', this.repoDir, ...args], { encoding: 'utf-8' }); + try { + return execFileSync('git', ['-C', this.repoDir, ...args], { + encoding: 'utf-8', + // Pipe stderr so an *expected* missing path (getFile's + // try/catch -> null) stays silent instead of spamming the log + // with "fatal: path does not exist". A genuine, unexpected git + // failure still surfaces: callers without their own try/catch + // re-throw below with the captured stderr attached. + stdio: ['pipe', 'pipe', 'pipe'], + }); + } catch (error) { + const stderr = error && typeof error === 'object' && 'stderr' in error + ? String((error as { stderr: unknown }).stderr).trim() + : ''; + throw new Error( + \`git \${args.join(' ')} failed\` + (stderr ? \`:\\n\${stderr}\` : ''), + ); + } } private fetch(ref: string): void { diff --git a/scripts/e2e-suites.txt b/scripts/e2e-suites.txt index b6f588f..6ac6a1a 100644 --- a/scripts/e2e-suites.txt +++ b/scripts/e2e-suites.txt @@ -1,10 +1,10 @@ # E2E suite manifest — the single source of truth for which vitest suites run # per provider. scripts/run-e2e.sh reads this, expands ${provider}, and runs # them; CI calls run-e2e.sh so this list is never duplicated in the workflow. -# scripts/check-e2e-suite-registration.mjs enforces that every +# scripts/run-e2e.sh's own forward/reverse checks enforce that every # e2e/suites/*.e2e.test.ts is registered here: the ${provider} line covers the # provider-specific suites (github/gitlab/gitea); every other shared suite -# must be listed explicitly, or CI fails. +# must be listed explicitly, or the run fails. e2e/suites/${provider}.e2e.test.ts e2e/suites/sync-manager.e2e.test.ts e2e/suites/source-control-flows.e2e.test.ts \ No newline at end of file diff --git a/scripts/run-e2e.sh b/scripts/run-e2e.sh index 0c85aab..1da3be1 100755 --- a/scripts/run-e2e.sh +++ b/scripts/run-e2e.sh @@ -4,8 +4,8 @@ # vitest suites, then clean up (even on failure). CI calls this same script # (see .github/workflows/ci.yml), so the suite list lives in exactly one # place: scripts/e2e-suites.txt. Add a new shared suite there and both local -# and CI pick it up; scripts/check-e2e-suite-registration.mjs fails CI if a -# suite file isn't registered. +# and CI pick it up; this script's own forward/reverse checks below fail the +# run if a suite file isn't registered (or a manifest entry doesn't exist). set -euo pipefail provider="" @@ -42,12 +42,66 @@ scripts/e2e-harness.sh seed # Suite manifest: scripts/e2e-suites.txt (single source of truth). ${provider} # expands to the active provider's contract suite; the rest are shared suites. # `|| [ -n "$line" ]` keeps the last line even without a trailing newline. -SUITES=() +PROVIDERS=(github gitlab gitea) +manifest_has_dynamic=0 +SHARED_SUITES=() while IFS= read -r line || [ -n "$line" ]; do case "$line" in ''|\#*) continue;; esac - SUITES+=("$(printf '%s' "$line" | sed "s/\${provider}/$provider/g")") + if [[ "$line" == *'${provider}'* ]]; then + manifest_has_dynamic=1 + continue + fi + SHARED_SUITES+=("$line") done < scripts/e2e-suites.txt +if [ "$manifest_has_dynamic" -ne 1 ]; then + echo "scripts/e2e-suites.txt is missing a \${provider} line -- provider-specific suites (github/gitlab/gitea) would not run." >&2 + exit 1 +fi + +SUITES=("e2e/suites/${provider}.e2e.test.ts" "${SHARED_SUITES[@]}") + +# Forward check: every manifest entry (after ${provider} expansion) must +# exist on disk -- catches a typo'd or deleted suite path in the manifest. +for suite in "${SUITES[@]}"; do + if [[ ! -f "$suite" ]]; then + echo "E2E suite not found: $suite" >&2 + exit 1 + fi +done + +# Reverse check: every e2e/suites/*.e2e.test.ts file on disk must be either a +# known provider suite (github/gitlab/gitea -- covered by the ${provider} +# line regardless of which provider this run targets) or a shared suite +# explicitly registered in the manifest. Catches a new suite file added +# without wiring it into scripts/e2e-suites.txt, which would otherwise pass +# CI without ever running (the exact "fake green" this guards against). +is_shared_suite() { + local candidate="$1" s + for s in "${SHARED_SUITES[@]}"; do + [[ "$s" == "$candidate" ]] && return 0 + done + return 1 +} +unregistered=() +for file in e2e/suites/*.e2e.test.ts; do + [ -e "$file" ] || continue + base="$(basename "$file" .e2e.test.ts)" + is_known_provider=0 + for p in "${PROVIDERS[@]}"; do + [ "$base" = "$p" ] && is_known_provider=1 && break + done + [ "$is_known_provider" -eq 1 ] && continue + is_shared_suite "$file" || unregistered+=("$file") +done +if [ "${#unregistered[@]}" -gt 0 ]; then + echo "Unregistered E2E suite file(s) -- add to scripts/e2e-suites.txt:" >&2 + printf ' %s\n' "${unregistered[@]}" >&2 + exit 1 +fi + +echo "[run-e2e] running suites: ${SUITES[*]}" >&2 + # vitest.e2e.config.ts's `include` matches every e2e/suites/*.e2e.test.ts, so # the other two providers' suites would also try to run (and fail on missing # credentials) if not explicitly limited to this list. source-control-flows From 54e3fb75e82c35a9ab2abdafe89caec4d89d9302 Mon Sep 17 00:00:00 2001 From: tianyao Date: Sat, 22 Aug 2026 12:04:00 +0000 Subject: [PATCH 022/104] fix(test): show per-test progress in real-provider E2E CI logs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit vitest's default reporter only prints once a whole file finishes, and these suites do real network round trips per test — in CI that reads as a silent hang. Switch to the verbose reporter so each test prints as it completes. Co-Authored-By: Claude Sonnet 5 --- vitest.e2e.config.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts index 284e929..0d618aa 100644 --- a/vitest.e2e.config.ts +++ b/vitest.e2e.config.ts @@ -25,6 +25,11 @@ export default defineConfig({ exclude: ['**/node_modules/**', '**/.claude/**'], testTimeout: 120_000, hookTimeout: 120_000, + // Real-provider suites run network round trips per test with nothing + // printed until a whole file finishes under the default reporter — + // in CI that reads as a hang. verbose prints each test as it + // completes, so progress is visible while it's still running. + reporters: ['verbose'], // Provisioning spins up one container per provider; running suites in // parallel workers would multiply that for no benefit at this scale. fileParallelism: false, From 039588feeef4d2956739534b13588404fbb984fd Mon Sep 17 00:00:00 2001 From: tianyao Date: Sat, 22 Aug 2026 12:12:03 +0000 Subject: [PATCH 023/104] fix(test): capture post-push head before asserting no-op repeat push MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit headAfterFirst was captured before the first push instead of after, so expectNoCommitSince compared against the pre-push head — failing on the commit the first push itself legitimately created, not on any duplicate mutation from the second push. Co-Authored-By: Claude Sonnet 5 --- e2e/suites/source-control-flows.e2e.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/suites/source-control-flows.e2e.test.ts b/e2e/suites/source-control-flows.e2e.test.ts index 88a28a8..5403dd8 100644 --- a/e2e/suites/source-control-flows.e2e.test.ts +++ b/e2e/suites/source-control-flows.e2e.test.ts @@ -627,12 +627,12 @@ describe('Source Control Flows E2E', () => { await s.baseline(p, 'v1'); s.writeLocal(p, 'v2'); - const headAfterFirst = await s.head(); const first = await s.push([p]); expect(first.success, describePushResult(first)).toBe(1); await s.expectRemoteContent(p, 'v2'); const shaAfterFirst = s.metadataSha(p); expect(shaAfterFirst).toBeTruthy(); + const headAfterFirst = await s.head(); const second = await s.push([p]); expect(second.success, describePushResult(second)).toBe(0); From d6cdc36049ee7d728e838e7b339b88292149e9d4 Mon Sep 17 00:00:00 2001 From: ClaudiaFang Date: Sat, 22 Aug 2026 20:18:57 +0800 Subject: [PATCH 024/104] fix(settings): keep release history accessible after dismiss --- src/settings-implementation.ts | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/settings-implementation.ts b/src/settings-implementation.ts index 1030495..07edc2a 100644 --- a/src/settings-implementation.ts +++ b/src/settings-implementation.ts @@ -2,6 +2,7 @@ import {App, PluginSettingTab, Setting, Notice, TextComponent} 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"; @@ -172,10 +173,8 @@ export class GitLabSyncSettingTab extends PluginSettingTab { } // Persistent (until dismissed) banner surfacing the current version's notable - // highlights right at the top of the settings tab, so users who dismissed or - // never saw the WhatsNewModal (see main.ts) can still find them. Separate - // from `lastSeenVersion` — that gate controls the once-per-upgrade modal, - // this one just tracks whether the banner itself was dismissed. + // 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; @@ -206,6 +205,17 @@ export class GitLabSyncSettingTab extends PluginSettingTab { }); } + 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).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 @@ -249,6 +259,7 @@ export class GitLabSyncSettingTab extends PluginSettingTab { containerEl.empty(); this.renderWhatsNewBanner(containerEl); + this.renderReleaseHistorySetting(containerEl); this.renderConnectionStatus(containerEl); new Setting(containerEl) @@ -490,7 +501,7 @@ export class GitLabSyncSettingTab extends PluginSettingTab { .setPlaceholder('https://gitea.example.com') .setValue(this.plugin.settings.giteaBaseUrl) .onChange((value) => { - this.plugin.settings.giteaBaseUrl = value; + this.plugin.settings.giteaBaseUrl = value || 'https://gitea.example.com'; void this.plugin.saveSettings(); this.plugin.initializeGitService(); this.scheduleConnectionTest(); From b9a90b29a31b82254b6b4e9646ecec29db53a361 Mon Sep 17 00:00:00 2001 From: tianyao Date: Sat, 22 Aug 2026 12:18:49 +0000 Subject: [PATCH 025/104] perf(test): memoize remote reads in source-control-flows scenarios MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each remote-read verifier call (getFile/fileMissing/listCommitShas) does its own `git fetch origin ` even when nothing has mutated the remote since the last read in the same test — most tests do 3-5 such reads per push. Cache them in SourceControlScenario, invalidated on any call to manager.pushFiles/pullFile or service.pushFile/deleteFile. The invalidation hooks onto the manager/service instances themselves (via a thin Proxy), not this class's own push()/baseline() wrappers, so it stays correct even for mutations this class doesn't mediate directly — e.g. the selection stack's `actionService.push()`, which calls manager.pushFiles through BoundarySyncWorkspace. Scoped to source-control-flows.e2e.test.ts only (the sole consumer of SourceControlScenario); the shared GitVerifier and the other real-provider suites are untouched. Co-Authored-By: Claude Sonnet 5 --- e2e/support/source-control-scenarios.ts | 64 +++++++++++++++++++++---- 1 file changed, 56 insertions(+), 8 deletions(-) diff --git a/e2e/support/source-control-scenarios.ts b/e2e/support/source-control-scenarios.ts index c1edbb4..72c263b 100644 --- a/e2e/support/source-control-scenarios.ts +++ b/e2e/support/source-control-scenarios.ts @@ -37,16 +37,37 @@ export class SourceControlScenario { private readonly service: GitServiceInterface; private readonly verifier: GitVerifierType; private readonly branch: string; + /** + * Memoizes remote reads (each of which is a real `git fetch` round trip) + * between remote mutations. Invalidated by `invalidatingProxy` below + * whenever `manager.pushFiles`/`pullFile` or `service.pushFile`/ + * `deleteFile` is called on the wrapped instances this scenario hands + * out — including indirectly, e.g. via the selection stack's + * `actionService.push`, which calls `manager.pushFiles` through + * `BoundarySyncWorkspace` rather than through this class's own `push()`. + * Wrapping the instances themselves (instead of only this class's + * wrapper methods) is what makes that indirect path safe to cache too. + */ + private readonly remoteCache = new Map(); constructor(fixture: SyncManagerFixture) { this.vault = fixture.createVault(); this.settings = fixture.makeSettings(); - this.manager = fixture.newManager(this.vault, this.settings); - this.service = fixture.service; + const invalidate = (): void => this.remoteCache.clear(); + this.manager = invalidatingProxy(fixture.newManager(this.vault, this.settings), ['pushFiles', 'pullFile'], invalidate); + this.service = invalidatingProxy(fixture.service, ['pushFile', 'deleteFile'], invalidate); this.verifier = fixture.verifier; this.branch = fixture.branch; } + /** Runs `fn` once and memoizes it under `key` until the next remote mutation. */ + private async cachedRemote(key: string, fn: () => Promise): Promise { + if (this.remoteCache.has(key)) return this.remoteCache.get(key) as T; + const value = await fn(); + this.remoteCache.set(key, value); + return value; + } + // --- local vault ops ------------------------------------------------- writeLocal(path: string, content: string | ArrayBuffer): void { @@ -113,36 +134,40 @@ export class SourceControlScenario { // --- independent remote assertions (via the git-CLI verifier) -------- async remoteContent(path: string): Promise<{ content: string; sha: string } | null> { - return this.verifier.getFile(path, this.branch); + return this.cachedRemote(`file:${path}`, () => this.verifier.getFile(path, this.branch)); } async expectRemoteContent(path: string, expected: string): Promise { - const remote = await this.verifier.getFile(path, this.branch); + const remote = await this.cachedRemote(`file:${path}`, () => this.verifier.getFile(path, this.branch)); expect(remote?.content, `remote content for ${path}`).toBe(expected); } async expectRemoteMissing(path: string): Promise { - expect(await this.verifier.fileMissing(path, this.branch), `expected ${path} missing on remote`).toBe(true); + expect(await this.cachedRemote(`missing:${path}`, () => this.verifier.fileMissing(path, this.branch)), `expected ${path} missing on remote`).toBe(true); } async expectRemoteExists(path: string): Promise { - expect(await this.verifier.fileMissing(path, this.branch), `expected ${path} present on remote`).toBe(false); + expect(await this.cachedRemote(`missing:${path}`, () => this.verifier.fileMissing(path, this.branch)), `expected ${path} present on remote`).toBe(false); } /** Current branch tip sha. */ async head(): Promise { - const [tip] = await this.verifier.listCommitShas(this.branch, 1); + const [tip] = await this.cachedRemote('shas:2', () => this.verifier.listCommitShas(this.branch, 2)); return tip!; } /** Newest-first commit shas on the branch (independent of the service). */ async listCommitShas(count: number): Promise { + if (count <= 2) { + const shas = await this.cachedRemote('shas:2', () => this.verifier.listCommitShas(this.branch, 2)); + return shas.slice(0, count); + } return this.verifier.listCommitShas(this.branch, count); } /** Asserts exactly one new commit landed since `headBefore` (the new commit's parent is `headBefore`). */ async expectSingleCommitSince(headBefore: string): Promise { - const [headAfter, headAfterParent] = await this.verifier.listCommitShas(this.branch, 2); + const [headAfter, headAfterParent] = await this.cachedRemote('shas:2', () => this.verifier.listCommitShas(this.branch, 2)); expect(headAfter, 'expected a new commit on the branch').not.toBe(headBefore); expect(headAfterParent, 'expected exactly one new commit since baseline').toBe(headBefore); } @@ -204,6 +229,29 @@ export interface SelectionStack { readonly workspace: BoundarySyncWorkspace; } +/** + * Wraps `target` so that calling any method named in `mutatingMethods` still + * behaves exactly as before, but also invokes `onMutation` once the call + * resolves. Every other property/method passes through untouched. Used to + * invalidate SourceControlScenario's remote-read cache on every path that + * can mutate the remote — including ones this file doesn't call directly + * (e.g. BoundarySyncWorkspace invoking `manager.pushFiles`). + */ +function invalidatingProxy(target: T, mutatingMethods: (keyof T)[], onMutation: () => void): T { + return new Proxy(target, { + get(obj, prop, receiver): unknown { + const value: unknown = Reflect.get(obj, prop, receiver); + if (typeof value !== 'function') return value; + if (!mutatingMethods.includes(prop as keyof T)) return value.bind(obj); + return async (...args: unknown[]) => { + const result: unknown = await (value as (...a: unknown[]) => unknown).apply(obj, args); + onMutation(); + return result; + }; + }, + }); +} + /** Builds a SyncChange with a path-derived ChangeId (mirrors FileStatusAdapter). */ export function change(path: string, kind: SyncChange['kind'], previousPath?: string): SyncChange { return { id: toChangeId(path), path, kind, previousPath }; From c37e37ceffa4d86edce33ba9a985b74f8defd44d Mon Sep 17 00:00:00 2001 From: tianyao Date: Sat, 22 Aug 2026 12:33:28 +0000 Subject: [PATCH 026/104] fix(settings): keep release history accessible after dismiss Missing i18n keys (settings.releaseHistory.name/desc/button) referenced by settings-implementation.ts's renderReleaseHistorySetting broke npm run build. Add them to all three locales. Co-Authored-By: Claude Sonnet 5 --- src/i18n/locales/en.ts | 4 ++++ src/i18n/locales/zh-cn.ts | 4 ++++ src/i18n/locales/zh-tw.ts | 4 ++++ 3 files changed, 12 insertions(+) diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index efde946..16a8df9 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -115,6 +115,10 @@ const en = { 'settings.whatsNewBanner.title': "What's new in v{version}", 'settings.whatsNewBanner.dismiss': 'Dismiss', + 'settings.releaseHistory.name': 'Release history', + 'settings.releaseHistory.desc': 'View past release notes for this plugin.', + 'settings.releaseHistory.button': 'View history', + 'syncStatus.viewTitle': 'Sync status', 'syncStatus.emptyPrompt': 'Click "Refresh" to check sync status', 'syncStatus.progress.checkingWithCount': 'Checking files… {current}/{total} ({pct}%)', diff --git a/src/i18n/locales/zh-cn.ts b/src/i18n/locales/zh-cn.ts index 145d004..f336e50 100644 --- a/src/i18n/locales/zh-cn.ts +++ b/src/i18n/locales/zh-cn.ts @@ -117,6 +117,10 @@ const zhCn: Partial> = { 'settings.whatsNewBanner.title': 'v{version} 更新重点', 'settings.whatsNewBanner.dismiss': '关闭提示', + 'settings.releaseHistory.name': '发布记录', + 'settings.releaseHistory.desc': '查看此插件过去的发布说明。', + 'settings.releaseHistory.button': '查看记录', + 'syncStatus.viewTitle': '同步状态', 'syncStatus.emptyPrompt': '点击「刷新」以检查同步状态', 'syncStatus.progress.checkingWithCount': '检查文件中… {current}/{total}({pct}%)', diff --git a/src/i18n/locales/zh-tw.ts b/src/i18n/locales/zh-tw.ts index 8fe3130..a479461 100644 --- a/src/i18n/locales/zh-tw.ts +++ b/src/i18n/locales/zh-tw.ts @@ -117,6 +117,10 @@ const zhTw: Partial> = { 'settings.whatsNewBanner.title': 'v{version} 更新重點', 'settings.whatsNewBanner.dismiss': '關閉提示', + 'settings.releaseHistory.name': '發布紀錄', + 'settings.releaseHistory.desc': '查看此外掛過去的發布說明。', + 'settings.releaseHistory.button': '查看紀錄', + 'syncStatus.viewTitle': '同步狀態', 'syncStatus.emptyPrompt': '點擊「重新整理」以檢查同步狀態', 'syncStatus.progress.checkingWithCount': '檢查檔案中… {current}/{total}({pct}%)', From 8228a049603872fdb83d372295456274ed706c30 Mon Sep 17 00:00:00 2001 From: Tianyao Date: Sat, 22 Aug 2026 20:33:42 +0800 Subject: [PATCH 027/104] test(settings): keep release history accessible after dismiss --- src/i18n/locales/en.ts | 3 +++ src/i18n/locales/zh-cn.ts | 3 +++ src/i18n/locales/zh-tw.ts | 3 +++ tests/ui/SettingsConnectionStatus.test.ts | 22 ++++++++++++++++++++++ 4 files changed, 31 insertions(+) diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 16a8df9..e5b3d31 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -114,6 +114,9 @@ const en = { 'settings.whatsNewBanner.title': "What's new in v{version}", 'settings.whatsNewBanner.dismiss': 'Dismiss', + 'settings.releaseHistory.name': 'Release history', + 'settings.releaseHistory.desc': "Review what's new in current and previous versions", + 'settings.releaseHistory.button': 'View release history', 'settings.releaseHistory.name': 'Release history', 'settings.releaseHistory.desc': 'View past release notes for this plugin.', diff --git a/src/i18n/locales/zh-cn.ts b/src/i18n/locales/zh-cn.ts index f336e50..122220d 100644 --- a/src/i18n/locales/zh-cn.ts +++ b/src/i18n/locales/zh-cn.ts @@ -116,6 +116,9 @@ const zhCn: Partial> = { 'settings.whatsNewBanner.title': 'v{version} 更新重点', 'settings.whatsNewBanner.dismiss': '关闭提示', + 'settings.releaseHistory.name': '版本更新记录', + 'settings.releaseHistory.desc': '查看当前与过往版本的更新内容', + 'settings.releaseHistory.button': '查看更新记录', 'settings.releaseHistory.name': '发布记录', 'settings.releaseHistory.desc': '查看此插件过去的发布说明。', diff --git a/src/i18n/locales/zh-tw.ts b/src/i18n/locales/zh-tw.ts index a479461..913a0a0 100644 --- a/src/i18n/locales/zh-tw.ts +++ b/src/i18n/locales/zh-tw.ts @@ -116,6 +116,9 @@ const zhTw: Partial> = { 'settings.whatsNewBanner.title': 'v{version} 更新重點', 'settings.whatsNewBanner.dismiss': '關閉提示', + 'settings.releaseHistory.name': '版本更新紀錄', + 'settings.releaseHistory.desc': '查看目前與過往版本的更新內容', + 'settings.releaseHistory.button': '查看更新紀錄', 'settings.releaseHistory.name': '發布紀錄', 'settings.releaseHistory.desc': '查看此外掛過去的發布說明。', diff --git a/tests/ui/SettingsConnectionStatus.test.ts b/tests/ui/SettingsConnectionStatus.test.ts index 6da4ce4..453a714 100644 --- a/tests/ui/SettingsConnectionStatus.test.ts +++ b/tests/ui/SettingsConnectionStatus.test.ts @@ -121,3 +121,25 @@ describe('GitLabSyncSettingTab ignore patterns setting', () => { expect(textarea.value).toBe('draft/\n*.tmp'); }); }); + +describe('GitLabSyncSettingTab release history', () => { + it('keeps release history accessible after the current-version banner was dismissed', () => { + vi.useFakeTimers(); + 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); + tab.containerEl = createContainer(); + + try { + tab.display(); + + expect(tab.containerEl.querySelector('.gfs-whats-new-banner')).toBeNull(); + const buttons = Array.from(tab.containerEl.querySelectorAll('button')); + expect(buttons.some(button => button.textContent === 'View release history')).toBe(true); + } finally { + vi.clearAllTimers(); + vi.useRealTimers(); + } + }); +}); From de5565324f702e822ec138a411bcb44db568196a Mon Sep 17 00:00:00 2001 From: tianyao Date: Sat, 22 Aug 2026 12:37:08 +0000 Subject: [PATCH 028/104] fix(i18n): remove duplicate releaseHistory keys from concurrent fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit My earlier fix and 8228a04 (concurrent push) both added settings.releaseHistory.{name,desc,button} independently, so the rebase merged in two copies of each key per locale file — TS1117 (duplicate object literal property). Keep 8228a04's wording, drop mine. Co-Authored-By: Claude Sonnet 5 --- src/i18n/locales/en.ts | 4 ---- src/i18n/locales/zh-cn.ts | 4 ---- src/i18n/locales/zh-tw.ts | 4 ---- 3 files changed, 12 deletions(-) diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index e5b3d31..7e5a746 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -118,10 +118,6 @@ const en = { 'settings.releaseHistory.desc': "Review what's new in current and previous versions", 'settings.releaseHistory.button': 'View release history', - 'settings.releaseHistory.name': 'Release history', - 'settings.releaseHistory.desc': 'View past release notes for this plugin.', - 'settings.releaseHistory.button': 'View history', - 'syncStatus.viewTitle': 'Sync status', 'syncStatus.emptyPrompt': 'Click "Refresh" to check sync status', 'syncStatus.progress.checkingWithCount': 'Checking files… {current}/{total} ({pct}%)', diff --git a/src/i18n/locales/zh-cn.ts b/src/i18n/locales/zh-cn.ts index 122220d..ea0774c 100644 --- a/src/i18n/locales/zh-cn.ts +++ b/src/i18n/locales/zh-cn.ts @@ -120,10 +120,6 @@ const zhCn: Partial> = { 'settings.releaseHistory.desc': '查看当前与过往版本的更新内容', 'settings.releaseHistory.button': '查看更新记录', - 'settings.releaseHistory.name': '发布记录', - 'settings.releaseHistory.desc': '查看此插件过去的发布说明。', - 'settings.releaseHistory.button': '查看记录', - 'syncStatus.viewTitle': '同步状态', 'syncStatus.emptyPrompt': '点击「刷新」以检查同步状态', 'syncStatus.progress.checkingWithCount': '检查文件中… {current}/{total}({pct}%)', diff --git a/src/i18n/locales/zh-tw.ts b/src/i18n/locales/zh-tw.ts index 913a0a0..cda7811 100644 --- a/src/i18n/locales/zh-tw.ts +++ b/src/i18n/locales/zh-tw.ts @@ -120,10 +120,6 @@ const zhTw: Partial> = { 'settings.releaseHistory.desc': '查看目前與過往版本的更新內容', 'settings.releaseHistory.button': '查看更新紀錄', - 'settings.releaseHistory.name': '發布紀錄', - 'settings.releaseHistory.desc': '查看此外掛過去的發布說明。', - 'settings.releaseHistory.button': '查看紀錄', - 'syncStatus.viewTitle': '同步狀態', 'syncStatus.emptyPrompt': '點擊「重新整理」以檢查同步狀態', 'syncStatus.progress.checkingWithCount': '檢查檔案中… {current}/{total}({pct}%)', From b1d22083237a9bf563d641685618164a3c23641a Mon Sep 17 00:00:00 2001 From: Tianyao Date: Sat, 22 Aug 2026 20:55:34 +0800 Subject: [PATCH 029/104] perf(ci): gate and tier real-provider E2E --- .github/workflows/ci.yml | 26 ++++++++++++++-- e2e/suites/source-control-flows.e2e.test.ts | 33 +++++++++++---------- scripts/run-e2e.sh | 19 ++++++++++++ scripts/run-preflight.sh | 19 ++++++++++++ 4 files changed, 80 insertions(+), 17 deletions(-) create mode 100755 scripts/run-preflight.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bd6a6e8..aa4d015 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,11 +57,33 @@ jobs: - 'package-lock.json' - '.github/workflows/ci.yml' + # Fast local gate: run cheap deterministic checks in parallel before any + # real-provider E2E spends remote API time. The release-critical reusable CI + # still runs after E2E below; this is only an early failure gate. + preflight: + name: Preflight / ${{ matrix.check }} + runs-on: ubuntu-latest + strategy: + fail-fast: true + max-parallel: 3 + matrix: + check: [lint, test, build] + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: '22' + cache: npm + - run: npm ci --ignore-scripts + - name: Run ${{ matrix.check }} + run: bash scripts/run-preflight.sh "${{ matrix.check }}" + # Real-provider E2E: one matrix job covering GitHub, GitLab, and Gitea (see - # docs/testing/real-provider-e2e.md). + # docs/testing/real-provider-e2e.md). It starts only after the fast local + # preflight passes, then provider legs run in parallel. provider-e2e: name: E2E / ${{ matrix.provider }} - needs: changes + needs: [changes, preflight] runs-on: [self-hosted, linux, x64, 32gb-ram] # Runs when sync/provider-relevant paths changed, or unconditionally on # workflow_dispatch/schedule/a push to main (main always gets the full diff --git a/e2e/suites/source-control-flows.e2e.test.ts b/e2e/suites/source-control-flows.e2e.test.ts index 5403dd8..ded8171 100644 --- a/e2e/suites/source-control-flows.e2e.test.ts +++ b/e2e/suites/source-control-flows.e2e.test.ts @@ -14,9 +14,12 @@ vi.mock('../../src/ui/BatchConflictResolutionModal'); // Provider matrix: Core scenarios run on every provider; Extended scenarios // (rename chains, unicode, batch-scale, etc.) exercise SyncManager/model -// behavior that's provider-agnostic, so they run on GitHub only to keep -// real-API CI fast and stable. Stress (1000-file) is opt-in via E2E_STRESS=1. +// behavior that's provider-agnostic. PR/branch CI runs the core tier; GitHub +// main, schedule, manual, and local runs use the full tier. Stress (1000-file) +// remains opt-in via E2E_STRESS=1. const isGitHub = process.env.E2E_PROVIDER === 'github'; +const e2eTier = process.env.E2E_TIER ?? 'full'; +const runExtended = isGitHub && e2eTier !== 'core'; const isStress = process.env.E2E_STRESS === '1'; describe('Source Control Flows E2E', () => { @@ -86,7 +89,7 @@ describe('Source Control Flows E2E', () => { // Extended: nested move + rename chain (SyncManager/model behavior, // provider-agnostic) — GitHub only. - it.skipIf(!isGitHub)('moves files across nested directories in one commit', async () => { + it.skipIf(!runExtended)('moves files across nested directories in one commit', async () => { const s = scenario(); const oldFlat = path('nested-move/folder/a.md'); const oldNested = path('nested-move/folder/nested/b.md'); @@ -112,7 +115,7 @@ describe('Source Control Flows E2E', () => { await s.expectSingleCommitSince(headBefore); }); - it.skipIf(!isGitHub)('collapses a rename chain (A->B->C) into a single move of the original path', async () => { + it.skipIf(!runExtended)('collapses a rename chain (A->B->C) into a single move of the original path', async () => { const s = scenario(); const a = path('rename-chain/a.md'); const b = path('rename-chain/b.md'); @@ -248,7 +251,7 @@ describe('Source Control Flows E2E', () => { await s.expectSingleCommitSince(headBefore); }); - it.skipIf(!isGitHub)('overwrites a remotely-created file with local content on a no-baseline add/add (current contract)', async () => { + it.skipIf(!runExtended)('overwrites a remotely-created file with local content on a no-baseline add/add (current contract)', async () => { const s = scenario(); const p = path('conflict-add-add/a.md'); await s.seedRemote(p, 'remote'); @@ -343,7 +346,7 @@ describe('Source Control Flows E2E', () => { // Phase 5 — Mixed batch operations // ------------------------------------------------------------------ describe('mixed batch operations', () => { - it.skipIf(!isGitHub)('pushes a create + modify + rename in one commit', async () => { + it.skipIf(!runExtended)('pushes a create + modify + rename in one commit', async () => { const s = scenario(); const create = path('mixed-cmr/create.md'); const modify = path('mixed-cmr/modify.md'); @@ -478,7 +481,7 @@ describe('Source Control Flows E2E', () => { expect(selection.isIncluded(cc.id)).toBe(true); }); - it.skipIf(!isGitHub)('pushes a subset then the remaining subset as two separate commits', async () => { + it.skipIf(!runExtended)('pushes a subset then the remaining subset as two separate commits', async () => { const s = scenario(); const a = path('subset-then-rest/a.md'); const b = path('subset-then-rest/b.md'); @@ -517,7 +520,7 @@ describe('Source Control Flows E2E', () => { await s.expectRemoteContent(c, 'c-v2'); }); - it.skipIf(!isGitHub)('rename yields a path-derived ChangeId; selecting the new id pushes the move', async () => { + it.skipIf(!runExtended)('rename yields a path-derived ChangeId; selecting the new id pushes the move', async () => { const s = scenario(); const oldP = path('selection-rename/a.md'); const newP = path('selection-rename/archive/a.md'); @@ -641,7 +644,7 @@ describe('Source Control Flows E2E', () => { expect(s.metadataSha(p), 'metadata not corrupted by the no-op repeat').toBe(shaAfterFirst); }); - it.skipIf(!isGitHub)('re-syncs cleanly after a skipped conflict (no stale operation state)', async () => { + it.skipIf(!runExtended)('re-syncs cleanly after a skipped conflict (no stale operation state)', async () => { const s = scenario(); const p = path('retry-after-skip/a.md'); await s.baseline(p, 'v1'); @@ -671,7 +674,7 @@ describe('Source Control Flows E2E', () => { // Phase 8 — Path edge cases + batch scale // ------------------------------------------------------------------ describe('path edge cases and batch scale', () => { - it.skipIf(!isGitHub)('creates, modifies, and renames a unicode-named file', async () => { + it.skipIf(!runExtended)('creates, modifies, and renames a unicode-named file', async () => { const s = scenario(); const original = path('unicode/筆記/測試文件.md'); const archived = path('unicode/筆記/已歸檔.md'); @@ -694,7 +697,7 @@ describe('Source Control Flows E2E', () => { await s.expectSingleCommitSince(headBefore); }); - it.skipIf(!isGitHub)('creates and modifies a file with spaces and symbols', async () => { + it.skipIf(!runExtended)('creates and modifies a file with spaces and symbols', async () => { const s = scenario(); const p = path('spaces/folder/my note (draft).md'); await s.baseline(p, 'draft-v1'); @@ -707,7 +710,7 @@ describe('Source Control Flows E2E', () => { await s.expectSingleCommitSince(headBefore); }); - it.skipIf(!isGitHub)('moves and modifies a deeply nested file', async () => { + it.skipIf(!runExtended)('moves and modifies a deeply nested file', async () => { const s = scenario(); const oldP = path('deep/a/b/c/d/e/note.md'); const newP = path('deep/archive/x/y/z/w/note.md'); @@ -725,7 +728,7 @@ describe('Source Control Flows E2E', () => { await s.expectSingleCommitSince(headBefore); }); - it.skipIf(!isGitHub)('creates 100 files in one commit', async () => { + it.skipIf(!runExtended)('creates 100 files in one commit', async () => { const s = scenario(); const paths = Array.from({ length: 100 }, (_, i) => path(`batch-100/${String(i).padStart(3, '0')}.md`)); for (const p of paths) s.writeLocal(p, `content ${p}`); @@ -740,7 +743,7 @@ describe('Source Control Flows E2E', () => { await s.expectRemoteContent(paths[99]!, `content ${paths[99]}`); }); - it.skipIf(!isGitHub)('pushes a 100-file mixed batch (modify + create + rename) in one commit', async () => { + it.skipIf(!runExtended)('pushes a 100-file mixed batch (modify + create + rename) in one commit', async () => { const s = scenario(); const modifyPaths = Array.from({ length: 40 }, (_, i) => path(`mixed-100/modify/${i}.md`)); const createPaths = Array.from({ length: 30 }, (_, i) => path(`mixed-100/create/${i}.md`)); @@ -769,7 +772,7 @@ describe('Source Control Flows E2E', () => { await s.expectRemoteContent(renameNew[0]!, 'r-v1'); }); - it.skipIf(!isStress || !isGitHub)('stress: creates 1000 files', async () => { + it.skipIf(!isStress || !runExtended)('stress: creates 1000 files', async () => { const s = scenario(); const paths = Array.from({ length: 1000 }, (_, i) => path(`batch-1000/${String(i).padStart(4, '0')}.md`)); for (const p of paths) s.writeLocal(p, `content ${p}`); diff --git a/scripts/run-e2e.sh b/scripts/run-e2e.sh index 1da3be1..dbdd9fc 100755 --- a/scripts/run-e2e.sh +++ b/scripts/run-e2e.sh @@ -9,10 +9,13 @@ set -euo pipefail provider="" +tier="auto" while [[ $# -gt 0 ]]; do case "$1" in --provider) provider="$2"; shift 2 ;; --provider=*) provider="${1#*=}"; shift ;; + --tier) tier="$2"; shift 2 ;; + --tier=*) tier="${1#*=}"; shift ;; *) shift ;; esac done @@ -21,7 +24,22 @@ if [ -z "$provider" ]; then exit 1 fi +if [ "$tier" = "auto" ]; then + if [ "${GITHUB_ACTIONS:-}" != "true" ]; then + tier="full" + elif [ "$provider" = "github" ] && { [ "${GITHUB_REF_NAME:-}" = "main" ] || [ "${GITHUB_REF_NAME:-}" = "master" ] || [ "${GITHUB_EVENT_NAME:-}" = "schedule" ] || [ "${GITHUB_EVENT_NAME:-}" = "workflow_dispatch" ]; }; then + tier="full" + else + tier="core" + fi +fi +if [ "$tier" != "core" ] && [ "$tier" != "full" ]; then + echo "Invalid E2E tier: $tier (expected core|full|auto)" >&2 + exit 1 +fi + export E2E_PROVIDER="$provider" +export E2E_TIER="$tier" export E2E_WORKDIR="${E2E_WORKDIR:-${TMPDIR:-/tmp}/gfs-e2e-${provider}}" cleanup() { @@ -100,6 +118,7 @@ if [ "${#unregistered[@]}" -gt 0 ]; then exit 1 fi +echo "[run-e2e] tier=$E2E_TIER provider=$E2E_PROVIDER" >&2 echo "[run-e2e] running suites: ${SUITES[*]}" >&2 # vitest.e2e.config.ts's `include` matches every e2e/suites/*.e2e.test.ts, so diff --git a/scripts/run-preflight.sh b/scripts/run-preflight.sh new file mode 100755 index 0000000..e5b8b9f --- /dev/null +++ b/scripts/run-preflight.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail + +check="${1:-}" +case "$check" in + lint) + npm run lint + ;; + test) + npm test + ;; + build) + npm run build + ;; + *) + echo "Usage: scripts/run-preflight.sh " >&2 + exit 2 + ;; +esac From f4491254a1a7fe190e8a18dd2cf912c620426133 Mon Sep 17 00:00:00 2001 From: Tianyao Date: Sat, 22 Aug 2026 23:01:42 +0800 Subject: [PATCH 030/104] feat(source-control): full-width diff tab and mobile view-title dedup Add a main-area DiffTabView for viewing a change's diff at full width on desktop (with a split/unified layout toggle), instead of splitting the narrow sidebar. Also collapse the Sync status panel's duplicated title (native tab title + in-panel header both said "Source Control" on mobile) into one "Sync status" label, and rename the panel's Push button to "Sync". Co-Authored-By: Claude Sonnet 5 --- src/i18n/locales/en.ts | 20 +- src/i18n/locales/zh-cn.ts | 20 +- src/i18n/locales/zh-tw.ts | 20 +- src/main.ts | 26 ++ src/ui/SyncConflictModal.ts | 110 ++----- src/ui/components/DiffLayoutToggle.ts | 33 ++ src/ui/components/icons.ts | 3 + src/ui/source-control/ChangeTree.ts | 22 ++ src/ui/source-control/DiffTabView.ts | 77 +++++ src/ui/source-control/SourceControlHeader.ts | 52 ++- .../source-control/SourceControlItemView.ts | 38 ++- src/ui/source-control/SourceControlView.ts | 136 ++++++-- styles.css | 310 +++++++++++++----- tests/ui/SyncConflictModal.test.ts | 37 ++- tests/ui/components/DiffLayoutToggle.test.ts | 35 ++ tests/ui/source-control/ChangeTree.test.ts | 65 ++++ tests/ui/source-control/DiffTabView.test.ts | 58 ++++ .../SourceControlItemView.test.ts | 77 ++++- .../source-control/SourceControlView.test.ts | 69 +++- 19 files changed, 982 insertions(+), 226 deletions(-) create mode 100644 src/ui/components/DiffLayoutToggle.ts create mode 100644 src/ui/source-control/DiffTabView.ts create mode 100644 tests/ui/components/DiffLayoutToggle.test.ts create mode 100644 tests/ui/source-control/DiffTabView.test.ts diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 7e5a746..796e03f 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -211,11 +211,6 @@ const en = { 'syncConflictModal.title': 'Conflict in {fileName}', 'syncConflictModal.description': 'The remote file has different content. Review the differences and choose which version to keep.', - 'syncConflictModal.tab.diff': 'Diff', - 'syncConflictModal.tab.local': 'Local', - 'syncConflictModal.tab.remote': 'Remote', - 'syncConflictModal.localVersion': 'Local version', - 'syncConflictModal.remoteVersion': 'Remote version', 'syncConflictModal.differences': 'Differences', 'syncConflictModal.keepLocal': 'Keep local', 'syncConflictModal.keepLocal.tooltip': 'Overwrite remote with your local content', @@ -253,7 +248,6 @@ const en = { 'batchConflictModal.cancel': 'Cancel', 'batchConflictModal.unresolvedWarning': 'Choose a resolution for every conflict before continuing.', - 'sourceControl.viewTitle': 'Source Control', 'sourceControl.filter.all': 'All', 'sourceControl.filter.changes': 'Changes', 'sourceControl.filter.readyToPush': 'Ready to Push', @@ -267,11 +261,19 @@ const en = { 'sourceControl.section.remoteChanges': 'REMOTE CHANGES', 'sourceControl.section.conflicts': 'CONFLICTS', 'sourceControl.section.synced': 'SYNCED', - 'sourceControl.push': ' Push ({count})', + 'sourceControl.push': ' Sync ({count})', 'sourceControl.push.tooltip': 'Push {count} ready file(s)', 'sourceControl.empty': 'No changes', - 'sourceControl.diff.selectPrompt': 'Select a change to see its diff.', - 'sourceControl.detail.back': ' Back', + 'sourceControl.detail.back': 'Back', + 'sourceControl.info.lastSync': 'Last sync: {time}', + 'sourceControl.info.neverSynced': 'Never synced', + 'sourceControl.search.placeholder': 'Filter by path…', + 'sourceControl.search.clear': 'Clear filter', + 'sourceControl.folder.selectAll': 'Select all in folder', + 'sourceControl.diff.switchToSplit': 'Switch to side-by-side diff', + 'sourceControl.diff.switchToUnified': 'Switch to single-column diff', + 'sourceControl.diff.split': 'Split', + 'sourceControl.diff.unified': 'Unified', }; export default en; diff --git a/src/i18n/locales/zh-cn.ts b/src/i18n/locales/zh-cn.ts index ea0774c..13c06aa 100644 --- a/src/i18n/locales/zh-cn.ts +++ b/src/i18n/locales/zh-cn.ts @@ -213,11 +213,6 @@ const zhCn: Partial> = { 'syncConflictModal.title': '{fileName} 发生冲突', 'syncConflictModal.description': '远程文件内容有所不同。请查看差异并选择要保留的版本。', - 'syncConflictModal.tab.diff': '差异', - 'syncConflictModal.tab.local': '本机', - 'syncConflictModal.tab.remote': '远程', - 'syncConflictModal.localVersion': '本机版本', - 'syncConflictModal.remoteVersion': '远程版本', 'syncConflictModal.differences': '差异', 'syncConflictModal.keepLocal': '保留本机', 'syncConflictModal.keepLocal.tooltip': '以本机内容覆盖远程', @@ -255,7 +250,6 @@ const zhCn: Partial> = { 'batchConflictModal.cancel': '取消', 'batchConflictModal.unresolvedWarning': '请先为每个冲突选择解决方式,才能继续。', - 'sourceControl.viewTitle': '源代码管理', 'sourceControl.filter.all': '全部', 'sourceControl.filter.changes': '更改', 'sourceControl.filter.readyToPush': '待推送', @@ -269,11 +263,19 @@ const zhCn: Partial> = { 'sourceControl.section.remoteChanges': '远程更改', 'sourceControl.section.conflicts': '冲突', 'sourceControl.section.synced': '已同步', - 'sourceControl.push': ' 推送 ({count})', + 'sourceControl.push': ' 同步 ({count})', 'sourceControl.push.tooltip': '推送 {count} 个已就绪的文件', 'sourceControl.empty': '没有更改', - 'sourceControl.diff.selectPrompt': '选择一项更改以查看差异。', - 'sourceControl.detail.back': ' 返回', + 'sourceControl.detail.back': '返回', + 'sourceControl.info.lastSync': '上次同步:{time}', + 'sourceControl.info.neverSynced': '尚未同步', + 'sourceControl.search.placeholder': '按路径过滤…', + 'sourceControl.search.clear': '清除过滤', + 'sourceControl.folder.selectAll': '选取文件夹内全部项目', + 'sourceControl.diff.switchToSplit': '切换为两栏式差异显示', + 'sourceControl.diff.switchToUnified': '切换为单栏式差异显示', + 'sourceControl.diff.split': '两栏', + 'sourceControl.diff.unified': '单栏', }; export default zhCn; diff --git a/src/i18n/locales/zh-tw.ts b/src/i18n/locales/zh-tw.ts index cda7811..3f421d7 100644 --- a/src/i18n/locales/zh-tw.ts +++ b/src/i18n/locales/zh-tw.ts @@ -213,11 +213,6 @@ const zhTw: Partial> = { 'syncConflictModal.title': '{fileName} 發生衝突', 'syncConflictModal.description': '遠端檔案內容有所不同。請檢視差異並選擇要保留的版本。', - 'syncConflictModal.tab.diff': '差異', - 'syncConflictModal.tab.local': '本機', - 'syncConflictModal.tab.remote': '遠端', - 'syncConflictModal.localVersion': '本機版本', - 'syncConflictModal.remoteVersion': '遠端版本', 'syncConflictModal.differences': '差異', 'syncConflictModal.keepLocal': '保留本機', 'syncConflictModal.keepLocal.tooltip': '以本機內容覆蓋遠端', @@ -255,7 +250,6 @@ const zhTw: Partial> = { 'batchConflictModal.cancel': '取消', 'batchConflictModal.unresolvedWarning': '請先為每個衝突選擇解決方式,才能繼續。', - 'sourceControl.viewTitle': '原始碼控制', 'sourceControl.filter.all': '全部', 'sourceControl.filter.changes': '變更', 'sourceControl.filter.readyToPush': '待推送', @@ -269,11 +263,19 @@ const zhTw: Partial> = { 'sourceControl.section.remoteChanges': '遠端變更', 'sourceControl.section.conflicts': '衝突', 'sourceControl.section.synced': '已同步', - 'sourceControl.push': ' 推送 ({count})', + 'sourceControl.push': ' 同步 ({count})', 'sourceControl.push.tooltip': '推送 {count} 個已就緒的檔案', 'sourceControl.empty': '沒有變更', - 'sourceControl.diff.selectPrompt': '選擇一項變更以檢視差異。', - 'sourceControl.detail.back': ' 返回', + 'sourceControl.detail.back': '返回', + 'sourceControl.info.lastSync': '上次同步:{time}', + 'sourceControl.info.neverSynced': '尚未同步', + 'sourceControl.search.placeholder': '以路徑過濾…', + 'sourceControl.search.clear': '清除過濾', + 'sourceControl.folder.selectAll': '選取資料夾內全部項目', + 'sourceControl.diff.switchToSplit': '切換為兩欄式差異顯示', + 'sourceControl.diff.switchToUnified': '切換為單欄式差異顯示', + 'sourceControl.diff.split': '兩欄', + 'sourceControl.diff.unified': '單欄', }; export default zhTw; diff --git a/src/main.ts b/src/main.ts index f2af4a3..31d2ef4 100644 --- a/src/main.ts +++ b/src/main.ts @@ -7,6 +7,7 @@ import { GitServiceInterface, GitTreeEntry } from './services/git-service-interf import { ConnectionTestResult } from './services/git-service-base'; import { 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'; import { logger } from './utils/logger'; import { ConfirmModal } from './ui/ConfirmModal'; @@ -61,6 +62,11 @@ export default class GitLabFilesPush extends Plugin { (leaf) => new SourceControlItemView(leaf, this) ); + this.registerView( + SOURCE_CONTROL_DIFF_VIEW_TYPE, + (leaf) => new DiffTabView(leaf) + ); + this.addRibbonIcon('git-compare', t('main.ribbon.openSyncStatus'), async () => { await this.activateSourceControlView(); }); @@ -414,6 +420,26 @@ export default class GitLabFilesPush extends Plugin { } } + /** + * Shows a change's diff in a main-area tab, which is where a wide + * side-by-side view has room to exist -- the Source Control panel lives + * in a narrow sidebar. Reuses the single existing diff tab (if any) + * rather than opening a new one per file. + */ + async openDiffTab(path: string, content: DiffTabContent | null): Promise { + const { workspace } = this.app; + + let leaf = workspace.getLeavesOfType(SOURCE_CONTROL_DIFF_VIEW_TYPE)[0]; + if (!leaf) { + leaf = workspace.getLeaf('tab'); + await leaf.setViewState({ type: SOURCE_CONTROL_DIFF_VIEW_TYPE, active: true }); + } + + const view = leaf.view; + if (view instanceof DiffTabView) view.setDiff(path, content); + await workspace.revealLeaf(leaf); + } + async pushAllFiles(): Promise { await this.runAllFiles('push'); } diff --git a/src/ui/SyncConflictModal.ts b/src/ui/SyncConflictModal.ts index b16d196..ad1f14d 100644 --- a/src/ui/SyncConflictModal.ts +++ b/src/ui/SyncConflictModal.ts @@ -1,8 +1,8 @@ import { App, Modal, Setting } from 'obsidian'; import { t } from '../i18n'; import { isBinaryPath } from '../utils/path'; - -type ConflictPanelName = 'diff' | 'local' | 'remote'; +import { renderDiffLayoutToggle, type DiffLayout } from './components/DiffLayoutToggle'; +import { renderDiffPanel } from './components/DiffPanel'; /** * Apply the "destructive" button style, but only when the running Obsidian @@ -77,87 +77,47 @@ export class SyncConflictModal extends Modal { })); } + /** + * The split diff layout already lays local and remote content side by + * side (with per-line highlighting, unlike a plain full-text dump), so a + * separate Local/Remote tab pair would just duplicate it -- this renders + * the diff view directly with no tab switching needed. + */ private renderTextComparison(contentEl: HTMLElement) { const localContent = this.localContent as string; const remoteContent = this.remoteContent as string; - const panels = {} as Record; - const tabs = {} as Record; - - const setActivePanel = (name: ConflictPanelName) => { - (Object.keys(panels) as ConflictPanelName[]).forEach(key => { - panels[key].toggleClass('is-active', key === name); - tabs[key].toggleClass('is-active', key === name); - }); - }; - - const tabsContainer = contentEl.createDiv({ cls: 'conflict-tabs' }); - const tabLabels: Record = { - diff: t('syncConflictModal.tab.diff'), - local: t('syncConflictModal.tab.local'), - remote: t('syncConflictModal.tab.remote') - }; - (['diff', 'local', 'remote'] as const).forEach(name => { - const tab = tabsContainer.createEl('button', { text: tabLabels[name], cls: 'conflict-tab' }); - tab.addEventListener('click', () => setActivePanel(name)); - tabs[name] = tab; - }); - const contentArea = contentEl.createDiv({ cls: 'conflict-content-area' }); - - const diffContainer = contentArea.createDiv({ cls: 'conflict-diff-container' }); - - const localSection = diffContainer.createDiv({ cls: 'conflict-section conflict-panel' }); - localSection.createEl('h3', { text: t('syncConflictModal.localVersion') }); - const localPre = localSection.createEl('pre', { cls: 'conflict-content' }); - localPre.createEl('code', { text: localContent }); - panels.local = localSection; - - const remoteSection = diffContainer.createDiv({ cls: 'conflict-section conflict-panel' }); - remoteSection.createEl('h3', { text: t('syncConflictModal.remoteVersion') }); - const remotePre = remoteSection.createEl('pre', { cls: 'conflict-content' }); - remotePre.createEl('code', { text: remoteContent }); - panels.remote = remoteSection; - - const diffSection = contentArea.createDiv({ cls: 'conflict-diff-section conflict-panel' }); - diffSection.createEl('h3', { text: t('syncConflictModal.differences') }); - const diffPre = diffSection.createEl('pre', { cls: 'conflict-diff' }); - this.renderDiff(diffPre, localContent, remoteContent); - panels.diff = diffSection; - - setActivePanel('diff'); + const diffSection = contentArea.createDiv({ cls: 'conflict-diff-section' }); + this.renderDiffTab(diffSection, remoteContent, localContent); } - private renderDiff(container: HTMLElement, localContent: string, remoteContent: string) { - const localLines = localContent.split('\n'); - const remoteLines = remoteContent.split('\n'); - - const createLine = (text: string, type: 'header' | 'added' | 'removed' | 'unchanged') => { - const lineEl = container.createSpan({ cls: `diff-line ${type}` }); - lineEl.textContent = text + '\n'; + /** + * Renders the "Diff" tab via the shared `renderDiffPanel` (same component + * as the Source Control diff views) instead of a bespoke line-diff, with + * a button to switch between split (two-column) and unified (one-column) + * layout -- defaulting to unified so the modal doesn't open unnecessarily + * wide. Only one layout is ever visible at a time (see the + * `scv-diff-layout-*` CSS rules shared with the other diff views). + */ + private renderDiffTab(container: HTMLElement, remoteContent: string, localContent: string): void { + const header = container.createDiv({ cls: 'conflict-diff-header' }); + header.createEl('h3', { text: t('syncConflictModal.differences') }); + const toggleSlot = header.createDiv({ cls: 'conflict-diff-header-toggle' }); + + const body = container.createDiv({ cls: 'scv-diff-tab-body' }); + renderDiffPanel(body, remoteContent, localContent); + + let layout: DiffLayout = 'unified'; + const applyLayout = (): void => { + body.className = `scv-diff-tab-body scv-diff-layout-${layout}`; + toggleSlot.empty(); + renderDiffLayoutToggle(toggleSlot, layout, (next) => { + layout = next; + applyLayout(); + }); }; - - createLine('--- Remote', 'header'); - createLine('+++ Local', 'header'); - createLine('', 'unchanged'); - - const maxLines = Math.max(localLines.length, remoteLines.length); - - for (let i = 0; i < maxLines; i++) { - const remoteLine = remoteLines[i]; - const localLine = localLines[i]; - - if (remoteLine !== localLine) { - if (remoteLine !== undefined) { - createLine(`- ${remoteLine}`, 'removed'); - } - if (localLine !== undefined) { - createLine(`+ ${localLine}`, 'added'); - } - } else if (remoteLine !== undefined) { - createLine(` ${remoteLine}`, 'unchanged'); - } - } + applyLayout(); } onClose() { diff --git a/src/ui/components/DiffLayoutToggle.ts b/src/ui/components/DiffLayoutToggle.ts new file mode 100644 index 0000000..91fc1c7 --- /dev/null +++ b/src/ui/components/DiffLayoutToggle.ts @@ -0,0 +1,33 @@ +import { setIcon, setTooltip } from 'obsidian'; +import { t } from '../../i18n'; +import { ICONS } from './icons'; + +export type DiffLayout = 'split' | 'unified'; + +/** + * Renders the split/unified diff layout toggle shared by every diff surface + * (mobile detail, desktop diff tab, conflict modal). Always shows a text + * label alongside the icon -- an icon-only button risks rendering as a blank + * square if the icon id isn't in the host's bundled icon set, and this + * control needs to be unmistakable regardless of that. + */ +export function renderDiffLayoutToggle( + container: HTMLElement, + layout: DiffLayout, + onToggle: (next: DiffLayout) => void, +): HTMLButtonElement { + const btn = container.createEl('button', { cls: 'scv-diff-layout-toggle' }); + const switchingTo: DiffLayout = layout === 'split' ? 'unified' : 'split'; + + setIcon(btn.createSpan({ cls: 'scv-diff-layout-toggle-icon' }), switchingTo === 'split' ? ICONS.diffSplit : ICONS.diffUnified); + btn.createSpan({ + cls: 'scv-diff-layout-toggle-label', + text: switchingTo === 'split' ? t('sourceControl.diff.split') : t('sourceControl.diff.unified'), + }); + setTooltip(btn, switchingTo === 'split' + ? t('sourceControl.diff.switchToSplit') + : t('sourceControl.diff.switchToUnified')); + + btn.addEventListener('click', () => onToggle(switchingTo)); + return btn; +} diff --git a/src/ui/components/icons.ts b/src/ui/components/icons.ts index 512aa02..e085def 100644 --- a/src/ui/components/icons.ts +++ b/src/ui/components/icons.ts @@ -28,4 +28,7 @@ export const ICONS = { // Info strip branch: 'git-branch', folder: 'folder', + // Diff layout toggle (mobile) + diffSplit: 'columns-2', + diffUnified: 'rows-2', } as const; diff --git a/src/ui/source-control/ChangeTree.ts b/src/ui/source-control/ChangeTree.ts index 478f38f..21884f2 100644 --- a/src/ui/source-control/ChangeTree.ts +++ b/src/ui/source-control/ChangeTree.ts @@ -7,10 +7,13 @@ import { } from '../../logic/source-control/ChangeTreeBuilder'; import type { SourceControlItem } from '../../logic/source-control/SourceControlViewModel'; import type { ChangeId } from '../../logic/source-control/types'; +import { t } from '../../i18n'; import { renderChangeItem, type ChangeItemCallbacks } from './ChangeItem'; export interface ChangeTreeCallbacks extends ChangeItemCallbacks { onToggleFolder: (path: string) => void; + /** Selects/deselects every file under a folder (recursively) for push in one action. */ + onToggleFolderSelect: (ids: readonly ChangeId[], selected: boolean) => void; } const builder = new ChangeTreeBuilder(); @@ -64,6 +67,15 @@ function renderFolder( const folderEl = container.createDiv({ cls: 'scv-tree-folder' }); const row = folderEl.createDiv({ cls: 'scv-tree-folder-row' }); + const fileIds = collectFileIds(folder); + const selectedCount = fileIds.filter(id => byId.get(id)?.isReadyToPush).length; + const checkbox = row.createEl('input', { type: 'checkbox', cls: 'scv-tree-folder-select' }); + checkbox.setAttr('title', t('sourceControl.folder.selectAll')); + checkbox.checked = fileIds.length > 0 && selectedCount === fileIds.length; + checkbox.indeterminate = selectedCount > 0 && selectedCount < fileIds.length; + checkbox.addEventListener('click', (evt) => evt.stopPropagation()); + checkbox.addEventListener('change', () => callbacks.onToggleFolderSelect(fileIds, checkbox.checked)); + const toggle = row.createEl('button', { cls: 'scv-tree-folder-toggle' }); toggle.setAttr('aria-expanded', String(!collapsed)); toggle.setText(collapsed ? '▶' : '▼'); @@ -87,3 +99,13 @@ function renderFile( if (!item) return; renderChangeItem(container, item, file.name, callbacks); } + +/** Recursively collects the ids of every file under a folder node, for the folder's "select all" checkbox. */ +function collectFileIds(folder: ChangeTreeFolderNode): ChangeId[] { + const ids: ChangeId[] = []; + for (const child of folder.children) { + if (child.type === 'file') ids.push(child.id); + else ids.push(...collectFileIds(child)); + } + return ids; +} diff --git a/src/ui/source-control/DiffTabView.ts b/src/ui/source-control/DiffTabView.ts new file mode 100644 index 0000000..1b61e19 --- /dev/null +++ b/src/ui/source-control/DiffTabView.ts @@ -0,0 +1,77 @@ +import { ItemView, WorkspaceLeaf } from 'obsidian'; +import { t } from '../../i18n'; +import { renderDiffLayoutToggle, type DiffLayout } from '../components/DiffLayoutToggle'; +import { renderDiffPanel } from '../components/DiffPanel'; + +export const SOURCE_CONTROL_DIFF_VIEW_TYPE = 'source-control-diff-view'; + +export interface DiffTabContent { + remote: string; + local: string; +} + +/** + * Shows one change's diff in a full main-area workspace tab. The Source + * Control panel itself lives in a narrow sidebar, so a side-by-side diff + * needs the width a main-area tab gives it instead of splitting that + * sidebar in half (docs/source-control-refactor mirrors the pre-refactor + * DiffView's rationale). Only one of these is ever open: opening a second + * change's diff reuses the leaf and replaces the content. + */ +export class DiffTabView extends ItemView { + private path: string | null = null; + private content: DiffTabContent | null = null; + /** Toggled explicitly via the layout button rather than by tab width, so split and unified never both take up space. */ + private layout: DiffLayout = 'split'; + + constructor(leaf: WorkspaceLeaf) { + super(leaf); + } + + getViewType(): string { return SOURCE_CONTROL_DIFF_VIEW_TYPE; } + getIcon(): string { return 'file-diff'; } + + getDisplayText(): string { + return this.path ? t('diffView.titleWithFile', { path: this.path }) : t('diffView.title'); + } + + /** The change currently on screen, so the caller can tell when it goes stale. */ + getPath(): string | null { return this.path; } + + setDiff(path: string, content: DiffTabContent | null): void { + this.path = path; + this.content = content; + // Obsidian reads the tab title from getDisplayText(); nudge it to re-read. + this.leaf.setViewState({ type: SOURCE_CONTROL_DIFF_VIEW_TYPE, active: true }).catch(() => { /* title only */ }); + this.render(); + } + + onOpen(): Promise { + this.render(); + return Promise.resolve(); + } + + private render(): void { + const container = this.containerEl.children[1] as HTMLElement | null; + if (!container) return; + + container.empty(); + container.addClass('scv-diff-tab'); + + if (!this.path || !this.content) { + container.createDiv({ cls: 'scv-diff-empty', text: t('diffView.empty') }); + return; + } + + const header = container.createDiv({ cls: 'scv-diff-tab-header' }); + header.createDiv({ cls: 'scv-diff-tab-path', text: this.path }); + + renderDiffLayoutToggle(header, this.layout, (next) => { + this.layout = next; + this.render(); + }); + + const body = container.createDiv({ cls: `scv-diff-tab-body scv-diff-layout-${this.layout}` }); + renderDiffPanel(body, this.content.remote, this.content.local); + } +} diff --git a/src/ui/source-control/SourceControlHeader.ts b/src/ui/source-control/SourceControlHeader.ts index a21d05b..87f6e7e 100644 --- a/src/ui/source-control/SourceControlHeader.ts +++ b/src/ui/source-control/SourceControlHeader.ts @@ -1,21 +1,67 @@ +import { Platform, setIcon } from 'obsidian'; import { t } from '../../i18n'; +import { ICONS } from '../components/icons'; import { renderPushButton } from './PushButton'; +export interface SourceControlWorkspaceInfo { + serviceName: string; + branch: string; + vaultFolder: string; + /** Epoch ms of the most recent successful push/pull, or 0 if nothing has synced yet. */ + lastSyncTime: number; +} + export interface SourceControlHeaderProps { readyToPushCount: number; + workspaceInfo: SourceControlWorkspaceInfo; } export interface SourceControlHeaderCallbacks { onPush: () => void; } -/** Renders the Source Control view title and its Push button. */ +/** + * Renders the Sync status view's connection/branch/last-sync info and Push + * button. No title here -- Obsidian's own tab header already shows "Sync + * status" (SourceControlItemView.getDisplayText), so repeating it in-panel + * duplicated the label, most visibly on mobile's stacked tab layout. + */ export function renderSourceControlHeader( container: HTMLElement, props: SourceControlHeaderProps, callbacks: SourceControlHeaderCallbacks, ): void { const header = container.createDiv({ cls: 'scv-header' }); - header.createSpan({ cls: 'scv-header-title', text: t('sourceControl.viewTitle') }); - renderPushButton(header, props.readyToPushCount, callbacks.onPush); + const titleRow = header.createDiv({ cls: 'scv-header-title-row' }); + renderPushButton(titleRow, props.readyToPushCount, callbacks.onPush); + + renderInfoStrip(header, props.workspaceInfo); +} + +function renderInfoStrip(container: HTMLElement, info: SourceControlWorkspaceInfo): void { + const strip = container.createDiv({ cls: 'scv-info' }); + + strip.createSpan({ cls: 'scv-info-item', text: info.serviceName }); + + if (!Platform.isMobile) { + strip.createSpan({ cls: 'scv-info-sep', text: '·' }); + const branch = strip.createSpan({ cls: 'scv-info-item' }); + setIcon(branch.createSpan({ cls: 'scv-info-icon' }), ICONS.branch); + branch.createSpan({ text: ` ${info.branch}` }); + } + + if (info.vaultFolder) { + strip.createSpan({ cls: 'scv-info-sep', text: '·' }); + const folder = strip.createSpan({ cls: 'scv-info-item' }); + setIcon(folder.createSpan({ cls: 'scv-info-icon' }), ICONS.folder); + folder.createSpan({ text: ` ${info.vaultFolder}` }); + } + + strip.createSpan({ cls: 'scv-info-sep', text: '·' }); + strip.createSpan({ + cls: 'scv-info-time', + text: info.lastSyncTime > 0 + ? t('sourceControl.info.lastSync', { time: new Date(info.lastSyncTime).toLocaleTimeString() }) + : t('sourceControl.info.neverSynced'), + }); } diff --git a/src/ui/source-control/SourceControlItemView.ts b/src/ui/source-control/SourceControlItemView.ts index 90178e7..602187d 100644 --- a/src/ui/source-control/SourceControlItemView.ts +++ b/src/ui/source-control/SourceControlItemView.ts @@ -1,8 +1,9 @@ -import { ItemView, WorkspaceLeaf, debounce } from 'obsidian'; +import { ItemView, Platform, TFile, WorkspaceLeaf, debounce } from 'obsidian'; import GitLabFilesPush from '../../main'; import { t } from '../../i18n'; import type { SourceControlItem } from '../../logic/source-control/SourceControlViewModel'; import { SourceControlView, type SourceControlViewCallbacks } from './SourceControlView'; +import type { SourceControlWorkspaceInfo } from './SourceControlHeader'; // 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 @@ -25,22 +26,55 @@ export class SourceControlItemView extends ItemView { SourceControlItemView.RENDER_THROTTLE_MS, false, ); + /** Guards against a slower diff load finishing after a later click and clobbering it in the main-area tab. */ + private diffTabRequestSeq = 0; constructor(leaf: WorkspaceLeaf, private readonly plugin: GitLabFilesPush) { super(leaf); const callbacks: SourceControlViewCallbacks = { onPush: (changeIds) => this.runAction(this.plugin.sourceControlActions.push(changeIds)), loadDiffContent: (item: SourceControlItem) => this.plugin.sourceControlActions.loadDiffContent(item), + // Desktop: the panel is a narrow sidebar, so the diff opens in a + // full-width main-area tab instead of splitting that sidebar. + // Mobile keeps its own in-panel detail view (SourceControlView). + onOpenDiff: (item) => { if (!Platform.isMobile) void this.openDesktopDiffTab(item); }, + onOpenLocalFile: (item) => this.openLocalFile(item.path), + onOpenRemoteFile: (item) => this.openRemoteFile(item.path), }; this.view = new SourceControlView( this.plugin.sourceControlViewModel, this.plugin.pushSelectionStore, callbacks, + () => this.getWorkspaceInfo(), ); } + private async openDesktopDiffTab(item: SourceControlItem): Promise { + const requestId = ++this.diffTabRequestSeq; + const content = await this.plugin.sourceControlActions.loadDiffContent(item); + if (requestId !== this.diffTabRequestSeq) return; + await this.plugin.openDiffTab(item.path, content); + } + + private openLocalFile(path: string): void { + const file = this.app.vault.getFileByPath(path); + if (file instanceof TFile) void this.app.workspace.getLeaf(false).openFile(file); + } + + private openRemoteFile(path: string): void { + const url = this.plugin.syncWorkspace.getRemoteFileUrl(path); + if (url) window.open(url, '_blank'); + } + + private getWorkspaceInfo(): SourceControlWorkspaceInfo { + const info = this.plugin.syncWorkspace.getInfo(); + const lastSyncTime = Object.values(this.plugin.settings.syncMetadata) + .reduce((latest, metadata) => Math.max(latest, metadata.lastSyncedAt), 0); + return { ...info, lastSyncTime }; + } + getViewType(): string { return SOURCE_CONTROL_VIEW_TYPE; } - getDisplayText(): string { return t('sourceControl.viewTitle'); } + getDisplayText(): string { return t('syncStatus.viewTitle'); } getIcon(): string { return 'git-compare'; } onOpen(): Promise { diff --git a/src/ui/source-control/SourceControlView.ts b/src/ui/source-control/SourceControlView.ts index a1afc0d..f54ee12 100644 --- a/src/ui/source-control/SourceControlView.ts +++ b/src/ui/source-control/SourceControlView.ts @@ -1,13 +1,15 @@ -import { Platform } from 'obsidian'; +import { debounce, Platform, setIcon, setTooltip } from 'obsidian'; import { t, type TranslationKey } from '../../i18n'; import type { PushSelectionStore } from '../../logic/source-control/PushSelectionStore'; import type { SourceControlFilter } from '../../logic/source-control/SourceControlFilter'; import { SourceControlViewModel, type SourceControlItem } from '../../logic/source-control/SourceControlViewModel'; import type { ChangeId } from '../../logic/source-control/types'; +import { ICONS } from '../components/icons'; +import { renderDiffLayoutToggle, type DiffLayout } from '../components/DiffLayoutToggle'; import { renderDiffPanel } from '../components/DiffPanel'; import { renderChangeTree, type ChangeTreeCallbacks } from './ChangeTree'; import { renderFilterMenu } from './FilterMenu'; -import { renderSourceControlHeader } from './SourceControlHeader'; +import { renderSourceControlHeader, type SourceControlWorkspaceInfo } from './SourceControlHeader'; export interface SourceControlDiffContent { remote: string; @@ -21,6 +23,16 @@ export interface SourceControlViewCallbacks { onOpenDiff?: (item: SourceControlItem) => void | Promise; /** Supplies diff content for the selected change; omit to leave the diff pane empty. */ loadDiffContent?: (item: SourceControlItem) => Promise; + /** + * A `local-only` change has nothing on the remote to diff against, so + * clicking it opens the file itself instead of an empty diff view. + */ + onOpenLocalFile?: (item: SourceControlItem) => void | Promise; + /** + * A `remote-only` change has nothing local to diff against, so clicking + * it opens the file on the remote (in the browser) instead. + */ + onOpenRemoteFile?: (item: SourceControlItem) => void | Promise; } /** Active-filter header title keys. Every filter renders one header + a flat tree (no section breakdown). */ @@ -58,14 +70,23 @@ const TREE_OPTIONS = { collapseSingleChild: true }; export class SourceControlView { private filter: SourceControlFilter = 'all'; private showSynced = false; + private searchQuery = ''; private readonly collapsedFolders = new Set(); private selectedChangeId: ChangeId | null = null; + /** Mobile detail view only: which layout the diff renders in, toggled explicitly rather than by container width, so only one ever takes up space. */ + private mobileDiffLayout: DiffLayout = 'unified'; private container?: HTMLElement; + private readonly applySearchDebounced = debounce( + (value: string) => this.applySearch(value), + 150, + false, + ); constructor( private readonly viewModel: SourceControlViewModel, private readonly selection: PushSelectionStore, private readonly callbacks: SourceControlViewCallbacks, + private readonly getWorkspaceInfo: () => SourceControlWorkspaceInfo, ) {} render(container: HTMLElement): void { @@ -84,11 +105,6 @@ export class SourceControlView { const main = container.createDiv({ cls: 'scv-main' }); this.renderMain(main); - - if (!isMobile) { - const diffPane = container.createDiv({ cls: 'scv-diff' }); - this.renderDiffPane(diffPane); - } } getFilter(): SourceControlFilter { return this.filter; } @@ -104,10 +120,12 @@ export class SourceControlView { renderSourceControlHeader( container, - { readyToPushCount: state.counts['ready-to-push'] }, + { readyToPushCount: state.counts['ready-to-push'], workspaceInfo: this.getWorkspaceInfo() }, { onPush: () => { void this.callbacks.onPush(this.selection.getSelectedChangeIds()); } }, ); + this.renderSearchBox(container); + renderFilterMenu( container, this.filter, @@ -124,9 +142,12 @@ export class SourceControlView { }, ); + const query = this.searchQuery.trim().toLowerCase(); + const items = query ? state.items.filter(item => item.path.toLowerCase().includes(query)) : state.items; + const body = container.createDiv({ cls: 'scv-body' }); - this.renderActiveFilterHeader(body, state.filter, state.items.length); - if (state.items.length === 0) { + this.renderActiveFilterHeader(body, state.filter, items.length); + if (items.length === 0) { body.createDiv({ cls: 'scv-empty', text: t('sourceControl.empty') }); return; } @@ -134,10 +155,62 @@ export class SourceControlView { const treeCallbacks: ChangeTreeCallbacks = { onToggleFolder: (path) => this.toggleFolder(path), onToggleSelect: (id, selected) => this.toggleSelect(id, selected), + onToggleFolderSelect: (ids, selected) => this.toggleFolderSelect(ids, selected), onOpenDiff: (item) => this.openDiff(item), }; - renderChangeTree(body, state.items, this.collapsedFolders, treeCallbacks, TREE_OPTIONS); + renderChangeTree(body, items, this.collapsedFolders, treeCallbacks, TREE_OPTIONS); + } + + /** + * Renders the path-filter search box. Kept as a normal part of the + * `rerender()`-driven tree (rather than persisted across renders like the + * legacy view did), so typing re-focuses the freshly rebuilt input and + * restores its caret position instead of losing focus on every keystroke. + */ + private renderSearchBox(container: HTMLElement): void { + const row = container.createDiv({ cls: 'scv-search' }); + row.toggleClass('has-query', this.searchQuery.length > 0); + setIcon(row.createSpan({ cls: 'scv-search-icon' }), ICONS.search); + + const input = row.createEl('input', { + type: 'text', + cls: 'scv-search-input', + attr: { placeholder: t('sourceControl.search.placeholder'), spellcheck: 'false' }, + }); + input.value = this.searchQuery; + + const clear = row.createEl('button', { cls: 'scv-search-clear' }); + setIcon(clear, ICONS.clear); + setTooltip(clear, t('sourceControl.search.clear')); + + input.addEventListener('input', () => this.applySearchDebounced(input.value)); + input.addEventListener('keydown', (evt) => { + if (evt.key !== 'Escape' || input.value === '') return; + evt.preventDefault(); + input.value = ''; + this.applySearchDebounced.cancel(); + this.applySearch(''); + }); + clear.addEventListener('click', () => { + input.value = ''; + input.focus(); + this.applySearchDebounced.cancel(); + this.applySearch(''); + }); + } + + private applySearch(value: string): void { + if (value === this.searchQuery) return; + const focused = document.activeElement === this.container?.querySelector('.scv-search-input'); + const cursor = focused ? (this.container?.querySelector('.scv-search-input')?.selectionStart ?? null) : null; + this.searchQuery = value; + this.rerender(); + if (!focused) return; + const newInput = this.container?.querySelector('.scv-search-input'); + if (!newInput) return; + newInput.focus(); + if (cursor !== null) newInput.setSelectionRange(cursor, cursor); } /** Renders the single active-filter header (e.g. "ALL (132)") above the flat tree. */ @@ -147,23 +220,24 @@ export class SourceControlView { header.createSpan({ cls: 'scv-active-filter-count', text: String(count) }); } - private renderDiffPane(container: HTMLElement): void { - if (!this.selectedChangeId) { - container.createDiv({ cls: 'scv-diff-empty', text: t('sourceControl.diff.selectPrompt') }); - return; - } - void this.loadAndRenderDiff(container, this.selectedChangeId); - } - private renderDetail(root: HTMLElement): void { const detail = root.createDiv({ cls: 'scv-detail' }); - const backBtn = detail.createEl('button', { cls: 'scv-detail-back', text: t('sourceControl.detail.back') }); + const bar = detail.createDiv({ cls: 'scv-detail-bar' }); + + const backBtn = bar.createEl('button', { cls: 'scv-detail-back' }); + setIcon(backBtn.createSpan({ cls: 'scv-detail-back-icon' }), ICONS.back); + backBtn.createSpan({ cls: 'scv-detail-back-label', text: t('sourceControl.detail.back') }); backBtn.addEventListener('click', () => { this.selectedChangeId = null; this.rerender(); }); - const diffContainer = detail.createDiv({ cls: 'scv-detail-diff' }); + renderDiffLayoutToggle(bar, this.mobileDiffLayout, (next) => { + this.mobileDiffLayout = next; + this.rerender(); + }); + + const diffContainer = detail.createDiv({ cls: `scv-detail-diff scv-diff-layout-${this.mobileDiffLayout}` }); if (this.selectedChangeId) void this.loadAndRenderDiff(diffContainer, this.selectedChangeId); } @@ -191,7 +265,27 @@ export class SourceControlView { this.rerender(); } + private toggleFolderSelect(ids: readonly ChangeId[], selected: boolean): void { + for (const id of ids) { + if (selected) this.selection.includeForPush(id); + else this.selection.excludeFromPush(id); + } + this.rerender(); + } + private openDiff(item: SourceControlItem): void { + // Neither kind has a counterpart to diff against, so clicking opens + // the file itself (local-only) or its remote page (remote-only) + // instead of navigating into an empty diff view. + if (item.kind === 'local-only' && this.callbacks.onOpenLocalFile) { + void this.callbacks.onOpenLocalFile(item); + return; + } + if (item.kind === 'remote-only' && this.callbacks.onOpenRemoteFile) { + void this.callbacks.onOpenRemoteFile(item); + return; + } + this.selectedChangeId = item.id; if (this.callbacks.onOpenDiff) void this.callbacks.onOpenDiff(item); this.rerender(); diff --git a/styles.css b/styles.css index e494def..0e235b5 100644 --- a/styles.css +++ b/styles.css @@ -15,20 +15,98 @@ border-bottom: 1px solid var(--background-modifier-border); } -.scv-header-title { - font-weight: 600; - font-size: 0.9em; +.scv-header-title-row { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; +} + +/* ── Workspace info strip (provider · branch · vault folder · last sync) ── */ +.scv-info { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 4px; + margin-top: 4px; + font-size: 0.75em; + color: var(--text-muted); +} + +.scv-info-item { + display: inline-flex; + align-items: center; +} + +.scv-info-icon { + display: inline-flex; + align-items: center; +} + +.scv-info-icon .svg-icon { width: 11px; height: 11px; } + +.scv-info-sep { color: var(--text-faint); } + +.scv-info-time { white-space: nowrap; } + +/* ── Search / path filter ─────────────────────────────────────── */ +.scv-search { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + border-bottom: 1px solid var(--background-modifier-border); + flex-shrink: 0; +} + +.scv-search-icon { + display: flex; + align-items: center; + color: var(--text-faint); + flex-shrink: 0; +} + +.scv-search-icon .svg-icon { width: 14px; height: 14px; } + +.scv-search-input { + flex: 1; + min-width: 0; + height: 26px; + padding: 0 6px; + font-size: 0.82em; + background: var(--background-modifier-form-field); + border: 1px solid var(--background-modifier-border); + border-radius: 4px; + color: var(--text-normal); +} + +.scv-search-clear { + display: none; + align-items: center; + justify-content: center; + padding: 2px; + height: 22px; + width: 22px; + flex-shrink: 0; + background: transparent; + border: none; + box-shadow: none; + color: var(--text-muted); + cursor: pointer; } +.scv-search.has-query .scv-search-clear { display: flex; } + +.scv-search-clear:hover { color: var(--text-normal); } + +.scv-search-clear .svg-icon { width: 14px; height: 14px; } + .scv-main { display: flex; flex-direction: column; flex: 1; min-height: 0; -} - -.scv-desktop .scv-main { - flex-direction: row; + min-width: 0; } .scv-body { @@ -218,6 +296,13 @@ .scv-tree-folder-row:hover { background: var(--background-modifier-hover); } +.scv-tree-folder-select { + width: 15px; + height: 15px; + flex-shrink: 0; + cursor: pointer; +} + .scv-tree-folder-toggle { display: inline-flex; align-items: center; @@ -352,16 +437,68 @@ font-size: 0.88em; } -/* ── Inline diff pane (desktop side-by-side) ──────────────────────── */ -.scv-diff { - flex: 1; - min-width: 0; - border-left: 1px solid var(--background-modifier-border); - overflow-y: auto; - padding: 10px 12px; +/* ── Diff tab (desktop: full main-area workspace tab) ─────────────── */ +.scv-diff-tab { + padding: 10px 14px; + overflow: hidden; + display: flex; + flex-direction: column; + height: 100%; container-type: inline-size; } +.scv-diff-tab-header { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 8px; +} + +.scv-diff-tab-path { + font-family: var(--font-monospace); + font-size: 0.82em; + color: var(--text-muted); + overflow-wrap: anywhere; +} + +.scv-diff-tab-body { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; +} + +/* Let the diff fill all the space this full tab gives it, instead of the + compact scroll box used elsewhere -- there's a whole tab of room here. + Deliberately doesn't touch `display` (owned by the toggle-class rules + below), so split and unified never both end up visible at once. */ +.scv-diff-tab-body .ssv-diff-split { + flex: 1; + min-height: 0; +} + +.scv-diff-tab-body .ssv-diff-grid { + height: 100%; + max-height: none; + min-height: 0; +} + +.scv-diff-tab-body .ssv-diff-unified { + flex: 1; + max-height: none; + min-height: 0; +} + +/* Explicit layout toggle (button-driven, not tab width) -- only one of + split/unified is ever shown, so they never both take up space. These + two-class selectors outrank the width-based @container rules below. */ +.scv-diff-tab-body.scv-diff-layout-split .ssv-diff-split { display: block; } +.scv-diff-tab-body.scv-diff-layout-split .ssv-diff-unified { display: none; } +.scv-diff-tab-body.scv-diff-layout-unified .ssv-diff-split { display: none; } +.scv-diff-tab-body.scv-diff-layout-unified .ssv-diff-unified { display: block; } + .scv-diff-empty { padding: 36px 20px; text-align: center; @@ -376,26 +513,93 @@ height: 100%; } -.scv-detail-back { +.scv-detail-bar { flex-shrink: 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; margin: 8px 10px; - padding: 6px 12px; - align-self: flex-start; - border-radius: 5px; +} + +.scv-detail-back { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 14px 7px 10px; + border-radius: 6px; border: 1px solid var(--background-modifier-border); - background: var(--background-secondary); + background: var(--interactive-normal); + color: var(--text-normal); + cursor: pointer; + font-size: 0.88em; + font-weight: 600; +} + +.scv-detail-back:hover { background: var(--interactive-hover); } + +.scv-detail-back-icon { display: inline-flex; align-items: center; } +.scv-detail-back-icon .svg-icon { width: 15px; height: 15px; } + +.scv-diff-layout-toggle { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 12px; + border-radius: 6px; + border: 1px solid var(--background-modifier-border); + background: var(--interactive-normal); color: var(--text-normal); cursor: pointer; font-size: 0.85em; + font-weight: 600; + white-space: nowrap; } +.scv-diff-layout-toggle:hover { background: var(--interactive-hover); } + +.scv-diff-layout-toggle-icon { display: inline-flex; align-items: center; } +.scv-diff-layout-toggle-icon .svg-icon { width: 15px; height: 15px; } + .scv-detail-diff { flex: 1; - overflow-y: auto; + display: flex; + flex-direction: column; + min-height: 0; padding: 0 12px 12px; container-type: inline-size; } +/* Same "fill the room this dedicated page has" fix as the desktop tab -- + the mobile detail view is its own full-screen page, not a cramped + sidebar split, so the diff shouldn't be capped to a short scroll box. + Deliberately doesn't touch `display` (owned by the toggle-class rules + below), so split and unified never both end up visible at once. */ +.scv-detail-diff .ssv-diff-split { + flex: 1; + min-height: 0; +} + +.scv-detail-diff .ssv-diff-grid { + height: 100%; + max-height: none; + min-height: 0; +} + +.scv-detail-diff .ssv-diff-unified { + flex: 1; + max-height: none; + min-height: 0; +} + +/* Explicit layout toggle (button-driven, not container width) -- only one + of split/unified is ever shown, so they never both take up space. These + two-class selectors outrank the width-based @container rules below. */ +.scv-detail-diff.scv-diff-layout-split .ssv-diff-split { display: block; } +.scv-detail-diff.scv-diff-layout-split .ssv-diff-unified { display: none; } +.scv-detail-diff.scv-diff-layout-unified .ssv-diff-split { display: none; } +.scv-detail-diff.scv-diff-layout-unified .ssv-diff-unified { display: block; } + /* ── Side-by-side diff (default for wide panels) ──────────────────── */ .ssv-diff-split { border: 1px solid var(--background-modifier-border); @@ -610,76 +814,26 @@ flex-shrink: 0; } -.conflict-tabs { - display: none; - gap: 6px; - margin-bottom: 12px; - flex-shrink: 0; -} - -.conflict-tab { - padding: 6px 12px; - border-radius: 5px; - border: 1px solid var(--background-modifier-border); - background: var(--background-secondary); - color: var(--text-muted); - cursor: pointer; - font-size: 0.85em; -} - -.conflict-tab.is-active { - background: var(--interactive-accent); - color: var(--text-on-accent); - border-color: var(--interactive-accent); -} - .conflict-content-area { flex: 1; overflow-y: auto; min-height: 0; } -.conflict-diff-container { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); - gap: 14px; - margin-bottom: 18px; -} - -.conflict-panel { - min-width: 0; -} - -/* Below this width two side-by-side panes are too narrow to read a real - Markdown file comfortably, so fall back to the tab/single-pane view. */ -@media (max-width: 900px) { - .conflict-diff-container { grid-template-columns: 1fr; } - - .conflict-tabs { display: flex; } - - .conflict-panel { display: none; } - .conflict-panel.is-active { display: block; } +.conflict-diff-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 8px; } -.conflict-section h3, -.conflict-diff-section h3 { - margin: 0 0 8px; +.conflict-diff-header h3 { + margin: 0; font-size: 0.9em; font-weight: 600; } -.conflict-content, -.conflict-diff { - overflow: auto; - padding: 10px; - background: var(--background-secondary); - border-radius: 5px; - margin: 0; - font-family: var(--font-monospace); - font-size: 0.82em; - white-space: pre; -} - .conflict-binary-notice { color: var(--text-muted); padding: 16px; diff --git a/tests/ui/SyncConflictModal.test.ts b/tests/ui/SyncConflictModal.test.ts index 0e65afe..830544f 100644 --- a/tests/ui/SyncConflictModal.test.ts +++ b/tests/ui/SyncConflictModal.test.ts @@ -32,26 +32,41 @@ describe('applyDestructiveStyle (Obsidian version compatibility)', () => { describe('SyncConflictModal', () => { beforeAll(() => { setupObsidianDOM(); }); - it('defaults to the diff panel and switches panels via tabs', () => { + it('renders the diff view directly with no redundant Local/Remote tabs', () => { const modal = new SyncConflictModal(new App(), 'note.md', 'local', 'remote', vi.fn()); modal.contentEl = createContainer(); modal.onOpen(); const contentEl = modal.contentEl; - const tabs = Array.from(contentEl.querySelectorAll('.conflict-tab')); - const panels = Array.from(contentEl.querySelectorAll('.conflict-panel')); + expect(contentEl.querySelector('.conflict-diff-section')).not.toBeNull(); + expect(contentEl.querySelector('.conflict-tabs')).toBeNull(); + expect(contentEl.querySelector('.conflict-tab')).toBeNull(); + expect(contentEl.querySelector('.conflict-diff-container')).toBeNull(); + }); + + describe('diff layout toggle', () => { + it('renders the shared diff panel defaulting to the unified (single-column) layout', () => { + const modal = new SyncConflictModal(new App(), 'note.md', 'local', 'remote', vi.fn()); + modal.contentEl = createContainer(); + + modal.onOpen(); - const activePanel = () => panels.find(panel => panel.classList.contains('is-active')); - const activeTab = () => tabs.find(tab => tab.classList.contains('is-active')); + const body = modal.contentEl.querySelector('.scv-diff-tab-body'); + expect(body?.classList.contains('scv-diff-layout-unified')).toBe(true); + expect(modal.contentEl.querySelector('.ssv-diff-split')).not.toBeNull(); + }); - expect(activeTab()?.textContent).toBe('Diff'); - expect(activePanel()?.classList.contains('conflict-diff-section')).toBe(true); + it('switches to the split layout when the toggle button is clicked, never showing both at once', () => { + const modal = new SyncConflictModal(new App(), 'note.md', 'local', 'remote', vi.fn()); + modal.contentEl = createContainer(); - const localTab = tabs.find(tab => tab.textContent === 'Local'); - localTab?.dispatchEvent(new Event('click')); + modal.onOpen(); + (modal.contentEl.querySelector('.scv-diff-layout-toggle') as HTMLButtonElement).click(); - expect(activeTab()?.textContent).toBe('Local'); - expect(activePanel()?.classList.contains('conflict-section')).toBe(true); + const body = modal.contentEl.querySelector('.scv-diff-tab-body'); + expect(body?.classList.contains('scv-diff-layout-split')).toBe(true); + expect(body?.classList.contains('scv-diff-layout-unified')).toBe(false); + }); }); }); diff --git a/tests/ui/components/DiffLayoutToggle.test.ts b/tests/ui/components/DiffLayoutToggle.test.ts new file mode 100644 index 0000000..8bf761b --- /dev/null +++ b/tests/ui/components/DiffLayoutToggle.test.ts @@ -0,0 +1,35 @@ +import { beforeAll, describe, expect, it, vi } from 'vitest'; +import { renderDiffLayoutToggle } from '../../../src/ui/components/DiffLayoutToggle'; +import { setupObsidianDOM, createContainer } from '../setup-dom'; + +beforeAll(() => { setupObsidianDOM(); }); + +describe('renderDiffLayoutToggle', () => { + it('always shows a visible text label, not just an icon', () => { + const container = createContainer(); + renderDiffLayoutToggle(container, 'unified', vi.fn()); + + const label = container.querySelector('.scv-diff-layout-toggle-label'); + expect(label?.textContent).toBeTruthy(); + }); + + it('labels itself with the layout it will switch to, not the current one', () => { + const container = createContainer(); + renderDiffLayoutToggle(container, 'unified', vi.fn()); + expect(container.querySelector('.scv-diff-layout-toggle-label')?.textContent).toBe('Split'); + + const container2 = createContainer(); + renderDiffLayoutToggle(container2, 'split', vi.fn()); + expect(container2.querySelector('.scv-diff-layout-toggle-label')?.textContent).toBe('Unified'); + }); + + it('calls onToggle with the target layout when clicked', () => { + const container = createContainer(); + const onToggle = vi.fn(); + renderDiffLayoutToggle(container, 'unified', onToggle); + + (container.querySelector('.scv-diff-layout-toggle') as HTMLButtonElement).click(); + + expect(onToggle).toHaveBeenCalledWith('split'); + }); +}); diff --git a/tests/ui/source-control/ChangeTree.test.ts b/tests/ui/source-control/ChangeTree.test.ts index 63d120f..730f0d8 100644 --- a/tests/ui/source-control/ChangeTree.test.ts +++ b/tests/ui/source-control/ChangeTree.test.ts @@ -18,6 +18,7 @@ describe('renderChangeTree', () => { container = createContainer(); callbacks = { onToggleFolder: vi.fn(), + onToggleFolderSelect: vi.fn(), onToggleSelect: vi.fn(), onOpenDiff: vi.fn(), }; @@ -125,4 +126,68 @@ describe('renderChangeTree', () => { expect(callbacks.onToggleFolder).toHaveBeenCalledWith('notes'); }); + + describe('folder select-all checkbox', () => { + it('is unchecked, not indeterminate, when no file in the folder is ready to push', () => { + const items = [ + item({ id: toChangeId('c-1'), path: 'notes/daily.md', kind: 'local-modified' }), + item({ id: toChangeId('c-2'), path: 'notes/idea.md', kind: 'local-only' }), + ]; + renderChangeTree(container, items, new Set(), callbacks); + + const checkbox = container.querySelector('.scv-tree-folder-select') as HTMLInputElement; + expect(checkbox.checked).toBe(false); + expect(checkbox.indeterminate).toBe(false); + }); + + it('is indeterminate when only some files in the folder are ready to push', () => { + const items = [ + item({ id: toChangeId('c-1'), path: 'notes/daily.md', kind: 'local-modified', isReadyToPush: true }), + item({ id: toChangeId('c-2'), path: 'notes/idea.md', kind: 'local-only', isReadyToPush: false }), + ]; + renderChangeTree(container, items, new Set(), callbacks); + + const checkbox = container.querySelector('.scv-tree-folder-select') as HTMLInputElement; + expect(checkbox.checked).toBe(false); + expect(checkbox.indeterminate).toBe(true); + }); + + it('is checked when every file in the folder (including nested subfolders) is ready to push', () => { + const items = [ + item({ id: toChangeId('c-1'), path: 'notes/daily.md', kind: 'local-modified', isReadyToPush: true }), + item({ id: toChangeId('c-2'), path: 'notes/sub/idea.md', kind: 'local-only', isReadyToPush: true }), + ]; + renderChangeTree(container, items, new Set(), callbacks); + + const checkbox = container.querySelector('.scv-tree-folder-select') as HTMLInputElement; + expect(checkbox.checked).toBe(true); + expect(checkbox.indeterminate).toBe(false); + }); + + it('calls onToggleFolderSelect with every descendant ChangeId when checked', () => { + const items = [ + item({ id: toChangeId('c-1'), path: 'notes/daily.md', kind: 'local-modified' }), + item({ id: toChangeId('c-2'), path: 'notes/sub/idea.md', kind: 'local-only' }), + ]; + renderChangeTree(container, items, new Set(), callbacks); + + const checkbox = container.querySelector('.scv-tree-folder-select') as HTMLInputElement; + checkbox.checked = true; + checkbox.dispatchEvent(new Event('change')); + + expect(callbacks.onToggleFolderSelect).toHaveBeenCalledWith( + [toChangeId('c-1'), toChangeId('c-2')], + true, + ); + }); + + it('does not open a diff or toggle the folder disclosure when the checkbox is clicked', () => { + const items = [item({ id: toChangeId('c-1'), path: 'notes/daily.md', kind: 'local-modified' })]; + renderChangeTree(container, items, new Set(), callbacks); + + (container.querySelector('.scv-tree-folder-select') as HTMLElement).click(); + + expect(callbacks.onToggleFolder).not.toHaveBeenCalled(); + }); + }); }); diff --git a/tests/ui/source-control/DiffTabView.test.ts b/tests/ui/source-control/DiffTabView.test.ts new file mode 100644 index 0000000..4513e7e --- /dev/null +++ b/tests/ui/source-control/DiffTabView.test.ts @@ -0,0 +1,58 @@ +import { beforeAll, describe, expect, it, vi } from 'vitest'; +import { WorkspaceLeaf } from 'obsidian'; +import { DiffTabView } from '../../../src/ui/source-control/DiffTabView'; +import { setupObsidianDOM } from '../setup-dom'; + +beforeAll(() => { setupObsidianDOM(); }); + +function buildLeaf() { + return { setViewState: vi.fn().mockResolvedValue(undefined) } as unknown as WorkspaceLeaf; +} + +describe('DiffTabView', () => { + it('shows an empty state until a diff is set', () => { + const view = new DiffTabView(buildLeaf()); + void view.onOpen(); + + const container = view.containerEl.children[1] as HTMLElement; + expect(container.querySelector('.scv-diff-empty')).not.toBeNull(); + }); + + it('renders the path and diff once set, defaulting to the split layout', () => { + const view = new DiffTabView(buildLeaf()); + void view.onOpen(); + + view.setDiff('notes/a.md', { remote: 'remote text', local: 'local text' }); + + const container = view.containerEl.children[1] as HTMLElement; + expect(container.querySelector('.scv-diff-tab-path')?.textContent).toBe('notes/a.md'); + expect(container.querySelector('.scv-diff-tab-body')?.classList.contains('scv-diff-layout-split')).toBe(true); + expect(view.getPath()).toBe('notes/a.md'); + }); + + it('switches to the unified layout when the toggle button is clicked, never showing both at once', () => { + const view = new DiffTabView(buildLeaf()); + void view.onOpen(); + view.setDiff('a.md', { remote: 'remote text', local: 'local text' }); + + const container = view.containerEl.children[1] as HTMLElement; + (container.querySelector('.scv-diff-layout-toggle') as HTMLButtonElement).click(); + + 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('reuses the leaf for a second change instead of stacking tabs', () => { + const view = new DiffTabView(buildLeaf()); + void view.onOpen(); + + view.setDiff('a.md', { remote: 'r1', local: 'l1' }); + view.setDiff('b.md', { remote: 'r2', local: 'l2' }); + + const container = view.containerEl.children[1] as HTMLElement; + expect(view.getPath()).toBe('b.md'); + expect(container.querySelectorAll('.scv-diff-tab-path')).toHaveLength(1); + expect(container.querySelector('.scv-diff-tab-path')?.textContent).toBe('b.md'); + }); +}); diff --git a/tests/ui/source-control/SourceControlItemView.test.ts b/tests/ui/source-control/SourceControlItemView.test.ts index 1701a55..32843a8 100644 --- a/tests/ui/source-control/SourceControlItemView.test.ts +++ b/tests/ui/source-control/SourceControlItemView.test.ts @@ -1,25 +1,27 @@ import { beforeAll, describe, expect, it, vi } from 'vitest'; -import { WorkspaceLeaf } from 'obsidian'; +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'; import { OperationState } from '../../../src/logic/source-control/OperationState'; import { PushSelectionStore } from '../../../src/logic/source-control/PushSelectionStore'; import { SourceControlViewModel } from '../../../src/logic/source-control/SourceControlViewModel'; -import { toChangeId } from '../../../src/logic/source-control/types'; +import { toChangeId, type SyncChangeKind } from '../../../src/logic/source-control/types'; import { SyncStatusService } from '../../../src/logic/sync-status-service'; import type GitLabFilesPush from '../../../src/main'; import { setupObsidianDOM } from '../setup-dom'; beforeAll(() => { setupObsidianDOM(); }); -function buildPlugin() { +function buildPlugin(kind: SyncChangeKind = 'local-only') { const repository = new ChangeRepository(); - repository.replace([{ id: toChangeId('a.md'), path: 'a.md', kind: 'local-only' }]); + repository.replace([{ id: toChangeId('a.md'), path: 'a.md', kind }]); const selection = new PushSelectionStore(); const operations = new OperationState(); const viewModel = new SourceControlViewModel(repository, selection, operations); const push = vi.fn().mockResolvedValue(undefined); - const loadDiffContent = vi.fn().mockResolvedValue(null); + 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'); const status = new SyncStatusService(); const plugin = { @@ -29,9 +31,21 @@ function buildPlugin() { sourceControlViewModel: viewModel, sourceControlActions: { push, loadDiffContent }, sync: { status }, + syncWorkspace: { getInfo: () => ({ serviceName: 'GitHub', branch: 'main', vaultFolder: '' }), getRemoteFileUrl }, + settings: { syncMetadata: {} }, + openDiffTab, } as unknown as GitLabFilesPush; - return { plugin, repository, selection, push, status }; + return { plugin, repository, selection, push, loadDiffContent, openDiffTab, getRemoteFileUrl, status }; +} + +function buildLeaf() { + const openFile = vi.fn(); + const app = { + vault: { getFileByPath: vi.fn().mockReturnValue(null) }, + workspace: { getLeaf: vi.fn().mockReturnValue({ openFile }) }, + }; + return { leaf: { app } as unknown as WorkspaceLeaf, app, openFile }; } describe('SourceControlItemView', () => { @@ -88,6 +102,57 @@ describe('SourceControlItemView', () => { expect(ids).toEqual(new Set(['a.md', 'b.md'])); }); + it('opens a change diff in the main-area tab via plugin.openDiffTab on desktop', async () => { + // local-modified (not local-only/remote-only) has a real diff to show. + const { plugin, loadDiffContent, openDiffTab } = buildPlugin('local-modified'); + const view = new SourceControlItemView({} as WorkspaceLeaf, plugin); + await view.onOpen(); + + const container = view.containerEl.children[1] as HTMLElement; + (container.querySelector('.scv-change-item') as HTMLElement).click(); + await Promise.resolve(); + await Promise.resolve(); + + expect(loadDiffContent).toHaveBeenCalledWith(expect.objectContaining({ id: toChangeId('a.md') })); + expect(openDiffTab).toHaveBeenCalledWith('a.md', { remote: 'remote text', local: 'local text' }); + }); + + it('opens the local file directly for a local-only change (nothing to diff against)', async () => { + const { plugin, openDiffTab } = buildPlugin('local-only'); + const { leaf, app, openFile } = buildLeaf(); + const file = Object.assign(new TFile(), { path: 'a.md' }); + (app.vault.getFileByPath as ReturnType).mockReturnValue(file); + const view = new SourceControlItemView(leaf, plugin); + await view.onOpen(); + + const container = view.containerEl.children[1] as HTMLElement; + (container.querySelector('.scv-change-item') as HTMLElement).click(); + + expect(app.vault.getFileByPath).toHaveBeenCalledWith('a.md'); + expect(openFile).toHaveBeenCalledWith(file); + expect(openDiffTab).not.toHaveBeenCalled(); + }); + + it('opens the remote file in the browser for a remote-only change (nothing local to diff against)', async () => { + const originalOpen = window.open; + const windowOpen = vi.fn(); + window.open = windowOpen; + try { + const { plugin, getRemoteFileUrl, openDiffTab } = 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-item') as HTMLElement).click(); + + expect(getRemoteFileUrl).toHaveBeenCalledWith('a.md'); + expect(windowOpen).toHaveBeenCalledWith('https://github.com/owner/repo/blob/main/a.md', '_blank'); + expect(openDiffTab).not.toHaveBeenCalled(); + } finally { + window.open = originalOpen; + } + }); + it('stops re-rendering once closed', async () => { const { plugin, status } = buildPlugin(); const view = new SourceControlItemView({} as WorkspaceLeaf, plugin); diff --git a/tests/ui/source-control/SourceControlView.test.ts b/tests/ui/source-control/SourceControlView.test.ts index 493da34..da25583 100644 --- a/tests/ui/source-control/SourceControlView.test.ts +++ b/tests/ui/source-control/SourceControlView.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it, vi, beforeAll, beforeEach } from 'vitest'; +import { describe, expect, it, vi, beforeAll, beforeEach, afterEach } from 'vitest'; +import { Platform } from 'obsidian'; import { SourceControlView, type SourceControlViewCallbacks } from '../../../src/ui/source-control/SourceControlView'; import { ChangeRepository } from '../../../src/logic/source-control/ChangeRepository'; import { OperationState } from '../../../src/logic/source-control/OperationState'; @@ -16,7 +17,12 @@ function buildView(changes: SyncChange[], callbacks: Partial ({ + serviceName: 'GitHub', + branch: 'main', + vaultFolder: '', + lastSyncTime: 0, + })); return { view, selection, operations, onPush }; } @@ -209,7 +215,14 @@ describe('SourceControlView', () => { }); describe('diff selection', () => { - it('loads and renders diff content for the clicked change', async () => { + // Desktop has no inline diff pane -- clicking a change only notifies + // 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; }); + + it('loads and renders diff content in the mobile detail view for the clicked change', async () => { + Platform.isMobile = 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' }], @@ -222,10 +235,21 @@ describe('SourceControlView', () => { await Promise.resolve(); expect(loadDiffContent).toHaveBeenCalledWith(expect.objectContaining({ id: toChangeId('c-1') })); + expect(container.querySelector('.scv-detail-diff')).not.toBeNull(); // Reuses the existing diff panel renderer (Phase 3 spec: don't rewrite diff UI), which uses its own 'ssv-' class prefix. expect(container.querySelector('.ssv-diff-split')).not.toBeNull(); }); + 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); + + (container.querySelector('.scv-change-item') as HTMLElement).click(); + + expect(container.querySelector('.scv-diff')).toBeNull(); + expect(container.querySelector('.scv-detail')).toBeNull(); + }); + it('notifies onOpenDiff with the selected item', () => { const onOpenDiff = vi.fn(); const { view } = buildView( @@ -238,6 +262,45 @@ describe('SourceControlView', () => { expect(onOpenDiff).toHaveBeenCalledWith(expect.objectContaining({ id: toChangeId('c-1'), path: 'a.md' })); }); + + describe('mobile diff layout toggle', () => { + it('defaults to the unified (single-column) layout, with the split diff hidden', async () => { + Platform.isMobile = 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); + }); + + it('switches to the split layout when the toggle button is clicked, never showing both at once', async () => { + Platform.isMobile = 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(); + + (container.querySelector('.scv-diff-layout-toggle') as HTMLButtonElement).click(); + await Promise.resolve(); + await Promise.resolve(); + + const diffContainer = container.querySelector('.scv-detail-diff'); + expect(diffContainer?.classList.contains('scv-diff-layout-split')).toBe(true); + expect(diffContainer?.classList.contains('scv-diff-layout-unified')).toBe(false); + }); + }); }); describe('rename stability', () => { From 8c69cc8da702df74b6635c2991c999aaa990a5f7 Mon Sep 17 00:00:00 2001 From: Tianyao Date: Sat, 22 Aug 2026 23:44:39 +0800 Subject: [PATCH 031/104] refactor(sync-status): integrate source control view model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add selectedItems and refreshStatus projections to SourceControlViewState so later UI commits can render a 'SELECTED FOR SYNC (N)' section and refresh button states. selectedItems reuses buildSummary.readyToPush (selected + non-synced) so the section and Sync button count never drift. Add RefreshState holder (idle/loading/failed) mirroring OperationState's API shape but for a single view-wide refresh rather than per-change operations. Add ViewModel.refresh(), which delegates to an injected refresh callback wired to SyncWorkspace.refresh() in main.ts and drives the RefreshState lifecycle. Refresh republishes sync.status, so the existing subscription repopulates ChangeRepository — refresh never becomes a second population path, and no ChangeRepository.reload() is added. Wire RefreshState + the refresh delegate into the ViewModel in main.ts. Update the ViewModel docstring to acknowledge the refresh-delegation responsibility (it delegates; no provider/refresh logic lives in it). Domain untouched: only SourceControlViewModel.ts edited and RefreshState.ts new under src/logic/source-control/ (filter/summary/types/repository/store/ adapter unchanged). --- src/logic/source-control/RefreshState.ts | 37 ++++++++++ .../source-control/SourceControlViewModel.ts | 41 ++++++++++- src/main.ts | 5 ++ .../logic/source-control/RefreshState.test.ts | 44 ++++++++++++ .../SourceControlViewModel.test.ts | 69 ++++++++++++++++++- .../SourceControlItemView.test.ts | 4 +- .../source-control/SourceControlView.test.ts | 7 +- 7 files changed, 200 insertions(+), 7 deletions(-) create mode 100644 src/logic/source-control/RefreshState.ts create mode 100644 tests/logic/source-control/RefreshState.test.ts diff --git a/src/logic/source-control/RefreshState.ts b/src/logic/source-control/RefreshState.ts new file mode 100644 index 0000000..7247ebc --- /dev/null +++ b/src/logic/source-control/RefreshState.ts @@ -0,0 +1,37 @@ +/** + * Single-value refresh status for the whole Source Control view, mirroring + * {@link OperationState}'s API shape (start/fail/succeed/get/clear) but for + * one global refresh rather than per-{@link ChangeId} operations. + * + * Holds no refresh logic of its own: {@link SourceControlViewModel.refresh} + * delegates to the injected `syncWorkspace.refresh()` and only drives this + * holder so the UI can show "Refreshing…" / a failed state. Keeping it a + * separate holder (rather than reusing `OperationState`) avoids conflating a + * view-wide background refresh with per-change push/pull operations. + */ +export type RefreshStatus = 'idle' | 'loading' | 'failed'; + +export class RefreshState { + private status: RefreshStatus = 'idle'; + + start(): void { + this.status = 'loading'; + } + + fail(): void { + this.status = 'failed'; + } + + succeed(): void { + this.status = 'idle'; + } + + /** Resets back to idle, clearing a prior failure so the button no longer shows the error state. */ + clear(): void { + this.status = 'idle'; + } + + get(): RefreshStatus { + return this.status; + } +} \ No newline at end of file diff --git a/src/logic/source-control/SourceControlViewModel.ts b/src/logic/source-control/SourceControlViewModel.ts index c880a70..8fe7141 100644 --- a/src/logic/source-control/SourceControlViewModel.ts +++ b/src/logic/source-control/SourceControlViewModel.ts @@ -1,6 +1,7 @@ import type { ChangeRepository } from './ChangeRepository'; import { buildSummary, type SourceControlCounts } from './SourceControlSummary'; import type { OperationState, OperationStatus } from './OperationState'; +import type { RefreshState, RefreshStatus } from './RefreshState'; import type { PushSelectionStore } from './PushSelectionStore'; import { matchesFilter, type SourceControlFilter } from './SourceControlFilter'; import type { ChangeId, SyncChange, SyncChangeKind } from './types'; @@ -19,6 +20,15 @@ export interface SourceControlItem { export interface SourceControlViewState { filter: SourceControlFilter; items: SourceControlItem[]; + /** + * The actionable changes the user has currently selected for push, as + * full row items. Empty when nothing is selected. Reuses the same + * `selected + non-synced` definition as `buildSummary.readyToPush` so the + * "SELECTED FOR SYNC (N)" section and the Sync button count can't drift. + */ + selectedItems: 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; } @@ -37,12 +47,21 @@ export interface SourceControlViewState { * `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). + * + * 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. */ export class SourceControlViewModel { constructor( private readonly changes: ChangeRepository, private readonly selection: PushSelectionStore, private readonly operations: OperationState, + private readonly refreshSource: () => Promise, + private readonly refreshState: RefreshState, ) {} getState(filter: SourceControlFilter = 'all', showSynced = false): SourceControlViewState { @@ -52,7 +71,27 @@ export class SourceControlViewModel { .filter(change => matchesFilter(change, filter, this.selection)) .filter(() => this.isRenderable(filter, showSynced)) .map(change => this.toItem(change)); - return { filter, items, counts: summary.counts }; + const selectedItems = summary.readyToPush.map(change => this.toItem(change)); + return { filter, items, selectedItems, refreshStatus: this.refreshState.get(), counts: summary.counts }; + } + + /** + * 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. + */ + async refresh(): Promise { + this.refreshState.start(); + try { + await this.refreshSource(); + this.refreshState.succeed(); + } catch (error) { + this.refreshState.fail(); + throw error; + } } private isRenderable(filter: SourceControlFilter, showSynced: boolean): boolean { diff --git a/src/main.ts b/src/main.ts index 31d2ef4..e9afa97 100644 --- a/src/main.ts +++ b/src/main.ts @@ -21,6 +21,7 @@ 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 { PushSelectionStore } from './logic/source-control/PushSelectionStore'; import { SourceControlViewModel } from './logic/source-control/SourceControlViewModel'; import { SourceControlActionService } from './logic/source-control/SourceControlActionService'; @@ -43,6 +44,7 @@ export default class GitLabFilesPush extends Plugin { changeRepository: ChangeRepository; pushSelectionStore: PushSelectionStore; operationState: OperationState; + refreshState: RefreshState; sourceControlViewModel: SourceControlViewModel; sourceControlActions: SourceControlActionService; private unsubscribeChangeRepository?: () => void; @@ -114,10 +116,13 @@ export default class GitLabFilesPush extends Plugin { this.changeRepository = new ChangeRepository(); this.pushSelectionStore = new PushSelectionStore(); this.operationState = new OperationState(); + this.refreshState = new RefreshState(); this.sourceControlViewModel = new SourceControlViewModel( this.changeRepository, this.pushSelectionStore, this.operationState, + () => this.syncWorkspace.refresh(), + this.refreshState, ); this.sourceControlActions = new SourceControlActionService( this.changeRepository, diff --git a/tests/logic/source-control/RefreshState.test.ts b/tests/logic/source-control/RefreshState.test.ts new file mode 100644 index 0000000..45ecf03 --- /dev/null +++ b/tests/logic/source-control/RefreshState.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; +import { RefreshState } from '../../../src/logic/source-control/RefreshState'; + +describe('RefreshState', () => { + it('starts idle', () => { + expect(new RefreshState().get()).toBe('idle'); + }); + + it('transitions to loading on start', () => { + const state = new RefreshState(); + state.start(); + expect(state.get()).toBe('loading'); + }); + + it('transitions back to idle on succeed', () => { + const state = new RefreshState(); + state.start(); + state.succeed(); + expect(state.get()).toBe('idle'); + }); + + it('transitions to failed on fail', () => { + const state = new RefreshState(); + state.start(); + state.fail(); + expect(state.get()).toBe('failed'); + }); + + it('clears a failure back to idle', () => { + const state = new RefreshState(); + state.start(); + state.fail(); + state.clear(); + expect(state.get()).toBe('idle'); + }); + + it('succeed clears a failed state back to idle', () => { + const state = new RefreshState(); + state.start(); + state.fail(); + state.succeed(); + expect(state.get()).toBe('idle'); + }); +}); \ No newline at end of file diff --git a/tests/logic/source-control/SourceControlViewModel.test.ts b/tests/logic/source-control/SourceControlViewModel.test.ts index cd9b1d1..01c7771 100644 --- a/tests/logic/source-control/SourceControlViewModel.test.ts +++ b/tests/logic/source-control/SourceControlViewModel.test.ts @@ -1,6 +1,7 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { ChangeRepository } from '../../../src/logic/source-control/ChangeRepository'; import { OperationState } from '../../../src/logic/source-control/OperationState'; +import { RefreshState } from '../../../src/logic/source-control/RefreshState'; import { PushSelectionStore } from '../../../src/logic/source-control/PushSelectionStore'; import { SourceControlViewModel } from '../../../src/logic/source-control/SourceControlViewModel'; import { toChangeId, type SyncChange } from '../../../src/logic/source-control/types'; @@ -10,8 +11,10 @@ function buildViewModel(changes: SyncChange[]) { repository.replace(changes); const selection = new PushSelectionStore(); const operations = new OperationState(); - const viewModel = new SourceControlViewModel(repository, selection, operations); - return { viewModel, selection, operations }; + const refreshState = new RefreshState(); + const refreshSource = vi.fn().mockResolvedValue(undefined); + const viewModel = new SourceControlViewModel(repository, selection, operations, refreshSource, refreshState); + return { viewModel, selection, operations, refreshState, refreshSource }; } describe('SourceControlViewModel', () => { @@ -112,4 +115,64 @@ describe('SourceControlViewModel', () => { expect(item?.path).toBe('new.md'); expect(item?.previousPath).toBe('old.md'); }); + + it('projects selectedItems as the actionable changes currently in PushSelectionStore', () => { + const changes: SyncChange[] = [ + { id: toChangeId('c-1'), path: 'a.md', kind: 'local-only' }, + { id: toChangeId('c-2'), path: 'b.md', kind: 'remote-only' }, + { id: toChangeId('c-3'), path: 'c.md', kind: 'synced' }, + ]; + const { viewModel, selection } = buildViewModel(changes); + selection.includeForPush(toChangeId('c-1')); + selection.includeForPush(toChangeId('c-2')); + selection.includeForPush(toChangeId('c-3')); + + const state = viewModel.getState('all'); + expect(state.selectedItems.map(i => i.id)).toEqual([toChangeId('c-1'), toChangeId('c-2')]); + // Synced is never actionable, so it's excluded even when selected. + expect(state.selectedItems.every(i => i.kind !== 'synced')).toBe(true); + expect(state.selectedItems[0]?.isReadyToPush).toBe(true); + }); + + it('reports an empty selectedItems projection when nothing is selected', () => { + const { viewModel } = buildViewModel([{ id: toChangeId('c-1'), path: 'a.md', kind: 'local-only' }]); + expect(viewModel.getState('all').selectedItems).toEqual([]); + }); + + it('surfaces the current refresh status on every view state', () => { + const { viewModel, refreshState } = buildViewModel([{ id: toChangeId('c-1'), path: 'a.md', kind: 'local-only' }]); + expect(viewModel.getState('all').refreshStatus).toBe('idle'); + refreshState.start(); + expect(viewModel.getState('all').refreshStatus).toBe('loading'); + refreshState.fail(); + expect(viewModel.getState('all').refreshStatus).toBe('failed'); + }); + + it('refresh() delegates to the refresh source and drives the RefreshState lifecycle', async () => { + const { viewModel, refreshState, refreshSource } = buildViewModel([ + { id: toChangeId('c-1'), path: 'a.md', kind: 'local-only' }, + ]); + + await viewModel.refresh(); + + expect(refreshSource).toHaveBeenCalledTimes(1); + expect(refreshState.get()).toBe('idle'); + }); + + it('refresh() marks the RefreshState failed and rethrows when the refresh source rejects', async () => { + const refreshSource = vi.fn().mockRejectedValue(new Error('boom')); + const repository = new ChangeRepository(); + repository.replace([{ id: toChangeId('c-1'), path: 'a.md', kind: 'local-only' }]); + const refreshState = new RefreshState(); + const viewModel = new SourceControlViewModel( + repository, + new PushSelectionStore(), + new OperationState(), + refreshSource, + refreshState, + ); + + await expect(viewModel.refresh()).rejects.toThrow('boom'); + expect(refreshState.get()).toBe('failed'); + }); }); diff --git a/tests/ui/source-control/SourceControlItemView.test.ts b/tests/ui/source-control/SourceControlItemView.test.ts index 32843a8..572fc7c 100644 --- a/tests/ui/source-control/SourceControlItemView.test.ts +++ b/tests/ui/source-control/SourceControlItemView.test.ts @@ -3,6 +3,7 @@ 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'; import { OperationState } from '../../../src/logic/source-control/OperationState'; +import { RefreshState } from '../../../src/logic/source-control/RefreshState'; import { PushSelectionStore } from '../../../src/logic/source-control/PushSelectionStore'; import { SourceControlViewModel } from '../../../src/logic/source-control/SourceControlViewModel'; import { toChangeId, type SyncChangeKind } from '../../../src/logic/source-control/types'; @@ -17,7 +18,8 @@ function buildPlugin(kind: SyncChangeKind = 'local-only') { repository.replace([{ id: toChangeId('a.md'), path: 'a.md', kind }]); const selection = new PushSelectionStore(); const operations = new OperationState(); - const viewModel = new SourceControlViewModel(repository, selection, operations); + const refreshState = new RefreshState(); + const viewModel = new SourceControlViewModel(repository, selection, operations, vi.fn().mockResolvedValue(undefined), refreshState); const push = vi.fn().mockResolvedValue(undefined); const loadDiffContent = vi.fn().mockResolvedValue({ remote: 'remote text', local: 'local text' }); const openDiffTab = vi.fn().mockResolvedValue(undefined); diff --git a/tests/ui/source-control/SourceControlView.test.ts b/tests/ui/source-control/SourceControlView.test.ts index da25583..17e02d7 100644 --- a/tests/ui/source-control/SourceControlView.test.ts +++ b/tests/ui/source-control/SourceControlView.test.ts @@ -3,6 +3,7 @@ import { Platform } from 'obsidian'; import { SourceControlView, type SourceControlViewCallbacks } from '../../../src/ui/source-control/SourceControlView'; import { ChangeRepository } from '../../../src/logic/source-control/ChangeRepository'; import { OperationState } from '../../../src/logic/source-control/OperationState'; +import { RefreshState } from '../../../src/logic/source-control/RefreshState'; import { PushSelectionStore } from '../../../src/logic/source-control/PushSelectionStore'; import { SourceControlViewModel } from '../../../src/logic/source-control/SourceControlViewModel'; import { toChangeId, type SyncChange } from '../../../src/logic/source-control/types'; @@ -15,7 +16,9 @@ function buildView(changes: SyncChange[], callbacks: Partial ({ serviceName: 'GitHub', @@ -23,7 +26,7 @@ function buildView(changes: SyncChange[], callbacks: Partial { From 625fad25c59911b07949db4f3af15aee3a9f3a5f Mon Sep 17 00:00:00 2001 From: Tianyao Date: Sat, 22 Aug 2026 23:47:00 +0800 Subject: [PATCH 032/104] feat(sync-status): add selection workflow and sync action UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the 'SELECTED FOR SYNC (N)' summary section above the change tree, rendered only when the user has at least one actionable change selected for push. Its count comes straight from the ViewModel's single-source selectedItems projection (the same selected + non-synced definition as the Sync button count), so the section and the Sync button can never drift. Synced changes are excluded even when selected. Drop the 'Ready to Push' chip from the filter row. The visible row is now four chips — All / Local / Remote / Conflict — backed by the unchanged domain filters (all / changes / remote-changes / conflicts). data-filter attributes keep the domain values; only the displayed labels change via new i18n keys (sourceControl.filter.local / .remote / .conflict). The ready-to-push and synced domain filters remain in the type (the former is just no longer exposed as a chip; the latter still surfaces via the 'Show synced' toggle). Add sourceControl.section.selectedForSync i18n key (en/zh-cn/zh-tw) and CSS for .scv-selected-section. FilterMenu/SourceControlView tests updated. --- src/i18n/locales/en.ts | 4 +++ src/i18n/locales/zh-cn.ts | 4 +++ src/i18n/locales/zh-tw.ts | 4 +++ src/ui/source-control/FilterMenu.ts | 33 ++++++++++++------- src/ui/source-control/SourceControlView.ts | 17 ++++++++++ styles.css | 26 +++++++++++++++ tests/ui/source-control/FilterMenu.test.ts | 14 ++++++-- .../source-control/SourceControlView.test.ts | 29 ++++++++++++++++ 8 files changed, 117 insertions(+), 14 deletions(-) diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 796e03f..1285651 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -250,6 +250,9 @@ const en = { 'sourceControl.filter.all': 'All', 'sourceControl.filter.changes': 'Changes', + 'sourceControl.filter.local': 'Local', + 'sourceControl.filter.remote': 'Remote', + 'sourceControl.filter.conflict': 'Conflict', 'sourceControl.filter.readyToPush': 'Ready to Push', 'sourceControl.filter.remoteChanges': 'Remote Changes', 'sourceControl.filter.conflicts': 'Conflicts', @@ -257,6 +260,7 @@ const en = { 'sourceControl.filter.showSynced': 'Show synced', 'sourceControl.section.all': 'ALL', 'sourceControl.section.readyToPush': 'READY TO PUSH', + 'sourceControl.section.selectedForSync': 'SELECTED FOR SYNC', 'sourceControl.section.changes': 'CHANGES', 'sourceControl.section.remoteChanges': 'REMOTE CHANGES', 'sourceControl.section.conflicts': 'CONFLICTS', diff --git a/src/i18n/locales/zh-cn.ts b/src/i18n/locales/zh-cn.ts index 13c06aa..9315e90 100644 --- a/src/i18n/locales/zh-cn.ts +++ b/src/i18n/locales/zh-cn.ts @@ -252,6 +252,9 @@ const zhCn: Partial> = { 'sourceControl.filter.all': '全部', 'sourceControl.filter.changes': '更改', + 'sourceControl.filter.local': '本地', + 'sourceControl.filter.remote': '远程', + 'sourceControl.filter.conflict': '冲突', 'sourceControl.filter.readyToPush': '待推送', 'sourceControl.filter.remoteChanges': '远程更改', 'sourceControl.filter.conflicts': '冲突', @@ -259,6 +262,7 @@ const zhCn: Partial> = { 'sourceControl.filter.showSynced': '显示已同步', 'sourceControl.section.all': '全部', 'sourceControl.section.readyToPush': '待推送', + 'sourceControl.section.selectedForSync': '已选同步', 'sourceControl.section.changes': '更改', 'sourceControl.section.remoteChanges': '远程更改', 'sourceControl.section.conflicts': '冲突', diff --git a/src/i18n/locales/zh-tw.ts b/src/i18n/locales/zh-tw.ts index 3f421d7..4f3d9aa 100644 --- a/src/i18n/locales/zh-tw.ts +++ b/src/i18n/locales/zh-tw.ts @@ -252,6 +252,9 @@ const zhTw: Partial> = { 'sourceControl.filter.all': '全部', 'sourceControl.filter.changes': '變更', + 'sourceControl.filter.local': '本地', + 'sourceControl.filter.remote': '遠端', + 'sourceControl.filter.conflict': '衝突', 'sourceControl.filter.readyToPush': '待推送', 'sourceControl.filter.remoteChanges': '遠端變更', 'sourceControl.filter.conflicts': '衝突', @@ -259,6 +262,7 @@ const zhTw: Partial> = { 'sourceControl.filter.showSynced': '顯示已同步', 'sourceControl.section.all': '全部', 'sourceControl.section.readyToPush': '待推送', + 'sourceControl.section.selectedForSync': '已選同步', 'sourceControl.section.changes': '變更', 'sourceControl.section.remoteChanges': '遠端變更', 'sourceControl.section.conflicts': '衝突', diff --git a/src/ui/source-control/FilterMenu.ts b/src/ui/source-control/FilterMenu.ts index f7bd306..292ea50 100644 --- a/src/ui/source-control/FilterMenu.ts +++ b/src/ui/source-control/FilterMenu.ts @@ -2,18 +2,29 @@ import { t, type TranslationKey } from '../../i18n'; import type { SourceControlFilter } from '../../logic/source-control/SourceControlFilter'; /** - * Action filter chips, in spec order. `synced` is deliberately NOT a permanent - * chip — it surfaces only when the user opts in via the "Show synced" toggle, - * so a quiet workspace isn't dominated by a large synced count. + * Action filter chips, in spec order. The visible row is four chips — + * All / Local / Remote / Conflict — backed by the unchanged domain filters + * (`all` / `changes` / `remote-changes` / `conflicts`). "Ready to Push" is no + * longer a chip: it's surfaced as the inline "SELECTED FOR SYNC (N)" section + * instead. `synced` is deliberately NOT a permanent chip — it surfaces only + * when the user opts in via the "Show synced" toggle, so a quiet workspace + * isn't dominated by a large synced count. */ -const ACTION_FILTERS: SourceControlFilter[] = ['all', 'changes', 'ready-to-push', 'remote-changes', 'conflicts']; +const ACTION_FILTERS: SourceControlFilter[] = ['all', 'changes', 'remote-changes', 'conflicts']; +/** + * Displayed chip labels. Domain values stay as `data-filter` attributes; only + * the visible label changes (e.g. the `changes` domain filter reads "Local" + * because it surfaces local-side changes). `ready-to-push` and `synced` keep + * their existing keys even though `ready-to-push` is no longer a chip, so the + * record stays total over {@link SourceControlFilter}. + */ const FILTER_LABEL_KEYS: Record = { all: 'sourceControl.filter.all', - changes: 'sourceControl.filter.changes', + changes: 'sourceControl.filter.local', 'ready-to-push': 'sourceControl.filter.readyToPush', - 'remote-changes': 'sourceControl.filter.remoteChanges', - conflicts: 'sourceControl.filter.conflicts', + 'remote-changes': 'sourceControl.filter.remote', + conflicts: 'sourceControl.filter.conflict', synced: 'sourceControl.filter.synced', }; @@ -25,10 +36,10 @@ export interface FilterMenuCallbacks { } /** - * Renders the Source Control filter row: the five action chips (All, Changes, - * Ready to Push, Remote Changes, Conflicts) followed by a "Show synced" - * toggle. The `synced` chip is appended only when `showSynced` is on, so a - * hidden synced bucket contributes no chip and no count to the row. + * Renders the Source Control filter row: the four action chips (All, Local, + * Remote, Conflict) followed by a "Show synced" toggle. The `synced` chip is + * appended only when `showSynced` is on, so a hidden synced bucket contributes + * no chip and no count to the row. * * Per-filter counts come straight from the ViewModel's single-source counts; * the menu never recomputes one. diff --git a/src/ui/source-control/SourceControlView.ts b/src/ui/source-control/SourceControlView.ts index f54ee12..41c7fdc 100644 --- a/src/ui/source-control/SourceControlView.ts +++ b/src/ui/source-control/SourceControlView.ts @@ -145,6 +145,8 @@ export class SourceControlView { const query = this.searchQuery.trim().toLowerCase(); const items = query ? state.items.filter(item => item.path.toLowerCase().includes(query)) : state.items; + this.renderSelectedSection(container, state.selectedItems); + const body = container.createDiv({ cls: 'scv-body' }); this.renderActiveFilterHeader(body, state.filter, items.length); if (items.length === 0) { @@ -220,6 +222,21 @@ export class SourceControlView { header.createSpan({ cls: 'scv-active-filter-count', text: String(count) }); } + /** + * Renders the "SELECTED FOR SYNC (N)" summary, only when the user has at + * least one actionable change selected for push. Sits above the tree so the + * current push batch is always visible regardless of the active filter. + * The count comes straight from the ViewModel's single-source + * `selectedItems` projection (same definition as the Sync button count), + * so the two can never drift. + */ + private renderSelectedSection(container: HTMLElement, selectedItems: readonly SourceControlItem[]): void { + if (selectedItems.length === 0) return; + const section = container.createDiv({ cls: 'scv-selected-section' }); + section.createSpan({ cls: 'scv-selected-section-title', text: t('sourceControl.section.selectedForSync') }); + section.createSpan({ cls: 'scv-selected-section-count', text: String(selectedItems.length) }); + } + private renderDetail(root: HTMLElement): void { const detail = root.createDiv({ cls: 'scv-detail' }); const bar = detail.createDiv({ cls: 'scv-detail-bar' }); diff --git a/styles.css b/styles.css index 0e235b5..bb614b6 100644 --- a/styles.css +++ b/styles.css @@ -216,6 +216,32 @@ text-align: center; } +/* ── Selected-for-sync summary ──────────────────────────────── */ +.scv-selected-section { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + margin: 4px 12px 0 12px; + border-radius: 6px; + background: var(--background-modifier-hover); + color: var(--text-muted); + font-size: 0.78em; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.02em; +} + +.scv-selected-section-count { + background: var(--interactive-accent); + color: var(--text-on-accent); + border-radius: 10px; + padding: 1px 6px; + font-size: 0.9em; + min-width: 18px; + text-align: center; +} + /* ── Push button ───────────────────────────────────────────────── */ .scv-push-btn { display: flex; diff --git a/tests/ui/source-control/FilterMenu.test.ts b/tests/ui/source-control/FilterMenu.test.ts index dc6858f..5b90a91 100644 --- a/tests/ui/source-control/FilterMenu.test.ts +++ b/tests/ui/source-control/FilterMenu.test.ts @@ -18,18 +18,26 @@ describe('renderFilterMenu', () => { callbacks = { onFilterChange: vi.fn(), onToggleShowSynced: vi.fn() }; }); - it('renders the five action chips (no synced chip) when showSynced is false', () => { + it('renders the four action chips (All/Local/Remote/Conflict, no synced chip) when showSynced is false', () => { renderFilterMenu(container, 'all', zeroCounts, false, callbacks); const filters = Array.from(container.querySelectorAll('.scv-filter-option')).map(el => el.getAttribute('data-filter')); - expect(filters).toEqual(['all', 'changes', 'ready-to-push', 'remote-changes', 'conflicts']); + expect(filters).toEqual(['all', 'changes', 'remote-changes', 'conflicts']); + }); + + it('labels the chips with the domain-relabeled display names (Local/Remote/Conflict)', () => { + renderFilterMenu(container, 'all', zeroCounts, false, callbacks); + + const labels = Array.from(container.querySelectorAll('.scv-filter-option .scv-filter-label')).map(el => el.textContent); + // Domain values stay as data-filter; only the visible labels change. + expect(labels).toEqual(['All', 'Local', 'Remote', 'Conflict']); }); it('appends the synced chip when showSynced is true', () => { renderFilterMenu(container, 'all', { ...zeroCounts, synced: 7 }, true, callbacks); const filters = Array.from(container.querySelectorAll('.scv-filter-option')).map(el => el.getAttribute('data-filter')); - expect(filters).toEqual(['all', 'changes', 'ready-to-push', 'remote-changes', 'conflicts', 'synced']); + expect(filters).toEqual(['all', 'changes', 'remote-changes', 'conflicts', 'synced']); const syncedOption = container.querySelector('.scv-filter-option[data-filter="synced"]'); expect(syncedOption?.querySelector('.scv-filter-count')?.textContent).toBe('7'); }); diff --git a/tests/ui/source-control/SourceControlView.test.ts b/tests/ui/source-control/SourceControlView.test.ts index 17e02d7..67a00f8 100644 --- a/tests/ui/source-control/SourceControlView.test.ts +++ b/tests/ui/source-control/SourceControlView.test.ts @@ -168,6 +168,35 @@ describe('SourceControlView', () => { expect(selection.isIncluded(toChangeId('c-1'))).toBe(false); }); + + it('renders the "SELECTED FOR SYNC" section only when at least one actionable change is selected', () => { + const { view, selection } = buildView([ + { id: toChangeId('c-1'), path: 'a.md', kind: 'local-only' }, + { id: toChangeId('c-2'), path: 'b.md', kind: 'synced' }, + ]); + + view.render(container); + expect(container.querySelector('.scv-selected-section')).toBeNull(); + + selection.includeForPush(toChangeId('c-1')); + view.render(container); + const section = container.querySelector('.scv-selected-section'); + expect(section).not.toBeNull(); + expect(section?.querySelector('.scv-selected-section-title')?.textContent).toBe('SELECTED FOR SYNC'); + expect(section?.querySelector('.scv-selected-section-count')?.textContent).toBe('1'); + }); + + it('excludes synced changes from the SELECTED FOR SYNC count even when selected', () => { + const { view, selection } = buildView([ + { id: toChangeId('c-1'), path: 'a.md', kind: 'local-only' }, + { id: toChangeId('c-2'), path: 'b.md', kind: 'synced' }, + ]); + selection.includeForPush(toChangeId('c-1')); + selection.includeForPush(toChangeId('c-2')); + view.render(container); + + expect(container.querySelector('.scv-selected-section-count')?.textContent).toBe('1'); + }); }); describe('push action', () => { From 759b717da7e6d49dbc6f99e2dc18b5ab71480d59 Mon Sep 17 00:00:00 2001 From: Tianyao Date: Sat, 22 Aug 2026 23:51:33 +0800 Subject: [PATCH 033/104] feat(sync-status): add refresh and operation feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a refresh button to the Source Control header with three states driven by the RefreshState holder: idle (icon-only), loading ('Refreshing...' with a spinning icon, disabled), and failed ('Refresh failed'). The button reads refreshStatus off the ViewModel state and calls a new onRefresh callback. Wire onRefresh in SourceControlViewCallbacks and SourceControlItemView to the ViewModel's refresh() delegate via a runRefresh helper that renders immediately (so the loading state shows once refresh() sets RefreshState to 'loading' synchronously) then re-renders on settle (idle on success, failed on rejection — the rejection is swallowed since the state was already recorded on the holder). Add text labels alongside the per-change OperationIndicator icon ('Syncing' / 'Synced' / 'Failed') via new sourceControl.op.* i18n keys, so an in-flight operation is readable rather than icon-only. Add sourceControl.refresh.* i18n keys (en/zh-cn/zh-tw) and CSS for .scv-refresh-btn states and .scv-op-label. The push button's full-width layout becomes flex:1 so the refresh button sits beside it. Refresh and operation tests added. --- src/i18n/locales/en.ts | 6 ++ src/i18n/locales/zh-cn.ts | 6 ++ src/i18n/locales/zh-tw.ts | 6 ++ src/ui/source-control/OperationIndicator.ts | 15 +++- src/ui/source-control/SourceControlHeader.ts | 41 ++++++++-- .../source-control/SourceControlItemView.ts | 16 ++++ src/ui/source-control/SourceControlView.ts | 13 +++- styles.css | 49 +++++++++++- .../SourceControlItemView.test.ts | 13 ++++ .../source-control/SourceControlView.test.ts | 75 ++++++++++++++++++- 10 files changed, 225 insertions(+), 15 deletions(-) diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 1285651..7b8f1ad 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -267,6 +267,12 @@ const en = { 'sourceControl.section.synced': 'SYNCED', 'sourceControl.push': ' Sync ({count})', 'sourceControl.push.tooltip': 'Push {count} ready file(s)', + 'sourceControl.refresh.tooltip': 'Refresh', + 'sourceControl.refresh.refreshing': 'Refreshing…', + 'sourceControl.refresh.failed': 'Refresh failed', + 'sourceControl.op.syncing': 'Syncing', + 'sourceControl.op.synced': 'Synced', + 'sourceControl.op.failed': 'Failed', 'sourceControl.empty': 'No changes', 'sourceControl.detail.back': 'Back', 'sourceControl.info.lastSync': 'Last sync: {time}', diff --git a/src/i18n/locales/zh-cn.ts b/src/i18n/locales/zh-cn.ts index 9315e90..52a1089 100644 --- a/src/i18n/locales/zh-cn.ts +++ b/src/i18n/locales/zh-cn.ts @@ -269,6 +269,12 @@ const zhCn: Partial> = { 'sourceControl.section.synced': '已同步', 'sourceControl.push': ' 同步 ({count})', 'sourceControl.push.tooltip': '推送 {count} 个已就绪的文件', + 'sourceControl.refresh.tooltip': '刷新', + 'sourceControl.refresh.refreshing': '刷新中…', + 'sourceControl.refresh.failed': '刷新失败', + 'sourceControl.op.syncing': '同步中', + 'sourceControl.op.synced': '已同步', + 'sourceControl.op.failed': '失败', 'sourceControl.empty': '没有更改', 'sourceControl.detail.back': '返回', 'sourceControl.info.lastSync': '上次同步:{time}', diff --git a/src/i18n/locales/zh-tw.ts b/src/i18n/locales/zh-tw.ts index 4f3d9aa..7c8950c 100644 --- a/src/i18n/locales/zh-tw.ts +++ b/src/i18n/locales/zh-tw.ts @@ -269,6 +269,12 @@ const zhTw: Partial> = { 'sourceControl.section.synced': '已同步', 'sourceControl.push': ' 同步 ({count})', 'sourceControl.push.tooltip': '推送 {count} 個已就緒的檔案', + 'sourceControl.refresh.tooltip': '重新整理', + 'sourceControl.refresh.refreshing': '重新整理中…', + 'sourceControl.refresh.failed': '重新整理失敗', + 'sourceControl.op.syncing': '同步中', + 'sourceControl.op.synced': '已同步', + 'sourceControl.op.failed': '失敗', 'sourceControl.empty': '沒有變更', 'sourceControl.detail.back': '返回', 'sourceControl.info.lastSync': '上次同步:{time}', diff --git a/src/ui/source-control/OperationIndicator.ts b/src/ui/source-control/OperationIndicator.ts index 8785db8..ebd40e0 100644 --- a/src/ui/source-control/OperationIndicator.ts +++ b/src/ui/source-control/OperationIndicator.ts @@ -1,17 +1,26 @@ import { setIcon } from 'obsidian'; +import { t, type TranslationKey } from '../../i18n'; import { ICONS } from '../components/icons'; import type { OperationStatus } from '../../logic/source-control/OperationState'; +const OP_LABEL_KEYS: Record, TranslationKey> = { + running: 'sourceControl.op.syncing', + success: 'sourceControl.op.synced', + failed: 'sourceControl.op.failed', +}; + /** - * Renders a small per-change status indicator for an in-flight operation. - * Renders nothing for 'idle' — the common case — so rows stay quiet until an + * Renders a small per-change status indicator for an in-flight operation: + * icon plus a short text label ("Syncing" / "Synced" / "Failed"). Renders + * nothing for 'idle' — the common case — so rows stay quiet until an * operation is actually running/finished. */ export function renderOperationIndicator(container: HTMLElement, status: OperationStatus): HTMLElement | undefined { if (status === 'idle') return undefined; const el = container.createSpan({ cls: `scv-op-indicator scv-op-${status}` }); - setIcon(el, operationIcon(status)); + setIcon(el.createSpan({ cls: 'scv-op-icon' }), operationIcon(status)); + el.createSpan({ cls: 'scv-op-label', text: t(OP_LABEL_KEYS[status]) }); return el; } diff --git a/src/ui/source-control/SourceControlHeader.ts b/src/ui/source-control/SourceControlHeader.ts index 87f6e7e..12cac4d 100644 --- a/src/ui/source-control/SourceControlHeader.ts +++ b/src/ui/source-control/SourceControlHeader.ts @@ -1,5 +1,6 @@ -import { Platform, setIcon } from 'obsidian'; +import { Platform, setIcon, setTooltip } from 'obsidian'; import { t } from '../../i18n'; +import type { RefreshStatus } from '../../logic/source-control/RefreshState'; import { ICONS } from '../components/icons'; import { renderPushButton } from './PushButton'; @@ -14,17 +15,20 @@ export interface SourceControlWorkspaceInfo { export interface SourceControlHeaderProps { readyToPushCount: number; workspaceInfo: SourceControlWorkspaceInfo; + refreshStatus: RefreshStatus; } export interface SourceControlHeaderCallbacks { onPush: () => void; + onRefresh: () => void; } /** - * Renders the Sync status view's connection/branch/last-sync info and Push - * button. No title here -- Obsidian's own tab header already shows "Sync - * status" (SourceControlItemView.getDisplayText), so repeating it in-panel - * duplicated the label, most visibly on mobile's stacked tab layout. + * Renders the Sync status view's connection/branch/last-sync info, Sync + * button, and Refresh button. No title here -- Obsidian's own tab header + * already shows "Sync status" (SourceControlItemView.getDisplayText), so + * repeating it in-panel duplicated the label, most visibly on mobile's + * stacked tab layout. */ export function renderSourceControlHeader( container: HTMLElement, @@ -34,10 +38,37 @@ export function renderSourceControlHeader( const header = container.createDiv({ cls: 'scv-header' }); const titleRow = header.createDiv({ cls: 'scv-header-title-row' }); renderPushButton(titleRow, props.readyToPushCount, callbacks.onPush); + renderRefreshButton(titleRow, props.refreshStatus, callbacks.onRefresh); renderInfoStrip(header, props.workspaceInfo); } +function renderRefreshButton(container: HTMLElement, status: RefreshStatus, onRefresh: () => void): void { + const btn = container.createEl('button', { cls: `scv-refresh-btn is-${status}` }); + btn.setAttr('aria-label', t('sourceControl.refresh.tooltip')); + setIcon(btn.createSpan({ cls: 'scv-refresh-btn-icon' }), ICONS.refresh); + + const label = btn.createSpan({ cls: 'scv-refresh-btn-label' }); + if (status === 'loading') { + label.textContent = t('sourceControl.refresh.refreshing'); + btn.disabled = true; + } else if (status === 'failed') { + label.textContent = t('sourceControl.refresh.failed'); + setTooltip(btn, t('sourceControl.refresh.failed')); + } else { + label.textContent = ''; + setTooltip(btn, t('sourceControl.refresh.tooltip')); + } + + // Show the label span only when there's text (loading/failed); idle stays icon-only. + if (status === 'idle') label.addClass('is-hidden'); + + btn.addEventListener('click', () => { + if (status === 'loading') return; + onRefresh(); + }); +} + function renderInfoStrip(container: HTMLElement, info: SourceControlWorkspaceInfo): void { const strip = container.createDiv({ cls: 'scv-info' }); diff --git a/src/ui/source-control/SourceControlItemView.ts b/src/ui/source-control/SourceControlItemView.ts index 602187d..1cc4508 100644 --- a/src/ui/source-control/SourceControlItemView.ts +++ b/src/ui/source-control/SourceControlItemView.ts @@ -33,6 +33,7 @@ export class SourceControlItemView extends ItemView { super(leaf); const callbacks: SourceControlViewCallbacks = { onPush: (changeIds) => this.runAction(this.plugin.sourceControlActions.push(changeIds)), + onRefresh: () => this.runRefresh(), loadDiffContent: (item: SourceControlItem) => this.plugin.sourceControlActions.loadDiffContent(item), // Desktop: the panel is a narrow sidebar, so the diff opens in a // full-width main-area tab instead of splitting that sidebar. @@ -109,4 +110,19 @@ export class SourceControlItemView extends ItemView { this.renderView(); void action.finally(() => this.renderView()); } + + /** + * Refresh reuses the same render-then-settle pattern as {@link runAction}, + * but the ViewModel's refresh() sets its `RefreshState` to 'loading' + * synchronously (before the first `await`), so the immediate render shows + * "Refreshing…". The settle render projects 'idle' on success or + * 'failed' on rejection. The rejection is swallowed here so a failed + * refresh surfaces as the button's failed state rather than an unhandled + * rejection — the state was already recorded on the `RefreshState` holder. + */ + private runRefresh(): void { + const refresh = this.plugin.sourceControlViewModel.refresh(); + this.renderView(); + void refresh.then(() => this.renderView(), () => this.renderView()); + } } diff --git a/src/ui/source-control/SourceControlView.ts b/src/ui/source-control/SourceControlView.ts index 41c7fdc..1545684 100644 --- a/src/ui/source-control/SourceControlView.ts +++ b/src/ui/source-control/SourceControlView.ts @@ -19,6 +19,8 @@ export interface SourceControlDiffContent { export interface SourceControlViewCallbacks { /** Hands push intent off to whatever wires this view to the sync pipeline; never called by the UI directly against a Git provider. */ onPush: (changeIds: ChangeId[]) => void | Promise; + /** Triggers a view-wide refresh; the host wires this to the ViewModel's refresh delegate. */ + onRefresh: () => 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. */ @@ -120,8 +122,15 @@ export class SourceControlView { renderSourceControlHeader( container, - { readyToPushCount: state.counts['ready-to-push'], workspaceInfo: this.getWorkspaceInfo() }, - { onPush: () => { void this.callbacks.onPush(this.selection.getSelectedChangeIds()); } }, + { + readyToPushCount: state.counts['ready-to-push'], + workspaceInfo: this.getWorkspaceInfo(), + refreshStatus: state.refreshStatus, + }, + { + onPush: () => { void this.callbacks.onPush(this.selection.getSelectedChangeIds()); }, + onRefresh: () => this.callbacks.onRefresh(), + }, ); this.renderSearchBox(container); diff --git a/styles.css b/styles.css index bb614b6..797b641 100644 --- a/styles.css +++ b/styles.css @@ -18,8 +18,8 @@ .scv-header-title-row { display: flex; align-items: center; - justify-content: flex-end; gap: 8px; + padding: 0 10px; } /* ── Workspace info strip (provider · branch · vault folder · last sync) ── */ @@ -248,8 +248,8 @@ align-items: center; justify-content: center; gap: 6px; - width: calc(100% - 20px); - margin: 8px 10px; + flex: 1 1 auto; + margin: 8px 0; padding: 7px 11px; border-radius: 5px; font-size: 0.85em; @@ -270,6 +270,43 @@ .scv-push-btn:not(:disabled):hover { opacity: 0.85; } +/* ── Refresh button ───────────────────────────────────────────── */ +.scv-refresh-btn { + display: flex; + align-items: center; + gap: 5px; + flex: 0 0 auto; + margin: 8px 0; + padding: 6px 9px; + border-radius: 5px; + font-size: 0.8em; + cursor: pointer; + border: 1px solid var(--background-modifier-border); + background: var(--background-modifier-form-field); + color: var(--text-muted); + min-height: 32px; +} + +.scv-refresh-btn:hover { color: var(--text-normal); } + +.scv-refresh-btn.is-loading { cursor: progress; } +.scv-refresh-btn.is-loading .scv-refresh-btn-icon { animation: scv-refresh-spin 1s linear infinite; } + +.scv-refresh-btn.is-failed { + color: var(--text-error); + border-color: var(--background-modifier-error-border, var(--background-modifier-border)); +} + +.scv-refresh-btn:disabled { opacity: 0.7; cursor: progress; } + +.scv-refresh-btn-label { white-space: nowrap; } +.scv-refresh-btn.is-idle .scv-refresh-btn-label { display: none; } + +@keyframes scv-refresh-spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + /* ── Change sections & tree ───────────────────────────────────────── */ .scv-section-header { display: flex; @@ -446,11 +483,17 @@ .scv-op-indicator { display: inline-flex; align-items: center; + gap: 3px; flex-shrink: 0; } .scv-op-indicator .svg-icon { width: 14px; height: 14px; } +.scv-op-label { + font-size: 0.72em; + white-space: nowrap; +} + .scv-op-running { color: var(--text-muted); } .scv-op-success { color: var(--color-green); } .scv-op-failed { color: var(--color-red); } diff --git a/tests/ui/source-control/SourceControlItemView.test.ts b/tests/ui/source-control/SourceControlItemView.test.ts index 572fc7c..f48f4fa 100644 --- a/tests/ui/source-control/SourceControlItemView.test.ts +++ b/tests/ui/source-control/SourceControlItemView.test.ts @@ -81,6 +81,19 @@ describe('SourceControlItemView', () => { expect(push).toHaveBeenCalledWith([toChangeId('a.md')]); }); + it('forwards refresh clicks to the ViewModel refresh delegate', async () => { + const { plugin } = buildPlugin(); + const refreshSpy = vi.spyOn(plugin.sourceControlViewModel, 'refresh').mockResolvedValue(undefined); + const view = new SourceControlItemView({} as WorkspaceLeaf, plugin); + await view.onOpen(); + + const container = view.containerEl.children[1] as HTMLElement; + (container.querySelector('.scv-refresh-btn') as HTMLButtonElement).click(); + await Promise.resolve(); + + expect(refreshSpy).toHaveBeenCalledTimes(1); + }); + it('re-renders when the shared SyncStatusService publishes a change', async () => { const { plugin, repository, status } = buildPlugin(); const view = new SourceControlItemView({} as WorkspaceLeaf, plugin); diff --git a/tests/ui/source-control/SourceControlView.test.ts b/tests/ui/source-control/SourceControlView.test.ts index 67a00f8..dc5beee 100644 --- a/tests/ui/source-control/SourceControlView.test.ts +++ b/tests/ui/source-control/SourceControlView.test.ts @@ -20,13 +20,14 @@ function buildView(changes: SyncChange[], callbacks: Partial ({ + const onRefresh = callbacks.onRefresh ?? vi.fn(); + const view = new SourceControlView(viewModel, selection, { onPush, onRefresh, ...callbacks }, () => ({ serviceName: 'GitHub', branch: 'main', vaultFolder: '', lastSyncTime: 0, })); - return { view, selection, operations, refreshState, refreshSource, onPush }; + return { view, selection, operations, refreshState, refreshSource, onPush, onRefresh }; } describe('SourceControlView', () => { @@ -236,6 +237,14 @@ describe('SourceControlView', () => { expect(indicator?.classList.contains('scv-op-running')).toBe(true); }); + it('renders a text label alongside the operation indicator', () => { + const { view, operations } = buildView([{ id: toChangeId('c-1'), path: 'a.md', kind: 'local-only' }]); + operations.start(toChangeId('c-1')); + view.render(container); + + expect(container.querySelector('.scv-op-indicator .scv-op-label')?.textContent).toBe('Syncing'); + }); + it('shows no indicator once the operation is idle again', () => { const { view, operations } = buildView([{ id: toChangeId('c-1'), path: 'a.md', kind: 'local-only' }]); operations.start(toChangeId('c-1')); @@ -246,6 +255,68 @@ describe('SourceControlView', () => { }); }); + describe('refresh', () => { + it('renders the refresh button in the idle state by default', () => { + const { view } = buildView([{ id: toChangeId('c-1'), path: 'a.md', kind: 'local-only' }]); + view.render(container); + + const btn = container.querySelector('.scv-refresh-btn'); + expect(btn).not.toBeNull(); + expect(btn?.classList.contains('is-idle')).toBe(true); + }); + + it('renders the "Refreshing…" label while loading', () => { + const { view, refreshState } = buildView([{ id: toChangeId('c-1'), path: 'a.md', kind: 'local-only' }]); + view.render(container); + refreshState.start(); + view.render(container); + + const btn = container.querySelector('.scv-refresh-btn'); + expect(btn?.classList.contains('is-loading')).toBe(true); + expect(btn?.querySelector('.scv-refresh-btn-label')?.textContent).toBe('Refreshing…'); + expect((btn as HTMLButtonElement).disabled).toBe(true); + }); + + it('renders the "Refresh failed" label in the failed state', () => { + const { view, refreshState } = buildView([{ id: toChangeId('c-1'), path: 'a.md', kind: 'local-only' }]); + view.render(container); + refreshState.fail(); + view.render(container); + + const btn = container.querySelector('.scv-refresh-btn'); + expect(btn?.classList.contains('is-failed')).toBe(true); + expect(btn?.querySelector('.scv-refresh-btn-label')?.textContent).toBe('Refresh failed'); + }); + + it('calls onRefresh when the refresh button is clicked', () => { + const onRefresh = vi.fn(); + const { view } = buildView( + [{ id: toChangeId('c-1'), path: 'a.md', kind: 'local-only' }], + { onRefresh }, + ); + view.render(container); + + (container.querySelector('.scv-refresh-btn') as HTMLButtonElement).click(); + + expect(onRefresh).toHaveBeenCalledTimes(1); + }); + + it('does not call onRefresh while a refresh is already loading', () => { + const onRefresh = vi.fn(); + const { view, refreshState } = buildView( + [{ id: toChangeId('c-1'), path: 'a.md', kind: 'local-only' }], + { onRefresh }, + ); + view.render(container); + refreshState.start(); + view.render(container); + + (container.querySelector('.scv-refresh-btn') as HTMLButtonElement).click(); + + expect(onRefresh).not.toHaveBeenCalled(); + }); + }); + describe('diff selection', () => { // Desktop has no inline diff pane -- clicking a change only notifies // onOpenDiff, and the host (SourceControlItemView) opens a main-area From dd8ddd5761d5e146c7ae0a146bff60d882575f9f Mon Sep 17 00:00:00 2001 From: Tianyao Date: Sun, 23 Aug 2026 00:06:16 +0800 Subject: [PATCH 034/104] feat(source-control): presentation adapter, diff stat, responsive mobile Add ChangePresentation UI adapter so all kind-specific presentation (badge letter, subtitle, rename display, deleted-locally tooltip) lives in the UI layer, keeping the domain filters/summary semantics-only. remote-only is badged 'D' (deleted locally) rather than 'A'; rename and subtitle move out of ChangeItem into the adapter. Thread an optional diff-stat through each row: local-only stats are eagerly resolved from the in-memory sync.status (no provider call) on render and cached; two-sided stats lazy-load on open and reuse the diff content the pane already fetches. The cache clears on refresh. Null results (binary/missing content) are cached too so they aren't retried every rerender. Responsive mobile layout: chips collapse to a single filter dropdown, the header push button is hidden, and a sticky bottom sync bar appears when a push selection exists. The mobile tree uses a flatter shape (collapseSingleChild + maxDepth). No src/logic/source-control/ files touched beyond ViewModel + RefreshState. - New: src/ui/source-control/ChangePresentation.ts - New i18n: sourceControl.status.{added,modified,renamed,deletedLocally, modifiedRemotely,conflict,synced} + deletedLocally.tooltip (en/zh-cn/zh-tw) - Tests: ChangePresentation (badge/subtitle/rename/stat), ChangeTree (remote-only D, subtitle, diff-stat span), SourceControlView (stat caching/clear-on-refresh/lazy-load, mobile dropdown + bottom sync bar) - 629 tests pass, eslint clean, build + Obsidian 1.11 compat pass --- src/i18n/locales/en.ts | 8 ++ src/i18n/locales/zh-cn.ts | 8 ++ src/i18n/locales/zh-tw.ts | 8 ++ src/ui/source-control/ChangeItem.ts | 51 +++---- src/ui/source-control/ChangePresentation.ts | 101 ++++++++++++++ src/ui/source-control/FilterMenu.ts | 57 ++++++-- src/ui/source-control/SourceControlHeader.ts | 10 +- .../source-control/SourceControlItemView.ts | 24 ++++ src/ui/source-control/SourceControlView.ts | 74 +++++++++- styles.css | 75 ++++++++++- .../source-control/ChangePresentation.test.ts | 114 ++++++++++++++++ tests/ui/source-control/ChangeTree.test.ts | 29 ++++ .../source-control/SourceControlView.test.ts | 127 ++++++++++++++++++ 13 files changed, 645 insertions(+), 41 deletions(-) create mode 100644 src/ui/source-control/ChangePresentation.ts create mode 100644 tests/ui/source-control/ChangePresentation.test.ts diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 7b8f1ad..7038db6 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -273,6 +273,14 @@ const en = { 'sourceControl.op.syncing': 'Syncing', 'sourceControl.op.synced': 'Synced', 'sourceControl.op.failed': 'Failed', + 'sourceControl.status.added': 'Added', + 'sourceControl.status.modified': 'Modified', + 'sourceControl.status.renamed': 'Renamed', + 'sourceControl.status.deletedLocally': 'Deleted locally', + 'sourceControl.status.deletedLocally.tooltip': 'Remote file will be removed during sync', + 'sourceControl.status.modifiedRemotely': 'Modified remotely', + 'sourceControl.status.conflict': 'Conflict', + 'sourceControl.status.synced': 'Synced', 'sourceControl.empty': 'No changes', 'sourceControl.detail.back': 'Back', 'sourceControl.info.lastSync': 'Last sync: {time}', diff --git a/src/i18n/locales/zh-cn.ts b/src/i18n/locales/zh-cn.ts index 52a1089..f97c41a 100644 --- a/src/i18n/locales/zh-cn.ts +++ b/src/i18n/locales/zh-cn.ts @@ -275,6 +275,14 @@ const zhCn: Partial> = { 'sourceControl.op.syncing': '同步中', 'sourceControl.op.synced': '已同步', 'sourceControl.op.failed': '失败', + 'sourceControl.status.added': '已添加', + 'sourceControl.status.modified': '已修改', + 'sourceControl.status.renamed': '已重命名', + 'sourceControl.status.deletedLocally': '本地已删除', + 'sourceControl.status.deletedLocally.tooltip': '远程文件将在同步时被移除', + 'sourceControl.status.modifiedRemotely': '远程已修改', + 'sourceControl.status.conflict': '冲突', + 'sourceControl.status.synced': '已同步', 'sourceControl.empty': '没有更改', 'sourceControl.detail.back': '返回', 'sourceControl.info.lastSync': '上次同步:{time}', diff --git a/src/i18n/locales/zh-tw.ts b/src/i18n/locales/zh-tw.ts index 7c8950c..d3dea73 100644 --- a/src/i18n/locales/zh-tw.ts +++ b/src/i18n/locales/zh-tw.ts @@ -275,6 +275,14 @@ const zhTw: Partial> = { 'sourceControl.op.syncing': '同步中', 'sourceControl.op.synced': '已同步', 'sourceControl.op.failed': '失敗', + 'sourceControl.status.added': '已新增', + 'sourceControl.status.modified': '已修改', + 'sourceControl.status.renamed': '已重新命名', + 'sourceControl.status.deletedLocally': '本地已刪除', + 'sourceControl.status.deletedLocally.tooltip': '遠端檔案將於同步時被移除', + 'sourceControl.status.modifiedRemotely': '遠端已修改', + 'sourceControl.status.conflict': '衝突', + 'sourceControl.status.synced': '已同步', 'sourceControl.empty': '沒有變更', 'sourceControl.detail.back': '返回', 'sourceControl.info.lastSync': '上次同步:{time}', diff --git a/src/ui/source-control/ChangeItem.ts b/src/ui/source-control/ChangeItem.ts index bbb312c..027b945 100644 --- a/src/ui/source-control/ChangeItem.ts +++ b/src/ui/source-control/ChangeItem.ts @@ -1,57 +1,53 @@ import { setIcon } from 'obsidian'; import { ICONS } from '../components/icons'; import { renderOperationIndicator } from './OperationIndicator'; +import { presentChange, type ChangeStat } from './ChangePresentation'; import type { SourceControlItem } from '../../logic/source-control/SourceControlViewModel'; -import type { ChangeId, SyncChangeKind } from '../../logic/source-control/types'; +import type { ChangeId } from '../../logic/source-control/types'; export interface ChangeItemCallbacks { onToggleSelect: (id: ChangeId, selected: boolean) => void; onOpenDiff: (item: SourceControlItem) => void; -} - -interface KindBadge { - letter: string; - cls: string; + /** Looks up a cached diff stat for a row, if one has been computed. */ + getDiffStat?: (id: ChangeId) => ChangeStat | undefined; } /** - * Single-letter status badge per change kind, matching the VS Code style - * tree example in the Phase 3 spec (`M daily.md`, `A idea.md`, `! settings.md`). + * Renders a single change row: selection checkbox, status badge, name (with + * rename arrow for moves), optional diff-stat span, and operation + * indicator. All kind-specific presentation (badge letter, subtitle, + * rename display) comes from {@link presentChange} so this component stays a + * pure renderer. */ -const KIND_BADGE: Record = { - 'local-only': { letter: 'A', cls: 'local-only' }, - 'local-modified': { letter: 'M', cls: 'local-modified' }, - 'remote-only': { letter: 'A', cls: 'remote-only' }, - 'remote-modified': { letter: 'M', cls: 'remote-modified' }, - moved: { letter: 'R', cls: 'moved' }, - conflict: { letter: '!', cls: 'conflict' }, - synced: { letter: 'S', cls: 'synced' }, -}; - -/** Renders a single change row: selection checkbox, status badge, name, operation indicator. */ export function renderChangeItem( container: HTMLElement, item: SourceControlItem, displayName: string, callbacks: ChangeItemCallbacks, ): HTMLElement { + const view = presentChange(item, displayName); + const row = container.createDiv({ cls: `scv-change-item scv-kind-${item.kind}` }); row.setAttr('data-change-id', item.id); + if (view.tooltip) row.setAttr('title', view.tooltip); const checkbox = row.createEl('input', { type: 'checkbox', cls: 'scv-change-select' }); checkbox.checked = item.isReadyToPush; checkbox.addEventListener('change', () => callbacks.onToggleSelect(item.id, checkbox.checked)); - const badge = KIND_BADGE[item.kind]; + const badge = view.badge; row.createSpan({ cls: `scv-badge scv-badge-${badge.cls}`, text: badge.letter }); const label = row.createDiv({ cls: 'scv-change-name' }); - if (item.previousPath) { - const previousName = item.previousPath.split('/').pop() ?? item.previousPath; - label.createSpan({ cls: 'scv-change-rename-from', text: previousName }); + if (view.renameFrom) { + label.createSpan({ cls: 'scv-change-rename-from', text: view.renameFrom }); setIcon(label.createSpan({ cls: 'scv-change-rename-arrow' }), ICONS.moved); } - label.createSpan({ cls: 'scv-change-name-text', text: displayName }); + label.createSpan({ cls: 'scv-change-name-text', text: view.displayName }); + label.createSpan({ cls: 'scv-change-subtitle', text: view.subtitle }); + + const stat = callbacks.getDiffStat?.(item.id); + if (stat) row.createSpan({ cls: 'scv-diff-stat', text: formatStat(stat) }); renderOperationIndicator(row, item.operationStatus); @@ -62,3 +58,10 @@ export function renderChangeItem( return row; } + +function formatStat(stat: ChangeStat): string { + const parts: string[] = []; + if (stat.additions > 0) parts.push(`+${stat.additions}`); + if (stat.deletions > 0) parts.push(`-${stat.deletions}`); + return parts.join(' '); +} \ No newline at end of file diff --git a/src/ui/source-control/ChangePresentation.ts b/src/ui/source-control/ChangePresentation.ts new file mode 100644 index 0000000..cd41171 --- /dev/null +++ b/src/ui/source-control/ChangePresentation.ts @@ -0,0 +1,101 @@ +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 + * optional tooltip. All UI-specific meaning (M/A/D/R icons, "Deleted + * locally" wording, rename arrow) lives here rather than in the domain, so + * `SyncChangeKind` / `SourceControlFilter` / `SourceControlSummary` stay + * semantics-only and untouched. + */ +export interface ChangeRowView { + badge: { letter: string; cls: string }; + subtitle: string; + displayName: string; + /** Present for a tracked rename: the old name shown before an arrow and `displayName`. */ + renameFrom?: string; + tooltip?: string; +} + +const SUBTITLE_KEYS: Record = { + 'local-only': 'sourceControl.status.added', + 'local-modified': 'sourceControl.status.modified', + 'remote-only': 'sourceControl.status.deletedLocally', + 'remote-modified': 'sourceControl.status.modifiedRemotely', + moved: 'sourceControl.status.renamed', + conflict: 'sourceControl.status.conflict', + synced: 'sourceControl.status.synced', +}; + +/** + * Single-letter status badge per change kind. Note `remote-only` (locally + * deleted) is badged `D`, not `A` — a local deletion is what the user sees, + * per the resolved decision to keep the domain filter semantics (remote-only + * stays in the Remote bucket) while the UI row reads "Deleted locally". + */ +const BADGE: Record = { + 'local-only': { letter: 'A', cls: 'local-only' }, + 'local-modified': { letter: 'M', cls: 'local-modified' }, + 'remote-only': { letter: 'D', cls: 'remote-only' }, + 'remote-modified': { letter: 'M', cls: 'remote-modified' }, + moved: { letter: 'R', cls: 'moved' }, + conflict: { letter: '!', cls: 'conflict' }, + synced: { letter: 'S', cls: 'synced' }, +}; + +/** + * Projects a {@link SourceControlItem} into a UI row view. `displayName` is + * the tree node's file name (passed in from `ChangeTree`); the rename "from" + * name is derived here from `item.previousPath` so the rename-arrow rendering + * moves out of `ChangeItem`. + */ +export function presentChange(item: SourceControlItem, displayName: string): ChangeRowView { + const view: ChangeRowView = { + badge: BADGE[item.kind], + subtitle: t(SUBTITLE_KEYS[item.kind]), + displayName, + }; + if (item.previousPath) view.renameFrom = item.previousPath.split('/').pop() ?? item.previousPath; + if (item.kind === 'remote-only') 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 }; +} + +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/FilterMenu.ts b/src/ui/source-control/FilterMenu.ts index 292ea50..806a642 100644 --- a/src/ui/source-control/FilterMenu.ts +++ b/src/ui/source-control/FilterMenu.ts @@ -35,12 +35,21 @@ export interface FilterMenuCallbacks { onToggleShowSynced: (show: boolean) => void; } +export interface FilterMenuOptions { + /** When true a compact `` dropdown replaces the chips (same domain + * values, same counts inline as "Label (N)"), with the "Show synced" toggle + * kept below it. + * * Per-filter counts come straight from the ViewModel's single-source counts; * the menu never recomputes one. */ @@ -50,25 +59,51 @@ export function renderFilterMenu( counts: Record, showSynced: boolean, callbacks: FilterMenuCallbacks, + options: FilterMenuOptions = {}, ): void { const menu = container.createDiv({ cls: 'scv-filter-menu' }); - const renderChip = (value: SourceControlFilter): void => { - const isActive = value === current; - const btn = menu.createEl('button', { cls: `scv-filter-option${isActive ? ' is-active' : ''}` }); - btn.setAttr('data-filter', value); - btn.setAttr('aria-pressed', String(isActive)); - btn.createSpan({ cls: 'scv-filter-label', text: t(FILTER_LABEL_KEYS[value]) }); - btn.createSpan({ cls: 'scv-filter-count', text: String(counts[value] ?? 0) }); - btn.addEventListener('click', () => callbacks.onFilterChange(value)); - }; + if (options.isMobile) { + renderFilterDropdown(menu, current, counts, callbacks); + } else { + const renderChip = (value: SourceControlFilter): void => { + const isActive = value === current; + const btn = menu.createEl('button', { cls: `scv-filter-option${isActive ? ' is-active' : ''}` }); + btn.setAttr('data-filter', value); + btn.setAttr('aria-pressed', String(isActive)); + btn.createSpan({ cls: 'scv-filter-label', text: t(FILTER_LABEL_KEYS[value]) }); + btn.createSpan({ cls: 'scv-filter-count', text: String(counts[value] ?? 0) }); + btn.addEventListener('click', () => callbacks.onFilterChange(value)); + }; - for (const value of ACTION_FILTERS) renderChip(value); - if (showSynced) renderChip('synced'); + for (const value of ACTION_FILTERS) renderChip(value); + if (showSynced) renderChip('synced'); + } const toggle = menu.createEl('label', { cls: 'scv-filter-show-synced' }); const checkbox = toggle.createEl('input', { type: 'checkbox', cls: 'scv-filter-show-synced-checkbox' }); checkbox.checked = showSynced; checkbox.addEventListener('change', () => callbacks.onToggleShowSynced(checkbox.checked)); toggle.createSpan({ cls: 'scv-filter-show-synced-label', text: t('sourceControl.filter.showSynced') }); +} + +/** + * Mobile filter dropdown: one ``, header push button + hidden, sticky bottom sync bar, flatter tree (`maxDepth: 2`). + +Domain-untouched invariant verified: +`git diff claude/source-control-foundation -- src/logic/source-control/` +shows ONLY `RefreshState.ts` (new) + `SourceControlViewModel.ts` (edited). ## Verification Evidence ```text npx eslint . -> PASS, 0 errors -npm run build -> PASS, incl. Obsidian 1.11 compatibility -npx vitest run -> PASS, 56 files / 613 tests -npm run test:e2e -- --provider gitea -> PASS, 2 files / 14 tests; container removed -actionlint v1.7.12 .github/workflows/ci.yml -> PASS, 0 errors -git diff --check -> PASS -real CI run 32338116598 -> PASS after failed-only rerun of a disabled Gitea leg assigned to an offline runner -GitHub/GitLab sandbox branch query -> PASS, no e2e/pr/127 or source-branch refs remain +npm run build -> PASS, incl. Obsidian 1.11.0 compat typecheck + esbuild +npx vitest run -> PASS, 61 files / 629 tests +git diff claude/source-control-foundation -- src/logic/source-control/ -> only RefreshState.ts + SourceControlViewModel.ts ``` -The AGENTS-required Haiku verifier was unavailable in this environment, so verification ran -locally in this session. +Pre-commit husky hook (`npm run lint && npm run build`) ran green on every +commit. ## Exact Next Step -Complete the remaining Obsidian desktop/mobile move smoke tests. Verify moving and editing a -tracked file appears under Moves and applies as one remote move, while an occupied remote -destination remains a skipped conflict. +The plan's four commits are all landed and locally green. Remaining before +declaring the feature fully done per AGENTS.md Definition of Done: +- Manual Obsidian verification (desktop + mobile) of the runtime UI surface: + refresh button states, "SELECTED FOR SYNC" section, per-row subtitles/badges + (esp. `remote-only` → `D` "Deleted locally"), diff-stat `+N -M` spans, and + the mobile filter dropdown + bottom sync bar. +- If opening a PR is desired, push `feat/sync-status-workflow-ui` and open a PR + against the base branch (`claude/source-control-foundation`) with the four + commits; the base branch name should be confirmed with the user first. \ No newline at end of file From 853793c307925ef8aa286c9c12fefe9fbabc6084 Mon Sep 17 00:00:00 2001 From: Tianyao Date: Sun, 23 Aug 2026 00:27:10 +0800 Subject: [PATCH 036/104] feat(source-control): selected-section rows, drop show-synced toggle, colored diff-stat - Selected section now lists actual selected change rows (full renderChangeItem rows with unselecting checkboxes) instead of a bare count; sits in a boxed region between the filter and the tree. - Tree keeps selected rows visible but muted via an is-selected class (italic name, reduced opacity) so context isn't lost. - Remove the 'Show synced' toggle and the synced chip from the UI; the domain synced filter/summary stay computed by the ViewModel but have no entry point. - Drop the inline status subtitle from each row; the kind label now lives on the badge tooltip, removing the M/Modified redundancy. - Split the diff stat into green additions / red deletions spans. - Use design tokens (--radius-s/--radius-m) for chip and section radii. --- src/ui/source-control/ChangeItem.ts | 44 ++++++---- src/ui/source-control/FilterMenu.ts | 57 +++++-------- src/ui/source-control/SourceControlView.ts | 82 +++++++++++-------- styles.css | 61 ++++++++++---- tests/ui/setup-dom.ts | 3 +- tests/ui/source-control/ChangeTree.test.ts | 20 ++++- tests/ui/source-control/FilterMenu.test.ts | 46 ++++------- .../source-control/SourceControlView.test.ts | 60 +++++++------- 8 files changed, 206 insertions(+), 167 deletions(-) diff --git a/src/ui/source-control/ChangeItem.ts b/src/ui/source-control/ChangeItem.ts index 027b945..a8cbafa 100644 --- a/src/ui/source-control/ChangeItem.ts +++ b/src/ui/source-control/ChangeItem.ts @@ -1,4 +1,4 @@ -import { setIcon } from 'obsidian'; +import { setIcon, setTooltip } from 'obsidian'; import { ICONS } from '../components/icons'; import { renderOperationIndicator } from './OperationIndicator'; import { presentChange, type ChangeStat } from './ChangePresentation'; @@ -14,10 +14,15 @@ export interface ChangeItemCallbacks { /** * Renders a single change row: selection checkbox, status badge, name (with - * rename arrow for moves), optional diff-stat span, and operation - * indicator. All kind-specific presentation (badge letter, subtitle, - * rename display) comes from {@link presentChange} so this component stays a - * pure renderer. + * rename arrow for moves), optional diff-stat, and operation indicator. All + * kind-specific presentation (badge letter, kind label, rename display) + * comes from {@link presentChange} so this component stays a pure renderer. + * + * The kind's short label (e.g. "Modified") is shown as the badge tooltip + * rather than an inline subtitle, so the row reads `M name +3 -1` + * without the `M`/`Modified` redundancy. A row selected for push gets an + * `is-selected` class so the tree can keep it visible but visually muted + * while the dedicated Selected section carries the working copy. */ export function renderChangeItem( container: HTMLElement, @@ -27,7 +32,7 @@ export function renderChangeItem( ): HTMLElement { const view = presentChange(item, displayName); - const row = container.createDiv({ cls: `scv-change-item scv-kind-${item.kind}` }); + const row = container.createDiv({ cls: `scv-change-item scv-kind-${item.kind}${item.isReadyToPush ? ' is-selected' : ''}` }); row.setAttr('data-change-id', item.id); if (view.tooltip) row.setAttr('title', view.tooltip); @@ -35,8 +40,8 @@ export function renderChangeItem( checkbox.checked = item.isReadyToPush; checkbox.addEventListener('change', () => callbacks.onToggleSelect(item.id, checkbox.checked)); - const badge = view.badge; - row.createSpan({ cls: `scv-badge scv-badge-${badge.cls}`, text: badge.letter }); + const badgeEl = row.createSpan({ cls: `scv-badge scv-badge-${view.badge.cls}`, text: view.badge.letter }); + setTooltip(badgeEl, view.subtitle); const label = row.createDiv({ cls: 'scv-change-name' }); if (view.renameFrom) { @@ -44,10 +49,8 @@ export function renderChangeItem( setIcon(label.createSpan({ cls: 'scv-change-rename-arrow' }), ICONS.moved); } label.createSpan({ cls: 'scv-change-name-text', text: view.displayName }); - label.createSpan({ cls: 'scv-change-subtitle', text: view.subtitle }); - const stat = callbacks.getDiffStat?.(item.id); - if (stat) row.createSpan({ cls: 'scv-diff-stat', text: formatStat(stat) }); + renderDiffStat(row, callbacks.getDiffStat?.(item.id)); renderOperationIndicator(row, item.operationStatus); @@ -59,9 +62,18 @@ export function renderChangeItem( return row; } -function formatStat(stat: ChangeStat): string { - const parts: string[] = []; - if (stat.additions > 0) parts.push(`+${stat.additions}`); - if (stat.deletions > 0) parts.push(`-${stat.deletions}`); - return parts.join(' '); +/** + * Renders the +/- diff stat as two colored spans (green additions, red + * deletions) so the magnitude and direction read at a glance. Nothing is + * rendered when the stat is unavailable or zero on both sides. + */ +function renderDiffStat(row: HTMLElement, stat: ChangeStat | undefined): void { + if (!stat) return; + const hasAdd = stat.additions > 0; + const hasDel = stat.deletions > 0; + if (!hasAdd && !hasDel) return; + const wrap = row.createSpan({ cls: 'scv-diff-stat' }); + if (hasAdd) wrap.createSpan({ cls: 'scv-diff-stat-add', text: `+${stat.additions}` }); + if (hasAdd && hasDel) wrap.createSpan({ cls: 'scv-diff-stat-sep', text: ' ' }); + if (hasDel) wrap.createSpan({ cls: 'scv-diff-stat-del', text: `-${stat.deletions}` }); } \ No newline at end of file diff --git a/src/ui/source-control/FilterMenu.ts b/src/ui/source-control/FilterMenu.ts index 806a642..c729bf1 100644 --- a/src/ui/source-control/FilterMenu.ts +++ b/src/ui/source-control/FilterMenu.ts @@ -5,10 +5,10 @@ import type { SourceControlFilter } from '../../logic/source-control/SourceContr * Action filter chips, in spec order. The visible row is four chips — * All / Local / Remote / Conflict — backed by the unchanged domain filters * (`all` / `changes` / `remote-changes` / `conflicts`). "Ready to Push" is no - * longer a chip: it's surfaced as the inline "SELECTED FOR SYNC (N)" section - * instead. `synced` is deliberately NOT a permanent chip — it surfaces only - * when the user opts in via the "Show synced" toggle, so a quiet workspace - * isn't dominated by a large synced count. + * longer a chip: it's surfaced as the dedicated "SELECTED FOR SYNC" section + * instead. `synced` is intentionally not surfaced in the UI: a quiet + * workspace stays quiet, and the domain `synced` filter/summary (still + * computed by the ViewModel) simply has no chip to open it. */ const ACTION_FILTERS: SourceControlFilter[] = ['all', 'changes', 'remote-changes', 'conflicts']; @@ -16,8 +16,8 @@ const ACTION_FILTERS: SourceControlFilter[] = ['all', 'changes', 'remote-changes * Displayed chip labels. Domain values stay as `data-filter` attributes; only * the visible label changes (e.g. the `changes` domain filter reads "Local" * because it surfaces local-side changes). `ready-to-push` and `synced` keep - * their existing keys even though `ready-to-push` is no longer a chip, so the - * record stays total over {@link SourceControlFilter}. + * their keys so the record stays total over {@link SourceControlFilter}, + * even though neither is a chip. */ const FILTER_LABEL_KEYS: Record = { all: 'sourceControl.filter.all', @@ -31,8 +31,6 @@ const FILTER_LABEL_KEYS: Record = { export interface FilterMenuCallbacks { /** Switches the active filter chip. */ onFilterChange: (filter: SourceControlFilter) => void; - /** Toggles whether synced changes are surfaced at all (the "Show synced" switch). */ - onToggleShowSynced: (show: boolean) => void; } export interface FilterMenuOptions { @@ -42,13 +40,8 @@ export interface FilterMenuOptions { /** * Renders the Source Control filter row: the four action chips (All, Local, - * Remote, Conflict) followed by a "Show synced" toggle. The `synced` chip is - * appended only when `showSynced` is on, so a hidden synced bucket contributes - * no chip and no count to the row. - * - * On mobile a single `` dropdown replaces the + * chips (same domain values, counts inline as "Label (N)"). * * Per-filter counts come straight from the ViewModel's single-source counts; * the menu never recomputes one. @@ -57,7 +50,6 @@ export function renderFilterMenu( container: HTMLElement, current: SourceControlFilter, counts: Record, - showSynced: boolean, callbacks: FilterMenuCallbacks, options: FilterMenuOptions = {}, ): void { @@ -65,26 +57,20 @@ export function renderFilterMenu( if (options.isMobile) { renderFilterDropdown(menu, current, counts, callbacks); - } else { - const renderChip = (value: SourceControlFilter): void => { - const isActive = value === current; - const btn = menu.createEl('button', { cls: `scv-filter-option${isActive ? ' is-active' : ''}` }); - btn.setAttr('data-filter', value); - btn.setAttr('aria-pressed', String(isActive)); - btn.createSpan({ cls: 'scv-filter-label', text: t(FILTER_LABEL_KEYS[value]) }); - btn.createSpan({ cls: 'scv-filter-count', text: String(counts[value] ?? 0) }); - btn.addEventListener('click', () => callbacks.onFilterChange(value)); - }; - - for (const value of ACTION_FILTERS) renderChip(value); - if (showSynced) renderChip('synced'); + return; } - const toggle = menu.createEl('label', { cls: 'scv-filter-show-synced' }); - const checkbox = toggle.createEl('input', { type: 'checkbox', cls: 'scv-filter-show-synced-checkbox' }); - checkbox.checked = showSynced; - checkbox.addEventListener('change', () => callbacks.onToggleShowSynced(checkbox.checked)); - toggle.createSpan({ cls: 'scv-filter-show-synced-label', text: t('sourceControl.filter.showSynced') }); + const renderChip = (value: SourceControlFilter): void => { + const isActive = value === current; + const btn = menu.createEl('button', { cls: `scv-filter-option${isActive ? ' is-active' : ''}` }); + btn.setAttr('data-filter', value); + btn.setAttr('aria-pressed', String(isActive)); + btn.createSpan({ cls: 'scv-filter-label', text: t(FILTER_LABEL_KEYS[value]) }); + btn.createSpan({ cls: 'scv-filter-count', text: String(counts[value] ?? 0) }); + btn.addEventListener('click', () => callbacks.onFilterChange(value)); + }; + + for (const value of ACTION_FILTERS) renderChip(value); } /** @@ -98,9 +84,8 @@ function renderFilterDropdown( counts: Record, callbacks: FilterMenuCallbacks, ): void { - const values: SourceControlFilter[] = [...ACTION_FILTERS]; const select = menu.createEl('select', { cls: 'scv-filter-dropdown' }); - for (const value of values) { + for (const value of ACTION_FILTERS) { const option = select.createEl('option', { value }); option.textContent = `${t(FILTER_LABEL_KEYS[value])} (${counts[value] ?? 0})`; if (value === current) option.setAttr('selected', 'selected'); diff --git a/src/ui/source-control/SourceControlView.ts b/src/ui/source-control/SourceControlView.ts index 0901b39..1e192ac 100644 --- a/src/ui/source-control/SourceControlView.ts +++ b/src/ui/source-control/SourceControlView.ts @@ -9,6 +9,7 @@ import { ICONS } from '../components/icons'; import { renderDiffLayoutToggle, type DiffLayout } from '../components/DiffLayoutToggle'; import { renderDiffPanel } from '../components/DiffPanel'; import { renderChangeTree, type ChangeTreeCallbacks } from './ChangeTree'; +import { renderChangeItem } from './ChangeItem'; import { renderFilterMenu } from './FilterMenu'; import { renderSourceControlHeader, type SourceControlWorkspaceInfo } from './SourceControlHeader'; @@ -76,12 +77,16 @@ const MOBILE_TREE_OPTIONS = { collapseSingleChild: true, maxDepth: 2 }; * - Every filter — including "All" — renders a single flat tree. "All" no * longer breaks the view into CHANGES / REMOTE CHANGES / SYNCED sections, so * a change never appears twice and SYNCED never leaks into All. - * - Synced is hidden by default (`showSynced = false`): the `synced` chip is - * absent and synced rows render nowhere. The "Show synced" toggle opts in. + * - Synced is not surfaced in the UI: there is no `synced` chip and no + * "Show synced" toggle, so a quiet workspace stays quiet. The domain + * `synced` filter/summary are still computed by the ViewModel but simply + * have no entry point here. + * - Selected changes get a first-class "SELECTED FOR SYNC (N)" region + * (between the filter and the tree) listing the working push batch; the + * same rows remain in the tree, visually muted via `is-selected`. */ export class SourceControlView { private filter: SourceControlFilter = 'all'; - private showSynced = false; private searchQuery = ''; private readonly collapsedFolders = new Set(); private selectedChangeId: ChangeId | null = null; @@ -122,7 +127,6 @@ export class SourceControlView { } getFilter(): SourceControlFilter { return this.filter; } - getShowSynced(): boolean { return this.showSynced; } getSelectedChangeId(): ChangeId | null { return this.selectedChangeId; } private rerender(): void { @@ -130,7 +134,7 @@ export class SourceControlView { } private renderMain(container: HTMLElement): void { - const state = this.viewModel.getState(this.filter, this.showSynced); + const state = this.viewModel.getState(this.filter); const isMobile = Platform.isMobile; @@ -153,27 +157,26 @@ export class SourceControlView { this.renderSearchBox(container); + const treeCallbacks: ChangeTreeCallbacks = { + onToggleFolder: (path) => this.toggleFolder(path), + onToggleSelect: (id, selected) => this.toggleSelect(id, selected), + onToggleFolderSelect: (ids, selected) => this.toggleFolderSelect(ids, selected), + onOpenDiff: (item) => this.openDiff(item), + getDiffStat: (id) => this.diffStatCache.get(id) ?? undefined, + }; + renderFilterMenu( container, this.filter, state.counts, - this.showSynced, - { - onFilterChange: (filter) => { this.filter = filter; this.rerender(); }, - onToggleShowSynced: (show) => { - this.showSynced = show; - // If the user hid synced while viewing it, fall back to All. - if (!show && this.filter === 'synced') this.filter = 'all'; - this.rerender(); - }, - }, + { onFilterChange: (filter) => { this.filter = filter; this.rerender(); } }, { isMobile }, ); const query = this.searchQuery.trim().toLowerCase(); const items = query ? state.items.filter(item => item.path.toLowerCase().includes(query)) : state.items; - this.renderSelectedSection(container, state.selectedItems); + this.renderSelectedSection(container, state.selectedItems, treeCallbacks); const body = container.createDiv({ cls: 'scv-body' }); this.renderActiveFilterHeader(body, state.filter, items.length); @@ -182,14 +185,6 @@ export class SourceControlView { return; } - const treeCallbacks: ChangeTreeCallbacks = { - onToggleFolder: (path) => this.toggleFolder(path), - onToggleSelect: (id, selected) => this.toggleSelect(id, selected), - onToggleFolderSelect: (ids, selected) => this.toggleFolderSelect(ids, selected), - onOpenDiff: (item) => this.openDiff(item), - getDiffStat: (id) => this.diffStatCache.get(id) ?? undefined, - }; - renderChangeTree(body, items, this.collapsedFolders, treeCallbacks, isMobile ? MOBILE_TREE_OPTIONS : TREE_OPTIONS); this.eagerLoadLocalStats(items); @@ -256,18 +251,31 @@ export class SourceControlView { } /** - * Renders the "SELECTED FOR SYNC (N)" summary, only when the user has at - * least one actionable change selected for push. Sits above the tree so the - * current push batch is always visible regardless of the active filter. - * The count comes straight from the ViewModel's single-source + * Renders the "SELECTED FOR SYNC (N)" workspace — a first-class region + * (not just a count) that lists every actionable change the user has + * ticked for push, each as a full row whose checkbox unselects it. Sits + * between the filter and the tree so the working push batch stays + * visible regardless of the active filter, and the same items remain in + * the tree (visually muted via `is-selected`) so context isn't lost. + * The set comes straight from the ViewModel's single-source * `selectedItems` projection (same definition as the Sync button count), - * so the two can never drift. + * so the section and the button can never drift. */ - private renderSelectedSection(container: HTMLElement, selectedItems: readonly SourceControlItem[]): void { + private renderSelectedSection( + container: HTMLElement, + selectedItems: readonly SourceControlItem[], + callbacks: ChangeTreeCallbacks, + ): void { if (selectedItems.length === 0) return; const section = container.createDiv({ cls: 'scv-selected-section' }); - section.createSpan({ cls: 'scv-selected-section-title', text: t('sourceControl.section.selectedForSync') }); - section.createSpan({ cls: 'scv-selected-section-count', text: String(selectedItems.length) }); + const header = section.createDiv({ cls: 'scv-selected-section-header' }); + header.createSpan({ cls: 'scv-selected-section-title', text: t('sourceControl.section.selectedForSync') }); + header.createSpan({ cls: 'scv-selected-section-count', text: String(selectedItems.length) }); + + const list = section.createDiv({ cls: 'scv-selected-section-list' }); + for (const item of selectedItems) { + renderChangeItem(list, item, basename(item.path), callbacks); + } } private renderDetail(root: HTMLElement): void { @@ -293,8 +301,8 @@ export class SourceControlView { private async loadAndRenderDiff(container: HTMLElement, changeId: ChangeId): Promise { if (!this.callbacks.loadDiffContent) return; - const item = this.viewModel.getState('all', this.showSynced).items.find(i => i.id === changeId) - ?? this.viewModel.getState('synced', this.showSynced).items.find(i => i.id === changeId); + const item = this.viewModel.getState('all').items.find(i => i.id === changeId) + ?? this.viewModel.getState('synced', true).items.find(i => i.id === changeId); if (!item) return; const content = await this.callbacks.loadDiffContent(item); @@ -386,4 +394,10 @@ export class SourceControlView { btn.createSpan({ cls: 'scv-mobile-sync-count', text: String(readyCount) }); btn.addEventListener('click', () => { void this.callbacks.onPush(this.selection.getSelectedChangeIds()); }); } +} + +/** 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); } \ No newline at end of file diff --git a/styles.css b/styles.css index 1be2af7..b7e539a 100644 --- a/styles.css +++ b/styles.css @@ -135,7 +135,7 @@ align-items: center; gap: 5px; padding: 4px 10px; - border-radius: 20px; + border-radius: var(--radius-s); border: 1px solid transparent; font-size: 0.80em; font-weight: 500; @@ -160,7 +160,7 @@ .scv-filter-count { background: rgba(0, 0, 0, 0.12); - border-radius: 10px; + border-radius: var(--radius-s); padding: 1px 6px; font-size: 0.85em; min-width: 18px; @@ -216,32 +216,54 @@ text-align: center; } -/* ── Selected-for-sync summary ──────────────────────────────── */ +/* ── Selected-for-sync workspace ───────────────────────────── */ .scv-selected-section { + margin: 6px 8px 4px 8px; + padding: 6px 0; + border-radius: var(--radius-m); + background: var(--background-secondary); + border: 1px solid var(--background-modifier-border); + overflow: hidden; +} + +.scv-selected-section-header { display: flex; align-items: center; gap: 6px; - padding: 6px 12px; - margin: 4px 12px 0 12px; - border-radius: 6px; - background: var(--background-modifier-hover); + padding: 2px 10px 6px 10px; color: var(--text-muted); - font-size: 0.78em; + font-size: 0.74em; font-weight: 600; text-transform: uppercase; letter-spacing: 0.02em; } +.scv-selected-section-title { flex-shrink: 0; } + .scv-selected-section-count { background: var(--interactive-accent); color: var(--text-on-accent); - border-radius: 10px; + border-radius: var(--radius-s); padding: 1px 6px; font-size: 0.9em; min-width: 18px; text-align: center; } +.scv-selected-section-list { + display: flex; + flex-direction: column; +} + +.scv-selected-section-list .scv-change-item { + border-left: none; + background: transparent; +} + +.scv-selected-section-list .scv-change-item:hover { + background: var(--background-modifier-hover); +} + /* ── Push button ───────────────────────────────────────────────── */ .scv-push-btn { display: flex; @@ -460,23 +482,28 @@ white-space: nowrap; } -.scv-change-subtitle { - color: var(--text-faint); - font-family: var(--font-interface); - font-size: 0.92em; - font-weight: normal; - white-space: nowrap; - flex-shrink: 0; +.scv-change-item.is-selected { + opacity: 0.55; +} + +.scv-change-item.is-selected .scv-change-name-text { + font-style: italic; } .scv-diff-stat { + display: inline-flex; + align-items: baseline; + gap: 1px; font-family: var(--font-monospace); font-size: 0.72em; - color: var(--text-muted); flex-shrink: 0; white-space: nowrap; } +.scv-diff-stat-add { color: var(--color-green); } +.scv-diff-stat-del { color: var(--color-red); } +.scv-diff-stat-sep { width: 2px; } + .scv-change-rename-from { color: var(--text-faint); text-decoration: line-through; diff --git a/tests/ui/setup-dom.ts b/tests/ui/setup-dom.ts index 514c3b5..7f79309 100644 --- a/tests/ui/setup-dom.ts +++ b/tests/ui/setup-dom.ts @@ -22,13 +22,14 @@ export function setupObsidianDOM(): void { const proto = window.HTMLElement.prototype; if ('createEl' in proto) return; - type DomOpts = { cls?: string; text?: string; type?: string }; + type DomOpts = { cls?: string; text?: string; type?: string; value?: string }; const toOpts = (o?: DomOpts | string): DomOpts => (typeof o === 'string' ? { cls: o } : o ?? {}); function applyOpts(el: Element, o: DomOpts): void { if (o.cls) el.className = o.cls; if (o.text) el.textContent = o.text; if (o.type) (el as HTMLInputElement).type = o.type; + if (o.value !== undefined) (el as HTMLInputElement).value = o.value; } Object.assign(proto, { diff --git a/tests/ui/source-control/ChangeTree.test.ts b/tests/ui/source-control/ChangeTree.test.ts index 4b1ecd5..878d2f1 100644 --- a/tests/ui/source-control/ChangeTree.test.ts +++ b/tests/ui/source-control/ChangeTree.test.ts @@ -56,18 +56,20 @@ describe('renderChangeTree', () => { expect(container.querySelector('.scv-badge')?.textContent).toBe('D'); }); - it('renders a status subtitle next to the change name', () => { + it('does not render an inline status subtitle (the kind label lives on the badge tooltip)', () => { const items = [item({ id: toChangeId('c-1'), path: 'a.md', kind: 'local-modified' })]; renderChangeTree(container, items, new Set(), callbacks); - expect(container.querySelector('.scv-change-subtitle')?.textContent).toBe('Modified'); + expect(container.querySelector('.scv-change-subtitle')).toBeNull(); }); - it('renders the diff stat from the getDiffStat callback when available', () => { + it('renders the diff stat as colored add/del spans from the getDiffStat callback', () => { const items = [item({ id: toChangeId('c-1'), path: 'a.md', kind: 'local-modified' })]; callbacks.getDiffStat = () => ({ additions: 3, deletions: 1 }); renderChangeTree(container, items, new Set(), callbacks); + expect(container.querySelector('.scv-diff-stat-add')?.textContent).toBe('+3'); + expect(container.querySelector('.scv-diff-stat-del')?.textContent).toBe('-1'); expect(container.querySelector('.scv-diff-stat')?.textContent).toBe('+3 -1'); }); @@ -98,6 +100,18 @@ describe('renderChangeTree', () => { expect(checkbox.checked).toBe(true); }); + it('marks a ready-to-push row with the is-selected class', () => { + const items = [ + item({ id: toChangeId('c-1'), path: 'a.md', kind: 'local-only', isReadyToPush: true }), + item({ id: toChangeId('c-2'), path: 'b.md', kind: 'local-only', isReadyToPush: false }), + ]; + renderChangeTree(container, items, new Set(), callbacks); + + const rows = container.querySelectorAll('.scv-change-item'); + expect(rows[0]?.classList.contains('is-selected')).toBe(true); + expect(rows[1]?.classList.contains('is-selected')).toBe(false); + }); + it('calls onToggleSelect with the ChangeId when the checkbox changes', () => { const items = [item({ id: toChangeId('c-1'), path: 'a.md', kind: 'local-only' })]; renderChangeTree(container, items, new Set(), callbacks); diff --git a/tests/ui/source-control/FilterMenu.test.ts b/tests/ui/source-control/FilterMenu.test.ts index 5b90a91..552fdbc 100644 --- a/tests/ui/source-control/FilterMenu.test.ts +++ b/tests/ui/source-control/FilterMenu.test.ts @@ -11,73 +11,63 @@ const zeroCounts: Record = { describe('renderFilterMenu', () => { let container: HTMLElement; - let callbacks: { onFilterChange: (f: SourceControlFilter) => void; onToggleShowSynced: (s: boolean) => void }; + let callbacks: { onFilterChange: (f: SourceControlFilter) => void }; beforeEach(() => { container = createContainer(); - callbacks = { onFilterChange: vi.fn(), onToggleShowSynced: vi.fn() }; + callbacks = { onFilterChange: vi.fn() }; }); - it('renders the four action chips (All/Local/Remote/Conflict, no synced chip) when showSynced is false', () => { - renderFilterMenu(container, 'all', zeroCounts, false, callbacks); + it('renders the four action chips (All/Local/Remote/Conflict) and never a synced chip', () => { + renderFilterMenu(container, 'all', zeroCounts, callbacks); const filters = Array.from(container.querySelectorAll('.scv-filter-option')).map(el => el.getAttribute('data-filter')); expect(filters).toEqual(['all', 'changes', 'remote-changes', 'conflicts']); + expect(container.querySelector('.scv-filter-option[data-filter="synced"]')).toBeNull(); }); it('labels the chips with the domain-relabeled display names (Local/Remote/Conflict)', () => { - renderFilterMenu(container, 'all', zeroCounts, false, callbacks); + renderFilterMenu(container, 'all', zeroCounts, callbacks); const labels = Array.from(container.querySelectorAll('.scv-filter-option .scv-filter-label')).map(el => el.textContent); // Domain values stay as data-filter; only the visible labels change. expect(labels).toEqual(['All', 'Local', 'Remote', 'Conflict']); }); - it('appends the synced chip when showSynced is true', () => { - renderFilterMenu(container, 'all', { ...zeroCounts, synced: 7 }, true, callbacks); + it('does not render a Show synced toggle', () => { + renderFilterMenu(container, 'all', zeroCounts, callbacks); - const filters = Array.from(container.querySelectorAll('.scv-filter-option')).map(el => el.getAttribute('data-filter')); - expect(filters).toEqual(['all', 'changes', 'remote-changes', 'conflicts', 'synced']); - const syncedOption = container.querySelector('.scv-filter-option[data-filter="synced"]'); - expect(syncedOption?.querySelector('.scv-filter-count')?.textContent).toBe('7'); + expect(container.querySelector('.scv-filter-show-synced-checkbox')).toBeNull(); }); it('marks the current filter chip as active', () => { - renderFilterMenu(container, 'conflicts', zeroCounts, false, callbacks); + renderFilterMenu(container, 'conflicts', zeroCounts, callbacks); const active = container.querySelector('.scv-filter-option.is-active'); expect(active?.getAttribute('data-filter')).toBe('conflicts'); }); it('shows the per-filter count from the ViewModel', () => { - renderFilterMenu(container, 'all', { ...zeroCounts, conflicts: 3 }, false, callbacks); + renderFilterMenu(container, 'all', { ...zeroCounts, conflicts: 3 }, callbacks); const conflictsOption = container.querySelector('.scv-filter-option[data-filter="conflicts"]'); expect(conflictsOption?.querySelector('.scv-filter-count')?.textContent).toBe('3'); }); it('calls onFilterChange with the clicked filter value', () => { - renderFilterMenu(container, 'all', zeroCounts, false, callbacks); + renderFilterMenu(container, 'all', zeroCounts, callbacks); (container.querySelector('.scv-filter-option[data-filter="remote-changes"]') as HTMLButtonElement).click(); expect(callbacks.onFilterChange).toHaveBeenCalledWith('remote-changes'); }); - it('renders the Show synced toggle reflecting the showSynced state', () => { - renderFilterMenu(container, 'all', zeroCounts, false, callbacks); - const checkbox = container.querySelector('.scv-filter-show-synced-checkbox') as HTMLInputElement; - expect(checkbox).not.toBeNull(); - expect(checkbox.checked).toBe(false); - }); - - it('calls onToggleShowSynced when the Show synced checkbox changes', () => { - renderFilterMenu(container, 'all', zeroCounts, false, callbacks); - - const checkbox = container.querySelector('.scv-filter-show-synced-checkbox') as HTMLInputElement; - checkbox.checked = true; - checkbox.dispatchEvent(new Event('change')); + it('renders a mobile dropdown (no chips) when isMobile is true', () => { + renderFilterMenu(container, 'all', { ...zeroCounts, conflicts: 3 }, callbacks, { isMobile: true }); - expect(callbacks.onToggleShowSynced).toHaveBeenCalledWith(true); + expect(container.querySelector('.scv-filter-dropdown')).not.toBeNull(); + expect(container.querySelector('.scv-filter-option')).toBeNull(); + const options = Array.from(container.querySelectorAll('.scv-filter-dropdown option')).map(o => (o as HTMLOptionElement).value); + expect(options).toEqual(['all', 'changes', 'remote-changes', 'conflicts']); }); }); \ No newline at end of file diff --git a/tests/ui/source-control/SourceControlView.test.ts b/tests/ui/source-control/SourceControlView.test.ts index 3d183de..a6cf1f7 100644 --- a/tests/ui/source-control/SourceControlView.test.ts +++ b/tests/ui/source-control/SourceControlView.test.ts @@ -94,8 +94,8 @@ describe('SourceControlView', () => { }); }); - describe('show synced toggle', () => { - it('hides the synced chip and synced rows by default', () => { + describe('synced surfacing removed', () => { + it('never renders a synced chip or a Show synced toggle', () => { const { view } = buildView([ { id: toChangeId('c-1'), path: 'a.md', kind: 'local-only' }, { id: toChangeId('c-2'), path: 'd.md', kind: 'synced' }, @@ -103,44 +103,18 @@ describe('SourceControlView', () => { view.render(container); expect(container.querySelector('.scv-filter-option[data-filter="synced"]')).toBeNull(); - expect(view.getShowSynced()).toBe(false); + expect(container.querySelector('.scv-filter-show-synced-checkbox')).toBeNull(); }); - it('reveals the synced chip and renders synced rows when toggled on', () => { + it('excludes synced rows from every filter view', () => { const { view } = buildView([ { id: toChangeId('c-1'), path: 'a.md', kind: 'local-only' }, { id: toChangeId('c-2'), path: 'd.md', kind: 'synced' }, ]); view.render(container); - const checkbox = container.querySelector('.scv-filter-show-synced-checkbox') as HTMLInputElement; - checkbox.checked = true; - checkbox.dispatchEvent(new Event('change')); - - expect(view.getShowSynced()).toBe(true); - const syncedChip = container.querySelector('.scv-filter-option[data-filter="synced"]'); - expect(syncedChip).not.toBeNull(); - expect(syncedChip?.querySelector('.scv-filter-count')?.textContent).toBe('1'); - }); - - it('falls back to All when synced is hidden while viewing the synced filter', () => { - const { view } = buildView([ - { id: toChangeId('c-1'), path: 'a.md', kind: 'local-only' }, - { id: toChangeId('c-2'), path: 'd.md', kind: 'synced' }, - ]); - view.render(container); - - // Opt in and switch to the synced filter. - const toggle = container.querySelector('.scv-filter-show-synced-checkbox') as HTMLInputElement; - toggle.checked = true; - toggle.dispatchEvent(new Event('change')); - (container.querySelector('.scv-filter-option[data-filter="synced"]') as HTMLButtonElement).click(); - expect(view.getFilter()).toBe('synced'); - - // Opt back out: filter snaps back to All. - toggle.checked = false; - toggle.dispatchEvent(new Event('change')); - expect(view.getFilter()).toBe('all'); + expect(container.querySelectorAll('.scv-change-item')).toHaveLength(1); + expect(container.querySelector('.scv-kind-synced')).toBeNull(); }); }); @@ -187,6 +161,28 @@ describe('SourceControlView', () => { expect(section?.querySelector('.scv-selected-section-count')?.textContent).toBe('1'); }); + it('lists the selected change as a real row inside the Selected section, unselecting on checkbox clear', () => { + const { view, selection } = buildView([ + { id: toChangeId('c-1'), path: 'notes/a.md', kind: 'local-only' }, + ]); + selection.includeForPush(toChangeId('c-1')); + view.render(container); + + const section = container.querySelector('.scv-selected-section') as HTMLElement; + const row = section.querySelector('.scv-change-item') as HTMLElement; + expect(row?.getAttribute('data-change-id')).toBe('c-1'); + expect(row?.querySelector('.scv-change-name-text')?.textContent).toBe('a.md'); + expect(row?.classList.contains('is-selected')).toBe(true); + + const checkbox = row.querySelector('.scv-change-select') as HTMLInputElement; + expect(checkbox.checked).toBe(true); + checkbox.checked = false; + checkbox.dispatchEvent(new Event('change')); + + expect(selection.isIncluded(toChangeId('c-1'))).toBe(false); + expect(container.querySelector('.scv-selected-section')).toBeNull(); + }); + it('excludes synced changes from the SELECTED FOR SYNC count even when selected', () => { const { view, selection } = buildView([ { id: toChangeId('c-1'), path: 'a.md', kind: 'local-only' }, From a9d3e98956054555b419edbc0da1e4898d4ab7a2 Mon Sep 17 00:00:00 2001 From: Tianyao Date: Sun, 23 Aug 2026 00:31:24 +0800 Subject: [PATCH 037/104] fix(source-control): make whole view scroll, add clear-selection, click-to-collapse folders - Move the Selected section into the scroll body so the whole lower region (selected rows + filter header + tree) scrolls as one. Previously the selected section sat outside the scroll container with no height cap, so a tall selection blew out the layout under .scv-root{overflow:hidden} and nothing scrolled. - Add a 'Clear' button to the Selected section header that deselects every selected change in one shot, instead of unchecking rows one by one. - Make the entire folder row toggle collapse (clicking the folder name or chevron expands/collapses); only the select-all checkbox keeps its own action. The chevron stops propagation so it doesn't double-fire. --- src/i18n/locales/en.ts | 2 ++ src/i18n/locales/zh-cn.ts | 2 ++ src/i18n/locales/zh-tw.ts | 2 ++ src/ui/source-control/ChangeTree.ts | 15 +++++++++++-- src/ui/source-control/SourceControlView.ts | 22 +++++++++++++++++-- styles.css | 16 ++++++++++++++ .../source-control/SourceControlView.test.ts | 21 ++++++++++++++++++ 7 files changed, 76 insertions(+), 4 deletions(-) diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 7038db6..dc82447 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -261,6 +261,8 @@ const en = { 'sourceControl.section.all': 'ALL', 'sourceControl.section.readyToPush': 'READY TO PUSH', 'sourceControl.section.selectedForSync': 'SELECTED FOR SYNC', + 'sourceControl.section.clearSelection': 'Clear', + 'sourceControl.section.clearSelection.tooltip': 'Deselect all changes', 'sourceControl.section.changes': 'CHANGES', 'sourceControl.section.remoteChanges': 'REMOTE CHANGES', 'sourceControl.section.conflicts': 'CONFLICTS', diff --git a/src/i18n/locales/zh-cn.ts b/src/i18n/locales/zh-cn.ts index f97c41a..d2fa864 100644 --- a/src/i18n/locales/zh-cn.ts +++ b/src/i18n/locales/zh-cn.ts @@ -263,6 +263,8 @@ const zhCn: Partial> = { 'sourceControl.section.all': '全部', 'sourceControl.section.readyToPush': '待推送', 'sourceControl.section.selectedForSync': '已选同步', + 'sourceControl.section.clearSelection': '清除', + 'sourceControl.section.clearSelection.tooltip': '取消全部选择', 'sourceControl.section.changes': '更改', 'sourceControl.section.remoteChanges': '远程更改', 'sourceControl.section.conflicts': '冲突', diff --git a/src/i18n/locales/zh-tw.ts b/src/i18n/locales/zh-tw.ts index d3dea73..f62d1eb 100644 --- a/src/i18n/locales/zh-tw.ts +++ b/src/i18n/locales/zh-tw.ts @@ -263,6 +263,8 @@ const zhTw: Partial> = { 'sourceControl.section.all': '全部', 'sourceControl.section.readyToPush': '待推送', 'sourceControl.section.selectedForSync': '已選同步', + 'sourceControl.section.clearSelection': '清除', + 'sourceControl.section.clearSelection.tooltip': '取消全部選取', 'sourceControl.section.changes': '變更', 'sourceControl.section.remoteChanges': '遠端變更', 'sourceControl.section.conflicts': '衝突', diff --git a/src/ui/source-control/ChangeTree.ts b/src/ui/source-control/ChangeTree.ts index 21884f2..1dce928 100644 --- a/src/ui/source-control/ChangeTree.ts +++ b/src/ui/source-control/ChangeTree.ts @@ -66,6 +66,8 @@ function renderFolder( const collapsed = collapsedFolders.has(folder.path); const folderEl = container.createDiv({ cls: 'scv-tree-folder' }); const row = folderEl.createDiv({ cls: 'scv-tree-folder-row' }); + row.setAttr('role', 'button'); + row.setAttr('aria-expanded', String(!collapsed)); const fileIds = collectFileIds(folder); const selectedCount = fileIds.filter(id => byId.get(id)?.isReadyToPush).length; @@ -77,12 +79,21 @@ function renderFolder( checkbox.addEventListener('change', () => callbacks.onToggleFolderSelect(fileIds, checkbox.checked)); const toggle = row.createEl('button', { cls: 'scv-tree-folder-toggle' }); - toggle.setAttr('aria-expanded', String(!collapsed)); + toggle.setAttr('aria-hidden', 'true'); toggle.setText(collapsed ? '▶' : '▼'); - toggle.addEventListener('click', () => callbacks.onToggleFolder(folder.path)); + // The whole row toggles; the chevron is just a visual affordance, so + // stop its click from double-firing the row handler. + toggle.addEventListener('click', (evt) => { evt.stopPropagation(); callbacks.onToggleFolder(folder.path); }); row.createSpan({ cls: 'scv-tree-folder-name', text: folder.name }); + // Clicking anywhere on the row (name, padding) toggles collapse — except + // the select-all checkbox, which keeps its own action. + row.addEventListener('click', (evt) => { + if (evt.target === checkbox) return; + callbacks.onToggleFolder(folder.path); + }); + if (!collapsed) { const childrenEl = folderEl.createDiv({ cls: 'scv-tree-children' }); renderNodes(childrenEl, folder.children, byId, collapsedFolders, callbacks, options); diff --git a/src/ui/source-control/SourceControlView.ts b/src/ui/source-control/SourceControlView.ts index 1e192ac..d69d770 100644 --- a/src/ui/source-control/SourceControlView.ts +++ b/src/ui/source-control/SourceControlView.ts @@ -176,9 +176,13 @@ export class SourceControlView { const query = this.searchQuery.trim().toLowerCase(); const items = query ? state.items.filter(item => item.path.toLowerCase().includes(query)) : state.items; - this.renderSelectedSection(container, state.selectedItems, treeCallbacks); - + // The scroll container: selected section + active-filter header + tree + // all live here so the whole lower region scrolls as one. Pinned + // controls (header, search, filter) stay outside so they don't scroll + // away; a tall Selected section therefore scrolls with the tree + // instead of blowing out the layout under `.scv-root { overflow: hidden }`. const body = container.createDiv({ cls: 'scv-body' }); + this.renderSelectedSection(body, state.selectedItems, treeCallbacks); this.renderActiveFilterHeader(body, state.filter, items.length); if (items.length === 0) { body.createDiv({ cls: 'scv-empty', text: t('sourceControl.empty') }); @@ -272,12 +276,26 @@ export class SourceControlView { header.createSpan({ cls: 'scv-selected-section-title', text: t('sourceControl.section.selectedForSync') }); header.createSpan({ cls: 'scv-selected-section-count', text: String(selectedItems.length) }); + 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', () => this.clearSelection(selectedItems)); + const list = section.createDiv({ cls: 'scv-selected-section-list' }); for (const item of selectedItems) { renderChangeItem(list, item, basename(item.path), callbacks); } } + /** Unselects every change currently in the Selected section in one shot. */ + private clearSelection(items: readonly SourceControlItem[]): void { + for (const item of items) this.selection.excludeFromPush(item.id); + this.rerender(); + } + private renderDetail(root: HTMLElement): void { const detail = root.createDiv({ cls: 'scv-detail' }); const bar = detail.createDiv({ cls: 'scv-detail-bar' }); diff --git a/styles.css b/styles.css index b7e539a..1436a36 100644 --- a/styles.css +++ b/styles.css @@ -240,6 +240,22 @@ .scv-selected-section-title { flex-shrink: 0; } +.scv-selected-section-clear { + margin-left: auto; + border: none; + background: transparent; + color: var(--text-muted); + font-size: 0.9em; + padding: 2px 6px; + border-radius: var(--radius-s); + cursor: pointer; +} + +.scv-selected-section-clear:hover { + color: var(--text-normal); + background: var(--background-modifier-hover); +} + .scv-selected-section-count { background: var(--interactive-accent); color: var(--text-on-accent); diff --git a/tests/ui/source-control/SourceControlView.test.ts b/tests/ui/source-control/SourceControlView.test.ts index a6cf1f7..77fccbb 100644 --- a/tests/ui/source-control/SourceControlView.test.ts +++ b/tests/ui/source-control/SourceControlView.test.ts @@ -183,6 +183,27 @@ describe('SourceControlView', () => { expect(container.querySelector('.scv-selected-section')).toBeNull(); }); + it('clears all selected changes at once via the Clear button in the section header', () => { + const { view, selection } = buildView([ + { id: toChangeId('c-1'), path: 'a.md', kind: 'local-only' }, + { id: toChangeId('c-2'), path: 'b.md', kind: 'local-modified' }, + { id: toChangeId('c-3'), path: 'c.md', kind: 'remote-only' }, + ]); + selection.includeForPush(toChangeId('c-1')); + selection.includeForPush(toChangeId('c-2')); + selection.includeForPush(toChangeId('c-3')); + view.render(container); + + const clearBtn = container.querySelector('.scv-selected-section-clear') as HTMLButtonElement; + expect(clearBtn).not.toBeNull(); + clearBtn.click(); + + expect(selection.isIncluded(toChangeId('c-1'))).toBe(false); + expect(selection.isIncluded(toChangeId('c-2'))).toBe(false); + expect(selection.isIncluded(toChangeId('c-3'))).toBe(false); + expect(container.querySelector('.scv-selected-section')).toBeNull(); + }); + it('excludes synced changes from the SELECTED FOR SYNC count even when selected', () => { const { view, selection } = buildView([ { id: toChangeId('c-1'), path: 'a.md', kind: 'local-only' }, From 7538e0a8e4cf273e2d3480ad8f9280f540038838 Mon Sep 17 00:00:00 2001 From: Tianyao Date: Sun, 23 Aug 2026 00:38:00 +0800 Subject: [PATCH 038/104] refactor(source-control): Selected section becomes a read-only action queue - Selected rows now render as queue items (badge + name + diff-stat, NO checkbox) instead of full tree rows, so the section reads as an action preview of the working push batch rather than a second active copy of the tree. Selection still happens in the tree below via checkboxes. - Eager-load diff stats for every selected change (any kind) so the queue previews +/- next to each row; tree two-sided rows stay lazy on open. - Drop the is-selected muting (opacity/italic) from tree rows: a checked checkbox is the only selection signal in the browser, matching the queue/ browser role split. - Export renderDiffStat and add renderSelectedQueueItem (reuses presentChange + the colored diff-stat spans). --- src/ui/source-control/ChangeItem.ts | 38 ++++++++++++++++- src/ui/source-control/SourceControlView.ts | 41 +++++++++++++----- styles.css | 37 ++++++++++------ .../source-control/SourceControlView.test.ts | 42 +++++++++++++++---- 4 files changed, 126 insertions(+), 32 deletions(-) diff --git a/src/ui/source-control/ChangeItem.ts b/src/ui/source-control/ChangeItem.ts index a8cbafa..3f46675 100644 --- a/src/ui/source-control/ChangeItem.ts +++ b/src/ui/source-control/ChangeItem.ts @@ -67,7 +67,7 @@ export function renderChangeItem( * deletions) so the magnitude and direction read at a glance. Nothing is * rendered when the stat is unavailable or zero on both sides. */ -function renderDiffStat(row: HTMLElement, stat: ChangeStat | undefined): void { +export function renderDiffStat(row: HTMLElement, stat: ChangeStat | undefined): void { if (!stat) return; const hasAdd = stat.additions > 0; const hasDel = stat.deletions > 0; @@ -76,4 +76,40 @@ function renderDiffStat(row: HTMLElement, stat: ChangeStat | undefined): void { if (hasAdd) wrap.createSpan({ cls: 'scv-diff-stat-add', text: `+${stat.additions}` }); if (hasAdd && hasDel) wrap.createSpan({ cls: 'scv-diff-stat-sep', text: ' ' }); if (hasDel) wrap.createSpan({ cls: 'scv-diff-stat-del', text: `-${stat.deletions}` }); +} + +/** + * Renders a compact queue row for the "SELECTED FOR SYNC" section: badge + + * name (with rename arrow for moves) + diff-stat, with NO selection checkbox + * and NO operation indicator. The Selected section is an action preview of + * the working push batch, not a second copy of the tree — selection happens + * in the tree below, so the queue stays read-only (clicking opens the diff). + */ +export function renderSelectedQueueItem( + container: HTMLElement, + item: SourceControlItem, + displayName: string, + callbacks: ChangeItemCallbacks, +): HTMLElement { + const view = presentChange(item, displayName); + + const row = container.createDiv({ cls: `scv-queue-item scv-kind-${item.kind}` }); + row.setAttr('data-change-id', item.id); + if (view.tooltip) row.setAttr('title', view.tooltip); + + const badgeEl = row.createSpan({ cls: `scv-badge scv-badge-${view.badge.cls}`, text: view.badge.letter }); + setTooltip(badgeEl, view.subtitle); + + const label = row.createDiv({ cls: 'scv-queue-name' }); + if (view.renameFrom) { + label.createSpan({ cls: 'scv-change-rename-from', text: view.renameFrom }); + setIcon(label.createSpan({ cls: 'scv-change-rename-arrow' }), ICONS.moved); + } + label.createSpan({ cls: 'scv-queue-name-text', text: view.displayName }); + + renderDiffStat(row, callbacks.getDiffStat?.(item.id)); + + row.addEventListener('click', () => callbacks.onOpenDiff(item)); + + return row; } \ No newline at end of file diff --git a/src/ui/source-control/SourceControlView.ts b/src/ui/source-control/SourceControlView.ts index d69d770..4959960 100644 --- a/src/ui/source-control/SourceControlView.ts +++ b/src/ui/source-control/SourceControlView.ts @@ -9,7 +9,7 @@ import { ICONS } from '../components/icons'; import { renderDiffLayoutToggle, type DiffLayout } from '../components/DiffLayoutToggle'; import { renderDiffPanel } from '../components/DiffPanel'; import { renderChangeTree, type ChangeTreeCallbacks } from './ChangeTree'; -import { renderChangeItem } from './ChangeItem'; +import { renderSelectedQueueItem } from './ChangeItem'; import { renderFilterMenu } from './FilterMenu'; import { renderSourceControlHeader, type SourceControlWorkspaceInfo } from './SourceControlHeader'; @@ -192,6 +192,7 @@ export class SourceControlView { renderChangeTree(body, items, this.collapsedFolders, treeCallbacks, isMobile ? MOBILE_TREE_OPTIONS : TREE_OPTIONS); this.eagerLoadLocalStats(items); + this.eagerLoadSelectedStats(state.selectedItems); if (isMobile) this.renderMobileSyncBar(container, state.counts['ready-to-push']); } @@ -255,15 +256,15 @@ export class SourceControlView { } /** - * Renders the "SELECTED FOR SYNC (N)" workspace — a first-class region - * (not just a count) that lists every actionable change the user has - * ticked for push, each as a full row whose checkbox unselects it. Sits - * between the filter and the tree so the working push batch stays - * visible regardless of the active filter, and the same items remain in - * the tree (visually muted via `is-selected`) so context isn't lost. - * The set comes straight from the ViewModel's single-source - * `selectedItems` projection (same definition as the Sync button count), - * so the section and the button can never drift. + * Renders the "SELECTED FOR SYNC (N)" workspace — a read-only action + * preview of the working push batch. Each queued change is a compact row + * (badge + name + diff-stat, NO checkbox): this is not a second copy of + * the tree but the queue the Sync button will act on. Selection itself + * happens in the tree below, which keeps the same rows visible but + * muted via `is-selected` so context isn't lost. The set comes straight + * from the ViewModel's single-source `selectedItems` projection (same + * definition as the Sync button count), so the section and the button + * can never drift. */ private renderSelectedSection( container: HTMLElement, @@ -286,7 +287,7 @@ export class SourceControlView { const list = section.createDiv({ cls: 'scv-selected-section-list' }); for (const item of selectedItems) { - renderChangeItem(list, item, basename(item.path), callbacks); + renderSelectedQueueItem(list, item, basename(item.path), callbacks); } } @@ -388,6 +389,24 @@ export class SourceControlView { })).then(() => this.rerender()); } + /** + * Eagerly resolves +/- stats for every change in the Selected section so + * the action queue previews `+3 -1` next to each row. Unlike + * {@link eagerLoadLocalStats} this covers all kinds (two-sided changes may + * involve a remote fetch), but the selected set is the user's working + * push batch — small and worth the round-trip. Null results are cached so + * an unavailable stat isn't retried on every rerender. + */ + private eagerLoadSelectedStats(selectedItems: readonly SourceControlItem[]): void { + if (!this.callbacks.loadDiffStat) return; + const pending = selectedItems.filter(item => !this.diffStatCache.has(item.id)); + if (pending.length === 0) return; + void Promise.all(pending.map(async item => { + const stat = await this.callbacks.loadDiffStat!(item); + this.diffStatCache.set(item.id, stat ?? null); + })).then(() => this.rerender()); + } + /** * Lazily resolves the +/- stat for a single two-sided change on open, * caching it so subsequent renders show the stat without a refetch. diff --git a/styles.css b/styles.css index 1436a36..5e37b12 100644 --- a/styles.css +++ b/styles.css @@ -271,15 +271,36 @@ flex-direction: column; } -.scv-selected-section-list .scv-change-item { - border-left: none; - background: transparent; +.scv-queue-item { + display: flex; + align-items: center; + gap: 5px; + padding: 4px 10px; + font-family: var(--font-monospace); + font-size: 0.80em; + color: var(--text-normal); + cursor: pointer; } -.scv-selected-section-list .scv-change-item:hover { +.scv-queue-item:hover { background: var(--background-modifier-hover); } +.scv-queue-name { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + gap: 5px; + overflow: hidden; +} + +.scv-queue-name-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + /* ── Push button ───────────────────────────────────────────────── */ .scv-push-btn { display: flex; @@ -498,14 +519,6 @@ white-space: nowrap; } -.scv-change-item.is-selected { - opacity: 0.55; -} - -.scv-change-item.is-selected .scv-change-name-text { - font-style: italic; -} - .scv-diff-stat { display: inline-flex; align-items: baseline; diff --git a/tests/ui/source-control/SourceControlView.test.ts b/tests/ui/source-control/SourceControlView.test.ts index 77fccbb..cc8e008 100644 --- a/tests/ui/source-control/SourceControlView.test.ts +++ b/tests/ui/source-control/SourceControlView.test.ts @@ -161,7 +161,7 @@ describe('SourceControlView', () => { expect(section?.querySelector('.scv-selected-section-count')?.textContent).toBe('1'); }); - it('lists the selected change as a real row inside the Selected section, unselecting on checkbox clear', () => { + it('lists the selected change as a read-only queue row (badge + name, no checkbox) inside the Selected section', () => { const { view, selection } = buildView([ { id: toChangeId('c-1'), path: 'notes/a.md', kind: 'local-only' }, ]); @@ -169,15 +169,26 @@ describe('SourceControlView', () => { view.render(container); const section = container.querySelector('.scv-selected-section') as HTMLElement; - const row = section.querySelector('.scv-change-item') as HTMLElement; + const row = section.querySelector('.scv-queue-item') as HTMLElement; expect(row?.getAttribute('data-change-id')).toBe('c-1'); - expect(row?.querySelector('.scv-change-name-text')?.textContent).toBe('a.md'); - expect(row?.classList.contains('is-selected')).toBe(true); + expect(row?.querySelector('.scv-queue-name-text')?.textContent).toBe('a.md'); + expect(row?.querySelector('.scv-badge')?.textContent).toBe('A'); + // The queue is an action preview, not a second copy of the tree: + // no selection checkbox here — selection happens in the tree below. + expect(row?.querySelector('.scv-change-select')).toBeNull(); + }); - const checkbox = row.querySelector('.scv-change-select') as HTMLInputElement; - expect(checkbox.checked).toBe(true); - checkbox.checked = false; - checkbox.dispatchEvent(new Event('change')); + it('unselects via the tree row checkbox, removing the change from the Selected section', () => { + const { view, selection } = buildView([ + { id: toChangeId('c-1'), path: 'notes/a.md', kind: 'local-only' }, + ]); + selection.includeForPush(toChangeId('c-1')); + view.render(container); + + const treeCheckbox = container.querySelector('.scv-body .scv-change-item .scv-change-select') as HTMLInputElement; + expect(treeCheckbox.checked).toBe(true); + treeCheckbox.checked = false; + treeCheckbox.dispatchEvent(new Event('change')); expect(selection.isIncluded(toChangeId('c-1'))).toBe(false); expect(container.querySelector('.scv-selected-section')).toBeNull(); @@ -517,6 +528,21 @@ describe('SourceControlView', () => { expect(loadDiffStat).toHaveBeenCalledTimes(1); expect(container.querySelector('.scv-diff-stat')?.textContent).toBe('+1 -4'); }); + + it('eager-loads stats for selected changes of any kind so the Selected queue previews them', async () => { + const loadDiffStat = vi.fn().mockResolvedValue({ additions: 2, deletions: 1 }); + const { view, selection } = buildView( + [{ id: toChangeId('c-1'), path: 'a.md', kind: 'local-modified' }], + { loadDiffStat }, + ); + selection.includeForPush(toChangeId('c-1')); + view.render(container); + await flush(); + + // A two-sided change in the queue is eager-loaded (unlike tree-only rows). + expect(loadDiffStat).toHaveBeenCalledWith(expect.objectContaining({ kind: 'local-modified' })); + expect(container.querySelector('.scv-selected-section .scv-queue-item .scv-diff-stat')?.textContent).toBe('+2 -1'); + }); }); describe('mobile layout', () => { From 639840a29bae86c930324914537812399be78667 Mon Sep 17 00:00:00 2001 From: Tianyao Date: Sun, 23 Aug 2026 00:52:56 +0800 Subject: [PATCH 039/104] refactor(source-control): converge UI to sync-intent workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #135 review flagged that the view mixed VS Code staged / Git status / sync-queue concepts. Converged to a single sync-intent workflow, view-layer only (no domain file touched; the domain-untouched invariant still holds). Filter chips redesigned to All / Needs Sync / Remote / Conflict / Synced via a UI chip model mapping (domain filter, showSynced): - Needs Sync (domain all, showSynced=false) = actionable set, default — keeps a quiet workspace quiet. - All (domain all, showSynced=true) composes actionable + synced by concatenating getState('all',false) + getState('synced',true) in the view; the domain all filter still returns actionable-only, so no domain change was needed. - Synced re-surfaces (domain synced, showSynced=true); Local dropped. SELECTED FOR SYNC renamed to SYNC QUEUE (the queue stays a compact read-only action preview). Badge tooltips read Added locally / Modified locally. Mobile sync bar becomes N files selected + Sync. Verification: eslint 0 errors; build clean (incl. Obsidian 1.11.0 compat); vitest 632/633 (1 unrelated pre-existing ci-workflow.test.ts failure from an uncommitted ci.yml). Domain diff shows only RefreshState.ts + SourceControlViewModel.ts. --- progress.md | 7 +- session-handoff.md | 72 ++++------- src/i18n/locales/en.ts | 9 +- src/i18n/locales/zh-cn.ts | 9 +- src/i18n/locales/zh-tw.ts | 9 +- src/ui/source-control/ChangeItem.ts | 2 +- src/ui/source-control/FilterMenu.ts | 118 ++++++++++-------- src/ui/source-control/SourceControlView.ts | 92 ++++++++------ styles.css | 21 ++-- .../source-control/ChangePresentation.test.ts | 4 +- tests/ui/source-control/FilterMenu.test.ts | 60 +++++---- .../source-control/SourceControlView.test.ts | 64 +++++++--- 12 files changed, 257 insertions(+), 210 deletions(-) diff --git a/progress.md b/progress.md index 8cc915e..917eec5 100644 --- a/progress.md +++ b/progress.md @@ -4,8 +4,8 @@ Completed work is archived in [archive/](./archive/), one file per calendar mont ## Current State -**Last Updated:** 2026-08-22 -**Active Feature:** sync-status-workflow-ui plan (`.kilo/plans/1787412338771-sync-status-workflow-ui.md`) — code complete on `feat/sync-status-workflow-ui` (4 commits, all green); manual Obsidian desktop/mobile UI verification remains. Prior active feature feat-026 / issue #105 (sync architecture refactor on `refactor/sync-domain-pipeline`) still has manual Obsidian move smoke tests outstanding. +**Last Updated:** 2026-08-23 +**Active Feature:** sync-status-workflow-ui plan (`.kilo/plans/1787412338771-sync-status-workflow-ui.md`) on `feat/sync-status-workflow-ui` — core 4 commits + 2 follow-up UX commits + 1 queue-refactor commit + 1 UX-convergence commit (8 total); automated checks green; manual Obsidian desktop/mobile UI verification remains. The convergence pass (renamed `SELECTED FOR SYNC`→`SYNC QUEUE`, 5-chip filter `All/Needs Sync/Remote/Conflict/Synced` with "All" composing actionable+synced view-side, default `Needs Sync`, `Added/Modified locally` badge tooltips, mobile bar `N files selected`+`Sync`) stayed entirely in the view layer — domain-untouched invariant still holds. Prior active feature feat-026 / issue #105 (sync architecture refactor on `refactor/sync-domain-pipeline`) still has manual Obsidian move smoke tests outstanding. **Parallel Work:** PR #87 (4x Dependabot security alerts via npm overrides) and Issue #57 (live-credential smoke test). ## Outstanding Items @@ -14,10 +14,11 @@ Completed work is archived in [archive/](./archive/), one file per calendar mont 1. **feat-025 manual verification** — Tree view code is complete and all automated checks pass; manual Obsidian verification in a real vault remains for user to confirm functionality (tree hierarchy, folder expand/collapse, checkboxes, Show synced toggle). 2. **PR #87** — Dependabot security patches via npm overrides; awaiting review/merge. 3. **Issue #57** — Live-credential smoke test; pre-existing, relevant before pushing major sync work. -4. **sync-status-workflow-ui manual verification** — All 4 commits landed and automated checks pass (eslint/build/vitest green); manual Obsidian desktop + mobile verification of the runtime UI (refresh button states, selected-for-sync section, per-row subtitles/badges incl. `remote-only`→`D`, diff-stat spans, mobile filter dropdown + bottom sync bar) remains before opening a PR. +4. **sync-status-workflow-ui manual verification** — 8 commits landed, automated checks pass (eslint/build/vitest green; 632/633, the 1 failure is the unrelated pre-existing `ci-workflow.test.ts` vs an uncommitted `ci.yml`); manual Obsidian desktop + mobile verification of the runtime UI (refresh button states, `SYNC QUEUE` section, 5-chip filter incl. `Needs Sync` default and `Synced`, badge tooltips `Modified locally`/`Added locally`, diff-stat spans, mobile `N files selected`+`Sync` bar) remains before opening a PR. ## Latest Evidence +- [x] sync-status-workflow-ui UX convergence (2026-08-23), branch `feat/sync-status-workflow-ui`, commit (pending): converged the PR's mixed workflow concepts per review feedback, view-layer only. (1) Renamed `SELECTED FOR SYNC`→`SYNC QUEUE` (i18n en/zh-cn/zh-tw); the queue stays a compact read-only action preview (badge+name+diff-stat, no checkbox). (2) Filter chips redesigned to `All / Needs Sync / Remote / Conflict / Synced` via a UI chip model mapping `(domain filter, showSynced)` — `Needs Sync` (domain `all`, actionable) is the default; `All` composes actionable+synced by concatenating `getState('all',false)` + `getState('synced',true)` in the view (the domain `all` filter still returns actionable-only, so no domain change); `Synced` re-surfaces (domain `synced`, showSynced=true); `Local` dropped. (3) Badge tooltip wording `Added`→`Added locally`, `Modified`→`Modified locally`. (4) Mobile sync bar → `N files selected` + `Sync` button (was a single full-width `SELECTED FOR SYNC (N)` button). Domain-untouched invariant verified: `git diff claude/source-control-foundation -- src/logic/source-control/` shows only `RefreshState.ts` (new) + `SourceControlViewModel.ts` (edited). Verification: `npx eslint .` — 0 errors; `npm run build` — clean incl. Obsidian 1.11.0 compat; `npx vitest run` — 632/633 (1 unrelated pre-existing failure in `ci-workflow.test.ts` from an uncommitted `ci.yml`, not touched by this work). - [x] sync-status-workflow-ui plan (2026-08-22), branch `feat/sync-status-workflow-ui`, 4 commits (`8c69cc8` → `dd8ddd5`) ahead of `claude/source-control-foundation` @ `f449125`: (1) ViewModel `selectedItems`/`refreshStatus` projections + `refresh()` delegate backed by new `RefreshState` (idle/loading/failed); (2) filter chips drop `ready-to-push` (4 chips: All/Local/Remote/Conflict) + "SELECTED FOR SYNC (N)" section; (3) refresh button (idle/loading/failed) + `OperationIndicator` text labels + `runRefresh` render-on-start-and-settle; (4) new `ChangePresentation` UI adapter (`remote-only` badged `D`, subtitles, rename display), eager local-only diff-stat from in-memory `sync.status` + lazy two-sided stat on open + clear-on-refresh cache (null results cached to stop an eager-retry rerender loop), responsive mobile (filter dropdown, hidden header push button, sticky bottom sync bar, flatter tree). Domain-untouched invariant verified: `git diff claude/source-control-foundation -- src/logic/source-control/` shows only `RefreshState.ts` (new) + `SourceControlViewModel.ts` (edited). Verification: `npx eslint .` — 0 errors; `npm run build` — clean incl. Obsidian 1.11.0 compat; `npx vitest run` — 61 files / 629 tests; husky pre-commit hook green on every commit. Manual Obsidian desktop/mobile UI verification remains. - [x] Issue #105 post-push CI hardening (2026-08-20), commit `948df28`: diagnosed run 32336155736 as two exhausted transient-provider attempts rather than a planner regression (GitHub 503/socket close; GitLab deadline exceeded). Increased provider E2E attempts from 2 to 3. A duplicate matrix cancelled by the shared push/PR concurrency group now produces a neutral aggregate gate with `run-ci=false`, so it neither creates a misleading `E2E gate` failure nor starts duplicate downstream CI; real failures still block. SyncManager E2E push preconditions now include `success`, `failed`, and provider `errors` in assertion diagnostics instead of surfacing only a secondary count mismatch. Added workflow contract and diagnostic unit tests and updated the E2E documentation. Verification: `actionlint v1.7.12 .github/workflows/ci.yml` — 0 errors; `npx eslint .` — 0 errors; `npm run build` — clean including Obsidian 1.11 compatibility; `npx vitest run` — 56 files / 613 tests; `npm run test:e2e -- --provider gitea` — 2 files / 14 tests and container cleanup; `git diff --check` — clean. Real CI run 32338116598 passed GitHub/GitLab production E2E, independent verification, cleanup, aggregate gate, Node 22/24 tests, lint, package, and build/release. The initial disabled-Gitea job landed on offline runner `heavenweb-runner-8`; failed-only rerun completed its skip in 11s and the full run concluded success. Provider API checks found no remaining `e2e/pr/127/**` or branch-source E2E refs. AGENTS-required Haiku was unavailable, so verification ran locally and through real CI. diff --git a/session-handoff.md b/session-handoff.md index 468ea0c..3f74bec 100644 --- a/session-handoff.md +++ b/session-handoff.md @@ -1,62 +1,42 @@ # Session Handoff -**Date:** 2026-08-22 -**Branch:** `feat/sync-status-workflow-ui` (4 commits ahead of `claude/source-control-foundation` @ `f449125`) -**Active Feature:** sync-status-workflow-ui plan (`.kilo/plans/1787412338771-sync-status-workflow-ui.md`) — COMPLETE - -## Completed This Session - -Implemented the full four-commit "Sync Status Workflow UI" feature. All four -commits land on `feat/sync-status-workflow-ui`, each passing the husky -pre-commit hook (`npm run lint && npm run build`): - -1. `8c69cc8` — `SourceControlViewModel` gains `selectedItems` + - `refreshStatus` projections and a `refresh()` delegate backed by a new - `RefreshState` holder (idle/loading/failed, mirrors `OperationState`). - `main.ts` wires `() => syncWorkspace.refresh()` as the delegate. 5-arg - ViewModel constructor; 3 test helpers updated. -2. `625fad2` — Filter chips drop `ready-to-push` (now 4: All/Local/Remote/ - Conflict via new `sourceControl.filter.local/remote/conflict` i18n; domain - `data-filter` values unchanged). New `renderSelectedSection()` shows - "SELECTED FOR SYNC (N)" above the tree. -3. `759b717` — Refresh button (idle icon-only / loading "Refreshing…" - spinning+disabled / failed "Refresh failed") in the header; `onRefresh` - added to `SourceControlViewCallbacks`; `OperationIndicator` now renders - icon + text label; `SourceControlItemView.runRefresh()` renders on start - and settle, swallows rejection. -4. `dd8ddd5` — New `ChangePresentation` UI adapter (badge letter/subtitle/ - rename/tooltip per kind; `remote-only` badged `D` not `A`). Diff-stat - threaded through rows: local-only stats eager-loaded from in-memory - `sync.status` (no provider call) + cached; two-sided stats lazy-load on - open; cache clears on refresh (null results cached too, to avoid an - eager-retry rerender loop that initially OOM'd the test worker). - Responsive mobile: chips → single filter `` dropdown replaces the - * chips (same domain values, counts inline as "Label (N)"). + * Renders the Source Control filter row: five chips — All / Needs Sync / + * Remote / Conflict / Synced. On mobile a single `` over the same domain filter values, - * options labeled "Label (N)". Keeps the chip row's counts and domain values - * but collapses four chips into a single control. + * Mobile filter dropdown: one `` dropdown + * Incoming / Conflict / Synced. On mobile a single `