Skip to content

Feat/sync status workflow UI - #135

Merged
ClaudiaFang merged 17 commits into
claude/source-control-foundationfrom
feat/sync-status-workflow-ui
Aug 24, 2026
Merged

Feat/sync status workflow UI#135
ClaudiaFang merged 17 commits into
claude/source-control-foundationfrom
feat/sync-status-workflow-ui

Conversation

@ClaudiaFang

Copy link
Copy Markdown
Member

Description

Type of Change

  • feat: New feature
  • fix: Bug fix
  • perf: Performance improvement
  • docs: Documentation update (Diátaxis compliant)
  • refactor: Code refactoring
  • ci/cd: Automation or pipeline change
  • skill: AI Agent skill update

Engineering Standards

  • Conventional Commits followed
  • Documentation updated (if applicable)
  • Local tests passed
  • AI Agent skills validated (if applicable)

Related Issues

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).
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.
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.
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
… 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.
…ck-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.
… 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).
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.
Restructure .github/workflows/ci.yml so lint, unit-test (Node 22|24),
build, and provider-e2e all start in parallel after a push instead of
lint/test/build waiting behind the real-provider E2E matrix.

- Remove preflight (duplicated the reusable workflow's lint/test/build),
  e2e-gate, the shared reusable firstsun-dev/.github CI workflow call
  (its internal release fired before any E2E gate, so it couldn't be
  made to release-after-gate without inlining), and the standalone
  build-artifact job (raw-artifact upload folded into CI / Build, same
  job as the build, so upload state can't drift from build state).
- Add CI / Lint, CI / Unit Test (Node 22|24), CI / Build, CI / Provider
  E2E / <provider>, CI / Required Checks (aggregate gate, if: always(),
  passes only when every validation job is success/skipped), Release /
  Package (zip artifact, all branches), Release / Publish (semantic-
  release + build-provenance attestation, main/master only).
- provider-e2e internals unchanged (gitea-disabled notice, concurrency
  group e2e-<branch>-<provider>, run-scoped E2E_WORKDIR, retry, if:
  always() cleanup); only name and needs: [changes] (dropped preflight)
  changed, so cleanup workflows' shared concurrency naming still matches.
- All third-party actions pinned to commit SHAs (upload-artifact@v7,
  attest-build-provenance@v4.2.2).

Verification: actionlint v1.7.7 .github/workflows/ci.yml -- 0 errors
(only the known 32gb-ram self-hosted-label false positive); YAML parses.
Source tree (eslint/build/vitest) untouched by this change.

Follow-ups (progress.md 0b/0c): real PR run + execution-time comparisons;
manual branch-protection switch to require CI / Required Checks.
Replace the read-only SYNC QUEUE with a VS Code Staged/Changes model:
checking a tree row moves it up into the CHECKED CHANGES region and OUT
of the lower Changes tree (no duplication); unchecking it there moves it
back down. Both regions are collapsible via their headers.

- CHECKED CHANGES (N): selected items as normal rows (badge+name+diff-stat,
  checkbox checked). Uncheck here moves the row back to the tree.
- CHANGES (M): active-filter items EXCLUDING selected, as the folder tree.
  The active-filter-header count now reflects remaining (unchecked) rows.
- Both headers (Checked Changes + the active-filter header) toggle
  collapse; state persists across rerenders like collapsed folders.
- Mobile sync bar still renders when the lower tree is empty but the
  queue is non-empty (no early return).

i18n: section label 'SYNC QUEUE' -> 'Checked Changes' (zh: 已勾选的更改/
已勾選的變更). Domain untouched (only RefreshState.ts +
SourceControlViewModel.ts vs the base branch).

Verification: eslint 0; build clean (incl. Obsidian 1.11.0 compat);
vitest 634/635 (1 unrelated pre-existing ci-workflow.test.ts failure).
…ges tree

The Checked Changes region and the Changes tree shared one scroll
container, so expanding the tree (many files) pushed the pinned queue
out of view. Split them: Checked Changes is flex-shrink:0 at the top
(its list capped at 38vh with its own scroll), and the Changes region is
a flex:1 column whose tree scrolls independently. Expanding folders in
the tree now never hides the queue above it.
…cks gate

The old contract asserted the removed e2e-gate cancelled-handling string
and run-ci mechanism. Replace with assertions for the new DAG: validation
jobs run in parallel (no preflight/e2e-gate), and release is gated behind
a single CI / Required Checks aggregate (if: always(), needs all four
validation jobs) that treats a cancelled matrix leg as a hard failure.
… DiffStatProvider + SelectionController

Two intertwined improvements to the Source Control view.

UX — queue/repository separation:
- Upper action region becomes "SYNC QUEUE" with an "N files selected"
  subtitle (always a flat list); lower source region becomes "Repository
  Changes" with a Tree/List view toggle scoped to it.
- List view renders flat rows with a dim right-aligned folder-path suffix.
- Mobile collapses the Sync Queue by default so the repository tree stays
  primary.
- Drops 6 dead sourceControl.section.* keys; adds queueSubtitle/
  repositoryChanges/view.* keys (en/zh-cn/zh-tw).

Architecture (issue #136, PR #135 clean-code review):
- Rename the selectedItems projection -> syncQueue (domain term, not UI).
- Extract DiffStatProvider (cache + eager/lazy load + clear) out of the
  view so the view owns no diff cache; +9 unit tests.
- Expose PushSelectionStore via viewModel.selection; add selectMany/
  deselectMany batch methods; the view drops its direct store reference
  and routes all selection through the ViewModel (no reach-past-ViewModel),
  consolidating inline toggle/clear loops.

Domain-untouched: sync pipeline untouched; only PushSelectionStore (+batch
methods) and SourceControlViewModel (rename + selection getter) change in
src/logic/source-control.

Closes #136
…d/download routing

The `remote-only` badge was `D` (read as "deleted locally"), which is
misleading — pushFiles never propagates a local deletion. Re-badge to `↓`
("Remote available") and `remote-modified` to `↕`. Add an inline Download
button on `remote-only` rows wired to `actionService.pull`, so a remote
file pulls into the vault without first entering the Sync Queue. Route
the Sync button per-kind via `changeOperation(kind)`: upload kinds
(local-only/local-modified/moved/conflict) -> onPush, download kinds
(remote-only/remote-modified) -> new onPull callback. Split the Sync
Queue into Upload/Download sub-groups (labels only when mixed) so a
queued batch reads as what the Sync button will actually do.

View-layer only; no src/logic/** changes. New optional onPull/onDownload
callbacks on SourceControlViewCallbacks, wired in SourceControlItemView
to sourceControlActions.pull.

Verification: npx eslint . -- 0 errors; npm run build -- clean (tsc +
Obsidian 1.11.0 compat + esbuild); npx vitest run -- 62 files / 668 tests
pass. Real-provider gitea E2E + manual Obsidian verification remain.
…h local deletes

Add a 'local-deleted' SyncStatus/SyncChangeKind so a previously-tracked
file removed locally classifies distinctly from a never-tracked 'remote-only'
download candidate (badge D, 'Deleted locally', changeOperation download),
driven by a new SyncStatusFacts.wasTracked flag in SyncStatusService.classify.
Threaded through FileStatusAdapter/SourceControlFilter/SourceControlSummary/
ChangePresentation, and identifyExtraFiles + move reconciliation treat
local-deleted like remote-only.

Thread a RefreshReason (startup|manual|local-change|remote-change|sync-complete)
through RefreshState.start -> SourceControlViewModel.refresh (default manual;
startup refresh now goes through the ViewModel with 'startup'), recording the
reason and a lastCheckedAt timestamp so the header can surface 'Last checked'.

Wire app.vault create/delete events to new optimistic handlers
(handleFileCreated -> unsynced; handleFileDeleted: synced/modified ->
local-deleted, unsynced/moved -> drop) alongside the existing modify/rename
handlers. All update the shared SyncStatusService which republishes through
the existing pipeline (no direct filesystem->UI path; syncMetadata is not
cleared on delete so out-of-band move reconciliation keeps working, #66).

Add a 'Last checked: just now / <time>' line to the header info strip, fed by
refreshState.getLastCheckedAt(); hidden before the first refresh.

Tests: new SyncStatusRefreshService suite (handleFileCreated/handleFileDeleted/
identifyExtraFiles local-deleted classification), RefreshState reason/
lastCheckedAt, sync-status-service local-deleted matrix, FileStatusAdapter/
SourceControlFilter/SourceControlSummary/ChangePresentation local-deleted,
SourceControlViewModel refresh-reason threading, SourceControlView header
'Last checked' line. i18n keys across en/zh-cn/zh-tw.
@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
C Security Rating on New Code (required ≥ A)

See analysis details on SonarQube Cloud

Catch issues before they fail your Quality Gate with our IDE extension SonarQube for IDE

@ClaudiaFang
ClaudiaFang merged commit 66d7b8a into claude/source-control-foundation Aug 24, 2026
18 of 23 checks passed
@ClaudiaFang
ClaudiaFang deleted the feat/sync-status-workflow-ui branch August 24, 2026 06:21
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