Skip to content

refactor(source-control): isolate explicit sync intent orchestration - #150

Closed
ClaudiaFang wants to merge 15 commits into
mainfrom
claude/fix-source-control-explicit-sync-intent
Closed

ClaudiaFang wants to merge 15 commits into
mainfrom
claude/fix-source-control-explicit-sync-intent

Conversation

@ClaudiaFang

Copy link
Copy Markdown
Member

Why

The Source Control path had started accumulating unrelated responsibilities in the same classes: immediate row actions, queued sync intent resolution, merged-plan construction, execution/result aggregation, and read-side cleanup during ViewModel projection.

This PR keeps the current Source Control behavior but makes the intent/execution boundaries explicit.

Architecture cleanup

  • extract SyncIntentRequest into a dedicated intent type
  • extract Sync Queue orchestration into SyncIntentExecutor
    • resolve ChangeId against the current repository snapshot
    • revalidate explicit action overrides
    • bucket push / pull / delete-remote
    • build one merged Sync Plan
    • confirm once
    • commit the remote mutation bucket once
    • apply the pull bucket locally
    • aggregate one completion result
  • keep SourceControlActionService as the stable UI-facing facade for immediate actions and sync()
  • make SourceControlViewModel.getState() observational instead of clearing stale overrides while rendering
  • add repository snapshot notifications and reconcile stale selection/action intent when the authoritative change snapshot is replaced
  • document the current responsibility boundaries in docs/source-control.md

Behavior preserved

  • one Sync click -> one merged review plan
  • remote push/move/delete/conflict mutations -> one provider batch/commit
  • pure pull selection -> zero remote commits
  • stale/illegal action override -> current kind default
  • existing SourceControlActionService.sync() API remains compatible
  • no provider access added to UI/source-control layers

Tests

  • existing SourceControlActionService tests continue to cover the facade and the full queued-sync workflow
  • added focused SyncSelectionStore.reconcile coverage for illegal overrides, legal overrides, and removed changes

Intentionally not in this PR

  • no SourceControlView.ts / ChangeItem.ts UI refactor
  • no generic command bus / DI framework
  • no rewrite of SyncWorkspace, SyncManager, coordinators, or provider services

Release intent for the architecture commit is refactor, not feat.

ClaudiaFang and others added 15 commits September 1, 2026 00:40
renderDiffViewer rendered an empty diff panel synchronously, then
SourceControlView's async load appended a second, real one alongside it
via a raw renderDiffPanel(container, ...) call -- two Remote/Local panels
stacked on screen, the top one permanently empty. DiffViewer now owns the
diff body's whole lifecycle: content-less options render just the body,
and callers fill it in later through a returned handle's setContent(),
which empties the body before rendering so there's ever only one panel.

Also stops relying on Platform.isMobile alone for split/unified: tablets
report isMobile too, so the previous "mobile defaults to unified" policy
didn't stop a user's saved split preference from producing unreadable
~150px columns on an actual phone. renderDiffViewer now forces unified
and hides the layout toggle whenever Platform.isPhone is true, regardless
of session preference, across all three diff surfaces (diff tab, conflict
modal, mobile detail) that share it.

Adds defensive min-width/max-width and minmax(0, 1fr) grid tracks so a
long unbroken diff line can't push the split panel past its container.
CLAUDE.md still described src/ui/SyncStatusView.ts as the plugin's main UI
and never mentioned the Source Control surface that replaced it, so an
agent reading it cold would look for a file that no longer exists and miss
the real call chain (SourceControlItemView -> SourceControlView ->
SourceControlActionService -> SyncWorkspace -> SyncManager/executors).
Also documents the two compatibility identifiers (SOURCE_CONTROL_VIEW_TYPE
= 'sync-status-view', the open-sync-status command id) as intentional, not
leftover legacy code to clean up.
The remote-delete E2E called service.deleteFile() directly, with a
comment saying it reproduced src/ui/SyncStatusView.ts's real call path --
but that view was removed. The production path is now
SourceControlActionService.deleteRemote() -> SyncWorkspace.deleteRemote()
-> RemoteDeleteExecutor -> gitService.deleteFile(), which also clears
tracked metadata and the live status row as part of the same call, not as
a separate manual step the way this test's old manager.clearMetadata()
call implied. Rebuilds the test on a real SyncManagerWorkspace +
SourceControlActionService, verified against a live Gitea sandbox
(npm run test:e2e -- --provider gitea: 36 passed, 18 skipped).
docs/source-control-refactor/{roadmap,phase-1..4}.md describe an
in-progress migration (roadmap.md dated 2026-08-22, still narrating
uncommitted WIP) that has since landed on main in full -- nothing in that
directory reflects the current implementation, but nothing marked it as
historical either. Adds a banner to each pointing at the new
docs/source-control.md, which describes only the current architecture and
call chain without duplicating the old roadmap's narrative.
Two regression guards so a future refactor can't silently undo this
cleanup: eslint.config.mts's no-restricted-imports rule blocking
ui/sync-status and SyncStatusView imports is now asserted directly (it
existed before this PR but had no test locking it in), and the remote
delete E2E is now locked to keep going through
SourceControlActionService/SyncWorkspace rather than quietly reverting to
a direct service.deleteFile() provider bypass.
…ection

