mirror of
https://github.com/firstsun-dev/git-files-sync.git
synced 2026-07-22 17:20:30 +00:00
Merge pull request #2 from firstsun-dev/worktree-org-repo
feat: Support .gitignore filtering and GitHub-style diff viewer
This commit is contained in:
commit
325e68b89a
21 changed files with 1520 additions and 1411 deletions
164
COMPLIANCE.md
164
COMPLIANCE.md
|
|
@ -1,164 +0,0 @@
|
|||
# Obsidian Plugin Development Compliance Checklist
|
||||
|
||||
This document verifies that the Git File Push plugin follows Obsidian's plugin development guidelines.
|
||||
|
||||
## ✅ Required Files
|
||||
|
||||
- [x] **manifest.json** - Plugin metadata
|
||||
- [x] `id`: "git-file-push"
|
||||
- [x] `name`: "Git File Push"
|
||||
- [x] `version`: "1.0.0"
|
||||
- [x] `minAppVersion`: "0.15.0"
|
||||
- [x] `description`: Clear description of plugin functionality
|
||||
- [x] `author`: "tianyao"
|
||||
- [x] `authorUrl`: GitHub profile URL
|
||||
- [x] `isDesktopOnly`: false (supports mobile)
|
||||
|
||||
- [x] **main.js** - Compiled plugin code
|
||||
- [x] Generated by esbuild
|
||||
- [x] Not tracked in git (in .gitignore)
|
||||
- [x] Uploaded to GitHub releases
|
||||
|
||||
- [x] **styles.css** - Plugin styles (optional but included)
|
||||
|
||||
- [x] **versions.json** - Version compatibility mapping
|
||||
```json
|
||||
{
|
||||
"1.0.0": "0.15.0"
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **LICENSE** - 0-BSD License
|
||||
|
||||
## ✅ Build Configuration
|
||||
|
||||
### esbuild.config.mjs
|
||||
- [x] Entry point: `src/main.ts`
|
||||
- [x] Output: `main.js`
|
||||
- [x] Format: CommonJS (`cjs`)
|
||||
- [x] Target: ES2018
|
||||
- [x] External dependencies properly configured:
|
||||
- [x] `obsidian`
|
||||
- [x] `electron`
|
||||
- [x] All `@codemirror/*` packages
|
||||
- [x] All `@lezer/*` packages
|
||||
- [x] Node.js built-in modules
|
||||
- [x] Source maps: inline for dev, disabled for production
|
||||
- [x] Minification: enabled for production
|
||||
- [x] Tree shaking: enabled
|
||||
|
||||
### tsconfig.json
|
||||
- [x] Module: ESNext
|
||||
- [x] Target: ES6
|
||||
- [x] Strict type checking enabled
|
||||
- [x] Source maps: inline
|
||||
- [x] Library: DOM, ES5, ES6, ES7
|
||||
|
||||
## ✅ Project Structure
|
||||
|
||||
```
|
||||
git-file-push/
|
||||
├── .github/
|
||||
│ └── workflows/ # CI/CD workflows
|
||||
├── src/
|
||||
│ ├── main.ts # Plugin entry point
|
||||
│ ├── settings.ts # Settings interface
|
||||
│ ├── logic/ # Business logic
|
||||
│ ├── services/ # API services
|
||||
│ └── ui/ # UI components
|
||||
├── tests/ # Test files
|
||||
├── manifest.json # Plugin metadata
|
||||
├── versions.json # Version mapping
|
||||
├── styles.css # Plugin styles
|
||||
├── package.json # NPM configuration
|
||||
├── tsconfig.json # TypeScript config
|
||||
├── esbuild.config.mjs # Build config
|
||||
├── .gitignore # Git ignore rules
|
||||
├── LICENSE # License file
|
||||
└── README.md # Documentation
|
||||
```
|
||||
|
||||
## ✅ Plugin Class
|
||||
|
||||
- [x] Main class extends `Plugin` from Obsidian API
|
||||
- [x] Implements `onload()` method
|
||||
- [x] Implements `onunload()` method
|
||||
- [x] Properly registers:
|
||||
- [x] Commands via `addCommand()`
|
||||
- [x] Ribbon icons via `addRibbonIcon()`
|
||||
- [x] Settings tab via `addSettingTab()`
|
||||
- [x] View types via `registerView()`
|
||||
- [x] Event handlers via `registerEvent()`
|
||||
- [x] Context menu items via `registerEvent()`
|
||||
|
||||
## ✅ Settings
|
||||
|
||||
- [x] Settings interface defined
|
||||
- [x] Default settings provided
|
||||
- [x] Settings loaded in `onload()` via `loadData()`
|
||||
- [x] Settings saved via `saveData()`
|
||||
- [x] Settings tab UI implemented
|
||||
|
||||
## ✅ Development Scripts
|
||||
|
||||
- [x] `npm run dev` - Development mode with watch
|
||||
- [x] `npm run build` - Production build
|
||||
- [x] `npm run version` - Version bump script
|
||||
- [x] `npm run lint` - Code linting
|
||||
- [x] `npm run test` - Run tests
|
||||
|
||||
## ✅ Git Configuration
|
||||
|
||||
- [x] `.gitignore` properly configured:
|
||||
- [x] `node_modules/` excluded
|
||||
- [x] `main.js` excluded (built file)
|
||||
- [x] `*.map` excluded (source maps)
|
||||
- [x] IDE files excluded
|
||||
- [x] `data.json` excluded (user data)
|
||||
|
||||
## ✅ Release Process
|
||||
|
||||
- [x] GitHub Actions workflow for releases
|
||||
- [x] Automated version bumping
|
||||
- [x] Release assets include:
|
||||
- [x] `main.js`
|
||||
- [x] `manifest.json`
|
||||
- [x] `styles.css`
|
||||
|
||||
## ✅ Best Practices
|
||||
|
||||
- [x] TypeScript with strict mode
|
||||
- [x] ESLint configuration
|
||||
- [x] Unit tests with Vitest
|
||||
- [x] Proper error handling
|
||||
- [x] User notifications via `Notice`
|
||||
- [x] Async operations properly handled
|
||||
- [x] No blocking operations in main thread
|
||||
- [x] Mobile compatibility (`isDesktopOnly: false`)
|
||||
- [x] Proper cleanup in `onunload()`
|
||||
|
||||
## ✅ API Usage
|
||||
|
||||
- [x] Uses official Obsidian API
|
||||
- [x] No direct DOM manipulation outside Obsidian API
|
||||
- [x] Proper use of `App`, `Plugin`, `PluginSettingTab`
|
||||
- [x] Proper use of `TFile`, `Vault` APIs
|
||||
- [x] Proper use of `Modal`, `Notice` for UI
|
||||
- [x] Proper use of `WorkspaceLeaf`, `ItemView` for custom views
|
||||
|
||||
## ✅ Documentation
|
||||
|
||||
- [x] README.md with:
|
||||
- [x] Plugin description
|
||||
- [x] Features list
|
||||
- [x] Installation instructions
|
||||
- [x] Usage guide
|
||||
- [x] Configuration guide
|
||||
- [x] CHANGELOG.md for version history
|
||||
- [x] LICENSE file
|
||||
|
||||
## Summary
|
||||
|
||||
✅ **The Git File Push plugin fully complies with Obsidian plugin development guidelines.**
|
||||
|
||||
All required files are present, build configuration is correct, and the plugin follows best practices for Obsidian plugin development.
|
||||
111
README.md
111
README.md
|
|
@ -5,18 +5,26 @@ An Obsidian plugin that enables seamless synchronization of individual notes wit
|
|||
## 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
|
||||
- **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
|
||||
|
||||
|
|
@ -42,39 +50,120 @@ An Obsidian plugin that enables seamless synchronization of individual notes wit
|
|||
|
||||
## Usage
|
||||
|
||||
### View Sync Status
|
||||
### 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"
|
||||
- Click "Refresh Status" to check all files against the remote repository
|
||||
- View complete git diff for modified files
|
||||
- Push or pull individual files directly from the status view
|
||||
- Use "Push All Modified" or "Pull All Modified" for batch operations
|
||||
|
||||
**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
|
||||
|
||||
**All files:**
|
||||
- Use Command Palette: "Push all markdown files"
|
||||
- Or use "Push All Modified" button in the sync status view
|
||||
**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
|
||||
|
||||
**All files:**
|
||||
**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"
|
||||
- Or use "Pull All Modified" button in the sync status view
|
||||
- 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
|
||||
|
||||
## Development
|
||||
|
||||
|
|
|
|||
138
RELEASE.md
138
RELEASE.md
|
|
@ -1,138 +0,0 @@
|
|||
# Release Guide
|
||||
|
||||
This guide explains how to create a new release for the Git File Push plugin.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Ensure all changes are committed and pushed to the main branch
|
||||
- All tests pass (`npm run test`)
|
||||
- Build succeeds (`npm run build`)
|
||||
|
||||
## Release Process
|
||||
|
||||
### 1. Update Version
|
||||
|
||||
Run the version bump script:
|
||||
|
||||
```bash
|
||||
# For patch version (1.0.0 -> 1.0.1)
|
||||
npm version patch
|
||||
|
||||
# For minor version (1.0.0 -> 1.1.0)
|
||||
npm version minor
|
||||
|
||||
# For major version (1.0.0 -> 2.0.0)
|
||||
npm version major
|
||||
```
|
||||
|
||||
This will:
|
||||
- Update `package.json` version
|
||||
- Update `manifest.json` version
|
||||
- Update `versions.json` with the new version
|
||||
- Create a git commit with the version bump
|
||||
|
||||
### 2. Update CHANGELOG.md
|
||||
|
||||
Edit `CHANGELOG.md` and add a new section for the version:
|
||||
|
||||
```markdown
|
||||
## [1.0.1] - 2026-04-24
|
||||
|
||||
### Added
|
||||
- New feature description
|
||||
|
||||
### Fixed
|
||||
- Bug fix description
|
||||
|
||||
### Changed
|
||||
- Change description
|
||||
|
||||
[1.0.1]: https://github.com/tianyao/gitlab-files-push/releases/tag/1.0.1
|
||||
```
|
||||
|
||||
Commit the changelog:
|
||||
|
||||
```bash
|
||||
git add CHANGELOG.md
|
||||
git commit -m "docs: update changelog for v1.0.1"
|
||||
```
|
||||
|
||||
### 3. Create and Push Tag
|
||||
|
||||
```bash
|
||||
# Create a tag matching the version
|
||||
git tag 1.0.1
|
||||
|
||||
# Push the tag to trigger the release workflow
|
||||
git push origin 1.0.1
|
||||
```
|
||||
|
||||
### 4. Automated Release
|
||||
|
||||
The GitHub Actions workflow will automatically:
|
||||
1. Run tests
|
||||
2. Build the plugin
|
||||
3. Create a GitHub release
|
||||
4. Upload `main.js`, `manifest.json`, and `styles.css` as release assets
|
||||
|
||||
### 5. Verify Release
|
||||
|
||||
1. Go to https://github.com/tianyao/git-file-push/releases
|
||||
2. Verify the new release is created
|
||||
3. Check that all three files are attached
|
||||
4. Review the release notes
|
||||
|
||||
## Manual Release (if needed)
|
||||
|
||||
If the automated workflow fails, you can create a release manually:
|
||||
|
||||
1. Build the plugin:
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
2. Go to GitHub → Releases → Draft a new release
|
||||
3. Choose the tag you created
|
||||
4. Add release notes from CHANGELOG.md
|
||||
5. Upload `main.js`, `manifest.json`, and `styles.css`
|
||||
6. Publish the release
|
||||
|
||||
## Version Numbering
|
||||
|
||||
Follow [Semantic Versioning](https://semver.org/):
|
||||
|
||||
- **MAJOR** (x.0.0): Breaking changes
|
||||
- **MINOR** (0.x.0): New features, backwards compatible
|
||||
- **PATCH** (0.0.x): Bug fixes, backwards compatible
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Workflow fails on test
|
||||
|
||||
```bash
|
||||
npm run test
|
||||
```
|
||||
|
||||
Fix any failing tests before creating the release.
|
||||
|
||||
### Workflow fails on build
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
Ensure the build succeeds locally.
|
||||
|
||||
### Tag already exists
|
||||
|
||||
```bash
|
||||
# Delete local tag
|
||||
git tag -d 1.0.1
|
||||
|
||||
# Delete remote tag
|
||||
git push origin :refs/tags/1.0.1
|
||||
|
||||
# Create new tag
|
||||
git tag 1.0.1
|
||||
git push origin 1.0.1
|
||||
```
|
||||
191
RELEASE_GUIDE.md
191
RELEASE_GUIDE.md
|
|
@ -1,191 +0,0 @@
|
|||
# 发布新版本指南
|
||||
|
||||
本项目提供两种发布方式:
|
||||
|
||||
## 方式一:自动版本管理(推荐)
|
||||
|
||||
使用 GitHub Actions 自动更新版本号并发布。
|
||||
|
||||
### 步骤:
|
||||
|
||||
1. 进入 GitHub 仓库
|
||||
2. 点击 **Actions** 标签
|
||||
3. 选择 **Auto Release** workflow
|
||||
4. 点击 **Run workflow** 按钮
|
||||
5. 选择版本类型:
|
||||
- **patch**: 修复 bug (1.0.0 → 1.0.1)
|
||||
- **minor**: 新功能 (1.0.0 → 1.1.0)
|
||||
- **major**: 重大变更 (1.0.0 → 2.0.0)
|
||||
6. 点击 **Run workflow**
|
||||
|
||||
### 自动执行的操作:
|
||||
|
||||
✅ 运行测试
|
||||
✅ 更新 package.json 版本号
|
||||
✅ 更新 manifest.json 版本号
|
||||
✅ 更新 versions.json
|
||||
✅ 更新 CHANGELOG.md
|
||||
✅ 提交并推送更改
|
||||
✅ 创建 git tag
|
||||
✅ 创建 GitHub Release
|
||||
✅ 上传 main.js, manifest.json, styles.css
|
||||
|
||||
### 优点:
|
||||
|
||||
- 一键完成所有操作
|
||||
- 不会遗漏任何步骤
|
||||
- 版本号自动同步
|
||||
- 减少人为错误
|
||||
|
||||
---
|
||||
|
||||
## 方式二:手动发布
|
||||
|
||||
适合需要更多控制的情况。
|
||||
|
||||
### 步骤:
|
||||
|
||||
1. **更新版本号**
|
||||
```bash
|
||||
# 修复 bug
|
||||
npm version patch
|
||||
|
||||
# 新功能
|
||||
npm version minor
|
||||
|
||||
# 重大变更
|
||||
npm version major
|
||||
```
|
||||
|
||||
2. **手动更新 manifest.json**
|
||||
```bash
|
||||
# 编辑 manifest.json,更新 version 字段
|
||||
```
|
||||
|
||||
3. **手动更新 versions.json**
|
||||
```json
|
||||
{
|
||||
"1.0.0": "0.15.0",
|
||||
"1.0.1": "0.15.0" // 添加新版本
|
||||
}
|
||||
```
|
||||
|
||||
4. **更新 CHANGELOG.md**
|
||||
```markdown
|
||||
## [1.0.1] - 2026-04-24
|
||||
|
||||
### Fixed
|
||||
- Bug fix description
|
||||
```
|
||||
|
||||
5. **提交更改**
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "chore: bump version to 1.0.1"
|
||||
git push
|
||||
```
|
||||
|
||||
6. **创建并推送 tag**
|
||||
```bash
|
||||
git tag 1.0.1
|
||||
git push origin 1.0.1
|
||||
```
|
||||
|
||||
7. **GitHub Actions 自动创建 Release**
|
||||
- release.yml workflow 会自动触发
|
||||
- 构建插件
|
||||
- 创建 GitHub Release(草稿)
|
||||
- 上传文件
|
||||
|
||||
8. **编辑 Release**
|
||||
- 进入 GitHub Releases
|
||||
- 编辑草稿 release
|
||||
- 添加发布说明
|
||||
- 发布
|
||||
|
||||
---
|
||||
|
||||
## 版本号规范
|
||||
|
||||
遵循 [语义化版本](https://semver.org/lang/zh-CN/):
|
||||
|
||||
- **MAJOR (x.0.0)**: 不兼容的 API 变更
|
||||
- **MINOR (0.x.0)**: 向后兼容的新功能
|
||||
- **PATCH (0.0.x)**: 向后兼容的 bug 修复
|
||||
|
||||
### 示例:
|
||||
|
||||
- `1.0.0` → `1.0.1`: 修复了一个 bug
|
||||
- `1.0.1` → `1.1.0`: 添加了新功能
|
||||
- `1.1.0` → `2.0.0`: 重大变更,可能不兼容旧版本
|
||||
|
||||
---
|
||||
|
||||
## 发布检查清单
|
||||
|
||||
在发布前确认:
|
||||
|
||||
- [ ] 所有测试通过 (`npm run test`)
|
||||
- [ ] 构建成功 (`npm run build`)
|
||||
- [ ] Lint 检查通过 (`npm run lint`)
|
||||
- [ ] 在 Obsidian 中测试过
|
||||
- [ ] 更新了 CHANGELOG.md
|
||||
- [ ] README.md 是最新的
|
||||
|
||||
---
|
||||
|
||||
## 故障排除
|
||||
|
||||
### Workflow 失败
|
||||
|
||||
**测试失败:**
|
||||
```bash
|
||||
npm run test
|
||||
```
|
||||
修复失败的测试后重试。
|
||||
|
||||
**构建失败:**
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
确保本地构建成功。
|
||||
|
||||
**权限错误:**
|
||||
- 检查仓库设置 → Actions → General → Workflow permissions
|
||||
- 选择 "Read and write permissions"
|
||||
|
||||
### Tag 已存在
|
||||
|
||||
```bash
|
||||
# 删除本地 tag
|
||||
git tag -d 1.0.1
|
||||
|
||||
# 删除远程 tag
|
||||
git push origin :refs/tags/1.0.1
|
||||
|
||||
# 重新创建
|
||||
git tag 1.0.1
|
||||
git push origin 1.0.1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 首次发布到 Obsidian 社区
|
||||
|
||||
完成首次 release 后:
|
||||
|
||||
1. Fork [obsidian-releases](https://github.com/obsidianmd/obsidian-releases)
|
||||
2. 编辑 `community-plugins.json`,添加:
|
||||
```json
|
||||
{
|
||||
"id": "git-file-push",
|
||||
"name": "Git File Push",
|
||||
"author": "tianyao",
|
||||
"description": "Sync individual notes with GitLab or GitHub. Push, pull, and track changes across mobile and desktop with visual diff and conflict resolution.",
|
||||
"repo": "tianyao/git-file-push"
|
||||
}
|
||||
```
|
||||
3. 创建 Pull Request
|
||||
4. 等待 Obsidian 团队审核
|
||||
|
||||
详见 [SUBMISSION.md](./SUBMISSION.md)
|
||||
|
|
@ -1,191 +0,0 @@
|
|||
# 发布方式总览
|
||||
|
||||
本项目提供三种发布方式,根据你的需求选择:
|
||||
|
||||
## 🚀 方式一:Semantic Release(推荐)
|
||||
|
||||
**完全自动化,基于 commit message**
|
||||
|
||||
### 优点
|
||||
- ✅ 完全自动化,无需手动操作
|
||||
- ✅ 强制规范的 commit message
|
||||
- ✅ 自动生成 CHANGELOG
|
||||
- ✅ 版本号由代码变更决定,更合理
|
||||
- ✅ 不会遗漏任何步骤
|
||||
|
||||
### 使用方法
|
||||
```bash
|
||||
# 1. 按规范提交代码
|
||||
git commit -m "feat: add new feature"
|
||||
|
||||
# 2. 推送到主分支
|
||||
git push origin main
|
||||
|
||||
# 3. 自动发布!
|
||||
```
|
||||
|
||||
### Commit 规范
|
||||
- `feat:` → 新功能 → MINOR 版本 (1.0.0 → 1.1.0)
|
||||
- `fix:` → Bug 修复 → PATCH 版本 (1.0.0 → 1.0.1)
|
||||
- `BREAKING CHANGE:` → 重大变更 → MAJOR 版本 (1.0.0 → 2.0.0)
|
||||
|
||||
### 配置文件
|
||||
- `.releaserc.json` - Semantic Release 配置
|
||||
- `.github/workflows/semantic-release.yml` - GitHub Actions workflow
|
||||
|
||||
### 详细文档
|
||||
参见 [SEMANTIC_RELEASE.md](./SEMANTIC_RELEASE.md)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 方式二:Auto Release
|
||||
|
||||
**手动触发,选择版本类型**
|
||||
|
||||
### 优点
|
||||
- ✅ 可以手动控制发布时机
|
||||
- ✅ 简单的版本选择(patch/minor/major)
|
||||
- ✅ 自动更新所有文件
|
||||
|
||||
### 使用方法
|
||||
1. 进入 GitHub → Actions → Auto Release
|
||||
2. 点击 "Run workflow"
|
||||
3. 选择版本类型:
|
||||
- **patch**: 1.0.0 → 1.0.1
|
||||
- **minor**: 1.0.0 → 1.1.0
|
||||
- **major**: 1.0.0 → 2.0.0
|
||||
4. 点击 "Run workflow"
|
||||
|
||||
### 自动执行
|
||||
- 运行测试
|
||||
- 更新版本号(package.json, manifest.json, versions.json)
|
||||
- 更新 CHANGELOG
|
||||
- 创建 commit 和 tag
|
||||
- 构建插件
|
||||
- 创建 GitHub Release
|
||||
- 上传文件
|
||||
|
||||
### 配置文件
|
||||
- `.github/workflows/auto-release.yml`
|
||||
|
||||
---
|
||||
|
||||
## 📦 方式三:Manual Release
|
||||
|
||||
**完全手动控制**
|
||||
|
||||
### 优点
|
||||
- ✅ 完全控制每个步骤
|
||||
- ✅ 适合特殊情况
|
||||
|
||||
### 使用方法
|
||||
```bash
|
||||
# 1. 更新版本号
|
||||
npm version patch # 或 minor, major
|
||||
|
||||
# 2. 手动更新 manifest.json 和 versions.json
|
||||
|
||||
# 3. 更新 CHANGELOG.md
|
||||
|
||||
# 4. 提交更改
|
||||
git add .
|
||||
git commit -m "chore: bump version to 1.0.1"
|
||||
git push
|
||||
|
||||
# 5. 创建并推送 tag
|
||||
git tag 1.0.1
|
||||
git push origin 1.0.1
|
||||
|
||||
# 6. GitHub Actions 自动创建 Release(草稿)
|
||||
|
||||
# 7. 手动编辑并发布 Release
|
||||
```
|
||||
|
||||
### 配置文件
|
||||
- `.github/workflows/release.yml`
|
||||
|
||||
---
|
||||
|
||||
## 📊 对比表
|
||||
|
||||
| 特性 | Semantic Release | Auto Release | Manual Release |
|
||||
|------|-----------------|--------------|----------------|
|
||||
| 自动化程度 | 🟢 完全自动 | 🟡 半自动 | 🔴 手动 |
|
||||
| 版本决策 | 🟢 基于代码变更 | 🟡 手动选择 | 🟡 手动选择 |
|
||||
| CHANGELOG | 🟢 自动生成 | 🟡 需要手动编辑 | 🔴 完全手动 |
|
||||
| Commit 规范 | 🟢 强制规范 | 🔴 无要求 | 🔴 无要求 |
|
||||
| 出错风险 | 🟢 低 | 🟡 中 | 🔴 高 |
|
||||
| 学习成本 | 🟡 需要学习规范 | 🟢 简单 | 🟢 简单 |
|
||||
|
||||
---
|
||||
|
||||
## 🎯 推荐使用场景
|
||||
|
||||
### 使用 Semantic Release(推荐)
|
||||
- ✅ 日常开发和发布
|
||||
- ✅ 团队协作项目
|
||||
- ✅ 需要规范的 commit history
|
||||
- ✅ 希望完全自动化
|
||||
|
||||
### 使用 Auto Release
|
||||
- ✅ 不想学习 commit 规范
|
||||
- ✅ 需要手动控制发布时机
|
||||
- ✅ 快速发布
|
||||
|
||||
### 使用 Manual Release
|
||||
- ✅ 特殊情况需要完全控制
|
||||
- ✅ 调试发布流程
|
||||
- ✅ 紧急修复
|
||||
|
||||
---
|
||||
|
||||
## 🔧 配置
|
||||
|
||||
### 启用 Semantic Release
|
||||
|
||||
如果你选择使用 Semantic Release,建议禁用其他两个 workflow 以避免冲突:
|
||||
|
||||
```bash
|
||||
# 重命名或删除其他 workflows
|
||||
mv .github/workflows/auto-release.yml .github/workflows/auto-release.yml.disabled
|
||||
mv .github/workflows/release.yml .github/workflows/release.yml.disabled
|
||||
```
|
||||
|
||||
### 同时使用多个方式
|
||||
|
||||
如果你想保留多个选项:
|
||||
- Semantic Release 用于日常自动发布
|
||||
- Auto Release 用于紧急手动发布
|
||||
- Manual Release 作为备用
|
||||
|
||||
---
|
||||
|
||||
## 📚 相关文档
|
||||
|
||||
- [SEMANTIC_RELEASE.md](./SEMANTIC_RELEASE.md) - Semantic Release 详细指南
|
||||
- [RELEASE_GUIDE.md](./RELEASE_GUIDE.md) - 手动发布指南
|
||||
- [WORKFLOWS.md](./WORKFLOWS.md) - GitHub Actions 说明
|
||||
- [SUBMISSION.md](./SUBMISSION.md) - Obsidian 插件提交指南
|
||||
|
||||
---
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 推荐:使用 Semantic Release
|
||||
|
||||
1. **学习 commit 规范**(5 分钟)
|
||||
- `feat:` 新功能
|
||||
- `fix:` Bug 修复
|
||||
- `docs:` 文档更新
|
||||
|
||||
2. **正常开发**
|
||||
```bash
|
||||
git commit -m "feat: add new feature"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
3. **自动发布!**
|
||||
- 无需其他操作
|
||||
- 检查 GitHub Releases 查看结果
|
||||
|
||||
就这么简单!🎉
|
||||
|
|
@ -1,236 +0,0 @@
|
|||
# Semantic Release 使用指南
|
||||
|
||||
本项目使用 [semantic-release](https://semantic-release.gitbook.io/) 自动管理版本号和发布。
|
||||
|
||||
## 工作原理
|
||||
|
||||
Semantic Release 根据 **commit message** 自动决定版本号:
|
||||
|
||||
- `feat:` → **MINOR** 版本 (1.0.0 → 1.1.0)
|
||||
- `fix:` → **PATCH** 版本 (1.0.0 → 1.0.1)
|
||||
- `BREAKING CHANGE:` → **MAJOR** 版本 (1.0.0 → 2.0.0)
|
||||
|
||||
## Commit Message 规范
|
||||
|
||||
使用 [Conventional Commits](https://www.conventionalcommits.org/) 格式:
|
||||
|
||||
```
|
||||
<type>(<scope>): <subject>
|
||||
|
||||
<body>
|
||||
|
||||
<footer>
|
||||
```
|
||||
|
||||
### Type 类型
|
||||
|
||||
| Type | 说明 | 版本影响 |
|
||||
|------|------|---------|
|
||||
| `feat` | 新功能 | MINOR |
|
||||
| `fix` | Bug 修复 | PATCH |
|
||||
| `perf` | 性能优化 | PATCH |
|
||||
| `refactor` | 代码重构 | PATCH |
|
||||
| `docs` | 文档更新 | 无 |
|
||||
| `style` | 代码格式 | 无 |
|
||||
| `test` | 测试相关 | 无 |
|
||||
| `build` | 构建系统 | 无 |
|
||||
| `ci` | CI 配置 | 无 |
|
||||
| `chore` | 其他杂项 | 无 |
|
||||
|
||||
### 示例
|
||||
|
||||
**新功能 (MINOR):**
|
||||
```bash
|
||||
git commit -m "feat: add GitHub support"
|
||||
git commit -m "feat(sync): add batch pull operation"
|
||||
```
|
||||
|
||||
**Bug 修复 (PATCH):**
|
||||
```bash
|
||||
git commit -m "fix: resolve conflict detection issue"
|
||||
git commit -m "fix(ui): correct button alignment"
|
||||
```
|
||||
|
||||
**重大变更 (MAJOR):**
|
||||
```bash
|
||||
git commit -m "feat: redesign settings API
|
||||
|
||||
BREAKING CHANGE: settings structure has changed"
|
||||
```
|
||||
|
||||
**不触发发布:**
|
||||
```bash
|
||||
git commit -m "docs: update README"
|
||||
git commit -m "chore: update dependencies"
|
||||
git commit -m "test: add unit tests"
|
||||
```
|
||||
|
||||
## 自动发布流程
|
||||
|
||||
### 触发条件
|
||||
|
||||
当你推送到 `main` 或 `master` 分支时,semantic-release 会自动:
|
||||
|
||||
1. ✅ 分析所有 commit messages
|
||||
2. ✅ 决定新版本号
|
||||
3. ✅ 更新 package.json
|
||||
4. ✅ 更新 manifest.json
|
||||
5. ✅ 更新 versions.json
|
||||
6. ✅ 生成 CHANGELOG.md
|
||||
7. ✅ 创建 git commit 和 tag
|
||||
8. ✅ 构建插件
|
||||
9. ✅ 创建 GitHub Release
|
||||
10. ✅ 上传 main.js, manifest.json, styles.css
|
||||
|
||||
### 使用步骤
|
||||
|
||||
1. **开发功能并提交**
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "feat: add new sync feature"
|
||||
```
|
||||
|
||||
2. **推送到主分支**
|
||||
```bash
|
||||
git push origin main
|
||||
```
|
||||
|
||||
3. **自动发布**
|
||||
- GitHub Actions 自动运行
|
||||
- 检查 commit messages
|
||||
- 如果有 `feat` 或 `fix`,自动创建新版本
|
||||
- 自动发布到 GitHub Releases
|
||||
|
||||
## 配置文件
|
||||
|
||||
### .releaserc.json
|
||||
|
||||
```json
|
||||
{
|
||||
"branches": ["main", "master"],
|
||||
"plugins": [
|
||||
"@semantic-release/commit-analyzer",
|
||||
"@semantic-release/release-notes-generator",
|
||||
"@semantic-release/changelog",
|
||||
"@semantic-release/npm",
|
||||
"@semantic-release/exec",
|
||||
"@semantic-release/git",
|
||||
"@semantic-release/github"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### GitHub Actions Workflow
|
||||
|
||||
`.github/workflows/semantic-release.yml` 会在推送到 main/master 时自动运行。
|
||||
|
||||
## 版本号示例
|
||||
|
||||
假设当前版本是 `1.0.0`:
|
||||
|
||||
| Commits | 新版本 |
|
||||
|---------|--------|
|
||||
| `fix: bug fix` | 1.0.1 |
|
||||
| `feat: new feature` | 1.1.0 |
|
||||
| `feat: feature with BREAKING CHANGE` | 2.0.0 |
|
||||
| `fix: bug` + `feat: feature` | 1.1.0 |
|
||||
| `docs: update` | 无发布 |
|
||||
|
||||
## 跳过发布
|
||||
|
||||
如果你不想触发发布,在 commit message 中添加 `[skip ci]`:
|
||||
|
||||
```bash
|
||||
git commit -m "docs: update README [skip ci]"
|
||||
```
|
||||
|
||||
## 手动触发发布
|
||||
|
||||
如果需要手动触发:
|
||||
|
||||
```bash
|
||||
npm run semantic-release
|
||||
```
|
||||
|
||||
## 查看发布历史
|
||||
|
||||
所有发布都会记录在:
|
||||
- GitHub Releases: https://github.com/tianyao/git-file-push/releases
|
||||
- CHANGELOG.md: 自动生成的变更日志
|
||||
|
||||
## 与其他 Workflows 的关系
|
||||
|
||||
现在项目有三个发布方式:
|
||||
|
||||
1. **semantic-release.yml** (推荐) - 自动根据 commit 发布
|
||||
2. **auto-release.yml** - 手动触发,选择版本类型
|
||||
3. **release.yml** - 手动创建 tag 触发
|
||||
|
||||
**推荐使用 semantic-release**,因为它:
|
||||
- ✅ 完全自动化
|
||||
- ✅ 强制规范的 commit message
|
||||
- ✅ 自动生成 CHANGELOG
|
||||
- ✅ 不会遗漏步骤
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **每个 commit 只做一件事**
|
||||
```bash
|
||||
# 好
|
||||
git commit -m "feat: add GitHub support"
|
||||
git commit -m "fix: resolve sync conflict"
|
||||
|
||||
# 不好
|
||||
git commit -m "feat: add GitHub support and fix sync conflict"
|
||||
```
|
||||
|
||||
2. **使用 scope 组织 commits**
|
||||
```bash
|
||||
git commit -m "feat(sync): add batch operations"
|
||||
git commit -m "fix(ui): correct modal layout"
|
||||
git commit -m "docs(readme): update installation guide"
|
||||
```
|
||||
|
||||
3. **重大变更要明确说明**
|
||||
```bash
|
||||
git commit -m "feat: redesign API
|
||||
|
||||
BREAKING CHANGE: The settings API has been completely redesigned.
|
||||
Old settings will need to be migrated manually."
|
||||
```
|
||||
|
||||
4. **开发时使用 feature branch**
|
||||
```bash
|
||||
git checkout -b feature/github-support
|
||||
# 开发...
|
||||
git commit -m "feat: add GitHub support"
|
||||
git push origin feature/github-support
|
||||
# 创建 PR 合并到 main
|
||||
```
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 没有触发发布
|
||||
|
||||
检查:
|
||||
- Commit message 格式是否正确
|
||||
- 是否推送到 main/master 分支
|
||||
- GitHub Actions 是否有权限
|
||||
|
||||
### 版本号不符合预期
|
||||
|
||||
检查:
|
||||
- Commit message 的 type 是否正确
|
||||
- 是否有 BREAKING CHANGE
|
||||
|
||||
### 构建失败
|
||||
|
||||
检查:
|
||||
- 测试是否通过 (`npm run test`)
|
||||
- 构建是否成功 (`npm run build`)
|
||||
|
||||
## 参考资料
|
||||
|
||||
- [Semantic Release 文档](https://semantic-release.gitbook.io/)
|
||||
- [Conventional Commits](https://www.conventionalcommits.org/)
|
||||
- [Commit Message 规范](https://www.conventionalcommits.org/zh-hans/)
|
||||
199
SUBMISSION.md
199
SUBMISSION.md
|
|
@ -1,199 +0,0 @@
|
|||
# Obsidian Plugin Submission Checklist
|
||||
|
||||
This checklist ensures the Git File Push plugin meets all requirements for submission to the Obsidian Community Plugins directory.
|
||||
|
||||
## ✅ Required Files
|
||||
|
||||
- [x] **README.md**
|
||||
- [x] Clear description of what the plugin does
|
||||
- [x] Installation instructions
|
||||
- [x] Usage guide
|
||||
- [x] Screenshots or examples (optional but recommended)
|
||||
|
||||
- [x] **manifest.json**
|
||||
- [x] `id`: "git-file-push" (lowercase, hyphens only)
|
||||
- [x] `name`: "Git File Push"
|
||||
- [x] `version`: "1.0.0" (semantic versioning)
|
||||
- [x] `minAppVersion`: "0.15.0"
|
||||
- [x] `description`: Clear, concise description
|
||||
- [x] `author`: "tianyao"
|
||||
- [x] `authorUrl`: Valid GitHub profile URL
|
||||
- [x] `isDesktopOnly`: false
|
||||
|
||||
- [x] **versions.json**
|
||||
```json
|
||||
{
|
||||
"1.0.0": "0.15.0"
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **LICENSE**
|
||||
- [x] Open source license (0-BSD)
|
||||
- [x] Compatible with Obsidian's requirements
|
||||
|
||||
- [x] **main.js** (in releases, not in repo)
|
||||
- [x] Built and minified
|
||||
- [x] Uploaded to GitHub releases
|
||||
|
||||
- [x] **styles.css** (optional)
|
||||
- [x] Plugin-specific styles
|
||||
|
||||
## ✅ GitHub Repository Requirements
|
||||
|
||||
- [x] **Public repository**
|
||||
- [x] **GitHub releases**
|
||||
- [x] Release tagged with version number (e.g., "1.0.0", NOT "v1.0.0")
|
||||
- [x] Release includes: main.js, manifest.json, styles.css
|
||||
- [x] **.gitignore**
|
||||
- [x] Excludes node_modules
|
||||
- [x] Excludes main.js (built file)
|
||||
- [x] Excludes *.map files
|
||||
|
||||
## ✅ Code Quality
|
||||
|
||||
- [x] **TypeScript**
|
||||
- [x] Properly typed
|
||||
- [x] No critical type errors
|
||||
- [x] Compiles successfully
|
||||
|
||||
- [x] **Plugin Class**
|
||||
- [x] Extends `Plugin` from Obsidian API
|
||||
- [x] Implements `onload()`
|
||||
- [x] Implements `onunload()`
|
||||
- [x] Proper cleanup in `onunload()`
|
||||
|
||||
- [x] **API Usage**
|
||||
- [x] Uses official Obsidian API only
|
||||
- [x] No private API usage
|
||||
- [x] No direct DOM manipulation outside API
|
||||
|
||||
- [x] **Error Handling**
|
||||
- [x] Proper try-catch blocks
|
||||
- [x] User-friendly error messages
|
||||
- [x] No unhandled promise rejections
|
||||
|
||||
## ✅ Build Configuration
|
||||
|
||||
- [x] **esbuild or similar bundler**
|
||||
- [x] Bundles to single main.js
|
||||
- [x] External: obsidian, electron, @codemirror/*, @lezer/*
|
||||
- [x] Format: CommonJS (cjs)
|
||||
- [x] Target: ES2018 or later
|
||||
|
||||
- [x] **package.json**
|
||||
- [x] Build script defined
|
||||
- [x] Version script defined (optional)
|
||||
- [x] Dependencies properly listed
|
||||
|
||||
## ✅ Release Process
|
||||
|
||||
- [x] **GitHub Actions** (optional but recommended)
|
||||
- [x] Automated release workflow
|
||||
- [x] Triggers on tag push
|
||||
- [x] Builds and uploads assets
|
||||
|
||||
- [x] **Version Management**
|
||||
- [x] Semantic versioning (MAJOR.MINOR.PATCH)
|
||||
- [x] manifest.json version matches tag
|
||||
- [x] versions.json updated with minAppVersion
|
||||
|
||||
## ✅ Documentation
|
||||
|
||||
- [x] **README.md includes:**
|
||||
- [x] Plugin name and description
|
||||
- [x] Features list
|
||||
- [x] Installation instructions
|
||||
- [x] Usage guide
|
||||
- [x] Configuration options
|
||||
|
||||
- [x] **CHANGELOG.md** (recommended)
|
||||
- [x] Version history
|
||||
- [x] Changes for each version
|
||||
|
||||
## ✅ Testing
|
||||
|
||||
- [x] **Manual Testing**
|
||||
- [x] Tested on desktop
|
||||
- [x] Tested on mobile (if not desktop-only)
|
||||
- [x] No console errors
|
||||
- [x] No performance issues
|
||||
|
||||
- [x] **Automated Tests** (optional but recommended)
|
||||
- [x] Unit tests
|
||||
- [x] Tests pass in CI
|
||||
|
||||
## ✅ Security & Privacy
|
||||
|
||||
- [x] **No malicious code**
|
||||
- [x] **No data collection without consent**
|
||||
- [x] **No external API calls without user configuration**
|
||||
- [x] **Secure credential storage**
|
||||
- [x] Uses Obsidian's data storage
|
||||
- [x] No credentials in code
|
||||
|
||||
## ✅ Mobile Compatibility
|
||||
|
||||
- [x] **isDesktopOnly: false**
|
||||
- [x] **No Node.js-specific APIs** (unless desktop-only)
|
||||
- [x] **Responsive UI**
|
||||
- [x] **Touch-friendly controls**
|
||||
|
||||
## 📋 Submission Steps
|
||||
|
||||
1. **Create GitHub Release**
|
||||
```bash
|
||||
git tag 1.0.0
|
||||
git push origin 1.0.0
|
||||
```
|
||||
- Release will be created automatically by GitHub Actions
|
||||
- Ensure main.js, manifest.json, and styles.css are attached
|
||||
|
||||
2. **Fork obsidian-releases**
|
||||
- Fork: https://github.com/obsidianmd/obsidian-releases
|
||||
|
||||
3. **Add Plugin to community-plugins.json**
|
||||
```json
|
||||
{
|
||||
"id": "git-file-push",
|
||||
"name": "Git File Push",
|
||||
"author": "tianyao",
|
||||
"description": "Sync individual notes with GitLab or GitHub. Push, pull, and track changes across mobile and desktop with visual diff and conflict resolution.",
|
||||
"repo": "tianyao/git-file-push"
|
||||
}
|
||||
```
|
||||
|
||||
4. **Create Pull Request**
|
||||
- Title: "Add Git File Push plugin"
|
||||
- Description: Brief description of the plugin
|
||||
- Link to your repository
|
||||
- Link to your first release
|
||||
|
||||
5. **Wait for Review**
|
||||
- Obsidian team will review
|
||||
- May request changes
|
||||
- Once approved, plugin will be available in Community Plugins
|
||||
|
||||
## 🔍 Pre-Submission Checklist
|
||||
|
||||
Before submitting, verify:
|
||||
|
||||
- [ ] Plugin works correctly in Obsidian
|
||||
- [ ] No console errors
|
||||
- [ ] README is clear and complete
|
||||
- [ ] GitHub release exists with correct assets
|
||||
- [ ] manifest.json version matches release tag
|
||||
- [ ] License is included
|
||||
- [ ] Code is clean and well-documented
|
||||
|
||||
## 📚 References
|
||||
|
||||
- [Obsidian Plugin Developer Docs](https://docs.obsidian.md/Plugins/Getting+started/Build+a+plugin)
|
||||
- [Release your plugin with GitHub Actions](https://docs.obsidian.md/Plugins/Releasing/Release+your+plugin+with+GitHub+Actions)
|
||||
- [Submit your plugin](https://docs.obsidian.md/Plugins/Releasing/Submit+your+plugin)
|
||||
- [Community Plugins Repository](https://github.com/obsidianmd/obsidian-releases)
|
||||
|
||||
## ✅ Status
|
||||
|
||||
**The Git File Push plugin is ready for submission to the Obsidian Community Plugins directory.**
|
||||
|
||||
All requirements are met. Follow the submission steps above to submit the plugin.
|
||||
122
WORKFLOWS.md
122
WORKFLOWS.md
|
|
@ -1,122 +0,0 @@
|
|||
# GitHub Actions Workflows
|
||||
|
||||
This project includes automated workflows for continuous integration and releases.
|
||||
|
||||
## Workflows
|
||||
|
||||
### 1. Check (`.github/workflows/check.yml`)
|
||||
|
||||
Runs on every push and pull request to main/master/develop branches.
|
||||
|
||||
**Jobs:**
|
||||
- **Lint**: Runs ESLint to check code quality
|
||||
- **Test**: Runs the test suite with coverage
|
||||
- **Build**: Builds the plugin and uploads artifacts
|
||||
|
||||
**Triggers:**
|
||||
- Push to main, master, or develop branches
|
||||
- Pull requests to main, master, or develop branches
|
||||
|
||||
### 2. Auto Release (`.github/workflows/auto-release.yml`)
|
||||
|
||||
Automatically bumps version, creates a tag, and publishes a release.
|
||||
|
||||
**How to use:**
|
||||
1. Go to GitHub → Actions → Auto Release
|
||||
2. Click "Run workflow"
|
||||
3. Select version bump type:
|
||||
- **patch**: Bug fixes (1.0.0 → 1.0.1)
|
||||
- **minor**: New features (1.0.0 → 1.1.0)
|
||||
- **major**: Breaking changes (1.0.0 → 2.0.0)
|
||||
4. Click "Run workflow"
|
||||
|
||||
**What it does:**
|
||||
1. Runs tests
|
||||
2. Bumps version in package.json, manifest.json, and versions.json
|
||||
3. Updates CHANGELOG.md
|
||||
4. Commits and pushes changes
|
||||
5. Creates and pushes a git tag
|
||||
6. Creates a GitHub release
|
||||
7. Uploads main.js, manifest.json, and styles.css
|
||||
|
||||
### 3. Release (`.github/workflows/release.yml`)
|
||||
|
||||
Triggered when you manually push a tag.
|
||||
|
||||
**How to use:**
|
||||
```bash
|
||||
# Create and push a tag
|
||||
git tag 1.0.1
|
||||
git push origin 1.0.1
|
||||
```
|
||||
|
||||
**What it does:**
|
||||
1. Runs tests
|
||||
2. Builds the plugin
|
||||
3. Creates a GitHub release
|
||||
4. Uploads release assets
|
||||
|
||||
## Recommended Workflow
|
||||
|
||||
### For Regular Development
|
||||
|
||||
1. Make changes and commit to a feature branch
|
||||
2. Create a pull request to main/master
|
||||
3. The Check workflow will run automatically
|
||||
4. Merge when all checks pass
|
||||
|
||||
### For Releases
|
||||
|
||||
**Option A: Automatic (Recommended)**
|
||||
1. Go to GitHub Actions → Auto Release
|
||||
2. Select version bump type
|
||||
3. Click "Run workflow"
|
||||
4. Done! The release is created automatically
|
||||
|
||||
**Option B: Manual**
|
||||
1. Update version manually:
|
||||
```bash
|
||||
npm run version
|
||||
```
|
||||
2. Update CHANGELOG.md
|
||||
3. Commit changes:
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "chore: bump version to 1.0.1"
|
||||
git push
|
||||
```
|
||||
4. Create and push tag:
|
||||
```bash
|
||||
git tag 1.0.1
|
||||
git push origin 1.0.1
|
||||
```
|
||||
5. The Release workflow will create the GitHub release
|
||||
|
||||
## Build Artifacts
|
||||
|
||||
After each successful build in the Check workflow, artifacts are uploaded and available for 7 days:
|
||||
- main.js
|
||||
- manifest.json
|
||||
- styles.css
|
||||
|
||||
You can download these from the Actions tab → Select a workflow run → Artifacts section.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Workflow fails on test
|
||||
```bash
|
||||
npm run test
|
||||
```
|
||||
Fix any failing tests before releasing.
|
||||
|
||||
### Workflow fails on build
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
Ensure the build succeeds locally.
|
||||
|
||||
### Permission denied errors
|
||||
Make sure the repository has the correct permissions:
|
||||
- Settings → Actions → General → Workflow permissions
|
||||
- Select "Read and write permissions"
|
||||
- Check "Allow GitHub Actions to create and approve pull requests"
|
||||
48
package-lock.json
generated
48
package-lock.json
generated
|
|
@ -9,6 +9,7 @@
|
|||
"version": "1.0.0",
|
||||
"license": "0-BSD",
|
||||
"dependencies": {
|
||||
"ignore": "^7.0.5",
|
||||
"obsidian": "latest"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -960,6 +961,16 @@
|
|||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/eslintrc/node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/js": {
|
||||
"version": "9.30.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.30.1.tgz",
|
||||
|
|
@ -2716,16 +2727,6 @@
|
|||
"typescript": ">=4.8.4 <5.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
|
||||
"version": "7.0.5",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
|
||||
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser": {
|
||||
"version": "8.35.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.35.1.tgz",
|
||||
|
|
@ -5113,6 +5114,16 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-n/node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-n/node_modules/minimatch": {
|
||||
"version": "9.0.9",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
||||
|
|
@ -5318,6 +5329,16 @@
|
|||
"url": "https://eslint.org/donate"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint/node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/espree": {
|
||||
"version": "10.4.0",
|
||||
"resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
|
||||
|
|
@ -6190,10 +6211,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
|
||||
"dev": true,
|
||||
"version": "7.0.5",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
|
||||
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@
|
|||
"vitest": "^4.1.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"ignore": "^7.0.5",
|
||||
"obsidian": "latest"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
111
src/logic/gitignore-manager.ts
Normal file
111
src/logic/gitignore-manager.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import ignore, { Ignore } from 'ignore';
|
||||
import { App } from 'obsidian';
|
||||
import { GitServiceInterface } from '../services/git-service-interface';
|
||||
|
||||
export class GitignoreManager {
|
||||
private app: App;
|
||||
private gitService: GitServiceInterface;
|
||||
private branch: string;
|
||||
|
||||
private rootPath: string;
|
||||
|
||||
// Maps directory path (empty string for root) to Ignore instance
|
||||
private ignoreMap: Map<string, Ignore> = new Map();
|
||||
|
||||
constructor(app: App, gitService: GitServiceInterface, branch: string, rootPath: string) {
|
||||
this.app = app;
|
||||
this.gitService = gitService;
|
||||
this.branch = branch;
|
||||
this.rootPath = rootPath.replace(/^\/|\/$/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Discovers and parses .gitignore files from the local filesystem and remote repository.
|
||||
*/
|
||||
async loadGitignores(): Promise<void> {
|
||||
this.ignoreMap.clear();
|
||||
|
||||
// 1. Fetch all gitignore paths from the entire repo tree
|
||||
let gitignorePaths: string[] = [];
|
||||
try {
|
||||
gitignorePaths = await this.gitService.getRepoGitignores(this.branch);
|
||||
} catch (e) {
|
||||
console.warn('Failed to fetch repo gitignores', e);
|
||||
// Fallback to at least checking the root
|
||||
gitignorePaths = ['.gitignore'];
|
||||
}
|
||||
|
||||
// 2. Fetch and parse each .gitignore
|
||||
for (const fullGitignorePath of gitignorePaths) {
|
||||
const dirPath = fullGitignorePath === '.gitignore' ? '' : fullGitignorePath.slice(0, -('.gitignore'.length + 1));
|
||||
|
||||
let content: string | undefined;
|
||||
|
||||
// Determine local path relative to vault root
|
||||
let localPath: string | null = null;
|
||||
if (!this.rootPath) {
|
||||
localPath = fullGitignorePath;
|
||||
} else if (fullGitignorePath === this.rootPath + '/.gitignore' || fullGitignorePath.startsWith(this.rootPath + '/')) {
|
||||
localPath = fullGitignorePath.substring(this.rootPath.length + 1);
|
||||
}
|
||||
|
||||
// Try local first if it's within the vault
|
||||
if (localPath) {
|
||||
try {
|
||||
if (await this.app.vault.adapter.exists(localPath)) {
|
||||
content = await this.app.vault.adapter.read(localPath);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`Failed to read local ${localPath}`, e);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to remote (use absolute path starting with / to bypass rootPath)
|
||||
if (content === undefined) {
|
||||
try {
|
||||
const remoteFile = await this.gitService.getFile('/' + fullGitignorePath, this.branch);
|
||||
if (remoteFile && remoteFile.content) {
|
||||
content = remoteFile.content;
|
||||
}
|
||||
} catch {
|
||||
// It's okay if some gitignores fail to fetch
|
||||
}
|
||||
}
|
||||
|
||||
if (content) {
|
||||
const ig = ignore().add(content);
|
||||
this.ignoreMap.set(dirPath, ig);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a given file path should be ignored based on loaded .gitignore rules.
|
||||
*/
|
||||
isIgnored(filePath: string): boolean {
|
||||
// Path relative to Git Root
|
||||
const fullPath = this.rootPath ? `${this.rootPath}/${filePath}` : filePath;
|
||||
|
||||
// Iterate through all loaded .gitignore files.
|
||||
for (const [dirPath, ig] of this.ignoreMap.entries()) {
|
||||
if (dirPath === '') {
|
||||
// Root .gitignore applies to everything (test against fullPath)
|
||||
if (ig.ignores(fullPath)) {
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
// Subdirectory .gitignore
|
||||
// Check if the file is inside this directory (dirPath is relative to git root)
|
||||
const prefix = dirPath + '/';
|
||||
if (fullPath.startsWith(prefix)) {
|
||||
// Extract the path relative to the directory containing .gitignore
|
||||
const relativePath = fullPath.substring(prefix.length);
|
||||
if (ig.ignores(relativePath)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -19,47 +19,60 @@ export class SyncManager {
|
|||
this.gitService = gitService;
|
||||
}
|
||||
|
||||
async pushFile(file: TFile) {
|
||||
if (!this.app.vault.getFileByPath(file.path)) {
|
||||
new Notice(`File ${file.name} no longer exists in vault.`);
|
||||
async pushFile(fileOrPath: TFile | string) {
|
||||
const isString = typeof fileOrPath === 'string';
|
||||
const path = isString ? fileOrPath : fileOrPath.path;
|
||||
const name = isString ? path.split('/').pop() || path : fileOrPath.name;
|
||||
|
||||
if (isString) {
|
||||
if (!(await this.app.vault.adapter.exists(path))) {
|
||||
new Notice(`File ${name} no longer exists in vault.`);
|
||||
return;
|
||||
}
|
||||
} else if (!this.app.vault.getFileByPath(path)) {
|
||||
new Notice(`File ${name} no longer exists in vault.`);
|
||||
return;
|
||||
}
|
||||
const content = await this.app.vault.read(file);
|
||||
|
||||
const content = isString ? await this.app.vault.adapter.read(path) : (fileOrPath instanceof TFile ? await this.app.vault.read(fileOrPath) : '');
|
||||
const serviceName = this.settings.serviceType === 'gitlab' ? 'GitLab' : 'GitHub';
|
||||
try {
|
||||
// Check if this is a renamed file
|
||||
const renamedFrom = this.detectRename(file);
|
||||
if (renamedFrom) {
|
||||
await this.handleRename(file, renamedFrom, content);
|
||||
return;
|
||||
let renamedFrom = null;
|
||||
if (!isString && fileOrPath instanceof TFile) {
|
||||
renamedFrom = this.detectRename(fileOrPath);
|
||||
if (renamedFrom) {
|
||||
await this.handleRename(fileOrPath, renamedFrom, content);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Conflict detection
|
||||
const remote = await this.gitService.getFile(file.path, this.settings.branch);
|
||||
const lastSynced = this.settings.syncMetadata[file.path];
|
||||
const remote = await this.gitService.getFile(path, this.settings.branch);
|
||||
const lastSynced = this.settings.syncMetadata[path];
|
||||
|
||||
if (remote.sha && lastSynced && remote.sha !== lastSynced.lastSyncedSha) {
|
||||
new SyncConflictModal(this.app, file, content, remote.content, (choice) => {
|
||||
new SyncConflictModal(this.app, name, content, remote.content, (choice) => {
|
||||
void (async () => {
|
||||
try {
|
||||
if (choice === 'local') {
|
||||
await this.performPush(file, content, remote.sha);
|
||||
await this.performPush({ path, name }, content, remote.sha);
|
||||
} else {
|
||||
await this.performPull(file, remote.content, remote.sha);
|
||||
await this.performPull(isString ? { path, name } : fileOrPath, remote.content, remote.sha);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
new Notice(`Failed to resolve conflict for ${file.name}: ${e instanceof Error ? e.message : String(e)}`);
|
||||
new Notice(`Failed to resolve conflict for ${name}: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
})();
|
||||
}).open();
|
||||
return;
|
||||
}
|
||||
|
||||
await this.performPush(file, content, remote.sha);
|
||||
await this.performPush({ path, name }, content, remote.sha);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
new Notice(`Failed to push ${file.name} to ${serviceName}: ${e instanceof Error ? e.message : String(e)}`);
|
||||
new Notice(`Failed to push ${name} to ${serviceName}: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -117,7 +130,7 @@ export class SyncManager {
|
|||
}
|
||||
}
|
||||
|
||||
private async performPush(file: TFile, content: string, existingSha?: string) {
|
||||
private async performPush(file: {path: string, name: string}, content: string, existingSha?: string) {
|
||||
const serviceName = this.settings.serviceType === 'gitlab' ? 'GitLab' : 'GitHub';
|
||||
await this.gitService.pushFile(
|
||||
file.path,
|
||||
|
|
@ -139,57 +152,72 @@ export class SyncManager {
|
|||
new Notice(`Pushed ${file.name} to ${serviceName}`);
|
||||
}
|
||||
|
||||
async pullFile(file: TFile) {
|
||||
if (!this.app.vault.getFileByPath(file.path)) {
|
||||
new Notice(`File ${file.name} no longer exists in vault.`);
|
||||
async pullFile(fileOrPath: TFile | string) {
|
||||
const isString = typeof fileOrPath === 'string';
|
||||
const path = isString ? fileOrPath : fileOrPath.path;
|
||||
const name = isString ? path.split('/').pop() || path : fileOrPath.name;
|
||||
|
||||
if (isString) {
|
||||
if (!(await this.app.vault.adapter.exists(path))) {
|
||||
new Notice(`File ${name} no longer exists in vault.`);
|
||||
return;
|
||||
}
|
||||
} else if (!this.app.vault.getFileByPath(path)) {
|
||||
new Notice(`File ${name} no longer exists in vault.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const serviceName = this.settings.serviceType === 'gitlab' ? 'GitLab' : 'GitHub';
|
||||
try {
|
||||
const remote = await this.gitService.getFile(file.path, this.settings.branch);
|
||||
const localContent = await this.app.vault.read(file);
|
||||
const lastSynced = this.settings.syncMetadata[file.path];
|
||||
const remote = await this.gitService.getFile(path, this.settings.branch);
|
||||
const localContent = isString ? await this.app.vault.adapter.read(path) : (fileOrPath instanceof TFile ? await this.app.vault.read(fileOrPath) : '');
|
||||
const lastSynced = this.settings.syncMetadata[path];
|
||||
|
||||
if (localContent === remote.content) {
|
||||
// Still update metadata even if content matches
|
||||
this.settings.syncMetadata[file.path] = {
|
||||
this.settings.syncMetadata[path] = {
|
||||
lastSyncedSha: remote.sha,
|
||||
lastSyncedAt: Date.now()
|
||||
};
|
||||
await this.saveSettings();
|
||||
new Notice(`${file.name} is already up to date.`);
|
||||
new Notice(`${name} is already up to date.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Conflict detection for pull
|
||||
if (remote.sha && lastSynced && remote.sha !== lastSynced.lastSyncedSha) {
|
||||
new SyncConflictModal(this.app, file, localContent, remote.content, (choice) => {
|
||||
new SyncConflictModal(this.app, name, localContent, remote.content, (choice) => {
|
||||
void (async () => {
|
||||
try {
|
||||
if (choice === 'local') {
|
||||
await this.performPush(file, localContent, remote.sha);
|
||||
await this.performPush({ path, name }, localContent, remote.sha);
|
||||
} else {
|
||||
await this.performPull(file, remote.content, remote.sha);
|
||||
await this.performPull(isString ? { path, name } : fileOrPath, remote.content, remote.sha);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
new Notice(`Failed to resolve conflict for ${file.name}: ${e instanceof Error ? e.message : String(e)}`);
|
||||
new Notice(`Failed to resolve conflict for ${name}: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
})();
|
||||
}).open();
|
||||
return;
|
||||
}
|
||||
|
||||
await this.performPull(file, remote.content, remote.sha);
|
||||
await this.performPull(isString ? { path, name } : fileOrPath, remote.content, remote.sha);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
new Notice(`Failed to pull ${file.name} from ${serviceName}: ${e instanceof Error ? e.message : String(e)}`);
|
||||
new Notice(`Failed to pull ${name} from ${serviceName}: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async performPull(file: TFile, remoteContent: string, remoteSha: string) {
|
||||
private async performPull(file: TFile | {path: string, name: string}, remoteContent: string, remoteSha: string) {
|
||||
const serviceName = this.settings.serviceType === 'gitlab' ? 'GitLab' : 'GitHub';
|
||||
await this.app.vault.modify(file, remoteContent);
|
||||
|
||||
if (file instanceof TFile) {
|
||||
await this.app.vault.modify(file, remoteContent);
|
||||
} else {
|
||||
await this.app.vault.adapter.write(file.path, remoteContent);
|
||||
}
|
||||
|
||||
// Update metadata
|
||||
this.settings.syncMetadata[file.path] = {
|
||||
|
|
@ -199,7 +227,8 @@ export class SyncManager {
|
|||
};
|
||||
|
||||
await this.saveSettings();
|
||||
new Notice(`Pulled ${file.name} from ${serviceName}`);
|
||||
const name = file instanceof TFile ? file.name : file.name;
|
||||
new Notice(`Pulled ${name} from ${serviceName}`);
|
||||
}
|
||||
|
||||
private async saveSettings() {
|
||||
|
|
@ -212,50 +241,64 @@ export class SyncManager {
|
|||
/* eslint-enable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any */
|
||||
}
|
||||
|
||||
async pushAllFiles(files: TFile[], onProgress?: (current: number, total: number, fileName: string) => void): Promise<{ success: number; failed: number; errors: Array<{ file: string; error: string }> }> {
|
||||
async pushAllFiles(files: (TFile | string)[], onProgress?: (current: number, total: number, fileName: string) => void): Promise<{ success: number; failed: number; errors: Array<{ file: string; error: string }> }> {
|
||||
const results = { success: 0, failed: 0, errors: [] as Array<{ file: string; error: string }> };
|
||||
const serviceName = this.settings.serviceType === 'gitlab' ? 'GitLab' : 'GitHub';
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
if (!file) continue;
|
||||
const fileOrPath = files[i];
|
||||
if (!fileOrPath) continue;
|
||||
|
||||
const isString = typeof fileOrPath === 'string';
|
||||
const path = isString ? fileOrPath : fileOrPath.path;
|
||||
const name = isString ? path.split('/').pop() || path : fileOrPath.name;
|
||||
|
||||
if (onProgress) {
|
||||
onProgress(i + 1, files.length, file.name);
|
||||
onProgress(i + 1, files.length, name);
|
||||
}
|
||||
|
||||
try {
|
||||
const existingFile = this.app.vault.getFileByPath(file.path);
|
||||
if (!existingFile) {
|
||||
results.failed++;
|
||||
results.errors.push({ file: file.path, error: 'File no longer exists' });
|
||||
continue;
|
||||
let content: string;
|
||||
if (isString) {
|
||||
if (!(await this.app.vault.adapter.exists(path))) {
|
||||
results.failed++;
|
||||
results.errors.push({ file: path, error: 'File no longer exists' });
|
||||
continue;
|
||||
}
|
||||
content = await this.app.vault.adapter.read(path);
|
||||
} else {
|
||||
const existingFile = this.app.vault.getFileByPath(path);
|
||||
if (!existingFile) {
|
||||
results.failed++;
|
||||
results.errors.push({ file: path, error: 'File no longer exists' });
|
||||
continue;
|
||||
}
|
||||
content = await this.app.vault.read(existingFile);
|
||||
}
|
||||
|
||||
const content = await this.app.vault.read(existingFile);
|
||||
const remote = await this.gitService.getFile(file.path, this.settings.branch);
|
||||
const remote = await this.gitService.getFile(path, this.settings.branch);
|
||||
|
||||
await this.gitService.pushFile(
|
||||
file.path,
|
||||
path,
|
||||
content,
|
||||
this.settings.branch,
|
||||
`Update ${file.name} from Obsidian`,
|
||||
`Update ${name} from Obsidian`,
|
||||
remote.sha || undefined
|
||||
);
|
||||
|
||||
const newRemote = await this.gitService.getFile(file.path, this.settings.branch);
|
||||
this.settings.syncMetadata[file.path] = {
|
||||
const newRemote = await this.gitService.getFile(path, this.settings.branch);
|
||||
this.settings.syncMetadata[path] = {
|
||||
lastSyncedSha: newRemote.sha,
|
||||
lastSyncedAt: Date.now(),
|
||||
lastKnownPath: file.path
|
||||
lastKnownPath: path
|
||||
};
|
||||
|
||||
results.success++;
|
||||
} catch (e) {
|
||||
console.error(`Failed to push ${file.path}:`, e);
|
||||
console.error(`Failed to push ${path}:`, e);
|
||||
results.failed++;
|
||||
results.errors.push({
|
||||
file: file.path,
|
||||
file: path,
|
||||
error: e instanceof Error ? e.message : String(e)
|
||||
});
|
||||
}
|
||||
|
|
@ -273,48 +316,64 @@ export class SyncManager {
|
|||
return results;
|
||||
}
|
||||
|
||||
async pullAllFiles(files: TFile[], onProgress?: (current: number, total: number, fileName: string) => void): Promise<{ success: number; failed: number; errors: Array<{ file: string; error: string }> }> {
|
||||
async pullAllFiles(files: (TFile | string)[], onProgress?: (current: number, total: number, fileName: string) => void): Promise<{ success: number; failed: number; errors: Array<{ file: string; error: string }> }> {
|
||||
const results = { success: 0, failed: 0, errors: [] as Array<{ file: string; error: string }> };
|
||||
const serviceName = this.settings.serviceType === 'gitlab' ? 'GitLab' : 'GitHub';
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
if (!file) continue;
|
||||
const fileOrPath = files[i];
|
||||
if (!fileOrPath) continue;
|
||||
|
||||
const isString = typeof fileOrPath === 'string';
|
||||
const path = isString ? fileOrPath : fileOrPath.path;
|
||||
const name = isString ? path.split('/').pop() || path : fileOrPath.name;
|
||||
|
||||
if (onProgress) {
|
||||
onProgress(i + 1, files.length, file.name);
|
||||
onProgress(i + 1, files.length, name);
|
||||
}
|
||||
|
||||
try {
|
||||
const existingFile = this.app.vault.getFileByPath(file.path);
|
||||
if (!existingFile) {
|
||||
results.failed++;
|
||||
results.errors.push({ file: file.path, error: 'File no longer exists' });
|
||||
continue;
|
||||
if (isString) {
|
||||
if (!(await this.app.vault.adapter.exists(path))) {
|
||||
results.failed++;
|
||||
results.errors.push({ file: path, error: 'File no longer exists' });
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
const existingFile = this.app.vault.getFileByPath(path);
|
||||
if (!existingFile) {
|
||||
results.failed++;
|
||||
results.errors.push({ file: path, error: 'File no longer exists' });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const remote = await this.gitService.getFile(file.path, this.settings.branch);
|
||||
const remote = await this.gitService.getFile(path, this.settings.branch);
|
||||
|
||||
if (!remote.sha) {
|
||||
results.failed++;
|
||||
results.errors.push({ file: file.path, error: 'File not found in remote' });
|
||||
results.errors.push({ file: path, error: 'File not found in remote' });
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.app.vault.modify(existingFile, remote.content);
|
||||
if (isString) {
|
||||
await this.app.vault.adapter.write(path, remote.content);
|
||||
} else if (fileOrPath instanceof TFile) {
|
||||
await this.app.vault.modify(fileOrPath, remote.content);
|
||||
}
|
||||
|
||||
this.settings.syncMetadata[file.path] = {
|
||||
this.settings.syncMetadata[path] = {
|
||||
lastSyncedSha: remote.sha,
|
||||
lastSyncedAt: Date.now(),
|
||||
lastKnownPath: file.path
|
||||
lastKnownPath: path
|
||||
};
|
||||
|
||||
results.success++;
|
||||
} catch (e) {
|
||||
console.error(`Failed to pull ${file.path}:`, e);
|
||||
console.error(`Failed to pull ${path}:`, e);
|
||||
results.failed++;
|
||||
results.errors.push({
|
||||
file: file.path,
|
||||
file: path,
|
||||
error: e instanceof Error ? e.message : String(e)
|
||||
});
|
||||
}
|
||||
|
|
|
|||
27
src/main.ts
27
src/main.ts
|
|
@ -5,11 +5,13 @@ import { GitHubService } from './services/github-service';
|
|||
import { GitServiceInterface } from './services/git-service-interface';
|
||||
import { SyncManager } from './logic/sync-manager';
|
||||
import { SyncStatusView, SYNC_STATUS_VIEW_TYPE } from './ui/SyncStatusView';
|
||||
import { GitignoreManager } from './logic/gitignore-manager';
|
||||
|
||||
export default class GitLabFilesPush extends Plugin {
|
||||
settings: GitLabFilesPushSettings;
|
||||
gitService: GitServiceInterface;
|
||||
sync: SyncManager;
|
||||
gitignoreManager: GitignoreManager;
|
||||
|
||||
async onload() {
|
||||
await this.loadSettings();
|
||||
|
|
@ -33,6 +35,7 @@ export default class GitLabFilesPush extends Plugin {
|
|||
});
|
||||
|
||||
this.initializeGitService();
|
||||
this.gitignoreManager = new GitignoreManager(this.app, this.gitService, this.settings.branch, this.settings.rootPath);
|
||||
this.sync = new SyncManager(this.app, this.gitService, this.settings);
|
||||
|
||||
const serviceName = this.settings.serviceType === 'gitlab' ? 'GitLab' : 'GitHub';
|
||||
|
|
@ -70,7 +73,7 @@ export default class GitLabFilesPush extends Plugin {
|
|||
|
||||
this.addCommand({
|
||||
id: 'push-all-files',
|
||||
name: 'Push all markdown files',
|
||||
name: 'Push all files',
|
||||
callback: () => {
|
||||
void this.pushAllFiles();
|
||||
}
|
||||
|
|
@ -78,7 +81,7 @@ export default class GitLabFilesPush extends Plugin {
|
|||
|
||||
this.addCommand({
|
||||
id: 'pull-all-files',
|
||||
name: 'Pull all markdown files',
|
||||
name: 'Pull all files',
|
||||
callback: () => {
|
||||
void this.pullAllFiles();
|
||||
}
|
||||
|
|
@ -133,16 +136,20 @@ export default class GitLabFilesPush extends Plugin {
|
|||
}
|
||||
|
||||
async pushAllFiles(): Promise<void> {
|
||||
const allFiles = this.app.vault.getMarkdownFiles();
|
||||
const files = this.filterFilesByVaultFolder(allFiles);
|
||||
const allFiles = this.app.vault.getFiles();
|
||||
let files = this.filterFilesByVaultFolder(allFiles);
|
||||
const serviceName = this.settings.serviceType === 'gitlab' ? 'GitLab' : 'GitHub';
|
||||
|
||||
await this.gitService.listFiles(this.settings.branch);
|
||||
await this.gitignoreManager.loadGitignores();
|
||||
files = files.filter(f => !this.gitignoreManager.isIgnored(f.path));
|
||||
|
||||
if (files.length === 0) {
|
||||
new Notice('No files to push in the configured vault folder');
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = await this.showConfirmDialog(`Push ${files.length} markdown file(s) to ${serviceName}?`);
|
||||
const confirmed = await this.showConfirmDialog(`Push ${files.length} file(s) to ${serviceName}?`);
|
||||
if (!confirmed) return;
|
||||
|
||||
const progressNotice = new Notice(`Pushing 0/${files.length} files...`, 0);
|
||||
|
|
@ -165,16 +172,20 @@ export default class GitLabFilesPush extends Plugin {
|
|||
}
|
||||
|
||||
async pullAllFiles(): Promise<void> {
|
||||
const allFiles = this.app.vault.getMarkdownFiles();
|
||||
const files = this.filterFilesByVaultFolder(allFiles);
|
||||
const allFiles = this.app.vault.getFiles();
|
||||
let files = this.filterFilesByVaultFolder(allFiles);
|
||||
const serviceName = this.settings.serviceType === 'gitlab' ? 'GitLab' : 'GitHub';
|
||||
|
||||
await this.gitService.listFiles(this.settings.branch);
|
||||
await this.gitignoreManager.loadGitignores();
|
||||
files = files.filter(f => !this.gitignoreManager.isIgnored(f.path));
|
||||
|
||||
if (files.length === 0) {
|
||||
new Notice('No files to pull in the configured vault folder');
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = await this.showConfirmDialog(`Pull ${files.length} markdown file(s) from ${serviceName}? This will overwrite local changes.`);
|
||||
const confirmed = await this.showConfirmDialog(`Pull ${files.length} file(s) from ${serviceName}? This will overwrite local changes.`);
|
||||
if (!confirmed) return;
|
||||
|
||||
const progressNotice = new Notice(`Pulling 0/${files.length} files...`, 0);
|
||||
|
|
|
|||
|
|
@ -3,4 +3,7 @@ export interface GitServiceInterface {
|
|||
getFile(path: string, branch: string): Promise<{ content: string; sha: string }>;
|
||||
pushFile(path: string, content: string, branch: string, commitMessage: string, existingSha?: string): Promise<string>;
|
||||
testConnection(): Promise<void>;
|
||||
listFiles(branch: string, path?: string): Promise<string[]>;
|
||||
deleteFile(path: string, branch: string, commitMessage: string): Promise<void>;
|
||||
getRepoGitignores(branch: string): Promise<string[]>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,7 +36,9 @@ export class GitHubService implements GitServiceInterface {
|
|||
}
|
||||
|
||||
private getApiUrl(path: string): string {
|
||||
const fullPath = this.rootPath ? `${this.rootPath}/${path}` : path;
|
||||
const isAbsolute = path.startsWith('/');
|
||||
const cleanPath = path.replace(/^\//, '');
|
||||
const fullPath = (this.rootPath && !isAbsolute) ? `${this.rootPath}/${cleanPath}` : cleanPath;
|
||||
return `https://api.github.com/repos/${this.owner}/${this.repo}/contents/${fullPath}`;
|
||||
}
|
||||
|
||||
|
|
@ -70,9 +72,8 @@ export class GitHubService implements GitServiceInterface {
|
|||
url,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.token}`,
|
||||
'Accept': 'application/vnd.github.v3+json',
|
||||
'User-Agent': 'Obsidian-GitLab-Files-Push'
|
||||
'Authorization': `token ${this.token}`,
|
||||
'Accept': 'application/vnd.github.v3+json'
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -123,10 +124,9 @@ export class GitHubService implements GitServiceInterface {
|
|||
url,
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.token}`,
|
||||
'Authorization': `token ${this.token}`,
|
||||
'Accept': 'application/vnd.github.v3+json',
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'Obsidian-GitLab-Files-Push'
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
|
@ -145,19 +145,134 @@ export class GitHubService implements GitServiceInterface {
|
|||
if (!this.repo) throw new Error('Repository is missing');
|
||||
|
||||
const url = `https://api.github.com/repos/${this.owner}/${this.repo}`;
|
||||
|
||||
try {
|
||||
const response = await this.safeRequest({
|
||||
url,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `token ${this.token}`,
|
||||
'Accept': 'application/vnd.github.v3+json'
|
||||
}
|
||||
});
|
||||
|
||||
if (response.status !== 200) {
|
||||
const errorBody = response.text || JSON.stringify(response.json);
|
||||
throw new Error(`Failed to connect: ${response.status} ${url}. Response: ${errorBody}`);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
// Provide more helpful error messages for common issues
|
||||
if (e.message.includes('NAME') || e.message.includes('resolve')) {
|
||||
throw new Error(`DNS resolution failed. Please check your network connection or try restarting Obsidian. Original error: ${e.message}`);
|
||||
}
|
||||
if (e.message.includes('CERT') || e.message.includes('certificate')) {
|
||||
throw new Error(`SSL certificate error. This may be caused by network security settings. Original error: ${e.message}`);
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async listFiles(branch: string, path: string = ''): Promise<string[]> {
|
||||
const url = `https://api.github.com/repos/${this.owner}/${this.repo}/git/trees/${branch}?recursive=1`;
|
||||
|
||||
const response = await this.safeRequest({
|
||||
url,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.token}`,
|
||||
'Accept': 'application/vnd.github.v3+json',
|
||||
'User-Agent': 'Obsidian-GitLab-Files-Push'
|
||||
'Authorization': `token ${this.token}`,
|
||||
'Accept': 'application/vnd.github.v3+json'
|
||||
}
|
||||
});
|
||||
|
||||
if (response.status !== 200) {
|
||||
const errorBody = response.text || JSON.stringify(response.json);
|
||||
throw new Error(`Failed to connect: ${response.status} ${url}. Response: ${errorBody}`);
|
||||
throw new Error(`Failed to list files: ${response.status} ${url}. Response: ${errorBody}`);
|
||||
}
|
||||
|
||||
interface TreeItem {
|
||||
path: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
interface TreeResponse {
|
||||
tree: TreeItem[];
|
||||
}
|
||||
|
||||
const data = response.json as TreeResponse;
|
||||
const allFiles = data.tree
|
||||
.filter(item => item.type === 'blob')
|
||||
.map(item => item.path);
|
||||
|
||||
// Filter by rootPath if set
|
||||
if (this.rootPath) {
|
||||
const prefix = this.rootPath + '/';
|
||||
return allFiles
|
||||
.filter(file => file.startsWith(prefix))
|
||||
.map(file => file.substring(prefix.length));
|
||||
}
|
||||
|
||||
return allFiles;
|
||||
}
|
||||
|
||||
async deleteFile(path: string, branch: string, commitMessage: string): Promise<void> {
|
||||
const url = this.getApiUrl(path);
|
||||
|
||||
// Get current file SHA
|
||||
const fileInfo = await this.getFile(path, branch);
|
||||
if (!fileInfo.sha) {
|
||||
throw new Error(`File not found: ${path}`);
|
||||
}
|
||||
|
||||
const response = await this.safeRequest({
|
||||
url,
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `token ${this.token}`,
|
||||
'Accept': 'application/vnd.github.v3+json',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message: commitMessage,
|
||||
sha: fileInfo.sha,
|
||||
branch
|
||||
})
|
||||
});
|
||||
|
||||
if (response.status !== 200 && response.status !== 204) {
|
||||
const errorBody = response.text || JSON.stringify(response.json);
|
||||
throw new Error(`Failed to delete file: ${response.status} DELETE ${url}. Response: ${errorBody}`);
|
||||
}
|
||||
}
|
||||
|
||||
async getRepoGitignores(branch: string): Promise<string[]> {
|
||||
const url = `https://api.github.com/repos/${this.owner}/${this.repo}/git/trees/${branch}?recursive=1`;
|
||||
const response = await this.safeRequest({
|
||||
url,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `token ${this.token}`,
|
||||
'Accept': 'application/vnd.github.v3+json'
|
||||
}
|
||||
});
|
||||
|
||||
if (response.status !== 200) {
|
||||
return [];
|
||||
}
|
||||
|
||||
interface TreeItem {
|
||||
path: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
interface TreeResponse {
|
||||
tree: TreeItem[];
|
||||
}
|
||||
|
||||
const data = response.json as TreeResponse;
|
||||
return data.tree
|
||||
.filter(item => item.type === 'blob' && item.path.endsWith('.gitignore'))
|
||||
.map(item => item.path);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,7 +36,9 @@ export class GitLabService implements GitServiceInterface {
|
|||
}
|
||||
|
||||
private getApiUrl(path: string): string {
|
||||
const fullPath = this.rootPath ? `${this.rootPath}/${path}` : path;
|
||||
const isAbsolute = path.startsWith('/');
|
||||
const cleanPath = path.replace(/^\//, '');
|
||||
const fullPath = (this.rootPath && !isAbsolute) ? `${this.rootPath}/${cleanPath}` : cleanPath;
|
||||
const encodedPath = encodeURIComponent(fullPath);
|
||||
const encodedProjectId = encodeURIComponent(this.projectId);
|
||||
return `${this.baseUrl}/api/v4/projects/${encodedProjectId}/repository/files/${encodedPath}`;
|
||||
|
|
@ -158,4 +160,93 @@ export class GitLabService implements GitServiceInterface {
|
|||
throw new Error(`Failed to connect: ${response.status} ${url}. Response: ${errorBody}`);
|
||||
}
|
||||
}
|
||||
|
||||
async listFiles(branch: string, path: string = ''): Promise<string[]> {
|
||||
const encodedProjectId = encodeURIComponent(this.projectId);
|
||||
const searchPath = this.rootPath || path || '';
|
||||
const url = `${this.baseUrl}/api/v4/projects/${encodedProjectId}/repository/tree?ref=${branch}&recursive=true&per_page=100${searchPath ? `&path=${encodeURIComponent(searchPath)}` : ''}`;
|
||||
|
||||
const response = await this.safeRequest({
|
||||
url,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'PRIVATE-TOKEN': this.token
|
||||
}
|
||||
});
|
||||
|
||||
if (response.status !== 200) {
|
||||
const errorBody = response.text || JSON.stringify(response.json);
|
||||
throw new Error(`Failed to list files: ${response.status} ${url}. Response: ${errorBody}`);
|
||||
}
|
||||
|
||||
interface TreeItem {
|
||||
path: string;
|
||||
type: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const data = response.json as TreeItem[];
|
||||
const allFiles = data
|
||||
.filter(item => item.type === 'blob')
|
||||
.map(item => item.path);
|
||||
|
||||
// Filter by rootPath if set
|
||||
if (this.rootPath) {
|
||||
const prefix = this.rootPath + '/';
|
||||
return allFiles
|
||||
.filter(file => file.startsWith(prefix))
|
||||
.map(file => file.substring(prefix.length));
|
||||
}
|
||||
|
||||
return allFiles;
|
||||
}
|
||||
|
||||
async deleteFile(path: string, branch: string, commitMessage: string): Promise<void> {
|
||||
const url = this.getApiUrl(path);
|
||||
|
||||
const response = await this.safeRequest({
|
||||
url,
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'PRIVATE-TOKEN': this.token,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
branch,
|
||||
commit_message: commitMessage
|
||||
})
|
||||
});
|
||||
|
||||
if (response.status !== 200 && response.status !== 204) {
|
||||
const errorBody = response.text || JSON.stringify(response.json);
|
||||
throw new Error(`Failed to delete file: ${response.status} DELETE ${url}. Response: ${errorBody}`);
|
||||
}
|
||||
}
|
||||
|
||||
async getRepoGitignores(branch: string): Promise<string[]> {
|
||||
const encodedProjectId = encodeURIComponent(this.projectId);
|
||||
const url = `${this.baseUrl}/api/v4/projects/${encodedProjectId}/repository/tree?ref=${branch}&recursive=true&per_page=100`;
|
||||
|
||||
const response = await this.safeRequest({
|
||||
url,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'PRIVATE-TOKEN': this.token
|
||||
}
|
||||
});
|
||||
|
||||
if (response.status !== 200) {
|
||||
return [];
|
||||
}
|
||||
|
||||
interface TreeItem {
|
||||
path: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
const data = response.json as TreeItem[];
|
||||
return data
|
||||
.filter(item => item.type === 'blob' && item.path.endsWith('.gitignore'))
|
||||
.map(item => item.path);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
import { App, Modal, Setting, TFile } from 'obsidian';
|
||||
import { App, Modal, Setting } from 'obsidian';
|
||||
|
||||
export class SyncConflictModal extends Modal {
|
||||
private file: TFile;
|
||||
private fileName: string;
|
||||
private localContent: string;
|
||||
private remoteContent: string;
|
||||
private onChoose: (choice: 'local' | 'remote') => void;
|
||||
|
||||
constructor(app: App, file: TFile, local: string, remote: string, onChoose: (choice: 'local' | 'remote') => void) {
|
||||
constructor(app: App, fileName: string, local: string, remote: string, onChoose: (choice: 'local' | 'remote') => void) {
|
||||
super(app);
|
||||
this.file = file;
|
||||
this.fileName = fileName;
|
||||
this.localContent = local;
|
||||
this.remoteContent = remote;
|
||||
this.onChoose = onChoose;
|
||||
|
|
@ -18,7 +18,7 @@ export class SyncConflictModal extends Modal {
|
|||
const { contentEl } = this;
|
||||
contentEl.addClass('sync-conflict-modal');
|
||||
|
||||
contentEl.createEl('h2', { text: `Conflict in ${this.file.name}` });
|
||||
contentEl.createEl('h2', { text: `Conflict in ${this.fileName}` });
|
||||
contentEl.createEl('p', {
|
||||
text: 'The remote file has different content. Review the differences and choose which version to keep.',
|
||||
cls: 'conflict-description'
|
||||
|
|
@ -39,7 +39,7 @@ export class SyncConflictModal extends Modal {
|
|||
const diffSection = contentEl.createDiv({ cls: 'conflict-diff-section' });
|
||||
diffSection.createEl('h3', { text: 'Differences' });
|
||||
const diffPre = diffSection.createEl('pre', { cls: 'conflict-diff' });
|
||||
diffPre.createEl('code', { text: this.generateDiff() });
|
||||
this.renderDiff(diffPre);
|
||||
|
||||
const buttonContainer = contentEl.createDiv({ cls: 'conflict-buttons' });
|
||||
|
||||
|
|
@ -67,14 +67,18 @@ export class SyncConflictModal extends Modal {
|
|||
}));
|
||||
}
|
||||
|
||||
private generateDiff(): string {
|
||||
private renderDiff(container: HTMLElement) {
|
||||
const localLines = this.localContent.split('\n');
|
||||
const remoteLines = this.remoteContent.split('\n');
|
||||
|
||||
const diff: string[] = [];
|
||||
diff.push('--- Remote');
|
||||
diff.push('+++ Local');
|
||||
diff.push('');
|
||||
const createLine = (text: string, type: 'header' | 'added' | 'removed' | 'unchanged') => {
|
||||
const lineEl = container.createSpan({ cls: `diff-line ${type}` });
|
||||
lineEl.textContent = text + '\n';
|
||||
};
|
||||
|
||||
createLine('--- Remote', 'header');
|
||||
createLine('+++ Local', 'header');
|
||||
createLine('', 'unchanged');
|
||||
|
||||
const maxLines = Math.max(localLines.length, remoteLines.length);
|
||||
|
||||
|
|
@ -84,17 +88,15 @@ export class SyncConflictModal extends Modal {
|
|||
|
||||
if (remoteLine !== localLine) {
|
||||
if (remoteLine !== undefined) {
|
||||
diff.push(`- ${remoteLine}`);
|
||||
createLine(`- ${remoteLine}`, 'removed');
|
||||
}
|
||||
if (localLine !== undefined) {
|
||||
diff.push(`+ ${localLine}`);
|
||||
createLine(`+ ${localLine}`, 'added');
|
||||
}
|
||||
} else if (remoteLine !== undefined) {
|
||||
diff.push(` ${remoteLine}`);
|
||||
createLine(` ${remoteLine}`, 'unchanged');
|
||||
}
|
||||
}
|
||||
|
||||
return diff.join('\n');
|
||||
}
|
||||
|
||||
onClose() {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ import GitLabFilesPush from '../main';
|
|||
export const SYNC_STATUS_VIEW_TYPE = 'sync-status-view';
|
||||
|
||||
interface FileStatus {
|
||||
file: TFile;
|
||||
file?: TFile;
|
||||
path: string;
|
||||
status: 'synced' | 'modified' | 'unsynced' | 'remote-only' | 'checking';
|
||||
localContent?: string;
|
||||
remoteContent?: string;
|
||||
|
|
@ -16,6 +17,10 @@ export class SyncStatusView extends ItemView {
|
|||
plugin: GitLabFilesPush;
|
||||
private fileStatuses: Map<string, FileStatus> = new Map();
|
||||
private isRefreshing = false;
|
||||
private statusFilter: 'all' | 'synced' | 'modified' | 'unsynced' | 'remote-only' = 'all';
|
||||
private selectedFiles: Set<string> = new Set();
|
||||
private lastSyncTime: number = 0;
|
||||
private previousFilter: typeof this.statusFilter = 'all';
|
||||
|
||||
constructor(leaf: WorkspaceLeaf, plugin: GitLabFilesPush) {
|
||||
super(leaf);
|
||||
|
|
@ -58,23 +63,108 @@ export class SyncStatusView extends ItemView {
|
|||
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 buttonContainer = container.createDiv({ cls: 'sync-status-buttons' });
|
||||
const refreshBtn = buttonContainer.createEl('button', { text: 'Refresh status' });
|
||||
|
||||
// Refresh button
|
||||
const refreshBtn = buttonContainer.createEl('button', { text: 'Refresh status', cls: 'refresh-btn' });
|
||||
refreshBtn.addEventListener('click', () => {
|
||||
void this.refreshAllStatuses();
|
||||
});
|
||||
|
||||
const pushAllBtn = buttonContainer.createEl('button', { text: 'Push all modified', cls: 'push-all-btn' });
|
||||
pushAllBtn.addEventListener('click', () => {
|
||||
void this.pushAllModified();
|
||||
// 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 pullAllBtn = buttonContainer.createEl('button', { text: 'Pull all modified', cls: 'pull-all-btn' });
|
||||
pullAllBtn.addEventListener('click', () => {
|
||||
void this.pullAllModified();
|
||||
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[];
|
||||
|
||||
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;
|
||||
|
||||
const pushSelectedBtn = repoContainer.createEl('button', {
|
||||
text: `Push selected (${canPush})`,
|
||||
cls: 'push-all-btn'
|
||||
});
|
||||
pushSelectedBtn.disabled = canPush === 0;
|
||||
pushSelectedBtn.addEventListener('click', () => {
|
||||
void this.pushSelected();
|
||||
});
|
||||
|
||||
const pullSelectedBtn = repoContainer.createEl('button', {
|
||||
text: `Pull selected (${canPull})`,
|
||||
cls: 'pull-all-btn'
|
||||
});
|
||||
pullSelectedBtn.disabled = canPull === 0;
|
||||
pullSelectedBtn.addEventListener('click', () => {
|
||||
void this.pullSelected();
|
||||
});
|
||||
|
||||
const deleteSelectedBtn = repoContainer.createEl('button', {
|
||||
text: `Delete selected (${canDelete})`,
|
||||
cls: 'delete-btn'
|
||||
});
|
||||
deleteSelectedBtn.disabled = canDelete === 0;
|
||||
deleteSelectedBtn.addEventListener('click', () => {
|
||||
void this.deleteSelected();
|
||||
});
|
||||
|
||||
// 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' }
|
||||
];
|
||||
|
||||
for (const filter of filters) {
|
||||
const btn = filterContainer.createEl('button', {
|
||||
text: filter.label,
|
||||
cls: this.statusFilter === filter.value ? 'filter-active' : ''
|
||||
});
|
||||
btn.addEventListener('click', () => {
|
||||
// Clear selections when switching filter
|
||||
if (this.statusFilter !== filter.value) {
|
||||
this.selectedFiles.clear();
|
||||
}
|
||||
this.statusFilter = filter.value;
|
||||
this.renderView();
|
||||
});
|
||||
}
|
||||
|
||||
const statusContainer = container.createDiv({ cls: 'sync-status-list' });
|
||||
|
||||
if (this.fileStatuses.size === 0) {
|
||||
|
|
@ -88,16 +178,31 @@ export class SyncStatusView extends ItemView {
|
|||
}
|
||||
|
||||
private renderFileStatuses(container: HTMLElement): void {
|
||||
const statuses = Array.from(this.fileStatuses.values());
|
||||
const allStatuses = Array.from(this.fileStatuses.values());
|
||||
|
||||
// Apply filter
|
||||
const statuses = this.statusFilter === 'all'
|
||||
? allStatuses
|
||||
: allStatuses.filter(s => s.status === this.statusFilter);
|
||||
|
||||
const summary = container.createDiv({ cls: 'sync-status-summary' });
|
||||
const synced = statuses.filter(s => s.status === 'synced').length;
|
||||
const modified = statuses.filter(s => s.status === 'modified').length;
|
||||
const unsynced = statuses.filter(s => s.status === 'unsynced').length;
|
||||
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}` });
|
||||
|
||||
if (statuses.length === 0) {
|
||||
container.createEl('div', {
|
||||
text: this.statusFilter === 'all' ? 'No files found' : `No ${this.statusFilter} files`,
|
||||
cls: 'sync-status-empty'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
for (const fileStatus of statuses) {
|
||||
this.renderFileStatus(container, fileStatus);
|
||||
|
|
@ -109,6 +214,18 @@ export class SyncStatusView extends ItemView {
|
|||
|
||||
const headerEl = fileEl.createDiv({ cls: 'sync-status-file-header' });
|
||||
|
||||
// Add checkbox
|
||||
const checkbox = headerEl.createEl('input', { type: 'checkbox' });
|
||||
checkbox.checked = this.selectedFiles.has(fileStatus.path);
|
||||
checkbox.addEventListener('change', () => {
|
||||
if (checkbox.checked) {
|
||||
this.selectedFiles.add(fileStatus.path);
|
||||
} else {
|
||||
this.selectedFiles.delete(fileStatus.path);
|
||||
}
|
||||
this.renderView();
|
||||
});
|
||||
|
||||
let icon = '○';
|
||||
let statusText = '';
|
||||
let statusClass = '';
|
||||
|
|
@ -129,6 +246,11 @@ export class SyncStatusView extends ItemView {
|
|||
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...';
|
||||
|
|
@ -137,9 +259,120 @@ export class SyncStatusView extends ItemView {
|
|||
}
|
||||
|
||||
headerEl.createSpan({ text: `${icon} `, cls: `status-icon ${statusClass}` });
|
||||
headerEl.createSpan({ text: fileStatus.file.path, cls: 'file-path' });
|
||||
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();
|
||||
|
||||
await this.plugin.sync.pushFile(fileStatus.file || fileStatus.path);
|
||||
|
||||
// Wait a bit for the remote to update
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
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();
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
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)}`);
|
||||
}
|
||||
}
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
||||
if (fileStatus.status === 'remote-only') {
|
||||
const actionsEl = fileEl.createDiv({ cls: 'sync-status-actions' });
|
||||
const pullBtn = actionsEl.createEl('button', { text: 'Pull from remote' });
|
||||
|
||||
pullBtn.addEventListener('click', () => {
|
||||
void (async () => {
|
||||
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();
|
||||
}
|
||||
} 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();
|
||||
}
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
||||
if (fileStatus.status === 'modified' && fileStatus.diff) {
|
||||
const diffToggle = headerEl.createEl('button', {
|
||||
text: 'Show diff',
|
||||
|
|
@ -150,7 +383,17 @@ export class SyncStatusView extends ItemView {
|
|||
diffContainer.addClass('hidden');
|
||||
|
||||
const diffPre = diffContainer.createEl('pre');
|
||||
diffPre.createEl('code', { text: fileStatus.diff });
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
diffToggle.addEventListener('click', () => {
|
||||
if (diffContainer.hasClass('hidden')) {
|
||||
|
|
@ -167,13 +410,51 @@ export class SyncStatusView extends ItemView {
|
|||
const pullBtn = actionsEl.createEl('button', { text: 'Pull' });
|
||||
|
||||
pushBtn.addEventListener('click', () => {
|
||||
void this.plugin.sync.pushFile(fileStatus.file);
|
||||
setTimeout(() => void this.refreshFileStatus(fileStatus.file), 1000);
|
||||
void (async () => {
|
||||
try {
|
||||
// Mark as checking
|
||||
fileStatus.status = 'checking';
|
||||
this.renderView();
|
||||
|
||||
await this.plugin.sync.pushFile(fileStatus.file || fileStatus.path);
|
||||
|
||||
// Wait a bit for the remote to update
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
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();
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
pullBtn.addEventListener('click', () => {
|
||||
void this.plugin.sync.pullFile(fileStatus.file);
|
||||
setTimeout(() => void this.refreshFileStatus(fileStatus.file), 1000);
|
||||
void (async () => {
|
||||
try {
|
||||
// Mark as checking
|
||||
fileStatus.status = 'checking';
|
||||
this.renderView();
|
||||
|
||||
await this.plugin.sync.pullFile(fileStatus.file || fileStatus.path);
|
||||
|
||||
// Wait a bit for the file to be written
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
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();
|
||||
}
|
||||
})();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -192,29 +473,110 @@ export class SyncStatusView extends ItemView {
|
|||
const statusContainer = container.querySelector('.sync-status-list') as HTMLElement;
|
||||
if (statusContainer) {
|
||||
statusContainer.empty();
|
||||
statusContainer.createEl('div', { text: 'Checking files...', cls: 'sync-status-loading' });
|
||||
|
||||
// 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%');
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const allFiles = this.app.vault.getMarkdownFiles();
|
||||
const files = this.plugin.filterFilesByVaultFolder(allFiles);
|
||||
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));
|
||||
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<string, TFile>(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.renderView();
|
||||
// Add remote-only files to status, or queue cross-vaultFolder files for checking
|
||||
const extraFilesToCheck: Array<TFile | string> = [];
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
await this.refreshFileStatus(file);
|
||||
if (localFile) {
|
||||
// File exists locally but outside vaultFolder – check its real status
|
||||
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'
|
||||
});
|
||||
extraFilesToCheck.push(remotePath);
|
||||
} else {
|
||||
this.fileStatuses.set(remotePath, {
|
||||
path: remotePath,
|
||||
status: 'remote-only'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.renderView();
|
||||
new Notice(`Checked ${files.length} files`);
|
||||
|
||||
// Check status for local files with progress
|
||||
let filesToCheck: Array<TFile | string> = [...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 totalFiles = filesToCheck.length;
|
||||
let checkedFiles = 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}%)`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.lastSyncTime = Date.now();
|
||||
this.renderView();
|
||||
new Notice(`Checked ${files.length} local files and ${remoteFiles.length} remote files`);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
new Notice(`Failed to refresh: ${e instanceof Error ? e.message : String(e)}`);
|
||||
|
|
@ -223,10 +585,17 @@ export class SyncStatusView extends ItemView {
|
|||
}
|
||||
}
|
||||
|
||||
private async refreshFileStatus(file: TFile): Promise<void> {
|
||||
private async refreshFileStatus(fileOrPath: TFile | string): Promise<void> {
|
||||
try {
|
||||
const localContent = await this.app.vault.read(file);
|
||||
const remote = await this.plugin.gitService.getFile(file.path, this.plugin.settings.branch);
|
||||
const isString = typeof fileOrPath === 'string';
|
||||
const path = isString ? fileOrPath : fileOrPath.path;
|
||||
const file = isString ? undefined : fileOrPath;
|
||||
|
||||
const localContent = typeof fileOrPath === 'string'
|
||||
? 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'];
|
||||
let diff: string | undefined;
|
||||
|
|
@ -240,8 +609,9 @@ export class SyncStatusView extends ItemView {
|
|||
diff = this.generateDiff(remote.content, localContent);
|
||||
}
|
||||
|
||||
this.fileStatuses.set(file.path, {
|
||||
this.fileStatuses.set(path, {
|
||||
file,
|
||||
path: path,
|
||||
status,
|
||||
localContent,
|
||||
remoteContent: remote.content,
|
||||
|
|
@ -250,9 +620,11 @@ export class SyncStatusView extends ItemView {
|
|||
});
|
||||
|
||||
} catch (e) {
|
||||
console.error(`Error checking ${file.path}:`, e);
|
||||
this.fileStatuses.set(file.path, {
|
||||
file,
|
||||
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,
|
||||
status: 'unsynced'
|
||||
});
|
||||
}
|
||||
|
|
@ -289,7 +661,7 @@ export class SyncStatusView extends ItemView {
|
|||
async pushAllModified(): Promise<void> {
|
||||
const modifiedFiles = Array.from(this.fileStatuses.values())
|
||||
.filter(s => s.status === 'modified' || s.status === 'unsynced')
|
||||
.map(s => s.file);
|
||||
.map(s => s.file || s.path);
|
||||
|
||||
if (modifiedFiles.length === 0) {
|
||||
new Notice('No modified files to push');
|
||||
|
|
@ -313,6 +685,7 @@ export class SyncStatusView extends ItemView {
|
|||
console.error('Push errors:', results.errors);
|
||||
}
|
||||
|
||||
new Notice(`Push completed. Refreshing status...`);
|
||||
await this.refreshAllStatuses();
|
||||
} catch (e) {
|
||||
progressNotice.hide();
|
||||
|
|
@ -324,7 +697,7 @@ export class SyncStatusView extends ItemView {
|
|||
async pullAllModified(): Promise<void> {
|
||||
const modifiedFiles = Array.from(this.fileStatuses.values())
|
||||
.filter(s => s.status === 'modified')
|
||||
.map(s => s.file);
|
||||
.map(s => s.file || s.path);
|
||||
|
||||
if (modifiedFiles.length === 0) {
|
||||
new Notice('No modified files to pull');
|
||||
|
|
@ -348,6 +721,10 @@ export class SyncStatusView extends ItemView {
|
|||
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...`);
|
||||
await this.refreshAllStatuses();
|
||||
} catch (e) {
|
||||
progressNotice.hide();
|
||||
|
|
@ -356,6 +733,240 @@ export class SyncStatusView extends ItemView {
|
|||
}
|
||||
}
|
||||
|
||||
async pushSelected(): Promise<void> {
|
||||
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 && (s.status === 'modified' || s.status === 'unsynced')) as FileStatus[];
|
||||
|
||||
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 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);
|
||||
|
||||
try {
|
||||
const results = await this.plugin.sync.pushAllFiles(files, (current, total, fileName) => {
|
||||
progressNotice.setMessage(`Pushing ${current}/${total}: ${fileName}`);
|
||||
});
|
||||
|
||||
progressNotice.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 this.refreshAllStatuses();
|
||||
} catch (e) {
|
||||
progressNotice.hide();
|
||||
console.error(e);
|
||||
new Notice(`Push failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async pullSelected(): Promise<void> {
|
||||
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 && (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;
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Error pulling ${status.path}:`, e);
|
||||
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));
|
||||
|
||||
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)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteSelected(): Promise<void> {
|
||||
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;
|
||||
}
|
||||
|
||||
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.`;
|
||||
}
|
||||
|
||||
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)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async onClose(): Promise<void> {
|
||||
// Cleanup
|
||||
}
|
||||
|
|
|
|||
251
styles.css
251
styles.css
|
|
@ -40,38 +40,109 @@ If your plugin does not need CSS, delete this file.
|
|||
}
|
||||
|
||||
.sync-status-buttons {
|
||||
margin-bottom: 15px;
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.sync-status-buttons button {
|
||||
.sync-selection-buttons {
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
padding: 10px;
|
||||
background: var(--background-secondary);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.sync-repo-buttons {
|
||||
margin-bottom: 15px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
padding: 10px;
|
||||
background: var(--background-secondary);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.sync-status-buttons button,
|
||||
.sync-selection-buttons button,
|
||||
.sync-repo-buttons button {
|
||||
padding: 8px 16px;
|
||||
background: var(--interactive-accent);
|
||||
color: var(--text-on-accent);
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sync-status-buttons button:hover {
|
||||
.sync-status-buttons .refresh-btn {
|
||||
background: var(--interactive-accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Mobile optimization */
|
||||
@media (max-width: 768px) {
|
||||
.sync-status-buttons,
|
||||
.sync-selection-buttons,
|
||||
.sync-repo-buttons {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
.sync-status-buttons button:hover,
|
||||
.sync-selection-buttons button:hover,
|
||||
.sync-repo-buttons button:hover {
|
||||
background: var(--interactive-accent-hover);
|
||||
}
|
||||
|
||||
.sync-status-buttons .push-all-btn {
|
||||
.sync-status-buttons button:disabled,
|
||||
.sync-selection-buttons button:disabled,
|
||||
.sync-repo-buttons button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.sync-status-buttons button:disabled:hover,
|
||||
.sync-selection-buttons button:disabled:hover,
|
||||
.sync-repo-buttons button:disabled:hover {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.sync-repo-buttons .push-all-btn {
|
||||
background: var(--color-green);
|
||||
}
|
||||
|
||||
.sync-status-buttons .push-all-btn:hover {
|
||||
.sync-repo-buttons .push-all-btn:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.sync-status-buttons .pull-all-btn {
|
||||
.sync-repo-buttons .pull-all-btn {
|
||||
background: var(--color-blue);
|
||||
}
|
||||
|
||||
.sync-status-buttons .pull-all-btn:hover {
|
||||
.sync-repo-buttons .pull-all-btn:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.sync-repo-buttons .delete-btn {
|
||||
background: var(--color-red);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.sync-repo-buttons .delete-btn:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
|
|
@ -83,6 +154,19 @@ If your plugin does not need CSS, delete this file.
|
|||
background: var(--background-secondary);
|
||||
border-radius: 5px;
|
||||
font-weight: 500;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Mobile optimization */
|
||||
@media (max-width: 768px) {
|
||||
.sync-status-summary {
|
||||
gap: 10px;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.sync-status-summary div {
|
||||
flex: 1 1 45%;
|
||||
}
|
||||
}
|
||||
|
||||
.sync-status-empty,
|
||||
|
|
@ -92,6 +176,32 @@ If your plugin does not need CSS, delete this file.
|
|||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.sync-progress-container {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sync-progress-text {
|
||||
margin-bottom: 10px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.sync-progress-bar {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background: var(--background-secondary);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sync-progress-fill {
|
||||
height: 100%;
|
||||
background: var(--interactive-accent);
|
||||
transition: width 0.3s ease;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.sync-status-file {
|
||||
margin-bottom: 10px;
|
||||
padding: 10px;
|
||||
|
|
@ -105,11 +215,43 @@ If your plugin does not need CSS, delete this file.
|
|||
align-items: center;
|
||||
gap: 5px;
|
||||
margin-bottom: 5px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
font-weight: bold;
|
||||
font-size: 1.1em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
|
|
@ -124,10 +266,76 @@ If your plugin does not need CSS, delete this file.
|
|||
color: var(--text-error);
|
||||
}
|
||||
|
||||
.status-remote-only {
|
||||
color: var(--color-cyan);
|
||||
}
|
||||
|
||||
.status-checking {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.sync-status-filters {
|
||||
margin-bottom: 15px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
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);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
.sync-status-filters span {
|
||||
width: 100%;
|
||||
font-size: 0.85em;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.sync-status-filters button: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);
|
||||
}
|
||||
|
||||
.sync-status-file-header input[type="checkbox"] {
|
||||
margin-right: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sync-status-actions .remove-btn {
|
||||
background: var(--color-red);
|
||||
color: white;
|
||||
border-color: var(--color-red);
|
||||
}
|
||||
|
||||
.sync-status-actions .remove-btn:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.file-path {
|
||||
flex: 1;
|
||||
font-family: var(--font-monospace);
|
||||
|
|
@ -271,3 +479,32 @@ If your plugin does not need CSS, delete this file.
|
|||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue