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 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/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", 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/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/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/logic/source-control/OperationState.ts b/src/logic/source-control/OperationState.ts new file mode 100644 index 0000000..d03869e --- /dev/null +++ b/src/logic/source-control/OperationState.ts @@ -0,0 +1,39 @@ +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(); + + start(changeId: ChangeId): void { + this.status.set(changeId, 'running'); + } + + succeed(changeId: ChangeId): void { + this.status.set(changeId, 'success'); + } + + fail(changeId: ChangeId): void { + this.status.set(changeId, 'failed'); + } + + reset(changeId: ChangeId): void { + this.status.delete(changeId); + } + + get(changeId: ChangeId): OperationStatus { + return this.status.get(changeId) ?? '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..658a8c3 --- /dev/null +++ b/src/logic/source-control/PushSelectionStore.ts @@ -0,0 +1,39 @@ +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(); + + includeForPush(changeId: ChangeId): void { + this.selected.add(changeId); + } + + excludeFromPush(changeId: ChangeId): void { + this.selected.delete(changeId); + } + + isIncluded(changeId: ChangeId): boolean { + return this.selected.has(changeId); + } + + getSelectedChangeIds(): ChangeId[] { + return [...this.selected]; + } + + /** 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/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/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 new file mode 100644 index 0000000..33054d9 --- /dev/null +++ b/src/logic/source-control/types.ts @@ -0,0 +1,45 @@ +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; +} + +/** + * 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/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/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/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/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/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/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/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/logic/source-control/OperationState.test.ts b/tests/logic/source-control/OperationState.test.ts new file mode 100644 index 0000000..f34aa92 --- /dev/null +++ b/tests/logic/source-control/OperationState.test.ts @@ -0,0 +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 change', () => { + const state = new OperationState(); + + expect(state.get(toChangeId('change-a'))).toBe('idle'); + }); + + it('moves through running, success, and failed', () => { + const state = new OperationState(); + + state.start(toChangeId('change-a')); + expect(state.get(toChangeId('change-a'))).toBe('running'); + + state.succeed(toChangeId('change-a')); + expect(state.get(toChangeId('change-a'))).toBe('success'); + + state.start(toChangeId('change-a')); + state.fail(toChangeId('change-a')); + expect(state.get(toChangeId('change-a'))).toBe('failed'); + }); + + it('tracks multiple changes independently', () => { + const state = new OperationState(); + + state.start(toChangeId('change-a')); + state.succeed(toChangeId('change-b')); + + 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 change back to idle', () => { + const state = new OperationState(); + state.start(toChangeId('change-a')); + + state.reset(toChangeId('change-a')); + + expect(state.get(toChangeId('change-a'))).toBe('idle'); + }); + + it('clears all tracked state', () => { + const state = new OperationState(); + state.start(toChangeId('change-a')); + state.succeed(toChangeId('change-b')); + + state.clear(); + + 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 new file mode 100644 index 0000000..9b382da --- /dev/null +++ b/tests/logic/source-control/PushSelectionStore.test.ts @@ -0,0 +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(toChangeId('change-a')); + + 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(toChangeId('change-a')); + + store.excludeFromPush(toChangeId('change-a')); + + expect(store.isIncluded(toChangeId('change-a'))).toBe(false); + expect(store.getSelectedChangeIds()).toEqual([]); + }); + + it('tracks multiple changes independently', () => { + const store = new PushSelectionStore(); + + store.includeForPush(toChangeId('change-a')); + store.includeForPush(toChangeId('change-b')); + store.excludeFromPush(toChangeId('change-a')); + + 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(toChangeId('change-a')); + + store.refresh([toChangeId('change-a'), toChangeId('change-b')]); + + expect(store.isIncluded(toChangeId('change-a'))).toBe(true); + }); + + it('clears selection for a change removed by refresh', () => { + const store = new PushSelectionStore(); + store.includeForPush(toChangeId('change-a')); + store.includeForPush(toChangeId('change-b')); + + store.refresh([toChangeId('change-b')]); + + 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); + }); +}); 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(); + }); + }); +}); 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'); + }); +}); 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/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/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/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'); + }); + }); +}); 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); - }); -});