Adds resolveSyncAction/availableSyncActions to ChangeActionPolicy and
per-change action override tracking to SyncSelectionStore, so an
explicit user choice (e.g. pull instead of the default push) can
survive selection state without the store having to know change-kind
legality itself. Not yet wired into the UI or ActionService.
SourceControlItem now carries a resolved syncAction (override if still
legal for the current kind, else the kind default) and hasActionOverride,
projected in SourceControlViewModel with stale-override cleanup baked in.
Sync Queue grouping (Upload/Download/Delete) now reads item.syncAction
instead of recomputing defaultSyncAction(item.kind), so a queue with
overridden items groups by what Sync will actually do.

Action execution (ActionService, SyncPlan) still ignores the override —
that's the next commit.
SourceControlActionService.sync() now takes SyncIntentRequest[]
(changeId + optional action) instead of bare ChangeId[], resolving each
via ChangeActionPolicy.resolveSyncAction against the change's current
kind before bucketing into push/pull/delete-remote — so a stale intent
degrades to the kind's default rather than forcing an illegal action.
SourceControlView.runSync threads an item's override through only when
hasActionOverride is set; plain queue items still sync via the default.
Conflict resolution, plan merging, and the single-commit contract are
unchanged.
…ueue

Sync Queue rows now render a resolved-action control (icon+label on
desktop, icon-only on phone) instead of the plain Download button;
clicking it opens a Menu scoped to ChangeActionPolicy.availableSyncActions
for the row's kind, plus View diff and Remove from Sync Queue. Choosing
the kind's own default clears any stored override instead of recording a
redundant one. Repository Changes rows are unchanged — the control is
Sync Queue-only, per row, not added to every tree/list row.

Adds a DOM-backed Menu/MenuItem mock to tests/setup.ts (Obsidian's real
Menu drives a native/DOM popover Node can't render) so queue-row menu
interactions can be tested the same way as any other rendered control.
…n phone

The Repository Changes row's Download button still rendered its full
text label on phone, crowding the filename at narrow widths — the
mobile-diff branch fixed diff rendering but never touched this control.
Presentation-only: canDownload()/onDownload() are unchanged, the label
stays in the DOM (screen readers/tooltip still get it), only its visual
display and the button's padding change under body.is-mobile.
…Changes rows

Adds a "⋯" row menu (rowMenuActions in ChangeItem.ts) offering the
immediate actions relevant to a change's kind — e.g. local-modified gets
Push local / Use remote… / View diff / Add to Sync Queue / Delete local…,
remote-only gets Download / Delete remote… / Add to Sync Queue / Open
remote. conflict/synced get no menu (conflict resolution keeps its own
dedicated UI; synced is never actionable). Wired through new onPush/
onDeleteRemote/onDeleteLocal callbacks on SourceControlViewCallbacks,
executed immediately via SourceControlActionService — distinct from
queuing, which stays override-based via the existing action control.

Delete remote goes through the existing ConfirmModal first (no
equivalent safety net to Obsidian's own trash, which deleteLocal already
uses); delete local does not re-confirm since trashFile already is one.

The Modal mock in tests/setup.ts now appends modalEl to document.body on
open() (matching real Obsidian), needed to test the confirm flow the
same way other rendered controls are tested.
The merged Sync review can list additions/modifications/moves,
downloads, and deletions in one long scroll; a row far from its section
heading previously carried no cue of its own direction. Each file row
now repeats the section's existing icon (already shown once in the
heading) inline before the path — presentation only, no new grouping,
no selector: classification, conflict resolution, and bucket planning
are unchanged.
Separate queued sync intent execution from immediate Source Control actions, reconcile stale action overrides on repository snapshot changes, and keep ViewModel reads observational.
… in header

The header showed both "Last sync" and "Last checked" times, which read as
duplicated; keep only "Last checked" and drop the now-unused Conflict filter
chip (conflicts remain reachable via the default Needs Sync / All views).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@ClaudiaFang ClaudiaFang closed this Sep 1, 2026
@ClaudiaFang
ClaudiaFang deleted the claude/fix-source-control-explicit-sync-intent branch September 1, 2026 04:02
@sonarqubecloud

sonarqubecloud Bot commented Sep 1, 2026

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant