diff --git a/.claude/skills/obsidian-development/references/pr-template.md b/.claude/skills/obsidian-development/references/pr-template.md new file mode 100644 index 0000000..bd53ceb --- /dev/null +++ b/.claude/skills/obsidian-development/references/pr-template.md @@ -0,0 +1,35 @@ +# Pull Request Template + +## Template + +```markdown +# I am submitting a new Community Plugin + +- [ ] I attest that I have done my best to deliver a high-quality plugin, am proud of the code I have written, and would recommend it to others. I commit to maintaining the plugin and being responsive to bug reports. If I am no longer able to maintain it, I will make reasonable efforts to find a successor maintainer or withdraw the plugin from the directory. + +## Repo URL + + +Link to my plugin: + +## Release Checklist +- [ ] I have tested the plugin on + - [ ] Windows + - [ ] macOS + - [ ] Linux + - [ ] Android _(if applicable)_ + - [ ] iOS _(if applicable)_ +- [ ] My GitHub release contains all required files (as individual files, not just in the source.zip / source.tar.gz) + - [ ] `main.js` + - [ ] `manifest.json` + - [ ] `styles.css` _(optional)_ +- [ ] GitHub release name matches the exact version number specified in my manifest.json (_**Note:** Use the exact version number, don't include a prefix `v`_) +- [ ] The `id` in my `manifest.json` matches the `id` in the `community-plugins.json` file. +- [ ] My README.md describes the plugin's purpose and provides clear usage instructions. +- [ ] I have read the developer policies at https://docs.obsidian.md/Developer+policies, and have assessed my plugin's adherence to these policies. +- [ ] I have read the tips in https://docs.obsidian.md/Plugins/Releasing/Plugin+guidelines and have self-reviewed my plugin to avoid these common pitfalls. +- [ ] I have added a license in the LICENSE file. +- [ ] My project respects and is compatible with the original license of any code from other plugins that I'm using. + I have given proper attribution to these other projects in my `README.md`. + +``` diff --git a/LICENSE b/LICENSE index 30fa09b..947c44e 100644 --- a/LICENSE +++ b/LICENSE @@ -1,5 +1,21 @@ -Copyright (C) 2026 by tianyao +MIT License -Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted. +Copyright (c) 2026 Claudia Fang -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. \ No newline at end of file +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index c1888aa..47571d4 100644 --- a/README.md +++ b/README.md @@ -1,228 +1,142 @@ -# Git File Push - -An Obsidian plugin that enables seamless synchronization of individual notes with GitLab or GitHub repositories across mobile and desktop platforms. - -## Features - -- **Multiple Git Services**: Support for both GitLab and GitHub (user selectable) -- **GitHub Organization Support**: Works with both personal and organization repositories -- **Push to Remote**: Upload individual notes to your Git repository -- **Pull from Remote**: Download and sync notes from your repository -- **Batch Operations**: Push or pull all modified files at once with progress tracking -- **Sync Status View**: Visual dashboard showing all files' sync status with complete git diff -- **Remote-Only Files Detection**: Shows files that exist on remote but not locally -- **Batch Selection**: Select multiple files for batch push/pull/delete operations -- **Status Filtering**: Filter files by sync status (All/Synced/Modified/Not in remote/Remote only) -- **Progress Bar**: Real-time progress indicator during sync operations -- **Last Sync Time**: Display when the last sync check was performed -- **Conflict Resolution**: Visual diff viewer to compare local and remote versions when conflicts occur -- **Vault Folder Filter**: Optionally sync only files within a specific vault folder -- **Ribbon Icon**: Quick access button in the left sidebar for pushing the current note -- **Command Palette**: Commands for pushing and pulling files (single or batch) -- **Context Menu**: Right-click any file to push or pull directly from the file menu -- **Cross-Platform**: Works on both desktop and mobile versions of Obsidian -- **Mobile Optimized**: Responsive UI design for small screens -- **Sync Tracking**: Maintains metadata to track last synced SHA and timestamp for each file -- **Conflict Detection**: Automatically detects conflicts and prompts for resolution -- **Auto-Refresh**: Automatically updates file status after push/pull operations - -## Setup - -1. Install the plugin in Obsidian -2. Open Settings → Git File Push -3. Select your preferred Git service (GitLab or GitHub) -4. Configure the settings based on your choice: - -### GitLab Configuration - - **GitLab Personal Access Token**: Create a token in GitLab (User Settings → Access Tokens) with "API" scope - - **GitLab Base URL**: Defaults to `https://gitlab.com` (change if using self-hosted GitLab) - - **Project ID**: Found in your GitLab project's overview page - -### GitHub Configuration - - **GitHub Personal Access Token**: Create a token in GitHub (Settings → Developer Settings → Personal Access Tokens) with "repo" scope - - **Repository Owner**: Your GitHub username or organization name - - **Repository Name**: Name of the GitHub repository - -### Common Settings - - **Branch**: The branch to sync with (defaults to `main`) - - **Root Path**: Optional path prefix in the repository (e.g., "notes" to store files in a notes/ folder) - - **Vault Folder**: Optional vault folder to sync (e.g., "sync" to only sync files in the sync/ folder, leave empty to sync all files) - -## Usage - -### Sync Status View - -The Sync Status View is the main interface for managing your file synchronization. - -**Opening the view:** -- Click the list-checks icon in the left ribbon, or -- Use Command Palette: "Open sync status view" - -**Understanding the interface:** - -1. **Service Information Panel** - - Shows current service (GitLab/GitHub), branch, and vault folder - - Displays last sync time - -2. **Control Buttons** - - **Refresh status**: Check all files against remote repository (shows progress bar) - - **Select all**: Select all files in current filter view - - **Deselect all**: Clear all selections - - **Push selected (N)**: Push all selected files to remote - - **Pull selected (N)**: Pull all selected files from remote - - **Delete selected (N)**: Delete selected local files (with confirmation) - -3. **Status Filters** - - **All**: Show all files - - **Synced**: Files that match remote (✓) - - **Modified**: Files with local changes (⚠) - - **Not in remote**: Local files not yet pushed (✗) - - **Remote only**: Files on remote but not local (↓) - -4. **File Status Summary** - - Shows count of files in each status category - -5. **File List** - - Each file shows: checkbox, status icon, file path, and status text - - Click checkbox to select files for batch operations - - Files show different actions based on status: - - **Modified files**: Show diff button, Push, and Pull buttons - - **Not in remote**: Push to remote, Remove local file buttons - - **Remote only**: Pull from remote button - -**Workflow examples:** - -*Sync all changes to remote:* -1. Click "Refresh status" -2. Review modified files -3. Click "Select all" or manually select files -4. Click "Push selected" - -*Pull new files from remote:* -1. Click "Refresh status" -2. Click "Remote only" filter -3. Click "Select all" -4. Click "Pull selected" - -*Clean up local files not in remote:* -1. Click "Refresh status" -2. Click "Not in remote" filter -3. Select unwanted files -4. Click "Delete selected" - -### Push Files - -**Single file:** -- Click the cloud upload icon in the left ribbon, or -- Use Command Palette: "Push current file to GitLab/GitHub", or -- Right-click a file and select "Push to GitLab/GitHub" -- Status updates automatically after push - -**Multiple files:** -- Open Sync Status View -- Select files using checkboxes -- Click "Push selected (N)" -- Or use "Refresh status" and filter by "Modified" or "Not in remote" - -### Pull Files - -**Single file:** -- Use Command Palette: "Pull current file from GitLab/GitHub", or -- Right-click a file and select "Pull from GitLab/GitHub" -- Status updates automatically after pull - -**Multiple files:** -- Open Sync Status View -- Select files using checkboxes -- Click "Pull selected (N)" -- Or filter by "Remote only" to see files only on remote - -**Pull remote-only files:** -- These are files that exist on remote but not in your local vault -- Open Sync Status View → Click "Remote only" filter -- Select files and click "Pull selected" to download them - -### Batch Operations - -**Push all modified files:** -- Use Command Palette: "Push all markdown files" -- Confirms before pushing -- Shows progress during operation -- Auto-refreshes status when complete - -**Pull all modified files:** -- Use Command Palette: "Pull all markdown files" -- Warns about overwriting local changes -- Shows progress during operation -- Auto-refreshes status when complete - -### Conflict Resolution - -When a conflict is detected: -1. A modal will appear showing both local and remote versions -2. Review the differences in the diff viewer -3. Choose to keep either the local or remote version -4. The chosen version will be synced -5. File status updates automatically +# Git File Sync -## Development +[![Release](https://img.shields.io/github/v/release/firstsun-dev/git-files-push?style=flat-square)](https://github.com/firstsun-dev/git-files-push/releases) +[![Downloads](https://img.shields.io/github/downloads/firstsun-dev/git-files-push/total?style=flat-square)](https://github.com/firstsun-dev/git-files-push/releases) +[![License](https://img.shields.io/github/license/firstsun-dev/git-files-push?style=flat-square)](LICENSE) +[![Obsidian](https://img.shields.io/badge/Obsidian-v0.15.0+-purple?style=flat-square)](https://obsidian.md) -### Prerequisites -- Node.js v16 or higher -- npm or yarn +**Git File Sync** is a powerful Obsidian plugin that enables seamless synchronization of individual notes with GitLab or GitHub repositories. Unlike full-vault sync solutions, it gives you granular control over what gets pushed and pulled, making it perfect for shared projects, selective backups, and cross-platform workflows. -### Setup -```bash -# Clone the repository -git clone https://github.com/tianyao/gitlab-files-push.git +[繁體中文使用說明](USAGE_zh.md) -# Install dependencies -npm install +![sync-status](imgs/sync-status.png) +*The Sync Status View provides a clear overview of your files, allowing you to selectively push, pull, or view diffs for modified files.* -# Start development mode (watch mode) -npm run dev -``` +![conflict](imgs/git-diff.png) +*The Built-in Diff Viewer lets you compare local and remote changes side-by-side before syncing.* + +--- + +## Key Features + +### Selective Synchronization +Don't sync your whole vault. Selectively push or pull individual notes, or use batch operations for specific folders. Perfect for keeping personal notes private while sharing project files. + +### Visual Sync Dashboard +A comprehensive dashboard provides a bird's-eye view of your vault's status: +- **Status Filtering**: Instantly see what's modified, new, or missing. +- **Visual Diffs**: Compare local and remote changes line-by-line before syncing. +- **Remote-Only Detection**: Identify files existing on GitLab/GitHub that aren't in your vault yet. -### Available Commands -- `npm run dev` - Build in watch mode using esbuild -- `npm run build` - Type check and build for production -- `npm run lint` - Run ESLint checks -- `npm run test` - Run Vitest test suite -- `npm run test:ui` - Run tests with UI -- `npm run version` - Bump version in manifest.json and versions.json +### Intelligent Conflict Resolution +When versions clash, Git File Sync provides a dedicated diff viewer to help you resolve conflicts manually. Choose the local version, the remote version, or merge them with confidence. + +### Mobile First +Full support for Obsidian Mobile. Push and pull your notes on the go with a responsive UI designed specifically for touch interfaces. + +--- + +## Installation + +### From Community Plugins (Recommended) +1. Open **Obsidian Settings** > **Community plugins**. +2. Click **Browse** and search for `Git File Sync`. +3. Click **Install**, then **Enable**. ### Manual Installation +1. Download the latest `main.js`, `manifest.json`, and `styles.css` from the [Releases](https://github.com/firstsun-dev/git-files-push/releases) page. +2. Create a folder named `git-file-sync` in `/.obsidian/plugins/`. +3. Copy the downloaded files into that folder. +4. Reload Obsidian and enable the plugin. -Copy `main.js`, `manifest.json`, and `styles.css` (if exists) to your vault: -``` -VaultFolder/.obsidian/plugins/git-file-push/ -``` +--- + +## Configuration + +![Plugin Settings](imgs/plugin-settings.png) +*Configure the plugin by selecting your preferred Git service and providing the necessary credentials.* + +### 1. Choose Your Service +Go to **Settings** > **Git File Sync** and select either **GitLab** or **GitHub**. -## Project Structure +### 2. Provider Setup +| Service | Required Info | Scope Needed | +| :--- | :--- | :--- | +| **GitLab** | Personal Access Token, Project ID, Base URL | `api` | +| **GitHub** | Personal Access Token, Owner, Repo Name | `repo` | -- `src/main.ts` - Main plugin class and command registration -- `src/settings.ts` - Settings interface and configuration UI -- `src/services/gitlab-service.ts` - GitLab API integration -- `src/logic/sync-manager.ts` - Sync logic and conflict handling -- `esbuild.config.mjs` - Build configuration +### 3. Common Settings +- **Branch**: Specify the target branch (default: `main`). +- **Root Path**: Prefix for files in the repository (e.g., `notes/`). +- **Vault Folder**: Limit sync to a specific folder in your vault. -## Releasing +--- -1. Update `minAppVersion` in `manifest.json` if needed -2. Run `npm run version` to bump version numbers -3. Create a GitHub release with tag matching the version -4. Upload `manifest.json`, `main.js`, and `styles.css` as release assets +## Usage Guide -## Code Quality +### First-Time Setup +Once configured, you should perform an initial status check: +1. Open the **Sync Status View** by clicking the list icon in the left ribbon or using the Command Palette (`Open sync status view`). +2. Click **Refresh status**. The plugin will compare your local files with the remote repository. +3. Review the file list to see which files are synchronized, modified, or missing. + +### Daily Workflow: Pushing Changes +When you finish editing a note and want to save it to Git: +- **Current Note**: Use the cloud icon in the ribbon or the command `Push current file to GitLab/GitHub`. +- **Multiple Notes**: Open the Sync Status View, use the **Modified** filter, select the files you want to sync, and click **Push selected**. +- **Context Menu**: Right-click any file in the File Explorer and select `Push to GitLab/GitHub`. + +### Daily Workflow: Pulling Changes +To get the latest updates from other devices: +1. Open the Sync Status View and click **Refresh status**. +2. Files with remote updates will show as **Modified** or **Remote only**. +3. Select these files and click **Pull selected**. +4. **Warning**: Pulling will overwrite local changes. If there are conflicts, the Conflict Resolution tool will open. + +### Handling Conflicts +If a file has changed both locally and on the remote server: +1. A **Conflict Resolution** window will appear. +2. The left pane shows your **Local** version, and the right pane shows the **Remote** version. +3. Review the differences. +4. Click **Keep Local** to overwrite the remote version on next push, or **Keep Remote** to accept the remote changes and overwrite your local file. + +### Mobile Synchronization +On mobile devices: +- Swipe from the left to access the ribbon and open the Sync Status View. +- Use the **Pull** action to keep your mobile vault up to date before editing. +- After editing, use the **Push** action to save your changes back to the repository. + +--- + +## Privacy and Security + +- **Local Storage**: Your Personal Access Tokens (PAT) are stored locally in the plugin's data folder within your vault. They are never sent to any server other than GitLab/GitHub. +- **No Telemetry**: This plugin does not collect any data or usage analytics. + +--- + +## Development + +If you want to contribute or build from source: + +```bash +# Clone and install +git clone https://github.com/firstsun-dev/git-files-push.git +npm install + +# Development build +npm run dev + +# Production build +npm run build +``` -- ESLint is configured with Obsidian-specific rules -- Run `npm run lint` to check for issues -- Husky pre-commit hooks ensure code quality +--- ## License -0-BSD +This project is licensed under the [MIT License](LICENSE). -## Author +--- -[tianyao](https://github.com/tianyao) +**Created by [firstsun-dev](https://github.com/firstsun-dev)** diff --git a/USAGE_zh.md b/USAGE_zh.md new file mode 100644 index 0000000..9c88ee5 --- /dev/null +++ b/USAGE_zh.md @@ -0,0 +1,81 @@ +# Git File Sync 使用說明書 + +本指南將引導您如何使用 Git File Sync 插件,在行動裝置與桌面電腦之間,透過 GitLab 或 GitHub 輕鬆同步您的筆記。 + +--- + +## 1. 初始設定 + +在開始同步之前,請確保您已完成以下設定: + +![Plugin Settings](imgs/plugin-settings.png) +*在設定面板選擇您的 Git 服務並填入對應的憑證與路徑。* + +1. **選擇服務**:在 `設定` > `Git File Sync` 中選擇 GitLab 或 GitHub。 +2. **填寫憑證**: + - **GitHub**:需要 個人存取權杖 (PAT)、帳號名稱、儲存庫名稱。 + - **GitLab**:需要 個人存取權杖 (PAT)、專案 ID、伺服器網址 (預設為 gitlab.com)。 +3. **儲存庫路徑**:如果您想將筆記存放在儲存庫的特定資料夾(例如 `notes/`),請在 `Root Path` 中設定。 + +--- + +## 2. 核心操作流程 + +### 💡 檢查同步狀態 +每次開始工作或切換裝置時,建議先檢查狀態: +1. 點擊側邊欄的 **清單圖示** 或使用指令面板 (`Ctrl/Cmd + P`) 輸入 `Open sync status view`。 +2. 點擊 **Refresh status**。 +3. 您會看到檔案清單,標示為: + - **Synced**:已同步(與雲端一致)。 + - **Modified**:本機已修改(需要 Push)。 + - **Remote only**:雲端有新檔案(需要 Pull)。 + +![sync-status](imgs/sync-status.png) +*同步狀態面板讓您可以一目了然地確認哪些檔案已經修改,並進行上傳或下載。* + +--- + +### ⬆️ 如何上傳(Push) +當您寫完筆記,想備份到雲端時: +- **單一檔案**: + - 點擊左側功能列的 **雲端上傳圖示**。 + - 或者在檔案列表點擊右鍵,選擇 `Push to GitLab/GitHub`。 +- **批量上傳**: + - 在同步面板勾選多個檔案,點擊下方的 **Push selected**。 + +--- + +### ⬇️ 如何下載(Pull) +當您在另一台裝置更新了筆記,想同步回目前裝置時: +1. 打開同步面板,點擊 **Refresh status**。 +2. 找到顯示為 **Remote only** 或 **Modified** (雲端版本較新) 的檔案。 +3. 勾選後點擊 **Pull selected**。 +4. **注意**:Pull 會覆蓋掉您本機的內容。如果有衝突,會自動開啟衝突解決視窗。 + +--- + +## 3. 衝突處理 (Conflict Resolution) + +如果同一個檔案在本機和雲端都被修改過,同步時會跳出衝突視窗: +1. 左側為 **本機版本**,右側為 **雲端版本**。 +2. 您可以查看差異處。 +3. 選擇 **Keep Local**(保留本機)或 **Keep Remote**(採用雲端版本)。 +4. 選擇後系統會自動更新檔案。 + +![conflict](imgs/git-diff.png) +*內建的差異比對工具 (Diff Viewer) 可讓您在同步前並排比對本機與雲端的修改差異。* + +--- + +## 4. 行動裝置使用技巧 + +- **開啟面板**:從螢幕左側向右滑動,展開功能列即可看到同步圖示。 +- **工作前先 Pull**:建議每次開始寫筆記前,先點一下 Refresh 確保讀取到最新版本。 +- **完成後即 Push**:寫完後隨手 Push,確保您的變更已儲存至雲端。 + +--- + +## 🔒 隱私與安全 + +- 您的存取權杖 (Token) 僅會儲存在您本機的 Obsidian 資料夾內。 +- 本插件不會收集任何個人數據或使用紀錄。 diff --git a/imgs/git-diff.png b/imgs/git-diff.png new file mode 100644 index 0000000..5ac2f96 Binary files /dev/null and b/imgs/git-diff.png differ diff --git a/imgs/plugin-settings.png b/imgs/plugin-settings.png new file mode 100644 index 0000000..d0ac503 Binary files /dev/null and b/imgs/plugin-settings.png differ diff --git a/imgs/sync-status.png b/imgs/sync-status.png new file mode 100644 index 0000000..469e524 Binary files /dev/null and b/imgs/sync-status.png differ diff --git a/manifest.json b/manifest.json index 4b927da..5730c74 100644 --- a/manifest.json +++ b/manifest.json @@ -1,10 +1,10 @@ { - "id": "git-file-push", - "name": "Git File Push", + "id": "git-file-sync", + "name": "Git File Sync", "version": "1.0.0", "minAppVersion": "0.15.0", - "description": "Sync individual notes with GitLab or GitHub. Push, pull, and track changes across mobile and desktop with visual diff and conflict resolution.", - "author": "tianyao", - "authorUrl": "https://github.com/tianyao", + "description": "Selectively sync individual notes with GitLab or GitHub. Push, pull, diff, and resolve conflicts — file by file, on mobile and desktop.", + "author": "firstsun-dev", + "authorUrl": "https://github.com/firstsun-dev", "isDesktopOnly": false } \ No newline at end of file diff --git a/package.json b/package.json index 48802db..119258e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "git-file-push", + "name": "git-file-sync", "version": "1.0.0", - "description": "Push and pull individual notes to GitLab or GitHub across mobile and desktop.", + "description": "Selectively sync individual notes with GitLab or GitHub across mobile and desktop.", "main": "main.js", "type": "module", "scripts": { @@ -15,7 +15,7 @@ "semantic-release": "semantic-release" }, "keywords": [], - "license": "0-BSD", + "license": "MIT", "devDependencies": { "@eslint/js": "9.30.1", "@eslint/json": "0.14.0", diff --git a/src/logic/sync-manager.ts b/src/logic/sync-manager.ts index 5172f5f..eb5df37 100644 --- a/src/logic/sync-manager.ts +++ b/src/logic/sync-manager.ts @@ -234,7 +234,7 @@ export class SyncManager { private async saveSettings() { /* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any */ // @ts-ignore - access private method to save settings - const plugin = (this.app as any).plugins?.plugins?.['git-file-push']; + const plugin = (this.app as any).plugins?.plugins?.['git-file-sync']; if (plugin) { await plugin.saveSettings(); } diff --git a/src/main.ts b/src/main.ts index 97217b1..d296d3e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,4 +1,4 @@ -import { Plugin, TFile, MarkdownView, Notice } from 'obsidian'; +import { Plugin, TFile, MarkdownView, Notice, Platform } from 'obsidian'; import { DEFAULT_SETTINGS, GitLabFilesPushSettings, GitLabSyncSettingTab } from "./settings"; import { GitLabService } from './services/gitlab-service'; import { GitHubService } from './services/github-service'; @@ -40,7 +40,7 @@ export default class GitLabFilesPush extends Plugin { const serviceName = this.settings.serviceType === 'gitlab' ? 'GitLab' : 'GitHub'; - this.addRibbonIcon('upload-cloud', `Push to ${serviceName}`, (evt: MouseEvent) => { + this.addRibbonIcon('upload-cloud', Platform.isMobile ? `Push` : `Push to ${serviceName}`, (evt: MouseEvent) => { const activeView = this.app.workspace.getActiveViewOfType(MarkdownView); if (activeView && activeView.file instanceof TFile) { void this.sync.pushFile(activeView.file); diff --git a/src/ui/SyncStatusView.ts b/src/ui/SyncStatusView.ts index 8b7d039..c2c4e6a 100644 --- a/src/ui/SyncStatusView.ts +++ b/src/ui/SyncStatusView.ts @@ -1,4 +1,4 @@ -import { ItemView, WorkspaceLeaf, TFile, Notice } from 'obsidian'; +import { ItemView, WorkspaceLeaf, TFile, Notice, Platform, setTooltip } from 'obsidian'; import GitLabFilesPush from '../main'; export const SYNC_STATUS_VIEW_TYPE = 'sync-status-view'; @@ -13,212 +13,214 @@ interface FileStatus { diff?: string; } +interface DiffSide { + lineNum: number | null; + content: string | null; + type: 'removed' | 'added' | 'unchanged' | 'empty'; +} + +interface DiffRow { + left: DiffSide; + right: DiffSide; +} + +type FilterValue = 'all' | 'synced' | 'modified' | 'unsynced' | 'remote-only'; + export class SyncStatusView extends ItemView { plugin: GitLabFilesPush; private fileStatuses: Map = new Map(); private isRefreshing = false; - private statusFilter: 'all' | 'synced' | 'modified' | 'unsynced' | 'remote-only' = 'all'; + private statusFilter: FilterValue = 'all'; private selectedFiles: Set = new Set(); private lastSyncTime: number = 0; - private previousFilter: typeof this.statusFilter = 'all'; constructor(leaf: WorkspaceLeaf, plugin: GitLabFilesPush) { super(leaf); this.plugin = plugin; } - getViewType(): string { - return SYNC_STATUS_VIEW_TYPE; - } - - getDisplayText(): string { - return 'Sync status'; - } - - getIcon(): string { - return 'git-compare'; - } + getViewType(): string { return SYNC_STATUS_VIEW_TYPE; } + getDisplayText(): string { return 'Sync status'; } + getIcon(): string { return 'git-compare'; } async onOpen(): Promise { const container = this.containerEl.children[1]; if (!container) return; container.empty(); container.addClass('sync-status-view'); - this.renderView(); } private renderView(): void { - const container = this.containerEl.children[1]; + const container = this.containerEl.children[1] as HTMLElement; if (!container) return; container.empty(); - const headerEl = container.createDiv({ cls: 'sync-status-header' }); - headerEl.createEl('h4', { text: 'Repository sync status' }); + this.renderInfoStrip(container); + this.renderTabs(container); + this.renderActionBar(container); - const serviceName = this.plugin.settings.serviceType === 'gitlab' ? 'GitLab' : 'GitHub'; - const serviceInfo = container.createDiv({ cls: 'sync-status-info' }); - serviceInfo.createEl('div', { text: `Service: ${serviceName}` }); - serviceInfo.createEl('div', { text: `Branch: ${this.plugin.settings.branch}` }); - if (this.plugin.settings.vaultFolder) { - serviceInfo.createEl('div', { text: `Vault Folder: ${this.plugin.settings.vaultFolder}` }); - } - if (this.lastSyncTime > 0) { - const lastSyncDate = new Date(this.lastSyncTime); - const timeStr = lastSyncDate.toLocaleString(); - serviceInfo.createEl('div', { text: `Last sync: ${timeStr}` }); + const listEl = container.createDiv({ cls: 'ssv-list' }); + if (this.fileStatuses.size === 0) { + listEl.createDiv({ cls: 'ssv-empty', text: 'Click "Refresh" to check sync status' }); + } else { + this.renderFileList(listEl); } + } - const buttonContainer = container.createDiv({ cls: 'sync-status-buttons' }); - - // Refresh button - const refreshBtn = buttonContainer.createEl('button', { text: 'Refresh status', cls: 'refresh-btn' }); - refreshBtn.addEventListener('click', () => { - void this.refreshAllStatuses(); - }); - - // Selection buttons container - const selectionContainer = container.createDiv({ cls: 'sync-selection-buttons' }); - - const selectAllBtn = selectionContainer.createEl('button', { text: 'Select all' }); - selectAllBtn.addEventListener('click', () => { - const visibleStatuses = this.statusFilter === 'all' - ? Array.from(this.fileStatuses.values()) - : Array.from(this.fileStatuses.values()).filter(s => s.status === this.statusFilter); - - for (const status of visibleStatuses) { - this.selectedFiles.add(status.path); - } - this.renderView(); - }); - - const deselectAllBtn = selectionContainer.createEl('button', { text: 'Deselect all' }); - deselectAllBtn.addEventListener('click', () => { - this.selectedFiles.clear(); - this.renderView(); - }); - - // Repository operation buttons container - const repoContainer = container.createDiv({ cls: 'sync-repo-buttons' }); - - // Calculate counts for each operation - const selectedStatuses = Array.from(this.selectedFiles) - .map(path => this.fileStatuses.get(path)) - .filter(s => s) as FileStatus[]; + private renderInfoStrip(container: HTMLElement): void { + const el = container.createDiv({ cls: 'ssv-info' }); + const serviceName = this.plugin.settings.serviceType === 'gitlab' ? 'GitLab' : 'GitHub'; - const canPush = selectedStatuses.filter(s => s.file && (s.status === 'modified' || s.status === 'unsynced')).length; - const canPull = selectedStatuses.filter(s => s.status === 'modified' || s.status === 'remote-only').length; - const canDelete = selectedStatuses.filter(s => s.file || s.status === 'remote-only').length; + el.createSpan({ cls: 'ssv-info-item', text: serviceName }); - const pushSelectedBtn = repoContainer.createEl('button', { - text: `Push selected (${canPush})`, - cls: 'push-all-btn' - }); - pushSelectedBtn.disabled = canPush === 0; - pushSelectedBtn.addEventListener('click', () => { - void this.pushSelected(); - }); + if (!Platform.isMobile) { + el.createSpan({ cls: 'ssv-info-sep', text: '·' }); + const branchEl = el.createSpan({ cls: 'ssv-info-item' }); + branchEl.textContent = `⎇ ${this.plugin.settings.branch}`; + } - const pullSelectedBtn = repoContainer.createEl('button', { - text: `Pull selected (${canPull})`, - cls: 'pull-all-btn' - }); - pullSelectedBtn.disabled = canPull === 0; - pullSelectedBtn.addEventListener('click', () => { - void this.pullSelected(); - }); + if (this.plugin.settings.vaultFolder) { + el.createSpan({ cls: 'ssv-info-sep', text: '·' }); + el.createSpan({ cls: 'ssv-info-item', text: `📁 ${this.plugin.settings.vaultFolder}` }); + } - const deleteSelectedBtn = repoContainer.createEl('button', { - text: `Delete selected (${canDelete})`, - cls: 'delete-btn' - }); - deleteSelectedBtn.disabled = canDelete === 0; - deleteSelectedBtn.addEventListener('click', () => { - void this.deleteSelected(); - }); + if (this.lastSyncTime > 0) { + el.createSpan({ cls: 'ssv-info-sep', text: '·' }); + el.createSpan({ + cls: 'ssv-info-time', + text: Platform.isMobile ? new Date(this.lastSyncTime).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'}) : `Last sync: ${new Date(this.lastSyncTime).toLocaleTimeString()}` + }); + } + } - // Filter buttons - const filterContainer = container.createDiv({ cls: 'sync-status-filters' }); - filterContainer.createEl('span', { text: 'Show: ' }); - - type FilterValue = 'all' | 'synced' | 'modified' | 'unsynced' | 'remote-only'; - const filters: Array<{ value: FilterValue; label: string }> = [ - { value: 'all', label: 'All' }, - { value: 'synced', label: 'Synced' }, - { value: 'modified', label: 'Modified' }, - { value: 'unsynced', label: 'Not in remote' }, - { value: 'remote-only', label: 'Remote only' } + private renderTabs(container: HTMLElement): void { + const all = Array.from(this.fileStatuses.values()); + const counts: Record = { + all: all.length, + synced: all.filter(s => s.status === 'synced').length, + modified: all.filter(s => s.status === 'modified').length, + unsynced: all.filter(s => s.status === 'unsynced').length, + 'remote-only': all.filter(s => s.status === 'remote-only').length, + }; + + const tabs: Array<{ value: FilterValue; label: string; icon: string }> = [ + { value: 'all', label: 'All', icon: '' }, + { value: 'synced', label: 'Synced', icon: '✓' }, + { value: 'modified', label: 'Changed', icon: '⚠' }, + { value: 'unsynced', label: 'Local only', icon: '↑' }, + { value: 'remote-only', label: 'Remote', icon: '↓' }, ]; - for (const filter of filters) { - const btn = filterContainer.createEl('button', { - text: filter.label, - cls: this.statusFilter === filter.value ? 'filter-active' : '' + const tabsEl = container.createDiv({ cls: 'ssv-tabs' }); + for (const tab of tabs) { + const btn = tabsEl.createEl('button', { + cls: `ssv-tab${this.statusFilter === tab.value ? ' active' : ''}` }); + if (tab.icon) btn.createSpan({ text: tab.icon }); + btn.createSpan({ cls: 'ssv-tab-label', text: ` ${tab.label}` }); + const count = counts[tab.value]; + if (tab.value === 'all' || count > 0) { + btn.createSpan({ cls: 'ssv-tab-count', text: String(count) }); + } + setTooltip(btn, tab.label); btn.addEventListener('click', () => { - // Clear selections when switching filter - if (this.statusFilter !== filter.value) { - this.selectedFiles.clear(); - } - this.statusFilter = filter.value; + if (this.statusFilter !== tab.value) this.selectedFiles.clear(); + this.statusFilter = tab.value; this.renderView(); }); } + } - const statusContainer = container.createDiv({ cls: 'sync-status-list' }); - - if (this.fileStatuses.size === 0) { - statusContainer.createEl('div', { - text: 'Click "Refresh status" to check all files', - cls: 'sync-status-empty' + private renderActionBar(container: HTMLElement): void { + const all = Array.from(this.fileStatuses.values()); + const visible = this.statusFilter === 'all' + ? all + : all.filter(s => s.status === this.statusFilter); + + const selected = Array.from(this.selectedFiles) + .map(p => this.fileStatuses.get(p)) + .filter(Boolean) as FileStatus[]; + + const canPush = selected.filter(s => s.file && (s.status === 'modified' || s.status === 'unsynced')).length; + const canPull = selected.filter(s => s.status === 'modified' || s.status === 'remote-only').length; + const canDelete = selected.filter(s => s.file || s.status === 'remote-only').length; + + const allSelected = visible.length > 0 && visible.every(s => this.selectedFiles.has(s.path)); + + const bar = container.createDiv({ cls: 'ssv-action-bar' }); + + const refreshBtn = bar.createEl('button', { cls: 'ssv-btn ssv-btn-refresh' }); + refreshBtn.createSpan({ text: '↻' }); + refreshBtn.createSpan({ cls: 'ssv-btn-label', text: ' Refresh' }); + setTooltip(refreshBtn, 'Refresh all statuses'); + refreshBtn.addEventListener('click', () => void this.refreshAllStatuses()); + + if (this.fileStatuses.size > 0) { + bar.createDiv({ cls: 'ssv-bar-spacer' }); + + const selectRow = bar.createDiv({ cls: 'ssv-select-row' }); + const cb = selectRow.createEl('input', { type: 'checkbox' }); + cb.checked = allSelected; + cb.indeterminate = this.selectedFiles.size > 0 && !allSelected; + selectRow.createSpan({ cls: 'ssv-select-label', text: 'Select' }); + cb.addEventListener('change', () => { + if (cb.checked) { + for (const s of visible) this.selectedFiles.add(s.path); + } else { + this.selectedFiles.clear(); + } + this.renderView(); }); - } else { - this.renderFileStatuses(statusContainer); + + const pushBtn = bar.createEl('button', { cls: 'ssv-btn ssv-btn-push' }); + pushBtn.createSpan({ text: '↑' }); + pushBtn.createSpan({ cls: 'ssv-btn-label', text: ` Push (${canPush})` }); + pushBtn.disabled = canPush === 0; + setTooltip(pushBtn, `Push ${canPush} files`); + pushBtn.addEventListener('click', () => void this.pushSelected()); + + const pullBtn = bar.createEl('button', { cls: 'ssv-btn ssv-btn-pull' }); + pullBtn.createSpan({ text: '↓' }); + pullBtn.createSpan({ cls: 'ssv-btn-label', text: ` Pull (${canPull})` }); + pullBtn.disabled = canPull === 0; + setTooltip(pullBtn, `Pull ${canPull} files`); + pullBtn.addEventListener('click', () => void this.pullSelected()); + + const delBtn = bar.createEl('button', { cls: 'ssv-btn ssv-btn-delete' }); + delBtn.createSpan({ text: '✕' }); + delBtn.createSpan({ cls: 'ssv-btn-label', text: ` Del (${canDelete})` }); + delBtn.disabled = canDelete === 0; + setTooltip(delBtn, `Delete ${canDelete} files`); + delBtn.addEventListener('click', () => void this.deleteSelected()); } } - private renderFileStatuses(container: HTMLElement): void { - const allStatuses = Array.from(this.fileStatuses.values()); - - // Apply filter + private renderFileList(container: HTMLElement): void { + const all = Array.from(this.fileStatuses.values()); const statuses = this.statusFilter === 'all' - ? allStatuses - : allStatuses.filter(s => s.status === this.statusFilter); - - const summary = container.createDiv({ cls: 'sync-status-summary' }); - const synced = allStatuses.filter(s => s.status === 'synced').length; - const modified = allStatuses.filter(s => s.status === 'modified').length; - const unsynced = allStatuses.filter(s => s.status === 'unsynced').length; - const remoteOnly = allStatuses.filter(s => s.status === 'remote-only').length; - - summary.createEl('div', { text: `✓ Synced: ${synced}` }); - summary.createEl('div', { text: `⚠ Modified: ${modified}` }); - summary.createEl('div', { text: `✗ Unsynced: ${unsynced}` }); - summary.createEl('div', { text: `↓ Remote only: ${remoteOnly}` }); + ? all + : all.filter(s => s.status === this.statusFilter); if (statuses.length === 0) { - container.createEl('div', { - text: this.statusFilter === 'all' ? 'No files found' : `No ${this.statusFilter} files`, - cls: 'sync-status-empty' - }); + container.createDiv({ cls: 'ssv-empty', text: `No ${this.statusFilter} files` }); return; } - - for (const fileStatus of statuses) { - this.renderFileStatus(container, fileStatus); - } + for (const fs of statuses) this.renderFileItem(container, fs); } - private renderFileStatus(container: HTMLElement, fileStatus: FileStatus): void { - const fileEl = container.createDiv({ cls: 'sync-status-file' }); + private renderFileItem(container: HTMLElement, fileStatus: FileStatus): void { + const { icon, label, iconCls, badgeCls, fileCls } = this.statusMeta(fileStatus.status); + + const fileEl = container.createDiv({ cls: `ssv-file ${fileCls}` }); - const headerEl = fileEl.createDiv({ cls: 'sync-status-file-header' }); + // ── Main row ── + const row = fileEl.createDiv({ cls: 'ssv-file-row' }); - // Add checkbox - const checkbox = headerEl.createEl('input', { type: 'checkbox' }); - checkbox.checked = this.selectedFiles.has(fileStatus.path); - checkbox.addEventListener('change', () => { - if (checkbox.checked) { + const cb = row.createEl('input', { type: 'checkbox', cls: 'ssv-file-checkbox' }); + cb.checked = this.selectedFiles.has(fileStatus.path); + cb.addEventListener('change', () => { + if (cb.checked) { this.selectedFiles.add(fileStatus.path); } else { this.selectedFiles.delete(fileStatus.path); @@ -226,260 +228,282 @@ export class SyncStatusView extends ItemView { this.renderView(); }); - let icon = '○'; - let statusText = ''; - let statusClass = ''; - - switch (fileStatus.status) { - case 'synced': - icon = '✓'; - statusText = 'Synced'; - statusClass = 'status-synced'; - break; - case 'modified': - icon = '⚠'; - statusText = 'Modified'; - statusClass = 'status-modified'; - break; - case 'unsynced': - icon = '✗'; - statusText = 'Not in remote'; - statusClass = 'status-unsynced'; - break; - case 'remote-only': - icon = '↓'; - statusText = 'Remote only'; - statusClass = 'status-remote-only'; - break; - case 'checking': - icon = '⟳'; - statusText = 'Checking...'; - statusClass = 'status-checking'; - break; - } - - headerEl.createSpan({ text: `${icon} `, cls: `status-icon ${statusClass}` }); - headerEl.createSpan({ text: fileStatus.path, cls: 'file-path' }); - headerEl.createSpan({ text: ` (${statusText})`, cls: `status-text ${statusClass}` }); - - if (fileStatus.status === 'unsynced' && fileStatus.file) { - const actionsEl = fileEl.createDiv({ cls: 'sync-status-actions' }); - const pushBtn = actionsEl.createEl('button', { text: 'Push to remote' }); - const removeBtn = actionsEl.createEl('button', { text: 'Remove local file', cls: 'remove-btn' }); - - pushBtn.addEventListener('click', () => { - void (async () => { - try { - // Mark as checking - fileStatus.status = 'checking'; - this.renderView(); + row.createSpan({ cls: `ssv-file-icon ${iconCls}`, text: icon }); + row.createSpan({ cls: 'ssv-file-path', text: fileStatus.path }); + row.createSpan({ cls: `ssv-status-badge ${badgeCls}`, text: label }); - await this.plugin.sync.pushFile(fileStatus.file || fileStatus.path); + if (fileStatus.status === 'synced' || fileStatus.status === 'checking') return; - // Wait a bit for the remote to update - await new Promise(resolve => setTimeout(resolve, 500)); + // ── Action row ── + const actions = fileEl.createDiv({ cls: 'ssv-file-actions' }); - await this.refreshFileStatus(fileStatus.file || fileStatus.path); - this.renderView(); - } catch (e) { - console.error(e); - new Notice(`Push failed: ${e instanceof Error ? e.message : String(e)}`); - // Refresh to show current state - await this.refreshFileStatus(fileStatus.file || fileStatus.path); - this.renderView(); - } - })(); + // Diff toggle (modified only) + if (fileStatus.status === 'modified' && fileStatus.diff) { + const diffBtn = actions.createEl('button', { cls: 'ssv-action-btn diff' }); + diffBtn.createSpan({ text: '≡' }); + const btnLabel = diffBtn.createSpan({ cls: 'ssv-btn-label', text: ' Diff' }); + const diffEl = this.renderDiffPanel(fileEl, fileStatus); + setTooltip(diffBtn, 'Toggle diff view'); + diffBtn.addEventListener('click', () => { + const open = diffEl.hasClass('visible'); + diffEl.toggleClass('visible', !open); + btnLabel.setText(open ? ' Diff' : ' Hide'); + const firstChild = diffBtn.firstChild; + if (firstChild) firstChild.textContent = open ? '≡' : '▴'; }); + } - removeBtn.addEventListener('click', () => { - void (async () => { - const confirmed = await this.showConfirmDialog(`Delete local file "${fileStatus.path}"?`); - if (confirmed) { - try { - if (fileStatus.file) { - await this.app.fileManager.trashFile(fileStatus.file); - } else { - await this.app.vault.adapter.remove(fileStatus.path); - } - new Notice(`Deleted ${fileStatus.path}`); - this.fileStatuses.delete(fileStatus.path); - this.renderView(); - } catch (e) { - console.error(e); - new Notice(`Failed to delete: ${e instanceof Error ? e.message : String(e)}`); - } - } - })(); - }); + // Push + if ((fileStatus.status === 'modified' || fileStatus.status === 'unsynced') && fileStatus.file) { + const pushBtn = actions.createEl('button', { cls: 'ssv-action-btn push' }); + pushBtn.createSpan({ text: '↑' }); + pushBtn.createSpan({ cls: 'ssv-btn-label', text: ' Push' }); + setTooltip(pushBtn, 'Push to remote'); + pushBtn.addEventListener('click', () => void this.runSingleFile(fileStatus, 'push')); } - if (fileStatus.status === 'remote-only') { - const actionsEl = fileEl.createDiv({ cls: 'sync-status-actions' }); - const pullBtn = actionsEl.createEl('button', { text: 'Pull from remote' }); + // Pull + if (fileStatus.status === 'modified' || fileStatus.status === 'remote-only') { + const pullBtn = actions.createEl('button', { cls: 'ssv-action-btn pull' }); + pullBtn.createSpan({ text: '↓' }); + pullBtn.createSpan({ cls: 'ssv-btn-label', text: ' Pull' }); + setTooltip(pullBtn, 'Pull from remote'); + pullBtn.addEventListener('click', () => void this.runSingleFile(fileStatus, 'pull')); + } - pullBtn.addEventListener('click', () => { + // Remove local + if (fileStatus.status === 'unsynced' && fileStatus.file) { + const removeBtn = actions.createEl('button', { cls: 'ssv-action-btn danger' }); + removeBtn.createSpan({ text: '✕' }); + removeBtn.createSpan({ cls: 'ssv-btn-label', text: ' Remove' }); + setTooltip(removeBtn, 'Delete local file'); + removeBtn.addEventListener('click', () => { void (async () => { + const confirmed = await this.showConfirmDialog(`Delete local file "${fileStatus.path}"?`); + if (!confirmed) return; try { - // Mark as checking - fileStatus.status = 'checking'; - this.renderView(); - - const remote = await this.plugin.gitService.getFile(fileStatus.path, this.plugin.settings.branch); - - if (remote.content) { - // Ensure parent directories exist (recursively) - const pathParts = fileStatus.path.split('/'); - if (pathParts.length > 1) { - let currentPath = ''; - for (let i = 0; i < pathParts.length - 1; i++) { - currentPath += (i > 0 ? '/' : '') + pathParts[i]; - const dir = this.app.vault.getAbstractFileByPath(currentPath); - if (!dir) { - try { - await this.app.vault.createFolder(currentPath); - } catch { - // Folder might already exist, ignore error - } - } - } - } - - // Use adapter to support hidden files - await this.app.vault.adapter.write(fileStatus.path, remote.content); - - // Update sync metadata - this.plugin.settings.syncMetadata[fileStatus.path] = { - lastSyncedSha: remote.sha, - lastSyncedAt: Date.now(), - lastKnownPath: fileStatus.path - }; - await this.plugin.saveSettings(); - - new Notice(`Pulled ${fileStatus.path}`); - - // Wait for file to be created and vault to update - await new Promise(resolve => setTimeout(resolve, 1000)); - - // Force refresh all statuses to pick up the new file - await this.refreshAllStatuses(); + if (fileStatus.file) { + await this.app.fileManager.trashFile(fileStatus.file); + } else { + await this.app.vault.adapter.remove(fileStatus.path); } + new Notice(`Deleted ${fileStatus.path}`); + this.fileStatuses.delete(fileStatus.path); + this.renderView(); } catch (e) { - console.error(e); - new Notice(`Failed to pull: ${e instanceof Error ? e.message : String(e)}`); - // Refresh to show current state - await this.refreshAllStatuses(); + new Notice(`Failed to delete: ${e instanceof Error ? e.message : String(e)}`); } })(); }); } + } - if (fileStatus.status === 'modified' && fileStatus.diff) { - const diffToggle = headerEl.createEl('button', { - text: 'Show diff', - cls: 'diff-toggle' - }); + private async runSingleFile(fileStatus: FileStatus, op: 'push' | 'pull'): Promise { + try { + const originalStatus = fileStatus.status; + fileStatus.status = 'checking'; + this.renderView(); - const diffContainer = fileEl.createDiv({ cls: 'sync-status-diff' }); - diffContainer.addClass('hidden'); - - const diffPre = diffContainer.createEl('pre'); - - const diffLines = fileStatus.diff.split('\n'); - for (const line of diffLines) { - let type: 'header' | 'added' | 'removed' | 'unchanged' = 'unchanged'; - if (line.startsWith('---') || line.startsWith('+++')) type = 'header'; - else if (line.startsWith('+ ')) type = 'added'; - else if (line.startsWith('- ')) type = 'removed'; - - const lineEl = diffPre.createSpan({ cls: `diff-line ${type}` }); - lineEl.textContent = line + '\n'; + if (op === 'push') { + await this.plugin.sync.pushFile(fileStatus.file || fileStatus.path); + await new Promise(r => setTimeout(r, 500)); + } else if (originalStatus === 'remote-only' || !fileStatus.file) { + // pull remote-only + const remote = await this.plugin.gitService.getFile(fileStatus.path, this.plugin.settings.branch); + if (remote.content) { + await this.ensureParentDirs(fileStatus.path); + await this.app.vault.adapter.write(fileStatus.path, remote.content); + this.plugin.settings.syncMetadata[fileStatus.path] = { + lastSyncedSha: remote.sha, + lastSyncedAt: Date.now(), + lastKnownPath: fileStatus.path + }; + await this.plugin.saveSettings(); + } + await new Promise(r => setTimeout(r, 1000)); + await this.refreshAllStatuses(); + return; + } else { + await this.plugin.sync.pullFile(fileStatus.file || fileStatus.path); + await new Promise(r => setTimeout(r, 500)); } - diffToggle.addEventListener('click', () => { - if (diffContainer.hasClass('hidden')) { - diffContainer.removeClass('hidden'); - diffToggle.setText('Hide diff'); - } else { - diffContainer.addClass('hidden'); - diffToggle.setText('Show diff'); - } - }); + await this.refreshFileStatus(fileStatus.file || fileStatus.path); + this.renderView(); + } catch (e) { + new Notice(`${op === 'push' ? 'Push' : 'Pull'} failed: ${e instanceof Error ? e.message : String(e)}`); + await this.refreshFileStatus(fileStatus.file || fileStatus.path); + this.renderView(); + } + } - const actionsEl = fileEl.createDiv({ cls: 'sync-status-actions' }); - const pushBtn = actionsEl.createEl('button', { text: 'Push' }); - const pullBtn = actionsEl.createEl('button', { text: 'Pull' }); + private statusMeta(status: FileStatus['status']) { + switch (status) { + case 'synced': return { icon: '✓', label: 'Synced', iconCls: 'ssv-icon-synced', badgeCls: 'ssv-badge-synced', fileCls: 'status-synced' }; + case 'modified': return { icon: '⚠', label: 'Changed', iconCls: 'ssv-icon-modified', badgeCls: 'ssv-badge-modified', fileCls: 'status-modified' }; + case 'unsynced': return { icon: '↑', label: 'Local only', iconCls: 'ssv-icon-unsynced', badgeCls: 'ssv-badge-unsynced', fileCls: 'status-unsynced' }; + case 'remote-only': return { icon: '↓', label: 'Remote', iconCls: 'ssv-icon-remote', badgeCls: 'ssv-badge-remote', fileCls: 'status-remote' }; + default: return { icon: '⟳', label: 'Checking', iconCls: 'ssv-icon-checking', badgeCls: 'ssv-badge-checking', fileCls: 'status-checking' }; + } + } - pushBtn.addEventListener('click', () => { - void (async () => { - try { - // Mark as checking - fileStatus.status = 'checking'; - this.renderView(); + // ── Side-by-side diff ───────────────────────────────────────── - await this.plugin.sync.pushFile(fileStatus.file || fileStatus.path); + private renderDiffPanel(fileEl: HTMLElement, fileStatus: FileStatus): HTMLElement { + const diffEl = fileEl.createDiv({ cls: 'ssv-diff' }); + const rows = this.computeSideBySideDiff(fileStatus.remoteContent ?? '', fileStatus.localContent ?? ''); - // Wait a bit for the remote to update - await new Promise(resolve => setTimeout(resolve, 500)); + // Side-by-side (shown on wide containers via container query) + const splitEl = diffEl.createDiv({ cls: 'ssv-diff-split' }); + const grid = splitEl.createDiv({ cls: 'ssv-diff-grid' }); + grid.createDiv({ cls: 'ssv-diff-hd', text: 'Remote' }); + grid.createDiv({ cls: 'ssv-diff-hd', text: 'Local' }); + for (const row of rows) { + this.renderDiffCell(grid, row.left); + this.renderDiffCell(grid, row.right); + } - await this.refreshFileStatus(fileStatus.file || fileStatus.path); - this.renderView(); - } catch (e) { - console.error(e); - new Notice(`Push failed: ${e instanceof Error ? e.message : String(e)}`); - // Refresh to show current state - await this.refreshFileStatus(fileStatus.file || fileStatus.path); - this.renderView(); - } - })(); - }); + // Unified (shown on narrow containers / mobile via container query) + const unifiedEl = diffEl.createEl('pre', { cls: 'ssv-diff-unified' }); + for (const row of rows) { + const { left, right } = row; + if (left.type === 'removed') { + unifiedEl.createSpan({ cls: 'ssv-u-line removed' }).textContent = `- ${left.content ?? ''}\n`; + } + if (right.type === 'added') { + unifiedEl.createSpan({ cls: 'ssv-u-line added' }).textContent = `+ ${right.content ?? ''}\n`; + } + if (left.type === 'unchanged') { + unifiedEl.createSpan({ cls: 'ssv-u-line unchanged' }).textContent = ` ${left.content ?? ''}\n`; + } + } - pullBtn.addEventListener('click', () => { - void (async () => { - try { - // Mark as checking - fileStatus.status = 'checking'; - this.renderView(); + return diffEl; + } - await this.plugin.sync.pullFile(fileStatus.file || fileStatus.path); + private renderDiffCell(grid: HTMLElement, side: DiffSide): void { + const cell = grid.createDiv({ cls: `ssv-diff-cell ${side.type}` }); + cell.createSpan({ cls: 'ssv-diff-ln' }).textContent = side.lineNum !== null ? String(side.lineNum) : ''; + if (side.content !== null) { + cell.createSpan({ cls: 'ssv-diff-code' }).textContent = side.content; + } + } - // Wait a bit for the file to be written - await new Promise(resolve => setTimeout(resolve, 500)); + private computeSideBySideDiff(remote: string, local: string): DiffRow[] { + const normalize = (s: string) => s.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + const L = normalize(remote).split('\n'); + const R = normalize(local).split('\n'); + const m = L.length, n = R.length; + + // For very large files fall back to simple comparison + if (m * n > 250_000) return this.simpleDiff(L, R); + + // LCS DP — flat Uint32Array avoids noUncheckedIndexedAccess issues + const W = n + 1; + const dp = new Uint32Array((m + 1) * W); + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + dp[i * W + j] = L[i - 1] === R[j - 1] + ? (dp[(i - 1) * W + (j - 1)] ?? 0) + 1 + : Math.max(dp[(i - 1) * W + j] ?? 0, dp[i * W + (j - 1)] ?? 0); + } + } - await this.refreshFileStatus(fileStatus.file || fileStatus.path); - this.renderView(); - } catch (e) { - console.error(e); - new Notice(`Pull failed: ${e instanceof Error ? e.message : String(e)}`); - // Refresh to show current state - await this.refreshFileStatus(fileStatus.file || fileStatus.path); - this.renderView(); - } - })(); - }); + // Backtrack — li/ri = -1 when not applicable (avoids optional fields) + type Op = { type: 'unchanged' | 'removed' | 'added'; li: number; ri: number }; + const ops: Op[] = []; + let i = m, j = n; + while (i > 0 || j > 0) { + if (i > 0 && j > 0 && L[i - 1] === R[j - 1]) { + ops.push({ type: 'unchanged', li: i - 1, ri: j - 1 }); + i--; j--; + } else if (j > 0 && (i === 0 || (dp[i * W + (j - 1)] ?? 0) >= (dp[(i - 1) * W + j] ?? 0))) { + ops.push({ type: 'added', li: -1, ri: j - 1 }); + j--; + } else { + ops.push({ type: 'removed', li: i - 1, ri: -1 }); + i--; + } + } + ops.reverse(); + + // Pair consecutive removed ↔ added side-by-side + const rows: DiffRow[] = []; + let k = 0; + while (k < ops.length) { + const op = ops[k]; + if (op === undefined) break; + + if (op.type === 'unchanged') { + rows.push({ + left: { lineNum: op.li + 1, content: L[op.li] ?? null, type: 'unchanged' }, + right: { lineNum: op.ri + 1, content: R[op.ri] ?? null, type: 'unchanged' }, + }); + k++; + } else { + const removedIdxs: number[] = []; + const addedIdxs: number[] = []; + while (k < ops.length) { + const cur = ops[k]; + if (cur === undefined || cur.type === 'unchanged') break; + if (cur.type === 'removed') removedIdxs.push(cur.li); + else addedIdxs.push(cur.ri); + k++; + } + const len = Math.max(removedIdxs.length, addedIdxs.length); + for (let x = 0; x < len; x++) { + const rIdx = removedIdxs[x]; + const aIdx = addedIdxs[x]; + rows.push({ + left: rIdx !== undefined + ? { lineNum: rIdx + 1, content: L[rIdx] ?? null, type: 'removed' } + : { lineNum: null, content: null, type: 'empty' }, + right: aIdx !== undefined + ? { lineNum: aIdx + 1, content: R[aIdx] ?? null, type: 'added' } + : { lineNum: null, content: null, type: 'empty' }, + }); + } + } + } + return rows; + } + + // Fallback for very large files (> 500×500 lines) + private simpleDiff(L: string[], R: string[]): DiffRow[] { + const rows: DiffRow[] = []; + const max = Math.max(L.length, R.length); + for (let i = 0; i < max; i++) { + const l = L[i], r = R[i]; + if (l === undefined) rows.push({ left: { lineNum: null, content: null, type: 'empty' }, right: { lineNum: i + 1, content: r ?? null, type: 'added' } }); + else if (r === undefined) rows.push({ left: { lineNum: i + 1, content: l, type: 'removed' }, right: { lineNum: null, content: null, type: 'empty' } }); + else if (l === r) rows.push({ left: { lineNum: i + 1, content: l, type: 'unchanged' }, right: { lineNum: i + 1, content: r, type: 'unchanged' } }); + else rows.push({ left: { lineNum: i + 1, content: l, type: 'removed' }, right: { lineNum: i + 1, content: r, type: 'added' } }); } + return rows; } + // ── Batch / refresh operations (logic unchanged) ────────────── + async refreshAllStatuses(): Promise { if (this.isRefreshing) { - new Notice('Already refreshing...'); + new Notice('Already refreshing…'); return; } this.isRefreshing = true; this.fileStatuses.clear(); + // Show progress bar inside the list container const container = this.containerEl.children[1]; if (container) { - const statusContainer = container.querySelector('.sync-status-list') as HTMLElement; - if (statusContainer) { - statusContainer.empty(); - - // Add progress bar - const progressContainer = statusContainer.createDiv({ cls: 'sync-progress-container' }); - progressContainer.createEl('div', { text: 'Checking files...', cls: 'sync-progress-text' }); - const progressBar = progressContainer.createDiv({ cls: 'sync-progress-bar' }); - const progressFill = progressBar.createDiv({ cls: 'sync-progress-fill' }); - progressFill.setAttr('style', 'width: 0%'); + const listEl = container.querySelector('.ssv-list'); + if (listEl) { + listEl.empty(); + const prog = listEl.createDiv({ cls: 'ssv-progress' }); + prog.createDiv({ cls: 'ssv-progress-text', text: 'Checking files…' }); + const bar = prog.createDiv({ cls: 'ssv-progress-bar' }); + const fill = bar.createDiv({ cls: 'ssv-progress-fill' }); + fill.setAttr('style', 'width: 0%'); } } @@ -487,98 +511,68 @@ export class SyncStatusView extends ItemView { const allFiles = this.app.vault.getFiles(); let files = this.plugin.filterFilesByVaultFolder(allFiles); - // Get remote files let remoteFiles = await this.plugin.gitService.listFiles(this.plugin.settings.branch); - // Load .gitignore rules based on remote files, then filter out ignored paths await this.plugin.gitignoreManager.loadGitignores(); - remoteFiles = remoteFiles.filter(path => !this.plugin.gitignoreManager.isIgnored(path)); + remoteFiles = remoteFiles.filter(p => !this.plugin.gitignoreManager.isIgnored(p)); files = files.filter(f => !this.plugin.gitignoreManager.isIgnored(f.path)); const localFilePaths = new Set(files.map(f => f.path)); - // Use ALL local files (not just vaultFolder-filtered) for remote-only detection, - // so files like .claude/skills/*.md that live outside vaultFolder are not - // incorrectly labelled "remote only" when they actually exist locally. const allLocalFileMap = new Map(allFiles.map(f => [f.path, f])); - // Add local files to status for (const file of files) { - this.fileStatuses.set(file.path, { - file, - path: file.path, - status: 'checking' - }); + this.fileStatuses.set(file.path, { file, path: file.path, status: 'checking' }); } - // Add remote-only files to status, or queue cross-vaultFolder files for checking const extraFilesToCheck: Array = []; for (const remotePath of remoteFiles) { if (!localFilePaths.has(remotePath)) { let localFile = allLocalFileMap.get(remotePath); if (!localFile) { - const abstractFile = this.app.vault.getAbstractFileByPath(remotePath); - if (abstractFile instanceof TFile) { - localFile = abstractFile; - } + const abs = this.app.vault.getAbstractFileByPath(remotePath); + if (abs instanceof TFile) localFile = abs; } - if (localFile) { - // File exists locally but outside vaultFolder – check its real status - this.fileStatuses.set(remotePath, { - file: localFile, - path: remotePath, - status: 'checking' - }); + this.fileStatuses.set(remotePath, { file: localFile, path: remotePath, status: 'checking' }); extraFilesToCheck.push(localFile); } else if (await this.app.vault.adapter.exists(remotePath)) { - this.fileStatuses.set(remotePath, { - path: remotePath, - status: 'checking' - }); + this.fileStatuses.set(remotePath, { path: remotePath, status: 'checking' }); extraFilesToCheck.push(remotePath); } else { - this.fileStatuses.set(remotePath, { - path: remotePath, - status: 'remote-only' - }); + this.fileStatuses.set(remotePath, { path: remotePath, status: 'remote-only' }); } } } this.renderView(); - // Check status for local files with progress let filesToCheck: Array = [...files, ...extraFilesToCheck]; - - // Final filter for extra files to check (some might be ignored) filesToCheck = filesToCheck.filter(f => { - const path = typeof f === 'string' ? f : f.path; - return !this.plugin.gitignoreManager.isIgnored(path); + const p = typeof f === 'string' ? f : f.path; + return !this.plugin.gitignoreManager.isIgnored(p); }); - const totalFiles = filesToCheck.length; - let checkedFiles = 0; + const total = filesToCheck.length; + let checked = 0; for (const file of filesToCheck) { await this.refreshFileStatus(file); - checkedFiles++; - - // Update progress bar - if (container) { - const progressFill = container.querySelector('.sync-progress-fill') as HTMLElement; - const progressText = container.querySelector('.sync-progress-text') as HTMLElement; - if (progressFill && progressText) { - const percentage = Math.round((checkedFiles / totalFiles) * 100); - progressFill.style.width = `${percentage}%`; - progressText.textContent = `Checking files... ${checkedFiles}/${totalFiles} (${percentage}%)`; + checked++; + const c = this.containerEl.children[1]; + if (c) { + const fill = c.querySelector('.ssv-progress-fill'); + const text = c.querySelector('.ssv-progress-text'); + if (fill && text) { + const pct = Math.round((checked / total) * 100); + fill.setAttr('style', `width: ${pct}%`); + text.textContent = `Checking files… ${checked}/${total} (${pct}%)`; } } } this.lastSyncTime = Date.now(); this.renderView(); - new Notice(`Checked ${files.length} local files and ${remoteFiles.length} remote files`); + new Notice(`Checked ${files.length} local + ${remoteFiles.length} remote files`); } catch (e) { - console.error(e); new Notice(`Failed to refresh: ${e instanceof Error ? e.message : String(e)}`); } finally { this.isRefreshing = false; @@ -587,14 +581,14 @@ export class SyncStatusView extends ItemView { private async refreshFileStatus(fileOrPath: TFile | string): Promise { try { - const isString = typeof fileOrPath === 'string'; - const path = isString ? fileOrPath : fileOrPath.path; - const file = isString ? undefined : fileOrPath; + const isStr = typeof fileOrPath === 'string'; + const path = isStr ? fileOrPath : fileOrPath.path; + const file = isStr ? undefined : fileOrPath; - const localContent = typeof fileOrPath === 'string' - ? await this.app.vault.adapter.read(fileOrPath) + const localContent = isStr + ? await this.app.vault.adapter.read(fileOrPath) : await this.app.vault.read(fileOrPath); - + const remote = await this.plugin.gitService.getFile(path, this.plugin.settings.branch); let status: FileStatus['status']; @@ -609,22 +603,12 @@ export class SyncStatusView extends ItemView { diff = this.generateDiff(remote.content, localContent); } - this.fileStatuses.set(path, { - file, - path: path, - status, - localContent, - remoteContent: remote.content, - remoteSha: remote.sha, - diff - }); - - } catch (e) { + this.fileStatuses.set(path, { file, path, status, localContent, remoteContent: remote.content, remoteSha: remote.sha, diff }); + } catch { const path = typeof fileOrPath === 'string' ? fileOrPath : fileOrPath.path; - console.error(`Error checking ${path}:`, e); this.fileStatuses.set(path, { file: typeof fileOrPath === 'string' ? undefined : fileOrPath, - path: path, + path, status: 'unsynced' }); } @@ -633,63 +617,49 @@ export class SyncStatusView extends ItemView { private generateDiff(oldContent: string, newContent: string): string { const oldLines = oldContent.split('\n'); const newLines = newContent.split('\n'); - - const diff: string[] = []; - diff.push('--- Remote'); - diff.push('+++ Local'); - diff.push(''); - + const diff = ['--- Remote', '+++ Local', '']; const maxLines = Math.max(oldLines.length, newLines.length); - for (let i = 0; i < maxLines; i++) { - const oldLine = oldLines[i]; - const newLine = newLines[i]; - - if (oldLine !== newLine) { - if (oldLine !== undefined) { - diff.push(`- ${oldLine}`); - } - if (newLine !== undefined) { - diff.push(`+ ${newLine}`); - } + const o = oldLines[i], n = newLines[i]; + if (o !== n) { + if (o !== undefined) diff.push(`- ${o}`); + if (n !== undefined) diff.push(`+ ${n}`); } } - return diff.join('\n'); } + private async ensureParentDirs(filePath: string): Promise { + const parts = filePath.split('/'); + let cur = ''; + for (let i = 0; i < parts.length - 1; i++) { + cur += (i > 0 ? '/' : '') + parts[i]; + if (!this.app.vault.getAbstractFileByPath(cur)) { + try { await this.app.vault.createFolder(cur); } catch { /* already exists */ } + } + } + } + async pushAllModified(): Promise { const modifiedFiles = Array.from(this.fileStatuses.values()) .filter(s => s.status === 'modified' || s.status === 'unsynced') .map(s => s.file || s.path); - - if (modifiedFiles.length === 0) { - new Notice('No modified files to push'); - return; - } + if (modifiedFiles.length === 0) { new Notice('No modified files to push'); return; } const serviceName = this.plugin.settings.serviceType === 'gitlab' ? 'GitLab' : 'GitHub'; - const confirmed = await this.showConfirmDialog(`Push ${modifiedFiles.length} file(s) to ${serviceName}?`); - if (!confirmed) return; - - const progressNotice = new Notice(`Pushing 0/${modifiedFiles.length} files...`, 0); + if (!await this.showConfirmDialog(`Push ${modifiedFiles.length} file(s) to ${serviceName}?`)) return; + const prog = new Notice(`Pushing 0/${modifiedFiles.length} files…`, 0); try { - const results = await this.plugin.sync.pushAllFiles(modifiedFiles, (current, total, fileName) => { - progressNotice.setMessage(`Pushing ${current}/${total}: ${fileName}`); + const results = await this.plugin.sync.pushAllFiles(modifiedFiles, (cur, total, name) => { + prog.setMessage(`Pushing ${cur}/${total}: ${name}`); }); - - progressNotice.hide(); - - if (results.errors.length > 0) { - console.error('Push errors:', results.errors); - } - - new Notice(`Push completed. Refreshing status...`); + prog.hide(); + if (results.errors.length > 0) console.error('Push errors:', results.errors); + new Notice('Push completed. Refreshing…'); await this.refreshAllStatuses(); } catch (e) { - progressNotice.hide(); - console.error(e); + prog.hide(); new Notice(`Push failed: ${e instanceof Error ? e.message : String(e)}`); } } @@ -698,284 +668,164 @@ export class SyncStatusView extends ItemView { const modifiedFiles = Array.from(this.fileStatuses.values()) .filter(s => s.status === 'modified') .map(s => s.file || s.path); - - if (modifiedFiles.length === 0) { - new Notice('No modified files to pull'); - return; - } + if (modifiedFiles.length === 0) { new Notice('No modified files to pull'); return; } const serviceName = this.plugin.settings.serviceType === 'gitlab' ? 'GitLab' : 'GitHub'; - const confirmed = await this.showConfirmDialog(`Pull ${modifiedFiles.length} file(s) from ${serviceName}? This will overwrite local changes.`); - if (!confirmed) return; - - const progressNotice = new Notice(`Pulling 0/${modifiedFiles.length} files...`, 0); + if (!await this.showConfirmDialog(`Pull ${modifiedFiles.length} file(s) from ${serviceName}? This will overwrite local changes.`)) return; + const prog = new Notice(`Pulling 0/${modifiedFiles.length} files…`, 0); try { - const results = await this.plugin.sync.pullAllFiles(modifiedFiles, (current, total, fileName) => { - progressNotice.setMessage(`Pulling ${current}/${total}: ${fileName}`); + const results = await this.plugin.sync.pullAllFiles(modifiedFiles, (cur, total, name) => { + prog.setMessage(`Pulling ${cur}/${total}: ${name}`); }); - - progressNotice.hide(); - - if (results.errors.length > 0) { - console.error('Pull errors:', results.errors); - } - - // Wait a bit for files to be written - await new Promise(resolve => setTimeout(resolve, 1000)); - - new Notice(`Pull completed. Refreshing status...`); + prog.hide(); + if (results.errors.length > 0) console.error('Pull errors:', results.errors); + await new Promise(r => setTimeout(r, 1000)); + new Notice('Pull completed. Refreshing…'); await this.refreshAllStatuses(); } catch (e) { - progressNotice.hide(); - console.error(e); + prog.hide(); new Notice(`Pull failed: ${e instanceof Error ? e.message : String(e)}`); } } async pushSelected(): Promise { - if (this.selectedFiles.size === 0) { - new Notice('No files selected'); - return; - } + if (this.selectedFiles.size === 0) { new Notice('No files selected'); return; } - const selectedStatuses = Array.from(this.selectedFiles) - .map(path => this.fileStatuses.get(path)) + const targets = Array.from(this.selectedFiles) + .map(p => this.fileStatuses.get(p)) .filter(s => s && (s.status === 'modified' || s.status === 'unsynced')) as FileStatus[]; + if (targets.length === 0) { new Notice('No pushable files selected (need modified or local-only).'); return; } - if (selectedStatuses.length === 0) { - new Notice('No files can be pushed. Only modified or unsynced files can be pushed.'); - return; - } - - const files = selectedStatuses.map(s => s.file || s.path); - + const files = targets.map(s => s.file || s.path); const serviceName = this.plugin.settings.serviceType === 'gitlab' ? 'GitLab' : 'GitHub'; - const confirmed = await this.showConfirmDialog(`Push ${files.length} selected file(s) to ${serviceName}?`); - if (!confirmed) return; - - const progressNotice = new Notice(`Pushing 0/${files.length} files...`, 0); + if (!await this.showConfirmDialog(`Push ${files.length} selected file(s) to ${serviceName}?`)) return; + const prog = new Notice(`Pushing 0/${files.length} files…`, 0); try { - const results = await this.plugin.sync.pushAllFiles(files, (current, total, fileName) => { - progressNotice.setMessage(`Pushing ${current}/${total}: ${fileName}`); + const results = await this.plugin.sync.pushAllFiles(files, (cur, total, name) => { + prog.setMessage(`Pushing ${cur}/${total}: ${name}`); }); - - progressNotice.hide(); - - if (results.errors.length > 0) { - console.error('Push errors:', results.errors); - } - + prog.hide(); + if (results.errors.length > 0) console.error('Push errors:', results.errors); this.selectedFiles.clear(); - - // Wait a bit for remote to update - await new Promise(resolve => setTimeout(resolve, 1000)); - - new Notice(`Push completed. Refreshing status...`); + await new Promise(r => setTimeout(r, 1000)); + new Notice('Push completed. Refreshing…'); await this.refreshAllStatuses(); } catch (e) { - progressNotice.hide(); - console.error(e); + prog.hide(); new Notice(`Push failed: ${e instanceof Error ? e.message : String(e)}`); } } async pullSelected(): Promise { - if (this.selectedFiles.size === 0) { - new Notice('No files selected'); - return; - } + if (this.selectedFiles.size === 0) { new Notice('No files selected'); return; } - const selectedStatuses = Array.from(this.selectedFiles) - .map(path => this.fileStatuses.get(path)) + const targets = Array.from(this.selectedFiles) + .map(p => this.fileStatuses.get(p)) .filter(s => s && (s.status === 'modified' || s.status === 'remote-only')) as FileStatus[]; - - if (selectedStatuses.length === 0) { - new Notice('No files can be pulled. Only modified or remote-only files can be pulled.'); - return; - } + if (targets.length === 0) { new Notice('No pullable files selected (need changed or remote-only).'); return; } const serviceName = this.plugin.settings.serviceType === 'gitlab' ? 'GitLab' : 'GitHub'; - const confirmed = await this.showConfirmDialog(`Pull ${selectedStatuses.length} selected file(s) from ${serviceName}? This will overwrite local changes.`); - if (!confirmed) return; - - const progressNotice = new Notice(`Pulling 0/${selectedStatuses.length} files...`, 0); - - try { - let current = 0; - const errors: string[] = []; - - for (const status of selectedStatuses) { - current++; - progressNotice.setMessage(`Pulling ${current}/${selectedStatuses.length}: ${status.path}`); - - try { - if (status.status !== 'remote-only') { - // Existing file - use sync manager - await this.plugin.sync.pullFile(status.file || status.path); - } else { - // Remote-only file - create it - const remote = await this.plugin.gitService.getFile(status.path, this.plugin.settings.branch); - if (remote.content) { - // Ensure parent directories exist (recursively) - const pathParts = status.path.split('/'); - if (pathParts.length > 1) { - let currentPath = ''; - for (let i = 0; i < pathParts.length - 1; i++) { - currentPath += (i > 0 ? '/' : '') + pathParts[i]; - const dir = this.app.vault.getAbstractFileByPath(currentPath); - if (!dir) { - try { - await this.app.vault.createFolder(currentPath); - } catch { - // Folder might already exist, ignore error - } - } - } - } - - await this.app.vault.create(status.path, remote.content); - - // Update sync metadata - this.plugin.settings.syncMetadata[status.path] = { - lastSyncedSha: remote.sha, - lastSyncedAt: Date.now(), - lastKnownPath: status.path - }; - } + if (!await this.showConfirmDialog(`Pull ${targets.length} file(s) from ${serviceName}? This will overwrite local changes.`)) return; + + const prog = new Notice(`Pulling 0/${targets.length} files…`, 0); + const errors: string[] = []; + let cur = 0; + + for (const status of targets) { + cur++; + prog.setMessage(`Pulling ${cur}/${targets.length}: ${status.path}`); + try { + if (status.status !== 'remote-only') { + await this.plugin.sync.pullFile(status.file || status.path); + } else { + const remote = await this.plugin.gitService.getFile(status.path, this.plugin.settings.branch); + if (remote.content) { + await this.ensureParentDirs(status.path); + await this.app.vault.create(status.path, remote.content); + this.plugin.settings.syncMetadata[status.path] = { + lastSyncedSha: remote.sha, + lastSyncedAt: Date.now(), + lastKnownPath: status.path + }; } - } catch (e) { - console.error(`Error pulling ${status.path}:`, e); - errors.push(status.path); } + } catch { + errors.push(status.path); } + } - progressNotice.hide(); - - // Save settings if any metadata was updated - await this.plugin.saveSettings(); - - if (errors.length > 0) { - new Notice(`Pulled ${selectedStatuses.length - errors.length}/${selectedStatuses.length} files. ${errors.length} failed.`); - } else { - new Notice(`Successfully pulled ${selectedStatuses.length} files`); - } - - this.selectedFiles.clear(); - - // Wait a bit for files to be written - await new Promise(resolve => setTimeout(resolve, 1000)); + prog.hide(); + await this.plugin.saveSettings(); - new Notice(`Pull completed. Refreshing status...`); - await this.refreshAllStatuses(); - } catch (e) { - progressNotice.hide(); - console.error(e); - new Notice(`Pull failed: ${e instanceof Error ? e.message : String(e)}`); - } + new Notice(errors.length > 0 + ? `Pulled ${targets.length - errors.length}/${targets.length}. ${errors.length} failed.` + : `Pulled ${targets.length} files` + ); + this.selectedFiles.clear(); + await new Promise(r => setTimeout(r, 1000)); + await this.refreshAllStatuses(); } async deleteSelected(): Promise { - if (this.selectedFiles.size === 0) { - new Notice('No files selected'); - return; - } - - const selectedStatuses = Array.from(this.selectedFiles) - .map(path => this.fileStatuses.get(path)) - .filter(s => s) as FileStatus[]; - - const localFiles = selectedStatuses.filter(s => s.status !== 'remote-only'); - const remoteOnlyFiles = selectedStatuses.filter(s => s.status === 'remote-only'); - - if (localFiles.length === 0 && remoteOnlyFiles.length === 0) { - new Notice('No files selected to delete'); - return; + if (this.selectedFiles.size === 0) { new Notice('No files selected'); return; } + + const targets = Array.from(this.selectedFiles) + .map(p => this.fileStatuses.get(p)) + .filter(Boolean) as FileStatus[]; + + const local = targets.filter(s => s.status !== 'remote-only'); + const remote = targets.filter(s => s.status === 'remote-only'); + if (local.length === 0 && remote.length === 0) { new Notice('Nothing to delete'); return; } + + let msg = ''; + if (local.length > 0 && remote.length > 0) msg = `Delete ${local.length} local + ${remote.length} remote file(s)? Cannot be undone.`; + else if (local.length > 0) msg = `Delete ${local.length} local file(s)? Cannot be undone.`; + else msg = `Delete ${remote.length} remote file(s)? Cannot be undone.`; + + if (!await this.showConfirmDialog(msg)) return; + + const total = local.length + remote.length; + const prog = new Notice(`Deleting 0/${total} files…`, 0); + const errors: string[] = []; + let cur = 0; + + for (const s of local) { + cur++; + prog.setMessage(`Deleting local ${cur}/${total}: ${s.path}`); + try { + if (s.file) await this.app.fileManager.trashFile(s.file); + else await this.app.vault.adapter.remove(s.path); + this.fileStatuses.delete(s.path); + this.selectedFiles.delete(s.path); + } catch { errors.push(s.path); } } - let confirmMessage = ''; - if (localFiles.length > 0 && remoteOnlyFiles.length > 0) { - confirmMessage = `Delete ${localFiles.length} local file(s) and ${remoteOnlyFiles.length} remote file(s)? This cannot be undone.`; - } else if (localFiles.length > 0) { - confirmMessage = `Delete ${localFiles.length} local file(s)? This cannot be undone.`; - } else { - confirmMessage = `Delete ${remoteOnlyFiles.length} remote file(s)? This cannot be undone.`; + for (const s of remote) { + cur++; + prog.setMessage(`Deleting remote ${cur}/${total}: ${s.path}`); + try { + await this.plugin.gitService.deleteFile(s.path, this.plugin.settings.branch, `Delete ${s.path}`); + this.fileStatuses.delete(s.path); + this.selectedFiles.delete(s.path); + } catch { errors.push(s.path); } } - const confirmed = await this.showConfirmDialog(confirmMessage); - if (!confirmed) return; - - const totalFiles = localFiles.length + remoteOnlyFiles.length; - const progressNotice = new Notice(`Deleting 0/${totalFiles} files...`, 0); - - try { - let current = 0; - const errors: string[] = []; - - // Delete local files - for (const status of localFiles) { - current++; - progressNotice.setMessage(`Deleting local ${current}/${totalFiles}: ${status.path}`); - - try { - if (status.file) { - await this.app.fileManager.trashFile(status.file); - } else { - await this.app.vault.adapter.remove(status.path); - } - this.fileStatuses.delete(status.path); - this.selectedFiles.delete(status.path); - } catch (e) { - console.error(`Error deleting local ${status.path}:`, e); - errors.push(status.path); - } - } - - // Delete remote files - for (const status of remoteOnlyFiles) { - current++; - progressNotice.setMessage(`Deleting remote ${current}/${totalFiles}: ${status.path}`); - - try { - await this.plugin.gitService.deleteFile( - status.path, - this.plugin.settings.branch, - `Delete ${status.path}` - ); - this.fileStatuses.delete(status.path); - this.selectedFiles.delete(status.path); - } catch (e) { - console.error(`Error deleting remote ${status.path}:`, e); - errors.push(status.path); - } - } - - progressNotice.hide(); - - if (errors.length > 0) { - new Notice(`Deleted ${totalFiles - errors.length}/${totalFiles} files. ${errors.length} failed.`); - } else { - new Notice(`Successfully deleted ${totalFiles} files`); - } - - this.renderView(); - } catch (e) { - progressNotice.hide(); - console.error(e); - new Notice(`Delete failed: ${e instanceof Error ? e.message : String(e)}`); - } + prog.hide(); + new Notice(errors.length > 0 + ? `Deleted ${total - errors.length}/${total}. ${errors.length} failed.` + : `Deleted ${total} files` + ); + this.renderView(); } - async onClose(): Promise { - // Cleanup - } + async onClose(): Promise { /* cleanup */ } private showConfirmDialog(message: string): Promise { - return new Promise((resolve) => { + return new Promise(resolve => { // eslint-disable-next-line no-alert - const confirmed = confirm(message); - resolve(confirmed); + resolve(confirm(message)); }); } } diff --git a/styles.css b/styles.css index c54be15..6c28f70 100644 --- a/styles.css +++ b/styles.css @@ -1,472 +1,603 @@ -/* - -This CSS file will be included with your plugin, and -available in the app when your plugin is enabled. - -If your plugin does not need CSS, delete this file. - -*/ +/* ── VaultBridge – Sync Status View ────────────────────────────── */ +/* Full-height flex column so the list can scroll independently */ .sync-status-view { - padding: 10px; - overflow-y: auto; + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; + padding: 0; + container-type: inline-size; } -.hidden { - display: none !important; -} +.hidden { display: none !important; } -.sync-status-header { - margin-bottom: 15px; - padding-bottom: 10px; +/* ── Info strip ─────────────────────────────────────────────────── */ +.ssv-info { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + background: var(--background-secondary); border-bottom: 1px solid var(--background-modifier-border); + font-size: 0.75em; + color: var(--text-muted); + flex-wrap: wrap; + flex-shrink: 0; } -.sync-status-header h4 { - margin: 0; - font-size: 1.2em; +@container (max-width: 400px) { + .ssv-info { gap: 4px; padding: 4px 8px; } + .ssv-info-sep { display: none; } } -.sync-status-info { - margin-bottom: 15px; - padding: 10px; - background: var(--background-secondary); - border-radius: 5px; - font-size: 0.9em; +.ssv-info-item { + display: flex; + align-items: center; + gap: 3px; + font-weight: 500; + color: var(--text-normal); } -.sync-status-info div { - margin: 3px 0; +.ssv-info-sep { color: var(--background-modifier-border); } + +.ssv-info-time { + font-weight: 400; + color: var(--text-muted); } -.sync-status-buttons { - margin-bottom: 10px; +/* ── Filter tabs ────────────────────────────────────────────────── */ +.ssv-tabs { display: flex; - gap: 10px; - flex-wrap: wrap; + gap: 4px; + padding: 8px 10px; + border-bottom: 1px solid var(--background-modifier-border); + overflow-x: auto; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + flex-shrink: 0; } -.sync-selection-buttons { - margin-bottom: 10px; +.ssv-tabs::-webkit-scrollbar { display: none; } + +.ssv-tab { display: flex; - gap: 10px; - flex-wrap: wrap; - padding: 10px; - background: var(--background-secondary); - border-radius: 5px; + align-items: center; + gap: 5px; + padding: 4px 10px; + border-radius: 20px; + border: 1px solid transparent; + font-size: 0.80em; + font-weight: 500; + cursor: pointer; + white-space: nowrap; + background: transparent; + color: var(--text-muted); + transition: background 0.12s, color 0.12s; + min-height: 28px; } -.sync-repo-buttons { - margin-bottom: 15px; - display: flex; - gap: 10px; - flex-wrap: wrap; - padding: 10px; - background: var(--background-secondary); - border-radius: 5px; +@container (max-width: 400px) { + .ssv-tab-label { display: none; } + .ssv-tab { padding: 4px 8px; min-width: 32px; justify-content: center; } +} + +.ssv-tab:hover { + background: var(--background-modifier-hover); + color: var(--text-normal); } -.sync-status-buttons button, -.sync-selection-buttons button, -.sync-repo-buttons button { - padding: 8px 16px; +.ssv-tab.active { background: var(--interactive-accent); color: var(--text-on-accent); - border: none; - border-radius: 5px; - cursor: pointer; + border-color: var(--interactive-accent); +} + +.ssv-tab-count { + background: rgba(0, 0, 0, 0.12); + border-radius: 10px; + padding: 1px 6px; + font-size: 0.85em; + min-width: 18px; + text-align: center; +} +.is-mobile .ssv-tabs { + padding: 10px; + gap: 8px; + flex-wrap: wrap; + overflow-x: visible; +} + +.is-mobile .ssv-tab { + padding: 6px 14px; font-size: 0.9em; - white-space: nowrap; + min-height: 36px; + border-radius: 10px; + flex: 1 0 auto; + justify-content: center; } -.sync-status-buttons .refresh-btn { - background: var(--interactive-accent); - font-weight: 500; +.ssv-tab.active .ssv-tab-count { + background: rgba(255, 255, 255, 0.22); } -/* Mobile optimization */ -@media (max-width: 768px) { - .sync-status-buttons, - .sync-selection-buttons, - .sync-repo-buttons { - gap: 6px; - } +/* ── Action bar ─────────────────────────────────────────────────── */ +.ssv-action-bar { + display: flex; + align-items: center; + gap: 4px; + padding: 6px 10px; + border-bottom: 1px solid var(--background-modifier-border); + flex-wrap: wrap; + flex-shrink: 0; + background: var(--background-primary); +} - .sync-status-buttons button, - .sync-selection-buttons button, - .sync-repo-buttons button { - padding: 6px 10px; - font-size: 0.85em; - flex: 1 1 auto; - min-width: 0; - } +@container (max-width: 450px) { + .ssv-btn-label { display: none; } + .ssv-btn { padding: 5px 8px; min-width: 34px; justify-content: center; } + .ssv-select-label { display: none; } + .ssv-bar-spacer { display: none; } } -.sync-status-buttons button:hover, -.sync-selection-buttons button:hover, -.sync-repo-buttons button:hover { - background: var(--interactive-accent-hover); +/* ── Mobile specific overrides ─────────────────────────────────── */ +.is-mobile .ssv-btn-label, +.is-mobile .ssv-tab-label { + display: inline !important; } -.sync-status-buttons button:disabled, -.sync-selection-buttons button:disabled, -.sync-repo-buttons button:disabled { - opacity: 0.5; - cursor: not-allowed; +.is-mobile .ssv-btn { + padding: 8px 14px; + font-size: 0.9em; + min-height: 38px; + flex: 1 0 auto; + justify-content: center; } -.sync-status-buttons button:disabled:hover, -.sync-selection-buttons button:disabled:hover, -.sync-repo-buttons button:disabled:hover { - opacity: 0.5; +.is-mobile .ssv-action-bar { + gap: 8px; + padding: 10px; } -.sync-repo-buttons .push-all-btn { - background: var(--color-green); +.is-mobile .ssv-bar-spacer { + display: none; } -.sync-repo-buttons .push-all-btn:hover { - opacity: 0.8; + +.ssv-bar-spacer { flex: 1; } + +.ssv-btn { + display: flex; + align-items: center; + gap: 4px; + padding: 6px 11px; + border-radius: 5px; + font-size: 0.82em; + font-weight: 500; + cursor: pointer; + white-space: nowrap; + border: 1px solid transparent; + min-height: 30px; + transition: opacity 0.12s, background 0.12s; } -.sync-repo-buttons .pull-all-btn { - background: var(--color-blue); +.ssv-btn:disabled { + opacity: 0.38; + cursor: not-allowed; } -.sync-repo-buttons .pull-all-btn:hover { - opacity: 0.8; +.ssv-btn:not(:disabled):hover { opacity: 0.82; } + +.ssv-btn-refresh { + background: var(--background-secondary); + border-color: var(--background-modifier-border); + color: var(--text-normal); } -.sync-repo-buttons .delete-btn { - background: var(--color-red); +.ssv-btn-push { + background: var(--color-green); color: white; } -.sync-repo-buttons .delete-btn:hover { - opacity: 0.8; +.ssv-btn-pull { + background: var(--color-blue); + color: white; } -.sync-status-summary { +.ssv-btn-delete { + background: var(--color-red); + color: white; +} + +/* Indeterminate "select all" checkbox row */ +.ssv-select-row { display: flex; - gap: 20px; - margin-bottom: 15px; - padding: 10px; - background: var(--background-secondary); - border-radius: 5px; - font-weight: 500; - flex-wrap: wrap; + align-items: center; + gap: 6px; + font-size: 0.80em; + color: var(--text-muted); + cursor: pointer; + min-height: 30px; + padding: 0 4px; } -/* Mobile optimization */ -@media (max-width: 768px) { - .sync-status-summary { - gap: 10px; - font-size: 0.85em; - } +.ssv-select-row input[type="checkbox"] { + width: 15px; + height: 15px; + cursor: pointer; +} - .sync-status-summary div { - flex: 1 1 45%; - } +/* ── Scrollable file list ───────────────────────────────────────── */ +.ssv-list { + flex: 1; + overflow-y: auto; + overflow-x: hidden; } -.sync-status-empty, -.sync-status-loading { - padding: 20px; +/* ── Empty / loading states ─────────────────────────────────────── */ +.ssv-empty { + padding: 36px 20px; text-align: center; color: var(--text-muted); + font-size: 0.88em; } -.sync-progress-container { - padding: 20px; +/* ── Progress bar ───────────────────────────────────────────────── */ +.ssv-progress { + padding: 24px 16px; text-align: center; } -.sync-progress-text { - margin-bottom: 10px; +.ssv-progress-text { + font-size: 0.83em; color: var(--text-muted); - font-size: 0.9em; + margin-bottom: 10px; } -.sync-progress-bar { +.ssv-progress-bar { width: 100%; - height: 8px; + height: 4px; background: var(--background-secondary); - border-radius: 4px; + border-radius: 2px; overflow: hidden; } -.sync-progress-fill { +.ssv-progress-fill { height: 100%; background: var(--interactive-accent); - transition: width 0.3s ease; - border-radius: 4px; + transition: width 0.2s ease; + border-radius: 2px; } -.sync-status-file { - margin-bottom: 10px; - padding: 10px; - background: var(--background-primary-alt); - border-radius: 5px; - border-left: 3px solid var(--background-modifier-border); +/* ── File item ──────────────────────────────────────────────────── */ +.ssv-file { + padding: 9px 12px; + border-bottom: 1px solid var(--background-modifier-border-hover); + border-left: 3px solid transparent; + transition: background 0.1s; } -.sync-status-file-header { +.ssv-file:hover { background: var(--background-modifier-hover); } + +.ssv-file.status-synced { border-left-color: var(--color-green); } +.ssv-file.status-modified { border-left-color: var(--text-warning); } +.ssv-file.status-unsynced { border-left-color: var(--color-red); } +.ssv-file.status-remote { border-left-color: var(--color-blue); } +.ssv-file.status-checking { border-left-color: var(--background-modifier-border); } + +.ssv-file-row { display: flex; align-items: center; - gap: 5px; - margin-bottom: 5px; - flex-wrap: wrap; + gap: 7px; + min-height: 30px; } -.status-icon { - font-weight: bold; - font-size: 1.1em; +.ssv-file-checkbox { + width: 16px; + height: 16px; flex-shrink: 0; + cursor: pointer; } -/* Mobile optimization for file items */ -@media (max-width: 768px) { - .sync-status-file-header { - gap: 3px; - } - - .file-path { - font-size: 0.8em !important; - word-break: break-all; - } - - .status-text { - font-size: 0.75em !important; - } - - .diff-toggle { - margin-left: 0 !important; - margin-top: 5px; - width: 100%; - } - - .sync-status-actions { - flex-direction: column; - } - - .sync-status-actions button { - width: 100%; - } -} - -.status-synced { - color: var(--text-success); +.ssv-file-icon { + width: 18px; + text-align: center; + flex-shrink: 0; + font-size: 0.95em; + font-weight: 600; } -.status-modified { - color: var(--text-warning); -} +.ssv-icon-synced { color: var(--color-green); } +.ssv-icon-modified { color: var(--text-warning); } +.ssv-icon-unsynced { color: var(--color-red); } +.ssv-icon-remote { color: var(--color-blue); } +.ssv-icon-checking { color: var(--text-muted); } -.status-unsynced { - color: var(--text-error); +.ssv-file-path { + flex: 1; + font-family: var(--font-monospace); + font-size: 0.80em; + color: var(--text-normal); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; } -.status-remote-only { - color: var(--color-cyan); +.ssv-status-badge { + font-size: 0.70em; + font-weight: 600; + padding: 2px 8px; + border-radius: 10px; + white-space: nowrap; + flex-shrink: 0; + border: 1px solid currentColor; + opacity: 0.85; } -.status-checking { - color: var(--text-muted); -} +.ssv-badge-synced { color: var(--color-green); } +.ssv-badge-modified { color: var(--text-warning); } +.ssv-badge-unsynced { color: var(--color-red); } +.ssv-badge-remote { color: var(--color-blue); } +.ssv-badge-checking { color: var(--text-muted); } -.sync-status-filters { - margin-bottom: 15px; +/* ── Per-file action row ────────────────────────────────────────── */ +.ssv-file-actions { display: flex; - gap: 8px; - align-items: center; + gap: 5px; + margin-top: 7px; + padding-left: 41px; /* aligns under path */ flex-wrap: wrap; } -.sync-status-filters button { - padding: 6px 12px; - font-size: 0.9em; - background: var(--background-secondary); - color: var(--text-normal); - border: 1px solid var(--background-modifier-border); +.ssv-action-btn { + padding: 4px 10px; + font-size: 0.76em; border-radius: 4px; cursor: pointer; + border: 1px solid var(--background-modifier-border); + background: transparent; + color: var(--text-normal); + white-space: nowrap; + min-height: 26px; + transition: background 0.1s, opacity 0.1s; } -/* Mobile optimization */ -@media (max-width: 768px) { - .sync-status-filters { - gap: 5px; - } - - .sync-status-filters button { - padding: 5px 8px; - font-size: 0.8em; - flex: 1 1 auto; - } +.is-mobile .ssv-action-btn { + padding: 8px 15px; + font-size: 0.85em; + min-height: 40px; +} - .sync-status-filters span { - width: 100%; - font-size: 0.85em; - margin-bottom: 5px; - } +.is-mobile .ssv-action-btn .ssv-btn-label { + display: inline !important; } -.sync-status-filters button:hover { + +.ssv-action-btn:hover { background: var(--background-modifier-hover); } -.sync-status-filters button.filter-active { - background: var(--interactive-accent); - color: var(--text-on-accent); - border-color: var(--interactive-accent); +.ssv-action-btn.push { + border-color: var(--color-green); + color: var(--color-green); } -.sync-status-file-header input[type="checkbox"] { - margin-right: 8px; - cursor: pointer; +.ssv-action-btn.pull { + border-color: var(--color-blue); + color: var(--color-blue); } -.sync-status-actions .remove-btn { - background: var(--color-red); - color: white; +.ssv-action-btn.danger { border-color: var(--color-red); + color: var(--color-red); } -.sync-status-actions .remove-btn:hover { - opacity: 0.8; +.ssv-action-btn.diff { + border-color: var(--background-modifier-border); + color: var(--text-muted); } -.file-path { - flex: 1; +/* ── Diff panel ─────────────────────────────────────────────────── */ +.ssv-diff { + display: none; + margin-top: 6px; + padding-left: 41px; + container-type: inline-size; +} + +.ssv-diff.visible { display: block; } + +/* ── Side-by-side diff (default for wide panels) ──────────────────── */ +.ssv-diff-split { + border: 1px solid var(--background-modifier-border); + border-radius: 5px; + overflow: hidden; + background: var(--background-primary); +} + +.ssv-diff-grid { + display: grid; + grid-template-columns: 1fr 1fr; font-family: var(--font-monospace); + font-size: 0.74em; + max-height: 260px; + overflow-y: auto; +} + +.ssv-diff-hd { + position: sticky; + top: 0; + padding: 4px 8px; + background: var(--background-secondary); + color: var(--text-muted); font-size: 0.9em; + font-weight: 600; + border-bottom: 1px solid var(--background-modifier-border); + z-index: 1; } -.status-text { - font-size: 0.85em; - font-weight: 500; +.ssv-diff-hd:first-child { + border-right: 1px solid var(--background-modifier-border); } -.diff-toggle { - margin-left: auto; - padding: 4px 10px; - font-size: 0.85em; - background: var(--interactive-normal); - color: var(--text-normal); - border: 1px solid var(--background-modifier-border); - border-radius: 3px; - cursor: pointer; +.ssv-diff-cell { + display: flex; + align-items: baseline; + gap: 6px; + padding: 1px 6px; + min-height: 18px; + overflow: hidden; } -.diff-toggle:hover { - background: var(--interactive-hover); +/* Right column of each row gets a left separator */ +.ssv-diff-cell:nth-child(even) { + border-left: 1px solid var(--background-modifier-border); } -.sync-status-diff { - margin-top: 10px; - padding: 10px; - background: var(--background-secondary); - border-radius: 3px; - overflow-x: auto; +/* Row separator (every pair of cells) */ +.ssv-diff-cell:nth-child(4n+3), +.ssv-diff-cell:nth-child(4n+4) { + background-color: transparent; } -.sync-status-diff pre { - margin: 0; - font-family: var(--font-monospace); - font-size: 0.85em; - white-space: pre-wrap; - word-wrap: break-word; +.ssv-diff-cell.removed { background: rgba(var(--color-red-rgb), 0.12); } +.ssv-diff-cell.added { background: rgba(var(--color-green-rgb), 0.12); } +.ssv-diff-cell.unchanged { background: transparent; } +.ssv-diff-cell.empty { background: var(--background-secondary); opacity: 0.6; } + +.ssv-diff-ln { + color: var(--text-muted); + min-width: 22px; + text-align: right; + flex-shrink: 0; + user-select: none; + opacity: 0.6; + font-size: 0.88em; } -.sync-status-diff code { +.ssv-diff-code { + flex: 1; + white-space: pre-wrap; + word-break: break-all; color: var(--text-normal); } -.sync-status-actions { - margin-top: 10px; - display: flex; - gap: 10px; +/* ── Unified diff (narrow containers / mobile) ─────────────────────── */ +.ssv-diff-unified { + display: none; + background: var(--background-secondary); + border-radius: 5px; + padding: 8px 10px; + font-family: var(--font-monospace); + font-size: 0.74em; + white-space: pre-wrap; + word-break: break-all; + max-height: 240px; + overflow-y: auto; + margin: 0; } -.sync-status-actions button { - padding: 5px 12px; - font-size: 0.85em; - border: 1px solid var(--background-modifier-border); - border-radius: 3px; - cursor: pointer; - background: var(--interactive-normal); - color: var(--text-normal); +.ssv-u-line { + display: block; + padding: 1px 4px; + border-radius: 2px; +} + +.ssv-u-line.removed { background: rgba(var(--color-red-rgb), 0.18); } +.ssv-u-line.added { background: rgba(var(--color-green-rgb), 0.18); } +.ssv-u-line.unchanged { color: var(--text-muted); } + +/* Container query: switch layout based on the diff panel's own width */ +@container (max-width: 440px) { + .ssv-diff-split { display: none; } + .ssv-diff-unified { display: block; } } -.sync-status-actions button:hover { - background: var(--interactive-hover); +@container (min-width: 441px) { + .ssv-diff-split { display: block; } + .ssv-diff-unified { display: none; } } -/* Conflict Modal Styles */ -.sync-conflict-modal { - max-width: 900px; +/* ── Mobile adjustments ─────────────────────────────────────────── */ +@container (max-width: 480px) { + .ssv-info { padding: 5px 10px; gap: 8px; } + + .ssv-tabs { padding: 6px 8px; gap: 3px; } + .ssv-tab { padding: 4px 8px; font-size: 0.76em; } + .ssv-tab-label { display: none; } + + .ssv-action-bar { padding: 6px 8px; gap: 5px; } + .ssv-btn { padding: 5px 9px; font-size: 0.76em; } + .ssv-btn-label { display: none; } + + .ssv-file { padding: 8px 10px; } + .ssv-file-path { font-size: 0.76em; } + .ssv-status-badge { display: none; } + + .ssv-file-actions { padding-left: 0; margin-top: 8px; } + .ssv-action-btn { flex: 1 1 auto; text-align: center; min-height: 32px; } + .ssv-action-btn .ssv-btn-label { display: none; } + + .ssv-diff { padding-left: 0; } } +/* ── Conflict Modal ─────────────────────────────────────────────── */ +.sync-conflict-modal { max-width: 900px; } + .conflict-description { color: var(--text-muted); margin-bottom: 20px; + font-size: 0.9em; } .conflict-diff-container { display: grid; grid-template-columns: 1fr 1fr; - gap: 15px; - margin-bottom: 20px; + gap: 14px; + margin-bottom: 18px; } -.conflict-section h3 { - margin: 0 0 10px 0; - font-size: 1em; - font-weight: 600; -} - -.conflict-content { - max-height: 300px; - overflow-y: auto; - padding: 10px; - background: var(--background-secondary); - border-radius: 5px; - margin: 0; - font-family: var(--font-monospace); - font-size: 0.85em; - white-space: pre-wrap; - word-wrap: break-word; -} - -.conflict-diff-section { - margin-bottom: 20px; +@media (max-width: 600px) { + .conflict-diff-container { grid-template-columns: 1fr; } } +.conflict-section h3, .conflict-diff-section h3 { - margin: 0 0 10px 0; - font-size: 1em; + margin: 0 0 8px 0; + font-size: 0.9em; font-weight: 600; } +.conflict-content, .conflict-diff { - max-height: 400px; + max-height: 280px; overflow-y: auto; padding: 10px; background: var(--background-secondary); border-radius: 5px; margin: 0; font-family: var(--font-monospace); - font-size: 0.85em; + font-size: 0.82em; white-space: pre-wrap; word-wrap: break-word; } +.conflict-diff-section { margin-bottom: 18px; } + .conflict-buttons { display: flex; justify-content: flex-end; gap: 10px; - padding-top: 10px; + padding-top: 12px; border-top: 1px solid var(--background-modifier-border); + flex-wrap: wrap; +} + +@media (max-width: 480px) { + .conflict-buttons { justify-content: stretch; } + .conflict-buttons .setting-item-control { flex: 1; display: flex; flex-direction: column; gap: 8px; } + .conflict-buttons button { width: 100%; margin: 0 !important; } } .conflict-buttons .setting-item { @@ -478,33 +609,3 @@ If your plugin does not need CSS, delete this file. display: flex; gap: 10px; } - -/* GitHub style diff viewer */ -.diff-line { - display: block; - padding: 0 4px; - font-family: var(--font-monospace); - white-space: pre-wrap; - word-break: break-all; -} - -.diff-line.added { - background-color: rgba(46, 160, 67, 0.2); - color: var(--text-normal); -} - -.diff-line.removed { - background-color: rgba(248, 81, 73, 0.2); - color: var(--text-normal); -} - -.diff-line.unchanged { - color: var(--text-muted); -} - -.diff-line.header { - color: var(--text-accent); - font-weight: bold; - margin-bottom: 4px; -} -