From ebd30a9fd1e46404c320d2947cba67132167f815 Mon Sep 17 00:00:00 2001 From: An Khang Date: Thu, 8 Oct 2026 10:58:45 +0700 Subject: [PATCH] chore(release): bump version to 1.2.0 and modernize mobile UI --- CHANGELOG.md | 15 +- README.md | 8 +- SECURITY.md | 2 +- docs/ARCHITECTURE.md | 2 +- docs/PROJECT_SOURCE_OF_TRUTH.md | 10 +- manifest.json | 2 +- package-lock.json | 4 +- package.json | 2 +- src/main.ts | 7 +- src/settings.ts | 281 ++------- src/ui/clearTokenConfirmModal.ts | 4 +- src/ui/conflictResolutionModal.ts | 46 +- src/ui/pullConfirmModal.ts | 262 -------- src/ui/pullResultModal.ts | 201 ------ src/ui/pushConfirmModal.ts | 270 -------- src/ui/pushResultModal.ts | 201 ------ src/ui/syncConfirmModal.ts | 342 ++++++++++ src/ui/syncDashboardModal.ts | 209 +++--- src/ui/syncPreviewModal.ts | 139 ++-- src/ui/syncResultModal.ts | 239 +++++++ styles.css | 690 +++++++++++++++++++- tests/__mocks__/obsidian.ts | 8 +- tests/c5MobileAccessibility.test.ts | 13 +- tests/c6RuntimeUI.test.ts | 30 +- tests/communityCompliance.test.ts | 13 +- tests/empiricalChallengerM1.test.ts | 941 ++++++++++++++++++++++++++++ tests/m1ChallengerStress.test.ts | 628 +++++++++++++++++++ versions.json | 1 + 28 files changed, 3143 insertions(+), 1427 deletions(-) delete mode 100644 src/ui/pullConfirmModal.ts delete mode 100644 src/ui/pullResultModal.ts delete mode 100644 src/ui/pushConfirmModal.ts delete mode 100644 src/ui/pushResultModal.ts create mode 100644 src/ui/syncConfirmModal.ts create mode 100644 src/ui/syncResultModal.ts create mode 100644 tests/empiricalChallengerM1.test.ts create mode 100644 tests/m1ChallengerStress.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c44b10..adbeae6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,11 +5,24 @@ All notable changes to GitHub Vault Relay will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -Current repository evidence is 42 test files and 443 passing tests; `npm run verify` passed locally on 2026-10-07. Test totals in older release entries are historical snapshots from those releases, not current totals. +Current repository evidence is 44 test files and 482 passing tests; `npm run verify` passed locally on 2026-10-08. Test totals in older release entries are historical snapshots from those releases, not current totals. Paths and implementation details in older entries are historical release snapshots; the current internal path is `${app.vault.configDir}/github-vault-relay/`. --- +## [1.2.0] - 2026-10-08 + +### UI Modernization & Architecture Consolidation +- **Unified Modal Architectures**: Consolidated duplicate Pull/Push modal pairs into parameter-driven `SyncConfirmModal` and `SyncResultModal`. Pruned 4 redundant legacy modal files (`pullConfirmModal.ts`, `pushConfirmModal.ts`, `pullResultModal.ts`, `pushResultModal.ts`), eliminating over 930 lines of boilerplate while maintaining 100% feature and telemetry parity. +- **Embedded Dashboard File Inspection**: Integrated interactive, expandable change inspection directly within `SyncDashboardModal`. Vault changes can now be filtered by category (*All, Local Only, Remote Only, Local Changed, Remote Changed, Conflicts, Deletes*) and inspected without opening separate stacking modal dialogs on mobile devices. +- **Mobile-First Stylesheet & Responsive Touch UX**: Eliminated all inline style string attributes across UI components in favor of clean semantic classes in `styles.css`. Modernized action buttons for small viewports (`@media (max-width: 480px)`) into full-width stacked layouts, preventing button line-breaks while guaranteeing minimum 44px tap targets and `safe-area-inset-bottom` padding. +- **SettingsTab Declarative Consolidation**: Replaced duplicate imperative DOM creation in `VaultRelaySettingTab.display()` with an iterator over `getSettingDefinitions()`, cutting ~250 lines of duplicate settings logic and strongly typing setting definitions with 0 ESLint warnings. + +### Verification & Test Suite Hardening +- **Empirical Challenger & Stress Suites**: Added comprehensive adversarial stress suites (`tests/empiricalChallengerM1.test.ts` and `tests/m1ChallengerStress.test.ts`) covering 39 edge-case, reentrancy, and execution lifecycle scenarios. +- `npm run verify`: PASS — 44 test files, 482 tests passing, 0 lint warnings, clean typecheck, clean production build. +- `verify-release.mjs`: PASS — All manifests, package, lockfile, and documentation files strictly synchronized. + ## [1.1.0] - 2026-10-07 ### Security & Hardening diff --git a/README.md b/README.md index 8bac055..4e893af 100644 --- a/README.md +++ b/README.md @@ -2,16 +2,16 @@ > A conservative GitHub sync bridge for Obsidian mobile and desktop, without running Git on the phone. -[![Version](https://img.shields.io/badge/version-1.1.0-blue.svg)](https://github.com/ankhang0704/github-vault-relay/releases) +[![Version](https://img.shields.io/badge/version-1.2.0-blue.svg)](https://github.com/ankhang0704/github-vault-relay/releases) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) ## Current canonical baseline -- Release: `1.1.0` (manifest, package, tag, and `main` agree after release). +- Release: `1.2.0` (manifest, package, tag, and `main` agree after release). - Minimum Obsidian version: `1.11.4`. - Mobile support: enabled (`isDesktopOnly: false`). -- Automated evidence: 42 Vitest test files and 443 passing tests in the current tree. -- Latest local quality gate: `npm run verify` PASS on 2026-10-07 (lint, typecheck, test, build). +- Automated evidence: 44 Vitest test files and 482 passing tests in the current tree. +- Latest local quality gate: `npm run verify` PASS on 2026-10-08 (lint, typecheck, test, build). - The production bundle is generated by `npm run build`; `npm run verify` is the canonical gate. - Release-gate physical acceptance is PASS on Windows and iPhone for settings, hidden paths, normal files, and restart behavior. See [the manual matrix](docs/MANUAL_TEST_MATRIX.md) for the recorded sign-off. diff --git a/SECURITY.md b/SECURITY.md index 2200766..079628d 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,6 +1,6 @@ # Security Policy -Vault Relay is a conservative GitHub bridge. The current supported release is `1.1.0`; older releases are not the active security baseline. +Vault Relay is a conservative GitHub bridge. The current supported release is `1.2.0`; older releases are not the active security baseline. ## Credential storage diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 121f1ee..80d7e24 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,6 +1,6 @@ # GitHub Vault Relay: System Architecture -This document describes the architecture that exists in the current `1.0.9` source tree. File references are the evidence; diagrams are summaries, not a redesign proposal. +This document describes the architecture that exists in the current `1.2.0` source tree. File references are the evidence; diagrams are summaries, not a redesign proposal. ## System context diff --git a/docs/PROJECT_SOURCE_OF_TRUTH.md b/docs/PROJECT_SOURCE_OF_TRUTH.md index 72b0524..7a1c1ce 100644 --- a/docs/PROJECT_SOURCE_OF_TRUTH.md +++ b/docs/PROJECT_SOURCE_OF_TRUTH.md @@ -7,15 +7,15 @@ This is the current factual reference for Vault Relay. When this document confli | Fact | Canonical value | Evidence | | :--- | :--- | :--- | | Plugin/package | `github-vault-relay` | `manifest.json`, `package.json` | -| Release | `1.1.0` | `manifest.json`, `package.json`, Git tag `1.1.0` after release | +| Release | `1.2.0` | `manifest.json`, `package.json`, Git tag `1.2.0` after release | | Minimum Obsidian version | `1.11.4` | `manifest.json`, `tests/manifest.test.ts` | | Mobile | Supported; `isDesktopOnly: false` | `manifest.json`, `tests/manifest.test.ts` | | Remote service | GitHub REST/Git Data API over HTTPS | `src/github/githubClient.ts` | -| Test files | `42` | `tests/*.test.ts`, current `vitest run` output | -| Passing tests | `443` | current `vitest run` output | -| Quality gate | `PASS` (`npm run verify`, 2026-10-07) | `package.json`, local gate run | +| Test files | `44` | `tests/*.test.ts`, current `vitest run` output | +| Passing tests | `482` | current `vitest run` output | +| Quality gate | `PASS` (`npm run verify`, 2026-10-08) | `package.json`, local gate run | -The stable 1.1.0 release is published at `https://github.com/ankhang0704/github-vault-relay/releases/tag/1.1.0`. Provenance is generated by `.github/workflows/release.yml` from immutable tag `1.1.0`. +The stable 1.2.0 release is published at `https://github.com/ankhang0704/github-vault-relay/releases/tag/1.2.0`. Provenance is generated by `.github/workflows/release.yml` from immutable tag `1.2.0`. `main.js` is a generated, ignored build artifact. The current production build identity is recorded by the verification run and should be regenerated rather than hand-edited. The manual matrix records release asset testing for reproducible device verification. diff --git a/manifest.json b/manifest.json index ec1623a..c00e1be 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "github-vault-relay", "name": "GitHub Vault Relay", - "version": "1.1.0", + "version": "1.2.0", "minAppVersion": "1.11.4", "description": "A conservative GitHub bridge for mobile and desktop vault sync without requiring Git on the phone.", "author": "Vault Relay Contributors", diff --git a/package-lock.json b/package-lock.json index 5929683..4299ef4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "github-vault-relay", - "version": "1.1.0", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "github-vault-relay", - "version": "1.1.0", + "version": "1.2.0", "license": "MIT", "devDependencies": { "@eslint/js": "^9.20.0", diff --git a/package.json b/package.json index 4405e4f..c5bc610 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "github-vault-relay", - "version": "1.1.0", + "version": "1.2.0", "description": "A conservative GitHub bridge for mobile and desktop vault sync without requiring Git on the phone.", "main": "main.js", "scripts": { diff --git a/src/main.ts b/src/main.ts index 64768df..0f234b2 100644 --- a/src/main.ts +++ b/src/main.ts @@ -12,8 +12,7 @@ import { } from "./settings"; import { SyncDashboardModal } from "./ui/syncDashboardModal"; import { SyncPreviewModal } from "./ui/syncPreviewModal"; -import { PullConfirmModal } from "./ui/pullConfirmModal"; -import { PushConfirmModal } from "./ui/pushConfirmModal"; +import { SyncConfirmModal } from "./ui/syncConfirmModal"; import { GitHubClient } from "./github/githubClient"; import { sanitizeErrorMessage } from "./security/redact"; import { CANONICAL_SECRET_KEY, getStoredPat } from "./security/secretStore"; @@ -79,7 +78,7 @@ export default class VaultRelayPlugin extends Plugin { id: "pull-safe-changes", name: "Pull safe remote changes (GitHub -> Local)", callback: () => { - new PullConfirmModal(this.app, this).open(); + new SyncConfirmModal(this.app, this, "pull").open(); }, }); @@ -88,7 +87,7 @@ export default class VaultRelayPlugin extends Plugin { id: "push-safe-changes", name: "Push safe local changes (Local -> GitHub)", callback: () => { - new PushConfirmModal(this.app, this).open(); + new SyncConfirmModal(this.app, this, "push").open(); }, }); diff --git a/src/settings.ts b/src/settings.ts index 0f4df99..06f59f5 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -51,6 +51,24 @@ export const DEFAULT_SETTINGS: VaultRelaySettings = { settingsVersion: CURRENT_SETTINGS_VERSION, }; +interface SettingItemDef { + name: string; + desc?: string; + visible?: () => boolean; + render?: (setting: Setting, group: unknown) => void; + control?: { + type: string; + key: string; + placeholder?: string; + }; +} + +interface SettingGroupDef { + type: "group"; + heading: string; + items: SettingItemDef[]; +} + export class VaultRelaySettingTab extends PluginSettingTab { private plugin: VaultRelayPlugin; private tokenInputVal = ""; @@ -75,6 +93,10 @@ export class VaultRelaySettingTab extends PluginSettingTab { * Performs ZERO I/O in definitions and enables global settings search indexing. */ public override getSettingDefinitions(): SettingDefinitionItem[] { + return this.getSettingGroups() as unknown as SettingDefinitionItem[]; + } + + private getSettingGroups(): SettingGroupDef[] { const keyName = getSecretKeyForRepo( this.plugin.settings.owner, this.plugin.settings.repo @@ -261,7 +283,6 @@ export class VaultRelaySettingTab extends PluginSettingTab { }); } }, - visible: () => this.showManualSetup, }, ], }, @@ -343,15 +364,11 @@ export class VaultRelaySettingTab extends PluginSettingTab { // Security & Scope Notice Box const noticeBox = containerEl.createDiv({ cls: "vault-relay-notice-box", - attr: { - style: - "background-color: var(--background-secondary); border-left: 4px solid var(--interactive-accent); padding: 12px 16px; margin-bottom: 20px; border-radius: 4px;", - }, }); noticeBox.createEl("strong", { text: "šŸ”’ Token Security & Storage Guarantee:" }); const noticeList = noticeBox.createEl("ul", { - attr: { style: "margin: 6px 0 0 18px; font-size: 0.9em; line-height: 1.5;" }, + cls: "vault-relay-notice-list", }); noticeList.createEl("li", { text: `Tokens are stored exclusively in Obsidian SecretStorage (${backendLabel}) and NEVER written to plugin data.json or localStorage.`, @@ -369,240 +386,30 @@ export class VaultRelaySettingTab extends PluginSettingTab { } else if (this.plugin.settings.secretKey) { this.tokenExists = true; } - const tokenExists = this.tokenExists; - const keyName = getSecretKeyForRepo( - this.plugin.settings.owner, - this.plugin.settings.repo - ); - - // Section 1: GitHub Connection Wizard (Primary Connection Flow: ONE primary CTA only) - new Setting(containerEl).setName("Connection Wizard").setHeading(); - - const tokenSetting = new Setting(containerEl) - .setName("GitHub Fine-Grained PAT") - .setDesc( - tokenExists - ? `Status: Stored securely (${keyName}). Enter a new token below to replace.` - : "Personal Access Token with Read/Write access to Contents on your vault repository." - ); - - tokenSetting.addText((text) => { - text.setPlaceholder(tokenExists ? "••••••••••••••••••••" : "github_pat_..."); - text.onChange((value) => { - this.tokenInputVal = value.trim(); - }); - text.inputEl.type = "password"; - text.inputEl.addClass("vault-relay-token-input"); - }); - - tokenSetting.addButton((button) => { - button - .setButtonText("Save & Connect") - .setCta() - .onClick(() => { - void this.handleSaveAndConnect(button); - }); - button.buttonEl.addClass("vault-relay-btn-lg"); - }); - - // Repository Dropdown (if repos discovered or discovered previously) - if (this.discoveredRepos.length > 0) { - const repoSetting = new Setting(containerEl) - .setName("Select Repository") - .setDesc("Choose which repository to sync with this vault."); - - repoSetting.addDropdown((dropdown) => { - const currentFullName = - this.plugin.settings.owner && this.plugin.settings.repo - ? `${this.plugin.settings.owner}/${this.plugin.settings.repo}` - : ""; - - for (const r of this.discoveredRepos) { - dropdown.addOption(r.fullName, `${r.fullName} ${r.isPrivate ? "šŸ”’" : "🌐"}`); - } - - if (currentFullName) { - dropdown.setValue(currentFullName); - } - - dropdown.onChange((val) => { - void (async () => { - const selected = this.discoveredRepos.find((r) => r.fullName === val); - if (selected) { - this.plugin.settings.owner = selected.owner; - this.plugin.settings.repo = selected.name; - this.plugin.settings.branch = selected.defaultBranch || "main"; - await this.plugin.saveSettings(); - await this.discoverBranches(selected.owner, selected.name); - this.refreshTab(); - } - })(); - }); - }); - } - - // Branch Dropdown (if branches discovered) - if (this.discoveredBranches.length > 0) { - const branchSetting = new Setting(containerEl) - .setName("Select Branch") - .setDesc("Target Git branch for synchronization."); - - branchSetting.addDropdown((dropdown) => { - for (const b of this.discoveredBranches) { - dropdown.addOption(b.name, b.name); - } - dropdown.setValue(this.plugin.settings.branch || "main"); - dropdown.onChange((val) => { - void (async () => { - this.plugin.settings.branch = val; - await this.plugin.saveSettings(); - })(); - }); - }); - } - - // Section 2: Advanced / Security - new Setting(containerEl).setName("Advanced / Security").setHeading(); - - // Stored Credential Setting (Clear Token moved out of primary flow into Advanced / Security) - const credSetting = new Setting(containerEl) - .setName("Stored Credential") - .setDesc( - tokenExists - ? `Active in Obsidian SecretStorage (${keyName}).` - : "No token currently stored in SecretStorage." - ); - - if (tokenExists) { - credSetting.addButton((button) => { - button.setButtonText("Clear Token"); - const btn = button as unknown as Record ButtonComponent) | undefined>; - if (typeof btn["setDestructive"] === "function") { - btn["setDestructive"](); - } else { - button.buttonEl.addClass("mod-warning"); - } - button.onClick(() => { - this.handleClearToken(); - }); - button.buttonEl.addClass("vault-relay-btn-lg"); - }); - } - - // Manual Repository Configuration (Collapsible) - const advToggleSetting = new Setting(containerEl) - .setName("Manual Configuration") - .setDesc("Manually configure repository details, custom branches, and exclusion rules."); - - advToggleSetting.addButton((btn) => { - btn.setButtonText(this.showManualSetup ? "Hide Manual Setup" : "Show Manual Setup"); - btn.buttonEl.addClass("vault-relay-btn-lg"); - btn.onClick(() => { - this.showManualSetup = !this.showManualSetup; - this.refreshTab(); - }); - }); - - if (this.showManualSetup) { - // Repository Owner Setting - new Setting(containerEl) - .setName("Repository Owner") - .setDesc("GitHub username or organization that owns the repository (e.g. 'octocat').") - .addText((text) => - text - .setPlaceholder("octocat") - .setValue(this.plugin.settings.owner) - .onChange((value) => { - void (async () => { - const norm = normalizeRepoConfig(value, this.plugin.settings.repo); - this.plugin.settings.owner = norm.owner; - this.plugin.settings.repo = norm.repo; - await this.plugin.saveSettings(); - })(); - }) - ); - - // Repository Name Setting - new Setting(containerEl) - .setName("Repository Name") - .setDesc("Name of the GitHub repository (e.g. 'my-notes').") - .addText((text) => - text - .setPlaceholder("my-notes") - .setValue(this.plugin.settings.repo) - .onChange((value) => { - void (async () => { - const norm = normalizeRepoConfig(this.plugin.settings.owner, value); - this.plugin.settings.owner = norm.owner; - this.plugin.settings.repo = norm.repo; - await this.plugin.saveSettings(); - })(); - }) - ); - - // Branch Setting - new Setting(containerEl) - .setName("Branch") - .setDesc("Target Git branch (default: 'main').") - .addText((text) => - text - .setPlaceholder("main") - .setValue(this.plugin.settings.branch || "main") - .onChange((value) => { - void (async () => { - this.plugin.settings.branch = value.trim() || "main"; - await this.plugin.saveSettings(); - })(); - }) - ); - - // Excluded Paths Setting - new Setting(containerEl) - .setName("Excluded Paths") - .setDesc( - "Directories or file paths excluded from scanning and syncing (one per line). Trailing slash indicates directory prefix." - ) - .addTextArea((textArea) => { - textArea - .setPlaceholder(`${this.app.vault.configDir}/\n.git/\n_fit/`) - .setValue(this.plugin.settings.excludedPaths.join("\n")) - .onChange((value) => { - void (async () => { - this.plugin.settings.excludedPaths = parseExclusionRules(value, this.app.vault.configDir); - await this.plugin.saveSettings(); - })(); + const defs = this.getSettingGroups(); + for (const group of defs) { + if (group.type === "group" && group.heading) { + new Setting(containerEl).setName(group.heading).setHeading(); + } + for (const item of group.items) { + if (typeof item.visible === "function" && !item.visible()) continue; + const s = new Setting(containerEl).setName(item.name).setDesc(item.desc || ""); + if (typeof item.render === "function") { + item.render(s, group); + } else if (item.control && item.control.type === "text") { + const controlKey = item.control.key; + const placeholder = item.control.placeholder || ""; + s.addText((text) => { + text.setPlaceholder(placeholder); + text.setValue(String(this.getControlValue(controlKey) || "")); + text.onChange((val) => { + void this.setControlValue(controlKey, val); }); - textArea.inputEl.rows = 4; - textArea.inputEl.addClass("vault-relay-textarea"); - }); + }); + } + } } - // Section 3: Connection Diagnostics & Sync Actions - new Setting(containerEl).setName("Diagnostics & Sync").setHeading(); - - const actionsSetting = new Setting(containerEl) - .setName("Sync Operations") - .setDesc("Open the primary sync dashboard or run individual safe pull/push operations."); - - actionsSetting.addButton((button) => { - button - .setButtonText("Open Sync Dashboard") - .setCta() - .onClick(() => { - new SyncDashboardModal(this.app, this.plugin).open(); - }); - button.buttonEl.addClass("vault-relay-btn-lg"); - }); - - actionsSetting.addButton((button) => { - button - .setButtonText("Test Connection") - .onClick(() => { - void this.handleTestConnection(button); - }); - button.buttonEl.addClass("vault-relay-btn-lg"); - }); - // Refresh token status asynchronously if not already up to date void this.refreshTokenStatus(); } diff --git a/src/ui/clearTokenConfirmModal.ts b/src/ui/clearTokenConfirmModal.ts index 64ad578..9bbf3dc 100644 --- a/src/ui/clearTokenConfirmModal.ts +++ b/src/ui/clearTokenConfirmModal.ts @@ -25,12 +25,12 @@ export class ClearTokenConfirmModal extends Modal { contentEl.createEl("p", { text: "Are you sure you want to remove your GitHub Personal Access Token from Obsidian SecretStorage?", - attr: { style: "line-height: 1.5; margin-bottom: 12px;" }, + cls: "vault-relay-dialog-desc", }); contentEl.createEl("p", { text: "This action cannot be undone. Your repository and branch settings will remain, but synchronization will require you to re-enter a token.", - attr: { style: "color: var(--text-muted); font-size: 0.9em; margin-bottom: 20px;" }, + cls: "vault-relay-dialog-subtext", }); const buttonRow = new Setting(contentEl); diff --git a/src/ui/conflictResolutionModal.ts b/src/ui/conflictResolutionModal.ts index 2767e6a..c198956 100644 --- a/src/ui/conflictResolutionModal.ts +++ b/src/ui/conflictResolutionModal.ts @@ -94,22 +94,22 @@ export class ConflictResolutionModal extends Modal { if (this.conflicts.length === 0) { contentEl.createDiv({ text: "No active conflicts detected. All files are synchronized or safe.", - attr: { style: "padding: 20px 0; color: var(--text-muted); font-size: 0.95em;" }, + cls: "vault-relay-empty-state-text", }); - const closeBtn = contentEl.createEl("button", { text: "Close", cls: "mod-cta" }); + const closeBtn = contentEl.createEl("button", { text: "Close", cls: "mod-cta vault-relay-btn-lg" }); closeBtn.onclick = () => this.close(); return; } const desc = contentEl.createDiv({ - attr: { style: "margin-bottom: 16px; font-size: 0.88em; color: var(--text-muted); line-height: 1.4;" }, + cls: "vault-relay-conflict-intro", }); desc.setText( "The following notes have been modified both locally and on GitHub. Choose how you would like to resolve each conflict:" ); const listContainer = contentEl.createDiv({ - attr: { style: "max-height: 420px; overflow-y: auto; display: flex; flex-direction: column; gap: 12px;" }, + cls: "vault-relay-conflict-list", }); for (const conflict of this.conflicts) { @@ -119,14 +119,11 @@ export class ConflictResolutionModal extends Modal { private renderConflictCard(container: HTMLElement, conflict: ConflictRecord): void { const card = container.createDiv({ - attr: { - style: - "border: 1px solid var(--background-modifier-border); border-radius: 8px; padding: 14px; background-color: var(--background-secondary);", - }, + cls: "vault-relay-conflict-card", }); const header = card.createDiv({ - attr: { style: "font-weight: 600; word-break: break-all; margin-bottom: 6px; font-size: 0.95em; color: var(--text-normal);" }, + cls: "vault-relay-conflict-path", }); header.setText(conflict.path); @@ -137,53 +134,49 @@ export class ConflictResolutionModal extends Modal { if (isDeleteConflict) { const isLocalDel = conflict.conflictType === "DELETE_LOCAL_REMOTE_MODIFIED"; const statusDesc = card.createDiv({ - attr: { style: "margin-bottom: 8px; font-size: 0.85em; line-height: 1.4;" }, + cls: "vault-relay-conflict-delete-desc", }); statusDesc.createDiv({ text: isLocalDel ? "Deleted on this device, modified on GitHub." : "Modified on this device, deleted on GitHub.", - attr: { style: "color: var(--color-red, #e74c3c); font-weight: 600; margin-bottom: 4px;" }, + cls: "vault-relay-conflict-delete-badge", }); const explanation = isLocalDel ? "• Keep File: Restore the GitHub version locally.\n• Delete File: Delete the GitHub version in a new commit." : "• Keep File: Push local modifications to GitHub in a new commit.\n• Delete File: Move the local file to Obsidian trash."; statusDesc.createDiv({ text: explanation, - attr: { style: "color: var(--text-muted); font-size: 0.85em; white-space: pre-line;" }, + cls: "vault-relay-conflict-explanation", }); } else { card.createDiv({ text: "Both versions are preserved until you choose an action.", - attr: { style: "font-size: 0.8em; color: var(--text-muted); margin-bottom: 10px;" }, + cls: "vault-relay-conflict-note", }); } // Status / Progress indicator area const statusDiv = card.createDiv({ - attr: { - style: "display: none; font-size: 0.85em; margin-bottom: 10px; padding: 6px 10px; border-radius: 4px; background-color: var(--background-primary);", - }, + cls: "vault-relay-conflict-status", }); const btnRow = card.createDiv({ - attr: { style: "display: flex; flex-wrap: wrap; gap: 8px;" }, + cls: "vault-relay-conflict-actions", }); if (isDeleteConflict) { const keepFileBtn = btnRow.createEl("button", { text: "Keep File", - cls: "mod-cta", - attr: { style: "min-height: 44px; min-width: 44px;" }, + cls: "mod-cta vault-relay-btn-lg", }); const deleteFileBtn = btnRow.createEl("button", { text: "Delete File", - cls: "mod-warning", - attr: { style: "min-height: 44px; min-width: 44px;" }, + cls: "mod-warning vault-relay-btn-lg", }); const cancelBtn = btnRow.createEl("button", { text: "Cancel", - attr: { style: "min-height: 44px; min-width: 44px;" }, + cls: "vault-relay-btn-lg", }); cancelBtn.onclick = () => this.close(); @@ -246,20 +239,19 @@ export class ConflictResolutionModal extends Modal { } else { const keepBothBtn = btnRow.createEl("button", { text: "Keep Both", - cls: "mod-cta", - attr: { style: "min-height: 44px; min-width: 44px;" }, + cls: "mod-cta vault-relay-btn-lg", }); const keepLocalBtn = btnRow.createEl("button", { text: "Keep Local", - attr: { style: "min-height: 44px; min-width: 44px;" }, + cls: "vault-relay-btn-lg", }); const useRemoteBtn = btnRow.createEl("button", { text: "Use Remote", - attr: { style: "min-height: 44px; min-width: 44px;" }, + cls: "vault-relay-btn-lg", }); const cancelBtn = btnRow.createEl("button", { text: "Cancel", - attr: { style: "min-height: 44px; min-width: 44px;" }, + cls: "vault-relay-btn-lg", }); cancelBtn.onclick = () => this.close(); diff --git a/src/ui/pullConfirmModal.ts b/src/ui/pullConfirmModal.ts deleted file mode 100644 index 9144053..0000000 --- a/src/ui/pullConfirmModal.ts +++ /dev/null @@ -1,262 +0,0 @@ -/** - * Safe Pull Confirmation Modal for Vault Relay - * - * Runs a pre-flight scan and prompts the user for explicit confirmation - * before downloading and updating local vault files. - */ - -import { App, Modal, Notice, setIcon } from "obsidian"; -import type VaultRelayPlugin from "../main"; -import { GitHubClient } from "../github/githubClient"; -import { PullEngine } from "../sync/pullEngine"; -import { SyncEngine } from "../sync/syncEngine"; -import { PullExecutionReport, SyncPreviewReport } from "../sync/syncTypes"; -import { getStoredPat } from "../security/secretStore"; -import { sanitizeErrorMessage } from "../security/redact"; -import { computeSemanticPreview } from "../sync/semanticSummary"; -import { PullResultModal } from "./pullResultModal"; - -export type OnPullCompleteCallback = (report: PullExecutionReport) => Promise | void; - -export class PullConfirmModal extends Modal { - private plugin: VaultRelayPlugin; - private previewReport: SyncPreviewReport | null = null; - private isLoading = true; - private onComplete?: OnPullCompleteCallback; - - constructor(app: App, plugin: VaultRelayPlugin, onComplete?: OnPullCompleteCallback) { - super(app); - this.plugin = plugin; - this.onComplete = onComplete; - } - - public onOpen(): void { - this.modalEl.addClass("vault-relay-modal"); - this.modalEl.addClass("vault-relay-confirm-modal"); - void this.runPreflight().catch((err) => { - this.renderError(sanitizeErrorMessage(err)); - }); - } - - public onClose(): void { - const { contentEl } = this; - contentEl.empty(); - } - - private async runPreflight(): Promise { - this.isLoading = true; - this.renderLoading(); - - try { - const token = await getStoredPat(this.app, this.plugin.settings.owner, this.plugin.settings.repo); - if (!token) { - this.renderError("No GitHub PAT found in secure storage. Please configure settings first."); - return; - } - - const client = new GitHubClient({ - token, - owner: this.plugin.settings.owner, - repo: this.plugin.settings.repo, - branch: this.plugin.settings.branch, - }); - - const engine = new SyncEngine(this.app, this.plugin.settings, client); - this.previewReport = await engine.generatePreview(); - this.isLoading = false; - this.renderConfirmation(); - } catch (err) { - this.isLoading = false; - const msg = sanitizeErrorMessage(err); - this.renderError(msg); - new Notice(`Safe Pull preflight error: ${msg}`); - } - } - - private renderLoading(): void { - const { contentEl } = this; - contentEl.empty(); - - const container = contentEl.createDiv({ - attr: { - style: "display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 36px 20px;", - }, - }); - - const iconDiv = container.createDiv({ attr: { style: "margin-bottom: 16px;" } }); - setIcon(iconDiv, "refresh-cw"); - iconDiv.addClass("vault-relay-spin"); - - container.createEl("h3", { text: "Scanning remote & local state..." }); - container.createEl("p", { - text: "Checking branch head and building safe pull plan...", - attr: { style: "color: var(--text-muted); font-size: 0.9em;" }, - }); - } - - private renderError(message: string): void { - const { contentEl } = this; - contentEl.empty(); - - contentEl.createEl("h2", { text: "GitHub vault relay - safe pull" }); - - const errBox = contentEl.createDiv({ - attr: { - style: - "padding: 16px; border-radius: 6px; border-left: 4px solid var(--color-red, #e74c3c); background-color: var(--background-secondary); margin: 20px 0;", - }, - }); - errBox.createEl("h4", { - text: "Preflight check failed", - attr: { style: "margin: 0 0 8px 0; color: var(--text-error, #e74c3c);" }, - }); - errBox.createEl("p", { text: message, attr: { style: "margin: 0; color: var(--text-muted); font-size: 0.95em;" } }); - - const actions = contentEl.createDiv({ - attr: { style: "display: flex; justify-content: flex-end; margin-top: 20px;" }, - }); - const closeBtn = actions.createEl("button", { text: "Close" }); - closeBtn.onclick = () => this.close(); - } - - private renderConfirmation(): void { - if (!this.previewReport) return; - const { contentEl } = this; - contentEl.empty(); - - contentEl.createEl("h2", { text: "Confirm safe pull (GitHub → local)", attr: { style: "margin: 0 0 8px 0;" } }); - - // Truncated tree warning - if (this.previewReport.truncatedRemoteTree) { - const warnBox = contentEl.createDiv({ - attr: { - style: - "padding: 14px 16px; border-radius: 6px; border-left: 4px solid var(--color-red, #e74c3c); background-color: var(--background-secondary); margin-bottom: 16px;", - }, - }); - warnBox.createEl("strong", { - text: "🚫 Safe pull blocked: Truncated Git tree", - attr: { style: "color: var(--text-error, #e74c3c);" }, - }); - warnBox.createEl("p", { - text: "The remote repository tree was truncated by GitHub API (>100,000 items). Safe pull cannot proceed to avoid partial synchronization.", - attr: { style: "margin: 4px 0 0 0; font-size: 0.88em; color: var(--text-muted);" }, - }); - return; - } - - // Semantic Summary - const semantic = computeSemanticPreview(this.previewReport.items); - - // Safety Description Notice - const notice = contentEl.createDiv({ - attr: { - style: - "padding: 10px 14px; border-radius: 4px; background-color: var(--background-secondary); border-left: 4px solid var(--interactive-accent); font-size: 0.88em; margin-bottom: 16px; line-height: 1.4;", - }, - }); - let noticeText = "Vault Relay will pull new and updated notes from GitHub to your local Obsidian vault. Local modifications are never silently overwritten; conflicting versions are preserved safely in internal conflict storage."; - if (semantic.pullRemoveLocal > 0) { - noticeText += " Files to remove locally will be moved to trash according to your Obsidian trash settings."; - } - notice.createDiv({ text: noticeText }); - - // Summary of Actions - const summaryBox = contentEl.createDiv({ - attr: { - style: - "border: 1px solid var(--background-modifier-border); border-radius: 6px; padding: 12px 16px; background-color: var(--background-secondary-alt, var(--background-secondary)); margin-bottom: 20px;", - }, - }); - - summaryBox.createEl("h4", { text: "Planned actions summary", attr: { style: "margin: 0 0 10px 0;" } }); - - const list = summaryBox.createDiv({ attr: { style: "font-size: 0.9em; line-height: 1.6;" } }); - list.createDiv({ text: `• New files to create locally: ${semantic.pullCreate}` }); - list.createDiv({ text: `• Files to update locally: ${semantic.pullUpdate}` }); - - // Explicit Destructive Section - if (semantic.pullRemoveLocal > 0) { - const delRow = list.createDiv({ - attr: { - style: - "color: var(--color-red, #e74c3c); font-weight: 600; padding: 4px 8px; margin: 4px 0; border-radius: 4px; background-color: rgba(231, 76, 60, 0.1); border-left: 3px solid var(--color-red, #e74c3c);", - }, - }); - delRow.createDiv({ text: `āš ļø Files to remove locally: ${semantic.pullRemoveLocal}` }); - delRow.createDiv({ - text: "Files are moved to trash according to your Obsidian trash settings.", - attr: { style: "font-size: 0.82em; font-weight: normal; color: var(--text-muted); margin-top: 2px;" }, - }); - } - - if (semantic.pullMoves > 0) { - list.createDiv({ - text: `• Moves to apply locally: ${semantic.pullMoves}`, - attr: { style: "color: var(--color-purple, #9b59b6); font-weight: 500;" }, - }); - } - - if (semantic.deleteConflicts > 0) { - list.createDiv({ - text: `• Delete conflicts (require review): ${semantic.deleteConflicts}`, - attr: { style: "color: var(--color-red, #e74c3c); font-weight: 500;" }, - }); - } - - list.createDiv({ text: `• Potential conflicts (preserved to internal conflict storage): ${semantic.contentConflicts}` }); - list.createDiv({ text: `• Oversized files (>25 MiB, skipped): ${semantic.oversized}` }); - list.createDiv({ text: `• Local changes / notes kept untouched: ${semantic.totalPushMutations}` }); - list.createDiv({ text: `• Unchanged files: ${semantic.unchanged}` }); - - // Action Buttons - const actions = contentEl.createDiv({ - cls: "vault-relay-action-row", - attr: { style: "display: flex; justify-content: flex-end; gap: 10px; flex-wrap: wrap;" }, - }); - - const cancelBtn = actions.createEl("button", { - text: "Cancel", - attr: { style: "min-height: 44px; min-width: 44px; padding: 10px 16px;" }, - }); - cancelBtn.onclick = () => this.close(); - - const confirmBtn = actions.createEl("button", { - text: "Confirm Safe Pull", - cls: "mod-cta vault-relay-btn-lg", - }); - confirmBtn.onclick = () => { - void (async () => { - confirmBtn.disabled = true; - confirmBtn.textContent = "Pulling..."; - cancelBtn.disabled = true; - - try { - const token = await getStoredPat(this.app, this.plugin.settings.owner, this.plugin.settings.repo); - const client = new GitHubClient({ - token: token || "", - owner: this.plugin.settings.owner, - repo: this.plugin.settings.repo, - branch: this.plugin.settings.branch, - }); - - const pullEngine = new PullEngine(this.app, this.plugin.settings, client); - const report = await pullEngine.executeSafePull(); - - this.close(); - new PullResultModal(this.app, report).open(); - if (this.onComplete) { - try { - await this.onComplete(report); - } catch (callbackErr) { - console.warn("[GitHub Vault Relay] onComplete refresh error:", sanitizeErrorMessage(callbackErr)); - } - } - } catch (err) { - new Notice(`Safe Pull failed: ${sanitizeErrorMessage(err)}`); - this.close(); - } - })(); - }; - } -} diff --git a/src/ui/pullResultModal.ts b/src/ui/pullResultModal.ts deleted file mode 100644 index 89d99bf..0000000 --- a/src/ui/pullResultModal.ts +++ /dev/null @@ -1,201 +0,0 @@ -/** - * Safe Pull Result Modal for Vault Relay - * - * Displays honest, detailed execution reports following a Safe Pull operation. - */ - -import { App, Modal } from "obsidian"; -import { PullExecutionReport, PullFileResult } from "../sync/syncTypes"; -import { computeSemanticPullResults } from "../sync/semanticSummary"; - -export class PullResultModal extends Modal { - private report: PullExecutionReport; - - constructor(app: App, report: PullExecutionReport) { - super(app); - this.report = report; - } - - public onOpen(): void { - this.modalEl.addClass("vault-relay-modal"); - this.modalEl.addClass("vault-relay-result-modal"); - this.render(); - } - - public onClose(): void { - const { contentEl } = this; - contentEl.empty(); - } - - private render(): void { - const { contentEl } = this; - contentEl.empty(); - - contentEl.createEl("h2", { text: "Safe Pull Results" }); - - // Status Banner - const counts = computeSemanticPullResults(this.report); - let statusBg = "rgba(46, 204, 113, 0.15)"; - let statusFg = "var(--color-green, #2ecc71)"; - let statusIcon = "āœ…"; - let statusTitle = "PASS"; - - if (this.report.status === "FAIL") { - statusBg = "rgba(231, 76, 60, 0.15)"; - statusFg = "var(--color-red, #e74c3c)"; - statusIcon = "āŒ"; - statusTitle = "FAIL"; - } else if (this.report.status === "ABORTED") { - statusBg = "rgba(230, 126, 34, 0.15)"; - statusFg = "var(--color-orange, #e67e22)"; - statusIcon = "āš ļø"; - statusTitle = "ABORTED"; - } else if (this.report.status === "PASS_WITH_WARNINGS") { - statusBg = "rgba(241, 196, 15, 0.15)"; - statusFg = "var(--color-yellow, #f39c12)"; - statusIcon = "āš ļø"; - statusTitle = "PASS WITH WARNINGS"; - } - - const statusBanner = contentEl.createDiv({ - attr: { - style: `padding: 12px 16px; border-radius: 6px; background-color: ${statusBg}; border-left: 4px solid ${statusFg}; margin-bottom: 16px;`, - }, - }); - - statusBanner.createDiv({ - text: `${statusIcon} ${statusTitle} — ${this.report.summaryMessage}`, - attr: { style: `font-weight: 600; color: ${statusFg}; margin-bottom: 4px;` }, - }); - - // Summary Stat Badges - const statsGrid = contentEl.createDiv({ - attr: { - style: - "display: grid; grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); gap: 8px; margin-bottom: 16px;", - }, - }); - - this.createBadge(statsGrid, "Created", counts.created, "var(--color-green, #2ecc71)"); - this.createBadge(statsGrid, "Updated", counts.updated, "var(--color-cyan, #00b4d8)"); - if (counts.removed > 0) { - this.createBadge(statsGrid, "Removed locally", counts.removed, "var(--color-red, #e74c3c)"); - } - if (counts.moved > 0) { - this.createBadge(statsGrid, "Moved", counts.moved, "var(--color-purple, #9b59b6)"); - } - this.createBadge(statsGrid, "Conflicts", counts.conflicts, "var(--color-red, #e74c3c)"); - this.createBadge(statsGrid, "Oversized", counts.oversized, "var(--color-purple, #9b59b6)"); - this.createBadge(statsGrid, "Unsafe", counts.unsafe, "var(--color-orange, #e67e22)"); - this.createBadge(statsGrid, "Failed", counts.failed, "var(--color-red, #c0392b)"); - - // Actions Detail List - contentEl.createEl("h4", { text: "Execution Details", attr: { style: "margin: 12px 0 8px 0;" } }); - - const listContainer = contentEl.createDiv({ - attr: { - style: - "max-height: 280px; overflow-y: auto; border: 1px solid var(--background-modifier-border); border-radius: 4px; background-color: var(--background-primary);", - }, - }); - - const activeResults = this.report.results.filter( - (r) => r.action !== "SKIP_UNCHANGED" && r.action !== "SKIP_LOCAL_ONLY" && r.action !== "SKIP_LOCAL_CHANGED" - ); - - if (activeResults.length === 0) { - const emptyEl = listContainer.createDiv({ - attr: { style: "padding: 24px; text-align: center; color: var(--text-muted); font-size: 0.9em;" }, - }); - emptyEl.setText("No file changes required execution. Vault is fully up to date."); - } else { - for (const res of activeResults) { - this.renderRow(listContainer, res); - } - } - - // Close Action - const actions = contentEl.createDiv({ - attr: { style: "display: flex; justify-content: flex-end; margin-top: 16px;" }, - }); - - const closeBtn = actions.createEl("button", { - text: "Done", - attr: { style: "min-height: 44px; min-width: 44px; padding: 10px 16px;" }, - }); - closeBtn.onclick = () => this.close(); - } - - private createBadge(parent: HTMLElement, label: string, count: number, color: string): void { - const card = parent.createDiv({ - attr: { - style: - "padding: 8px 10px; border-radius: 6px; background-color: var(--background-secondary); border: 1px solid var(--background-modifier-border); text-align: center;", - }, - }); - - card.createDiv({ - text: String(count), - attr: { style: `font-size: 1.2em; font-weight: bold; color: ${count > 0 ? color : "var(--text-muted)"};` }, - }); - card.createDiv({ - text: label, - attr: { style: "font-size: 0.75em; color: var(--text-muted); margin-top: 2px;" }, - }); - } - - private renderRow(parent: HTMLElement, res: PullFileResult): void { - const row = parent.createDiv({ - attr: { - style: - "padding: 8px 12px; border-bottom: 1px solid var(--background-modifier-border); font-size: 0.85em;", - }, - }); - - const header = row.createDiv({ - attr: { style: "display: flex; justify-content: space-between; align-items: center; gap: 8px; flex-wrap: wrap;" }, - }); - - header.createDiv({ - text: res.path, - attr: { style: "font-weight: 500; word-break: break-all; color: var(--text-normal);" }, - }); - - const badgeGroup = header.createDiv({ attr: { style: "display: flex; gap: 6px; align-items: center;" } }); - - if (res.action === "PULL_DELETE") { - badgeGroup.createSpan({ - text: "Removed locally", - attr: { - style: "padding: 2px 6px; border-radius: 3px; font-size: 0.72em; font-weight: 600; background-color: rgba(231, 76, 60, 0.15); color: var(--color-red, #e74c3c);", - }, - }); - } - - let badgeColor = "#27ae60"; - if (res.status === "FAILED") badgeColor = "#e74c3c"; - if (res.status === "CONFLICT_PRESERVED") badgeColor = "#e67e22"; - if (res.status === "SKIPPED") badgeColor = "#95a5a6"; - - badgeGroup.createSpan({ - text: res.status, - attr: { - style: `padding: 2px 6px; border-radius: 3px; font-size: 0.72em; font-weight: 600; background-color: rgba(0,0,0,0.1); color: ${badgeColor};`, - }, - }); - - if (res.message) { - row.createDiv({ - text: res.message, - attr: { style: "font-size: 0.8em; color: var(--text-muted); margin-top: 3px;" }, - }); - } - - if (res.conflictPath) { - row.createDiv({ - text: `Preserved conflict file: ${res.conflictPath}`, - attr: { style: "font-size: 0.78em; color: var(--color-orange, #e67e22); font-family: var(--font-monospace); margin-top: 2px;" }, - }); - } - } -} diff --git a/src/ui/pushConfirmModal.ts b/src/ui/pushConfirmModal.ts deleted file mode 100644 index e58d6ac..0000000 --- a/src/ui/pushConfirmModal.ts +++ /dev/null @@ -1,270 +0,0 @@ -/** - * Safe Push Confirmation Modal for Vault Relay - * - * Runs a pre-flight scan and prompts the user for explicit confirmation - * before uploading and committing local vault files to GitHub. - */ - -import { App, Modal, Notice, setIcon } from "obsidian"; -import type VaultRelayPlugin from "../main"; -import { GitHubClient } from "../github/githubClient"; -import { PushEngine } from "../sync/pushEngine"; -import { SyncEngine } from "../sync/syncEngine"; -import { PushExecutionReport, SyncPreviewReport } from "../sync/syncTypes"; -import { getStoredPat } from "../security/secretStore"; -import { sanitizeErrorMessage } from "../security/redact"; -import { computeSemanticPreview } from "../sync/semanticSummary"; -import { PushResultModal } from "./pushResultModal"; - -export type OnPushCompleteCallback = (report: PushExecutionReport) => Promise | void; - -export class PushConfirmModal extends Modal { - private plugin: VaultRelayPlugin; - private previewReport: SyncPreviewReport | null = null; - private isLoading = true; - private onComplete?: OnPushCompleteCallback; - - constructor(app: App, plugin: VaultRelayPlugin, onComplete?: OnPushCompleteCallback) { - super(app); - this.plugin = plugin; - this.onComplete = onComplete; - } - - public onOpen(): void { - this.modalEl.addClass("vault-relay-modal"); - this.modalEl.addClass("vault-relay-confirm-modal"); - void this.runPreflight().catch((err) => { - this.renderError(sanitizeErrorMessage(err)); - }); - } - - public onClose(): void { - const { contentEl } = this; - contentEl.empty(); - } - - private async runPreflight(): Promise { - this.isLoading = true; - this.renderLoading(); - - try { - const token = await getStoredPat(this.app, this.plugin.settings.owner, this.plugin.settings.repo); - if (!token) { - this.renderError("No GitHub PAT found in secure storage. Please configure settings first."); - return; - } - - const client = new GitHubClient({ - token, - owner: this.plugin.settings.owner, - repo: this.plugin.settings.repo, - branch: this.plugin.settings.branch, - }); - - const engine = new SyncEngine(this.app, this.plugin.settings, client); - this.previewReport = await engine.generatePreview(); - this.isLoading = false; - this.renderConfirmation(); - } catch (err) { - this.isLoading = false; - const msg = sanitizeErrorMessage(err); - this.renderError(msg); - new Notice(`Safe Push preflight error: ${msg}`); - } - } - - private renderLoading(): void { - const { contentEl } = this; - contentEl.empty(); - - const container = contentEl.createDiv({ - attr: { - style: "display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 36px 20px;", - }, - }); - - const iconDiv = container.createDiv({ attr: { style: "margin-bottom: 16px;" } }); - setIcon(iconDiv, "refresh-cw"); - iconDiv.addClass("vault-relay-spin"); - - container.createEl("h3", { text: "Scanning Remote & Local State..." }); - container.createEl("p", { - text: "Checking branch HEAD and building Safe Push plan...", - attr: { style: "color: var(--text-muted); font-size: 0.9em;" }, - }); - } - - private renderError(message: string): void { - const { contentEl } = this; - contentEl.empty(); - - contentEl.createEl("h2", { text: "GitHub Vault Relay - Safe Push" }); - - const errBox = contentEl.createDiv({ - attr: { - style: - "padding: 16px; border-radius: 6px; border-left: 4px solid var(--color-red, #e74c3c); background-color: var(--background-secondary); margin: 20px 0;", - }, - }); - errBox.createEl("h4", { - text: "Preflight Check Failed", - attr: { style: "margin: 0 0 8px 0; color: var(--text-error, #e74c3c);" }, - }); - errBox.createEl("p", { text: message, attr: { style: "margin: 0; color: var(--text-muted); font-size: 0.95em;" } }); - - const actions = contentEl.createDiv({ - attr: { style: "display: flex; justify-content: flex-end; margin-top: 20px;" }, - }); - const closeBtn = actions.createEl("button", { text: "Close" }); - closeBtn.onclick = () => this.close(); - } - - private renderConfirmation(): void { - if (!this.previewReport) return; - const { contentEl } = this; - contentEl.empty(); - - contentEl.createEl("h2", { text: "Confirm Safe Push (Local → GitHub)", attr: { style: "margin: 0 0 8px 0;" } }); - - // Truncated tree warning - if (this.previewReport.truncatedRemoteTree) { - const warnBox = contentEl.createDiv({ - attr: { - style: - "padding: 14px 16px; border-radius: 6px; border-left: 4px solid var(--color-red, #e74c3c); background-color: var(--background-secondary); margin-bottom: 16px;", - }, - }); - warnBox.createEl("strong", { - text: "🚫 Safe Push Blocked: Truncated Git Tree", - attr: { style: "color: var(--text-error, #e74c3c);" }, - }); - warnBox.createEl("p", { - text: "The remote repository tree was truncated by GitHub API (>100,000 items). Safe push cannot proceed to avoid partial synchronization.", - attr: { style: "margin: 4px 0 0 0; font-size: 0.88em; color: var(--text-muted);" }, - }); - return; - } - - // Target repo info - contentEl.createDiv({ - text: `Target: ${this.plugin.settings.owner}/${this.plugin.settings.repo} (branch: ${this.plugin.settings.branch})`, - attr: { style: "color: var(--text-muted); font-size: 0.88em; margin-bottom: 14px;" }, - }); - - // Semantic Summary - const semantic = computeSemanticPreview(this.previewReport.items); - - // Safety Description Notice - const notice = contentEl.createDiv({ - attr: { - style: - "padding: 10px 14px; border-radius: 4px; background-color: var(--background-secondary); border-left: 4px solid var(--interactive-accent); font-size: 0.88em; margin-bottom: 16px; line-height: 1.4;", - }, - }); - let noticeText = "Vault Relay will upload your safe local changes (new, updated, and deleted notes) to GitHub in a single atomic commit."; - if (semantic.pushDeleteRemote > 0) { - noticeText += " The current GitHub version of deleted files will be removed in the new commit. Previous versions remain available in Git history while repository history remains available."; - } else { - noticeText += " Remote modifications and conflicts are never overwritten; optimistic concurrency guards ensure zero force-push."; - } - notice.createDiv({ text: noticeText }); - - // Summary of Actions - const summaryBox = contentEl.createDiv({ - attr: { - style: - "border: 1px solid var(--background-modifier-border); border-radius: 6px; padding: 12px 16px; background-color: var(--background-secondary-alt, var(--background-secondary)); margin-bottom: 20px;", - }, - }); - - summaryBox.createEl("h4", { text: "Planned Actions Summary", attr: { style: "margin: 0 0 10px 0;" } }); - - const list = summaryBox.createDiv({ attr: { style: "font-size: 0.9em; line-height: 1.6;" } }); - list.createDiv({ text: `• New files to create on GitHub: ${semantic.pushCreate}` }); - list.createDiv({ text: `• Files to update on GitHub: ${semantic.pushUpdate}` }); - - // Explicit Destructive Section - if (semantic.pushDeleteRemote > 0) { - const delRow = list.createDiv({ - attr: { - style: - "color: var(--color-red, #e74c3c); font-weight: 600; padding: 4px 8px; margin: 4px 0; border-radius: 4px; background-color: rgba(231, 76, 60, 0.1); border-left: 3px solid var(--color-red, #e74c3c);", - }, - }); - delRow.createDiv({ text: `āš ļø Files to delete from GitHub: ${semantic.pushDeleteRemote}` }); - delRow.createDiv({ - text: "The current GitHub version will be removed in the new commit. Previous versions remain available in Git history while repository history remains available.", - attr: { style: "font-size: 0.82em; font-weight: normal; color: var(--text-muted); margin-top: 2px;" }, - }); - } - - if (semantic.pushMoves > 0) { - list.createDiv({ - text: `• Moves to commit to GitHub: ${semantic.pushMoves}`, - attr: { style: "color: var(--color-purple, #9b59b6); font-weight: 500;" }, - }); - } - - if (semantic.deleteConflicts > 0) { - list.createDiv({ - text: `• Delete conflicts (not pushed, require review): ${semantic.deleteConflicts}`, - attr: { style: "color: var(--color-red, #e74c3c); font-weight: 500;" }, - }); - } - - list.createDiv({ text: `• Conflicting files (kept untouched / not pushed): ${semantic.contentConflicts}` }); - list.createDiv({ text: `• Oversized files (>25 MiB, skipped): ${semantic.oversized}` }); - list.createDiv({ text: `• Remote notes (kept untouched): ${semantic.totalPullMutations}` }); - list.createDiv({ text: `• Unchanged files: ${semantic.unchanged}` }); - - // Action Buttons - const actions = contentEl.createDiv({ - cls: "vault-relay-action-row", - attr: { style: "display: flex; justify-content: flex-end; gap: 10px; flex-wrap: wrap;" }, - }); - - const cancelBtn = actions.createEl("button", { - text: "Cancel", - }); - cancelBtn.onclick = () => this.close(); - - const confirmBtn = actions.createEl("button", { - text: "Confirm Safe Push", - cls: "mod-cta vault-relay-btn-lg", - }); - confirmBtn.onclick = () => { - void (async () => { - confirmBtn.disabled = true; - confirmBtn.textContent = "Pushing..."; - cancelBtn.disabled = true; - - try { - const token = await getStoredPat(this.app, this.plugin.settings.owner, this.plugin.settings.repo); - const client = new GitHubClient({ - token: token || "", - owner: this.plugin.settings.owner, - repo: this.plugin.settings.repo, - branch: this.plugin.settings.branch, - }); - - const pushEngine = new PushEngine(this.app, this.plugin.settings, client); - const report = await pushEngine.executeSafePush(); - - this.close(); - new PushResultModal(this.app, report).open(); - - if (this.onComplete) { - try { - await this.onComplete(report); - } catch (callbackErr) { - console.warn("[GitHub Vault Relay] onComplete refresh error after push:", sanitizeErrorMessage(callbackErr)); - } - } - } catch (err) { - new Notice(`Safe Push failed: ${sanitizeErrorMessage(err)}`); - this.close(); - } - })(); - }; - } -} diff --git a/src/ui/pushResultModal.ts b/src/ui/pushResultModal.ts deleted file mode 100644 index 7e60f28..0000000 --- a/src/ui/pushResultModal.ts +++ /dev/null @@ -1,201 +0,0 @@ -/** - * Safe Push Result Modal for Vault Relay - * - * Displays honest, detailed execution reports following a Safe Push operation. - */ - -import { App, Modal } from "obsidian"; -import { PushExecutionReport, PushFileResult } from "../sync/syncTypes"; -import { computeSemanticPushResults } from "../sync/semanticSummary"; - -export class PushResultModal extends Modal { - private report: PushExecutionReport; - - constructor(app: App, report: PushExecutionReport) { - super(app); - this.report = report; - } - - public onOpen(): void { - this.modalEl.addClass("vault-relay-modal"); - this.modalEl.addClass("vault-relay-result-modal"); - this.render(); - } - - public onClose(): void { - const { contentEl } = this; - contentEl.empty(); - } - - private render(): void { - const { contentEl } = this; - contentEl.empty(); - - contentEl.createEl("h2", { text: "Safe Push Results" }); - - // Status Banner - const counts = computeSemanticPushResults(this.report); - let statusBg = "rgba(46, 204, 113, 0.15)"; - let statusFg = "var(--color-green, #2ecc71)"; - let statusIcon = "āœ…"; - let statusTitle = "PASS"; - - if (this.report.status === "FAIL") { - statusBg = "rgba(231, 76, 60, 0.15)"; - statusFg = "var(--color-red, #e74c3c)"; - statusIcon = "āŒ"; - statusTitle = "FAIL"; - } else if (this.report.status === "ABORTED") { - statusBg = "rgba(230, 126, 34, 0.15)"; - statusFg = "var(--color-orange, #e67e22)"; - statusIcon = "āš ļø"; - statusTitle = "ABORTED"; - } else if (this.report.status === "PASS_WITH_WARNINGS") { - statusBg = "rgba(241, 196, 15, 0.15)"; - statusFg = "var(--color-yellow, #f39c12)"; - statusIcon = "āš ļø"; - statusTitle = "PASS WITH WARNINGS"; - } - - const statusBanner = contentEl.createDiv({ - attr: { - style: `padding: 12px 16px; border-radius: 6px; background-color: ${statusBg}; border-left: 4px solid ${statusFg}; margin-bottom: 16px;`, - }, - }); - - statusBanner.createDiv({ - text: `${statusIcon} ${statusTitle} — ${this.report.summaryMessage}`, - attr: { style: `font-weight: 600; color: ${statusFg}; margin-bottom: 4px;` }, - }); - - if (this.report.newCommitSha) { - statusBanner.createDiv({ - text: `New Remote Commit: ${this.report.newCommitSha.substring(0, 7)} (branch: ${this.report.branch})`, - attr: { style: "font-family: var(--font-monospace); font-size: 0.85em; color: var(--text-muted);" }, - }); - } - - // Summary Stat Badges - const statsGrid = contentEl.createDiv({ - attr: { - style: - "display: grid; grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); gap: 8px; margin-bottom: 16px;", - }, - }); - - this.createBadge(statsGrid, "Created", counts.created, "var(--color-green, #2ecc71)"); - this.createBadge(statsGrid, "Updated", counts.updated, "var(--color-cyan, #00b4d8)"); - if (counts.deleted > 0) { - this.createBadge(statsGrid, "Deleted from GitHub", counts.deleted, "var(--color-red, #e74c3c)"); - } - if (counts.moved > 0) { - this.createBadge(statsGrid, "Moved", counts.moved, "var(--color-purple, #9b59b6)"); - } - this.createBadge(statsGrid, "Conflicts", counts.conflicts, "var(--color-red, #e74c3c)"); - this.createBadge(statsGrid, "Oversized", counts.oversized, "var(--color-purple, #9b59b6)"); - this.createBadge(statsGrid, "Unsafe", counts.unsafe, "var(--color-orange, #e67e22)"); - this.createBadge(statsGrid, "Failed", counts.failed, "var(--color-red, #c0392b)"); - - // Actions Detail List - contentEl.createEl("h4", { text: "Execution Details", attr: { style: "margin: 12px 0 8px 0;" } }); - - const listContainer = contentEl.createDiv({ - attr: { - style: - "max-height: 280px; overflow-y: auto; border: 1px solid var(--background-modifier-border); border-radius: 4px; background-color: var(--background-primary);", - }, - }); - - const activeResults = this.report.results.filter( - (r) => r.action !== "SKIP_UNCHANGED" && r.action !== "SKIP_REMOTE_ONLY" && r.action !== "SKIP_REMOTE_CHANGED" - ); - - if (activeResults.length === 0) { - const emptyEl = listContainer.createDiv({ - attr: { style: "padding: 24px; text-align: center; color: var(--text-muted); font-size: 0.9em;" }, - }); - emptyEl.setText("No local changes required push. Remote repository is up to date."); - } else { - for (const res of activeResults) { - this.renderRow(listContainer, res); - } - } - - // Close Action - const actions = contentEl.createDiv({ - attr: { style: "display: flex; justify-content: flex-end; margin-top: 16px;" }, - }); - - const closeBtn = actions.createEl("button", { - text: "Done", - attr: { style: "min-height: 44px; min-width: 44px; padding: 10px 16px;" }, - }); - closeBtn.onclick = () => this.close(); - } - - private createBadge(parent: HTMLElement, label: string, count: number, color: string): void { - const card = parent.createDiv({ - attr: { - style: - "padding: 8px 10px; border-radius: 6px; background-color: var(--background-secondary); border: 1px solid var(--background-modifier-border); text-align: center;", - }, - }); - - card.createDiv({ - text: String(count), - attr: { style: `font-size: 1.2em; font-weight: bold; color: ${count > 0 ? color : "var(--text-muted)"};` }, - }); - card.createDiv({ - text: label, - attr: { style: "font-size: 0.75em; color: var(--text-muted); margin-top: 2px;" }, - }); - } - - private renderRow(parent: HTMLElement, res: PushFileResult): void { - const row = parent.createDiv({ - attr: { - style: - "padding: 8px 12px; border-bottom: 1px solid var(--background-modifier-border); font-size: 0.85em;", - }, - }); - - const header = row.createDiv({ - attr: { style: "display: flex; justify-content: space-between; align-items: center; gap: 8px; flex-wrap: wrap;" }, - }); - - header.createDiv({ - text: res.path, - attr: { style: "font-weight: 500; word-break: break-all; color: var(--text-normal);" }, - }); - - const badgeGroup = header.createDiv({ attr: { style: "display: flex; gap: 6px; align-items: center;" } }); - - if (res.action === "PUSH_DELETE") { - badgeGroup.createSpan({ - text: "Deleted from GitHub", - attr: { - style: "padding: 2px 6px; border-radius: 3px; font-size: 0.72em; font-weight: 600; background-color: rgba(231, 76, 60, 0.15); color: var(--color-red, #e74c3c);", - }, - }); - } - - let badgeColor = "#27ae60"; - if (res.status === "FAILED") badgeColor = "#e74c3c"; - if (res.status === "BLOCKED_CONFLICT") badgeColor = "#e67e22"; - if (res.status === "SKIPPED") badgeColor = "#95a5a6"; - - badgeGroup.createSpan({ - text: res.status, - attr: { - style: `padding: 2px 6px; border-radius: 3px; font-size: 0.72em; font-weight: 600; background-color: rgba(0,0,0,0.1); color: ${badgeColor};`, - }, - }); - - if (res.message) { - row.createDiv({ - text: res.message, - attr: { style: "font-size: 0.8em; color: var(--text-muted); margin-top: 3px;" }, - }); - } - } -} diff --git a/src/ui/syncConfirmModal.ts b/src/ui/syncConfirmModal.ts new file mode 100644 index 0000000..9ef8ce2 --- /dev/null +++ b/src/ui/syncConfirmModal.ts @@ -0,0 +1,342 @@ +/** + * Unified Safe Sync Confirmation Modal for Vault Relay + * + * Consolidates Safe Pull and Safe Push preflight scan and confirmation prompts. + */ + +import { App, Modal, Notice, setIcon } from "obsidian"; +import type VaultRelayPlugin from "../main"; +import { GitHubClient } from "../github/githubClient"; +import { PullEngine } from "../sync/pullEngine"; +import { PushEngine } from "../sync/pushEngine"; +import { SyncEngine } from "../sync/syncEngine"; +import { + PullExecutionReport, + PushExecutionReport, + SyncPreviewReport, +} from "../sync/syncTypes"; +import { getStoredPat } from "../security/secretStore"; +import { sanitizeErrorMessage } from "../security/redact"; +import { computeSemanticPreview } from "../sync/semanticSummary"; +import { SyncResultModal } from "./syncResultModal"; + +export type SyncDirection = "pull" | "push"; + +export type OnSyncCompleteCallback = ( + report: PullExecutionReport | PushExecutionReport +) => Promise | void; + +export class SyncConfirmModal extends Modal { + private plugin: VaultRelayPlugin; + private direction: SyncDirection; + private previewReport: SyncPreviewReport | null = null; + private isLoading = true; + private onComplete?: OnSyncCompleteCallback; + + constructor( + app: App, + plugin: VaultRelayPlugin, + direction: SyncDirection, + onComplete?: OnSyncCompleteCallback + ) { + super(app); + this.plugin = plugin; + this.direction = direction; + this.onComplete = onComplete; + } + + public onOpen(): void { + this.modalEl.addClass("vault-relay-modal"); + this.modalEl.addClass("vault-relay-confirm-modal"); + void this.runPreflight().catch((err) => { + this.renderError(sanitizeErrorMessage(err)); + }); + } + + public onClose(): void { + const { contentEl } = this; + contentEl.empty(); + } + + private async runPreflight(): Promise { + this.isLoading = true; + this.renderLoading(); + + try { + const token = await getStoredPat(this.app, this.plugin.settings.owner, this.plugin.settings.repo); + if (!token) { + this.renderError("No GitHub PAT found in secure storage. Please configure settings first."); + return; + } + + const client = new GitHubClient({ + token, + owner: this.plugin.settings.owner, + repo: this.plugin.settings.repo, + branch: this.plugin.settings.branch, + }); + + const engine = new SyncEngine(this.app, this.plugin.settings, client); + this.previewReport = await engine.generatePreview(); + this.isLoading = false; + this.renderConfirmation(); + } catch (err) { + this.isLoading = false; + const msg = sanitizeErrorMessage(err); + this.renderError(msg); + const label = this.direction === "pull" ? "Safe Pull" : "Safe Push"; + new Notice(`${label} preflight error: ${msg}`); + } + } + + private renderLoading(): void { + const { contentEl } = this; + contentEl.empty(); + + const container = contentEl.createDiv({ + cls: "vault-relay-loading-box", + }); + + const iconDiv = container.createDiv({ cls: "vault-relay-loading-icon" }); + setIcon(iconDiv, "refresh-cw"); + iconDiv.addClass("vault-relay-spin"); + + const isPull = this.direction === "pull"; + container.createEl("h3", { text: isPull ? "Scanning remote & local state..." : "Scanning Remote & Local State..." }); + container.createEl("p", { + text: isPull + ? "Checking branch head and building safe pull plan..." + : "Checking branch HEAD and building Safe Push plan...", + cls: "vault-relay-loading-subtitle", + }); + } + + private renderError(message: string): void { + const { contentEl } = this; + contentEl.empty(); + + const title = this.direction === "pull" ? "GitHub vault relay - safe pull" : "GitHub Vault Relay - Safe Push"; + contentEl.createEl("h2", { text: title }); + + const errBox = contentEl.createDiv({ + cls: "vault-relay-error-box", + }); + errBox.createEl("h4", { + text: this.direction === "pull" ? "Preflight check failed" : "Preflight Check Failed", + cls: "vault-relay-error-title", + }); + errBox.createEl("p", { text: message, cls: "vault-relay-error-desc" }); + + const actions = contentEl.createDiv({ + cls: "vault-relay-action-row", + }); + const closeBtn = actions.createEl("button", { + text: "Close", + cls: "vault-relay-btn-lg", + }); + closeBtn.onclick = () => this.close(); + } + + private renderConfirmation(): void { + if (!this.previewReport) return; + const { contentEl } = this; + contentEl.empty(); + + const isPull = this.direction === "pull"; + const headerTitle = isPull ? "Confirm safe pull (GitHub → local)" : "Confirm Safe Push (Local → GitHub)"; + contentEl.createEl("h2", { text: headerTitle, cls: "vault-relay-dashboard-title" }); + + // Truncated tree warning + if (this.previewReport.truncatedRemoteTree) { + const warnBox = contentEl.createDiv({ + cls: "vault-relay-warning-box", + }); + warnBox.createEl("strong", { + text: isPull ? "🚫 Safe pull blocked: Truncated Git tree" : "🚫 Safe Push Blocked: Truncated Git Tree", + cls: "vault-relay-warning-title", + }); + warnBox.createEl("p", { + text: `The remote repository tree was truncated by GitHub API (>100,000 items). Safe ${isPull ? "pull" : "push"} cannot proceed to avoid partial synchronization.`, + cls: "vault-relay-warning-desc", + }); + return; + } + + if (!isPull) { + // Target repo info for Push + contentEl.createDiv({ + text: `Target: ${this.plugin.settings.owner}/${this.plugin.settings.repo} (branch: ${this.plugin.settings.branch})`, + cls: "vault-relay-repo-info", + }); + } + + // Semantic Summary + const semantic = computeSemanticPreview(this.previewReport.items); + + // Safety Description Notice + const notice = contentEl.createDiv({ + cls: "vault-relay-notice-box", + }); + + let noticeText = ""; + if (isPull) { + noticeText = "Vault Relay will pull new and updated notes from GitHub to your local Obsidian vault. Local modifications are never silently overwritten; conflicting versions are preserved safely in internal conflict storage."; + if (semantic.pullRemoveLocal > 0) { + noticeText += " Files to remove locally will be moved to trash according to your Obsidian trash settings."; + } + } else { + noticeText = "Vault Relay will upload your safe local changes (new, updated, and deleted notes) to GitHub in a single atomic commit."; + if (semantic.pushDeleteRemote > 0) { + noticeText += " The current GitHub version of deleted files will be removed in the new commit. Previous versions remain available in Git history while repository history remains available."; + } else { + noticeText += " Remote modifications and conflicts are never overwritten; optimistic concurrency guards ensure zero force-push."; + } + } + notice.createDiv({ text: noticeText }); + + // Summary of Actions + const summaryBox = contentEl.createDiv({ + cls: "vault-relay-actions-box", + }); + + summaryBox.createEl("h4", { + text: isPull ? "Planned actions summary" : "Planned Actions Summary", + cls: "vault-relay-box-title", + }); + + const list = summaryBox.createDiv({ cls: "vault-relay-actions-list" }); + + if (isPull) { + list.createDiv({ text: `• New files to create locally: ${semantic.pullCreate}` }); + list.createDiv({ text: `• Files to update locally: ${semantic.pullUpdate}` }); + + if (semantic.pullRemoveLocal > 0) { + const delRow = list.createDiv({ + cls: "vault-relay-destructive-alert-row", + }); + delRow.createDiv({ text: `āš ļø Files to remove locally: ${semantic.pullRemoveLocal}` }); + delRow.createDiv({ + text: "Files are moved to trash according to your Obsidian trash settings.", + cls: "vault-relay-destructive-alert-subtext", + }); + } + + if (semantic.pullMoves > 0) { + list.createDiv({ + text: `• Moves to apply locally: ${semantic.pullMoves}`, + cls: "vault-relay-move-highlight", + }); + } + + if (semantic.deleteConflicts > 0) { + list.createDiv({ + text: `• Delete conflicts (require review): ${semantic.deleteConflicts}`, + cls: "vault-relay-conflict-alert", + }); + } + + list.createDiv({ text: `• Potential conflicts (preserved to internal conflict storage): ${semantic.contentConflicts}` }); + list.createDiv({ text: `• Oversized files (>25 MiB, skipped): ${semantic.oversized}` }); + list.createDiv({ text: `• Local changes / notes kept untouched: ${semantic.totalPushMutations}` }); + list.createDiv({ text: `• Unchanged files: ${semantic.unchanged}` }); + } else { + list.createDiv({ text: `• New files to create on GitHub: ${semantic.pushCreate}` }); + list.createDiv({ text: `• Files to update on GitHub: ${semantic.pushUpdate}` }); + + if (semantic.pushDeleteRemote > 0) { + const delRow = list.createDiv({ + cls: "vault-relay-destructive-alert-row", + }); + delRow.createDiv({ text: `āš ļø Files to delete from GitHub: ${semantic.pushDeleteRemote}` }); + delRow.createDiv({ + text: "The current GitHub version will be removed in the new commit. Previous versions remain available in Git history while repository history remains available.", + cls: "vault-relay-destructive-alert-subtext", + }); + } + + if (semantic.pushMoves > 0) { + list.createDiv({ + text: `• Moves to commit to GitHub: ${semantic.pushMoves}`, + cls: "vault-relay-move-highlight", + }); + } + + if (semantic.deleteConflicts > 0) { + list.createDiv({ + text: `• Delete conflicts (not pushed, require review): ${semantic.deleteConflicts}`, + cls: "vault-relay-conflict-alert", + }); + } + + list.createDiv({ text: `• Conflicting files (kept untouched / not pushed): ${semantic.contentConflicts}` }); + list.createDiv({ text: `• Oversized files (>25 MiB, skipped): ${semantic.oversized}` }); + list.createDiv({ text: `• Remote notes (kept untouched): ${semantic.totalPullMutations}` }); + list.createDiv({ text: `• Unchanged files: ${semantic.unchanged}` }); + } + + // Action Buttons + const actions = contentEl.createDiv({ + cls: "vault-relay-action-row", + }); + + const cancelBtn = actions.createEl("button", { + text: "Cancel", + cls: "vault-relay-btn-lg", + }); + cancelBtn.onclick = () => this.close(); + + const confirmBtn = actions.createEl("button", { + text: isPull ? "Confirm Safe Pull" : "Confirm Safe Push", + cls: "mod-cta vault-relay-btn-lg", + }); + + confirmBtn.onclick = () => { + void (async () => { + confirmBtn.disabled = true; + confirmBtn.textContent = isPull ? "Pulling..." : "Pushing..."; + cancelBtn.disabled = true; + + try { + const token = await getStoredPat(this.app, this.plugin.settings.owner, this.plugin.settings.repo); + const client = new GitHubClient({ + token: token || "", + owner: this.plugin.settings.owner, + repo: this.plugin.settings.repo, + branch: this.plugin.settings.branch, + }); + + if (isPull) { + const pullEngine = new PullEngine(this.app, this.plugin.settings, client); + const report = await pullEngine.executeSafePull(); + + this.close(); + new SyncResultModal(this.app, report, "pull").open(); + if (this.onComplete) { + try { + await this.onComplete(report); + } catch (callbackErr) { + console.warn("[GitHub Vault Relay] onComplete refresh error:", sanitizeErrorMessage(callbackErr)); + } + } + } else { + const pushEngine = new PushEngine(this.app, this.plugin.settings, client); + const report = await pushEngine.executeSafePush(); + + this.close(); + new SyncResultModal(this.app, report, "push").open(); + if (this.onComplete) { + try { + await this.onComplete(report); + } catch (callbackErr) { + console.warn("[GitHub Vault Relay] onComplete refresh error after push:", sanitizeErrorMessage(callbackErr)); + } + } + } + } catch (err) { + new Notice(`Safe ${isPull ? "Pull" : "Push"} failed: ${sanitizeErrorMessage(err)}`); + this.close(); + } + })(); + }; + } +} diff --git a/src/ui/syncDashboardModal.ts b/src/ui/syncDashboardModal.ts index 4f6dfbe..2dd73e4 100644 --- a/src/ui/syncDashboardModal.ts +++ b/src/ui/syncDashboardModal.ts @@ -20,8 +20,7 @@ import { sanitizeErrorMessage } from "../security/redact"; import { getPhaseLabel, SyncProgressEvent } from "../sync/progressTypes"; import { computeSemanticPreview } from "../sync/semanticSummary"; import { ConflictResolutionModal } from "./conflictResolutionModal"; -import { PullConfirmModal } from "./pullConfirmModal"; -import { PushConfirmModal } from "./pushConfirmModal"; +import { SyncConfirmModal } from "./syncConfirmModal"; import { SyncPreviewModal } from "./syncPreviewModal"; export class SyncDashboardModal extends Modal { @@ -33,6 +32,7 @@ export class SyncDashboardModal extends Modal { private progressEvent: SyncProgressEvent | null = null; private showAdvanced = false; private activeFilter: SyncCategory | "ALL" = "ALL"; + private showFileInspection = false; public isModalOpen = false; constructor(app: App, plugin: VaultRelayPlugin) { @@ -92,10 +92,10 @@ export class SyncDashboardModal extends Modal { contentEl.createEl("h2", { text: "GitHub Vault Relay" }); contentEl.createDiv({ text: "Connection setup required. Please configure your GitHub Personal Access Token, Repository, and Branch in settings.", - attr: { style: "padding: 16px; background-color: var(--background-secondary); border-radius: 6px; margin: 16px 0;" }, + cls: "vault-relay-notice-box", }); - const btn = contentEl.createEl("button", { text: "Close", cls: "mod-cta" }); + const btn = contentEl.createEl("button", { text: "Close", cls: "mod-cta vault-relay-btn-lg" }); btn.onclick = () => this.close(); } @@ -106,16 +106,16 @@ export class SyncDashboardModal extends Modal { // 1. Header with Repository info const header = contentEl.createDiv({ - attr: { style: "display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 14px;" }, + cls: "vault-relay-dashboard-header", }); const titleCol = header.createDiv(); - titleCol.createEl("h2", { text: "GitHub Vault Relay", attr: { style: "margin: 0 0 4px 0;" } }); + titleCol.createEl("h2", { text: "GitHub Vault Relay", cls: "vault-relay-dashboard-title" }); titleCol.createDiv({ text: `${this.plugin.settings.owner}/${this.plugin.settings.repo} (${this.plugin.settings.branch || "main"})`, - attr: { style: "font-size: 0.85em; color: var(--text-muted); font-family: var(--font-monospace);" }, + cls: "vault-relay-repo-info", }); - const refreshBtn = header.createEl("button", { text: "↻ Refresh" }); + const refreshBtn = header.createEl("button", { text: "↻ Refresh", cls: "vault-relay-btn-lg" }); refreshBtn.disabled = this.isLoading || this.isSyncing; refreshBtn.onclick = () => { void this.runScanAndRender().catch((err) => { @@ -125,7 +125,7 @@ export class SyncDashboardModal extends Modal { if (this.isLoading) { const loadingBox = contentEl.createDiv({ - attr: { style: "padding: 40px; text-align: center; color: var(--text-muted);" }, + cls: "vault-relay-loading-box", }); loadingBox.setText("Scanning repository and local notes..."); return; @@ -153,27 +153,19 @@ export class SyncDashboardModal extends Modal { if (!hasActionableChanges) { const zeroStateCard = contentEl.createDiv({ cls: "vault-relay-zero-state", - attr: { - style: - "padding: 28px 16px; text-align: center; background-color: var(--background-secondary); border-radius: 8px; border: 1px solid var(--background-modifier-border); margin-bottom: 14px;", - }, }); zeroStateCard.createDiv({ text: "āœ“ Everything is in sync", - attr: { style: "font-size: 1.15em; font-weight: 600; color: var(--color-green, #2ecc71); margin-bottom: 6px;" }, + cls: "vault-relay-zero-state-title", }); zeroStateCard.createDiv({ text: `${unchangedCount} files synchronized`, - attr: { style: "font-size: 0.9em; color: var(--text-muted);" }, + cls: "vault-relay-zero-state-desc", }); } else { // 4. Compact Summary Cards (Max 3 cards: Changes, Moves, Conflicts) const summaryGrid = contentEl.createDiv({ cls: "vault-relay-summary-cards", - attr: { - style: - "display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 10px; margin-bottom: 14px;", - }, }); // Card 1: Changes (combines Create + Update) @@ -192,7 +184,7 @@ export class SyncDashboardModal extends Modal { "Changes", totalChanges, secondaryText, - "var(--interactive-accent)" + "changes" ); } @@ -203,7 +195,7 @@ export class SyncDashboardModal extends Modal { "Moves", totalMoves, "", - "var(--color-purple, #9b59b6)" + "moves" ); } @@ -223,7 +215,7 @@ export class SyncDashboardModal extends Modal { "Conflicts", totalConflicts, secondaryText, - "var(--color-red, #e74c3c)", + "conflicts", true ); } @@ -233,14 +225,10 @@ export class SyncDashboardModal extends Modal { if (hasDeletions) { const delBanner = contentEl.createDiv({ cls: "vault-relay-destructive-banner", - attr: { - style: - "margin-bottom: 14px; padding: 10px 14px; border-radius: 6px; background-color: rgba(231, 76, 60, 0.08); border: 1px solid var(--color-red, #e74c3c); font-size: 0.88em; line-height: 1.4;", - }, }); delBanner.createDiv({ text: "⚠ Destructive changes", - attr: { style: "font-weight: 600; color: var(--color-red, #e74c3c); margin-bottom: 3px;" }, + cls: "vault-relay-destructive-title", }); const delParts: string[] = []; @@ -253,7 +241,7 @@ export class SyncDashboardModal extends Modal { delBanner.createDiv({ text: delParts.join(" Ā· "), - attr: { style: "color: var(--text-normal);" }, + cls: "vault-relay-destructive-desc", }); } @@ -261,10 +249,6 @@ export class SyncDashboardModal extends Modal { if (sem.totalConflicts > 0) { const banner = contentEl.createDiv({ cls: "vault-relay-conflict-banner", - attr: { - style: - "display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 8px; background-color: rgba(231, 76, 60, 0.12); border: 1px solid var(--color-red, #e74c3c); border-radius: 6px; padding: 10px 14px; margin-bottom: 14px;", - }, }); const conflictText = sem.deleteConflicts > 0 && sem.contentConflicts > 0 @@ -275,12 +259,11 @@ export class SyncDashboardModal extends Modal { banner.createDiv({ text: conflictText, - attr: { style: "font-weight: 600; font-size: 0.9em; color: var(--color-red, #e74c3c);" }, + cls: "vault-relay-conflict-title", }); const reviewBtn = banner.createEl("button", { text: "Review Conflicts", - cls: "mod-warning", - attr: { style: "min-height: 44px; min-width: 44px;" }, + cls: "mod-warning vault-relay-btn-lg", }); reviewBtn.onclick = () => { new ConflictResolutionModal( @@ -299,35 +282,30 @@ export class SyncDashboardModal extends Modal { // 7. Primary Sync Action Area const syncCard = contentEl.createDiv({ cls: "vault-relay-sync-action-area", - attr: { - style: - "padding: 14px; border-radius: 8px; background-color: var(--background-secondary); border: 1px solid var(--background-modifier-border); margin-bottom: 14px; text-align: center;", - }, }); if (this.isSyncing && this.progressEvent) { const prog = this.progressEvent; syncCard.createDiv({ text: getPhaseLabel(prog.phase), - attr: { style: "font-weight: 600; font-size: 0.95em; margin-bottom: 6px; color: var(--text-normal);" }, + cls: "vault-relay-sync-phase", }); if (prog.total > 0 && prog.completed > 0) { syncCard.createDiv({ text: `${prog.completed} / ${prog.total} file(s)`, - attr: { style: "font-size: 0.85em; color: var(--text-muted); margin-bottom: 4px;" }, + cls: "vault-relay-sync-count", }); } if (prog.currentPath) { syncCard.createDiv({ text: prog.currentPath, - attr: { style: "font-size: 0.8em; font-family: var(--font-monospace); color: var(--text-muted); word-break: break-all;" }, + cls: "vault-relay-sync-path", }); } } else { const syncBtn = syncCard.createEl("button", { text: "Sync Now", - cls: "mod-cta", - attr: { style: "width: 100%; min-height: 44px; font-size: 1.05em; font-weight: 600; cursor: pointer;" }, + cls: "mod-cta vault-relay-btn-lg vault-relay-sync-btn", }); if (!hasActionableChanges) { @@ -349,21 +327,21 @@ export class SyncDashboardModal extends Modal { // 8. Utility Actions: Preview Details + Advanced Toggle const actionsRow = contentEl.createDiv({ - cls: "vault-relay-actions-row", - attr: { style: "display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 8px; margin-bottom: 12px;" }, + cls: "vault-relay-action-row vault-relay-actions-row", }); const previewBtn = actionsRow.createEl("button", { - text: "šŸ” Preview Details", - attr: { style: "min-height: 44px; min-width: 44px;" }, + text: this.showFileInspection ? "Hide File Details ā–²" : "šŸ” Preview Details ā–¼", + cls: "vault-relay-btn-lg", }); previewBtn.onclick = () => { - new SyncPreviewModal(this.app, this.plugin).open(); + this.showFileInspection = !this.showFileInspection; + this.render(); }; const advancedToggle = actionsRow.createEl("button", { text: this.showAdvanced ? "Hide Advanced Details ā–²" : "View Advanced Details ā–¼", - attr: { style: "min-height: 44px; min-width: 44px; background: transparent; border: none; font-size: 0.82em; color: var(--text-muted); cursor: pointer;" }, + cls: "vault-relay-advanced-toggle vault-relay-btn-lg", }); advancedToggle.onclick = () => { this.showAdvanced = !this.showAdvanced; @@ -373,11 +351,15 @@ export class SyncDashboardModal extends Modal { // 9. Lightweight Footer (Unchanged presentation) const footer = contentEl.createDiv({ cls: "vault-relay-dashboard-footer", - attr: { style: "text-align: center; padding: 4px 0 2px 0; font-size: 0.82em; color: var(--text-muted);" }, }); footer.setText(`${unchangedCount} files already in sync`); - // 10. Collapsible Advanced Section + // 10. Embedded File Inspection Section (Task 3) + if (this.showFileInspection) { + this.renderFileInspectionSection(contentEl); + } + + // 11. Collapsible Advanced Section if (this.showAdvanced) { this.renderAdvancedSection(contentEl); } @@ -392,33 +374,35 @@ export class SyncDashboardModal extends Modal { isWarning?: boolean ): void { const card = container.createDiv({ - attr: { - style: `padding: 12px 14px; border-radius: 8px; background-color: ${ - isWarning ? "rgba(231, 76, 60, 0.08)" : "var(--background-secondary)" - }; border: 1px solid ${ - isWarning ? "var(--color-red, #e74c3c)" : "var(--background-modifier-border)" - }; display: flex; flex-direction: column; justify-content: center; min-height: 64px; text-align: left;`, - }, + cls: "vault-relay-summary-card", }); - card.addClass("vault-relay-summary-card"); if (isWarning) { card.addClass("vault-relay-summary-card-warning"); } card.createDiv({ text: title, - attr: { style: "font-size: 0.78em; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 2px;" }, + cls: "vault-relay-card-title", }); + const countCls = + color === "changes" + ? "vault-relay-card-count-changes" + : color === "moves" + ? "vault-relay-card-count-moves" + : color === "conflicts" + ? "vault-relay-card-count-conflicts" + : ""; + card.createDiv({ text: `${primaryCount} ${primaryCount === 1 ? "file" : "files"}`, - attr: { style: `font-size: 1.22em; font-weight: 700; color: ${color}; line-height: 1.2; margin-bottom: 2px;` }, + cls: `vault-relay-card-count ${countCls}`.trim(), }); if (secondaryText) { card.createDiv({ text: secondaryText, - attr: { style: "font-size: 0.75em; color: var(--text-muted); line-height: 1.2; word-break: break-word;" }, + cls: "vault-relay-card-desc", }); } } @@ -455,28 +439,25 @@ export class SyncDashboardModal extends Modal { private renderAdvancedSection(container: HTMLElement): void { const adv = container.createDiv({ - attr: { - style: - "border-top: 1px solid var(--background-modifier-border); padding-top: 14px; margin-top: 10px;", - }, + cls: "vault-relay-advanced-section", }); - adv.createEl("h3", { text: "Engineering Diagnostics & Individual Operations", attr: { style: "font-size: 0.95em; margin-bottom: 8px;" } }); + adv.createEl("h3", { text: "Engineering Diagnostics & Individual Operations", cls: "vault-relay-advanced-title" }); // Buttons for manual Safe Pull / Safe Push - const opBtns = adv.createDiv({ attr: { style: "display: flex; gap: 8px; margin-bottom: 12px; flex-wrap: wrap;" } }); - const pullBtn = opBtns.createEl("button", { text: "Safe Pull Only", attr: { style: "min-height: 44px; min-width: 44px;" } }); + const opBtns = adv.createDiv({ cls: "vault-relay-action-row" }); + const pullBtn = opBtns.createEl("button", { text: "Safe Pull Only", cls: "vault-relay-btn-lg" }); pullBtn.onclick = () => { - new PullConfirmModal(this.app, this.plugin, () => { + new SyncConfirmModal(this.app, this.plugin, "pull", () => { void this.runScanAndRender().catch((err) => { new Notice(sanitizeErrorMessage(err)); }); }).open(); }; - const pushBtn = opBtns.createEl("button", { text: "Safe Push Only", attr: { style: "min-height: 44px; min-width: 44px;" } }); + const pushBtn = opBtns.createEl("button", { text: "Safe Push Only", cls: "vault-relay-btn-lg" }); pushBtn.onclick = () => { - new PushConfirmModal(this.app, this.plugin, () => { + new SyncConfirmModal(this.app, this.plugin, "push", () => { void this.runScanAndRender().catch((err) => { new Notice(sanitizeErrorMessage(err)); }); @@ -486,19 +467,93 @@ export class SyncDashboardModal extends Modal { // Item List if (this.report) { const itemsList = adv.createDiv({ - attr: { style: "max-height: 240px; overflow-y: auto; border: 1px solid var(--background-modifier-border); border-radius: 4px; padding: 6px;" }, + cls: "vault-relay-file-list", }); for (const item of this.report.items) { const row = itemsList.createDiv({ - attr: { style: "display: flex; justify-content: space-between; font-size: 0.8em; padding: 4px 6px; border-bottom: 1px solid var(--background-modifier-border);" }, + cls: "vault-relay-file-row", + }); + const label = item.isMove && item.movedTo + ? `Move → ${item.movedTo}` + : item.isMove && item.movedFrom + ? `Move ← ${item.movedFrom}` + : item.category; + row.createDiv({ text: item.path, cls: "vault-relay-file-path" }); + row.createDiv({ text: label, cls: "vault-relay-file-label" }); + } + } + } + + private renderFileInspectionSection(container: HTMLElement): void { + if (!this.report) return; + + const section = container.createDiv({ + cls: "vault-relay-advanced-section", + }); + + const headerRow = section.createDiv({ + cls: "vault-relay-action-row", + }); + headerRow.createEl("h3", { text: "Detailed File Inspection", cls: "vault-relay-advanced-title" }); + + const openSeparateBtn = headerRow.createEl("button", { + text: "Open Standalone Window ↗", + cls: "vault-relay-btn-lg", + }); + openSeparateBtn.onclick = () => { + new SyncPreviewModal(this.app, this.plugin).open(); + }; + + // Filter bar + const filterBar = section.createDiv({ cls: "vault-relay-action-row" }); + const filters: Array<{ label: string; value: SyncCategory | "ALL" }> = [ + { label: "All", value: "ALL" }, + { label: "Local Only", value: "LOCAL_ONLY" }, + { label: "Remote Only", value: "REMOTE_ONLY" }, + { label: "Local Changed", value: "LOCAL_CHANGED" }, + { label: "Remote Changed", value: "REMOTE_CHANGED" }, + { label: "Conflicts", value: "POTENTIAL_CONFLICT" }, + { label: "Deletes", value: "LOCAL_DELETED" }, + ]; + + for (const f of filters) { + const btn = filterBar.createEl("button", { + text: f.label, + cls: `vault-relay-btn-lg ${this.activeFilter === f.value ? "mod-cta" : ""}`.trim(), + }); + btn.onclick = () => { + this.activeFilter = f.value; + this.render(); + }; + } + + const items = this.report.items.filter((item) => { + if (this.activeFilter === "ALL") return item.category !== "UNCHANGED"; + if (this.activeFilter === "LOCAL_DELETED") { + return item.category === "LOCAL_DELETED" || item.category === "REMOTE_DELETED"; + } + return item.category === this.activeFilter; + }); + + const itemsList = section.createDiv({ + cls: "vault-relay-file-list", + }); + + if (items.length === 0) { + const emptyEl = itemsList.createDiv({ cls: "vault-relay-zero-state" }); + emptyEl.createDiv({ text: "No files match the selected filter.", cls: "vault-relay-zero-state-desc" }); + } else { + for (const item of items) { + const row = itemsList.createDiv({ + cls: "vault-relay-file-row", }); const label = item.isMove && item.movedTo ? `Move → ${item.movedTo}` : item.isMove && item.movedFrom ? `Move ← ${item.movedFrom}` : item.category; - row.createDiv({ text: item.path, attr: { style: "word-break: break-all; font-family: var(--font-monospace);" } }); - row.createDiv({ text: label, attr: { style: "font-weight: 600; color: var(--text-muted);" } }); + row.createDiv({ text: item.path, cls: "vault-relay-file-path" }); + row.createDiv({ text: label, cls: "vault-relay-file-label" }); } } } diff --git a/src/ui/syncPreviewModal.ts b/src/ui/syncPreviewModal.ts index 1f9b6aa..266e5de 100644 --- a/src/ui/syncPreviewModal.ts +++ b/src/ui/syncPreviewModal.ts @@ -13,8 +13,7 @@ import { SyncCategory, SyncPreviewItem, SyncPreviewReport } from "../sync/syncTy import { computeSemanticPreview } from "../sync/semanticSummary"; import { getStoredPat } from "../security/secretStore"; import { sanitizeErrorMessage } from "../security/redact"; -import { PullConfirmModal } from "./pullConfirmModal"; -import { PushConfirmModal } from "./pushConfirmModal"; +import { SyncConfirmModal } from "./syncConfirmModal"; export class SyncPreviewModal extends Modal { private plugin: VaultRelayPlugin; @@ -83,20 +82,17 @@ export class SyncPreviewModal extends Modal { contentEl.empty(); const container = contentEl.createDiv({ - attr: { - style: - "display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 40px 20px;", - }, + cls: "vault-relay-loading-box", }); - const iconDiv = container.createDiv({ attr: { style: "margin-bottom: 16px;" } }); + const iconDiv = container.createDiv({ cls: "vault-relay-loading-icon" }); setIcon(iconDiv, "refresh-cw"); iconDiv.addClass("vault-relay-spin"); container.createEl("h3", { text: "Scanning Vault & GitHub Repository..." }); container.createEl("p", { text: "Calculating canonical Git blob hashes and fetching remote tree from api.github.com...", - attr: { style: "color: var(--text-muted); font-size: 0.9em;" }, + cls: "vault-relay-loading-subtitle", }); } @@ -107,33 +103,30 @@ export class SyncPreviewModal extends Modal { contentEl.createEl("h2", { text: "GitHub Vault Relay - Sync Preview" }); const errBox = contentEl.createDiv({ - attr: { - style: - "padding: 16px; border-radius: 6px; border-left: 4px solid var(--color-red, #e74c3c); background-color: var(--background-secondary); margin: 20px 0;", - }, + cls: "vault-relay-error-box", }); errBox.createEl("h4", { text: "Failed to load sync preview", - attr: { style: "margin: 0 0 8px 0; color: var(--text-error, #e74c3c);" }, + cls: "vault-relay-error-title", }); errBox.createEl("p", { text: message, - attr: { style: "margin: 0; color: var(--text-muted); font-size: 0.95em;" }, + cls: "vault-relay-error-desc", }); const actions = contentEl.createDiv({ - attr: { style: "display: flex; gap: 10px; justify-content: flex-end; margin-top: 20px;" }, + cls: "vault-relay-action-row", }); - const retryBtn = actions.createEl("button", { text: "Retry Scan" }); + const retryBtn = actions.createEl("button", { text: "Retry Scan", cls: "vault-relay-btn-lg" }); retryBtn.onclick = () => { void this.runScanAndRender().catch((err) => { this.renderError(sanitizeErrorMessage(err)); }); }; - const closeBtn = actions.createEl("button", { text: "Close" }); + const closeBtn = actions.createEl("button", { text: "Close", cls: "vault-relay-btn-lg" }); closeBtn.onclick = () => this.close(); } @@ -144,33 +137,28 @@ export class SyncPreviewModal extends Modal { // Header const headerEl = contentEl.createDiv({ - attr: { - style: - "display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 12px; flex-wrap: wrap; gap: 10px;", - }, + cls: "vault-relay-preview-header", }); const titleArea = headerEl.createDiv(); - titleArea.createEl("h2", { text: "GitHub Vault Relay - Sync Preview", attr: { style: "margin: 0 0 4px 0;" } }); + titleArea.createEl("h2", { text: "GitHub Vault Relay - Sync Preview", cls: "vault-relay-preview-title" }); titleArea.createDiv({ text: `Repository: ${this.plugin.settings.owner}/${this.plugin.settings.repo} | Branch: ${this.report.branch} (${ this.report.remoteCommitSha ? this.report.remoteCommitSha.substring(0, 7) : "HEAD" })`, - attr: { style: "color: var(--text-muted); font-size: 0.85em;" }, + cls: "vault-relay-repo-info", }); const actionArea = headerEl.createDiv({ cls: "vault-relay-action-row", - attr: { style: "display: flex; gap: 8px; flex-wrap: wrap;" }, }); const pullBtn = actionArea.createEl("button", { text: "Pull Safe Changes", - cls: "mod-cta", - attr: { style: "min-height: 44px; min-width: 44px; padding: 10px 16px;" }, + cls: "mod-cta vault-relay-btn-lg", }); pullBtn.onclick = () => { - new PullConfirmModal(this.app, this.plugin, async () => { + new SyncConfirmModal(this.app, this.plugin, "pull", async () => { if (this.isModalOpen) { await this.runScanAndRender(); } @@ -179,10 +167,10 @@ export class SyncPreviewModal extends Modal { const pushBtn = actionArea.createEl("button", { text: "Push Safe Changes", - attr: { style: "min-height: 44px; min-width: 44px; padding: 10px 16px;" }, + cls: "vault-relay-btn-lg", }); pushBtn.onclick = () => { - new PushConfirmModal(this.app, this.plugin, async () => { + new SyncConfirmModal(this.app, this.plugin, "push", async () => { if (this.isModalOpen) { await this.runScanAndRender(); } @@ -191,7 +179,7 @@ export class SyncPreviewModal extends Modal { const refreshBtn = actionArea.createEl("button", { text: "Refresh", - attr: { style: "min-height: 44px; min-width: 44px; padding: 10px 16px;" }, + cls: "vault-relay-btn-lg", }); refreshBtn.onclick = () => { void this.runScanAndRender().catch((err) => { @@ -202,14 +190,11 @@ export class SyncPreviewModal extends Modal { // Truncated tree warning banner (TRUNCATED_TREE_POLICY) if (this.report.truncatedRemoteTree) { const truncBox = contentEl.createDiv({ - attr: { - style: - "padding: 10px 14px; border-radius: 4px; border-left: 4px solid var(--color-red, #e74c3c); background-color: var(--background-secondary); margin-bottom: 12px; font-size: 0.88em;", - }, + cls: "vault-relay-warning-box", }); truncBox.createEl("strong", { text: "āš ļø Remote Tree Truncated (>100,000 objects): ", - attr: { style: "color: var(--text-error, #e74c3c);" }, + cls: "vault-relay-warning-title", }); truncBox.createSpan({ text: "GitHub API truncated the remote tree. Safe Pull is blocked to prevent partial synchronization.", @@ -219,10 +204,7 @@ export class SyncPreviewModal extends Modal { // Case collisions alert banner if (this.report.caseCollisions && this.report.caseCollisions.length > 0) { const caseBox = contentEl.createDiv({ - attr: { - style: - "padding: 10px 14px; border-radius: 4px; border-left: 4px solid var(--color-orange, #e67e22); background-color: var(--background-secondary); margin-bottom: 12px; font-size: 0.88em;", - }, + cls: "vault-relay-warning-box vault-relay-warning-box-orange", }); caseBox.createEl("strong", { text: "āš ļø Case Collisions Detected: " }); caseBox.createSpan({ @@ -233,10 +215,7 @@ export class SyncPreviewModal extends Modal { // Semantic Summary Badges Grid const semantic = computeSemanticPreview(this.report.items); const statsGrid = contentEl.createDiv({ - attr: { - style: - "display: grid; grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); gap: 8px; margin-bottom: 16px;", - }, + cls: "vault-relay-stat-grid", }); this.createStatBadge(statsGrid, "Local Only", semantic.pushCreate, "var(--color-cyan, #00b4d8)", "LOCAL_ONLY"); @@ -254,10 +233,6 @@ export class SyncPreviewModal extends Modal { // Filter Bar const filterBar = contentEl.createDiv({ cls: "vault-relay-filter-bar", - attr: { - style: - "display: flex; gap: 6px; padding-bottom: 8px; margin-bottom: 12px; border-bottom: 1px solid var(--background-modifier-border); overflow-x: auto;", - }, }); const displayTotalCount = this.report.items.filter((it) => !(it.isMove && it.movedFrom)).length; @@ -292,15 +267,12 @@ export class SyncPreviewModal extends Modal { } const listContainer = contentEl.createDiv({ - attr: { - style: - "max-height: 380px; overflow-y: auto; border: 1px solid var(--background-modifier-border); border-radius: 4px; background-color: var(--background-primary);", - }, + cls: "vault-relay-file-list", }); if (filteredItems.length === 0) { const emptyBox = listContainer.createDiv({ - attr: { style: "padding: 30px; text-align: center; color: var(--text-muted); font-size: 0.9em;" }, + cls: "vault-relay-empty-state-text", }); emptyBox.setText("No items match the selected category."); } else { @@ -314,16 +286,12 @@ export class SyncPreviewModal extends Modal { parent: HTMLElement, label: string, count: number, - color: string, + _color: string, category: SyncCategory | "MOVES" ): void { const isSelected = this.activeCategoryFilter === category; const card = parent.createDiv({ - attr: { - style: `padding: 8px 10px; border-radius: 6px; background-color: var(--background-secondary); border: 1px solid ${ - isSelected ? color : "var(--background-modifier-border)" - }; cursor: pointer; text-align: center; transition: all 0.15s ease;`, - }, + cls: `vault-relay-stat-badge ${isSelected ? "vault-relay-stat-badge-selected" : ""}`.trim(), }); card.onclick = () => { @@ -333,11 +301,11 @@ export class SyncPreviewModal extends Modal { card.createDiv({ text: String(count), - attr: { style: `font-size: 1.3em; font-weight: bold; color: ${count > 0 ? color : "var(--text-muted)"};` }, + cls: "vault-relay-stat-count", }); card.createDiv({ text: label, - attr: { style: "font-size: 0.75em; color: var(--text-muted); white-space: nowrap; margin-top: 2px;" }, + cls: "vault-relay-stat-label", }); } @@ -345,12 +313,7 @@ export class SyncPreviewModal extends Modal { const isSelected = this.activeCategoryFilter === filter; const tab = parent.createEl("button", { text: label, - cls: isSelected ? "mod-cta" : "", - attr: { - style: `font-size: 0.78em; padding: 4px 8px; border-radius: 4px; white-space: nowrap; ${ - isSelected ? "" : "background: transparent; border: none; color: var(--text-muted);" - }`, - }, + cls: isSelected ? "mod-cta vault-relay-filter-tab" : "vault-relay-filter-tab", }); tab.onclick = () => { @@ -361,34 +324,28 @@ export class SyncPreviewModal extends Modal { private renderItemRow(parent: HTMLElement, item: SyncPreviewItem): void { const row = parent.createDiv({ - attr: { - style: - "display: flex; justify-content: space-between; align-items: center; padding: 8px 12px; border-bottom: 1px solid var(--background-modifier-border); font-size: 0.85em; gap: 8px;", - }, + cls: "vault-relay-file-row", }); - const left = row.createDiv({ attr: { style: "overflow: hidden; text-overflow: ellipsis; flex: 1;" } }); + const left = row.createDiv({ cls: "vault-relay-file-info" }); if (item.isMove && item.movedTo) { const moveTitle = left.createDiv({ - attr: { - style: - "font-weight: 500; word-break: break-all; overflow-wrap: anywhere; color: var(--text-normal); display: flex; flex-wrap: wrap; align-items: center; gap: 4px;", - }, + cls: "vault-relay-move-title", }); - moveTitle.createSpan({ text: item.path, attr: { style: "text-decoration: line-through; opacity: 0.75;" } }); - moveTitle.createSpan({ text: " → ", attr: { style: "font-weight: bold; color: var(--color-purple, #9b59b6);" } }); - moveTitle.createSpan({ text: item.movedTo, attr: { style: "font-weight: 600;" } }); + moveTitle.createSpan({ text: item.path, cls: "vault-relay-strikethrough" }); + moveTitle.createSpan({ text: " → ", cls: "vault-relay-move-highlight" }); + moveTitle.createSpan({ text: item.movedTo, cls: "vault-relay-file-path" }); } else { left.createDiv({ text: item.path, - attr: { style: "font-weight: 500; word-break: break-all; overflow-wrap: anywhere; color: var(--text-normal);" }, + cls: "vault-relay-file-path", }); } if (item.isMove && !item.movedTo && item.movedFrom) { left.createDiv({ text: `šŸ“¦ Move destination from: ${item.movedFrom}`, - attr: { style: "font-size: 0.8em; color: var(--color-purple, #9b59b6); margin-top: 2px; font-weight: 500;" }, + cls: "vault-relay-move-dest", }); } @@ -399,42 +356,38 @@ export class SyncPreviewModal extends Modal { ? "deleted locally, modified remotely" : "deleted remotely, modified locally" }`, - attr: { style: "font-size: 0.8em; color: var(--color-red, #e74c3c); margin-top: 2px; font-weight: 500;" }, + cls: "vault-relay-delete-conflict-text", }); } if (item.details) { left.createDiv({ text: item.details, - attr: { style: "font-size: 0.8em; color: var(--text-muted); margin-top: 2px;" }, + cls: "vault-relay-file-details", }); } if (item.isOversized) { left.createDiv({ text: "āš ļø Oversized (>25 MiB mobile safety ceiling). Will be skipped during pull.", - attr: { style: "font-size: 0.78em; color: var(--color-orange, #e67e22); margin-top: 2px;" }, + cls: "vault-relay-oversized-text", }); } if (item.unsafeReason) { left.createDiv({ text: `🚫 Path unsafe: ${item.unsafeReason}`, - attr: { style: "font-size: 0.78em; color: var(--color-red, #e74c3c); margin-top: 2px;" }, + cls: "vault-relay-unsafe-text", }); } - const right = row.createDiv({ attr: { style: "display: flex; align-items: center; gap: 8px; flex-shrink: 0;" } }); + const right = row.createDiv({ cls: "vault-relay-file-actions" }); // Category badge + const badgeCategoryCls = item.isMove ? "vault-relay-badge-MOVES" : `vault-relay-badge-${item.category}`; right.createSpan({ text: this.getCategoryLabel(item.category, item.isMove), - attr: { - style: `padding: 2px 6px; border-radius: 3px; font-size: 0.75em; font-weight: 600; background-color: ${this.getCategoryBg( - item.category, - item.isMove - )}; color: ${this.getCategoryFg(item.category, item.isMove)};`, - }, + cls: `vault-relay-badge ${badgeCategoryCls}`, }); // Hash indicator @@ -443,9 +396,7 @@ export class SyncPreviewModal extends Modal { text: `L:${item.localSha ? item.localSha.substring(0, 6) : "-"} R:${ item.remoteSha ? item.remoteSha.substring(0, 6) : "-" }`, - attr: { - style: "font-family: var(--font-monospace); font-size: 0.75em; color: var(--text-muted);", - }, + cls: "vault-relay-hash-indicator", }); } } diff --git a/src/ui/syncResultModal.ts b/src/ui/syncResultModal.ts new file mode 100644 index 0000000..cf01f20 --- /dev/null +++ b/src/ui/syncResultModal.ts @@ -0,0 +1,239 @@ +/** + * Unified Safe Sync Result Modal for Vault Relay + * + * Displays honest, detailed execution reports following Safe Pull and Safe Push operations. + */ + +import { App, Modal } from "obsidian"; +import { + PullExecutionReport, + PullFileResult, + PushExecutionReport, + PushFileResult, +} from "../sync/syncTypes"; +import { + computeSemanticPullResults, + computeSemanticPushResults, +} from "../sync/semanticSummary"; +import { SyncDirection } from "./syncConfirmModal"; + +export class SyncResultModal extends Modal { + private report: PullExecutionReport | PushExecutionReport; + private direction: SyncDirection; + + constructor( + app: App, + report: PullExecutionReport | PushExecutionReport, + direction?: SyncDirection + ) { + super(app); + this.report = report; + if (direction) { + this.direction = direction; + } else { + const isPush = "newCommitSha" in report || "pushedCreated" in (report.counts ?? {}); + this.direction = isPush ? "push" : "pull"; + } + } + + public onOpen(): void { + this.modalEl.addClass("vault-relay-modal"); + this.modalEl.addClass("vault-relay-result-modal"); + this.render(); + } + + public onClose(): void { + const { contentEl } = this; + contentEl.empty(); + } + + private render(): void { + const { contentEl } = this; + contentEl.empty(); + + const isPull = this.direction === "pull"; + contentEl.createEl("h2", { text: isPull ? "Safe Pull Results" : "Safe Push Results" }); + + // Status Banner + const counts = isPull + ? computeSemanticPullResults(this.report as PullExecutionReport) + : computeSemanticPushResults(this.report as PushExecutionReport); + + let bannerModifier = "vault-relay-result-banner-pass"; + let statusIcon = "āœ…"; + let statusTitle = "PASS"; + + if (this.report.status === "FAIL") { + bannerModifier = "vault-relay-result-banner-fail"; + statusIcon = "āŒ"; + statusTitle = "FAIL"; + } else if (this.report.status === "ABORTED") { + bannerModifier = "vault-relay-result-banner-aborted"; + statusIcon = "āš ļø"; + statusTitle = "ABORTED"; + } else if (this.report.status === "PASS_WITH_WARNINGS") { + bannerModifier = "vault-relay-result-banner-warning"; + statusIcon = "āš ļø"; + statusTitle = "PASS WITH WARNINGS"; + } + + const statusBanner = contentEl.createDiv({ + cls: `vault-relay-result-banner ${bannerModifier}`, + }); + + statusBanner.createDiv({ + text: `${statusIcon} ${statusTitle} — ${this.report.summaryMessage}`, + cls: "vault-relay-result-title", + }); + + if (!isPull) { + const pushReport = this.report as PushExecutionReport; + if (pushReport.newCommitSha) { + statusBanner.createDiv({ + text: `New Remote Commit: ${pushReport.newCommitSha.substring(0, 7)} (branch: ${pushReport.branch})`, + cls: "vault-relay-result-commit", + }); + } + } + + // Summary Stat Badges + const statsGrid = contentEl.createDiv({ + cls: "vault-relay-stat-grid", + }); + + this.createBadge(statsGrid, "Created", counts.created, "var(--color-green, #2ecc71)"); + this.createBadge(statsGrid, "Updated", counts.updated, "var(--color-cyan, #00b4d8)"); + + if (isPull) { + const pullCounts = counts as ReturnType; + if (pullCounts.removed > 0) { + this.createBadge(statsGrid, "Removed locally", pullCounts.removed, "var(--color-red, #e74c3c)"); + } + } else { + const pushCounts = counts as ReturnType; + if (pushCounts.deleted > 0) { + this.createBadge(statsGrid, "Deleted from GitHub", pushCounts.deleted, "var(--color-red, #e74c3c)"); + } + } + + if (counts.moved > 0) { + this.createBadge(statsGrid, "Moved", counts.moved, "var(--color-purple, #9b59b6)"); + } + this.createBadge(statsGrid, "Conflicts", counts.conflicts, "var(--color-red, #e74c3c)"); + this.createBadge(statsGrid, "Oversized", counts.oversized, "var(--color-purple, #9b59b6)"); + this.createBadge(statsGrid, "Unsafe", counts.unsafe, "var(--color-orange, #e67e22)"); + this.createBadge(statsGrid, "Failed", counts.failed, "var(--color-red, #c0392b)"); + + // Actions Detail List + contentEl.createEl("h4", { text: "Execution Details", cls: "vault-relay-box-title" }); + + const listContainer = contentEl.createDiv({ + cls: "vault-relay-file-list", + }); + + const activeResults = isPull + ? (this.report as PullExecutionReport).results.filter( + (r) => r.action !== "SKIP_UNCHANGED" && r.action !== "SKIP_LOCAL_ONLY" && r.action !== "SKIP_LOCAL_CHANGED" + ) + : (this.report as PushExecutionReport).results.filter( + (r) => r.action !== "SKIP_UNCHANGED" && r.action !== "SKIP_REMOTE_ONLY" && r.action !== "SKIP_REMOTE_CHANGED" + ); + + if (activeResults.length === 0) { + const emptyEl = listContainer.createDiv({ + cls: "vault-relay-empty-state-text", + }); + const emptyMsg = isPull + ? "No file changes required execution. Vault is fully up to date." + : "No local changes required push. Remote repository is up to date."; + emptyEl.setText(emptyMsg); + } else { + for (const res of activeResults) { + this.renderRow(listContainer, res, isPull); + } + } + + // Close Action + const actions = contentEl.createDiv({ + cls: "vault-relay-action-row", + }); + + const closeBtn = actions.createEl("button", { + text: "Done", + cls: "mod-cta vault-relay-btn-lg", + }); + closeBtn.onclick = () => this.close(); + } + + private createBadge(parent: HTMLElement, label: string, count: number, _color: string): void { + const card = parent.createDiv({ + cls: "vault-relay-stat-badge", + }); + + card.createDiv({ + text: String(count), + cls: "vault-relay-stat-count", + }); + card.createDiv({ + text: label, + cls: "vault-relay-stat-label", + }); + } + + private renderRow( + parent: HTMLElement, + res: PullFileResult | PushFileResult, + isPull: boolean + ): void { + const row = parent.createDiv({ + cls: "vault-relay-file-row", + }); + + const header = row.createDiv({ + cls: "vault-relay-file-info", + }); + + header.createDiv({ + text: res.path, + cls: "vault-relay-file-path", + }); + + const badgeGroup = row.createDiv({ cls: "vault-relay-badge-group" }); + + if (res.action === "PULL_DELETE") { + badgeGroup.createSpan({ + text: "Removed locally", + cls: "vault-relay-badge vault-relay-badge-delete", + }); + } else if (res.action === "PUSH_DELETE") { + badgeGroup.createSpan({ + text: "Deleted from GitHub", + cls: "vault-relay-badge vault-relay-badge-delete", + }); + } + + let statusCls = "vault-relay-badge-status-success"; + if (res.status === "FAILED") statusCls = "vault-relay-badge-status-failed"; + if (res.status === "CONFLICT_PRESERVED" || res.status === "BLOCKED_CONFLICT") statusCls = "vault-relay-badge-status-conflict"; + if (res.status === "SKIPPED") statusCls = "vault-relay-badge-status-skipped"; + + badgeGroup.createSpan({ + text: res.status, + cls: `vault-relay-badge ${statusCls}`, + }); + + if (res.message) { + row.createDiv({ + text: res.message, + cls: "vault-relay-row-message", + }); + } + + if (isPull && "conflictPath" in res && res.conflictPath) { + row.createDiv({ + text: `Preserved conflict file: ${res.conflictPath}`, + cls: "vault-relay-conflict-preserved-path", + }); + } + } +} diff --git a/styles.css b/styles.css index 0567177..42042db 100644 --- a/styles.css +++ b/styles.css @@ -113,6 +113,7 @@ } .vault-relay-action-row, +.vault-relay-actions-row, .vault-relay-filter-bar { flex-wrap: wrap; max-width: 100%; @@ -191,12 +192,668 @@ word-break: break-all; } +/* --- Callouts & Banners --- */ +.vault-relay-notice-box { + background-color: var(--background-secondary); + border-left: 4px solid var(--interactive-accent); + padding: 12px 16px; + margin-bottom: 20px; + border-radius: 4px; + line-height: 1.4; + font-size: 0.88em; +} + +.vault-relay-notice-list { + margin: 6px 0 0 18px; + font-size: 0.9em; + line-height: 1.5; +} + +.vault-relay-error-box { + padding: 16px; + border-radius: 6px; + border-left: 4px solid var(--color-red, #e74c3c); + background-color: var(--background-secondary); + margin: 16px 0; +} + +.vault-relay-error-title { + margin: 0 0 8px 0; + color: var(--text-error, #e74c3c); +} + +.vault-relay-error-desc { + margin: 0; + color: var(--text-muted); + font-size: 0.95em; +} + +.vault-relay-warning-box { + padding: 12px 14px; + border-radius: 6px; + border-left: 4px solid var(--color-red, #e74c3c); + background-color: var(--background-secondary); + margin-bottom: 14px; + font-size: 0.88em; + line-height: 1.4; +} + +.vault-relay-warning-box-orange { + border-left-color: var(--color-orange, #e67e22); +} + +.vault-relay-warning-title { + color: var(--text-error, #e74c3c); + margin-right: 4px; +} + +.vault-relay-warning-desc { + margin: 4px 0 0 0; + font-size: 0.88em; + color: var(--text-muted); +} + +/* --- Dialog Typography --- */ +.vault-relay-dialog-desc { + line-height: 1.5; + margin-bottom: 12px; +} + +.vault-relay-dialog-subtext { + color: var(--text-muted); + font-size: 0.9em; + margin-bottom: 20px; + line-height: 1.4; +} + +/* --- Loading Box --- */ +.vault-relay-loading-box { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 36px 20px; + text-align: center; +} + +.vault-relay-loading-icon { + margin-bottom: 16px; +} + +.vault-relay-loading-subtitle { + color: var(--text-muted); + font-size: 0.9em; + margin-top: 4px; +} + +/* --- Planned Actions & Destructive Alerts --- */ +.vault-relay-actions-box { + border: 1px solid var(--background-modifier-border); + border-radius: 6px; + padding: 12px 16px; + background-color: var(--background-secondary-alt, var(--background-secondary)); + margin-bottom: 20px; +} + +.vault-relay-box-title { + margin: 0 0 10px 0; +} + +.vault-relay-actions-list { + font-size: 0.9em; + line-height: 1.6; +} + +.vault-relay-destructive-alert-row { + color: var(--color-red, #e74c3c); + font-weight: 600; + padding: 4px 8px; + margin: 4px 0; + border-radius: 4px; + background-color: rgba(231, 76, 60, 0.1); + border-left: 3px solid var(--color-red, #e74c3c); +} + +.vault-relay-destructive-alert-subtext { + font-size: 0.82em; + font-weight: normal; + color: var(--text-muted); + margin-top: 2px; +} + +.vault-relay-move-highlight { + color: var(--color-purple, #9b59b6); + font-weight: 500; +} + +.vault-relay-conflict-alert { + color: var(--color-red, #e74c3c); + font-weight: 500; +} + +/* --- Conflict Cards & Actions --- */ +.vault-relay-conflict-intro { + margin-bottom: 16px; + font-size: 0.88em; + color: var(--text-muted); + line-height: 1.4; +} + +.vault-relay-conflict-list { + max-height: 420px; + overflow-y: auto; + overscroll-behavior: contain; + -webkit-overflow-scrolling: touch; + display: flex; + flex-direction: column; + gap: 12px; +} + +.vault-relay-conflict-card { + border: 1px solid var(--background-modifier-border); + border-radius: 8px; + padding: 14px; + background-color: var(--background-secondary); +} + +.vault-relay-conflict-path { + font-weight: 600; + word-break: break-all; + margin-bottom: 6px; + font-size: 0.95em; + color: var(--text-normal); +} + +.vault-relay-conflict-delete-desc { + margin-bottom: 8px; + font-size: 0.85em; + line-height: 1.4; +} + +.vault-relay-conflict-delete-badge { + color: var(--color-red, #e74c3c); + font-weight: 600; + margin-bottom: 4px; +} + +.vault-relay-conflict-explanation { + color: var(--text-muted); + font-size: 0.85em; + white-space: pre-line; +} + +.vault-relay-conflict-note { + font-size: 0.8em; + color: var(--text-muted); + margin-bottom: 10px; +} + +.vault-relay-conflict-status { + display: none; + font-size: 0.85em; + margin-bottom: 10px; + padding: 6px 10px; + border-radius: 4px; + background-color: var(--background-primary); +} + +.vault-relay-conflict-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 10px; +} + +/* --- Stat Badges & Grids --- */ +.vault-relay-stat-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); + gap: 8px; + margin-bottom: 16px; +} + +.vault-relay-stat-badge { + padding: 8px 10px; + border-radius: 6px; + background-color: var(--background-secondary); + border: 1px solid var(--background-modifier-border); + text-align: center; + cursor: pointer; + transition: all 0.15s ease; +} + +.vault-relay-stat-badge-selected { + border-color: var(--interactive-accent); +} + +.vault-relay-stat-count { + font-size: 1.22em; + font-weight: bold; +} + +.vault-relay-stat-label { + font-size: 0.75em; + color: var(--text-muted); + margin-top: 2px; +} + +/* --- File List & Rows --- */ +.vault-relay-file-list { + max-height: min(45vh, 320px); + overflow-y: auto; + overscroll-behavior: contain; + -webkit-overflow-scrolling: touch; + border: 1px solid var(--background-modifier-border); + border-radius: 6px; + background-color: var(--background-primary); +} + +.vault-relay-file-row { + padding: 8px 12px; + border-bottom: 1px solid var(--background-modifier-border); + font-size: 0.85em; + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.vault-relay-file-info { + overflow: hidden; + text-overflow: ellipsis; + flex: 1; + min-width: 0; +} + +.vault-relay-file-actions { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.vault-relay-file-path { + font-weight: 500; + word-break: break-all; + color: var(--text-normal); + flex: 1; + min-width: 0; +} + +.vault-relay-file-label { + font-weight: 600; + color: var(--text-muted); +} + +.vault-relay-file-details { + font-size: 0.8em; + color: var(--text-muted); + margin-top: 4px; + width: 100%; +} + +.vault-relay-repo-info { + font-size: 0.85em; + color: var(--text-muted); + font-family: var(--font-monospace); + margin-bottom: 12px; +} + +.vault-relay-empty-state-text { + padding: 24px; + text-align: center; + color: var(--text-muted); + font-size: 0.9em; +} + +/* --- Result Modal & Status Banners --- */ +.vault-relay-result-banner { + padding: 12px 16px; + border-radius: 6px; + margin-bottom: 16px; + border-left: 4px solid var(--interactive-accent); + background-color: var(--background-secondary); +} + +.vault-relay-result-banner-pass { + background-color: rgba(46, 204, 113, 0.15); + border-left-color: var(--color-green, #2ecc71); +} + +.vault-relay-result-banner-fail { + background-color: rgba(231, 76, 60, 0.15); + border-left-color: var(--color-red, #e74c3c); +} + +.vault-relay-result-banner-aborted { + background-color: rgba(230, 126, 34, 0.15); + border-left-color: var(--color-orange, #e67e22); +} + +.vault-relay-result-banner-warning { + background-color: rgba(241, 196, 15, 0.15); + border-left-color: var(--color-yellow, #f39c12); +} + +.vault-relay-result-title { + font-weight: 600; + margin-bottom: 4px; +} + +.vault-relay-result-commit { + font-family: var(--font-monospace); + font-size: 0.85em; + color: var(--text-muted); +} + +.vault-relay-badge-group { + display: flex; + gap: 6px; + align-items: center; +} + +.vault-relay-badge { + padding: 2px 6px; + border-radius: 3px; + font-size: 0.72em; + font-weight: 600; + background-color: rgba(0, 0, 0, 0.1); +} + +.vault-relay-badge-delete { + background-color: rgba(231, 76, 60, 0.15); + color: var(--color-red, #e74c3c); +} + +.vault-relay-badge-status-success { + background-color: rgba(46, 204, 113, 0.15); + color: #27ae60; +} + +.vault-relay-badge-status-failed { + background-color: rgba(231, 76, 60, 0.15); + color: #e74c3c; +} + +.vault-relay-badge-status-conflict { + background-color: rgba(230, 126, 34, 0.15); + color: #e67e22; +} + +.vault-relay-badge-status-skipped { + background-color: rgba(149, 165, 166, 0.15); + color: #95a5a6; +} + +.vault-relay-row-message { + font-size: 0.8em; + color: var(--text-muted); + margin-top: 3px; +} + +.vault-relay-conflict-preserved-path { + font-size: 0.78em; + color: var(--color-orange, #e67e22); + font-family: var(--font-monospace); + margin-top: 2px; +} + +/* --- Dashboard & Preview Components --- */ +.vault-relay-dashboard-header, +.vault-relay-preview-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 14px; + flex-wrap: wrap; + gap: 10px; +} + +.vault-relay-dashboard-title, +.vault-relay-preview-title { + margin: 0 0 4px 0; +} + +.vault-relay-zero-state-title { + font-size: 1.15em; + font-weight: 600; + color: var(--color-green, #2ecc71); + margin-bottom: 6px; +} + +.vault-relay-zero-state-desc { + font-size: 0.9em; + color: var(--text-muted); +} + +.vault-relay-destructive-title { + font-weight: 600; + color: var(--color-red, #e74c3c); + margin-bottom: 3px; +} + +.vault-relay-destructive-desc { + color: var(--text-normal); +} + +.vault-relay-conflict-banner { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 8px; + background-color: rgba(231, 76, 60, 0.12); + border: 1px solid var(--color-red, #e74c3c); + border-radius: 6px; + padding: 10px 14px; + margin-bottom: 14px; +} + +.vault-relay-conflict-title { + font-weight: 600; + font-size: 0.9em; + color: var(--color-red, #e74c3c); +} + +.vault-relay-sync-action-area { + padding: 14px; + border-radius: 8px; + background-color: var(--background-secondary); + border: 1px solid var(--background-modifier-border); + margin-bottom: 14px; + text-align: center; +} + +.vault-relay-sync-phase { + font-weight: 600; + font-size: 0.95em; + margin-bottom: 6px; + color: var(--text-normal); +} + +.vault-relay-sync-count { + font-size: 0.85em; + color: var(--text-muted); + margin-bottom: 4px; +} + +.vault-relay-sync-path { + font-size: 0.8em; + font-family: var(--font-monospace); + color: var(--text-muted); + word-break: break-all; +} + +.vault-relay-sync-btn { + width: 100%; + font-size: 1.05em; + font-weight: 600; + cursor: pointer; +} + +.vault-relay-advanced-toggle { + background: transparent; + border: none; + font-size: 0.82em; + color: var(--text-muted); + cursor: pointer; +} + +.vault-relay-card-title { + font-size: 0.78em; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 2px; +} + +.vault-relay-card-count { + font-size: 1.22em; + font-weight: 700; + line-height: 1.2; + margin-bottom: 2px; +} + +.vault-relay-card-count-changes { + color: var(--interactive-accent); +} + +.vault-relay-card-count-moves { + color: var(--color-purple, #9b59b6); +} + +.vault-relay-card-count-conflicts { + color: var(--color-red, #e74c3c); +} + +.vault-relay-card-desc { + font-size: 0.75em; + color: var(--text-muted); + line-height: 1.2; + word-break: break-word; +} + +.vault-relay-advanced-section { + border-top: 1px solid var(--background-modifier-border); + padding-top: 14px; + margin-top: 10px; +} + +.vault-relay-advanced-title { + font-size: 0.95em; + margin-bottom: 8px; +} + +.vault-relay-filter-tab { + font-size: 0.78em; + padding: 4px 8px; + border-radius: 4px; + white-space: nowrap; + background: transparent; + border: none; + color: var(--text-muted); +} + +.vault-relay-move-title { + font-weight: 500; + word-break: break-all; + overflow-wrap: anywhere; + color: var(--text-normal); + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px; +} + +.vault-relay-strikethrough { + text-decoration: line-through; + opacity: 0.75; +} + +.vault-relay-move-dest { + font-size: 0.8em; + color: var(--color-purple, #9b59b6); + margin-top: 2px; + font-weight: 500; +} + +.vault-relay-delete-conflict-text { + font-size: 0.8em; + color: var(--color-red, #e74c3c); + margin-top: 2px; + font-weight: 500; +} + +.vault-relay-oversized-text { + font-size: 0.78em; + color: var(--color-orange, #e67e22); + margin-top: 2px; +} + +.vault-relay-unsafe-text { + font-size: 0.78em; + color: var(--color-red, #e74c3c); + margin-top: 2px; +} + +.vault-relay-hash-indicator { + font-family: var(--font-monospace); + font-size: 0.75em; + color: var(--text-muted); +} + +/* Preview Category Badges */ +.vault-relay-badge-LOCAL_ONLY { + background-color: rgba(0, 180, 216, 0.15); + color: #0077b6; +} +.vault-relay-badge-REMOTE_ONLY { + background-color: rgba(0, 119, 182, 0.15); + color: #023e8a; +} +.vault-relay-badge-LOCAL_CHANGED { + background-color: rgba(243, 156, 18, 0.15); + color: #d35400; +} +.vault-relay-badge-REMOTE_CHANGED { + background-color: rgba(155, 89, 182, 0.15); + color: #8e44ad; +} +.vault-relay-badge-LOCAL_DELETED { + background-color: rgba(231, 76, 60, 0.15); + color: #e74c3c; +} +.vault-relay-badge-REMOTE_DELETED { + background-color: rgba(232, 67, 147, 0.15); + color: #e84393; +} +.vault-relay-badge-POTENTIAL_CONFLICT, +.vault-relay-badge-DELETE_CONFLICT { + background-color: rgba(231, 76, 60, 0.15); + color: #c0392b; +} +.vault-relay-badge-DELETED { + background-color: rgba(127, 140, 141, 0.15); + color: #7f8c8d; +} +.vault-relay-badge-UNCHANGED { + background-color: rgba(46, 204, 113, 0.15); + color: #27ae60; +} +.vault-relay-badge-MOVES { + background-color: rgba(155, 89, 182, 0.15); + color: #8e44ad; +} + @media (max-width: 480px) { .modal.vault-relay-modal, .vault-relay-modal { width: calc(100vw - 12px); padding-left: 10px; padding-right: 10px; + padding-bottom: max(16px, env(safe-area-inset-bottom)); } .vault-relay-summary-cards { @@ -204,6 +861,11 @@ gap: 8px; } + .vault-relay-stat-grid { + grid-template-columns: repeat(2, 1fr); + gap: 6px; + } + .vault-relay-modal .setting-item, .vault-relay-settings .setting-item { align-items: stretch; @@ -217,9 +879,33 @@ width: 100%; } + /* Full-width stacked buttons on narrow touchscreens */ + .vault-relay-action-row, + .vault-relay-actions-row, + .vault-relay-conflict-actions { + flex-direction: column-reverse; + gap: 8px; + width: 100%; + } + .vault-relay-action-row button { width: 100%; - margin-bottom: 6px; + min-height: 44px; + margin-bottom: 0; + } + + .vault-relay-actions-row button, + .vault-relay-conflict-actions button, + .vault-relay-modal .setting-item-control button, + .vault-relay-settings .setting-item-control button { + width: 100%; + min-height: 44px; + margin-bottom: 0; } -} + .vault-relay-file-row { + flex-direction: column; + align-items: flex-start; + gap: 4px; + } +} diff --git a/tests/__mocks__/obsidian.ts b/tests/__mocks__/obsidian.ts index d45503d..9e131c1 100644 --- a/tests/__mocks__/obsidian.ts +++ b/tests/__mocks__/obsidian.ts @@ -417,11 +417,15 @@ export class MockElement { } addClass(cls: string): void { - this.classes.add(cls); + for (const c of cls.split(/\s+/)) { + if (c) this.classes.add(c); + } } removeClass(cls: string): void { - this.classes.delete(cls); + for (const c of cls.split(/\s+/)) { + if (c) this.classes.delete(c); + } } hasClass(cls: string): boolean { diff --git a/tests/c5MobileAccessibility.test.ts b/tests/c5MobileAccessibility.test.ts index 4f25b2c..1f6a9be 100644 --- a/tests/c5MobileAccessibility.test.ts +++ b/tests/c5MobileAccessibility.test.ts @@ -13,10 +13,8 @@ describe("C5-MOBILE: mobile-first static and accessibility audit", () => { const modalFiles = [ "src/ui/clearTokenConfirmModal.ts", "src/ui/conflictResolutionModal.ts", - "src/ui/pullConfirmModal.ts", - "src/ui/pullResultModal.ts", - "src/ui/pushConfirmModal.ts", - "src/ui/pushResultModal.ts", + "src/ui/syncConfirmModal.ts", + "src/ui/syncResultModal.ts", "src/ui/syncDashboardModal.ts", "src/ui/syncPreviewModal.ts", ]; @@ -44,15 +42,14 @@ describe("C5-MOBILE: mobile-first static and accessibility audit", () => { it("C5-MOBILE-003: disabled and loading states remain visible and plainly labeled", () => { const css = read("styles.css"); const conflictUi = read("src/ui/conflictResolutionModal.ts"); - const pullUi = read("src/ui/pullConfirmModal.ts"); - const pushUi = read("src/ui/pushConfirmModal.ts"); + const syncConfirmUi = read("src/ui/syncConfirmModal.ts"); expect(css).toMatch(/button:disabled[\s\S]*opacity:\s*0\.55/); expect(conflictUi).toContain("Pushing local version..."); expect(conflictUi).toContain("Pulling remote version..."); expect(conflictUi).toContain("Saving remote copy..."); - expect(pullUi).toContain('confirmBtn.textContent = "Pulling..."'); - expect(pushUi).toContain('confirmBtn.textContent = "Pushing..."'); + expect(syncConfirmUi).toContain('"Pulling..."'); + expect(syncConfirmUi).toContain('"Pushing..."'); }); it("C5-MOBILE-004: primary conflict UX uses clear choices without SHA jargon", () => { diff --git a/tests/c6RuntimeUI.test.ts b/tests/c6RuntimeUI.test.ts index 03e4c3c..dabf58f 100644 --- a/tests/c6RuntimeUI.test.ts +++ b/tests/c6RuntimeUI.test.ts @@ -10,10 +10,8 @@ import { App } from "obsidian"; import fs from "node:fs"; import path from "node:path"; import VaultRelayPlugin from "../src/main"; -import { PullConfirmModal } from "../src/ui/pullConfirmModal"; -import { PushConfirmModal } from "../src/ui/pushConfirmModal"; -import { PullResultModal } from "../src/ui/pullResultModal"; -import { PushResultModal } from "../src/ui/pushResultModal"; +import { SyncConfirmModal } from "../src/ui/syncConfirmModal"; +import { SyncResultModal } from "../src/ui/syncResultModal"; import { SyncDashboardModal } from "../src/ui/syncDashboardModal"; import { ConflictResolutionModal } from "../src/ui/conflictResolutionModal"; import { SyncPreviewModal } from "../src/ui/syncPreviewModal"; @@ -120,7 +118,7 @@ describe("C6 — Runtime UI Tests (C6-UI-DEL-001..006, C6-UI-MOVE-001..003, C6-U { REMOTE_DELETED: 2 } ); - const modal = new PullConfirmModal(app, plugin); + const modal = new SyncConfirmModal(app, plugin, "pull"); const internal = modal as unknown as ModalInternalWithPreview; internal.previewReport = report; internal.isLoading = false; @@ -143,7 +141,7 @@ describe("C6 — Runtime UI Tests (C6-UI-DEL-001..006, C6-UI-MOVE-001..003, C6-U { LOCAL_DELETED: 3 } ); - const modal = new PushConfirmModal(app, plugin); + const modal = new SyncConfirmModal(app, plugin, "push"); const internal = modal as unknown as ModalInternalWithPreview; internal.previewReport = report; internal.isLoading = false; @@ -182,7 +180,7 @@ describe("C6 — Runtime UI Tests (C6-UI-DEL-001..006, C6-UI-MOVE-001..003, C6-U ], }; - const modal = new PullResultModal(app, report); + const modal = new SyncResultModal(app, report, "pull"); modal.onOpen(); const text = getAllText(modal.contentEl as unknown as MockElement); @@ -214,7 +212,7 @@ describe("C6 — Runtime UI Tests (C6-UI-DEL-001..006, C6-UI-MOVE-001..003, C6-U results: [{ path: "oldFile.md", action: "PUSH_DELETE", status: "SUCCESS" }], }; - const modal = new PushResultModal(app, report); + const modal = new SyncResultModal(app, report, "push"); modal.onOpen(); const text = getAllText(modal.contentEl as unknown as MockElement); @@ -368,14 +366,14 @@ describe("C6 — Runtime UI Tests (C6-UI-DEL-001..006, C6-UI-MOVE-001..003, C6-U expect(css).toMatch(/min-height:\s*44px/); expect(css).toMatch(/min-width:\s*44px/); - const pullModal = new PullConfirmModal(app, plugin); + const pullModal = new SyncConfirmModal(app, plugin, "pull"); pullModal.onOpen(); const pullButtons = (pullModal.contentEl as unknown as MockElement).findAll((el: MockElement) => el.tag === "button"); for (const b of pullButtons) { expect(b.attributes.style || "").toMatch(/min-height:\s*44px/); } - const pushModal = new PushConfirmModal(app, plugin); + const pushModal = new SyncConfirmModal(app, plugin, "push"); pushModal.onOpen(); const pushButtons = (pushModal.contentEl as unknown as MockElement).findAll((el: MockElement) => el.tag === "button"); for (const b of pushButtons) { @@ -401,7 +399,7 @@ describe("C6 — Runtime UI Tests (C6-UI-DEL-001..006, C6-UI-MOVE-001..003, C6-U ); // 1. Pull Confirm: no destructive warning section - const pullModal = new PullConfirmModal(app, plugin); + const pullModal = new SyncConfirmModal(app, plugin, "pull"); const pullInternal = pullModal as unknown as ModalInternalWithPreview; pullInternal.previewReport = normalReport; pullInternal.isLoading = false; @@ -409,7 +407,7 @@ describe("C6 — Runtime UI Tests (C6-UI-DEL-001..006, C6-UI-MOVE-001..003, C6-U expect(getAllText(pullModal.contentEl as unknown as MockElement)).not.toContain("Files to remove locally"); // 2. Push Confirm: no destructive warning section - const pushModal = new PushConfirmModal(app, plugin); + const pushModal = new SyncConfirmModal(app, plugin, "push"); const pushInternal = pushModal as unknown as ModalInternalWithPreview; pushInternal.previewReport = normalReport; pushInternal.isLoading = false; @@ -417,7 +415,7 @@ describe("C6 — Runtime UI Tests (C6-UI-DEL-001..006, C6-UI-MOVE-001..003, C6-U expect(getAllText(pushModal.contentEl as unknown as MockElement)).not.toContain("Files to delete from GitHub"); // 3. Pull Result with 0 deletes: no "Removed locally" badge - const pullResModal = new PullResultModal(app, { + const pullResModal = new SyncResultModal(app, { status: "PASS", branch: "main", timestamp: Date.now(), @@ -436,12 +434,12 @@ describe("C6 — Runtime UI Tests (C6-UI-DEL-001..006, C6-UI-MOVE-001..003, C6-U failed: 0, }, results: [], - }); + }, "pull"); pullResModal.onOpen(); expect(getAllText(pullResModal.contentEl as unknown as MockElement)).not.toContain("Removed locally"); // 4. Push Result with 0 deletes: no "Deleted from GitHub" badge - const pushResModal = new PushResultModal(app, { + const pushResModal = new SyncResultModal(app, { status: "PASS", branch: "main", newCommitSha: "c2", @@ -461,7 +459,7 @@ describe("C6 — Runtime UI Tests (C6-UI-DEL-001..006, C6-UI-MOVE-001..003, C6-U failed: 0, }, results: [], - }); + }, "push"); pushResModal.onOpen(); expect(getAllText(pushResModal.contentEl as unknown as MockElement)).not.toContain("Deleted from GitHub"); diff --git a/tests/communityCompliance.test.ts b/tests/communityCompliance.test.ts index e1d662d..68867ea 100644 --- a/tests/communityCompliance.test.ts +++ b/tests/communityCompliance.test.ts @@ -49,10 +49,8 @@ describe("Obsidian Community Directory Compliance Suite (COMMUNITY-001..010)", ( "src/settings.ts", "src/ui/clearTokenConfirmModal.ts", "src/ui/conflictResolutionModal.ts", - "src/ui/pullConfirmModal.ts", - "src/ui/pullResultModal.ts", - "src/ui/pushConfirmModal.ts", - "src/ui/pushResultModal.ts", + "src/ui/syncConfirmModal.ts", + "src/ui/syncResultModal.ts", "src/ui/syncDashboardModal.ts", "src/ui/syncPreviewModal.ts", ]; @@ -151,8 +149,7 @@ describe("Obsidian Community Directory Compliance Suite (COMMUNITY-001..010)", ( it("COMMUNITY-016: async UI modal callbacks explicitly handle rejection", () => { const modalFiles = [ - "src/ui/pullConfirmModal.ts", - "src/ui/pushConfirmModal.ts", + "src/ui/syncConfirmModal.ts", "src/ui/syncPreviewModal.ts", "src/ui/syncDashboardModal.ts", ]; @@ -212,8 +209,8 @@ describe("Obsidian Community Directory Compliance Suite (COMMUNITY-001..010)", ( "src/settings.ts", "src/ui/clearTokenConfirmModal.ts", "src/ui/conflictResolutionModal.ts", - "src/ui/pullConfirmModal.ts", - "src/ui/pushConfirmModal.ts", + "src/ui/syncConfirmModal.ts", + "src/ui/syncResultModal.ts", "src/ui/syncDashboardModal.ts", "src/ui/syncPreviewModal.ts", ]; diff --git a/tests/empiricalChallengerM1.test.ts b/tests/empiricalChallengerM1.test.ts new file mode 100644 index 0000000..8db6a01 --- /dev/null +++ b/tests/empiricalChallengerM1.test.ts @@ -0,0 +1,941 @@ +/** + * Empirical Challenger Test Suite for Milestone 1: Unified Modal Architectures + * + * Adversarial and empirical verification of: + * 1. SyncConfirmModal (pull and push branches, preflight, actions summary, button lifecycle, error handling) + * 2. SyncResultModal (pull and push branches, direction inference, status banners, badges, filtering, teardown) + * 3. Total removal and absence of legacy modal files across the repository + * 4. Security invariants (zero PAT leakage, sanitized messages, mobile accessibility classes) + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; +import VaultRelayPlugin from "../src/main"; +import { SyncConfirmModal } from "../src/ui/syncConfirmModal"; +import { SyncResultModal } from "../src/ui/syncResultModal"; +import { PullEngine } from "../src/sync/pullEngine"; +import { PushEngine } from "../src/sync/pushEngine"; +import { SyncEngine } from "../src/sync/syncEngine"; +import { setStoredPat } from "../src/security/secretStore"; +import { App } from "obsidian"; +import { MockElement } from "./__mocks__/obsidian"; +import { + PullExecutionReport, + PushExecutionReport, + SyncPreviewItem, + SyncPreviewReport, + SyncCategoryCounts, +} from "../src/sync/syncTypes"; + +const repoRoot = process.cwd(); + +function asMock(el: unknown): MockElement { + return el as unknown as MockElement; +} + +function isModalOpen(modal: unknown): boolean { + return (modal as { isOpen?: boolean }).isOpen ?? false; +} + +function getAllText(el: unknown): string { + const mockEl = el as MockElement; + let res = mockEl?.textContent || ""; + if (mockEl?.children) { + for (const child of mockEl.children) { + res += " " + getAllText(child); + } + } + return res; +} + +function findButtons(el: unknown): MockElement[] { + const mockEl = el as MockElement; + return mockEl.findAll ? mockEl.findAll((child) => child.tag === "button") : []; +} + +function makeEmptyCounts(): SyncCategoryCounts { + return { + LOCAL_ONLY: 0, + LOCAL_CHANGED: 0, + LOCAL_DELETED: 0, + REMOTE_ONLY: 0, + REMOTE_CHANGED: 0, + REMOTE_DELETED: 0, + POTENTIAL_CONFLICT: 0, + DELETE_CONFLICT: 0, + DELETED: 0, + UNCHANGED: 0, + OVERSIZED: 0, + UNSAFE: 0, + }; +} + +function makeMockPreviewReport( + items: SyncPreviewItem[], + countsPartial: Partial = {}, + truncated = false +): SyncPreviewReport { + return { + timestamp: Date.now(), + branch: "main", + items, + counts: { ...makeEmptyCounts(), ...countsPartial }, + totalScannedLocal: items.length, + totalScannedRemote: items.length, + truncatedRemoteTree: truncated, + caseCollisions: [], + }; +} + +describe("Empirical Challenger M1: Unified Modal Architectures Verification", () => { + let app: App; + let plugin: VaultRelayPlugin; + + beforeEach(() => { + vi.restoreAllMocks(); + app = new App(); + plugin = new VaultRelayPlugin(app, { + id: "github-vault-relay", + name: "GitHub Vault Relay", + version: "1.1.0", + minAppVersion: "1.0.0", + author: "Test", + description: "Test", + }); + plugin.settings = { + owner: "octocat", + repo: "notes", + branch: "main", + excludedPaths: [".obsidian/", ".git/", "_fit/"], + }; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // ========================================================================= + // 1. LEGACY MODAL REMOVAL AUDIT + // ========================================================================= + describe("Audit 1: Total Decommissioning of Legacy Modals", () => { + it("CHALLENGE-M1-LEGACY-001: Legacy modal source files are physically absent from disk", () => { + const legacyFiles = [ + "src/ui/pullConfirmModal.ts", + "src/ui/pushConfirmModal.ts", + "src/ui/pullResultModal.ts", + "src/ui/pushResultModal.ts", + ]; + + for (const relPath of legacyFiles) { + const fullPath = path.join(repoRoot, relPath); + expect(fs.existsSync(fullPath), `File ${relPath} should NOT exist on disk`).toBe(false); + } + }); + + it("CHALLENGE-M1-LEGACY-002: Zero imports or references to legacy classes in src/", () => { + const srcDir = path.join(repoRoot, "src"); + const files: string[] = []; + + function walk(dir: string) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const entryPath = path.join(dir, entry.name); + if (entry.isDirectory()) walk(entryPath); + else if (entry.name.endsWith(".ts")) files.push(entryPath); + } + } + walk(srcDir); + + const legacyClasses = [ + "PullConfirmModal", + "PushConfirmModal", + "PullResultModal", + "PushResultModal", + ]; + + for (const file of files) { + const content = fs.readFileSync(file, "utf8"); + for (const className of legacyClasses) { + expect( + content.includes(className), + `File ${file} should not reference legacy class ${className}` + ).toBe(false); + } + } + }); + }); + + // ========================================================================= + // 2. SYNCCONFIRMMODAL PREFLIGHT STATES & SECURITY + // ========================================================================= + describe("Audit 2: SyncConfirmModal Preflight & Safety Guards", () => { + it("CHALLENGE-M1-PRE-001: Missing PAT in SecretStorage renders preflight error on Pull and Push", async () => { + // SecretStorage is empty (no PAT) + const pullModal = new SyncConfirmModal(app, plugin, "pull"); + await (pullModal as unknown as { runPreflight: () => Promise }).runPreflight(); + + const pullText = getAllText(pullModal.contentEl); + expect(pullText).toContain("Preflight check failed"); + expect(pullText).toContain("No GitHub PAT found in secure storage"); + const pullButtons = findButtons(pullModal.contentEl); + expect(pullButtons.some((b) => b.textContent.includes("Confirm"))).toBe(false); + + const pushModal = new SyncConfirmModal(app, plugin, "push"); + await (pushModal as unknown as { runPreflight: () => Promise }).runPreflight(); + + const pushText = getAllText(pushModal.contentEl); + expect(pushText).toContain("Preflight Check Failed"); + expect(pushText).toContain("No GitHub PAT found in secure storage"); + const pushButtons = findButtons(pushModal.contentEl); + expect(pushButtons.some((b) => b.textContent.includes("Confirm"))).toBe(false); + }); + + it("CHALLENGE-M1-PRE-002: Preflight error sanitization NEVER leaks raw GitHub PAT", async () => { + const secretToken = "ghp_abcdefghijklmnopqrstuvwxyz1234567890"; + await setStoredPat(app, plugin.settings.owner, plugin.settings.repo, secretToken); + + const rawTokenError = new Error( + `GitHub API 401 Unauthorized for token ${secretToken} at https://api.github.com` + ); + vi.spyOn(SyncEngine.prototype, "generatePreview").mockRejectedValue(rawTokenError); + + const modal = new SyncConfirmModal(app, plugin, "pull"); + await (modal as unknown as { runPreflight: () => Promise }).runPreflight(); + + const text = getAllText(modal.contentEl); + expect(text).toContain("Preflight check failed"); + expect(text).not.toContain(secretToken); + expect(text).toContain("[REDACTED_TOKEN]"); + }); + + it("CHALLENGE-M1-PRE-003: Truncated remote tree (>100,000 items) blocks both Pull and Push with no confirm button", () => { + const truncatedReport = makeMockPreviewReport([], {}, true); + + // Pull branch + const pullModal = new SyncConfirmModal(app, plugin, "pull"); + const pullInternal = pullModal as unknown as { + previewReport: SyncPreviewReport | null; + renderConfirmation: () => void; + }; + pullInternal.previewReport = truncatedReport; + pullInternal.renderConfirmation(); + + const pullText = getAllText(pullModal.contentEl); + expect(pullText).toContain("🚫 Safe pull blocked: Truncated Git tree"); + expect(pullText).toContain(">100,000 items"); + const pullBtns = findButtons(pullModal.contentEl); + expect(pullBtns.some((b) => b.textContent.includes("Confirm"))).toBe(false); + + // Push branch + const pushModal = new SyncConfirmModal(app, plugin, "push"); + const pushInternal = pushModal as unknown as { + previewReport: SyncPreviewReport | null; + renderConfirmation: () => void; + }; + pushInternal.previewReport = truncatedReport; + pushInternal.renderConfirmation(); + + const pushText = getAllText(pushModal.contentEl); + expect(pushText).toContain("🚫 Safe Push Blocked: Truncated Git Tree"); + expect(pushText).toContain(">100,000 items"); + const pushBtns = findButtons(pushModal.contentEl); + expect(pushBtns.some((b) => b.textContent.includes("Confirm"))).toBe(false); + }); + }); + + // ========================================================================= + // 3. PLANNED ACTIONS SUMMARY COMBINATORICS + // ========================================================================= + describe("Audit 3: Planned Actions Summary Full Matrix", () => { + it("CHALLENGE-M1-ACT-001: Pull branch correctly formats all planned action categories", () => { + const items: SyncPreviewItem[] = [ + { path: "new.md", category: "REMOTE_ONLY", remoteSha: "sha1" }, + { path: "update.md", category: "REMOTE_CHANGED", remoteSha: "sha2", baseSha: "sha1" }, + { path: "del1.md", category: "REMOTE_DELETED", baseSha: "sha3" }, + { path: "del2.md", category: "REMOTE_DELETED", baseSha: "sha4" }, + { path: "delMove.md", category: "REMOTE_DELETED", isMove: true, movedTo: "moveDest.md", baseSha: "sha5" }, + { path: "moveDest.md", category: "REMOTE_ONLY", isMove: true, movedFrom: "delMove.md", remoteSha: "sha5" }, + { path: "conflict.md", category: "POTENTIAL_CONFLICT", remoteSha: "sha6", localSha: "sha7" }, + { path: "delConf.md", category: "DELETE_CONFLICT", remoteSha: "sha8" }, + { path: "big.md", category: "REMOTE_ONLY", isOversized: true, remoteSha: "sha9" }, + { path: "local.md", category: "LOCAL_CHANGED", localSha: "sha10" }, + { path: "same.md", category: "UNCHANGED", remoteSha: "sha11", localSha: "sha11" }, + ]; + + const report = makeMockPreviewReport(items, { + REMOTE_ONLY: 1, + REMOTE_CHANGED: 1, + REMOTE_DELETED: 2, + POTENTIAL_CONFLICT: 1, + DELETE_CONFLICT: 1, + OVERSIZED: 1, + LOCAL_CHANGED: 1, + UNCHANGED: 1, + }); + + const modal = new SyncConfirmModal(app, plugin, "pull"); + const internal = modal as unknown as { + previewReport: SyncPreviewReport | null; + renderConfirmation: () => void; + }; + internal.previewReport = report; + internal.renderConfirmation(); + + const text = getAllText(modal.contentEl); + expect(text).toContain("Confirm safe pull (GitHub → local)"); + expect(text).toContain("New files to create locally: 2"); + expect(text).toContain("Files to update locally: 1"); + expect(text).toContain("āš ļø Files to remove locally: 2"); + expect(text).toContain("trash according to your Obsidian trash settings"); + expect(text).toContain("Moves to apply locally: 1"); + expect(text).toContain("Delete conflicts (require review): 1"); + expect(text).toContain("Potential conflicts (preserved to internal conflict storage): 1"); + expect(text).toContain("Oversized files (>25 MiB, skipped): 1"); + expect(text).toContain("Local changes / notes kept untouched: 1"); + expect(text).toContain("Unchanged files: 1"); + + const confirmBtn = findButtons(modal.contentEl).find((b) => b.textContent.includes("Confirm")); + expect(confirmBtn).toBeDefined(); + expect(confirmBtn?.textContent).toBe("Confirm Safe Pull"); + }); + + it("CHALLENGE-M1-ACT-002: Push branch correctly formats target and all planned action categories", () => { + const items: SyncPreviewItem[] = [ + { path: "pNew.md", category: "LOCAL_ONLY", localSha: "sha1" }, + { path: "pUpdate.md", category: "LOCAL_CHANGED", localSha: "sha2", baseSha: "sha1" }, + { path: "pDel.md", category: "LOCAL_DELETED", baseSha: "sha3" }, + { path: "pDelMove.md", category: "LOCAL_DELETED", isMove: true, movedTo: "pMoveDest.md", baseSha: "sha4" }, + { path: "pMoveDest.md", category: "LOCAL_ONLY", isMove: true, movedFrom: "pDelMove.md", localSha: "sha4" }, + { path: "pConf.md", category: "POTENTIAL_CONFLICT", remoteSha: "sha5", localSha: "sha6" }, + { path: "pDelConf.md", category: "DELETE_CONFLICT", baseSha: "sha7" }, + { path: "pBig.md", category: "LOCAL_ONLY", isOversized: true, localSha: "sha8" }, + { path: "pRemote.md", category: "REMOTE_CHANGED", remoteSha: "sha9" }, + { path: "pSame.md", category: "UNCHANGED", remoteSha: "sha10", localSha: "sha10" }, + ]; + + const report = makeMockPreviewReport(items, { + LOCAL_ONLY: 1, + LOCAL_CHANGED: 1, + LOCAL_DELETED: 1, + POTENTIAL_CONFLICT: 1, + DELETE_CONFLICT: 1, + OVERSIZED: 1, + REMOTE_CHANGED: 1, + UNCHANGED: 1, + }); + + const modal = new SyncConfirmModal(app, plugin, "push"); + const internal = modal as unknown as { + previewReport: SyncPreviewReport | null; + renderConfirmation: () => void; + }; + internal.previewReport = report; + internal.renderConfirmation(); + + const text = getAllText(modal.contentEl); + expect(text).toContain("Confirm Safe Push (Local → GitHub)"); + expect(text).toContain("Target: octocat/notes (branch: main)"); + expect(text).toContain("New files to create on GitHub: 2"); + expect(text).toContain("Files to update on GitHub: 1"); + expect(text).toContain("āš ļø Files to delete from GitHub: 1"); + expect(text).toContain("The current GitHub version will be removed in the new commit"); + expect(text).toContain("Moves to commit to GitHub: 1"); + expect(text).toContain("Delete conflicts (not pushed, require review): 1"); + expect(text).toContain("Conflicting files (kept untouched / not pushed): 1"); + expect(text).toContain("Oversized files (>25 MiB, skipped): 1"); + expect(text).toContain("Remote notes (kept untouched): 1"); + expect(text).toContain("Unchanged files: 1"); + + const confirmBtn = findButtons(modal.contentEl).find((b) => b.textContent.includes("Confirm")); + expect(confirmBtn).toBeDefined(); + expect(confirmBtn?.textContent).toBe("Confirm Safe Push"); + }); + }); + + // ========================================================================= + // 4. BUTTON EXECUTION LIFECYCLE, TEARDOWN & REJECTION SAFETY + // ========================================================================= + describe("Audit 4: Button Execution Lifecycle & Robustness", () => { + it("CHALLENGE-M1-LIFECYCLE-001: Cancel button cleanly tears down modal", () => { + const modal = new SyncConfirmModal(app, plugin, "pull"); + const internal = modal as unknown as { + previewReport: SyncPreviewReport | null; + renderConfirmation: () => void; + }; + internal.previewReport = makeMockPreviewReport([]); + internal.renderConfirmation(); + + const cancelBtn = findButtons(modal.contentEl).find((b) => b.textContent.includes("Cancel")); + expect(cancelBtn).toBeDefined(); + + cancelBtn?.onclick?.(); + expect(isModalOpen(modal)).toBe(false); + expect(modal.contentEl.children.length).toBe(0); + }); + + it("CHALLENGE-M1-LIFECYCLE-002: Pull confirm button dispatches PullEngine and triggers onComplete", async () => { + await setStoredPat(app, plugin.settings.owner, plugin.settings.repo, "test-token"); + + const mockPullReport: PullExecutionReport = { + status: "PASS", + branch: "main", + remoteCommitSha: "abc1111", + timestamp: Date.now(), + summaryMessage: "Pull succeeded cleanly", + counts: { + pulledCreated: 1, + pulledUpdated: 0, + pulledDeleted: 0, + pulledMoved: 0, + conflictsPreserved: 0, + unchanged: 2, + skippedLocalOnly: 0, + skippedLocalChanged: 0, + skippedOversized: 0, + skippedUnsafe: 0, + failed: 0, + }, + results: [{ path: "note.md", action: "PULL_CREATE", status: "SUCCESS" }], + }; + + const pullSpy = vi + .spyOn(PullEngine.prototype, "executeSafePull") + .mockResolvedValue(mockPullReport); + + const onCompleteMock = vi.fn(); + const modal = new SyncConfirmModal(app, plugin, "pull", onCompleteMock); + const internal = modal as unknown as { + previewReport: SyncPreviewReport | null; + renderConfirmation: () => void; + }; + internal.previewReport = makeMockPreviewReport([]); + internal.renderConfirmation(); + + const confirmBtn = findButtons(modal.contentEl).find((b) => b.textContent.includes("Confirm")); + const cancelBtn = findButtons(modal.contentEl).find((b) => b.textContent.includes("Cancel")); + + // Trigger confirm click + await confirmBtn?.onclick?.(); + // Wait for internal async task + await new Promise((r) => setTimeout(r, 20)); + + expect(pullSpy).toHaveBeenCalledTimes(1); + expect(confirmBtn?.disabled).toBe(true); + expect(cancelBtn?.disabled).toBe(true); + expect(confirmBtn?.textContent).toBe("Pulling..."); + expect(onCompleteMock).toHaveBeenCalledWith(mockPullReport); + expect(isModalOpen(modal)).toBe(false); + }); + + it("CHALLENGE-M1-LIFECYCLE-003: Push confirm button dispatches PushEngine and triggers onComplete", async () => { + await setStoredPat(app, plugin.settings.owner, plugin.settings.repo, "test-token"); + + const mockPushReport: PushExecutionReport = { + status: "PASS", + branch: "main", + newCommitSha: "def22223333", + timestamp: Date.now(), + summaryMessage: "Push succeeded cleanly", + counts: { + pushedCreated: 1, + pushedUpdated: 0, + pushedDeleted: 0, + pushedMoved: 0, + unchanged: 3, + skippedRemoteOnly: 0, + skippedRemoteChanged: 0, + skippedConflicts: 0, + skippedOversized: 0, + skippedUnsafe: 0, + failed: 0, + }, + results: [{ path: "pushNote.md", action: "PUSH_CREATE", status: "SUCCESS" }], + }; + + const pushSpy = vi + .spyOn(PushEngine.prototype, "executeSafePush") + .mockResolvedValue(mockPushReport); + + const onCompleteMock = vi.fn(); + const modal = new SyncConfirmModal(app, plugin, "push", onCompleteMock); + const internal = modal as unknown as { + previewReport: SyncPreviewReport | null; + renderConfirmation: () => void; + }; + internal.previewReport = makeMockPreviewReport([]); + internal.renderConfirmation(); + + const confirmBtn = findButtons(modal.contentEl).find((b) => b.textContent.includes("Confirm")); + const cancelBtn = findButtons(modal.contentEl).find((b) => b.textContent.includes("Cancel")); + + // Trigger confirm click + await confirmBtn?.onclick?.(); + await new Promise((r) => setTimeout(r, 20)); + + expect(pushSpy).toHaveBeenCalledTimes(1); + expect(confirmBtn?.disabled).toBe(true); + expect(cancelBtn?.disabled).toBe(true); + expect(confirmBtn?.textContent).toBe("Pushing..."); + expect(onCompleteMock).toHaveBeenCalledWith(mockPushReport); + expect(isModalOpen(modal)).toBe(false); + }); + + it("CHALLENGE-M1-LIFECYCLE-004: onComplete error does not bubble or crash UI", async () => { + await setStoredPat(app, plugin.settings.owner, plugin.settings.repo, "test-token"); + + const mockPullReport: PullExecutionReport = { + status: "PASS", + branch: "main", + timestamp: Date.now(), + summaryMessage: "OK", + counts: { + pulledCreated: 0, + pulledUpdated: 0, + pulledDeleted: 0, + pulledMoved: 0, + conflictsPreserved: 0, + unchanged: 0, + skippedLocalOnly: 0, + skippedLocalChanged: 0, + skippedOversized: 0, + skippedUnsafe: 0, + failed: 0, + }, + results: [], + }; + + vi.spyOn(PullEngine.prototype, "executeSafePull").mockResolvedValue(mockPullReport); + const failingCallback = vi.fn().mockRejectedValue(new Error("Downstream UI refresh exploded")); + + const modal = new SyncConfirmModal(app, plugin, "pull", failingCallback); + const internal = modal as unknown as { + previewReport: SyncPreviewReport | null; + renderConfirmation: () => void; + }; + internal.previewReport = makeMockPreviewReport([]); + internal.renderConfirmation(); + + const confirmBtn = findButtons(modal.contentEl).find((b) => b.textContent.includes("Confirm")); + + // Trigger confirm click which handles errors internally + confirmBtn?.onclick?.(); + await new Promise((r) => setTimeout(r, 20)); + expect(failingCallback).toHaveBeenCalledTimes(1); + expect(isModalOpen(modal)).toBe(false); + }); + + it("CHALLENGE-M1-LIFECYCLE-005: Rapid clicking confirm button synchronously disables button and dispatches engine", async () => { + await setStoredPat(app, plugin.settings.owner, plugin.settings.repo, "test-token"); + + let resolvePull: (val: PullExecutionReport) => void; + const pullPromise = new Promise((resolve) => { + resolvePull = resolve; + }); + + const pullSpy = vi.spyOn(PullEngine.prototype, "executeSafePull").mockImplementation(() => pullPromise); + + const modal = new SyncConfirmModal(app, plugin, "pull"); + const internal = modal as unknown as { + previewReport: SyncPreviewReport | null; + renderConfirmation: () => void; + }; + internal.previewReport = makeMockPreviewReport([]); + internal.renderConfirmation(); + + const confirmBtn = findButtons(modal.contentEl).find((b) => b.textContent.includes("Confirm")); + const cancelBtn = findButtons(modal.contentEl).find((b) => b.textContent.includes("Cancel")); + + // Click once + confirmBtn?.onclick?.(); + // Button must be synchronously disabled on the very first event dispatch + expect(confirmBtn?.disabled).toBe(true); + expect(cancelBtn?.disabled).toBe(true); + expect(confirmBtn?.textContent).toBe("Pulling..."); + + // Await next tick for getStoredPat + await new Promise((r) => setTimeout(r, 20)); + expect(pullSpy).toHaveBeenCalledTimes(1); + + // Finish execution + resolvePull!({ + status: "PASS", + branch: "main", + timestamp: Date.now(), + summaryMessage: "Done", + counts: { + pulledCreated: 0, + pulledUpdated: 0, + pulledDeleted: 0, + conflictsPreserved: 0, + unchanged: 0, + skippedLocalOnly: 0, + skippedLocalChanged: 0, + skippedOversized: 0, + skippedUnsafe: 0, + failed: 0, + }, + results: [], + }); + await new Promise((r) => setTimeout(r, 20)); + expect(pullSpy).toHaveBeenCalledTimes(1); + }); + + it("CHALLENGE-M1-LIFECYCLE-006: PushEngine failure closes modal gracefully without unhandled rejection", async () => { + await setStoredPat(app, plugin.settings.owner, plugin.settings.repo, "test-token"); + + vi.spyOn(PushEngine.prototype, "executeSafePush").mockRejectedValue( + new Error("GitHub API 500 Internal Server Error") + ); + + const modal = new SyncConfirmModal(app, plugin, "push"); + const internal = modal as unknown as { + previewReport: SyncPreviewReport | null; + renderConfirmation: () => void; + }; + internal.previewReport = makeMockPreviewReport([]); + internal.renderConfirmation(); + + const confirmBtn = findButtons(modal.contentEl).find((b) => b.textContent.includes("Confirm")); + + confirmBtn?.onclick?.(); + await new Promise((r) => setTimeout(r, 20)); + + expect(isModalOpen(modal)).toBe(false); + }); + }); + + // ========================================================================= + // 5. SYNCRESULTMODAL STATUS BANNERS, DIRECTION INFERENCE & SHAS + // ========================================================================= + describe("Audit 5: SyncResultModal Status Banners, Commit SHAs & Direction", () => { + it("CHALLENGE-M1-RES-001: Automatic direction detection handles pull vs push correctly", () => { + const pullReport: PullExecutionReport = { + status: "PASS", + branch: "main", + remoteCommitSha: "abc9999", + timestamp: Date.now(), + summaryMessage: "Pull complete", + counts: { + pulledCreated: 1, + pulledUpdated: 0, + pulledDeleted: 0, + pulledMoved: 0, + conflictsPreserved: 0, + unchanged: 0, + skippedLocalOnly: 0, + skippedLocalChanged: 0, + skippedOversized: 0, + skippedUnsafe: 0, + failed: 0, + }, + results: [], + }; + + const pullModal = new SyncResultModal(app, pullReport); + pullModal.onOpen(); + expect(getAllText(pullModal.contentEl)).toContain("Safe Pull Results"); + + const pushReport: PushExecutionReport = { + status: "PASS", + branch: "main", + newCommitSha: "def88889999", + timestamp: Date.now(), + summaryMessage: "Push complete", + counts: { + pushedCreated: 1, + pushedUpdated: 0, + pushedDeleted: 0, + pushedMoved: 0, + unchanged: 0, + skippedRemoteOnly: 0, + skippedRemoteChanged: 0, + skippedConflicts: 0, + skippedOversized: 0, + skippedUnsafe: 0, + failed: 0, + }, + results: [], + }; + + const pushModal = new SyncResultModal(app, pushReport); + pushModal.onOpen(); + expect(getAllText(pushModal.contentEl)).toContain("Safe Push Results"); + }); + + it("CHALLENGE-M1-RES-002: Status banners correctly represent PASS, PASS_WITH_WARNINGS, ABORTED, and FAIL", () => { + const baseReport: PullExecutionReport = { + status: "PASS", + branch: "main", + timestamp: Date.now(), + summaryMessage: "Test Summary", + counts: { + pulledCreated: 0, + pulledUpdated: 0, + pulledDeleted: 0, + pulledMoved: 0, + conflictsPreserved: 0, + unchanged: 0, + skippedLocalOnly: 0, + skippedLocalChanged: 0, + skippedOversized: 0, + skippedUnsafe: 0, + failed: 0, + }, + results: [], + }; + + const statuses: Array<{ status: PullExecutionReport["status"]; icon: string; title: string }> = [ + { status: "PASS", icon: "āœ…", title: "PASS" }, + { status: "PASS_WITH_WARNINGS", icon: "āš ļø", title: "PASS WITH WARNINGS" }, + { status: "ABORTED", icon: "āš ļø", title: "ABORTED" }, + { status: "FAIL", icon: "āŒ", title: "FAIL" }, + ]; + + for (const { status, icon, title } of statuses) { + const modal = new SyncResultModal(app, { ...baseReport, status }, "pull"); + modal.onOpen(); + const text = getAllText(modal.contentEl); + expect(text).toContain(`${icon} ${title}`); + expect(text).toContain("Test Summary"); + } + }); + + it("CHALLENGE-M1-RES-003: Push result displays truncated 7-char newCommitSha and branch", () => { + const report: PushExecutionReport = { + status: "PASS", + branch: "develop", + newCommitSha: "a1b2c3d4e5f67890", + timestamp: Date.now(), + summaryMessage: "Pushed 1 note", + counts: { + pushedCreated: 1, + pushedUpdated: 0, + pushedDeleted: 0, + pushedMoved: 0, + unchanged: 0, + skippedRemoteOnly: 0, + skippedRemoteChanged: 0, + skippedConflicts: 0, + skippedOversized: 0, + skippedUnsafe: 0, + failed: 0, + }, + results: [], + }; + + const modal = new SyncResultModal(app, report, "push"); + modal.onOpen(); + const text = getAllText(modal.contentEl); + expect(text).toContain("New Remote Commit: a1b2c3d (branch: develop)"); + }); + }); + + // ========================================================================= + // 6. SYNCRESULTMODAL BADGES, DETAILS FILTERING & ROW RENDERING + // ========================================================================= + describe("Audit 6: SyncResultModal Badges, Filtering & Itemized Rows", () => { + it("CHALLENGE-M1-ROW-001: Badge labels strictly adhere to Pull vs Push terminology", () => { + // Pull report with deletions + const pullReport: PullExecutionReport = { + status: "PASS", + branch: "main", + timestamp: Date.now(), + summaryMessage: "Done", + counts: { + pulledCreated: 1, + pulledUpdated: 0, + pulledDeleted: 3, + pulledMoved: 0, + conflictsPreserved: 0, + unchanged: 0, + skippedLocalOnly: 0, + skippedLocalChanged: 0, + skippedOversized: 0, + skippedUnsafe: 0, + failed: 0, + }, + results: [{ path: "del.md", action: "PULL_DELETE", status: "SUCCESS" }], + }; + + const pullModal = new SyncResultModal(app, pullReport, "pull"); + pullModal.onOpen(); + const pullText = getAllText(pullModal.contentEl); + expect(pullText).toContain("Removed locally"); + expect(pullText).not.toContain("Deleted from GitHub"); + + // Push report with deletions + const pushReport: PushExecutionReport = { + status: "PASS", + branch: "main", + timestamp: Date.now(), + summaryMessage: "Done", + counts: { + pushedCreated: 0, + pushedUpdated: 0, + pushedDeleted: 2, + pushedMoved: 0, + unchanged: 0, + skippedRemoteOnly: 0, + skippedRemoteChanged: 0, + skippedConflicts: 0, + skippedOversized: 0, + skippedUnsafe: 0, + failed: 0, + }, + results: [{ path: "remoteDel.md", action: "PUSH_DELETE", status: "SUCCESS" }], + }; + + const pushModal = new SyncResultModal(app, pushReport, "push"); + pushModal.onOpen(); + const pushText = getAllText(pushModal.contentEl); + expect(pushText).toContain("Deleted from GitHub"); + expect(pushText).not.toContain("Removed locally"); + }); + + it("CHALLENGE-M1-ROW-002: Execution details filter skip-actions and display accurate empty states", () => { + // Pull report with only skip actions + const pullReport: PullExecutionReport = { + status: "PASS", + branch: "main", + timestamp: Date.now(), + summaryMessage: "Up to date", + counts: { + pulledCreated: 0, + pulledUpdated: 0, + pulledDeleted: 0, + pulledMoved: 0, + conflictsPreserved: 0, + unchanged: 5, + skippedLocalOnly: 2, + skippedLocalChanged: 1, + skippedOversized: 0, + skippedUnsafe: 0, + failed: 0, + }, + results: [ + { path: "u.md", action: "SKIP_UNCHANGED", status: "SKIPPED" }, + { path: "l.md", action: "SKIP_LOCAL_ONLY", status: "SKIPPED" }, + { path: "lc.md", action: "SKIP_LOCAL_CHANGED", status: "SKIPPED" }, + ], + }; + + const pullModal = new SyncResultModal(app, pullReport, "pull"); + pullModal.onOpen(); + const pullText = getAllText(pullModal.contentEl); + expect(pullText).toContain("No file changes required execution. Vault is fully up to date."); + + // Push report with only skip actions + const pushReport: PushExecutionReport = { + status: "PASS", + branch: "main", + timestamp: Date.now(), + summaryMessage: "Up to date", + counts: { + pushedCreated: 0, + pushedUpdated: 0, + pushedDeleted: 0, + pushedMoved: 0, + unchanged: 4, + skippedRemoteOnly: 1, + skippedRemoteChanged: 1, + skippedConflicts: 0, + skippedOversized: 0, + skippedUnsafe: 0, + failed: 0, + }, + results: [ + { path: "u.md", action: "SKIP_UNCHANGED", status: "SKIPPED" }, + { path: "ro.md", action: "SKIP_REMOTE_ONLY", status: "SKIPPED" }, + { path: "rc.md", action: "SKIP_REMOTE_CHANGED", status: "SKIPPED" }, + ], + }; + + const pushModal = new SyncResultModal(app, pushReport, "push"); + pushModal.onOpen(); + const pushText = getAllText(pushModal.contentEl); + expect(pushText).toContain("No local changes required push. Remote repository is up to date."); + }); + + it("CHALLENGE-M1-ROW-003: Row rendering formats conflict file paths and messages faithfully", () => { + const pullReport: PullExecutionReport = { + status: "PASS_WITH_WARNINGS", + branch: "main", + timestamp: Date.now(), + summaryMessage: "Pull with preserved conflict", + counts: { + pulledCreated: 0, + pulledUpdated: 0, + pulledDeleted: 0, + pulledMoved: 0, + conflictsPreserved: 1, + unchanged: 0, + skippedLocalOnly: 0, + skippedLocalChanged: 0, + skippedOversized: 0, + skippedUnsafe: 0, + failed: 0, + }, + results: [ + { + path: "ConflictNote.md", + action: "PULL_UPDATE", + status: "CONFLICT_PRESERVED", + message: "Local version safely preserved to internal storage", + conflictPath: ".obsidian/github-vault-relay/conflicts/ConflictNote.md.conflict", + }, + ], + }; + + const modal = new SyncResultModal(app, pullReport, "pull"); + modal.onOpen(); + const text = getAllText(modal.contentEl); + expect(text).toContain("ConflictNote.md"); + expect(text).toContain("CONFLICT_PRESERVED"); + expect(text).toContain("Local version safely preserved to internal storage"); + expect(text).toContain("Preserved conflict file: .obsidian/github-vault-relay/conflicts/ConflictNote.md.conflict"); + + const doneBtn = findButtons(modal.contentEl).find((b) => b.textContent.includes("Done")); + expect(doneBtn).toBeDefined(); + doneBtn?.onclick?.(); + expect(isModalOpen(modal)).toBe(false); + }); + }); + + // ========================================================================= + // 7. RESPONSIVE CLASSES & ACCESSIBILITY AUDIT + // ========================================================================= + describe("Audit 7: Responsive CSS & Accessibility Contracts", () => { + it("CHALLENGE-M1-CSS-001: Both unified modals apply .vault-relay-modal and specialized class", () => { + const confirmModal = new SyncConfirmModal(app, plugin, "pull"); + confirmModal.onOpen(); + expect(asMock(confirmModal.modalEl).hasClass("vault-relay-modal")).toBe(true); + expect(asMock(confirmModal.modalEl).hasClass("vault-relay-confirm-modal")).toBe(true); + + const resultReport: PullExecutionReport = { + status: "PASS", + branch: "main", + timestamp: Date.now(), + summaryMessage: "Done", + counts: { + pulledCreated: 0, + pulledUpdated: 0, + pulledDeleted: 0, + pulledMoved: 0, + conflictsPreserved: 0, + unchanged: 0, + skippedLocalOnly: 0, + skippedLocalChanged: 0, + skippedOversized: 0, + skippedUnsafe: 0, + failed: 0, + }, + results: [], + }; + + const resultModal = new SyncResultModal(app, resultReport, "pull"); + resultModal.onOpen(); + expect(asMock(resultModal.modalEl).hasClass("vault-relay-modal")).toBe(true); + expect(asMock(resultModal.modalEl).hasClass("vault-relay-result-modal")).toBe(true); + }); + }); +}); diff --git a/tests/m1ChallengerStress.test.ts b/tests/m1ChallengerStress.test.ts new file mode 100644 index 0000000..c92f0ef --- /dev/null +++ b/tests/m1ChallengerStress.test.ts @@ -0,0 +1,628 @@ +/** + * Milestone 1 Empirical Stress Test Suite (tests/m1ChallengerStress.test.ts) + * + * Adversarially challenges: + * 1. Touch target compliance (44px min) & button styling classes. + * 2. Modal styling class attachment on open. + * 3. Truncated tree conditions & execution blocking. + * 4. Missing tokens & secure storage handling. + * 5. API error handling during preflight and execution. + * 6. Zero changes edge cases. + * 7. Command invocations for pull-safe-changes, push-safe-changes, etc. + * 8. Status banner & badge fidelity in SyncResultModal. + */ + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { App } from "obsidian"; +import fs from "node:fs"; +import path from "node:path"; +import VaultRelayPlugin from "../src/main"; +import { SyncConfirmModal } from "../src/ui/syncConfirmModal"; +import { SyncResultModal } from "../src/ui/syncResultModal"; +import { + SyncPreviewItem, + SyncPreviewReport, + PullExecutionReport, + PushExecutionReport, +} from "../src/sync/syncTypes"; +import * as secretStore from "../src/security/secretStore"; +import { SyncEngine } from "../src/sync/syncEngine"; +import { PullEngine } from "../src/sync/pullEngine"; +import { PushEngine } from "../src/sync/pushEngine"; +import { MockElement, CommandConfig } from "./__mocks__/obsidian"; + +const root = process.cwd(); + +function read(relativePath: string): string { + return fs.readFileSync(path.join(root, relativePath), "utf8"); +} + +function getAllText(el: MockElement | { textContent?: string; children?: MockElement[] }): string { + let res = el?.textContent || ""; + if (el?.children) { + for (const child of el.children) { + res += " " + getAllText(child); + } + } + return res; +} + +interface ModalInternalWithPreview { + previewReport?: SyncPreviewReport | null; + isLoading?: boolean; + renderConfirmation?: () => void; + renderError?: (msg: string) => void; + runPreflight?: () => Promise; +} + +describe("Milestone 1 Adversarial Stress Tests", () => { + let app: App; + let plugin: VaultRelayPlugin; + + beforeEach(() => { + vi.restoreAllMocks(); + app = new App(); + plugin = new VaultRelayPlugin(app, { + id: "github-vault-relay", + name: "GitHub Vault Relay", + version: "1.1.0", + minAppVersion: "1.0.0", + author: "Test", + description: "Test", + }); + plugin.settings = { + owner: "octocat", + repo: "vault-notes", + branch: "main", + excludedPaths: [".obsidian/", ".git/", "_fit/"], + }; + }); + + function makeMockReport(items: SyncPreviewItem[], truncated = false): SyncPreviewReport { + return { + timestamp: Date.now(), + branch: "main", + items, + counts: { + LOCAL_ONLY: 0, + LOCAL_CHANGED: 0, + LOCAL_DELETED: 0, + REMOTE_ONLY: 0, + REMOTE_CHANGED: 0, + REMOTE_DELETED: 0, + POTENTIAL_CONFLICT: 0, + DELETE_CONFLICT: 0, + DELETED: 0, + UNCHANGED: items.length, + OVERSIZED: 0, + UNSAFE: 0, + }, + totalScannedLocal: items.length, + totalScannedRemote: items.length, + truncatedRemoteTree: truncated, + caseCollisions: [], + }; + } + + // ========================================================================= + // 1. TOUCH TARGET & ACCESSIBILITY AUDIT (44px min) + // ========================================================================= + + it("CHALLENGE-TOUCH-001: Stylesheet enforces min 44px tap targets for buttons and action layout", () => { + const css = read("styles.css"); + + // Modal buttons must have min 44px height and width + expect(css).toMatch(/\.vault-relay-modal\s+button[\s\S]*?min-height:\s*44px/); + expect(css).toMatch(/\.vault-relay-modal\s+button[\s\S]*?min-width:\s*44px/); + + // .vault-relay-btn-lg must have min-height 44px + expect(css).toMatch(/\.vault-relay-btn-lg\s*\{[^}]*min-height:\s*44px/); + + // Media query < 480px must stack action buttons + expect(css).toMatch(/@media\s*\(max-width:\s*480px\)[\s\S]*?\.vault-relay-action-row\s+button\s*\{[^}]*width:\s*100%/); + }); + + it("CHALLENGE-TOUCH-002: SyncConfirmModal buttons strictly comply with 44px min dimensions", () => { + const report = makeMockReport([ + { path: "note1.md", category: "REMOTE_ONLY", remoteSha: "sha1" }, + ]); + + const modal = new SyncConfirmModal(app, plugin, "pull"); + const internal = modal as unknown as ModalInternalWithPreview; + internal.previewReport = report; + internal.isLoading = false; + internal.renderConfirmation?.(); + + const buttons = (modal.contentEl as unknown as MockElement).findAll((el) => el.tag === "button"); + expect(buttons.length).toBe(2); + + const cancelBtn = buttons.find((b) => b.textContent === "Cancel"); + expect(cancelBtn).toBeDefined(); + expect(cancelBtn?.hasClass("vault-relay-btn-lg")).toBe(true); + + const confirmBtn = buttons.find((b) => b.textContent === "Confirm Safe Pull"); + expect(confirmBtn).toBeDefined(); + const confirmClasses = Array.from(confirmBtn?.classes ?? []).join(" "); + expect(confirmClasses).toContain("vault-relay-btn-lg"); + expect(confirmClasses).toContain("mod-cta"); + }); + + it("CHALLENGE-TOUCH-003: SyncConfirmModal error state close button has min 44px tap target", () => { + const modal = new SyncConfirmModal(app, plugin, "push"); + const internal = modal as unknown as ModalInternalWithPreview; + internal.renderError?.("Network connection timed out"); + + const buttons = (modal.contentEl as unknown as MockElement).findAll((el) => el.tag === "button"); + expect(buttons.length).toBe(1); + + const closeBtn = buttons[0]; + expect(closeBtn.textContent).toBe("Close"); + expect(closeBtn.hasClass("vault-relay-btn-lg")).toBe(true); + }); + + it("CHALLENGE-TOUCH-004: SyncResultModal close button strictly complies with 44px min dimensions", () => { + const report: PullExecutionReport = { + status: "PASS", + branch: "main", + remoteCommitSha: "sha123", + timestamp: Date.now(), + summaryMessage: "Sync succeeded", + counts: { + pulledCreated: 1, + pulledUpdated: 0, + pulledDeleted: 0, + pulledMoved: 0, + conflictsPreserved: 0, + unchanged: 0, + skippedLocalOnly: 0, + skippedLocalChanged: 0, + skippedOversized: 0, + skippedUnsafe: 0, + failed: 0, + }, + results: [{ path: "note1.md", action: "PULL_CREATE", status: "SUCCESS" }], + }; + + const modal = new SyncResultModal(app, report, "pull"); + modal.onOpen(); + + const buttons = (modal.contentEl as unknown as MockElement).findAll((el) => el.tag === "button"); + expect(buttons.length).toBe(1); + + const doneBtn = buttons[0]; + expect(doneBtn.textContent).toBe("Done"); + expect(doneBtn.hasClass("vault-relay-btn-lg")).toBe(true); + }); + + // ========================================================================= + // 2. MODAL STYLING CLASS ATTACHMENT ON OPEN + // ========================================================================= + + it("CHALLENGE-CLASS-001: SyncConfirmModal attaches vault-relay-modal and vault-relay-confirm-modal", () => { + vi.spyOn(secretStore, "getStoredPat").mockResolvedValue("test-token"); + vi.spyOn(SyncEngine.prototype, "generatePreview").mockResolvedValue(makeMockReport([])); + + const pullModal = new SyncConfirmModal(app, plugin, "pull"); + pullModal.onOpen(); + expect((pullModal.modalEl as unknown as MockElement).hasClass("vault-relay-modal")).toBe(true); + expect((pullModal.modalEl as unknown as MockElement).hasClass("vault-relay-confirm-modal")).toBe(true); + + const pushModal = new SyncConfirmModal(app, plugin, "push"); + pushModal.onOpen(); + expect((pushModal.modalEl as unknown as MockElement).hasClass("vault-relay-modal")).toBe(true); + expect((pushModal.modalEl as unknown as MockElement).hasClass("vault-relay-confirm-modal")).toBe(true); + }); + + it("CHALLENGE-CLASS-002: SyncResultModal attaches vault-relay-modal and vault-relay-result-modal", () => { + const pullReport: PullExecutionReport = { + status: "PASS", + branch: "main", + remoteCommitSha: "sha1", + timestamp: Date.now(), + summaryMessage: "Done", + counts: { + pulledCreated: 0, + pulledUpdated: 0, + pulledDeleted: 0, + pulledMoved: 0, + conflictsPreserved: 0, + unchanged: 0, + skippedLocalOnly: 0, + skippedLocalChanged: 0, + skippedOversized: 0, + skippedUnsafe: 0, + failed: 0, + }, + results: [], + }; + + const modalWithExplicitDir = new SyncResultModal(app, pullReport, "pull"); + modalWithExplicitDir.onOpen(); + expect((modalWithExplicitDir.modalEl as unknown as MockElement).hasClass("vault-relay-modal")).toBe(true); + expect((modalWithExplicitDir.modalEl as unknown as MockElement).hasClass("vault-relay-result-modal")).toBe(true); + + const pushReport: PushExecutionReport = { + status: "PASS", + branch: "main", + newCommitSha: "newsha123", + timestamp: Date.now(), + summaryMessage: "Done", + counts: { + pushedCreated: 0, + pushedUpdated: 0, + pushedDeleted: 0, + pushedMoved: 0, + skippedConflicts: 0, + unchanged: 0, + skippedRemoteOnly: 0, + skippedRemoteChanged: 0, + skippedOversized: 0, + skippedUnsafe: 0, + failed: 0, + }, + results: [], + }; + + const modalWithAutoDetect = new SyncResultModal(app, pushReport); + modalWithAutoDetect.onOpen(); + expect((modalWithAutoDetect.modalEl as unknown as MockElement).hasClass("vault-relay-modal")).toBe(true); + expect((modalWithAutoDetect.modalEl as unknown as MockElement).hasClass("vault-relay-result-modal")).toBe(true); + }); + + // ========================================================================= + // 3. TRUNCATED REMOTE TREE POLICY (Adversarial Safety Block) + // ========================================================================= + + it("CHALLENGE-TRUNCATED-001: Truncated Git tree (>100k items) blocks Safe Pull without rendering confirm button", () => { + const report = makeMockReport([], true); // truncatedRemoteTree = true + + const modal = new SyncConfirmModal(app, plugin, "pull"); + const internal = modal as unknown as ModalInternalWithPreview; + internal.previewReport = report; + internal.isLoading = false; + internal.renderConfirmation?.(); + + const text = getAllText(modal.contentEl as unknown as MockElement); + expect(text).toContain("🚫 Safe pull blocked: Truncated Git tree"); + expect(text).toContain(">100,000 items"); + expect(text).toContain("Safe pull cannot proceed to avoid partial synchronization"); + + // Must NOT render confirm button + const buttons = (modal.contentEl as unknown as MockElement).findAll((el) => el.tag === "button"); + const confirmBtn = buttons.find((b) => b.textContent?.includes("Confirm")); + expect(confirmBtn).toBeUndefined(); + }); + + it("CHALLENGE-TRUNCATED-002: Truncated Git tree (>100k items) blocks Safe Push without rendering confirm button", () => { + const report = makeMockReport([], true); // truncatedRemoteTree = true + + const modal = new SyncConfirmModal(app, plugin, "push"); + const internal = modal as unknown as ModalInternalWithPreview; + internal.previewReport = report; + internal.isLoading = false; + internal.renderConfirmation?.(); + + const text = getAllText(modal.contentEl as unknown as MockElement); + expect(text).toContain("🚫 Safe Push Blocked: Truncated Git Tree"); + expect(text).toContain(">100,000 items"); + expect(text).toContain("Safe push cannot proceed to avoid partial synchronization"); + + // Must NOT render confirm button + const buttons = (modal.contentEl as unknown as MockElement).findAll((el) => el.tag === "button"); + const confirmBtn = buttons.find((b) => b.textContent?.includes("Confirm")); + expect(confirmBtn).toBeUndefined(); + }); + + // ========================================================================= + // 4. MISSING TOKEN & PREFLIGHT ERROR HANDLING + // ========================================================================= + + it("CHALLENGE-TOKEN-001: Missing PAT shows preflight error and safe Close button without crash", async () => { + vi.spyOn(secretStore, "getStoredPat").mockResolvedValue(null); + + const modal = new SyncConfirmModal(app, plugin, "pull"); + modal.onOpen(); + + // Allow runPreflight promise to resolve + await Promise.resolve(); + await Promise.resolve(); + + const text = getAllText(modal.contentEl as unknown as MockElement); + expect(text).toContain("Preflight check failed"); + expect(text).toContain("No GitHub PAT found in secure storage. Please configure settings first."); + + const closeBtn = (modal.contentEl as unknown as MockElement).findAll((el) => el.tag === "button")[0]; + expect(closeBtn).toBeDefined(); + expect(closeBtn.textContent).toBe("Close"); + + const closeSpy = vi.spyOn(modal, "close"); + closeBtn.onclick?.(); + expect(closeSpy).toHaveBeenCalled(); + }); + + it("CHALLENGE-API-001: Preflight API exception is sanitized and rendered with notice", async () => { + vi.spyOn(secretStore, "getStoredPat").mockResolvedValue("test-token"); + vi.spyOn(SyncEngine.prototype, "generatePreview").mockRejectedValue( + new Error("API rate limit exceeded for token ghp_1234567890abcdef1234567890abcdef1234") + ); + + const modal = new SyncConfirmModal(app, plugin, "push"); + modal.onOpen(); + + // Allow preflight promise to catch error + await Promise.resolve(); + await Promise.resolve(); + + const text = getAllText(modal.contentEl as unknown as MockElement); + expect(text).toContain("Preflight Check Failed"); + // Token must be redacted + expect(text).not.toContain("ghp_1234567890abcdef1234567890abcdef1234"); + expect(text).toContain("[REDACTED_TOKEN]"); + + const buttons = (modal.contentEl as unknown as MockElement).findAll((el) => el.tag === "button"); + expect(buttons.length).toBe(1); + expect(buttons[0].textContent).toBe("Close"); + }); + + // ========================================================================= + // 5. EXECUTION ERROR RESILIENCE + // ========================================================================= + + it("CHALLENGE-EXEC-001: Execution network error during Safe Pull closes modal cleanly", async () => { + vi.spyOn(secretStore, "getStoredPat").mockResolvedValue("test-token"); + vi.spyOn(PullEngine.prototype, "executeSafePull").mockRejectedValue( + new Error("GitHub 503 Server Unavailable") + ); + + const report = makeMockReport([ + { path: "note1.md", category: "REMOTE_ONLY", remoteSha: "sha1" }, + ]); + + const modal = new SyncConfirmModal(app, plugin, "pull"); + const internal = modal as unknown as ModalInternalWithPreview; + internal.previewReport = report; + internal.isLoading = false; + internal.renderConfirmation?.(); + + const buttons = (modal.contentEl as unknown as MockElement).findAll((el) => el.tag === "button"); + const confirmBtn = buttons.find((b) => b.textContent === "Confirm Safe Pull"); + expect(confirmBtn).toBeDefined(); + + const closeSpy = vi.spyOn(modal, "close"); + confirmBtn?.onclick?.(); + await new Promise((resolve) => setTimeout(resolve, 20)); + + // Modal must be closed cleanly + expect(closeSpy).toHaveBeenCalled(); + }); + + it("CHALLENGE-EXEC-002: Execution conflict error during Safe Push closes modal cleanly", async () => { + vi.spyOn(secretStore, "getStoredPat").mockResolvedValue("test-token"); + vi.spyOn(PushEngine.prototype, "executeSafePush").mockRejectedValue( + new Error("Remote ref moved concurrently (409 Conflict)") + ); + + const report = makeMockReport([ + { path: "local.md", category: "LOCAL_ONLY", localSha: "sha1" }, + ]); + + const modal = new SyncConfirmModal(app, plugin, "push"); + const internal = modal as unknown as ModalInternalWithPreview; + internal.previewReport = report; + internal.isLoading = false; + internal.renderConfirmation?.(); + + const buttons = (modal.contentEl as unknown as MockElement).findAll((el) => el.tag === "button"); + const confirmBtn = buttons.find((b) => b.textContent === "Confirm Safe Push"); + expect(confirmBtn).toBeDefined(); + + const pushCloseSpy = vi.spyOn(modal, "close"); + confirmBtn?.onclick?.(); + await new Promise((resolve) => setTimeout(resolve, 20)); + + // Modal must be closed cleanly + expect(pushCloseSpy).toHaveBeenCalled(); + }); + + // ========================================================================= + // 6. ZERO CHANGES EDGE CASES + // ========================================================================= + + it("CHALLENGE-ZERO-001: Preflight zero changes renders zero counts cleanly without destructive warnings", () => { + const report = makeMockReport([]); // 0 items + + const modal = new SyncConfirmModal(app, plugin, "pull"); + const internal = modal as unknown as ModalInternalWithPreview; + internal.previewReport = report; + internal.isLoading = false; + internal.renderConfirmation?.(); + + const text = getAllText(modal.contentEl as unknown as MockElement); + expect(text).toContain("New files to create locally: 0"); + expect(text).toContain("Files to update locally: 0"); + expect(text).toContain("Unchanged files: 0"); + expect(text).not.toContain("Files to remove locally"); + }); + + it("CHALLENGE-ZERO-002: SyncResultModal displays truthful zero-state text for pull", () => { + const report: PullExecutionReport = { + status: "PASS", + branch: "main", + remoteCommitSha: "sha1", + timestamp: Date.now(), + summaryMessage: "Vault is fully up to date", + counts: { + pulledCreated: 0, + pulledUpdated: 0, + pulledDeleted: 0, + pulledMoved: 0, + conflictsPreserved: 0, + unchanged: 15, + skippedLocalOnly: 0, + skippedLocalChanged: 0, + skippedOversized: 0, + skippedUnsafe: 0, + failed: 0, + }, + results: [{ path: "unchanged.md", action: "SKIP_UNCHANGED", status: "SKIPPED" }], + }; + + const modal = new SyncResultModal(app, report, "pull"); + modal.onOpen(); + + const text = getAllText(modal.contentEl as unknown as MockElement); + expect(text).toContain("No file changes required execution. Vault is fully up to date."); + }); + + it("CHALLENGE-ZERO-003: SyncResultModal displays truthful zero-state text for push", () => { + const report: PushExecutionReport = { + status: "PASS", + branch: "main", + newCommitSha: "sha1", + timestamp: Date.now(), + summaryMessage: "Remote repository is up to date", + counts: { + pushedCreated: 0, + pushedUpdated: 0, + pushedDeleted: 0, + pushedMoved: 0, + skippedConflicts: 0, + unchanged: 20, + skippedRemoteOnly: 0, + skippedRemoteChanged: 0, + skippedOversized: 0, + skippedUnsafe: 0, + failed: 0, + }, + results: [{ path: "unchanged.md", action: "SKIP_UNCHANGED", status: "SKIPPED" }], + }; + + const modal = new SyncResultModal(app, report, "push"); + modal.onOpen(); + + const text = getAllText(modal.contentEl as unknown as MockElement); + expect(text).toContain("No local changes required push. Remote repository is up to date."); + }); + + // ========================================================================= + // 7. OBSIDIAN COMMAND INVOCATIONS + // ========================================================================= + + it("CHALLENGE-CMD-001: Plugin command pull-safe-changes instantiates SyncConfirmModal without runtime crash", async () => { + const commands: CommandConfig[] = []; + vi.spyOn(plugin, "addCommand").mockImplementation((cmd: CommandConfig) => { + commands.push(cmd); + return cmd; + }); + + await plugin.onload(); + + const pullCmd = commands.find((c) => c.id === "pull-safe-changes"); + expect(pullCmd).toBeDefined(); + + expect(() => { + pullCmd?.callback?.(); + }).not.toThrow(); + }); + + it("CHALLENGE-CMD-002: Plugin command push-safe-changes instantiates SyncConfirmModal without runtime crash", async () => { + const commands: CommandConfig[] = []; + vi.spyOn(plugin, "addCommand").mockImplementation((cmd: CommandConfig) => { + commands.push(cmd); + return cmd; + }); + + await plugin.onload(); + + const pushCmd = commands.find((c) => c.id === "push-safe-changes"); + expect(pushCmd).toBeDefined(); + + expect(() => { + pushCmd?.callback?.(); + }).not.toThrow(); + }); + + // ========================================================================= + // 8. RESULT MODAL STATUS BANNER & BADGE FIDELITY + // ========================================================================= + + it("CHALLENGE-STATUS-001: SyncResultModal renders honest banners for all execution statuses", () => { + const baseCounts = { + pulledCreated: 0, + pulledUpdated: 0, + pulledDeleted: 0, + pulledMoved: 0, + conflictsPreserved: 0, + unchanged: 0, + skippedLocalOnly: 0, + skippedLocalChanged: 0, + skippedOversized: 0, + skippedUnsafe: 0, + failed: 0, + }; + + const statuses = ["PASS", "PASS_WITH_WARNINGS", "ABORTED", "FAIL"] as const; + + for (const status of statuses) { + const report: PullExecutionReport = { + status, + branch: "main", + remoteCommitSha: "sha1", + timestamp: Date.now(), + summaryMessage: `Operation resulted in ${status}`, + counts: baseCounts, + results: [], + }; + + const modal = new SyncResultModal(app, report, "pull"); + modal.onOpen(); + + const text = getAllText(modal.contentEl as unknown as MockElement); + if (status === "PASS") { + expect(text).toContain("āœ… PASS"); + } else if (status === "PASS_WITH_WARNINGS") { + expect(text).toContain("āš ļø PASS WITH WARNINGS"); + } else if (status === "ABORTED") { + expect(text).toContain("āš ļø ABORTED"); + } else if (status === "FAIL") { + expect(text).toContain("āŒ FAIL"); + } + } + }); + + it("CHALLENGE-STATUS-002: Push result shows new commit SHA and deleted from GitHub badge", () => { + const report: PushExecutionReport = { + status: "PASS", + branch: "main", + newCommitSha: "a1b2c3d4e5f67890", + timestamp: Date.now(), + summaryMessage: "Push complete", + counts: { + pushedCreated: 1, + pushedUpdated: 0, + pushedDeleted: 2, + pushedMoved: 0, + skippedConflicts: 0, + unchanged: 10, + skippedRemoteOnly: 0, + skippedRemoteChanged: 0, + skippedOversized: 0, + skippedUnsafe: 0, + failed: 0, + }, + results: [ + { path: "deleted1.md", action: "PUSH_DELETE", status: "SUCCESS" }, + { path: "deleted2.md", action: "PUSH_DELETE", status: "SUCCESS" }, + ], + }; + + const modal = new SyncResultModal(app, report, "push"); + modal.onOpen(); + + const text = getAllText(modal.contentEl as unknown as MockElement); + expect(text).toContain("New Remote Commit: a1b2c3d"); + expect(text).toContain("Deleted from GitHub"); + expect(text).toContain("deleted1.md"); + expect(text).toContain("deleted2.md"); + }); +}); diff --git a/versions.json b/versions.json index 655ca4f..a20638e 100644 --- a/versions.json +++ b/versions.json @@ -1,4 +1,5 @@ { + "1.2.0": "1.11.4", "1.1.0": "1.11.4", "1.0.9": "1.11.4", "1.0.8": "1.11.4",