mirror of
https://github.com/firstsun-dev/git-files-sync.git
synced 2026-07-22 06:54:27 +00:00
Merge pull request #51 from firstsun-dev/claude/fix-directory-symlink-pull-260713
feat: settings UX + folder picker + symlink fix + tree-SHA refresh + HTML-error clarity + i18n (consolidated)
This commit is contained in:
commit
2a76329f7d
57 changed files with 4720 additions and 395 deletions
26
AGENTS.md
26
AGENTS.md
|
|
@ -1,5 +1,31 @@
|
|||
# Project Agent Design & Hierarchy (Synchronized with Skills)
|
||||
|
||||
## Startup Workflow
|
||||
|
||||
Before writing code:
|
||||
- Read `feature_list.json` (active/next-up work — GitHub Issues on `firstsun-dev/git-files-sync`, Project #6, is the real source of truth; re-sync stale entries) and `progress.md` (what's currently open).
|
||||
- Read `session-handoff.md` for the previous session's exact stopping point.
|
||||
- Run `./init.sh` to confirm a clean, green baseline before editing.
|
||||
|
||||
## Definition of Done
|
||||
|
||||
A feature is done only when all of the following hold, with evidence recorded (command + result) in `progress.md`:
|
||||
- `npx eslint .` — 0 errors
|
||||
- `npm run build` — passes (tsc + Obsidian 1.11.0 compat typecheck + esbuild)
|
||||
- `npx vitest run` — passes
|
||||
- Manual verification in Obsidian, when the change has a runtime UI surface
|
||||
|
||||
## Stay in Scope
|
||||
|
||||
- One feature at a time: pick a single `feature_list.json` entry and finish it (with recorded evidence) before starting the next.
|
||||
- Don't expand scope mid-task — file a new GitHub issue via the `firstsun-pm` skill instead of quietly bundling unrelated work.
|
||||
|
||||
## End of Session
|
||||
|
||||
Before ending a session:
|
||||
- Overwrite `session-handoff.md` (don't append) with the new stopping point so the repo stays restartable.
|
||||
- Move finished items from `progress.md` into `archive/YYYY-MM.md` (current month) — next steps in `progress.md` should read as a short list, not a changelog.
|
||||
|
||||
## Agent Tiers
|
||||
|
||||
### 1. High-Tier (Red/Orange Group)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,15 @@
|
|||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Agent Workflow
|
||||
|
||||
- **Startup**: read `feature_list.json` (active/next-up work; GitHub Issues on `firstsun-dev/git-files-sync`, Project #6, is the actual source of truth — re-sync before trusting stale entries) and `progress.md` (what's open right now), then `session-handoff.md` for the previous session's exact stopping point.
|
||||
- **Before editing**: run `./init.sh` (installs deps, then lint + test + build) to confirm you're starting from a green baseline.
|
||||
- **Definition of done**: `npx eslint .` has 0 errors, `npm run build` passes (includes the Obsidian 1.11.0 compat typecheck), and `npx vitest run` passes, *and* evidence of that run is recorded (one line: command + result) in `progress.md` or the PR description — not just claimed.
|
||||
- **Scope**: work one `feature_list.json` entry at a time; don't start the next until the current one's evidence is recorded.
|
||||
- **End of session**: overwrite `session-handoff.md` with the new stopping point, move finished items from `progress.md` into `archive/YYYY-MM.md` (current month).
|
||||
- Issue/PR conventions (Conventional Commits titles, Project #6 fields, English-only for this public plugin repo) are defined in the `firstsun-pm` skill, not duplicated here.
|
||||
|
||||
## Development Commands
|
||||
- Build: `npm run build` (runs type check and esbuild in production mode)
|
||||
- Dev: `npm run dev` (builds in watch mode using esbuild)
|
||||
|
|
|
|||
20
archive/2026-07.md
Normal file
20
archive/2026-07.md
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
# Progress Archive — 2026-07
|
||||
|
||||
One archive file per calendar month. Each entry is one line: feature id/name +
|
||||
commit hash. Debugging narrative and design discussion belong in the commit
|
||||
message, not here. Start a new `archive/YYYY-MM.md` file when the month rolls
|
||||
over — don't let a single archive file grow without bound either.
|
||||
|
||||
- feat-001: Project Setup verified clean (npm install/lint/build/test) (commit 28f4f8e)
|
||||
- feat-002: Settings UX bundle implemented — conflict modal resize + tabs (#42), connection status badge (#41), local ignore patterns (#40); 254 tests pass, lint/build clean (commit 28f4f8e, branch claude/settings-ux-improvements-260713)
|
||||
- feat-005: Folder picker for root path / vault folder settings (#48) (commit c107979)
|
||||
- feat-003: Symlinked directories no longer break pull discovery (#33) — root cause: local scan recursed into a directory symlink as if it were a real tree, producing bogus remote .gitignore 404s and a permanently-stuck "remote only" status (commit 4c8896b)
|
||||
- feat-006: Tree-SHA-based refresh (#36) — status check now compares a locally-computed git blob SHA against each tree entry's SHA instead of a per-file getFile network request; diff content fetched on demand via new getBlob(sha,path) (commit 2ed5a43)
|
||||
- feat-007: Clear error when requestUrl() itself rejects with an HTML body (#31) — some Obsidian versions eagerly JSON-parse inside requestUrl(), so a login/proxy HTML page rejected the whole call with a raw SyntaxError bypassing the existing parseJson() HTML-detection wrapper (commit a867217)
|
||||
- feat-008: "What's new" modal after a version bump (#39) — hand-curated src/changelog.ts (separate from auto-generated CHANGELOG.md) with a `notable` flag per entry, numeric compareVersions(), WhatsNewModal shown once per upgrade (commit 4eebebc)
|
||||
- All six of the above (feat-002/003/005/006/007/008) consolidated onto branch claude/fix-directory-symlink-pull-260713 → PR #51 (open, not yet merged) to keep the PR count down per user request; PRs #49/#50/#52/#53 closed/auto-resolved as superseded
|
||||
- feat-011: perf(push): batch-commit push-all + SHA-based diffing — new GitServiceInterface.pushBatch, sync-manager classifies files via local git blob SHA vs. one pre-fetched tree instead of per-file getFile (commit c28e0ec)
|
||||
- feat-012: perf(delete): batch-commit remote-only file deletion — new GitServiceInterface.deleteBatch, mirrors pushBatch (commit d8e3663)
|
||||
- Issue #78: remote-repo root path picker now suggests from the remote tree, delete-remote-only-file silent failures fixed (3 root causes: unencoded path 404s, EISDIR on folder/remote-record collisions, wrong vaultFolder-prefixed path passed to deleteFile), SyncStatusView test coverage added (commits 896d77b, 563a28e merge, fa42fea, f1420f0)
|
||||
- feat(ui): persistent connection status badge in the global status bar, plus a theme-contrast follow-up fix (commits 83499c9, 12cce64)
|
||||
- feat-013: perf(push): parallelize blob creation within a batch commit — BaseGitService.mapWithConcurrency + BLOB_CREATE_CONCURRENCY=8 for GitHub/Gitea's pushBatch (GitLab unaffected, already single-request) (commit c7ae0f6)
|
||||
127
feature_list.json
Normal file
127
feature_list.json
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
{
|
||||
"_note": "GitHub Issues (firstsun-dev/git-files-sync, Project #6) is the source of truth for the full backlog and priority/estimate fields. This file mirrors only the active feature and the next few candidates so an agent session has a local, offline checkpoint — sync it against `gh issue list --repo firstsun-dev/git-files-sync --state open` at the start of a session rather than treating it as authoritative.",
|
||||
"_prPolicy": "User explicitly requested fewer PRs (2026-07-13): all issue work is consolidated onto branch claude/fix-directory-symlink-pull-260713 -> PR #51, not one PR per issue. Commit new work directly onto that branch unless told otherwise.",
|
||||
"features": [
|
||||
{
|
||||
"id": "feat-001",
|
||||
"name": "Project Setup",
|
||||
"description": "Confirm the project can install dependencies, run verification, and start from a clean checkout",
|
||||
"dependencies": [],
|
||||
"status": "done",
|
||||
"evidence": "Commit 28f4f8e - npm install/lint/build/test all pass from a clean checkout"
|
||||
},
|
||||
{
|
||||
"id": "feat-002",
|
||||
"name": "Settings UX bundle (issues #40, #41, #42)",
|
||||
"description": "Local ignore-pattern sync setting (#40), persistent connection status badge in settings (#41), and resized/tabbed conflict resolution modal (#42)",
|
||||
"dependencies": ["feat-001"],
|
||||
"status": "done",
|
||||
"evidence": "Consolidated onto PR #51 (branch claude/fix-directory-symlink-pull-260713); 302 tests pass"
|
||||
},
|
||||
{
|
||||
"id": "feat-003",
|
||||
"name": "fix: symbolic link pull fails (issue #33)",
|
||||
"description": "Directory symlinks were walked as real trees by local scanning code, causing bogus remote .gitignore 404s and a permanently-stuck 'remote only' status; also fixed a missing Windows symlinkSync type hint",
|
||||
"dependencies": [],
|
||||
"status": "done",
|
||||
"evidence": "Commit 4c8896b, consolidated onto PR #51"
|
||||
},
|
||||
{
|
||||
"id": "feat-004",
|
||||
"name": "fix: resolve sonarqube issues (issue #45)",
|
||||
"description": "Review current SonarQube findings and fix code smells/bugs/security hotspots where applicable",
|
||||
"dependencies": [],
|
||||
"status": "not-started",
|
||||
"evidence": ""
|
||||
},
|
||||
{
|
||||
"id": "feat-005",
|
||||
"name": "feat(settings): folder picker for root path / vault folder (issue #48)",
|
||||
"description": "Searchable folder suggester for the Root path and Vault folder settings fields, replacing free-text entry",
|
||||
"dependencies": [],
|
||||
"status": "done",
|
||||
"evidence": "Commit c107979, consolidated onto PR #51"
|
||||
},
|
||||
{
|
||||
"id": "feat-006",
|
||||
"name": "perf(refresh): use tree blob SHAs to avoid per-file content fetches (issue #36)",
|
||||
"description": "Status refresh compares a locally-computed git blob SHA against each remote tree entry's SHA instead of one getFile per file; diff content fetched on demand via new getBlob(sha,path); symlink-aware hashing per real/follow/skip modes",
|
||||
"dependencies": [],
|
||||
"status": "done",
|
||||
"evidence": "Commit 2ed5a43, consolidated onto PR #51; 302 tests pass"
|
||||
},
|
||||
{
|
||||
"id": "feat-007",
|
||||
"name": "fix: clear error when requestUrl() rejects with HTML (issue #31)",
|
||||
"description": "Some Obsidian versions eagerly JSON-parse inside requestUrl() itself, so a login/proxy HTML page rejected the whole call with a raw SyntaxError bypassing the existing parseJson() HTML-detection wrapper; added the same detection to safeRequest's outer catch",
|
||||
"dependencies": [],
|
||||
"status": "done",
|
||||
"evidence": "Commit a867217, consolidated onto PR #51"
|
||||
},
|
||||
{
|
||||
"id": "feat-008",
|
||||
"name": "feat: show new feature tips after update (issue #39)",
|
||||
"description": "Hand-curated src/changelog.ts (separate from auto-generated CHANGELOG.md) with a notable flag per entry, numeric compareVersions(), WhatsNewModal shown once per version bump via a new lastSeenVersion setting",
|
||||
"dependencies": [],
|
||||
"status": "done",
|
||||
"evidence": "Commit 4eebebc, consolidated onto PR #51"
|
||||
},
|
||||
{
|
||||
"id": "feat-009",
|
||||
"name": "feat: add i18n (multi-language) support (issue #38)",
|
||||
"description": "Extract hardcoded UI strings (settings tab, ribbon/command labels, Notice messages, sync status view/modals) into an i18n key system with locale detection and English fallback",
|
||||
"dependencies": [],
|
||||
"status": "done",
|
||||
"evidence": "Commit 144eb28, merged into claude/fix-directory-symlink-pull-260713 - 159 i18n keys (en+zh-tw), ~130 strings replaced; lint/build clean, 308 tests pass; consolidated onto PR #51"
|
||||
},
|
||||
{
|
||||
"id": "feat-010",
|
||||
"name": "feat: add bitbucket support (issue #37)",
|
||||
"description": "Add Bitbucket as a fourth GitServiceInterface provider; Bitbucket's API has no native blob SHA concept and is PR-based rather than direct-commit, so GitFile.sha semantics may need adjusting",
|
||||
"dependencies": ["feat-009"],
|
||||
"status": "not-started",
|
||||
"evidence": ""
|
||||
},
|
||||
{
|
||||
"id": "feat-011",
|
||||
"name": "perf(push): batch-commit push-all + SHA-based diffing",
|
||||
"description": "Push-all was up to 3N sequential HTTP calls and N separate commits for N files, plus two redundant full-tree fetches per run. Added optional GitServiceInterface.pushBatch (GitHub/Gitea via blob->tree->commit->ref, generalizing pushSymlink's pattern; GitLab via the native multi-action Commits API + a follow-up tree read for blob shas); sync-manager now classifies each file via a locally-computed git blob sha against one pre-fetched remote tree (no getFile per file) and commits all queued files in one grouped call (chunked at MAX_BATCH_PUSH_SIZE=200), falling back to the old per-file path when a provider has no pushBatch; main.ts/GitignoreManager now share one tree fetch instead of two",
|
||||
"dependencies": [],
|
||||
"status": "done",
|
||||
"evidence": "npx eslint . -> 0 errors; npm run build -> clean; npx vitest run -> 330/330 passed; consolidated onto PR #51"
|
||||
},
|
||||
{
|
||||
"id": "feat-012",
|
||||
"name": "perf(delete): batch-commit remote-only file deletion",
|
||||
"description": "Follow-up to feat-011: batch-deleting remote-only files via the sync status panel's checkbox multi-select was still N separate commits (SyncStatusView.performRemoteDeletion looped gitService.deleteFile per file). Added optional GitServiceInterface.deleteBatch, mirroring pushBatch: GitHub/Gitea via the git blob->tree->commit->ref Data API (a tree entry with sha:null removes that path); GitLab via the same Commits API actions array used for pushBatch, with action:'delete'. SyncStatusView.performRemoteDeletion now calls deleteBatch once (chunked at MAX_BATCH_PUSH_SIZE) when the provider supports it, falling back to the original sequential per-file performRemoteDeletionSequential otherwise",
|
||||
"dependencies": ["feat-011"],
|
||||
"status": "done",
|
||||
"evidence": "npx eslint . -> 0 errors; npm run build -> clean; npx vitest run -> 339/339 passed; consolidated onto PR #51"
|
||||
},
|
||||
{
|
||||
"id": "feat-013",
|
||||
"name": "perf(push): parallelize blob creation within a batch commit",
|
||||
"description": "User reported push-all was still slow on GitHub after feat-011's one-commit-per-run batching landed. Root cause: GitHubService/GiteaService.pushBatch still created each file's blob with a sequential `for` loop (N sequential POST .../git/blobs round trips before the single tree/commit/ref sequence), so wall-clock time was still O(N) latency-bound. Added a shared BaseGitService.mapWithConcurrency helper (order-preserving, bounded concurrency) and a BLOB_CREATE_CONCURRENCY=8 cap in git-service-base.ts; both services' pushBatch now creates blobs concurrently instead of one at a time. GitLab unaffected (its Commits API already sends the whole batch in one request, no per-file blob step)",
|
||||
"dependencies": ["feat-011"],
|
||||
"status": "done",
|
||||
"evidence": "npx eslint . -> 0 errors; npm run build -> clean; npx vitest run -> 340/340 passed; consolidated onto PR #51"
|
||||
},
|
||||
{
|
||||
"id": "feat-014",
|
||||
"name": "perf(push): GitHub batch push/delete via GraphQL createCommitOnBranch",
|
||||
"description": "User reported batch push was still slow on GitHub after feat-013's blob-concurrency fix. Root cause: GitHubService.pushBatch/deleteBatch still needed N/8 rounds of sequential REST blob-creation calls before the tree/commit/ref sequence. Replaced GitHub's pushBatch/deleteBatch with a single GraphQL createCommitOnBranch mutation carrying file content directly (additions/deletions), cutting an N-file batch to 2-3 HTTP calls total (ref lookup + mutation, plus one follow-up tree fetch for pushBatch's per-file shas, which the mutation doesn't return). Extracted BaseGitService.getLatestCommitSha out of resolveGitHubStyleBaseTree (still used by pushSymlink and Gitea's REST-only batch path, which has no GraphQL equivalent). Added explicit handling for GraphQL's 200-status-with-errors-array failure mode, which REST's status-code check can't catch.",
|
||||
"dependencies": ["feat-013"],
|
||||
"status": "done",
|
||||
"evidence": "Commit 114a575. npx eslint . -> 0 errors; npm run build -> clean; npx vitest run -> 341/341 passed; live smoke test against a real GitHub repo confirmed pushBatch/deleteBatch each produce one commit."
|
||||
},
|
||||
{
|
||||
"id": "feat-015",
|
||||
"name": "fix(push): avoid stale remote-tree read right after a batch push",
|
||||
"description": "User reported: batch-pushing two empty files, then refreshing immediately, showed them as 'modified' even though the diff was identical; refreshing again a bit later showed them synced. Root cause (confirmed via live GitHub API test, not a content bug - the pushed blob was byte-for-byte correct): SyncStatusView re-fetched the remote tree via refreshAllStatuses() right after push, and GitHub's tree-by-branch-name read can lag a just-completed write by a moment. Fix: SyncManager.pushAllFiles now also returns syncedPaths (path + new sha) from data already known at each write site (batch chunk result, sequential fallback, immediate symlink/rename push); SyncStatusView marks those paths 'synced' directly instead of re-fetching the tree, avoiding the race entirely. Pull unaffected.",
|
||||
"dependencies": ["feat-014"],
|
||||
"status": "done",
|
||||
"evidence": "Commit 7676325. npx eslint . -> 0 errors; npm run build -> clean; npx vitest run -> 344/344 passed."
|
||||
}
|
||||
],
|
||||
"_evidenceStyle": "Keep evidence to one line: commit hash + short pointer (e.g. 'Commit abc1234 - added X, tests pass'). Debugging narrative and design discussion belong in the commit message, not this file."
|
||||
}
|
||||
24
init.sh
Executable file
24
init.sh
Executable file
|
|
@ -0,0 +1,24 @@
|
|||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "=== Harness Initialization ==="
|
||||
|
||||
echo "=== npm install ==="
|
||||
npm install
|
||||
|
||||
echo "=== npm run lint ==="
|
||||
npm run lint
|
||||
|
||||
echo "=== npm test ==="
|
||||
npm test
|
||||
|
||||
echo "=== npm run build ==="
|
||||
npm run build
|
||||
|
||||
echo "=== Verification Complete ==="
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo "1. Read feature_list.json to see current feature state"
|
||||
echo "2. Pick ONE unfinished feature to work on"
|
||||
echo "3. Implement only that feature"
|
||||
echo "4. Re-run verification before claiming done"
|
||||
68
progress.md
Normal file
68
progress.md
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
# Session Progress Log
|
||||
|
||||
<!--
|
||||
CLEANUP CADENCE: this file tracks only what's still open. When a feature
|
||||
finishes, move its narrative to archive/YYYY-MM.md (current month) as a
|
||||
one-line entry (name + commit hash) and remove it from here. Archive once
|
||||
this file passes ~80 lines — "What's Done" is a snapshot, not a permanent
|
||||
changelog.
|
||||
-->
|
||||
|
||||
Completed work is archived in [archive/](./archive/), one file per calendar month — this file only tracks what's still open.
|
||||
|
||||
## Current State
|
||||
|
||||
**Last Updated:** 2026-07-14 12:05
|
||||
**Session ID:** current
|
||||
**Active Feature:** None — feat-014 and feat-015 both committed. Ready for the next issue.
|
||||
|
||||
## Status
|
||||
|
||||
### What's Done
|
||||
|
||||
- [x] feat-001..013 — see [archive/2026-07.md](./archive/2026-07.md)
|
||||
- [x] feat-014: GitHub's `pushBatch`/`deleteBatch` now use GraphQL `createCommitOnBranch` instead of the REST blob-per-file loop (commit `114a575`).
|
||||
- [x] feat-015: fixed a false "modified" status right after batch push, caused by GitHub's tree-by-branch-name read lagging a just-completed write. `pushAllFiles` now returns `syncedPaths`; `SyncStatusView` marks those files synced directly instead of re-fetching the remote tree (commit `7676325`).
|
||||
- Both committed onto `claude/fix-directory-symlink-pull-260713`, **not yet pushed to remote**.
|
||||
- Evidence: `npx eslint .` → 0 errors; `npm run build` → clean; `npx vitest run` → 344/344 passed.
|
||||
|
||||
### What's In Progress
|
||||
|
||||
- Nothing actively in progress.
|
||||
|
||||
### What's Next
|
||||
|
||||
1. Push `claude/fix-directory-symlink-pull-260713` to update PR #51 — confirm with the user first.
|
||||
2. Manually verify feat-014/feat-015 inside the actual Obsidian plugin UI (this session's verification used a live GitHub API smoke test driving `GitHubService` directly, not the full plugin).
|
||||
3. Filed issue #57 (repo `git-files-sync`, Project #6, P2, 4h estimate): build a repeatable live-credential smoke test process across GitHub/GitLab/Gitea, not just GitHub — https://github.com/firstsun-dev/git-files-sync/issues/57
|
||||
4. Re-sync against `gh issue list --repo firstsun-dev/git-files-sync --state open` — `feature_list.json`'s backlog is a snapshot from an earlier session.
|
||||
5. Issue #37 (Bitbucket provider support, feat-010): unblocked, still not started.
|
||||
6. PR #51 is large (10+ issues' worth of changes now). Flag to the user before piling on more commits.
|
||||
|
||||
## Blockers / Risks
|
||||
|
||||
- None currently.
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- **All work goes into one PR (#51)**: user explicitly said "不要那麼多pr merge" (don't want so many PRs). Commit new work directly onto `claude/fix-directory-symlink-pull-260713`.
|
||||
- **Optimistic local status update over a post-push re-fetch (feat-015)**: rather than delaying the refresh or retrying, mark just-pushed files synced from data already in hand. Correct by construction (it's the exact content just written) and sidesteps GitHub's eventual-consistency window entirely instead of just narrowing it.
|
||||
- **Credential handling for live smoke tests**: write PATs to a local scratchpad file the agent reads directly — never as a `!`-prefixed command (still lands in the transcript) or a Bash argument (blocked by the permission classifier). Filed as issue #57 to build a proper reusable process across all three providers.
|
||||
|
||||
## Files Modified This Session
|
||||
|
||||
- `src/services/github-service.ts`, `src/services/git-service-base.ts`, `tests/services/github-service.test.ts` (feat-014)
|
||||
- `src/logic/sync-manager.ts`, `src/ui/SyncStatusView.ts`, `tests/logic/sync-manager-batch.test.ts`, `tests/ui/SyncStatusView.test.ts` (feat-015)
|
||||
|
||||
## Evidence of Completion
|
||||
|
||||
- [x] Tests pass: `npx vitest run` → 344/344 passed
|
||||
- [x] Type check clean: `npm run build` → clean
|
||||
- [x] Lint clean: `npx eslint .` → 0 errors
|
||||
- [x] Live smoke tests against a real GitHub repo (feat-014 push/delete, feat-015 empty-file diagnosis)
|
||||
- [ ] Manual verification inside the actual Obsidian plugin UI — not yet done
|
||||
- [ ] Pushed to remote — not yet done, needs confirmation
|
||||
|
||||
## Notes for Next Session
|
||||
|
||||
- Working branch: `claude/fix-directory-symlink-pull-260713` (PR #51). Commits `114a575` and `7676325` are local only — push before starting new work, or confirm with the user.
|
||||
61
session-handoff.md
Normal file
61
session-handoff.md
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
# Session Handoff
|
||||
|
||||
<!--
|
||||
OVERWRITE, don't append: this file describes only the most recent session.
|
||||
Rewrite it at end of session; older handoffs live in git history, and completed
|
||||
work belongs in archive/YYYY-MM.md. If this file grows past ~80 lines, it is
|
||||
accumulating history instead of handing off.
|
||||
-->
|
||||
|
||||
## Current Objective
|
||||
|
||||
- Two fixes this session, both committed onto `claude/fix-directory-symlink-pull-260713` (PR #51), **not yet pushed to remote**:
|
||||
1. **feat-014** (commit `114a575`): GitHub's `pushBatch`/`deleteBatch` switched from the REST Git Data API's per-file blob-creation loop to a single GraphQL `createCommitOnBranch` mutation.
|
||||
2. **feat-015** (commit `7676325`): fixed a false "modified" status shown right after a batch push, caused by re-fetching the remote tree too soon after a write (GitHub's tree-by-branch-name read can lag a moment behind a just-completed commit).
|
||||
|
||||
## Completed This Session
|
||||
|
||||
- [x] `githubGraphQL()` helper in `github-service.ts`; explicit handling for GraphQL's 200-status-with-`errors`-array failure mode.
|
||||
- [x] `BaseGitService.getLatestCommitSha()` extracted from `resolveGitHubStyleBaseTree()`.
|
||||
- [x] `SyncManager.pushAllFiles()` now returns `syncedPaths: Array<{path, sha?}>`, populated at all three push-success sites (batch chunk, sequential fallback, immediate symlink/rename push).
|
||||
- [x] `SyncStatusView.executeBatchOperation()` marks just-pushed paths `'synced'` directly via a new `applyOptimisticSyncedStatus()` instead of calling `refreshAllStatuses()` after a push. Pull is unchanged (still does a full refresh).
|
||||
- [x] Live-verified both fixes against a real GitHub repo (`firstsun-dev/obsidian-sync-test`) using a scratchpad script that bundles the actual `github-service.ts` with a thin `obsidian` stub (`requestUrl` backed by real `fetch`) — not just mocks.
|
||||
- [x] Filed issue #57 on `firstsun-dev/git-files-sync` (Project #6, P2, 4h): build a repeatable live-credential smoke test process across GitHub/GitLab/Gitea (this session only covered GitHub).
|
||||
- [x] Cleaned up harness state: archived feat-011/012/013 + issue #78 fix + status-badge UI work into `archive/2026-07.md` (progress.md had grown past its own 80-line cleanup threshold); trimmed `feature_list.json` evidence strings under 300 chars. Harness self-audit went from 96/100 to 100/100.
|
||||
|
||||
## Verification Evidence
|
||||
|
||||
| Check | Command | Result | Notes |
|
||||
|---|---|---|---|
|
||||
| Lint | `npx eslint .` | 0 errors | |
|
||||
| Type check + compat | `npm run build` | Pass | Includes Obsidian 1.11.0 compat typecheck |
|
||||
| Tests | `npx vitest run` | 344/344 passed | +3 new: 2 for `syncedPaths`, 1 more for the post-push status behavior (total 5 new across both fixes) |
|
||||
| Live smoke test | ad-hoc scratchpad scripts | Pass | feat-014: pushBatch/deleteBatch each produced one commit with correct content. feat-015: single empty file and two-identical-empty-file batches both wrote correct 0-byte blobs with matching git shas — confirmed the bug was read-side (tree fetch timing), not write-side |
|
||||
| Manual (in Obsidian) | — | Not done | No Obsidian instance available in this environment |
|
||||
|
||||
## Files Changed (this session)
|
||||
|
||||
- feat-014: `src/services/github-service.ts`, `src/services/git-service-base.ts`, `tests/services/github-service.test.ts`
|
||||
- feat-015: `src/logic/sync-manager.ts`, `src/ui/SyncStatusView.ts`, `tests/logic/sync-manager-batch.test.ts`, `tests/ui/SyncStatusView.test.ts`
|
||||
- Harness state: `progress.md`, `session-handoff.md`, `feature_list.json`, `archive/2026-07.md`
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- **GraphQL only for GitHub**: GitLab's Commits API already sends a whole batch in one call; Gitea has no GraphQL API. `GitServiceInterface` stays REST-based elsewhere.
|
||||
- **Optimistic local status update over re-fetching (feat-015)**: use data already known from the push itself rather than trusting an immediate remote read, which sidesteps GitHub's eventual-consistency window rather than just narrowing it with a delay/retry.
|
||||
- **Credential handling**: PATs must go into a scratchpad file the agent reads directly (`fs.readFileSync`), never typed as a `!`-prefixed command (still lands in the transcript) or passed as a Bash command-line argument (blocked by the permission classifier as credential materialization). Filed issue #57 to formalize this as the only documented path for future provider testing.
|
||||
|
||||
## Blockers / Risks
|
||||
|
||||
- **Commits `114a575` and `7676325` are local only** — not pushed to `origin/claude/fix-directory-symlink-pull-260713` yet. Confirm with the user before pushing.
|
||||
- PR #51 keeps growing (10+ issues' worth now) — still worth flagging before adding more.
|
||||
|
||||
## Next Session Startup
|
||||
|
||||
1. Read `CLAUDE.md`, `feature_list.json`, `progress.md`, then this file.
|
||||
2. Run `./init.sh` before editing.
|
||||
3. Check whether `114a575`/`7676325` have been pushed yet (`git log origin/claude/fix-directory-symlink-pull-260713..HEAD`); push first if not, after confirming with the user.
|
||||
|
||||
## Recommended Next Step
|
||||
|
||||
- Push this session's two commits, then either re-sync `gh issue list` or move to issue #37 (Bitbucket provider support) per the previously agreed order.
|
||||
55
src/changelog.ts
Normal file
55
src/changelog.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { compareVersions } from './utils/version';
|
||||
|
||||
/**
|
||||
* Hand-curated, user-facing highlights shown in the "what's new" modal after an
|
||||
* update. Distinct from the auto-generated CHANGELOG.md (which lists every
|
||||
* commit for developers) — keep entries short and skimmable, and mark the ones
|
||||
* worth calling out as `notable` so they aren't buried among minor fixes.
|
||||
*
|
||||
* Add an entry here as part of cutting a release; versions are matched against
|
||||
* manifest.json's version by exact string, so keep them in sync.
|
||||
*/
|
||||
export interface ChangelogEntry {
|
||||
text: string;
|
||||
notable?: boolean;
|
||||
}
|
||||
|
||||
export interface ChangelogRelease {
|
||||
version: string;
|
||||
entries: ChangelogEntry[];
|
||||
}
|
||||
|
||||
export const CHANGELOG: ChangelogRelease[] = [
|
||||
{
|
||||
version: '1.3.0',
|
||||
entries: [
|
||||
{ text: 'The plugin now speaks multiple languages — English, Traditional Chinese, and Simplified Chinese. It follows your Obsidian display language automatically, or you can pick one in Settings.', notable: true },
|
||||
{ text: 'Checking sync status is now much faster, especially in vaults with lots of files.', notable: true },
|
||||
{ text: 'Fixed a bug where a linked (symlinked) folder could be pulled incorrectly instead of being treated as a link.' },
|
||||
{ text: 'Added a setting to keep specific files or folders out of sync, in addition to what your repo\'s .gitignore already excludes.' },
|
||||
{ text: 'Settings now show your connection status at a glance, so you can tell right away if something needs attention.' },
|
||||
{ text: 'The conflict resolution window can now be resized to see more content at once.' },
|
||||
{ text: 'Picking your sync folders is easier now, with a folder browser instead of typing paths by hand.' },
|
||||
{ text: 'Connection errors now explain what went wrong in plain language instead of a raw technical error.' },
|
||||
{ text: 'You\'ll now see a short "what\'s new" summary right after updating, so you don\'t miss new features.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
version: '1.2.1',
|
||||
entries: [
|
||||
{ text: 'Fixed compatibility with Obsidian versions back to 1.11.0', notable: true },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Releases newer than `lastSeenVersion`, newest first — what a "what's new"
|
||||
* modal should show after an update. Returns everything (unsorted by recency
|
||||
* concerns) when `lastSeenVersion` is empty, since callers are expected to
|
||||
* only invoke this once they've already decided an upgrade happened.
|
||||
*/
|
||||
export function getUnseenReleases(changelog: ChangelogRelease[], lastSeenVersion: string): ChangelogRelease[] {
|
||||
return changelog
|
||||
.filter(release => compareVersions(release.version, lastSeenVersion) > 0)
|
||||
.sort((a, b) => compareVersions(b.version, a.version));
|
||||
}
|
||||
56
src/i18n/index.ts
Normal file
56
src/i18n/index.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import en, { TranslationKey } from './locales/en';
|
||||
import zhTw from './locales/zh-tw';
|
||||
import zhCn from './locales/zh-cn';
|
||||
|
||||
export type { TranslationKey };
|
||||
|
||||
const locales: Record<string, Partial<Record<TranslationKey, string>>> = {
|
||||
en,
|
||||
'zh-tw': zhTw,
|
||||
'zh-cn': zhCn,
|
||||
};
|
||||
|
||||
/** User-facing language choices exposed in the settings UI. 'system' follows Obsidian's display language. */
|
||||
export type LanguageSetting = 'system' | 'en' | 'zh-tw' | 'zh-cn';
|
||||
|
||||
// Explicit language chosen in plugin settings, or 'system' (default) to follow
|
||||
// Obsidian's display language. Set once at load via setLanguageOverride().
|
||||
let languageOverride: LanguageSetting = 'system';
|
||||
|
||||
export function setLanguageOverride(language: LanguageSetting): void {
|
||||
languageOverride = language;
|
||||
}
|
||||
|
||||
// Obsidian sets window.moment's locale to match the app's display language
|
||||
// before plugins load. Not typed in the `obsidian` package, so read it off
|
||||
// the global defensively.
|
||||
function detectMomentLocale(): string {
|
||||
const w = window as unknown as { moment?: { locale: () => string } };
|
||||
try {
|
||||
return w.moment?.locale()?.toLowerCase() ?? '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// Maps a moment locale code to one of our shipped locales, falling back to
|
||||
// English when there's no matching translation.
|
||||
function resolveLocale(rawLocale: string): string {
|
||||
if (rawLocale in locales) return rawLocale;
|
||||
if (rawLocale.startsWith('zh')) return rawLocale.includes('cn') ? 'zh-cn' : 'zh-tw';
|
||||
return 'en';
|
||||
}
|
||||
|
||||
export function getActiveLocale(): string {
|
||||
if (languageOverride !== 'system') return languageOverride;
|
||||
return resolveLocale(detectMomentLocale());
|
||||
}
|
||||
|
||||
export function t(key: TranslationKey, vars?: Record<string, string | number>): string {
|
||||
const dict = locales[getActiveLocale()] ?? en;
|
||||
const template = dict[key] ?? en[key];
|
||||
if (!vars) return template;
|
||||
return template.replace(/\{(\w+)\}/g, (match, name: string) =>
|
||||
name in vars ? String(vars[name]) : match
|
||||
);
|
||||
}
|
||||
199
src/i18n/locales/en.ts
Normal file
199
src/i18n/locales/en.ts
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
// Source of truth for translation keys. Every key used by `t()` must exist
|
||||
// here; other locales may omit keys and fall back to this file.
|
||||
const en = {
|
||||
'settings.connectionStatus.checking': 'Checking…',
|
||||
'settings.connectionStatus.connected': 'Connected',
|
||||
'settings.connectionStatus.disconnected': 'Not connected',
|
||||
'settings.connectionStatus.withDetail': '{label} — {detail}',
|
||||
|
||||
'settings.language.name': 'Language',
|
||||
'settings.language.desc': 'UI language for this plugin',
|
||||
'settings.language.option.system': 'System default',
|
||||
'settings.language.option.en': 'English',
|
||||
'settings.language.option.zhTw': '繁體中文 (Traditional Chinese)',
|
||||
'settings.language.option.zhCn': '简体中文 (Simplified Chinese)',
|
||||
|
||||
'settings.gitService.name': 'Git service',
|
||||
'settings.gitService.desc': 'Choose your Git hosting service',
|
||||
|
||||
'settings.branch.name': 'Branch',
|
||||
'settings.branch.desc': 'Branch to push or pull from',
|
||||
'settings.branch.placeholder': 'Main',
|
||||
|
||||
'settings.rootPath.name': 'Root path',
|
||||
'settings.rootPath.desc': 'Optional: subfolder in repository (e.g. "notes")',
|
||||
'settings.rootPath.placeholder': 'Enter subfolder path',
|
||||
|
||||
'settings.vaultFolder.name': 'Vault folder',
|
||||
'settings.vaultFolder.desc': 'Optional: only sync files in this vault folder (e.g. "sync" to only sync files in the sync folder)',
|
||||
'settings.vaultFolder.placeholder': 'Leave empty to sync all files',
|
||||
|
||||
'settings.ignorePatterns.name': 'Ignore patterns',
|
||||
'settings.ignorePatterns.desc': 'Optional: .gitignore-style patterns (one per line) to exclude local files from sync, in addition to the repository\'s own .gitignore.',
|
||||
|
||||
'settings.symlinks.name': 'Symbolic links',
|
||||
'settings.symlinks.desc.supported': 'How to sync symlinks: "real" recreates the link on desktop and falls back to the target content on mobile, "follow" always syncs the target content, and "skip" ignores symlinks.',
|
||||
'settings.symlinks.desc.unsupported': 'How to sync symlinks: "follow" syncs the target content as a normal file, "skip" ignores symlinks. Real symlinks require GitHub.',
|
||||
'settings.symlinks.option.real': 'Real symlink (recommended)',
|
||||
'settings.symlinks.option.follow': 'Follow (sync target content)',
|
||||
'settings.symlinks.option.skip': 'Skip',
|
||||
|
||||
'settings.testConnection.name': 'Test connection',
|
||||
'settings.testConnection.desc': 'Verify your {service} settings',
|
||||
'settings.testConnection.button': 'Test connection',
|
||||
'settings.testConnection.failed': 'Connection failed: {reason}',
|
||||
'settings.testConnection.failed.unreachable': 'could not reach the repository',
|
||||
'settings.testConnection.branchNotFound.badge': 'branch "{branch}" not found',
|
||||
'settings.testConnection.branchNotFound.notice': 'Connected, but branch "{branch}" was not found. Check the Branch setting, or confirm the repository has a branch with this name.',
|
||||
'settings.testConnection.success': '{service} connection successful!',
|
||||
|
||||
'settings.token.placeholder': 'Enter your token',
|
||||
'settings.token.show': 'Show token',
|
||||
'settings.token.hide': 'Hide token',
|
||||
|
||||
'settings.gitlab.token.name': 'GitLab personal access token',
|
||||
'settings.gitlab.token.desc': 'Create a token in GitLab user settings > access tokens with "API" scope',
|
||||
'settings.gitlab.baseUrl.name': 'GitLab base URL',
|
||||
'settings.gitlab.baseUrl.desc': 'Defaults to https://gitlab.com',
|
||||
'settings.gitlab.projectId.name': 'Project ID',
|
||||
'settings.gitlab.projectId.desc': 'Found in GitLab project overview',
|
||||
'settings.gitlab.projectId.placeholder': 'Enter numeric project ID',
|
||||
|
||||
'settings.gitea.token.name': 'Gitea personal access token',
|
||||
'settings.gitea.token.desc': 'Create a token in user settings > applications > access tokens',
|
||||
'settings.gitea.baseUrl.name': 'Gitea base URL',
|
||||
'settings.gitea.baseUrl.desc': 'URL of your Gitea instance (e.g. https://gitea.example.com)',
|
||||
|
||||
'settings.github.token.name': 'GitHub personal access token',
|
||||
'settings.github.token.desc': 'Create a token in GitHub settings > developer settings > personal access tokens with "repo" scope',
|
||||
|
||||
'settings.repoOwner.name': 'Repository owner',
|
||||
'settings.repoOwner.desc.gitea': 'Gitea username or organization name',
|
||||
'settings.repoOwner.desc.github': 'GitHub username or organization name',
|
||||
'settings.repoOwner.placeholder': 'Username',
|
||||
|
||||
'settings.repoName.name': 'Repository name',
|
||||
'settings.repoName.desc.gitea': 'Name of the repository',
|
||||
'settings.repoName.desc.github': 'Name of the GitHub repository',
|
||||
'settings.repoName.placeholder': 'My notes',
|
||||
|
||||
'main.ribbon.openSyncStatus': 'Open sync status',
|
||||
'main.ribbon.push': 'Push',
|
||||
'main.ribbon.pushTo': 'Push to {service}',
|
||||
'main.command.openSyncStatus': 'Open sync status',
|
||||
'main.command.pushCurrentFile': 'Push current file',
|
||||
'main.command.pullCurrentFile': 'Pull current file',
|
||||
'main.command.pushAllFiles': 'Push all files',
|
||||
'main.command.pullAllFiles': 'Pull all files',
|
||||
'main.notice.noActiveNote': 'No active note to push',
|
||||
'main.contextMenu.pushTo': 'Push to {service}',
|
||||
'main.contextMenu.pullFrom': 'Pull from {service}',
|
||||
'main.notice.noFilesToRun': 'No files to {op} in the configured vault folder',
|
||||
'main.confirm.pushAll': 'Push {count} file(s) to {service}?',
|
||||
'main.confirm.pullAll': 'Pull {count} file(s) from {service}? This will overwrite local changes.',
|
||||
'main.progress.running': '{verb} 0/{total} files...',
|
||||
'main.progress.step': '{verb} {current}/{total}: {fileName}',
|
||||
'main.notice.runFailed': '{verb} failed: {message}',
|
||||
'main.op.push': 'push',
|
||||
'main.op.pull': 'pull',
|
||||
'main.verb.pushing': 'Pushing',
|
||||
'main.verb.pulling': 'Pulling',
|
||||
'main.verb.push': 'Push',
|
||||
'main.verb.pull': 'Pull',
|
||||
|
||||
'confirmModal.title': 'Confirm',
|
||||
'confirmModal.confirm': 'Confirm',
|
||||
'confirmModal.cancel': 'Cancel',
|
||||
|
||||
'whatsNew.title': "What's new",
|
||||
'whatsNew.viewChangelog': 'View full changelog',
|
||||
'whatsNew.gotIt': 'Got it',
|
||||
|
||||
'settings.whatsNewBanner.title': "What's new in v{version}",
|
||||
'settings.whatsNewBanner.dismiss': 'Dismiss',
|
||||
|
||||
'syncStatus.viewTitle': 'Sync status',
|
||||
'syncStatus.emptyPrompt': 'Click "Refresh" to check sync status',
|
||||
'syncStatus.progress.checkingWithCount': 'Checking files… {current}/{total} ({pct}%)',
|
||||
'syncStatus.progress.checking': 'Checking files…',
|
||||
'syncStatus.lastSync': 'Last sync: {time}',
|
||||
'syncStatus.tab.all': 'All',
|
||||
'syncStatus.tab.synced': 'Synced',
|
||||
'syncStatus.tab.modified': 'Changed',
|
||||
'syncStatus.tab.unsynced': 'Local only',
|
||||
'syncStatus.tab.remote-only': 'Remote',
|
||||
'syncStatus.noFilesForFilter': 'No {filter} files',
|
||||
'syncStatus.confirmDeleteLocal': 'Delete local file "{path}"? Handled per your vault\'s "Deleted files" setting.',
|
||||
'syncStatus.notice.deleted': 'Deleted {path}',
|
||||
'syncStatus.notice.deleteFailed': 'Failed to delete: {message}',
|
||||
'syncStatus.notice.opFailed': '{verb} failed: {message}',
|
||||
'syncStatus.notice.alreadyRefreshing': 'Already refreshing…',
|
||||
'syncStatus.notice.refreshed': 'Checked {local} local + {remote} remote files',
|
||||
'syncStatus.notice.refreshFailed': 'Failed to refresh: {message}',
|
||||
'syncStatus.notice.noPushableFiles.selected': 'No pushable files selected.',
|
||||
'syncStatus.notice.noPushableFiles.found': 'No pushable files found.',
|
||||
'syncStatus.notice.noPullableFiles.selected': 'No pullable files selected.',
|
||||
'syncStatus.notice.noPullableFiles.found': 'No pullable files found.',
|
||||
'syncStatus.confirm.pushSelected': 'Push {count} file(s) to {service}?',
|
||||
'syncStatus.confirm.pullSelected': 'Pull {count} file(s) from {service}? This will overwrite local changes.',
|
||||
'syncStatus.notice.opCompleted': '{verb} completed. Refreshing…',
|
||||
'syncStatus.notice.nothingToDelete': 'Nothing to delete',
|
||||
'syncStatus.notice.noFilesSelected': 'No files selected',
|
||||
'syncStatus.confirmDelete.localAndRemote': "Delete {local} local file(s) (per your vault's trash setting) and {remote} remote file(s) (cannot be undone)?",
|
||||
'syncStatus.confirmDelete.localOnly': 'Delete {local} local file(s)? They\'ll be handled per your vault\'s "Deleted files" setting.',
|
||||
'syncStatus.confirmDelete.remoteOnly': 'Delete {remote} remote file(s)? This cannot be undone.',
|
||||
'syncStatus.notice.deleteResult.partial': 'Deleted {succeeded}/{total}. {failed} failed.',
|
||||
'syncStatus.notice.deleteResult.partialWithMessage': 'Deleted {succeeded}/{total}. {failed} failed: {message}',
|
||||
'syncStatus.notice.deleteResult.success': 'Deleted {total} files',
|
||||
'syncStatus.progress.deleting': 'Deleting 0/{total} files…',
|
||||
'syncStatus.progress.pushing': 'Pushing {current}/{total}: {name}',
|
||||
'syncStatus.progress.pulling': 'Pulling {current}/{total}: {name}',
|
||||
'syncStatus.progress.deletingLocal': 'Deleting local {current}/{total}: {path}',
|
||||
'syncStatus.progress.deletingRemote': 'Deleting remote {current}/{total}: {path}',
|
||||
|
||||
'actionBar.select': 'Select',
|
||||
'actionBar.refresh': ' Refresh',
|
||||
'actionBar.refreshAll': 'Refresh all statuses',
|
||||
'actionBar.pushCount': ' Push ({count})',
|
||||
'actionBar.pushFiles': 'Push {count} files',
|
||||
'actionBar.pullCount': ' Pull ({count})',
|
||||
'actionBar.pullFiles': 'Pull {count} files',
|
||||
'actionBar.deleteCount': ' Delete ({count})',
|
||||
'actionBar.deleteFiles': 'Delete {count} files',
|
||||
|
||||
'syncStatus.status.checking': 'Checking',
|
||||
|
||||
'fileListItem.action.push': ' Push',
|
||||
'fileListItem.action.pull': ' Pull',
|
||||
'fileListItem.action.remove': ' Remove',
|
||||
'fileListItem.action.diff': ' Diff',
|
||||
'fileListItem.action.hide': ' Hide',
|
||||
'fileListItem.tooltip.pushToRemote': 'Push to remote',
|
||||
'fileListItem.tooltip.pullFromRemote': 'Pull from remote',
|
||||
'fileListItem.tooltip.deleteLocalFile': 'Delete local file',
|
||||
'fileListItem.tooltip.toggleDiff': 'Toggle diff view',
|
||||
'fileListItem.diff.symlinkChanged': 'Symlink target changed',
|
||||
'fileListItem.diff.loading': 'Loading diff…',
|
||||
'fileListItem.diff.clickToLoad': 'Click Diff to load…',
|
||||
'fileListItem.diff.binaryChanged': 'Binary file changed',
|
||||
|
||||
'diffPanel.remote': 'Remote',
|
||||
'diffPanel.local': 'Local',
|
||||
|
||||
'syncConflictModal.title': 'Conflict in {fileName}',
|
||||
'syncConflictModal.description': 'The remote file has different content. Review the differences and choose which version to keep.',
|
||||
'syncConflictModal.tab.diff': 'Diff',
|
||||
'syncConflictModal.tab.local': 'Local',
|
||||
'syncConflictModal.tab.remote': 'Remote',
|
||||
'syncConflictModal.localVersion': 'Local version',
|
||||
'syncConflictModal.remoteVersion': 'Remote version',
|
||||
'syncConflictModal.differences': 'Differences',
|
||||
'syncConflictModal.keepLocal': 'Keep local',
|
||||
'syncConflictModal.keepLocal.tooltip': 'Overwrite remote with your local content',
|
||||
'syncConflictModal.keepRemote': 'Keep remote',
|
||||
'syncConflictModal.keepRemote.tooltip': 'Overwrite local with remote content',
|
||||
'syncConflictModal.cancel': 'Cancel',
|
||||
};
|
||||
|
||||
export default en;
|
||||
export type TranslationKey = keyof typeof en;
|
||||
200
src/i18n/locales/zh-cn.ts
Normal file
200
src/i18n/locales/zh-cn.ts
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
import type { TranslationKey } from './en';
|
||||
|
||||
// Only needs to cover the keys that have a translation; anything missing
|
||||
// falls back to en.ts at lookup time.
|
||||
const zhCn: Partial<Record<TranslationKey, string>> = {
|
||||
'settings.connectionStatus.checking': '检查中…',
|
||||
'settings.connectionStatus.connected': '已连接',
|
||||
'settings.connectionStatus.disconnected': '未连接',
|
||||
'settings.connectionStatus.withDetail': '{label} — {detail}',
|
||||
|
||||
'settings.language.name': '语言',
|
||||
'settings.language.desc': '此插件的界面语言',
|
||||
'settings.language.option.system': '系统默认',
|
||||
'settings.language.option.en': 'English',
|
||||
'settings.language.option.zhTw': '繁體中文',
|
||||
'settings.language.option.zhCn': '简体中文',
|
||||
|
||||
'settings.gitService.name': 'Git 服务',
|
||||
'settings.gitService.desc': '选择您的 Git 托管服务',
|
||||
|
||||
'settings.branch.name': '分支',
|
||||
'settings.branch.desc': '要推送或拉取的分支',
|
||||
'settings.branch.placeholder': 'Main',
|
||||
|
||||
'settings.rootPath.name': '根路径',
|
||||
'settings.rootPath.desc': '选填:仓库中的子文件夹(例如「notes」)',
|
||||
'settings.rootPath.placeholder': '输入子文件夹路径',
|
||||
|
||||
'settings.vaultFolder.name': '库文件夹',
|
||||
'settings.vaultFolder.desc': '选填:仅同步此库文件夹内的文件(例如「sync」则只同步 sync 文件夹内的文件)',
|
||||
'settings.vaultFolder.placeholder': '留空以同步所有文件',
|
||||
|
||||
'settings.ignorePatterns.name': '忽略规则',
|
||||
'settings.ignorePatterns.desc': '选填:以 .gitignore 语法(每行一条)排除本机文件,会与远程仓库的 .gitignore 规则一并应用。',
|
||||
|
||||
'settings.symlinks.name': '符号链接',
|
||||
'settings.symlinks.desc.supported': '符号链接的同步方式:「real」会在桌面端重建链接,移动端则改为同步目标内容;「follow」始终同步目标内容;「skip」则忽略符号链接。',
|
||||
'settings.symlinks.desc.unsupported': '符号链接的同步方式:「follow」以普通文件同步目标内容,「skip」则忽略符号链接。真实符号链接仅支持 GitHub。',
|
||||
'settings.symlinks.option.real': '真实符号链接(推荐)',
|
||||
'settings.symlinks.option.follow': 'Follow(同步目标内容)',
|
||||
'settings.symlinks.option.skip': 'Skip(忽略)',
|
||||
|
||||
'settings.testConnection.name': '测试连接',
|
||||
'settings.testConnection.desc': '验证您的 {service} 设置',
|
||||
'settings.testConnection.button': '测试连接',
|
||||
'settings.testConnection.failed': '连接失败:{reason}',
|
||||
'settings.testConnection.failed.unreachable': '无法连接到仓库',
|
||||
'settings.testConnection.branchNotFound.badge': '找不到分支「{branch}」',
|
||||
'settings.testConnection.branchNotFound.notice': '已连接,但找不到分支「{branch}」。请检查分支设置,或确认仓库中存在此分支。',
|
||||
'settings.testConnection.success': '{service} 连接成功!',
|
||||
|
||||
'settings.token.placeholder': '输入您的令牌',
|
||||
'settings.token.show': '显示令牌',
|
||||
'settings.token.hide': '隐藏令牌',
|
||||
|
||||
'settings.gitlab.token.name': 'GitLab 个人访问令牌',
|
||||
'settings.gitlab.token.desc': '请在 GitLab 用户设置 > access tokens 创建具有「API」范围的令牌',
|
||||
'settings.gitlab.baseUrl.name': 'GitLab 基础 URL',
|
||||
'settings.gitlab.baseUrl.desc': '默认为 https://gitlab.com',
|
||||
'settings.gitlab.projectId.name': '项目 ID',
|
||||
'settings.gitlab.projectId.desc': '可在 GitLab 项目总览中找到',
|
||||
'settings.gitlab.projectId.placeholder': '输入数字项目 ID',
|
||||
|
||||
'settings.gitea.token.name': 'Gitea 个人访问令牌',
|
||||
'settings.gitea.token.desc': '请在用户设置 > applications > access tokens 创建令牌',
|
||||
'settings.gitea.baseUrl.name': 'Gitea 基础 URL',
|
||||
'settings.gitea.baseUrl.desc': '您的 Gitea 实例网址(例如 https://gitea.example.com)',
|
||||
|
||||
'settings.github.token.name': 'GitHub 个人访问令牌',
|
||||
'settings.github.token.desc': '请在 GitHub 设置 > developer settings > personal access tokens 创建具有「repo」范围的令牌',
|
||||
|
||||
'settings.repoOwner.name': '仓库所有者',
|
||||
'settings.repoOwner.desc.gitea': 'Gitea 用户名或组织名称',
|
||||
'settings.repoOwner.desc.github': 'GitHub 用户名或组织名称',
|
||||
'settings.repoOwner.placeholder': '用户名',
|
||||
|
||||
'settings.repoName.name': '仓库名称',
|
||||
'settings.repoName.desc.gitea': '仓库的名称',
|
||||
'settings.repoName.desc.github': 'GitHub 仓库的名称',
|
||||
'settings.repoName.placeholder': '我的笔记',
|
||||
|
||||
'main.ribbon.openSyncStatus': '打开同步状态',
|
||||
'main.ribbon.push': '推送',
|
||||
'main.ribbon.pushTo': '推送至 {service}',
|
||||
'main.command.openSyncStatus': '打开同步状态',
|
||||
'main.command.pushCurrentFile': '推送当前文件',
|
||||
'main.command.pullCurrentFile': '拉取当前文件',
|
||||
'main.command.pushAllFiles': '推送所有文件',
|
||||
'main.command.pullAllFiles': '拉取所有文件',
|
||||
'main.notice.noActiveNote': '没有可推送的当前笔记',
|
||||
'main.contextMenu.pushTo': '推送至 {service}',
|
||||
'main.contextMenu.pullFrom': '从 {service} 拉取',
|
||||
'main.notice.noFilesToRun': '设置的库文件夹中没有可{op}的文件',
|
||||
'main.confirm.pushAll': '要推送 {count} 个文件至 {service} 吗?',
|
||||
'main.confirm.pullAll': '要从 {service} 拉取 {count} 个文件吗?这将覆盖本机变更。',
|
||||
'main.progress.running': '{verb} 0/{total} 个文件...',
|
||||
'main.progress.step': '{verb} {current}/{total}:{fileName}',
|
||||
'main.notice.runFailed': '{verb}失败:{message}',
|
||||
'main.op.push': '推送',
|
||||
'main.op.pull': '拉取',
|
||||
'main.verb.pushing': '推送中',
|
||||
'main.verb.pulling': '拉取中',
|
||||
'main.verb.push': '推送',
|
||||
'main.verb.pull': '拉取',
|
||||
|
||||
'confirmModal.title': '确认',
|
||||
'confirmModal.confirm': '确认',
|
||||
'confirmModal.cancel': '取消',
|
||||
|
||||
'whatsNew.title': '新功能',
|
||||
'whatsNew.viewChangelog': '查看完整更新日志',
|
||||
'whatsNew.gotIt': '知道了',
|
||||
|
||||
'settings.whatsNewBanner.title': 'v{version} 更新重点',
|
||||
'settings.whatsNewBanner.dismiss': '关闭提示',
|
||||
|
||||
'syncStatus.viewTitle': '同步状态',
|
||||
'syncStatus.emptyPrompt': '点击「刷新」以检查同步状态',
|
||||
'syncStatus.progress.checkingWithCount': '检查文件中… {current}/{total}({pct}%)',
|
||||
'syncStatus.progress.checking': '检查文件中…',
|
||||
'syncStatus.lastSync': '上次同步:{time}',
|
||||
'syncStatus.tab.all': '全部',
|
||||
'syncStatus.tab.synced': '已同步',
|
||||
'syncStatus.tab.modified': '有变更',
|
||||
'syncStatus.tab.unsynced': '仅本机',
|
||||
'syncStatus.tab.remote-only': '远程',
|
||||
'syncStatus.noFilesForFilter': '没有{filter}的文件',
|
||||
'syncStatus.confirmDeleteLocal': '删除本机文件「{path}」?将依您库的「已删除文件」设置处理。',
|
||||
'syncStatus.notice.deleted': '已删除 {path}',
|
||||
'syncStatus.notice.deleteFailed': '删除失败:{message}',
|
||||
'syncStatus.notice.opFailed': '{verb}失败:{message}',
|
||||
'syncStatus.notice.alreadyRefreshing': '正在刷新中…',
|
||||
'syncStatus.notice.refreshed': '已检查 {local} 个本机文件与 {remote} 个远程文件',
|
||||
'syncStatus.notice.refreshFailed': '刷新失败:{message}',
|
||||
'syncStatus.notice.noPushableFiles.selected': '未选取任何可推送的文件。',
|
||||
'syncStatus.notice.noPushableFiles.found': '没有可推送的文件。',
|
||||
'syncStatus.notice.noPullableFiles.selected': '未选取任何可拉取的文件。',
|
||||
'syncStatus.notice.noPullableFiles.found': '没有可拉取的文件。',
|
||||
'syncStatus.confirm.pushSelected': '要推送 {count} 个文件至 {service} 吗?',
|
||||
'syncStatus.confirm.pullSelected': '要从 {service} 拉取 {count} 个文件吗?这将覆盖本机变更。',
|
||||
'syncStatus.notice.opCompleted': '{verb}完成,刷新中…',
|
||||
'syncStatus.notice.nothingToDelete': '没有可删除的项目',
|
||||
'syncStatus.notice.noFilesSelected': '尚未选取任何文件',
|
||||
'syncStatus.confirmDelete.localAndRemote': '要删除 {local} 个本机文件(依库的回收站设置处理)与 {remote} 个远程文件(无法恢复)吗?',
|
||||
'syncStatus.confirmDelete.localOnly': '要删除 {local} 个本机文件吗?将依您库的「已删除文件」设置处理。',
|
||||
'syncStatus.confirmDelete.remoteOnly': '要删除 {remote} 个远程文件吗?此操作无法恢复。',
|
||||
'syncStatus.notice.deleteResult.partial': '已删除 {succeeded}/{total} 个,{failed} 个失败。',
|
||||
'syncStatus.notice.deleteResult.partialWithMessage': '已删除 {succeeded}/{total} 个,{failed} 个失败:{message}',
|
||||
'syncStatus.notice.deleteResult.success': '已删除 {total} 个文件',
|
||||
'syncStatus.progress.deleting': '删除中 0/{total} 个文件…',
|
||||
'syncStatus.progress.pushing': '推送中 {current}/{total}:{name}',
|
||||
'syncStatus.progress.pulling': '拉取中 {current}/{total}:{name}',
|
||||
'syncStatus.progress.deletingLocal': '删除本机 {current}/{total}:{path}',
|
||||
'syncStatus.progress.deletingRemote': '删除远程 {current}/{total}:{path}',
|
||||
|
||||
'actionBar.select': '选取',
|
||||
'actionBar.refresh': ' 刷新',
|
||||
'actionBar.refreshAll': '刷新所有状态',
|
||||
'actionBar.pushCount': ' 推送({count})',
|
||||
'actionBar.pushFiles': '推送 {count} 个文件',
|
||||
'actionBar.pullCount': ' 拉取({count})',
|
||||
'actionBar.pullFiles': '拉取 {count} 个文件',
|
||||
'actionBar.deleteCount': ' 删除({count})',
|
||||
'actionBar.deleteFiles': '删除 {count} 个文件',
|
||||
|
||||
'syncStatus.status.checking': '检查中',
|
||||
|
||||
'fileListItem.action.push': ' 推送',
|
||||
'fileListItem.action.pull': ' 拉取',
|
||||
'fileListItem.action.remove': ' 移除',
|
||||
'fileListItem.action.diff': ' 差异',
|
||||
'fileListItem.action.hide': ' 隐藏',
|
||||
'fileListItem.tooltip.pushToRemote': '推送至远程',
|
||||
'fileListItem.tooltip.pullFromRemote': '从远程拉取',
|
||||
'fileListItem.tooltip.deleteLocalFile': '删除本机文件',
|
||||
'fileListItem.tooltip.toggleDiff': '切换差异视图',
|
||||
'fileListItem.diff.symlinkChanged': '符号链接目标已变更',
|
||||
'fileListItem.diff.loading': '加载差异中…',
|
||||
'fileListItem.diff.clickToLoad': '点击「差异」以加载…',
|
||||
'fileListItem.diff.binaryChanged': '二进制文件已变更',
|
||||
|
||||
'diffPanel.remote': '远程',
|
||||
'diffPanel.local': '本机',
|
||||
|
||||
'syncConflictModal.title': '{fileName} 发生冲突',
|
||||
'syncConflictModal.description': '远程文件内容有所不同。请查看差异并选择要保留的版本。',
|
||||
'syncConflictModal.tab.diff': '差异',
|
||||
'syncConflictModal.tab.local': '本机',
|
||||
'syncConflictModal.tab.remote': '远程',
|
||||
'syncConflictModal.localVersion': '本机版本',
|
||||
'syncConflictModal.remoteVersion': '远程版本',
|
||||
'syncConflictModal.differences': '差异',
|
||||
'syncConflictModal.keepLocal': '保留本机',
|
||||
'syncConflictModal.keepLocal.tooltip': '以本机内容覆盖远程',
|
||||
'syncConflictModal.keepRemote': '保留远程',
|
||||
'syncConflictModal.keepRemote.tooltip': '以远程内容覆盖本机',
|
||||
'syncConflictModal.cancel': '取消',
|
||||
};
|
||||
|
||||
export default zhCn;
|
||||
200
src/i18n/locales/zh-tw.ts
Normal file
200
src/i18n/locales/zh-tw.ts
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
import type { TranslationKey } from './en';
|
||||
|
||||
// Only needs to cover the keys that have a translation; anything missing
|
||||
// falls back to en.ts at lookup time.
|
||||
const zhTw: Partial<Record<TranslationKey, string>> = {
|
||||
'settings.connectionStatus.checking': '檢查中…',
|
||||
'settings.connectionStatus.connected': '已連線',
|
||||
'settings.connectionStatus.disconnected': '未連線',
|
||||
'settings.connectionStatus.withDetail': '{label} — {detail}',
|
||||
|
||||
'settings.language.name': '語言',
|
||||
'settings.language.desc': '此外掛的介面語言',
|
||||
'settings.language.option.system': '系統預設',
|
||||
'settings.language.option.en': 'English',
|
||||
'settings.language.option.zhTw': '繁體中文',
|
||||
'settings.language.option.zhCn': '简体中文',
|
||||
|
||||
'settings.gitService.name': 'Git 服務',
|
||||
'settings.gitService.desc': '選擇您的 Git 代管服務',
|
||||
|
||||
'settings.branch.name': '分支',
|
||||
'settings.branch.desc': '要推送或拉取的分支',
|
||||
'settings.branch.placeholder': 'Main',
|
||||
|
||||
'settings.rootPath.name': '根路徑',
|
||||
'settings.rootPath.desc': '選填:儲存庫中的子資料夾(例如「notes」)',
|
||||
'settings.rootPath.placeholder': '輸入子資料夾路徑',
|
||||
|
||||
'settings.vaultFolder.name': '保存庫資料夾',
|
||||
'settings.vaultFolder.desc': '選填:僅同步此保存庫資料夾內的檔案(例如「sync」則只同步 sync 資料夾內的檔案)',
|
||||
'settings.vaultFolder.placeholder': '留空以同步所有檔案',
|
||||
|
||||
'settings.ignorePatterns.name': '忽略規則',
|
||||
'settings.ignorePatterns.desc': '選填:以 .gitignore 語法(每行一條)排除本機檔案,會與遠端儲存庫的 .gitignore 規則一併套用。',
|
||||
|
||||
'settings.symlinks.name': '符號連結',
|
||||
'settings.symlinks.desc.supported': '符號連結的同步方式:「real」會在桌面版重建連結,行動裝置則改為同步目標內容;「follow」永遠同步目標內容;「skip」則忽略符號連結。',
|
||||
'settings.symlinks.desc.unsupported': '符號連結的同步方式:「follow」以一般檔案同步目標內容,「skip」則忽略符號連結。真實符號連結僅支援 GitHub。',
|
||||
'settings.symlinks.option.real': '真實符號連結(建議)',
|
||||
'settings.symlinks.option.follow': 'Follow(同步目標內容)',
|
||||
'settings.symlinks.option.skip': 'Skip(忽略)',
|
||||
|
||||
'settings.testConnection.name': '測試連線',
|
||||
'settings.testConnection.desc': '驗證您的 {service} 設定',
|
||||
'settings.testConnection.button': '測試連線',
|
||||
'settings.testConnection.failed': '連線失敗:{reason}',
|
||||
'settings.testConnection.failed.unreachable': '無法連線至儲存庫',
|
||||
'settings.testConnection.branchNotFound.badge': '找不到分支「{branch}」',
|
||||
'settings.testConnection.branchNotFound.notice': '已連線,但找不到分支「{branch}」。請檢查分支設定,或確認儲存庫中存在此分支。',
|
||||
'settings.testConnection.success': '{service} 連線成功!',
|
||||
|
||||
'settings.token.placeholder': '輸入您的權杖',
|
||||
'settings.token.show': '顯示權杖',
|
||||
'settings.token.hide': '隱藏權杖',
|
||||
|
||||
'settings.gitlab.token.name': 'GitLab 個人存取權杖',
|
||||
'settings.gitlab.token.desc': '請在 GitLab 使用者設定 > access tokens 建立具有「API」範圍的權杖',
|
||||
'settings.gitlab.baseUrl.name': 'GitLab 基礎 URL',
|
||||
'settings.gitlab.baseUrl.desc': '預設為 https://gitlab.com',
|
||||
'settings.gitlab.projectId.name': '專案 ID',
|
||||
'settings.gitlab.projectId.desc': '可在 GitLab 專案總覽中找到',
|
||||
'settings.gitlab.projectId.placeholder': '輸入數字專案 ID',
|
||||
|
||||
'settings.gitea.token.name': 'Gitea 個人存取權杖',
|
||||
'settings.gitea.token.desc': '請在使用者設定 > applications > access tokens 建立權杖',
|
||||
'settings.gitea.baseUrl.name': 'Gitea 基礎 URL',
|
||||
'settings.gitea.baseUrl.desc': '您的 Gitea 執行個體網址(例如 https://gitea.example.com)',
|
||||
|
||||
'settings.github.token.name': 'GitHub 個人存取權杖',
|
||||
'settings.github.token.desc': '請在 GitHub 設定 > developer settings > personal access tokens 建立具有「repo」範圍的權杖',
|
||||
|
||||
'settings.repoOwner.name': '儲存庫擁有者',
|
||||
'settings.repoOwner.desc.gitea': 'Gitea 使用者名稱或組織名稱',
|
||||
'settings.repoOwner.desc.github': 'GitHub 使用者名稱或組織名稱',
|
||||
'settings.repoOwner.placeholder': '使用者名稱',
|
||||
|
||||
'settings.repoName.name': '儲存庫名稱',
|
||||
'settings.repoName.desc.gitea': '儲存庫的名稱',
|
||||
'settings.repoName.desc.github': 'GitHub 儲存庫的名稱',
|
||||
'settings.repoName.placeholder': '我的筆記',
|
||||
|
||||
'main.ribbon.openSyncStatus': '開啟同步狀態',
|
||||
'main.ribbon.push': '推送',
|
||||
'main.ribbon.pushTo': '推送至 {service}',
|
||||
'main.command.openSyncStatus': '開啟同步狀態',
|
||||
'main.command.pushCurrentFile': '推送目前檔案',
|
||||
'main.command.pullCurrentFile': '拉取目前檔案',
|
||||
'main.command.pushAllFiles': '推送所有檔案',
|
||||
'main.command.pullAllFiles': '拉取所有檔案',
|
||||
'main.notice.noActiveNote': '沒有可推送的目前筆記',
|
||||
'main.contextMenu.pushTo': '推送至 {service}',
|
||||
'main.contextMenu.pullFrom': '從 {service} 拉取',
|
||||
'main.notice.noFilesToRun': '設定的保存庫資料夾中沒有可{op}的檔案',
|
||||
'main.confirm.pushAll': '要推送 {count} 個檔案至 {service} 嗎?',
|
||||
'main.confirm.pullAll': '要從 {service} 拉取 {count} 個檔案嗎?這將覆蓋本機變更。',
|
||||
'main.progress.running': '{verb} 0/{total} 個檔案...',
|
||||
'main.progress.step': '{verb} {current}/{total}:{fileName}',
|
||||
'main.notice.runFailed': '{verb}失敗:{message}',
|
||||
'main.op.push': '推送',
|
||||
'main.op.pull': '拉取',
|
||||
'main.verb.pushing': '推送中',
|
||||
'main.verb.pulling': '拉取中',
|
||||
'main.verb.push': '推送',
|
||||
'main.verb.pull': '拉取',
|
||||
|
||||
'confirmModal.title': '確認',
|
||||
'confirmModal.confirm': '確認',
|
||||
'confirmModal.cancel': '取消',
|
||||
|
||||
'whatsNew.title': '有什麼新功能',
|
||||
'whatsNew.viewChangelog': '查看完整更新日誌',
|
||||
'whatsNew.gotIt': '知道了',
|
||||
|
||||
'settings.whatsNewBanner.title': 'v{version} 更新重點',
|
||||
'settings.whatsNewBanner.dismiss': '關閉提示',
|
||||
|
||||
'syncStatus.viewTitle': '同步狀態',
|
||||
'syncStatus.emptyPrompt': '點擊「重新整理」以檢查同步狀態',
|
||||
'syncStatus.progress.checkingWithCount': '檢查檔案中… {current}/{total}({pct}%)',
|
||||
'syncStatus.progress.checking': '檢查檔案中…',
|
||||
'syncStatus.lastSync': '上次同步:{time}',
|
||||
'syncStatus.tab.all': '全部',
|
||||
'syncStatus.tab.synced': '已同步',
|
||||
'syncStatus.tab.modified': '有變更',
|
||||
'syncStatus.tab.unsynced': '僅本機',
|
||||
'syncStatus.tab.remote-only': '遠端',
|
||||
'syncStatus.noFilesForFilter': '沒有{filter}的檔案',
|
||||
'syncStatus.confirmDeleteLocal': '刪除本機檔案「{path}」?將依您保存庫的「已刪除的檔案」設定處理。',
|
||||
'syncStatus.notice.deleted': '已刪除 {path}',
|
||||
'syncStatus.notice.deleteFailed': '刪除失敗:{message}',
|
||||
'syncStatus.notice.opFailed': '{verb}失敗:{message}',
|
||||
'syncStatus.notice.alreadyRefreshing': '正在重新整理中…',
|
||||
'syncStatus.notice.refreshed': '已檢查 {local} 個本機檔案與 {remote} 個遠端檔案',
|
||||
'syncStatus.notice.refreshFailed': '重新整理失敗:{message}',
|
||||
'syncStatus.notice.noPushableFiles.selected': '未選取任何可推送的檔案。',
|
||||
'syncStatus.notice.noPushableFiles.found': '沒有可推送的檔案。',
|
||||
'syncStatus.notice.noPullableFiles.selected': '未選取任何可拉取的檔案。',
|
||||
'syncStatus.notice.noPullableFiles.found': '沒有可拉取的檔案。',
|
||||
'syncStatus.confirm.pushSelected': '要推送 {count} 個檔案至 {service} 嗎?',
|
||||
'syncStatus.confirm.pullSelected': '要從 {service} 拉取 {count} 個檔案嗎?這將覆蓋本機變更。',
|
||||
'syncStatus.notice.opCompleted': '{verb}完成,重新整理中…',
|
||||
'syncStatus.notice.nothingToDelete': '沒有可刪除的項目',
|
||||
'syncStatus.notice.noFilesSelected': '尚未選取任何檔案',
|
||||
'syncStatus.confirmDelete.localAndRemote': '要刪除 {local} 個本機檔案(依保存庫的垃圾桶設定處理)與 {remote} 個遠端檔案(無法復原)嗎?',
|
||||
'syncStatus.confirmDelete.localOnly': '要刪除 {local} 個本機檔案嗎?將依您保存庫的「已刪除的檔案」設定處理。',
|
||||
'syncStatus.confirmDelete.remoteOnly': '要刪除 {remote} 個遠端檔案嗎?此操作無法復原。',
|
||||
'syncStatus.notice.deleteResult.partial': '已刪除 {succeeded}/{total} 個,{failed} 個失敗。',
|
||||
'syncStatus.notice.deleteResult.partialWithMessage': '已刪除 {succeeded}/{total} 個,{failed} 個失敗:{message}',
|
||||
'syncStatus.notice.deleteResult.success': '已刪除 {total} 個檔案',
|
||||
'syncStatus.progress.deleting': '刪除中 0/{total} 個檔案…',
|
||||
'syncStatus.progress.pushing': '推送中 {current}/{total}:{name}',
|
||||
'syncStatus.progress.pulling': '拉取中 {current}/{total}:{name}',
|
||||
'syncStatus.progress.deletingLocal': '刪除本機 {current}/{total}:{path}',
|
||||
'syncStatus.progress.deletingRemote': '刪除遠端 {current}/{total}:{path}',
|
||||
|
||||
'actionBar.select': '選取',
|
||||
'actionBar.refresh': ' 重新整理',
|
||||
'actionBar.refreshAll': '重新整理所有狀態',
|
||||
'actionBar.pushCount': ' 推送({count})',
|
||||
'actionBar.pushFiles': '推送 {count} 個檔案',
|
||||
'actionBar.pullCount': ' 拉取({count})',
|
||||
'actionBar.pullFiles': '拉取 {count} 個檔案',
|
||||
'actionBar.deleteCount': ' 刪除({count})',
|
||||
'actionBar.deleteFiles': '刪除 {count} 個檔案',
|
||||
|
||||
'syncStatus.status.checking': '檢查中',
|
||||
|
||||
'fileListItem.action.push': ' 推送',
|
||||
'fileListItem.action.pull': ' 拉取',
|
||||
'fileListItem.action.remove': ' 移除',
|
||||
'fileListItem.action.diff': ' 差異',
|
||||
'fileListItem.action.hide': ' 隱藏',
|
||||
'fileListItem.tooltip.pushToRemote': '推送至遠端',
|
||||
'fileListItem.tooltip.pullFromRemote': '從遠端拉取',
|
||||
'fileListItem.tooltip.deleteLocalFile': '刪除本機檔案',
|
||||
'fileListItem.tooltip.toggleDiff': '切換差異檢視',
|
||||
'fileListItem.diff.symlinkChanged': '符號連結目標已變更',
|
||||
'fileListItem.diff.loading': '載入差異中…',
|
||||
'fileListItem.diff.clickToLoad': '點擊「差異」以載入…',
|
||||
'fileListItem.diff.binaryChanged': '二進位檔案已變更',
|
||||
|
||||
'diffPanel.remote': '遠端',
|
||||
'diffPanel.local': '本機',
|
||||
|
||||
'syncConflictModal.title': '{fileName} 發生衝突',
|
||||
'syncConflictModal.description': '遠端檔案內容有所不同。請檢視差異並選擇要保留的版本。',
|
||||
'syncConflictModal.tab.diff': '差異',
|
||||
'syncConflictModal.tab.local': '本機',
|
||||
'syncConflictModal.tab.remote': '遠端',
|
||||
'syncConflictModal.localVersion': '本機版本',
|
||||
'syncConflictModal.remoteVersion': '遠端版本',
|
||||
'syncConflictModal.differences': '差異',
|
||||
'syncConflictModal.keepLocal': '保留本機',
|
||||
'syncConflictModal.keepLocal.tooltip': '以本機內容覆蓋遠端',
|
||||
'syncConflictModal.keepRemote': '保留遠端',
|
||||
'syncConflictModal.keepRemote.tooltip': '以遠端內容覆蓋本機',
|
||||
'syncConflictModal.cancel': '取消',
|
||||
};
|
||||
|
||||
export default zhTw;
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
import ignore, { Ignore } from 'ignore';
|
||||
import { App } from 'obsidian';
|
||||
import { GitServiceInterface } from '../services/git-service-interface';
|
||||
import { GitServiceInterface, GitTreeEntry } from '../services/git-service-interface';
|
||||
import { logger } from '../utils/logger';
|
||||
import { readLocalSymlinkTarget } from '../utils/symlink';
|
||||
|
||||
export class GitignoreManager {
|
||||
private readonly app: App;
|
||||
|
|
@ -10,16 +11,21 @@ export class GitignoreManager {
|
|||
|
||||
private readonly rootPath: string;
|
||||
private readonly vaultFolder: string;
|
||||
|
||||
// User-defined local ignore patterns (settings.ignorePatterns), applied on top of
|
||||
// remote/local .gitignore rules. Matched against the same vault/rootPath-relative
|
||||
// path passed into isIgnored().
|
||||
private readonly localIgnore: Ignore | null;
|
||||
|
||||
// Maps directory path (empty string for root) to Ignore instance
|
||||
private readonly ignoreMap: Map<string, Ignore> = new Map();
|
||||
|
||||
constructor(app: App, gitService: GitServiceInterface, branch: string, rootPath: string, vaultFolder: string = '') {
|
||||
constructor(app: App, gitService: GitServiceInterface, branch: string, rootPath: string, vaultFolder: string = '', ignorePatterns: string = '') {
|
||||
this.app = app;
|
||||
this.gitService = gitService;
|
||||
this.branch = branch;
|
||||
this.rootPath = rootPath.replace(/^\/|\/$/g, '');
|
||||
this.vaultFolder = vaultFolder.replace(/^\/|\/$/g, '');
|
||||
this.localIgnore = ignorePatterns.trim() ? ignore().add(ignorePatterns) : null;
|
||||
}
|
||||
|
||||
private getNormalizedPath(path: string): string {
|
||||
|
|
@ -35,11 +41,34 @@ export class GitignoreManager {
|
|||
/**
|
||||
* Discovers and parses .gitignore files from the local filesystem and remote repository.
|
||||
* Local files take priority; remote supplements anything not found locally.
|
||||
*
|
||||
* @param remoteTree Optional pre-fetched, unfiltered remote tree (e.g. from
|
||||
* `gitService.listFilesDetailed(branch, false)`). When supplied, it's scanned
|
||||
* directly for `.gitignore` paths instead of making another remote fetch via
|
||||
* `getRepoGitignores`. Falls back to that fetch when omitted, so this method
|
||||
* still works standalone (e.g. in tests).
|
||||
*/
|
||||
async loadGitignores(): Promise<void> {
|
||||
async loadGitignores(remoteTree?: GitTreeEntry[]): Promise<void> {
|
||||
this.ignoreMap.clear();
|
||||
|
||||
// 1. Collect all potential gitignore paths
|
||||
const gitignorePaths = await this.collectGitignorePaths(remoteTree);
|
||||
|
||||
// Load content and build ignore instances
|
||||
for (const fullGitignorePath of gitignorePaths) {
|
||||
const dirPath = fullGitignorePath === '.gitignore'
|
||||
? ''
|
||||
: fullGitignorePath.slice(0, -(('.gitignore'.length) + 1));
|
||||
const content = await this.getGitignoreContent(fullGitignorePath);
|
||||
if (content) {
|
||||
this.ignoreMap.set(dirPath, ignore().add(content));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Collects every candidate .gitignore path: repo root, rootPath ancestors,
|
||||
* local vault scan, and the remote listing (from a pre-fetched tree when
|
||||
* supplied, else a dedicated remote fetch). */
|
||||
private async collectGitignorePaths(remoteTree?: GitTreeEntry[]): Promise<Set<string>> {
|
||||
const gitignorePaths = new Set<string>();
|
||||
|
||||
// a. Repo root
|
||||
|
|
@ -60,23 +89,26 @@ export class GitignoreManager {
|
|||
await this.scanLocalGitignores(gitignorePaths);
|
||||
|
||||
// d. Supplement with remote repo's gitignore listing (filtered to rootPath)
|
||||
await this.addRemoteGitignorePaths(gitignorePaths, remoteTree);
|
||||
|
||||
return gitignorePaths;
|
||||
}
|
||||
|
||||
/** Adds remote .gitignore paths to `out`: scanned directly from a
|
||||
* pre-fetched tree when supplied, else via a dedicated remote fetch. */
|
||||
private async addRemoteGitignorePaths(out: Set<string>, remoteTree?: GitTreeEntry[]): Promise<void> {
|
||||
if (remoteTree) {
|
||||
for (const entry of remoteTree) {
|
||||
if (entry.path.endsWith('.gitignore')) out.add(entry.path);
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const remotePaths = await this.gitService.getRepoGitignores(this.branch);
|
||||
for (const p of remotePaths) gitignorePaths.add(p);
|
||||
for (const p of remotePaths) out.add(p);
|
||||
} catch (e) {
|
||||
logger.warn('Failed to fetch repo gitignores', e);
|
||||
}
|
||||
|
||||
// 2. Load content and build ignore instances
|
||||
for (const fullGitignorePath of gitignorePaths) {
|
||||
const dirPath = fullGitignorePath === '.gitignore'
|
||||
? ''
|
||||
: fullGitignorePath.slice(0, -(('.gitignore'.length) + 1));
|
||||
const content = await this.getGitignoreContent(fullGitignorePath);
|
||||
if (content) {
|
||||
this.ignoreMap.set(dirPath, ignore().add(content));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async scanLocalGitignores(out: Set<string>): Promise<void> {
|
||||
|
|
@ -95,6 +127,11 @@ export class GitignoreManager {
|
|||
}
|
||||
}
|
||||
for (const subFolder of listing.folders) {
|
||||
// A folder that's actually a symlink (e.g. a shared folder linked in from
|
||||
// elsewhere) is a single git blob on the remote, not a real tree — walking
|
||||
// into it would scan an unrelated directory structure and produce bogus
|
||||
// .gitignore lookups against paths that don't exist in this repo.
|
||||
if (readLocalSymlinkTarget(this.app, subFolder) !== null) continue;
|
||||
await this.scanDir(subFolder, out);
|
||||
}
|
||||
} catch { /* adapter.list may be unavailable in some environments */ }
|
||||
|
|
@ -153,6 +190,8 @@ export class GitignoreManager {
|
|||
* Checks if a given file path should be ignored based on loaded .gitignore rules.
|
||||
*/
|
||||
isIgnored(filePath: string): boolean {
|
||||
if (this.localIgnore?.ignores(filePath)) return true;
|
||||
|
||||
const fullPath = this.rootPath ? `${this.rootPath}/${filePath}` : filePath;
|
||||
|
||||
for (const [dirPath, ig] of this.ignoreMap.entries()) {
|
||||
|
|
|
|||
|
|
@ -1,14 +1,36 @@
|
|||
import { TFile, App, Notice } from 'obsidian';
|
||||
import { GitServiceInterface } from '../services/git-service-interface';
|
||||
import { GitServiceInterface, GitTreeEntry } from '../services/git-service-interface';
|
||||
import { MAX_BATCH_PUSH_SIZE } from '../services/git-service-base';
|
||||
import { GitLabFilesPushSettings, getServiceName, getEffectiveSymlinkHandling } from '../settings';
|
||||
import { SyncConflictModal } from '../ui/SyncConflictModal';
|
||||
import { logger } from '../utils/logger';
|
||||
import { isBinaryPath, contentsEqual } from '../utils/path';
|
||||
import { readLocalSymlinkTarget, createLocalSymlink } from '../utils/symlink';
|
||||
import { gitBlobSha } from '../utils/git-blob-sha';
|
||||
|
||||
/** Result of syncing one file within a batch push/pull. */
|
||||
type BatchOutcome = 'done' | 'unchanged' | 'conflict';
|
||||
|
||||
/** A file classified as needing a push, queued for the grouped batch-commit call. */
|
||||
type ToPushEntry = { path: string; name: string; repoPath: string; content: string | ArrayBuffer; existingSha?: string };
|
||||
|
||||
/**
|
||||
* Result of a batch push. `syncedPaths` lists every path that's now confirmed
|
||||
* synced (content just written matches what's now on the remote), with its
|
||||
* new blob sha when known. The caller uses this to mark those files' UI
|
||||
* status directly rather than re-fetching the remote tree right after a
|
||||
* write — GitHub's tree-by-branch-name read can lag a successful write by a
|
||||
* moment, so an immediate re-fetch can misreport a just-pushed file as
|
||||
* "modified" even though nothing is actually different.
|
||||
*/
|
||||
export type PushResults = {
|
||||
success: number;
|
||||
failed: number;
|
||||
conflicts: number;
|
||||
errors: Array<{ file: string; error: string }>;
|
||||
syncedPaths: Array<{ path: string; sha?: string }>;
|
||||
};
|
||||
|
||||
export class SyncManager {
|
||||
private readonly app: App;
|
||||
private gitService: GitServiceInterface;
|
||||
|
|
@ -187,7 +209,7 @@ export class SyncManager {
|
|||
}
|
||||
}
|
||||
|
||||
private async performPush(file: {path: string, name: string}, content: string | ArrayBuffer, existingSha?: string, silent = false) {
|
||||
private async performPush(file: {path: string, name: string}, content: string | ArrayBuffer, existingSha?: string, silent = false): Promise<string | undefined> {
|
||||
const repoPath = this.getNormalizedPath(file.path);
|
||||
const result = await this.gitService.pushFile(
|
||||
repoPath,
|
||||
|
|
@ -207,6 +229,7 @@ export class SyncManager {
|
|||
if (newSha) await this.updateMetadata(file.path, newSha);
|
||||
|
||||
if (!silent) new Notice(`Pushed ${file.name} to ${this.serviceName}`);
|
||||
return newSha;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -351,17 +374,20 @@ export class SyncManager {
|
|||
new Notice(`${message}: ${detail}`);
|
||||
}
|
||||
|
||||
async pushAllFiles(files: (TFile | string)[], onProgress?: (current: number, total: number, fileName: string) => void): Promise<{ success: number; failed: number; conflicts: number; errors: Array<{ file: string; error: string }> }> {
|
||||
return this.processBatch(files, 'push', onProgress);
|
||||
async pushAllFiles(
|
||||
files: (TFile | string)[],
|
||||
onProgress?: (current: number, total: number, fileName: string) => void,
|
||||
remoteTree?: GitTreeEntry[]
|
||||
): Promise<PushResults> {
|
||||
return this.processPushBatch(files, onProgress, remoteTree);
|
||||
}
|
||||
|
||||
async pullAllFiles(files: (TFile | string)[], onProgress?: (current: number, total: number, fileName: string) => void): Promise<{ success: number; failed: number; conflicts: number; errors: Array<{ file: string; error: string }> }> {
|
||||
return this.processBatch(files, 'pull', onProgress);
|
||||
return this.processPullBatch(files, onProgress);
|
||||
}
|
||||
|
||||
private async processBatch(
|
||||
private async processPullBatch(
|
||||
files: (TFile | string)[],
|
||||
op: 'push' | 'pull',
|
||||
onProgress?: (current: number, total: number, fileName: string) => void
|
||||
): Promise<{ success: number; failed: number; conflicts: number; errors: Array<{ file: string; error: string }> }> {
|
||||
const results = { success: 0, failed: 0, conflicts: 0, errors: [] as Array<{ file: string; error: string }> };
|
||||
|
|
@ -374,25 +400,217 @@ export class SyncManager {
|
|||
onProgress?.(i + 1, files.length, name);
|
||||
|
||||
try {
|
||||
const outcome = op === 'push'
|
||||
? await this.processSingleBatchPush(fileOrPath, path, name, isString)
|
||||
: await this.processSingleBatchPull(fileOrPath, path, name, isString);
|
||||
|
||||
const outcome = await this.processSingleBatchPull(fileOrPath, path, name, isString);
|
||||
if (outcome === 'done') results.success++;
|
||||
else if (outcome === 'conflict') results.conflicts++;
|
||||
} catch (e) {
|
||||
logger.error(`Failed to ${op} ${path}:`, e);
|
||||
logger.error(`Failed to pull ${path}:`, e);
|
||||
results.failed++;
|
||||
results.errors.push({ file: path, error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
}
|
||||
|
||||
await this.saveSettings();
|
||||
this.notifyBatchResult(op, results.success, results.failed, results.conflicts);
|
||||
this.notifyBatchResult('pull', results.success, results.failed, results.conflicts);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private async processPushBatch(
|
||||
files: (TFile | string)[],
|
||||
onProgress?: (current: number, total: number, fileName: string) => void,
|
||||
remoteTree?: GitTreeEntry[]
|
||||
): Promise<PushResults> {
|
||||
const results: PushResults = { success: 0, failed: 0, conflicts: 0, errors: [], syncedPaths: [] };
|
||||
|
||||
const tree = remoteTree ?? await this.gitService.listFilesDetailed(this.settings.branch, false);
|
||||
const treeByFullPath = new Map<string, GitTreeEntry>(tree.map(e => [e.path, e]));
|
||||
const toPush: ToPushEntry[] = [];
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const fileOrPath = files[i];
|
||||
if (!fileOrPath) continue;
|
||||
|
||||
const { path, name, isString } = this.getFileInfo(fileOrPath);
|
||||
onProgress?.(i + 1, files.length, name);
|
||||
|
||||
try {
|
||||
const outcome = await this.classifyPushCandidate(fileOrPath, path, name, isString, treeByFullPath, toPush);
|
||||
if (outcome === 'done') {
|
||||
results.success++;
|
||||
// Symlink/rename pushes are committed immediately outside the
|
||||
// toPush queue, so the new sha isn't known here — the caller
|
||||
// still gets to mark the path synced, just without a sha update.
|
||||
results.syncedPaths.push({ path });
|
||||
}
|
||||
else if (outcome === 'conflict') results.conflicts++;
|
||||
// 'unchanged' and 'queued' don't move any of the counters directly:
|
||||
// 'unchanged' never did, and 'queued' is resolved by commitPushBatch below.
|
||||
} catch (e) {
|
||||
logger.error(`Failed to push ${path}:`, e);
|
||||
results.failed++;
|
||||
results.errors.push({ file: path, error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
}
|
||||
|
||||
if (toPush.length > 0) {
|
||||
await this.commitPushBatch(toPush, results);
|
||||
}
|
||||
|
||||
await this.saveSettings();
|
||||
this.notifyBatchResult('push', results.success, results.failed, results.conflicts);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classifies one file for the batch-push flow using a purely local
|
||||
* comparison (git blob sha vs. the pre-fetched remote tree's blob sha) —
|
||||
* no getFile network call. Symlinks and confirmed renames are pushed
|
||||
* immediately (as today) and never queued; everything else is either
|
||||
* resolved immediately ('unchanged'/'conflict') or appended to `toPush`
|
||||
* for the grouped commit.
|
||||
*/
|
||||
private async classifyPushCandidate(
|
||||
fileOrPath: TFile | string,
|
||||
path: string,
|
||||
name: string,
|
||||
isString: boolean,
|
||||
treeByFullPath: Map<string, GitTreeEntry>,
|
||||
toPush: ToPushEntry[]
|
||||
): Promise<BatchOutcome | 'queued'> {
|
||||
if (!await this.checkFileExists(path, isString)) throw new Error('File no longer exists');
|
||||
|
||||
// Symbolic link handling: real → push as a symlink (GitHub), skip → ignore.
|
||||
const symlinkTarget = readLocalSymlinkTarget(this.app, path);
|
||||
if (symlinkTarget !== null && await this.handleSymlinkPush({ path, name }, symlinkTarget, true)) {
|
||||
return getEffectiveSymlinkHandling(this.settings) !== 'skip' ? 'done' : 'unchanged';
|
||||
}
|
||||
|
||||
const content = await this.getFileContent(fileOrPath);
|
||||
const repoPath = this.getNormalizedPath(path);
|
||||
|
||||
// Rename detection
|
||||
if (!isString && fileOrPath instanceof TFile) {
|
||||
const renamedFrom = await this.detectRename(fileOrPath, content);
|
||||
if (renamedFrom) {
|
||||
await this.handleRename(fileOrPath, renamedFrom, content);
|
||||
return 'done';
|
||||
}
|
||||
}
|
||||
|
||||
const treeEntry = treeByFullPath.get(this.getFullPathForTree(repoPath));
|
||||
const outcome = await this.classifyAgainstTreeEntry(path, content, treeEntry);
|
||||
if (outcome !== 'queued') return outcome;
|
||||
|
||||
toPush.push({ path, name, repoPath, content, existingSha: treeEntry?.sha });
|
||||
return 'queued';
|
||||
}
|
||||
|
||||
/**
|
||||
* Decides a non-symlink, non-renamed file's outcome purely from a
|
||||
* pre-fetched tree entry and a locally-computed git blob sha — no network
|
||||
* call. Split out of classifyPushCandidate to keep both under the
|
||||
* cognitive-complexity limit.
|
||||
*/
|
||||
private async classifyAgainstTreeEntry(
|
||||
path: string,
|
||||
content: string | ArrayBuffer,
|
||||
treeEntry: GitTreeEntry | undefined
|
||||
): Promise<BatchOutcome | 'queued'> {
|
||||
// Don't convert a remote symlink into a regular file.
|
||||
if (treeEntry?.symlink) return 'unchanged';
|
||||
|
||||
// Skip if already in sync — compared locally, no network round trip.
|
||||
if (treeEntry?.sha) {
|
||||
const localSha = await gitBlobSha(content);
|
||||
if (localSha === treeEntry.sha) {
|
||||
await this.updateMetadata(path, treeEntry.sha);
|
||||
return 'unchanged';
|
||||
}
|
||||
}
|
||||
|
||||
// Same conflict check as the single-file flow: if the remote has moved on
|
||||
// from what we last synced, overwriting it here would silently discard
|
||||
// whatever changed on the remote. Skip it instead of force-pushing so the
|
||||
// batch action can't quietly clobber changes the way a single push would
|
||||
// stop and ask about via SyncConflictModal.
|
||||
const lastSynced = this.settings.syncMetadata[path];
|
||||
if (treeEntry?.sha && lastSynced && treeEntry.sha !== lastSynced.lastSyncedSha) {
|
||||
return 'conflict';
|
||||
}
|
||||
|
||||
return 'queued';
|
||||
}
|
||||
|
||||
/**
|
||||
* Path relative to rootPath, matching how each git service's getFullPath
|
||||
* would resolve `repoPath` — mirrors that logic locally so pre-fetched tree
|
||||
* entries (always full repo paths) can be looked up without depending on
|
||||
* each service's protected getFullPath.
|
||||
*/
|
||||
private getFullPathForTree(repoPath: string): string {
|
||||
if (repoPath.startsWith('/')) return repoPath.slice(1);
|
||||
const rootPath = this.settings.rootPath;
|
||||
if (!rootPath) return repoPath;
|
||||
const cleanRoot = rootPath.endsWith('/') ? rootPath : `${rootPath}/`;
|
||||
if (repoPath.startsWith(cleanRoot)) return repoPath;
|
||||
return cleanRoot + repoPath;
|
||||
}
|
||||
|
||||
/** Commits every queued file in one or more grouped batch-commit calls. */
|
||||
private async commitPushBatch(toPush: ToPushEntry[], results: PushResults): Promise<void> {
|
||||
if (!this.gitService.pushBatch) {
|
||||
await this.pushSequentialFallback(toPush, results);
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < toPush.length; i += MAX_BATCH_PUSH_SIZE) {
|
||||
await this.commitOneChunk(toPush.slice(i, i + MAX_BATCH_PUSH_SIZE), results);
|
||||
}
|
||||
}
|
||||
|
||||
/** Provider doesn't support a batch/atomic multi-file commit — fall back to
|
||||
* the same sequential per-file push used by the single-file flow. */
|
||||
private async pushSequentialFallback(toPush: ToPushEntry[], results: PushResults): Promise<void> {
|
||||
for (const f of toPush) {
|
||||
try {
|
||||
const sha = await this.performPush({ path: f.path, name: f.name }, f.content, f.existingSha, true);
|
||||
results.success++;
|
||||
results.syncedPaths.push({ path: f.path, sha });
|
||||
} catch (e) {
|
||||
results.failed++;
|
||||
results.errors.push({ file: f.path, error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async commitOneChunk(chunk: ToPushEntry[], results: PushResults): Promise<void> {
|
||||
try {
|
||||
const commitMessage = `Push ${chunk.length} file(s) from Obsidian`;
|
||||
const batchResults = await this.gitService.pushBatch!(
|
||||
chunk.map(f => ({ path: f.repoPath, content: f.content, existedRemotely: !!f.existingSha })),
|
||||
this.settings.branch,
|
||||
commitMessage
|
||||
);
|
||||
const shaByPath = new Map(batchResults.map(r => [r.path, r.sha]));
|
||||
for (const f of chunk) {
|
||||
const sha = shaByPath.get(f.repoPath);
|
||||
if (sha) await this.updateMetadata(f.path, sha);
|
||||
results.success++;
|
||||
results.syncedPaths.push({ path: f.path, sha });
|
||||
}
|
||||
} catch (e) {
|
||||
// Atomic per-provider failure: none of this chunk's files were
|
||||
// actually written, so every file in it is failed, not dropped.
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
for (const f of chunk) {
|
||||
results.failed++;
|
||||
results.errors.push({ file: f.path, error: message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private notifyBatchResult(op: 'push' | 'pull', success: number, failed: number, conflicts: number): void {
|
||||
const opName = op === 'push' ? 'Pushed' : 'Pulled';
|
||||
if (success > 0) {
|
||||
|
|
@ -443,52 +661,6 @@ export class SyncManager {
|
|||
}
|
||||
}
|
||||
|
||||
private async processSingleBatchPush(fileOrPath: TFile | string, path: string, name: string, isString: boolean): Promise<BatchOutcome> {
|
||||
if (!await this.checkFileExists(path, isString)) throw new Error('File no longer exists');
|
||||
|
||||
// Symbolic link handling: real → push as a symlink (GitHub), skip → ignore.
|
||||
const symlinkTarget = readLocalSymlinkTarget(this.app, path);
|
||||
if (symlinkTarget !== null && await this.handleSymlinkPush({ path, name }, symlinkTarget, true)) {
|
||||
return getEffectiveSymlinkHandling(this.settings) !== 'skip' ? 'done' : 'unchanged';
|
||||
}
|
||||
|
||||
const content = await this.getFileContent(fileOrPath);
|
||||
const repoPath = this.getNormalizedPath(path);
|
||||
|
||||
// Rename detection
|
||||
if (!isString && fileOrPath instanceof TFile) {
|
||||
const renamedFrom = await this.detectRename(fileOrPath, content);
|
||||
if (renamedFrom) {
|
||||
await this.handleRename(fileOrPath, renamedFrom, content);
|
||||
return 'done';
|
||||
}
|
||||
}
|
||||
|
||||
const remote = await this.gitService.getFile(repoPath, this.settings.branch);
|
||||
|
||||
// Don't convert a remote symlink into a regular file.
|
||||
if (remote.isSymlink) return 'unchanged';
|
||||
|
||||
// Skip if already in sync
|
||||
if (remote.sha && this.contentsEqual(content, remote.content)) {
|
||||
await this.updateMetadata(path, remote.sha);
|
||||
return 'unchanged';
|
||||
}
|
||||
|
||||
// Same conflict check as the single-file flow: if the remote has moved on
|
||||
// from what we last synced, overwriting it here would silently discard
|
||||
// whatever changed on the remote. Skip it instead of force-pushing so the
|
||||
// batch action can't quietly clobber changes the way a single push would
|
||||
// stop and ask about via SyncConflictModal.
|
||||
const lastSynced = this.settings.syncMetadata[path];
|
||||
if (remote.sha && lastSynced && remote.sha !== lastSynced.lastSyncedSha) {
|
||||
return 'conflict';
|
||||
}
|
||||
|
||||
await this.performPush({ path, name }, content, remote.sha || undefined, true);
|
||||
return 'done';
|
||||
}
|
||||
|
||||
private async processSingleBatchPull(fileOrPath: TFile | string, path: string, name: string, isString: boolean): Promise<BatchOutcome> {
|
||||
const repoPath = this.getNormalizedPath(path);
|
||||
const remote = await this.gitService.getFile(repoPath, this.settings.branch);
|
||||
|
|
|
|||
187
src/main.ts
187
src/main.ts
|
|
@ -1,14 +1,26 @@
|
|||
import { Plugin, TFile, MarkdownView, Notice, Platform, setTooltip } from 'obsidian';
|
||||
import { Plugin, TFile, MarkdownView, Notice, Platform, setTooltip, setIcon } from 'obsidian';
|
||||
import { DEFAULT_SETTINGS, GitLabFilesPushSettings, GitLabSyncSettingTab, getServiceName } from "./settings";
|
||||
import { GitLabService } from './services/gitlab-service';
|
||||
import { GitHubService } from './services/github-service';
|
||||
import { GiteaService } from './services/gitea-service';
|
||||
import { GitServiceInterface } from './services/git-service-interface';
|
||||
import { GitServiceInterface, GitTreeEntry } from './services/git-service-interface';
|
||||
import { ConnectionTestResult } from './services/git-service-base';
|
||||
import { SyncManager } from './logic/sync-manager';
|
||||
import { SyncStatusView, SYNC_STATUS_VIEW_TYPE } from './ui/SyncStatusView';
|
||||
import { GitignoreManager } from './logic/gitignore-manager';
|
||||
import { logger } from './utils/logger';
|
||||
import { ConfirmModal } from './ui/ConfirmModal';
|
||||
import { WhatsNewModal } from './ui/WhatsNewModal';
|
||||
import { CHANGELOG, getUnseenReleases } from './changelog';
|
||||
import { compareVersions } from './utils/version';
|
||||
import { t, setLanguageOverride } from './i18n';
|
||||
|
||||
export type ConnectionStatusState = 'checking' | 'connected' | 'disconnected';
|
||||
|
||||
export interface ConnectionStatus {
|
||||
state: ConnectionStatusState;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export default class GitLabFilesPush extends Plugin {
|
||||
settings: GitLabFilesPushSettings;
|
||||
|
|
@ -16,6 +28,10 @@ export default class GitLabFilesPush extends Plugin {
|
|||
sync: SyncManager;
|
||||
gitignoreManager: GitignoreManager;
|
||||
private pushRibbonEl: HTMLElement;
|
||||
private statusBarEl: HTMLElement;
|
||||
connectionStatus: ConnectionStatus = { state: 'checking' };
|
||||
private connectionStatusListeners: Set<(status: ConnectionStatus) => void> = new Set();
|
||||
private connectionTestSeq = 0;
|
||||
|
||||
async onload() {
|
||||
await this.loadSettings();
|
||||
|
|
@ -26,28 +42,35 @@ export default class GitLabFilesPush extends Plugin {
|
|||
(leaf) => new SyncStatusView(leaf, this)
|
||||
);
|
||||
|
||||
this.addRibbonIcon('git-compare', 'Open sync status', async () => {
|
||||
this.addRibbonIcon('git-compare', t('main.ribbon.openSyncStatus'), async () => {
|
||||
await this.activateSyncStatusView();
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: 'open-sync-status',
|
||||
name: 'Open sync status',
|
||||
name: t('main.command.openSyncStatus'),
|
||||
callback: async () => {
|
||||
await this.activateSyncStatusView();
|
||||
}
|
||||
});
|
||||
|
||||
this.initializeGitService();
|
||||
this.gitignoreManager = new GitignoreManager(this.app, this.gitService, this.settings.branch, this.settings.rootPath, this.settings.vaultFolder);
|
||||
this.gitignoreManager = new GitignoreManager(this.app, this.gitService, this.settings.branch, this.settings.rootPath, this.settings.vaultFolder, this.settings.ignorePatterns);
|
||||
this.sync = new SyncManager(this.app, this.gitService, this.settings, this.saveSettings.bind(this));
|
||||
|
||||
this.statusBarEl = this.addStatusBarItem();
|
||||
this.statusBarEl.addClass('gfs-status-bar-connection');
|
||||
setTooltip(this.statusBarEl, t('settings.connectionStatus.checking'));
|
||||
this.registerDomEvent(this.statusBarEl, 'click', () => void this.testConnection());
|
||||
this.onConnectionStatusChange((status) => this.renderStatusBarConnection(status));
|
||||
void this.testConnection();
|
||||
|
||||
this.pushRibbonEl = this.addRibbonIcon('upload-cloud', this.pushRibbonLabel(), async () => {
|
||||
const activeView = this.app.workspace.getActiveViewOfType(MarkdownView);
|
||||
if (activeView && activeView.file instanceof TFile) {
|
||||
await this.sync.pushFile(activeView.file);
|
||||
} else {
|
||||
new Notice('No active note to push');
|
||||
new Notice(t('main.notice.noActiveNote'));
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -57,7 +80,7 @@ export default class GitLabFilesPush extends Plugin {
|
|||
// leave a stale name in the Command Palette until Obsidian reloads.
|
||||
this.addCommand({
|
||||
id: 'push-current-file',
|
||||
name: 'Push current file',
|
||||
name: t('main.command.pushCurrentFile'),
|
||||
callback: async () => {
|
||||
const activeView = this.app.workspace.getActiveViewOfType(MarkdownView);
|
||||
if (activeView && activeView.file instanceof TFile) {
|
||||
|
|
@ -68,7 +91,7 @@ export default class GitLabFilesPush extends Plugin {
|
|||
|
||||
this.addCommand({
|
||||
id: 'pull-current-file',
|
||||
name: 'Pull current file',
|
||||
name: t('main.command.pullCurrentFile'),
|
||||
callback: async () => {
|
||||
const activeView = this.app.workspace.getActiveViewOfType(MarkdownView);
|
||||
if (activeView && activeView.file instanceof TFile) {
|
||||
|
|
@ -79,7 +102,7 @@ export default class GitLabFilesPush extends Plugin {
|
|||
|
||||
this.addCommand({
|
||||
id: 'push-all-files',
|
||||
name: 'Push all files',
|
||||
name: t('main.command.pushAllFiles'),
|
||||
callback: async () => {
|
||||
await this.pushAllFiles();
|
||||
}
|
||||
|
|
@ -87,7 +110,7 @@ export default class GitLabFilesPush extends Plugin {
|
|||
|
||||
this.addCommand({
|
||||
id: 'pull-all-files',
|
||||
name: 'Pull all files',
|
||||
name: t('main.command.pullAllFiles'),
|
||||
callback: async () => {
|
||||
await this.pullAllFiles();
|
||||
}
|
||||
|
|
@ -97,26 +120,133 @@ export default class GitLabFilesPush extends Plugin {
|
|||
this.app.workspace.on('file-menu', (menu, file) => {
|
||||
if (file instanceof TFile) {
|
||||
menu.addItem((item) => {
|
||||
item.setTitle(`Push to ${this.serviceName}`)
|
||||
item.setTitle(t('main.contextMenu.pushTo', { service: this.serviceName }))
|
||||
.setIcon('upload-cloud')
|
||||
.onClick(async () => { await this.sync.pushFile(file); });
|
||||
});
|
||||
menu.addItem((item) => {
|
||||
item.setTitle(`Pull from ${this.serviceName}`)
|
||||
item.setTitle(t('main.contextMenu.pullFrom', { service: this.serviceName }))
|
||||
.setIcon('download-cloud')
|
||||
.onClick(async () => { await this.sync.pullFile(file); });
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// A file deleted outside the plugin's own delete UI (e.g. from Obsidian's
|
||||
// file explorer) would otherwise leave its syncMetadata entry behind
|
||||
// forever; detectRename's rename-matching scan treats every such orphan
|
||||
// as a rename candidate and does a live remote lookup for it on every
|
||||
// future single-file push, so clear it as soon as Obsidian reports the delete.
|
||||
this.registerEvent(
|
||||
this.app.vault.on('delete', (file) => {
|
||||
if (file instanceof TFile) {
|
||||
void this.sync.clearMetadata(file.path);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
await this.checkForUpdateNotice();
|
||||
}
|
||||
|
||||
private async checkForUpdateNotice(): Promise<void> {
|
||||
try {
|
||||
const currentVersion = this.manifest.version;
|
||||
const lastSeen = this.settings.lastSeenVersion;
|
||||
|
||||
// A fresh install has nothing to compare against — just record the
|
||||
// current version silently rather than showing a "what's new" tip.
|
||||
if (lastSeen && compareVersions(currentVersion, lastSeen) > 0) {
|
||||
const newReleases = getUnseenReleases(CHANGELOG, lastSeen);
|
||||
if (newReleases.length > 0) {
|
||||
new WhatsNewModal(this.app, newReleases).open();
|
||||
}
|
||||
}
|
||||
|
||||
if (lastSeen !== currentVersion) {
|
||||
this.settings.lastSeenVersion = currentVersion;
|
||||
await this.saveSettings();
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('Failed to check for update notice', e);
|
||||
}
|
||||
}
|
||||
|
||||
private get serviceName(): string {
|
||||
return getServiceName(this.settings);
|
||||
}
|
||||
|
||||
// Subscribes to connection status changes, immediately replaying the current
|
||||
// status so late subscribers (e.g. the settings tab opened after the initial
|
||||
// test already ran) don't have to wait for the next change. Returns an
|
||||
// unsubscribe function.
|
||||
onConnectionStatusChange(listener: (status: ConnectionStatus) => void): () => void {
|
||||
this.connectionStatusListeners.add(listener);
|
||||
listener(this.connectionStatus);
|
||||
return () => this.connectionStatusListeners.delete(listener);
|
||||
}
|
||||
|
||||
private setConnectionStatus(status: ConnectionStatus): void {
|
||||
this.connectionStatus = status;
|
||||
for (const listener of this.connectionStatusListeners) listener(status);
|
||||
}
|
||||
|
||||
// Single source of truth for connection testing, shared by the settings tab
|
||||
// badge and the status bar item so both reflect the same in-flight request
|
||||
// instead of racing separate calls against the remote API.
|
||||
async testConnection(): Promise<ConnectionTestResult> {
|
||||
const seq = ++this.connectionTestSeq;
|
||||
this.setConnectionStatus({ state: 'checking' });
|
||||
|
||||
try {
|
||||
const result = await this.gitService.testConnection(this.settings.branch);
|
||||
if (seq !== this.connectionTestSeq) return result;
|
||||
|
||||
if (!result.repoOk) {
|
||||
this.setConnectionStatus({ state: 'disconnected', detail: result.error ?? t('settings.testConnection.failed.unreachable') });
|
||||
} else if (!result.branchOk) {
|
||||
this.setConnectionStatus({ state: 'disconnected', detail: t('settings.testConnection.branchNotFound.badge', { branch: this.settings.branch }) });
|
||||
} else {
|
||||
this.setConnectionStatus({ state: 'connected' });
|
||||
}
|
||||
return result;
|
||||
} catch (e: unknown) {
|
||||
if (seq === this.connectionTestSeq) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
this.setConnectionStatus({ state: 'disconnected', detail: message });
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private renderStatusBarConnection(status: ConnectionStatus): void {
|
||||
const el = this.statusBarEl;
|
||||
if (!el) return;
|
||||
el.empty();
|
||||
el.removeClass('is-checking', 'is-connected', 'is-disconnected');
|
||||
el.addClass(`is-${status.state}`);
|
||||
|
||||
const icons: Record<ConnectionStatusState, string> = {
|
||||
checking: 'loader',
|
||||
connected: 'check-circle',
|
||||
disconnected: 'alert-circle',
|
||||
};
|
||||
setIcon(el.createSpan({ cls: 'gfs-status-bar-icon' }), icons[status.state]);
|
||||
|
||||
const labels: Record<ConnectionStatusState, string> = {
|
||||
checking: t('settings.connectionStatus.checking'),
|
||||
connected: t('settings.connectionStatus.connected'),
|
||||
disconnected: t('settings.connectionStatus.disconnected'),
|
||||
};
|
||||
el.createSpan({ text: ` ${this.serviceName}: ${labels[status.state]}` });
|
||||
|
||||
setTooltip(el, status.detail
|
||||
? t('settings.connectionStatus.withDetail', { label: labels[status.state], detail: status.detail })
|
||||
: labels[status.state]);
|
||||
}
|
||||
|
||||
private pushRibbonLabel(): string {
|
||||
return Platform.isMobile ? 'Push' : `Push to ${this.serviceName}`;
|
||||
return Platform.isMobile ? t('main.ribbon.push') : t('main.ribbon.pushTo', { service: this.serviceName });
|
||||
}
|
||||
|
||||
// The ribbon icon's tooltip is set once when addRibbonIcon runs, so it goes
|
||||
|
|
@ -172,31 +302,40 @@ export default class GitLabFilesPush extends Plugin {
|
|||
const startPath = this.settings.vaultFolder || '';
|
||||
const allPaths = await this.listAllFilesFromAdapter(startPath);
|
||||
|
||||
await this.gitService.listFiles(this.settings.branch);
|
||||
await this.gitignoreManager.loadGitignores();
|
||||
// Fetch the remote tree once and share it with both gitignore discovery
|
||||
// and (for push) the SHA-based diff, instead of each fetching it separately.
|
||||
let tree: GitTreeEntry[] | undefined;
|
||||
try {
|
||||
tree = await this.gitService.listFilesDetailed(this.settings.branch, false);
|
||||
} catch (e) {
|
||||
logger.warn('Failed to fetch remote tree; falling back to per-call fetches', e);
|
||||
}
|
||||
|
||||
await this.gitignoreManager.loadGitignores(tree);
|
||||
const files = allPaths.filter(p => !this.gitignoreManager.isIgnored(this.getNormalizedPath(p)));
|
||||
|
||||
if (files.length === 0) {
|
||||
new Notice(`No files to ${op} in the configured vault folder`);
|
||||
new Notice(t('main.notice.noFilesToRun', { op: op === 'push' ? t('main.op.push') : t('main.op.pull') }));
|
||||
return;
|
||||
}
|
||||
|
||||
const msg = op === 'push'
|
||||
? `Push ${files.length} file(s) to ${this.serviceName}?`
|
||||
: `Pull ${files.length} file(s) from ${this.serviceName}? This will overwrite local changes.`;
|
||||
? t('main.confirm.pushAll', { count: files.length, service: this.serviceName })
|
||||
: t('main.confirm.pullAll', { count: files.length, service: this.serviceName });
|
||||
|
||||
const confirmed = await this.showConfirmDialog(msg);
|
||||
if (!confirmed) return;
|
||||
|
||||
const progressNotice = new Notice(`${op === 'push' ? 'Pushing' : 'Pulling'} 0/${files.length} files...`, 0);
|
||||
const runVerb = op === 'push' ? t('main.verb.pushing') : t('main.verb.pulling');
|
||||
const progressNotice = new Notice(t('main.progress.running', { verb: runVerb, total: files.length }), 0);
|
||||
|
||||
try {
|
||||
const results = op === 'push'
|
||||
? await this.sync.pushAllFiles(files, (current, total, fileName) => {
|
||||
progressNotice.setMessage(`Pushing ${current}/${total}: ${fileName}`);
|
||||
})
|
||||
progressNotice.setMessage(t('main.progress.step', { verb: t('main.verb.pushing'), current, total, fileName }));
|
||||
}, tree)
|
||||
: await this.sync.pullAllFiles(files, (current, total, fileName) => {
|
||||
progressNotice.setMessage(`Pulling ${current}/${total}: ${fileName}`);
|
||||
progressNotice.setMessage(t('main.progress.step', { verb: t('main.verb.pulling'), current, total, fileName }));
|
||||
});
|
||||
|
||||
progressNotice.hide();
|
||||
|
|
@ -207,7 +346,8 @@ export default class GitLabFilesPush extends Plugin {
|
|||
} catch (e) {
|
||||
progressNotice.hide();
|
||||
logger.error(String(e));
|
||||
new Notice(`${op === 'push' ? 'Push' : 'Pull'} failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
const failVerb = op === 'push' ? t('main.verb.push') : t('main.verb.pull');
|
||||
new Notice(t('main.notice.runFailed', { verb: failVerb, message: e instanceof Error ? e.message : String(e) }));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -295,6 +435,7 @@ export default class GitLabFilesPush extends Plugin {
|
|||
|
||||
async loadSettings() {
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData() as Partial<GitLabFilesPushSettings>);
|
||||
setLanguageOverride(this.settings.language);
|
||||
}
|
||||
|
||||
async saveSettings() {
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ export interface GitHubTreeItem {
|
|||
path: string;
|
||||
type: string;
|
||||
mode?: string;
|
||||
sha?: string;
|
||||
}
|
||||
|
||||
export interface GitHubTreeResponse {
|
||||
|
|
@ -41,6 +42,8 @@ export interface GitLabTreeItem {
|
|||
path: string;
|
||||
type: string;
|
||||
mode?: string;
|
||||
/** GitLab's tree API calls the blob SHA "id", not "sha". */
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export interface ConnectionTestResult {
|
||||
|
|
@ -52,6 +55,18 @@ export interface ConnectionTestResult {
|
|||
error?: string;
|
||||
}
|
||||
|
||||
/** Max files per single batch-commit call. Guards against oversized request
|
||||
* bodies / provider payload limits when a vault has thousands of files. */
|
||||
export const MAX_BATCH_PUSH_SIZE = 200;
|
||||
|
||||
/** How many blob-creation requests to have in flight at once when building a
|
||||
* batch commit. Creating each file's blob is an independent request with no
|
||||
* ordering dependency, so running them one-at-a-time (as opposed to the final
|
||||
* tree/commit/ref sequence, which genuinely is sequential) just adds N
|
||||
* round trips of pure latency. A moderate cap keeps this fast without
|
||||
* bursting past a provider's abuse-detection/secondary rate limits. */
|
||||
export const BLOB_CREATE_CONCURRENCY = 8;
|
||||
|
||||
export abstract class BaseGitService {
|
||||
protected token: string = '';
|
||||
protected rootPath: string = '';
|
||||
|
|
@ -78,8 +93,20 @@ export abstract class BaseGitService {
|
|||
|
||||
response = await requestUrl(options);
|
||||
} catch (error) {
|
||||
// Network-level failure (DNS, offline, TLS, etc.)
|
||||
// Network-level failure (DNS, offline, TLS, etc.) — but some Obsidian
|
||||
// versions eagerly parse the response body as JSON inside requestUrl()
|
||||
// itself, before `throw: false` or our own status check ever run. If a
|
||||
// proxy/login page returns HTML instead of JSON, that eager parse
|
||||
// throws here as a raw "Unexpected token '<' ... is not valid JSON"
|
||||
// error rather than surfacing as a normal response we could inspect.
|
||||
if (!silent) logger.error('Git Service Request Failed:', error);
|
||||
if (this.looksLikeJsonParseOfHtmlError(error)) {
|
||||
throw new Error(
|
||||
'Expected a JSON response from the Git server but received an HTML page ' +
|
||||
'(likely a login, SSO redirect, or proxy/error page). ' +
|
||||
'Please check the server URL, your access token, and any network proxy or firewall.'
|
||||
);
|
||||
}
|
||||
if (error instanceof Error) throw error;
|
||||
throw new Error(`Network error or unexpected failure: ${String(error)}`);
|
||||
}
|
||||
|
|
@ -102,6 +129,17 @@ export abstract class BaseGitService {
|
|||
|
||||
protected abstract addAuthHeader(headers: Record<string, string>): void;
|
||||
|
||||
/**
|
||||
* Detects V8's JSON.parse error for a response starting with '<' (an HTML
|
||||
* page), across its known message phrasings, e.g.:
|
||||
* "Unexpected token '<', "<!DOCTYPE "... is not valid JSON"
|
||||
* "Unexpected token < in JSON at position 0"
|
||||
*/
|
||||
private looksLikeJsonParseOfHtmlError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) return false;
|
||||
return /unexpected token/i.test(error.message) && /json/i.test(error.message) && error.message.includes('<');
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely parses a response body as JSON.
|
||||
*
|
||||
|
|
@ -199,6 +237,16 @@ export abstract class BaseGitService {
|
|||
return this.isBinary(path) ? bytes.buffer : new TextDecoder().decode(bytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a blob by SHA from a GitHub-shaped git data API (GitHub and Gitea
|
||||
* both expose `GET .../git/blobs/{sha}` returning base64 `content`).
|
||||
*/
|
||||
protected async fetchGitHubStyleBlob(url: string, path: string): Promise<GitFile> {
|
||||
const response = await this.safeRequest(url, 'GET');
|
||||
const data = this.parseJson<{ content: string; encoding: string; sha: string }>(response);
|
||||
return { content: this.decodeContent(data.content, path), sha: data.sha };
|
||||
}
|
||||
|
||||
protected handleFileNotFound(e: unknown): GitFile {
|
||||
if (e instanceof Error && e.message.includes('404')) {
|
||||
return { content: '', sha: '' };
|
||||
|
|
@ -222,6 +270,92 @@ export abstract class BaseGitService {
|
|||
return e instanceof Error ? e : new Error(String(e));
|
||||
}
|
||||
|
||||
/**
|
||||
* The base URL for a GitHub-shaped Git Data API (e.g.
|
||||
* `https://api.github.com/repos/{owner}/{repo}` or
|
||||
* `{baseUrl}/api/v1/repos/{owner}/{repo}` for Gitea). Only meaningful for
|
||||
* providers that implement pushBatch/pushSymlink via this API shape.
|
||||
*/
|
||||
protected getGitDataApiBase(): string {
|
||||
throw new Error('getGitDataApiBase is not implemented for this provider');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a branch to its latest commit sha via GitHub's
|
||||
* `git/ref/heads/{branch}` endpoint. Gitea's older versions require a
|
||||
* different branch-resolution endpoint, so it provides its own override
|
||||
* rather than using this helper.
|
||||
*/
|
||||
protected async getLatestCommitSha(branch: string): Promise<string> {
|
||||
const base = this.getGitDataApiBase();
|
||||
const refResp = await this.safeRequest(`${base}/git/ref/heads/${branch}`, 'GET');
|
||||
return this.parseJson<{ object: { sha: string } }>(refResp).object.sha;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a branch to its latest commit sha and that commit's base tree
|
||||
* sha. Only needed by the REST Git Data API flow (pushSymlink, and
|
||||
* Gitea's pushBatch/deleteBatch which lack a GraphQL alternative).
|
||||
*/
|
||||
protected async resolveGitHubStyleBaseTree(branch: string): Promise<{ latestCommitSha: string; baseTreeSha: string }> {
|
||||
const latestCommitSha = await this.getLatestCommitSha(branch);
|
||||
const base = this.getGitDataApiBase();
|
||||
const commitResp = await this.safeRequest(`${base}/git/commits/${latestCommitSha}`, 'GET');
|
||||
const baseTreeSha = this.parseJson<{ tree: { sha: string } }>(commitResp).tree.sha;
|
||||
|
||||
return { latestCommitSha, baseTreeSha };
|
||||
}
|
||||
|
||||
/**
|
||||
* Commits N tree items (already-created blobs) in one shot: builds a new
|
||||
* tree on top of baseTreeSha, commits it, and moves the branch ref to point
|
||||
* at the new commit. Shared by GitHub/Gitea's pushSymlink and pushBatch.
|
||||
*/
|
||||
protected async commitGitHubStyleTree(
|
||||
base: string,
|
||||
branch: string,
|
||||
baseTreeSha: string,
|
||||
latestCommitSha: string,
|
||||
// A null sha removes that path from the resulting tree — how a batch
|
||||
// delete is expressed at the tree level (mode/type are still required
|
||||
// fields on the entry but are otherwise irrelevant for a deletion).
|
||||
treeItems: Array<{ path: string; mode: string; type: 'blob'; sha: string | null }>,
|
||||
message: string
|
||||
): Promise<string> {
|
||||
const treeResp = await this.safeRequest(`${base}/git/trees`, 'POST', { base_tree: baseTreeSha, tree: treeItems });
|
||||
const newTreeSha = this.parseJson<{ sha: string }>(treeResp).sha;
|
||||
|
||||
const newCommitResp = await this.safeRequest(`${base}/git/commits`, 'POST', {
|
||||
message,
|
||||
tree: newTreeSha,
|
||||
parents: [latestCommitSha],
|
||||
});
|
||||
const newCommitSha = this.parseJson<{ sha: string }>(newCommitResp).sha;
|
||||
|
||||
await this.safeRequest(`${base}/git/refs/heads/${branch}`, 'PATCH', { sha: newCommitSha });
|
||||
|
||||
return newCommitSha;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs `fn` over `items` with at most `concurrency` calls in flight at
|
||||
* once, preserving result order. Used to parallelize independent
|
||||
* per-file requests (e.g. blob creation) that would otherwise pay N
|
||||
* round trips of latency running one at a time.
|
||||
*/
|
||||
protected async mapWithConcurrency<T, R>(items: T[], concurrency: number, fn: (item: T, index: number) => Promise<R>): Promise<R[]> {
|
||||
const results = new Array<R>(items.length);
|
||||
let nextIndex = 0;
|
||||
const worker = async (): Promise<void> => {
|
||||
while (nextIndex < items.length) {
|
||||
const i = nextIndex++;
|
||||
results[i] = await fn(items[i] as T, i);
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, worker));
|
||||
return results;
|
||||
}
|
||||
|
||||
async getRepoGitignores(branch: string): Promise<string[]> {
|
||||
try {
|
||||
const allFiles = await this.listFiles(branch, false); // Fetch ALL files to find gitignores
|
||||
|
|
|
|||
|
|
@ -13,6 +13,33 @@ export interface GitFile {
|
|||
export interface GitTreeEntry {
|
||||
path: string;
|
||||
symlink: boolean;
|
||||
/**
|
||||
* The blob's git SHA, when the provider's tree listing includes it. Lets a
|
||||
* refresh classify sync status by comparing a locally-computed blob SHA
|
||||
* against this, instead of fetching full file content per entry. Absent
|
||||
* entries fall back to a content-based comparison via getFile.
|
||||
*/
|
||||
sha?: string;
|
||||
}
|
||||
|
||||
/** One file's content to include in a batched multi-file commit. */
|
||||
export interface BatchPushItem {
|
||||
/** Path relative to rootPath, same shape pushFile's `path` param takes. */
|
||||
path: string;
|
||||
content: string | ArrayBuffer;
|
||||
/**
|
||||
* Whether this path already existed on the remote before this push, per the
|
||||
* caller's pre-fetched tree. Only GitLab's Commits API needs this (to choose
|
||||
* action 'create' vs 'update'); GitHub/Gitea's tree-based commit ignores it.
|
||||
*/
|
||||
existedRemotely?: boolean;
|
||||
}
|
||||
|
||||
/** Result for one file after a batch push completes. */
|
||||
export interface BatchPushResult {
|
||||
path: string;
|
||||
/** New blob sha, when the provider can report it directly. */
|
||||
sha?: string;
|
||||
}
|
||||
|
||||
export interface GitServiceInterface {
|
||||
|
|
@ -30,6 +57,29 @@ export interface GitServiceInterface {
|
|||
* implement it; callers must fall back to pushFile when it's absent.
|
||||
*/
|
||||
pushSymlink?(path: string, target: string, branch: string, commitMessage: string): Promise<{ path: string, sha?: string }>;
|
||||
/**
|
||||
* Push many files in a single commit. Optional: only providers with a way to
|
||||
* write multiple files atomically implement it; callers must fall back to
|
||||
* sequential pushFile calls when it's absent (mirrors pushSymlink?). Must be
|
||||
* atomic: on failure it throws rather than returning partial results, so the
|
||||
* caller can mark every item in the attempted batch as failed.
|
||||
*/
|
||||
pushBatch?(items: BatchPushItem[], branch: string, commitMessage: string): Promise<BatchPushResult[]>;
|
||||
deleteFile(path: string, branch: string, commitMessage: string): Promise<void>;
|
||||
/**
|
||||
* Delete many files in a single commit. Optional: only providers with a way
|
||||
* to write multiple changes atomically implement it; callers must fall back
|
||||
* to sequential deleteFile calls when it's absent (mirrors pushBatch?). Must
|
||||
* be atomic: on failure it throws rather than partially deleting, so the
|
||||
* caller can mark every path in the attempted batch as failed.
|
||||
*/
|
||||
deleteBatch?(paths: string[], branch: string, commitMessage: string): Promise<void>;
|
||||
getRepoGitignores(branch: string): Promise<string[]>;
|
||||
/**
|
||||
* Fetches a blob's content directly by its git SHA (from a GitTreeEntry),
|
||||
* bypassing the path/ref-based Contents API. Used to lazily load a modified
|
||||
* file's remote content (e.g. to render a diff) once a SHA-based refresh has
|
||||
* already determined it differs from the local copy.
|
||||
*/
|
||||
getBlob(sha: string, path: string): Promise<GitFile>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { GitServiceInterface, GitTreeEntry } from './git-service-interface';
|
||||
import { BaseGitService, ConnectionTestResult, GitFile, GitHubContentResponse, GitHubTreeResponse, GIT_SYMLINK_MODE } from './git-service-base';
|
||||
import { GitServiceInterface, GitTreeEntry, BatchPushItem, BatchPushResult } from './git-service-interface';
|
||||
import { BaseGitService, ConnectionTestResult, GitFile, GitHubContentResponse, GitHubTreeResponse, GIT_SYMLINK_MODE, BLOB_CREATE_CONCURRENCY } from './git-service-base';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
export class GiteaService extends BaseGitService implements GitServiceInterface {
|
||||
|
|
@ -21,7 +21,27 @@ export class GiteaService extends BaseGitService implements GitServiceInterface
|
|||
|
||||
private getApiUrl(path: string): string {
|
||||
const fullPath = this.getFullPath(path);
|
||||
return `${this.baseUrl}/api/v1/repos/${this.owner}/${this.repo}/contents/${fullPath}`;
|
||||
const encodedPath = fullPath.split('/').map(encodeURIComponent).join('/');
|
||||
return `${this.baseUrl}/api/v1/repos/${this.owner}/${this.repo}/contents/${encodedPath}`;
|
||||
}
|
||||
|
||||
protected getGitDataApiBase(): string {
|
||||
return `${this.baseUrl}/api/v1/repos/${this.owner}/${this.repo}`;
|
||||
}
|
||||
|
||||
// Gitea's git/commits/{sha} endpoint needs a resolved commit sha, not a
|
||||
// branch ref name, and older Gitea versions don't expose GitHub's
|
||||
// git/ref/heads/{branch} endpoint at all — resolve via /branches/{branch}
|
||||
// instead, same as listFilesDetailed already does.
|
||||
private async resolveBaseTree(branch: string): Promise<{ latestCommitSha: string; baseTreeSha: string }> {
|
||||
const branchUrl = `${this.baseUrl}/api/v1/repos/${this.owner}/${this.repo}/branches/${branch}`;
|
||||
const branchResp = await this.safeRequest(branchUrl, 'GET');
|
||||
const latestCommitSha = this.parseJson<{ commit: { id: string } }>(branchResp).commit.id;
|
||||
|
||||
const commitResp = await this.safeRequest(`${this.getGitDataApiBase()}/git/commits/${latestCommitSha}`, 'GET');
|
||||
const baseTreeSha = this.parseJson<{ tree: { sha: string } }>(commitResp).tree.sha;
|
||||
|
||||
return { latestCommitSha, baseTreeSha };
|
||||
}
|
||||
|
||||
async getFile(path: string, branch: string): Promise<GitFile> {
|
||||
|
|
@ -55,6 +75,31 @@ export class GiteaService extends BaseGitService implements GitServiceInterface
|
|||
return { path: data.content.path, sha: data.content.sha };
|
||||
}
|
||||
|
||||
async pushBatch(items: BatchPushItem[], branch: string, message: string): Promise<BatchPushResult[]> {
|
||||
if (items.length === 0) return [];
|
||||
const base = this.getGitDataApiBase();
|
||||
const { latestCommitSha, baseTreeSha } = await this.resolveBaseTree(branch);
|
||||
|
||||
const blobShas = await this.mapWithConcurrency(items, BLOB_CREATE_CONCURRENCY, async item => {
|
||||
const blobResp = await this.safeRequest(`${base}/git/blobs`, 'POST', {
|
||||
content: this.encodeContent(item.content),
|
||||
encoding: 'base64',
|
||||
});
|
||||
return this.parseJson<{ sha: string }>(blobResp).sha;
|
||||
});
|
||||
|
||||
const treeItems = items.map((item, i) => ({
|
||||
path: this.getFullPath(item.path),
|
||||
mode: '100644',
|
||||
type: 'blob' as const,
|
||||
sha: blobShas[i] as string,
|
||||
}));
|
||||
|
||||
await this.commitGitHubStyleTree(base, branch, baseTreeSha, latestCommitSha, treeItems, message);
|
||||
|
||||
return items.map((item, i) => ({ path: item.path, sha: blobShas[i] }));
|
||||
}
|
||||
|
||||
async listFilesDetailed(branch: string, useFilter = true): Promise<GitTreeEntry[]> {
|
||||
// Resolve branch name to commit SHA first for compatibility with all Gitea versions,
|
||||
// since the git/trees endpoint requires a SHA (not a ref name) on older instances.
|
||||
|
|
@ -77,7 +122,7 @@ export class GiteaService extends BaseGitService implements GitServiceInterface
|
|||
|
||||
const entries = treeData.tree
|
||||
.filter(item => item.type === 'blob')
|
||||
.map(item => ({ path: item.path, symlink: item.mode === GIT_SYMLINK_MODE }));
|
||||
.map(item => ({ path: item.path, symlink: item.mode === GIT_SYMLINK_MODE, sha: item.sha }));
|
||||
|
||||
if (!useFilter) return entries;
|
||||
|
||||
|
|
@ -88,8 +133,15 @@ export class GiteaService extends BaseGitService implements GitServiceInterface
|
|||
});
|
||||
}
|
||||
|
||||
async getBlob(sha: string, path: string): Promise<GitFile> {
|
||||
return this.fetchGitHubStyleBlob(`${this.baseUrl}/api/v1/repos/${this.owner}/${this.repo}/git/blobs/${sha}`, path);
|
||||
}
|
||||
|
||||
async deleteFile(path: string, branch: string, message: string): Promise<void> {
|
||||
const file = await this.getFile(path, branch);
|
||||
if (!file.sha) {
|
||||
throw new Error(`Cannot delete "${path}": file was not found on branch "${branch}".`);
|
||||
}
|
||||
const url = this.getApiUrl(path);
|
||||
const body = {
|
||||
message,
|
||||
|
|
@ -100,6 +152,21 @@ export class GiteaService extends BaseGitService implements GitServiceInterface
|
|||
await this.safeRequest(url, 'DELETE', body);
|
||||
}
|
||||
|
||||
async deleteBatch(paths: string[], branch: string, message: string): Promise<void> {
|
||||
if (paths.length === 0) return;
|
||||
const base = this.getGitDataApiBase();
|
||||
const { latestCommitSha, baseTreeSha } = await this.resolveBaseTree(branch);
|
||||
|
||||
const treeItems = paths.map(path => ({
|
||||
path: this.getFullPath(path),
|
||||
mode: '100644',
|
||||
type: 'blob' as const,
|
||||
sha: null,
|
||||
}));
|
||||
|
||||
await this.commitGitHubStyleTree(base, branch, baseTreeSha, latestCommitSha, treeItems, message);
|
||||
}
|
||||
|
||||
async testConnection(branch: string): Promise<ConnectionTestResult> {
|
||||
try {
|
||||
const url = `${this.baseUrl}/api/v1/repos/${this.owner}/${this.repo}`;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,22 @@
|
|||
import { GitServiceInterface, GitTreeEntry } from './git-service-interface';
|
||||
import { GitServiceInterface, GitTreeEntry, BatchPushItem, BatchPushResult } from './git-service-interface';
|
||||
import { BaseGitService, ConnectionTestResult, GitFile, GitHubContentResponse, GitHubTreeResponse, GIT_SYMLINK_MODE } from './git-service-base';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
/**
|
||||
* Commits any mix of file additions/deletions in one request. Used instead of
|
||||
* the REST Git Data API's blob -> tree -> commit -> ref sequence for
|
||||
* pushBatch/deleteBatch: those need one HTTP round trip per blob to upload
|
||||
* content, while this mutation carries file content directly in the request
|
||||
* body, so an N-file batch is one call instead of N+~5.
|
||||
*/
|
||||
const CREATE_COMMIT_MUTATION = `
|
||||
mutation ($input: CreateCommitOnBranchInput!) {
|
||||
createCommitOnBranch(input: $input) {
|
||||
commit { oid }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export class GitHubService extends BaseGitService implements GitServiceInterface {
|
||||
private owner: string = '';
|
||||
private repo: string = '';
|
||||
|
|
@ -19,7 +34,12 @@ export class GitHubService extends BaseGitService implements GitServiceInterface
|
|||
|
||||
private getApiUrl(path: string): string {
|
||||
const fullPath = this.getFullPath(path);
|
||||
return `https://api.github.com/repos/${this.owner}/${this.repo}/contents/${fullPath}`;
|
||||
const encodedPath = fullPath.split('/').map(encodeURIComponent).join('/');
|
||||
return `https://api.github.com/repos/${this.owner}/${this.repo}/contents/${encodedPath}`;
|
||||
}
|
||||
|
||||
protected getGitDataApiBase(): string {
|
||||
return `https://api.github.com/repos/${this.owner}/${this.repo}`;
|
||||
}
|
||||
|
||||
async getFile(path: string, branch: string): Promise<GitFile> {
|
||||
|
|
@ -66,35 +86,104 @@ export class GitHubService extends BaseGitService implements GitServiceInterface
|
|||
// (mode 120000) must be committed through the lower-level Git Data API:
|
||||
// blob -> tree (with the symlink mode) -> commit -> move the branch ref.
|
||||
const fullPath = this.getFullPath(path);
|
||||
const base = `https://api.github.com/repos/${this.owner}/${this.repo}`;
|
||||
const base = this.getGitDataApiBase();
|
||||
|
||||
const refResp = await this.safeRequest(`${base}/git/ref/heads/${branch}`, 'GET');
|
||||
const latestCommitSha = this.parseJson<{ object: { sha: string } }>(refResp).object.sha;
|
||||
|
||||
const commitResp = await this.safeRequest(`${base}/git/commits/${latestCommitSha}`, 'GET');
|
||||
const baseTreeSha = this.parseJson<{ tree: { sha: string } }>(commitResp).tree.sha;
|
||||
const { latestCommitSha, baseTreeSha } = await this.resolveGitHubStyleBaseTree(branch);
|
||||
|
||||
const blobResp = await this.safeRequest(`${base}/git/blobs`, 'POST', { content: target, encoding: 'utf-8' });
|
||||
const blobSha = this.parseJson<{ sha: string }>(blobResp).sha;
|
||||
|
||||
const treeResp = await this.safeRequest(`${base}/git/trees`, 'POST', {
|
||||
base_tree: baseTreeSha,
|
||||
tree: [{ path: fullPath, mode: GIT_SYMLINK_MODE, type: 'blob', sha: blobSha }],
|
||||
});
|
||||
const newTreeSha = this.parseJson<{ sha: string }>(treeResp).sha;
|
||||
|
||||
const newCommitResp = await this.safeRequest(`${base}/git/commits`, 'POST', {
|
||||
message,
|
||||
tree: newTreeSha,
|
||||
parents: [latestCommitSha],
|
||||
});
|
||||
const newCommitSha = this.parseJson<{ sha: string }>(newCommitResp).sha;
|
||||
|
||||
await this.safeRequest(`${base}/git/refs/heads/${branch}`, 'PATCH', { sha: newCommitSha });
|
||||
await this.commitGitHubStyleTree(
|
||||
base, branch, baseTreeSha, latestCommitSha,
|
||||
[{ path: fullPath, mode: GIT_SYMLINK_MODE, type: 'blob', sha: blobSha }],
|
||||
message
|
||||
);
|
||||
|
||||
return { path: fullPath, sha: blobSha };
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls a GitHub GraphQL mutation. GraphQL reports mutation-level failures
|
||||
* (e.g. a stale expectedHeadOid) as a 200 response with an `errors` array
|
||||
* rather than an HTTP error status, so this checks for that on top of
|
||||
* safeRequest's status-code check.
|
||||
*/
|
||||
private async githubGraphQL<T>(query: string, variables: Record<string, unknown>): Promise<T> {
|
||||
const response = await this.safeRequest('https://api.github.com/graphql', 'POST', { query, variables });
|
||||
const body = this.parseJson<{ data?: T; errors?: Array<{ message: string }> }>(response);
|
||||
if (body.errors && body.errors.length > 0) {
|
||||
throw new Error(`GitHub GraphQL error: ${body.errors.map(e => e.message).join('; ')}`);
|
||||
}
|
||||
if (!body.data) {
|
||||
throw new Error('GitHub GraphQL response returned no data');
|
||||
}
|
||||
return body.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs createCommitOnBranch, re-reading the branch HEAD and retrying on a
|
||||
* stale-expectedHeadOid failure. GitHub's git/ref/heads/{branch} read (used
|
||||
* to get expectedHeadOid) can briefly lag a just-completed write to the same
|
||||
* branch — e.g. a push immediately followed by a delete — so the oid it
|
||||
* returns may predate a file the caller is trying to add or remove, and
|
||||
* GitHub reports that as "path does not exist in tree <oid>" rather than as
|
||||
* an obvious staleness error. A short retry with a freshly re-read HEAD
|
||||
* self-heals once GitHub's read catches up.
|
||||
*/
|
||||
private async commitOnBranch(branch: string, message: string, fileChanges: Record<string, unknown>): Promise<string> {
|
||||
const maxAttempts = 3;
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
const expectedHeadOid = await this.getLatestCommitSha(branch);
|
||||
try {
|
||||
const data = await this.githubGraphQL<{ createCommitOnBranch: { commit: { oid: string } } }>(CREATE_COMMIT_MUTATION, {
|
||||
input: {
|
||||
branch: { repositoryNameWithOwner: `${this.owner}/${this.repo}`, branchName: branch },
|
||||
message: { headline: message },
|
||||
expectedHeadOid,
|
||||
fileChanges,
|
||||
},
|
||||
});
|
||||
return data.createCommitOnBranch.commit.oid;
|
||||
} catch (e) {
|
||||
const errorMessage = e instanceof Error ? e.message : String(e);
|
||||
const looksStale = /does not exist in tree|does not match|expectedHeadOid/i.test(errorMessage);
|
||||
if (!looksStale || attempt === maxAttempts) throw e;
|
||||
await new Promise(resolve => window.setTimeout(resolve, 500 * attempt));
|
||||
}
|
||||
}
|
||||
// Unreachable: the loop always returns or throws.
|
||||
throw new Error('commitOnBranch: exhausted retries without a result');
|
||||
}
|
||||
|
||||
async pushBatch(items: BatchPushItem[], branch: string, message: string): Promise<BatchPushResult[]> {
|
||||
if (items.length === 0) return [];
|
||||
|
||||
await this.commitOnBranch(branch, message, {
|
||||
additions: items.map(item => ({
|
||||
path: this.getFullPath(item.path),
|
||||
contents: this.encodeContent(item.content),
|
||||
})),
|
||||
});
|
||||
|
||||
// createCommitOnBranch only returns the new commit's oid, not each
|
||||
// file's blob sha, so read them back with a follow-up tree fetch
|
||||
// (mirrors GitLab's pushBatch, which has the same limitation). That
|
||||
// fetch is exposed to the same eventual-consistency lag the retry
|
||||
// above works around, so a fresh tree can still be briefly missing an
|
||||
// entry that was just committed; retry it too rather than silently
|
||||
// returning an undefined sha for that file.
|
||||
const fullPaths = items.map(item => this.getFullPath(item.path));
|
||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||
const freshTree = await this.listFilesDetailed(branch, false);
|
||||
const shaByPath = new Map(freshTree.map(e => [e.path, e.sha]));
|
||||
const results = items.map((item, i) => ({ path: item.path, sha: shaByPath.get(fullPaths[i] as string) }));
|
||||
if (results.every(r => r.sha) || attempt === 3) return results;
|
||||
await new Promise(resolve => window.setTimeout(resolve, 500 * attempt));
|
||||
}
|
||||
// Unreachable: the loop always returns on its last iteration.
|
||||
throw new Error('pushBatch: exhausted retries reading back blob shas');
|
||||
}
|
||||
|
||||
async listFilesDetailed(branch: string, useFilter = true): Promise<GitTreeEntry[]> {
|
||||
const url = `https://api.github.com/repos/${this.owner}/${this.repo}/git/trees/${branch}?recursive=1`;
|
||||
let data: GitHubTreeResponse;
|
||||
|
|
@ -111,7 +200,7 @@ export class GitHubService extends BaseGitService implements GitServiceInterface
|
|||
|
||||
const entries = data.tree
|
||||
.filter(item => item.type === 'blob')
|
||||
.map(item => ({ path: item.path, symlink: item.mode === GIT_SYMLINK_MODE }));
|
||||
.map(item => ({ path: item.path, symlink: item.mode === GIT_SYMLINK_MODE, sha: item.sha }));
|
||||
|
||||
if (!useFilter) return entries;
|
||||
|
||||
|
|
@ -122,8 +211,15 @@ export class GitHubService extends BaseGitService implements GitServiceInterface
|
|||
});
|
||||
}
|
||||
|
||||
async getBlob(sha: string, path: string): Promise<GitFile> {
|
||||
return this.fetchGitHubStyleBlob(`https://api.github.com/repos/${this.owner}/${this.repo}/git/blobs/${sha}`, path);
|
||||
}
|
||||
|
||||
async deleteFile(path: string, branch: string, message: string): Promise<void> {
|
||||
const file = await this.getFile(path, branch);
|
||||
if (!file.sha) {
|
||||
throw new Error(`Cannot delete "${path}": file was not found on branch "${branch}".`);
|
||||
}
|
||||
const url = this.getApiUrl(path);
|
||||
const body = {
|
||||
message,
|
||||
|
|
@ -134,6 +230,14 @@ export class GitHubService extends BaseGitService implements GitServiceInterface
|
|||
await this.safeRequest(url, 'DELETE', body);
|
||||
}
|
||||
|
||||
async deleteBatch(paths: string[], branch: string, message: string): Promise<void> {
|
||||
if (paths.length === 0) return;
|
||||
|
||||
await this.commitOnBranch(branch, message, {
|
||||
deletions: paths.map(path => ({ path: this.getFullPath(path) })),
|
||||
});
|
||||
}
|
||||
|
||||
async testConnection(branch: string): Promise<ConnectionTestResult> {
|
||||
try {
|
||||
const url = `https://api.github.com/repos/${this.owner}/${this.repo}`;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { GitServiceInterface, GitTreeEntry } from './git-service-interface';
|
||||
import { GitServiceInterface, GitTreeEntry, BatchPushItem, BatchPushResult } from './git-service-interface';
|
||||
import { BaseGitService, ConnectionTestResult, GitFile, GitLabFileResponse, GitLabTreeItem, GIT_SYMLINK_MODE } from './git-service-base';
|
||||
|
||||
export class GitLabService extends BaseGitService implements GitServiceInterface {
|
||||
|
|
@ -55,6 +55,28 @@ export class GitLabService extends BaseGitService implements GitServiceInterface
|
|||
return { path: data.file_path };
|
||||
}
|
||||
|
||||
async pushBatch(items: BatchPushItem[], branch: string, message: string): Promise<BatchPushResult[]> {
|
||||
if (items.length === 0) return [];
|
||||
const encodedProjectId = encodeURIComponent(this.projectId);
|
||||
const url = `${this.baseUrl}/api/v4/projects/${encodedProjectId}/repository/commits`;
|
||||
|
||||
const actions = items.map(item => ({
|
||||
action: item.existedRemotely ? 'update' : 'create',
|
||||
file_path: this.getFullPath(item.path),
|
||||
content: this.encodeContent(item.content),
|
||||
encoding: 'base64',
|
||||
}));
|
||||
|
||||
await this.safeRequest(url, 'POST', { branch, commit_message: message, actions });
|
||||
|
||||
// The Commits API response doesn't include each file's new blob sha, so
|
||||
// read it back via a single follow-up tree fetch (one extra call for the
|
||||
// whole batch, not per file) rather than per-file getFile calls.
|
||||
const freshTree = await this.listFilesDetailed(branch, false);
|
||||
const shaByPath = new Map(freshTree.map(e => [e.path, e.sha]));
|
||||
return items.map(item => ({ path: item.path, sha: shaByPath.get(this.getFullPath(item.path)) }));
|
||||
}
|
||||
|
||||
async listFilesDetailed(branch: string, useFilter = true): Promise<GitTreeEntry[]> {
|
||||
const encodedProjectId = encodeURIComponent(this.projectId);
|
||||
let allEntries: GitTreeEntry[] = [];
|
||||
|
|
@ -75,7 +97,7 @@ export class GitLabService extends BaseGitService implements GitServiceInterface
|
|||
|
||||
const entries = data
|
||||
.filter(item => item.type === 'blob')
|
||||
.map(item => ({ path: item.path, symlink: item.mode === GIT_SYMLINK_MODE }));
|
||||
.map(item => ({ path: item.path, symlink: item.mode === GIT_SYMLINK_MODE, sha: item.id }));
|
||||
|
||||
if (useFilter) {
|
||||
const filtered = entries.filter(e => {
|
||||
|
|
@ -95,6 +117,16 @@ export class GitLabService extends BaseGitService implements GitServiceInterface
|
|||
return allEntries;
|
||||
}
|
||||
|
||||
async getBlob(sha: string, path: string): Promise<GitFile> {
|
||||
// Unlike GitHub/Gitea's base64-JSON blob endpoint, GitLab's raw blob
|
||||
// endpoint returns the file's actual bytes directly.
|
||||
const encodedProjectId = encodeURIComponent(this.projectId);
|
||||
const url = `${this.baseUrl}/api/v4/projects/${encodedProjectId}/repository/blobs/${sha}/raw`;
|
||||
const response = await this.safeRequest(url, 'GET');
|
||||
const content = this.isBinary(path) ? response.arrayBuffer : response.text;
|
||||
return { content, sha };
|
||||
}
|
||||
|
||||
async deleteFile(path: string, branch: string, message: string): Promise<void> {
|
||||
const url = this.getApiUrl(path);
|
||||
const body = {
|
||||
|
|
@ -105,6 +137,16 @@ export class GitLabService extends BaseGitService implements GitServiceInterface
|
|||
await this.safeRequest(url, 'DELETE', body);
|
||||
}
|
||||
|
||||
async deleteBatch(paths: string[], branch: string, message: string): Promise<void> {
|
||||
if (paths.length === 0) return;
|
||||
const encodedProjectId = encodeURIComponent(this.projectId);
|
||||
const url = `${this.baseUrl}/api/v4/projects/${encodedProjectId}/repository/commits`;
|
||||
|
||||
const actions = paths.map(path => ({ action: 'delete', file_path: this.getFullPath(path) }));
|
||||
|
||||
await this.safeRequest(url, 'POST', { branch, commit_message: message, actions });
|
||||
}
|
||||
|
||||
async testConnection(branch: string): Promise<ConnectionTestResult> {
|
||||
const encodedProjectId = encodeURIComponent(this.projectId);
|
||||
try {
|
||||
|
|
|
|||
293
src/settings.ts
293
src/settings.ts
|
|
@ -1,5 +1,9 @@
|
|||
import {App, PluginSettingTab, Setting, Notice, TextComponent} from 'obsidian';
|
||||
import GitLabFilesPush from "./main";
|
||||
import GitLabFilesPush, { type ConnectionStatus } from "./main";
|
||||
import {FolderSuggest} from "./ui/FolderSuggest";
|
||||
import {RemoteFolderSuggest} from "./ui/RemoteFolderSuggest";
|
||||
import { t, setLanguageOverride, type LanguageSetting } from "./i18n";
|
||||
import { CHANGELOG } from "./changelog";
|
||||
|
||||
// Minimal shape of Obsidian >= 1.13's SettingDefinitionItem. Declared locally so
|
||||
// the plugin still type-checks against older Obsidian typings (minAppVersion
|
||||
|
|
@ -44,6 +48,14 @@ export interface GitLabFilesPushSettings {
|
|||
rootPath: string;
|
||||
vaultFolder: string;
|
||||
symlinkHandling: SymlinkHandling;
|
||||
/** Multi-line, .gitignore-style patterns applied locally, in addition to the remote repo's .gitignore rules. */
|
||||
ignorePatterns: string;
|
||||
/** Plugin version last seen by this vault, used to show a "what's new" tip after an update. */
|
||||
lastSeenVersion: string;
|
||||
/** Version whose "what's new" banner in the settings tab has been dismissed, if any. */
|
||||
bannerDismissedVersion: string;
|
||||
/** UI language. 'system' follows Obsidian's display language, falling back to English if unsupported. */
|
||||
language: LanguageSetting;
|
||||
}
|
||||
|
||||
export function getServiceName(settings: GitLabFilesPushSettings): string {
|
||||
|
|
@ -81,17 +93,38 @@ export const DEFAULT_SETTINGS: GitLabFilesPushSettings = {
|
|||
branch: 'main',
|
||||
syncMetadata: {},
|
||||
vaultFolder: '',
|
||||
symlinkHandling: 'real'
|
||||
symlinkHandling: 'real',
|
||||
ignorePatterns: '',
|
||||
lastSeenVersion: '',
|
||||
bannerDismissedVersion: '',
|
||||
language: 'system'
|
||||
}
|
||||
|
||||
const CONNECTION_TEST_DEBOUNCE_MS = 800;
|
||||
|
||||
export class GitLabSyncSettingTab extends PluginSettingTab {
|
||||
plugin: GitLabFilesPush;
|
||||
private statusBadgeEl: HTMLElement | null = null;
|
||||
private connectionTestTimer: number | null = null;
|
||||
private unsubscribeConnectionStatus: (() => void) | null = null;
|
||||
|
||||
constructor(app: App, plugin: GitLabFilesPush) {
|
||||
super(app, plugin);
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
// The status badge mirrors the plugin's shared connection status (also
|
||||
// driving the status bar item) instead of running its own test, so both
|
||||
// stay in sync and don't race separate requests against the remote API.
|
||||
hide(): void {
|
||||
this.unsubscribeConnectionStatus?.();
|
||||
this.unsubscribeConnectionStatus = null;
|
||||
if (this.connectionTestTimer) {
|
||||
window.clearTimeout(this.connectionTestTimer);
|
||||
this.connectionTestTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Kept as a fallback for Obsidian < 1.13.0 (older than 1.13, down to
|
||||
// minAppVersion 1.11.0), which don't know about getSettingDefinitions()
|
||||
// and always call display().
|
||||
|
|
@ -120,12 +153,105 @@ export class GitLabSyncSettingTab extends PluginSettingTab {
|
|||
}
|
||||
}
|
||||
|
||||
// Persistent (until dismissed) banner surfacing the current version's notable
|
||||
// highlights right at the top of the settings tab, so users who dismissed or
|
||||
// never saw the WhatsNewModal (see main.ts) can still find them. Separate
|
||||
// from `lastSeenVersion` — that gate controls the once-per-upgrade modal,
|
||||
// this one just tracks whether the banner itself was dismissed.
|
||||
private renderWhatsNewBanner(containerEl: HTMLElement): void {
|
||||
const currentVersion = this.plugin.manifest.version;
|
||||
if (this.plugin.settings.bannerDismissedVersion === currentVersion) return;
|
||||
|
||||
const release = CHANGELOG.find(r => r.version === currentVersion);
|
||||
const notableEntries = release?.entries.filter(entry => entry.notable) ?? [];
|
||||
if (notableEntries.length === 0) return;
|
||||
|
||||
const banner = containerEl.createDiv({ cls: 'gfs-whats-new-banner' });
|
||||
const textEl = banner.createDiv({ cls: 'gfs-whats-new-banner-text' });
|
||||
textEl.createEl('strong', { text: t('settings.whatsNewBanner.title', { version: currentVersion }) });
|
||||
const list = textEl.createEl('ul', { cls: 'gfs-whats-new-banner-list' });
|
||||
for (const entry of notableEntries) {
|
||||
list.createEl('li', { text: entry.text });
|
||||
}
|
||||
|
||||
const dismissBtn = banner.createEl('button', {
|
||||
cls: 'gfs-whats-new-banner-dismiss',
|
||||
text: '×',
|
||||
attr: { 'aria-label': t('settings.whatsNewBanner.dismiss') }
|
||||
});
|
||||
dismissBtn.addEventListener('click', () => {
|
||||
void (async () => {
|
||||
this.plugin.settings.bannerDismissedVersion = currentVersion;
|
||||
await this.plugin.saveSettings();
|
||||
this.refresh();
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
||||
// Rebuilding the whole settings tab (renderSettings) to refresh the badge
|
||||
// would empty and recreate every field, stealing focus mid-typing. The
|
||||
// badge element is instead created once per renderSettings pass and
|
||||
// updated in place by setStatusBadge(), driven by the plugin's shared
|
||||
// connection status (see main.ts) so it stays in sync with the status bar.
|
||||
private renderConnectionStatus(containerEl: HTMLElement): void {
|
||||
this.statusBadgeEl = containerEl.createDiv({ cls: 'gfs-connection-status' });
|
||||
this.unsubscribeConnectionStatus?.();
|
||||
this.unsubscribeConnectionStatus = this.plugin.onConnectionStatusChange((status) => this.setStatusBadge(status));
|
||||
}
|
||||
|
||||
private setStatusBadge(status: ConnectionStatus): void {
|
||||
const badge = this.statusBadgeEl;
|
||||
if (!badge) return;
|
||||
|
||||
badge.removeClass('is-checking', 'is-connected', 'is-disconnected');
|
||||
badge.addClass(`is-${status.state}`);
|
||||
|
||||
const labels: Record<ConnectionStatus['state'], string> = {
|
||||
checking: t('settings.connectionStatus.checking'),
|
||||
connected: t('settings.connectionStatus.connected'),
|
||||
disconnected: t('settings.connectionStatus.disconnected')
|
||||
};
|
||||
const label = labels[status.state];
|
||||
badge.setText(status.detail ? t('settings.connectionStatus.withDetail', { label, detail: status.detail }) : label);
|
||||
}
|
||||
|
||||
// Debounced so token/branch fields (which call this on every keystroke)
|
||||
// don't hit the remote API on every character typed.
|
||||
private scheduleConnectionTest(): void {
|
||||
if (this.connectionTestTimer) {
|
||||
window.clearTimeout(this.connectionTestTimer);
|
||||
}
|
||||
this.connectionTestTimer = window.setTimeout(() => {
|
||||
this.connectionTestTimer = null;
|
||||
void this.plugin.testConnection();
|
||||
}, CONNECTION_TEST_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
private renderSettings(containerEl: HTMLElement): void {
|
||||
containerEl.empty();
|
||||
|
||||
this.renderWhatsNewBanner(containerEl);
|
||||
this.renderConnectionStatus(containerEl);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Git service')
|
||||
.setDesc('Choose your Git hosting service')
|
||||
.setName(t('settings.language.name'))
|
||||
.setDesc(t('settings.language.desc'))
|
||||
.addDropdown(dropdown => dropdown
|
||||
.addOption('system', t('settings.language.option.system'))
|
||||
.addOption('en', t('settings.language.option.en'))
|
||||
.addOption('zh-tw', t('settings.language.option.zhTw'))
|
||||
.addOption('zh-cn', t('settings.language.option.zhCn'))
|
||||
.setValue(this.plugin.settings.language)
|
||||
.onChange((value: string) => {
|
||||
this.plugin.settings.language = value as LanguageSetting;
|
||||
void this.plugin.saveSettings();
|
||||
setLanguageOverride(this.plugin.settings.language);
|
||||
this.refresh();
|
||||
}));
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t('settings.gitService.name'))
|
||||
.setDesc(t('settings.gitService.desc'))
|
||||
.addDropdown(dropdown => dropdown
|
||||
.addOption('gitlab', 'GitLab')
|
||||
.addOption('github', 'GitHub')
|
||||
|
|
@ -149,52 +275,70 @@ export class GitLabSyncSettingTab extends PluginSettingTab {
|
|||
}
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Branch')
|
||||
.setDesc('Branch to push or pull from')
|
||||
.setName(t('settings.branch.name'))
|
||||
.setDesc(t('settings.branch.desc'))
|
||||
.addText(text => text
|
||||
.setPlaceholder('Main')
|
||||
.setPlaceholder(t('settings.branch.placeholder'))
|
||||
.setValue(this.plugin.settings.branch)
|
||||
.onChange((value) => {
|
||||
this.plugin.settings.branch = value || 'main';
|
||||
void this.plugin.saveSettings();
|
||||
this.scheduleConnectionTest();
|
||||
}));
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Root path')
|
||||
.setDesc('Optional: subfolder in repository (e.g. "notes")')
|
||||
.addText(text => text
|
||||
.setPlaceholder('Enter subfolder path')
|
||||
.setValue(this.plugin.settings.rootPath)
|
||||
.onChange((value) => {
|
||||
this.plugin.settings.rootPath = value.replace(/^\/|\/$/g, '');
|
||||
void this.plugin.saveSettings();
|
||||
this.plugin.initializeGitService();
|
||||
}));
|
||||
.setName(t('settings.rootPath.name'))
|
||||
.setDesc(t('settings.rootPath.desc'))
|
||||
.addText(text => {
|
||||
text.setPlaceholder(t('settings.rootPath.placeholder'))
|
||||
.setValue(this.plugin.settings.rootPath)
|
||||
.onChange((value) => {
|
||||
this.plugin.settings.rootPath = value.replace(/^\/|\/$/g, '');
|
||||
void this.plugin.saveSettings();
|
||||
this.plugin.initializeGitService();
|
||||
});
|
||||
RemoteFolderSuggest.attach(this.app, text.inputEl, this.plugin);
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Vault folder')
|
||||
.setDesc('Optional: only sync files in this vault folder (e.g. "sync" to only sync files in the sync folder)')
|
||||
.addText(text => text
|
||||
.setPlaceholder('Leave empty to sync all files')
|
||||
.setValue(this.plugin.settings.vaultFolder)
|
||||
.onChange((value) => {
|
||||
this.plugin.settings.vaultFolder = value.replace(/^\/|\/$/g, '');
|
||||
void this.plugin.saveSettings();
|
||||
}));
|
||||
.setName(t('settings.vaultFolder.name'))
|
||||
.setDesc(t('settings.vaultFolder.desc'))
|
||||
.addText(text => {
|
||||
text.setPlaceholder(t('settings.vaultFolder.placeholder'))
|
||||
.setValue(this.plugin.settings.vaultFolder)
|
||||
.onChange((value) => {
|
||||
this.plugin.settings.vaultFolder = value.replace(/^\/|\/$/g, '');
|
||||
void this.plugin.saveSettings();
|
||||
});
|
||||
FolderSuggest.attach(this.app, text.inputEl);
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName(t('settings.ignorePatterns.name'))
|
||||
.setDesc(t('settings.ignorePatterns.desc'))
|
||||
.addTextArea(text => {
|
||||
text.setPlaceholder(`${this.app.vault.configDir}/\n*.tmp`)
|
||||
.setValue(this.plugin.settings.ignorePatterns)
|
||||
.onChange((value) => {
|
||||
this.plugin.settings.ignorePatterns = value;
|
||||
void this.plugin.saveSettings();
|
||||
});
|
||||
text.inputEl.rows = 4;
|
||||
});
|
||||
|
||||
// "Real symlink" needs the Git Data API, which only GitHub offers. For
|
||||
// other providers, offer follow/skip only so the option can't mislead.
|
||||
const supportsRealSymlink = this.plugin.settings.serviceType === 'github';
|
||||
new Setting(containerEl)
|
||||
.setName('Symbolic links')
|
||||
.setName(t('settings.symlinks.name'))
|
||||
.setDesc(supportsRealSymlink
|
||||
? 'How to sync symlinks: "real" recreates the link on desktop and falls back to the target content on mobile, "follow" always syncs the target content, and "skip" ignores symlinks.'
|
||||
: 'How to sync symlinks: "follow" syncs the target content as a normal file, "skip" ignores symlinks. Real symlinks require GitHub.')
|
||||
? t('settings.symlinks.desc.supported')
|
||||
: t('settings.symlinks.desc.unsupported'))
|
||||
.addDropdown(dropdown => {
|
||||
if (supportsRealSymlink) dropdown.addOption('real', 'Real symlink (recommended)');
|
||||
if (supportsRealSymlink) dropdown.addOption('real', t('settings.symlinks.option.real'));
|
||||
dropdown
|
||||
.addOption('follow', 'Follow (sync target content)')
|
||||
.addOption('skip', 'Skip')
|
||||
.addOption('follow', t('settings.symlinks.option.follow'))
|
||||
.addOption('skip', t('settings.symlinks.option.skip'))
|
||||
.setValue(getEffectiveSymlinkHandling(this.plugin.settings))
|
||||
.onChange((value: string) => {
|
||||
this.plugin.settings.symlinkHandling = value as SymlinkHandling;
|
||||
|
|
@ -203,29 +347,30 @@ export class GitLabSyncSettingTab extends PluginSettingTab {
|
|||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Test connection')
|
||||
.setDesc(`Verify your ${getServiceName(this.plugin.settings)} settings`)
|
||||
.setName(t('settings.testConnection.name'))
|
||||
.setDesc(t('settings.testConnection.desc', { service: getServiceName(this.plugin.settings) }))
|
||||
.addButton(button => button
|
||||
.setButtonText('Test connection')
|
||||
.setButtonText(t('settings.testConnection.button'))
|
||||
.onClick(async () => {
|
||||
try {
|
||||
const result = await this.plugin.gitService.testConnection(this.plugin.settings.branch);
|
||||
const result = await this.plugin.testConnection();
|
||||
if (!result.repoOk) {
|
||||
new Notice(`Connection failed: ${result.error ?? 'could not reach the repository'}`);
|
||||
new Notice(t('settings.testConnection.failed', { reason: result.error ?? t('settings.testConnection.failed.unreachable') }));
|
||||
} else if (!result.branchOk) {
|
||||
new Notice(
|
||||
`Connected, but branch "${this.plugin.settings.branch}" was not found. ` +
|
||||
'Check the Branch setting, or confirm the repository has a branch with this name.',
|
||||
t('settings.testConnection.branchNotFound.notice', { branch: this.plugin.settings.branch }),
|
||||
8000
|
||||
);
|
||||
} else {
|
||||
new Notice(`${getServiceName(this.plugin.settings)} connection successful!`);
|
||||
new Notice(t('settings.testConnection.success', { service: getServiceName(this.plugin.settings) }));
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
new Notice(`Connection failed: ${message}`);
|
||||
new Notice(t('settings.testConnection.failed', { reason: message }));
|
||||
}
|
||||
}));
|
||||
|
||||
this.scheduleConnectionTest();
|
||||
}
|
||||
|
||||
// Token fields are masked like a password input (with a toggle to reveal
|
||||
|
|
@ -239,18 +384,18 @@ export class GitLabSyncSettingTab extends PluginSettingTab {
|
|||
.addText(text => {
|
||||
textComponent = text;
|
||||
text.inputEl.type = 'password';
|
||||
text.setPlaceholder('Enter your token')
|
||||
text.setPlaceholder(t('settings.token.placeholder'))
|
||||
.setValue(getValue())
|
||||
.onChange(onChange);
|
||||
})
|
||||
.addExtraButton(btn => {
|
||||
btn.setIcon('eye')
|
||||
.setTooltip('Show token')
|
||||
.setTooltip(t('settings.token.show'))
|
||||
.onClick(() => {
|
||||
const revealing = textComponent.inputEl.type === 'password';
|
||||
textComponent.inputEl.type = revealing ? 'text' : 'password';
|
||||
btn.setIcon(revealing ? 'eye-off' : 'eye');
|
||||
btn.setTooltip(revealing ? 'Hide token' : 'Show token');
|
||||
btn.setTooltip(revealing ? t('settings.token.hide') : t('settings.token.show'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -258,19 +403,20 @@ export class GitLabSyncSettingTab extends PluginSettingTab {
|
|||
private displayGitLabSettings(containerEl: HTMLElement): void {
|
||||
this.addTokenSetting(
|
||||
containerEl,
|
||||
'GitLab personal access token',
|
||||
'Create a token in GitLab user settings > access tokens with "API" scope',
|
||||
t('settings.gitlab.token.name'),
|
||||
t('settings.gitlab.token.desc'),
|
||||
() => this.plugin.settings.gitlabToken,
|
||||
(value) => {
|
||||
this.plugin.settings.gitlabToken = value;
|
||||
void this.plugin.saveSettings();
|
||||
this.plugin.initializeGitService();
|
||||
this.scheduleConnectionTest();
|
||||
}
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('GitLab base URL')
|
||||
.setDesc('Defaults to https://gitlab.com')
|
||||
.setName(t('settings.gitlab.baseUrl.name'))
|
||||
.setDesc(t('settings.gitlab.baseUrl.desc'))
|
||||
.addText(text => text
|
||||
.setPlaceholder('https://gitlab.com')
|
||||
.setValue(this.plugin.settings.gitlabBaseUrl)
|
||||
|
|
@ -278,37 +424,40 @@ export class GitLabSyncSettingTab extends PluginSettingTab {
|
|||
this.plugin.settings.gitlabBaseUrl = value || 'https://gitlab.com';
|
||||
void this.plugin.saveSettings();
|
||||
this.plugin.initializeGitService();
|
||||
this.scheduleConnectionTest();
|
||||
}));
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Project ID')
|
||||
.setDesc('Found in GitLab project overview')
|
||||
.setName(t('settings.gitlab.projectId.name'))
|
||||
.setDesc(t('settings.gitlab.projectId.desc'))
|
||||
.addText(text => text
|
||||
.setPlaceholder('Enter numeric project ID')
|
||||
.setPlaceholder(t('settings.gitlab.projectId.placeholder'))
|
||||
.setValue(this.plugin.settings.projectId)
|
||||
.onChange((value) => {
|
||||
this.plugin.settings.projectId = value;
|
||||
void this.plugin.saveSettings();
|
||||
this.plugin.initializeGitService();
|
||||
this.scheduleConnectionTest();
|
||||
}));
|
||||
}
|
||||
|
||||
private displayGiteaSettings(containerEl: HTMLElement): void {
|
||||
this.addTokenSetting(
|
||||
containerEl,
|
||||
'Gitea personal access token',
|
||||
'Create a token in user settings > applications > access tokens',
|
||||
t('settings.gitea.token.name'),
|
||||
t('settings.gitea.token.desc'),
|
||||
() => this.plugin.settings.giteaToken,
|
||||
(value) => {
|
||||
this.plugin.settings.giteaToken = value;
|
||||
void this.plugin.saveSettings();
|
||||
this.plugin.initializeGitService();
|
||||
this.scheduleConnectionTest();
|
||||
}
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Gitea base URL')
|
||||
.setDesc('URL of your Gitea instance (e.g. https://gitea.example.com)')
|
||||
.setName(t('settings.gitea.baseUrl.name'))
|
||||
.setDesc(t('settings.gitea.baseUrl.desc'))
|
||||
.addText(text => text
|
||||
.setPlaceholder('https://gitea.example.com')
|
||||
.setValue(this.plugin.settings.giteaBaseUrl)
|
||||
|
|
@ -316,68 +465,74 @@ export class GitLabSyncSettingTab extends PluginSettingTab {
|
|||
this.plugin.settings.giteaBaseUrl = value;
|
||||
void this.plugin.saveSettings();
|
||||
this.plugin.initializeGitService();
|
||||
this.scheduleConnectionTest();
|
||||
}));
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Repository owner')
|
||||
.setDesc('Gitea username or organization name')
|
||||
.setName(t('settings.repoOwner.name'))
|
||||
.setDesc(t('settings.repoOwner.desc.gitea'))
|
||||
.addText(text => text
|
||||
.setPlaceholder('Username')
|
||||
.setPlaceholder(t('settings.repoOwner.placeholder'))
|
||||
.setValue(this.plugin.settings.giteaOwner)
|
||||
.onChange((value) => {
|
||||
this.plugin.settings.giteaOwner = value;
|
||||
void this.plugin.saveSettings();
|
||||
this.plugin.initializeGitService();
|
||||
this.scheduleConnectionTest();
|
||||
}));
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Repository name')
|
||||
.setDesc('Name of the repository')
|
||||
.setName(t('settings.repoName.name'))
|
||||
.setDesc(t('settings.repoName.desc.gitea'))
|
||||
.addText(text => text
|
||||
.setPlaceholder('My notes')
|
||||
.setPlaceholder(t('settings.repoName.placeholder'))
|
||||
.setValue(this.plugin.settings.giteaRepo)
|
||||
.onChange((value) => {
|
||||
this.plugin.settings.giteaRepo = value;
|
||||
void this.plugin.saveSettings();
|
||||
this.plugin.initializeGitService();
|
||||
this.scheduleConnectionTest();
|
||||
}));
|
||||
}
|
||||
|
||||
private displayGitHubSettings(containerEl: HTMLElement): void {
|
||||
this.addTokenSetting(
|
||||
containerEl,
|
||||
'GitHub personal access token',
|
||||
'Create a token in GitHub settings > developer settings > personal access tokens with "repo" scope',
|
||||
t('settings.github.token.name'),
|
||||
t('settings.github.token.desc'),
|
||||
() => this.plugin.settings.githubToken,
|
||||
(value) => {
|
||||
this.plugin.settings.githubToken = value;
|
||||
void this.plugin.saveSettings();
|
||||
this.plugin.initializeGitService();
|
||||
this.scheduleConnectionTest();
|
||||
}
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Repository owner')
|
||||
.setDesc('GitHub username or organization name')
|
||||
.setName(t('settings.repoOwner.name'))
|
||||
.setDesc(t('settings.repoOwner.desc.github'))
|
||||
.addText(text => text
|
||||
.setPlaceholder('Username')
|
||||
.setPlaceholder(t('settings.repoOwner.placeholder'))
|
||||
.setValue(this.plugin.settings.githubOwner)
|
||||
.onChange((value) => {
|
||||
this.plugin.settings.githubOwner = value;
|
||||
void this.plugin.saveSettings();
|
||||
this.plugin.initializeGitService();
|
||||
this.scheduleConnectionTest();
|
||||
}));
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Repository name')
|
||||
.setDesc('Name of the GitHub repository')
|
||||
.setName(t('settings.repoName.name'))
|
||||
.setDesc(t('settings.repoName.desc.github'))
|
||||
.addText(text => text
|
||||
.setPlaceholder('My notes')
|
||||
.setPlaceholder(t('settings.repoName.placeholder'))
|
||||
.setValue(this.plugin.settings.githubRepo)
|
||||
.onChange((value) => {
|
||||
this.plugin.settings.githubRepo = value;
|
||||
void this.plugin.saveSettings();
|
||||
this.plugin.initializeGitService();
|
||||
this.scheduleConnectionTest();
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { App, Modal, ButtonComponent } from 'obsidian';
|
||||
import { t } from '../i18n';
|
||||
|
||||
export class ConfirmModal extends Modal {
|
||||
private readonly message: string;
|
||||
|
|
@ -14,20 +15,20 @@ export class ConfirmModal extends Modal {
|
|||
|
||||
onOpen() {
|
||||
const { contentEl } = this;
|
||||
contentEl.createEl('h3', { text: 'Confirm' });
|
||||
contentEl.createEl('h3', { text: t('confirmModal.title') });
|
||||
contentEl.createEl('p', { text: this.message });
|
||||
|
||||
const buttonContainer = contentEl.createDiv({ cls: 'ssv-confirm-buttons modal-button-container' });
|
||||
|
||||
new ButtonComponent(buttonContainer)
|
||||
.setButtonText('Cancel')
|
||||
.setButtonText(t('confirmModal.cancel'))
|
||||
.onClick(() => {
|
||||
this.close();
|
||||
if (this.onCancel) this.onCancel();
|
||||
});
|
||||
|
||||
new ButtonComponent(buttonContainer)
|
||||
.setButtonText('Confirm')
|
||||
.setButtonText(t('confirmModal.confirm'))
|
||||
.setCta()
|
||||
.onClick(() => {
|
||||
this.close();
|
||||
|
|
|
|||
38
src/ui/FolderSuggest.ts
Normal file
38
src/ui/FolderSuggest.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import {AbstractInputSuggest, App, TFolder} from 'obsidian';
|
||||
|
||||
/**
|
||||
* Type-ahead folder suggester for a settings text input. Suggests existing
|
||||
* vault folders but never forces a selection, since callers (e.g. the "Root
|
||||
* path" repo setting) may need to accept a path that doesn't exist locally.
|
||||
*/
|
||||
export class FolderSuggest extends AbstractInputSuggest<TFolder> {
|
||||
constructor(app: App, private readonly inputEl: HTMLInputElement) {
|
||||
super(app, inputEl);
|
||||
}
|
||||
|
||||
protected getSuggestions(query: string): TFolder[] {
|
||||
const lowerQuery = query.toLowerCase();
|
||||
return this.app.vault.getAllFolders(true)
|
||||
.filter(folder => folder.path.toLowerCase().contains(lowerQuery))
|
||||
.sort((a, b) => a.path.localeCompare(b.path));
|
||||
}
|
||||
|
||||
renderSuggestion(folder: TFolder, el: HTMLElement): void {
|
||||
el.setText(folder.path === '/' ? '/' : folder.path);
|
||||
}
|
||||
|
||||
selectSuggestion(folder: TFolder): void {
|
||||
const path = folder.path === '/' ? '' : folder.path;
|
||||
this.setValue(path);
|
||||
// TextComponent listens for the native "input" event to fire onChange,
|
||||
// so dispatch one to trigger the existing save/initializeGitService flow.
|
||||
this.inputEl.dispatchEvent(new Event('input'));
|
||||
this.close();
|
||||
}
|
||||
|
||||
/** Attaches a FolderSuggest to `inputEl`; the instance self-registers via the base class, so the caller has nothing to hold onto. */
|
||||
static attach(app: App, inputEl: HTMLInputElement): void {
|
||||
// eslint-disable-next-line sonarjs/constructor-for-side-effects -- AbstractInputSuggest wires itself to inputEl in its constructor; there's nothing to assign.
|
||||
new FolderSuggest(app, inputEl);
|
||||
}
|
||||
}
|
||||
64
src/ui/RemoteFolderSuggest.ts
Normal file
64
src/ui/RemoteFolderSuggest.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import {AbstractInputSuggest, App} from 'obsidian';
|
||||
import GitLabFilesPush from '../main';
|
||||
|
||||
/**
|
||||
* Type-ahead folder suggester for the "Root path" setting. Unlike FolderSuggest
|
||||
* (which lists local vault folders), this lists folders that actually exist in
|
||||
* the configured remote repository, since Root path is a repo-side path and has
|
||||
* no relationship to the local vault's folder structure.
|
||||
*/
|
||||
export class RemoteFolderSuggest extends AbstractInputSuggest<string> {
|
||||
private cachedFolders: string[] | null = null;
|
||||
|
||||
constructor(app: App, private readonly inputEl: HTMLInputElement, private readonly plugin: GitLabFilesPush) {
|
||||
super(app, inputEl);
|
||||
}
|
||||
|
||||
private async loadFolders(): Promise<string[]> {
|
||||
if (this.cachedFolders) return this.cachedFolders;
|
||||
|
||||
const paths = await this.plugin.gitService.listFiles(this.plugin.settings.branch, false);
|
||||
const folders = new Set<string>();
|
||||
for (const path of paths) {
|
||||
const parts = path.split('/');
|
||||
parts.pop(); // drop the filename itself
|
||||
let acc = '';
|
||||
for (const part of parts) {
|
||||
acc = acc ? `${acc}/${part}` : part;
|
||||
folders.add(acc);
|
||||
}
|
||||
}
|
||||
|
||||
this.cachedFolders = Array.from(folders).sort((a, b) => a.localeCompare(b));
|
||||
return this.cachedFolders;
|
||||
}
|
||||
|
||||
protected async getSuggestions(query: string): Promise<string[]> {
|
||||
const lowerQuery = query.toLowerCase();
|
||||
let folders: string[];
|
||||
try {
|
||||
folders = await this.loadFolders();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return folders.filter(folder => folder.toLowerCase().contains(lowerQuery));
|
||||
}
|
||||
|
||||
renderSuggestion(folder: string, el: HTMLElement): void {
|
||||
el.setText(folder);
|
||||
}
|
||||
|
||||
selectSuggestion(folder: string): void {
|
||||
this.setValue(folder);
|
||||
// TextComponent listens for the native "input" event to fire onChange,
|
||||
// so dispatch one to trigger the existing save/initializeGitService flow.
|
||||
this.inputEl.dispatchEvent(new Event('input'));
|
||||
this.close();
|
||||
}
|
||||
|
||||
/** Attaches a RemoteFolderSuggest to `inputEl`; the instance self-registers via the base class, so the caller has nothing to hold onto. */
|
||||
static attach(app: App, inputEl: HTMLInputElement, plugin: GitLabFilesPush): void {
|
||||
// eslint-disable-next-line sonarjs/constructor-for-side-effects -- AbstractInputSuggest wires itself to inputEl in its constructor; there's nothing to assign.
|
||||
new RemoteFolderSuggest(app, inputEl, plugin);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,7 @@
|
|||
import { App, Modal, Setting } from 'obsidian';
|
||||
import { t } from '../i18n';
|
||||
|
||||
type ConflictPanelName = 'diff' | 'local' | 'remote';
|
||||
|
||||
/**
|
||||
* Apply the "destructive" button style, but only when the running Obsidian
|
||||
|
|
@ -33,49 +36,78 @@ export class SyncConflictModal extends Modal {
|
|||
const { contentEl } = this;
|
||||
contentEl.addClass('sync-conflict-modal');
|
||||
|
||||
contentEl.createEl('h2', { text: `Conflict in ${this.fileName}` });
|
||||
contentEl.createEl('h2', { text: t('syncConflictModal.title', { fileName: this.fileName }) });
|
||||
contentEl.createEl('p', {
|
||||
text: 'The remote file has different content. Review the differences and choose which version to keep.',
|
||||
text: t('syncConflictModal.description'),
|
||||
cls: 'conflict-description'
|
||||
});
|
||||
|
||||
const diffContainer = contentEl.createDiv({ cls: 'conflict-diff-container' });
|
||||
const panels = {} as Record<ConflictPanelName, HTMLElement>;
|
||||
const tabs = {} as Record<ConflictPanelName, HTMLElement>;
|
||||
|
||||
const localSection = diffContainer.createDiv({ cls: 'conflict-section' });
|
||||
localSection.createEl('h3', { text: 'Local version' });
|
||||
const setActivePanel = (name: ConflictPanelName) => {
|
||||
(Object.keys(panels) as ConflictPanelName[]).forEach(key => {
|
||||
panels[key].toggleClass('is-active', key === name);
|
||||
tabs[key].toggleClass('is-active', key === name);
|
||||
});
|
||||
};
|
||||
|
||||
const tabsContainer = contentEl.createDiv({ cls: 'conflict-tabs' });
|
||||
const tabLabels: Record<ConflictPanelName, string> = {
|
||||
diff: t('syncConflictModal.tab.diff'),
|
||||
local: t('syncConflictModal.tab.local'),
|
||||
remote: t('syncConflictModal.tab.remote')
|
||||
};
|
||||
(['diff', 'local', 'remote'] as const).forEach(name => {
|
||||
const tab = tabsContainer.createEl('button', { text: tabLabels[name], cls: 'conflict-tab' });
|
||||
tab.addEventListener('click', () => setActivePanel(name));
|
||||
tabs[name] = tab;
|
||||
});
|
||||
|
||||
const contentArea = contentEl.createDiv({ cls: 'conflict-content-area' });
|
||||
|
||||
const diffContainer = contentArea.createDiv({ cls: 'conflict-diff-container' });
|
||||
|
||||
const localSection = diffContainer.createDiv({ cls: 'conflict-section conflict-panel' });
|
||||
localSection.createEl('h3', { text: t('syncConflictModal.localVersion') });
|
||||
const localPre = localSection.createEl('pre', { cls: 'conflict-content' });
|
||||
localPre.createEl('code', { text: this.localContent });
|
||||
panels.local = localSection;
|
||||
|
||||
const remoteSection = diffContainer.createDiv({ cls: 'conflict-section' });
|
||||
remoteSection.createEl('h3', { text: 'Remote version' });
|
||||
const remoteSection = diffContainer.createDiv({ cls: 'conflict-section conflict-panel' });
|
||||
remoteSection.createEl('h3', { text: t('syncConflictModal.remoteVersion') });
|
||||
const remotePre = remoteSection.createEl('pre', { cls: 'conflict-content' });
|
||||
remotePre.createEl('code', { text: this.remoteContent });
|
||||
panels.remote = remoteSection;
|
||||
|
||||
const diffSection = contentEl.createDiv({ cls: 'conflict-diff-section' });
|
||||
diffSection.createEl('h3', { text: 'Differences' });
|
||||
const diffSection = contentArea.createDiv({ cls: 'conflict-diff-section conflict-panel' });
|
||||
diffSection.createEl('h3', { text: t('syncConflictModal.differences') });
|
||||
const diffPre = diffSection.createEl('pre', { cls: 'conflict-diff' });
|
||||
this.renderDiff(diffPre);
|
||||
panels.diff = diffSection;
|
||||
|
||||
setActivePanel('diff');
|
||||
|
||||
const buttonContainer = contentEl.createDiv({ cls: 'conflict-buttons' });
|
||||
|
||||
new Setting(buttonContainer)
|
||||
.addButton(btn => btn
|
||||
.setButtonText('Keep local')
|
||||
.setTooltip('Overwrite remote with your local content')
|
||||
.setButtonText(t('syncConflictModal.keepLocal'))
|
||||
.setTooltip(t('syncConflictModal.keepLocal.tooltip'))
|
||||
.setCta()
|
||||
.onClick(() => {
|
||||
this.onChoose('local');
|
||||
this.close();
|
||||
}))
|
||||
.addButton(btn => applyDestructiveStyle(btn)
|
||||
.setButtonText('Keep remote')
|
||||
.setTooltip('Overwrite local with remote content')
|
||||
.setButtonText(t('syncConflictModal.keepRemote'))
|
||||
.setTooltip(t('syncConflictModal.keepRemote.tooltip'))
|
||||
.onClick(() => {
|
||||
this.onChoose('remote');
|
||||
this.close();
|
||||
}))
|
||||
.addButton(btn => btn
|
||||
.setButtonText('Cancel')
|
||||
.setButtonText(t('syncConflictModal.cancel'))
|
||||
.onClick(() => {
|
||||
this.close();
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { ItemView, WorkspaceLeaf, TFile, Notice, Platform, setIcon, setTooltip } from 'obsidian';
|
||||
import GitLabFilesPush from '../main';
|
||||
import { getServiceName, getEffectiveSymlinkHandling } from '../settings';
|
||||
import { getServiceName, getEffectiveSymlinkHandling, type SymlinkHandling } from '../settings';
|
||||
import { ConfirmModal } from './ConfirmModal';
|
||||
import { logger } from '../utils/logger';
|
||||
import { type FileStatus, type FilterValue } from './types';
|
||||
|
|
@ -8,6 +8,12 @@ import { renderActionBar } from './components/ActionBar';
|
|||
import { renderFileItem, statusMeta, type FileItemCallbacks } from './components/FileListItem';
|
||||
import { ICONS } from './components/icons';
|
||||
import { isBinaryPath, contentsEqual } from '../utils/path';
|
||||
import { readLocalSymlinkTarget } from '../utils/symlink';
|
||||
import { gitBlobSha } from '../utils/git-blob-sha';
|
||||
import { type GitTreeEntry } from '../services/git-service-interface';
|
||||
import { MAX_BATCH_PUSH_SIZE } from '../services/git-service-base';
|
||||
import { t, type TranslationKey } from '../i18n';
|
||||
import { type PushResults } from '../logic/sync-manager';
|
||||
|
||||
export const SYNC_STATUS_VIEW_TYPE = 'sync-status-view';
|
||||
|
||||
|
|
@ -26,7 +32,7 @@ export class SyncStatusView extends ItemView {
|
|||
}
|
||||
|
||||
getViewType(): string { return SYNC_STATUS_VIEW_TYPE; }
|
||||
getDisplayText(): string { return 'Sync status'; }
|
||||
getDisplayText(): string { return t('syncStatus.viewTitle'); }
|
||||
getIcon(): string { return 'git-compare'; }
|
||||
|
||||
onOpen(): Promise<void> {
|
||||
|
|
@ -41,6 +47,10 @@ export class SyncStatusView extends ItemView {
|
|||
private renderView(): void {
|
||||
const container = this.containerEl.children[1] as HTMLElement;
|
||||
if (!container) return;
|
||||
|
||||
const prevListEl = container.querySelector<HTMLElement>('.ssv-list');
|
||||
const scrollTop = prevListEl?.scrollTop ?? 0;
|
||||
|
||||
container.empty();
|
||||
|
||||
this.renderInfoStrip(container);
|
||||
|
|
@ -53,10 +63,12 @@ export class SyncStatusView extends ItemView {
|
|||
this.renderProgressBar(listEl);
|
||||
this.renderCheckedFilesDuringRefresh(listEl);
|
||||
} else if (this.fileStatuses.size === 0) {
|
||||
listEl.createDiv({ cls: 'ssv-empty', text: 'Click "Refresh" to check sync status' });
|
||||
listEl.createDiv({ cls: 'ssv-empty', text: t('syncStatus.emptyPrompt') });
|
||||
} else {
|
||||
this.renderFileList(listEl);
|
||||
}
|
||||
|
||||
listEl.scrollTop = scrollTop;
|
||||
}
|
||||
|
||||
private renderProgressBar(container: HTMLElement): void {
|
||||
|
|
@ -65,7 +77,7 @@ export class SyncStatusView extends ItemView {
|
|||
const prog = container.createDiv({ cls: 'ssv-progress' });
|
||||
prog.createDiv({
|
||||
cls: 'ssv-progress-text',
|
||||
text: total > 0 ? `Checking files… ${current}/${total} (${pct}%)` : 'Checking files…'
|
||||
text: total > 0 ? t('syncStatus.progress.checkingWithCount', { current, total, pct }) : t('syncStatus.progress.checking')
|
||||
});
|
||||
const bar = prog.createDiv({ cls: 'ssv-progress-bar' });
|
||||
bar.createDiv({ cls: 'ssv-progress-fill' }).setAttr('style', `width: ${pct}%`);
|
||||
|
|
@ -111,7 +123,7 @@ export class SyncStatusView extends ItemView {
|
|||
cls: 'ssv-info-time',
|
||||
text: Platform.isMobile
|
||||
? new Date(this.lastSyncTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
: `Last sync: ${new Date(this.lastSyncTime).toLocaleTimeString()}`
|
||||
: t('syncStatus.lastSync', { time: new Date(this.lastSyncTime).toLocaleTimeString() })
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -129,11 +141,11 @@ export class SyncStatusView extends ItemView {
|
|||
};
|
||||
|
||||
const tabs: Array<{ value: FilterValue; label: string }> = [
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'synced', label: 'Synced' },
|
||||
{ value: 'modified', label: 'Changed' },
|
||||
{ value: 'unsynced', label: 'Local only' },
|
||||
{ value: 'remote-only', label: 'Remote' },
|
||||
{ value: 'all', label: t('syncStatus.tab.all') },
|
||||
{ value: 'synced', label: t('syncStatus.tab.synced') },
|
||||
{ value: 'modified', label: t('syncStatus.tab.modified') },
|
||||
{ value: 'unsynced', label: t('syncStatus.tab.unsynced') },
|
||||
{ value: 'remote-only', label: t('syncStatus.tab.remote-only') },
|
||||
];
|
||||
|
||||
const tabsEl = container.createDiv({ cls: 'ssv-tabs' });
|
||||
|
|
@ -202,9 +214,26 @@ export class SyncStatusView extends ItemView {
|
|||
onPush: (fs) => void this.runSingleFile(fs, 'push'),
|
||||
onPull: (fs) => void this.runSingleFile(fs, 'pull'),
|
||||
onDelete: (fs) => void this.handleLocalDelete(fs),
|
||||
onExpandDiff: (fs) => this.loadDiffContent(fs),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily fetches a modified file's remote content by SHA (Phase 2 of the
|
||||
* SHA-based refresh) so the diff panel has something to render. Mutates
|
||||
* the FileStatus object in place, caching the result on the same instance
|
||||
* held in this.fileStatuses so re-expanding doesn't refetch.
|
||||
*/
|
||||
private async loadDiffContent(fileStatus: FileStatus): Promise<void> {
|
||||
if (fileStatus.remoteContent !== undefined || !fileStatus.remoteSha) return;
|
||||
try {
|
||||
const blob = await this.plugin.gitService.getBlob(fileStatus.remoteSha, fileStatus.path);
|
||||
fileStatus.remoteContent = blob.content;
|
||||
} catch (e) {
|
||||
logger.warn(`Failed to load diff content for ${fileStatus.path}`, e);
|
||||
}
|
||||
}
|
||||
|
||||
private renderFileList(container: HTMLElement): void {
|
||||
const all = Array.from(this.fileStatuses.values());
|
||||
const statuses = this.statusFilter === 'all'
|
||||
|
|
@ -212,7 +241,8 @@ export class SyncStatusView extends ItemView {
|
|||
: all.filter(s => s.status === this.statusFilter);
|
||||
|
||||
if (statuses.length === 0) {
|
||||
container.createDiv({ cls: 'ssv-empty', text: `No ${this.statusFilter} files` });
|
||||
const filterLabel = this.statusFilter === 'all' ? t('syncStatus.tab.all') : statusMeta(this.statusFilter).label;
|
||||
container.createDiv({ cls: 'ssv-empty', text: t('syncStatus.noFilesForFilter', { filter: filterLabel }) });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -225,7 +255,7 @@ export class SyncStatusView extends ItemView {
|
|||
// ── Single-file operations ──────────────────────────────────────
|
||||
|
||||
private async handleLocalDelete(fileStatus: FileStatus): Promise<void> {
|
||||
const confirmed = await this.showConfirmDialog(`Delete local file "${fileStatus.path}"? Handled per your vault's "Deleted files" setting.`);
|
||||
const confirmed = await this.showConfirmDialog(t('syncStatus.confirmDeleteLocal', { path: fileStatus.path }));
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
if (fileStatus.file) {
|
||||
|
|
@ -234,11 +264,11 @@ export class SyncStatusView extends ItemView {
|
|||
await this.app.vault.adapter.remove(fileStatus.path);
|
||||
}
|
||||
await this.plugin.sync.clearMetadata(fileStatus.path);
|
||||
new Notice(`Deleted ${fileStatus.path}`);
|
||||
new Notice(t('syncStatus.notice.deleted', { path: fileStatus.path }));
|
||||
this.fileStatuses.delete(fileStatus.path);
|
||||
this.renderView();
|
||||
} catch (e) {
|
||||
new Notice(`Failed to delete: ${e instanceof Error ? e.message : String(e)}`);
|
||||
new Notice(t('syncStatus.notice.deleteFailed', { message: e instanceof Error ? e.message : String(e) }));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -254,11 +284,12 @@ export class SyncStatusView extends ItemView {
|
|||
}
|
||||
|
||||
await new Promise(r => window.setTimeout(r, 500));
|
||||
await this.refreshFileStatus(fileStatus.file || fileStatus.path);
|
||||
await this.refreshFileStatus(fileStatus.file || fileStatus.path, undefined);
|
||||
this.renderView();
|
||||
} catch (e) {
|
||||
new Notice(`${op === 'push' ? 'Push' : 'Pull'} failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
await this.refreshFileStatus(fileStatus.file || fileStatus.path);
|
||||
const verb = op === 'push' ? t('main.verb.push') : t('main.verb.pull');
|
||||
new Notice(t('syncStatus.notice.opFailed', { verb, message: e instanceof Error ? e.message : String(e) }));
|
||||
await this.refreshFileStatus(fileStatus.file || fileStatus.path, undefined);
|
||||
this.renderView();
|
||||
}
|
||||
}
|
||||
|
|
@ -267,7 +298,7 @@ export class SyncStatusView extends ItemView {
|
|||
|
||||
async refreshAllStatuses(): Promise<void> {
|
||||
if (this.isRefreshing) {
|
||||
new Notice('Already refreshing…');
|
||||
new Notice(t('syncStatus.notice.alreadyRefreshing'));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -288,16 +319,16 @@ export class SyncStatusView extends ItemView {
|
|||
this.renderView();
|
||||
|
||||
const filesToCheck = this.getCheckableFiles(files.local, extra, files.hiddenLocalPaths);
|
||||
await this.performStatusCheck(filesToCheck);
|
||||
await this.performStatusCheck(filesToCheck, files.remoteMap);
|
||||
|
||||
this.lastSyncTime = Date.now();
|
||||
this.isRefreshing = false; // Set to false BEFORE final renderView
|
||||
this.renderView();
|
||||
new Notice(`Checked ${files.local.length + files.hiddenLocalPaths.size} local + ${files.remoteMap.size} remote files`);
|
||||
new Notice(t('syncStatus.notice.refreshed', { local: files.local.length + files.hiddenLocalPaths.size, remote: files.remoteMap.size }));
|
||||
} catch (e) {
|
||||
this.isRefreshing = false;
|
||||
this.renderView();
|
||||
new Notice(`Failed to refresh: ${e instanceof Error ? e.message : String(e)}`);
|
||||
new Notice(t('syncStatus.notice.refreshFailed', { message: e instanceof Error ? e.message : String(e) }));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -309,7 +340,7 @@ export class SyncStatusView extends ItemView {
|
|||
await this.plugin.gitignoreManager.loadGitignores();
|
||||
|
||||
// Map remote paths to vault paths
|
||||
const remoteMap = new Map<string, string>(); // vaultPath -> remoteFullPath
|
||||
const remoteMap = new Map<string, GitTreeEntry>(); // vaultPath -> tree entry (path, symlink, sha)
|
||||
const skipSymlinks = getEffectiveSymlinkHandling(this.plugin.settings) === 'skip';
|
||||
for (const entry of remoteEntries) {
|
||||
if (entry.symlink && skipSymlinks) continue; // Symlink handling: skip
|
||||
|
|
@ -318,7 +349,7 @@ export class SyncStatusView extends ItemView {
|
|||
|
||||
const vaultPath = this.plugin.getVaultPath(normalized);
|
||||
if (!this.plugin.gitignoreManager.isIgnored(normalized)) {
|
||||
remoteMap.set(vaultPath, entry.path);
|
||||
remoteMap.set(vaultPath, entry);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -364,12 +395,25 @@ export class SyncStatusView extends ItemView {
|
|||
try {
|
||||
const listing = await this.app.vault.adapter.list(folderPath);
|
||||
for (const file of listing.files) {
|
||||
if (this.isHidden(file)) {
|
||||
if (!this.isHidden(file)) continue;
|
||||
// Guard against a symlinked folder being misclassified as a file
|
||||
// by the adapter's raw listing (Node's dirent type doesn't follow
|
||||
// links) — still track it as a link entry rather than a readable
|
||||
// file, same as a symlinked folder found via listing.folders below.
|
||||
if (readLocalSymlinkTarget(this.app, file) !== null || await this.isLocalFile(file)) {
|
||||
result.push(file);
|
||||
}
|
||||
}
|
||||
for (const folder of listing.folders) {
|
||||
if (folder === '.git' || folder.endsWith('/.git')) continue;
|
||||
// A symlinked folder is a single blob on the remote, not a real tree —
|
||||
// walking into it would scan whatever unrelated directory it points at.
|
||||
// Track the link itself (same as a hidden file) so push/pull can still
|
||||
// handle it via the existing symlink machinery, without recursing.
|
||||
if (readLocalSymlinkTarget(this.app, folder) !== null) {
|
||||
if (this.isHidden(folder)) result.push(folder);
|
||||
continue;
|
||||
}
|
||||
await this.recursiveScan(folder, result);
|
||||
}
|
||||
} catch { /* adapter may not support listing */ }
|
||||
|
|
@ -379,13 +423,19 @@ export class SyncStatusView extends ItemView {
|
|||
return path.split('/').some(part => part.startsWith('.'));
|
||||
}
|
||||
|
||||
/** True only for an actual local file — excludes real directories (and symlinks to one), which `adapter.stat()` follows. */
|
||||
private async isLocalFile(vaultPath: string): Promise<boolean> {
|
||||
const stat = await this.app.vault.adapter.stat(vaultPath);
|
||||
return stat?.type === 'file';
|
||||
}
|
||||
|
||||
private initializeFileStatuses(localFiles: TFile[]): void {
|
||||
for (const file of localFiles) {
|
||||
this.fileStatuses.set(file.path, { file, path: file.path, status: 'checking' });
|
||||
}
|
||||
}
|
||||
|
||||
private async identifyExtraFiles(remoteMap: Map<string, string>, localFilePaths: Set<string>, allLocalFileMap: Map<string, TFile>) {
|
||||
private async identifyExtraFiles(remoteMap: Map<string, GitTreeEntry>, localFilePaths: Set<string>, allLocalFileMap: Map<string, TFile>) {
|
||||
const extra: Array<TFile | string> = [];
|
||||
for (const [vaultPath] of remoteMap.entries()) {
|
||||
if (localFilePaths.has(vaultPath)) continue;
|
||||
|
|
@ -398,9 +448,13 @@ export class SyncStatusView extends ItemView {
|
|||
|
||||
if (localFile) {
|
||||
extra.push(localFile);
|
||||
} else if (await this.app.vault.adapter.exists(vaultPath)) {
|
||||
} else if (await this.isLocalFile(vaultPath)) {
|
||||
extra.push(vaultPath);
|
||||
} else {
|
||||
// Either nothing exists locally, or the remote's record (e.g. a
|
||||
// stale symlink push) now collides with a real local folder of
|
||||
// the same name — either way there's no readable local file to
|
||||
// compare, so it's remote-only.
|
||||
this.fileStatuses.set(vaultPath, { path: vaultPath, status: 'remote-only' });
|
||||
}
|
||||
}
|
||||
|
|
@ -431,7 +485,7 @@ export class SyncStatusView extends ItemView {
|
|||
private static readonly STATUS_CHECK_CONCURRENCY = 8;
|
||||
private static readonly RENDER_THROTTLE_MS = 150;
|
||||
|
||||
private async performStatusCheck(filesToCheck: Array<TFile | string>): Promise<void> {
|
||||
private async performStatusCheck(filesToCheck: Array<TFile | string>, remoteMap: Map<string, GitTreeEntry>): Promise<void> {
|
||||
const total = filesToCheck.length;
|
||||
this.refreshProgress = { current: 0, total };
|
||||
|
||||
|
|
@ -448,7 +502,10 @@ export class SyncStatusView extends ItemView {
|
|||
const worker = async (): Promise<void> => {
|
||||
while (next < total) {
|
||||
const file = filesToCheck[next++];
|
||||
if (file) await this.refreshFileStatus(file);
|
||||
if (file) {
|
||||
const path = typeof file === 'string' ? file : file.path;
|
||||
await this.refreshFileStatus(file, remoteMap.get(path));
|
||||
}
|
||||
this.refreshProgress.current++;
|
||||
maybeRender();
|
||||
}
|
||||
|
|
@ -459,22 +516,20 @@ export class SyncStatusView extends ItemView {
|
|||
maybeRender(true);
|
||||
}
|
||||
|
||||
private async refreshFileStatus(fileOrPath: TFile | string): Promise<void> {
|
||||
/**
|
||||
* Classifies a file's sync status. When the remote tree entry carries a git
|
||||
* blob SHA (the common case), this is a single local hash + comparison with
|
||||
* no network request (Phase 1 of the SHA-based refresh). Falls back to the
|
||||
* previous full-content comparison via getFile() when a tree entry is
|
||||
* missing a SHA, or wasn't found on the remote at all (new local file).
|
||||
*/
|
||||
private async refreshFileStatus(fileOrPath: TFile | string, remoteEntry: GitTreeEntry | undefined): Promise<void> {
|
||||
try {
|
||||
const isStr = typeof fileOrPath === 'string';
|
||||
const path = isStr ? fileOrPath : fileOrPath.path;
|
||||
const file = isStr ? undefined : fileOrPath;
|
||||
|
||||
const binary = this.isBinary(path);
|
||||
const localContent = await this.readFileContent(fileOrPath, binary, isStr);
|
||||
|
||||
// Important: Use SyncManager's logic which handles rootPath/vaultFolder mapping
|
||||
const repoPath = this.plugin.getNormalizedPath(path);
|
||||
const remote = await this.plugin.gitService.getFile(repoPath, this.plugin.settings.branch);
|
||||
|
||||
const { status, diff } = this.determineFileStatus(binary, localContent, remote);
|
||||
|
||||
this.fileStatuses.set(path, { file, path, status, localContent, remoteContent: remote.content, remoteSha: remote.sha, diff });
|
||||
if (remoteEntry?.sha !== undefined) {
|
||||
await this.refreshFileStatusBySha(fileOrPath, remoteEntry);
|
||||
} else {
|
||||
await this.refreshFileStatusByContent(fileOrPath);
|
||||
}
|
||||
} catch (e) {
|
||||
const path = typeof fileOrPath === 'string' ? fileOrPath : fileOrPath.path;
|
||||
logger.warn(`Failed to determine sync status for ${path}`, e);
|
||||
|
|
@ -486,11 +541,80 @@ export class SyncStatusView extends ItemView {
|
|||
}
|
||||
}
|
||||
|
||||
private async refreshFileStatusBySha(fileOrPath: TFile | string, remoteEntry: GitTreeEntry): Promise<void> {
|
||||
const isStr = typeof fileOrPath === 'string';
|
||||
const path = isStr ? fileOrPath : fileOrPath.path;
|
||||
const file = isStr ? undefined : fileOrPath;
|
||||
const binary = this.isBinary(path);
|
||||
|
||||
const symlinkMode = getEffectiveSymlinkHandling(this.plugin.settings);
|
||||
const localContent = await this.readLocalContentForSha(fileOrPath, isStr, binary, remoteEntry.symlink, symlinkMode);
|
||||
const localSha = await gitBlobSha(localContent);
|
||||
|
||||
const status = localSha === remoteEntry.sha ? 'synced' : 'modified';
|
||||
this.fileStatuses.set(path, {
|
||||
file, path, status, localContent,
|
||||
remoteSha: remoteEntry.sha,
|
||||
isSymlink: remoteEntry.symlink,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines what to hash locally so it's comparable to the remote blob SHA.
|
||||
* A symlink's blob content is its target path string, not the content it
|
||||
* points at, so "real" mode hashes the raw link target instead of following
|
||||
* it. "follow" mode (and "real" without an actual local OS symlink, e.g. on
|
||||
* mobile) always hashes the local file's content as read normally.
|
||||
*/
|
||||
private async readLocalContentForSha(
|
||||
fileOrPath: TFile | string, isStr: boolean, binary: boolean, remoteIsSymlink: boolean, symlinkMode: SymlinkHandling
|
||||
): Promise<string | ArrayBuffer> {
|
||||
if (remoteIsSymlink && symlinkMode === 'real') {
|
||||
const path = typeof fileOrPath === 'string' ? fileOrPath : fileOrPath.path;
|
||||
const target = readLocalSymlinkTarget(this.app, path);
|
||||
if (target !== null) return target;
|
||||
}
|
||||
return this.readFileContent(fileOrPath, binary, isStr);
|
||||
}
|
||||
|
||||
/** Fallback status check via full content fetch, for entries without a usable tree SHA. */
|
||||
private async refreshFileStatusByContent(fileOrPath: TFile | string): Promise<void> {
|
||||
const isStr = typeof fileOrPath === 'string';
|
||||
const path = isStr ? fileOrPath : fileOrPath.path;
|
||||
const file = isStr ? undefined : fileOrPath;
|
||||
|
||||
const binary = this.isBinary(path);
|
||||
const localContent = await this.readFileContent(fileOrPath, binary, isStr);
|
||||
|
||||
// Important: Use SyncManager's logic which handles rootPath/vaultFolder mapping
|
||||
const repoPath = this.plugin.getNormalizedPath(path);
|
||||
const remote = await this.plugin.gitService.getFile(repoPath, this.plugin.settings.branch);
|
||||
|
||||
const status = this.determineFileStatus(localContent, remote);
|
||||
|
||||
this.fileStatuses.set(path, { file, path, status, localContent, remoteContent: remote.content, remoteSha: remote.sha });
|
||||
}
|
||||
|
||||
private async readStringPathContent(path: string, binary: boolean): Promise<string | ArrayBuffer> {
|
||||
try {
|
||||
return binary
|
||||
? await this.app.vault.adapter.readBinary(path)
|
||||
: await this.app.vault.adapter.read(path);
|
||||
} catch (e) {
|
||||
// A folder that's an OS symlink can surface here (not yet known to
|
||||
// the remote, so it skipped the sha-based symlink handling above);
|
||||
// adapter.read() follows the link and throws EISDIR trying to read
|
||||
// a directory. Fall back to the raw link target, consistent with
|
||||
// how a symlinked folder is treated as a single blob elsewhere.
|
||||
const target = readLocalSymlinkTarget(this.app, path);
|
||||
if (target !== null) return target;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private async readFileContent(fileOrPath: TFile | string, binary: boolean, isStr: boolean): Promise<string | ArrayBuffer> {
|
||||
if (isStr) {
|
||||
return binary
|
||||
? await this.app.vault.adapter.readBinary(fileOrPath as string)
|
||||
: await this.app.vault.adapter.read(fileOrPath as string);
|
||||
return this.readStringPathContent(fileOrPath as string, binary);
|
||||
}
|
||||
if (fileOrPath instanceof TFile) {
|
||||
try {
|
||||
|
|
@ -510,22 +634,10 @@ export class SyncStatusView extends ItemView {
|
|||
throw new Error('Expected TFile when isStr is false');
|
||||
}
|
||||
|
||||
private determineFileStatus(binary: boolean, localContent: string | ArrayBuffer, remote: { sha?: string; content?: string | ArrayBuffer }): { status: FileStatus['status']; diff?: string } {
|
||||
if (!remote.sha) {
|
||||
return { status: 'unsynced' };
|
||||
}
|
||||
if (remote.content && this.contentsEqual(localContent, remote.content)) {
|
||||
return { status: 'synced' };
|
||||
}
|
||||
const diff = this.computeDiff(binary, localContent, remote.content || '');
|
||||
return { status: 'modified', diff };
|
||||
}
|
||||
|
||||
private computeDiff(binary: boolean, localContent: string | ArrayBuffer, remoteContent: string | ArrayBuffer): string {
|
||||
if (binary || typeof localContent !== 'string' || typeof remoteContent !== 'string') {
|
||||
return 'Binary file changed';
|
||||
}
|
||||
return this.generateDiff(remoteContent, localContent);
|
||||
private determineFileStatus(localContent: string | ArrayBuffer, remote: { sha?: string; content?: string | ArrayBuffer }): FileStatus['status'] {
|
||||
if (!remote.sha) return 'unsynced';
|
||||
if (remote.content && this.contentsEqual(localContent, remote.content)) return 'synced';
|
||||
return 'modified';
|
||||
}
|
||||
|
||||
private isBinary(path: string): boolean { return isBinaryPath(path); }
|
||||
|
|
@ -534,21 +646,6 @@ export class SyncStatusView extends ItemView {
|
|||
return contentsEqual(a, b);
|
||||
}
|
||||
|
||||
private generateDiff(oldContent: string, newContent: string): string {
|
||||
const oldLines = oldContent.split('\n');
|
||||
const newLines = newContent.split('\n');
|
||||
const diff = ['--- Remote', '+++ Local', ''];
|
||||
const maxLines = Math.max(oldLines.length, newLines.length);
|
||||
for (let i = 0; i < maxLines; i++) {
|
||||
const o = oldLines[i], n = newLines[i];
|
||||
if (o !== n) {
|
||||
if (o !== undefined) diff.push(`- ${o}`);
|
||||
if (n !== undefined) diff.push(`+ ${n}`);
|
||||
}
|
||||
}
|
||||
return diff.join('\n');
|
||||
}
|
||||
|
||||
// ── Batch push/pull/delete ─────────────────────────────────────
|
||||
|
||||
async pushAllModified(): Promise<void> { await this.runBatchOperation('modified', 'push'); }
|
||||
|
|
@ -556,6 +653,11 @@ export class SyncStatusView extends ItemView {
|
|||
async pushSelected(): Promise<void> { await this.runBatchOperation('selected', 'push'); }
|
||||
async pullSelected(): Promise<void> { await this.runBatchOperation('selected', 'pull'); }
|
||||
|
||||
private static readonly NO_RUNNABLE_FILES_KEYS: Record<'push' | 'pull', Record<'selected' | 'found', TranslationKey>> = {
|
||||
push: { selected: 'syncStatus.notice.noPushableFiles.selected', found: 'syncStatus.notice.noPushableFiles.found' },
|
||||
pull: { selected: 'syncStatus.notice.noPullableFiles.selected', found: 'syncStatus.notice.noPullableFiles.found' },
|
||||
};
|
||||
|
||||
private async runBatchOperation(filter: 'modified' | 'selected', op: 'push' | 'pull'): Promise<void> {
|
||||
const targets = Array.from(this.fileStatuses.values()).filter(s => {
|
||||
if (filter === 'selected' && !this.selectedFiles.has(s.path)) return false;
|
||||
|
|
@ -565,32 +667,68 @@ export class SyncStatusView extends ItemView {
|
|||
});
|
||||
|
||||
if (targets.length === 0) {
|
||||
new Notice(`No ${op}able files ${filter === 'selected' ? 'selected' : 'found'}.`);
|
||||
const scope = filter === 'selected' ? 'selected' : 'found';
|
||||
new Notice(t(SyncStatusView.NO_RUNNABLE_FILES_KEYS[op][scope]));
|
||||
return;
|
||||
}
|
||||
|
||||
const files = targets.map(s => s.file || s.path);
|
||||
const serviceName = getServiceName(this.plugin.settings);
|
||||
const msg = op === 'push'
|
||||
? `Push ${files.length} file(s) to ${serviceName}?`
|
||||
: `Pull ${files.length} file(s) from ${serviceName}? This will overwrite local changes.`;
|
||||
? t('syncStatus.confirm.pushSelected', { count: files.length, service: serviceName })
|
||||
: t('syncStatus.confirm.pullSelected', { count: files.length, service: serviceName });
|
||||
|
||||
if (!await this.showConfirmDialog(msg)) return;
|
||||
|
||||
const prog = new Notice(`${op === 'push' ? 'Pushing' : 'Pulling'} 0/${files.length} files…`, 0);
|
||||
await this.executeBatchOperation(filter, op, files);
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks just-pushed paths as 'synced' directly from data already in hand
|
||||
* (the content that was just written, and the new sha when the provider
|
||||
* reported one), instead of re-fetching the remote tree. Used in place of
|
||||
* refreshAllStatuses() right after a push — see the call site's comment.
|
||||
*/
|
||||
private applyOptimisticSyncedStatus(syncedPaths: Array<{ path: string; sha?: string }>): void {
|
||||
for (const { path, sha } of syncedPaths) {
|
||||
const existing = this.fileStatuses.get(path);
|
||||
this.fileStatuses.set(path, {
|
||||
...existing,
|
||||
path,
|
||||
status: 'synced',
|
||||
remoteSha: sha ?? existing?.remoteSha,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async executeBatchOperation(filter: 'modified' | 'selected', op: 'push' | 'pull', files: Array<string | TFile>): Promise<void> {
|
||||
const runVerb = op === 'push' ? t('main.verb.pushing') : t('main.verb.pulling');
|
||||
const prog = new Notice(t('main.progress.running', { verb: runVerb, total: files.length }), 0);
|
||||
try {
|
||||
const results = op === 'push'
|
||||
? await this.plugin.sync.pushAllFiles(files, (cur, total, name) => prog.setMessage(`Pushing ${cur}/${total}: ${name}`))
|
||||
: await this.plugin.sync.pullAllFiles(files, (cur, total, name) => prog.setMessage(`Pulling ${cur}/${total}: ${name}`));
|
||||
? await this.plugin.sync.pushAllFiles(files, (cur, total, name) => prog.setMessage(t('syncStatus.progress.pushing', { current: cur, total, name })))
|
||||
: await this.plugin.sync.pullAllFiles(files, (cur, total, name) => prog.setMessage(t('syncStatus.progress.pulling', { current: cur, total, name })));
|
||||
|
||||
prog.hide();
|
||||
if (results.errors.length > 0) logger.error(`${op} errors:`, results.errors);
|
||||
if (filter === 'selected') this.selectedFiles.clear();
|
||||
new Notice(`${op === 'push' ? 'Push' : 'Pull'} completed. Refreshing…`);
|
||||
await this.refreshAllStatuses();
|
||||
const doneVerb = op === 'push' ? t('main.verb.push') : t('main.verb.pull');
|
||||
new Notice(t('syncStatus.notice.opCompleted', { verb: doneVerb }));
|
||||
|
||||
if (op === 'push') {
|
||||
// Mark just-pushed files synced directly instead of re-fetching the
|
||||
// remote tree: GitHub's tree-by-branch-name read can lag a few
|
||||
// seconds behind a just-completed write, so an immediate refresh
|
||||
// can misreport a file we know just synced correctly as "modified".
|
||||
this.applyOptimisticSyncedStatus((results as PushResults).syncedPaths);
|
||||
this.renderView();
|
||||
} else {
|
||||
await this.refreshAllStatuses();
|
||||
}
|
||||
} catch (e) {
|
||||
prog.hide();
|
||||
new Notice(`${op === 'push' ? 'Push' : 'Pull'} failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
const failVerb = op === 'push' ? t('main.verb.push') : t('main.verb.pull');
|
||||
new Notice(t('syncStatus.notice.opFailed', { verb: failVerb, message: e instanceof Error ? e.message : String(e) }));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -599,26 +737,33 @@ export class SyncStatusView extends ItemView {
|
|||
if (targets.length === 0) return;
|
||||
|
||||
const { local, remote } = this.partitionTargets(targets);
|
||||
if (local.length === 0 && remote.length === 0) { new Notice('Nothing to delete'); return; }
|
||||
if (local.length === 0 && remote.length === 0) { new Notice(t('syncStatus.notice.nothingToDelete')); return; }
|
||||
if (!await this.confirmDeletion(local.length, remote.length)) return;
|
||||
|
||||
const total = local.length + remote.length;
|
||||
const prog = new Notice(`Deleting 0/${total} files…`, 0);
|
||||
const errors: string[] = [];
|
||||
const prog = new Notice(t('syncStatus.progress.deleting', { total }), 0);
|
||||
const errors: { path: string, message: string }[] = [];
|
||||
|
||||
await this.performLocalDeletion(local, total, prog, errors);
|
||||
await this.performRemoteDeletion(remote, total, local.length, prog, errors);
|
||||
|
||||
prog.hide();
|
||||
new Notice(errors.length > 0
|
||||
? `Deleted ${total - errors.length}/${total}. ${errors.length} failed.`
|
||||
: `Deleted ${total} files`
|
||||
);
|
||||
if (errors.length > 0) {
|
||||
logger.error('Delete errors:', errors);
|
||||
new Notice(t('syncStatus.notice.deleteResult.partialWithMessage', {
|
||||
succeeded: total - errors.length,
|
||||
total,
|
||||
failed: errors.length,
|
||||
message: errors.map(e => e.message).join('; ')
|
||||
}));
|
||||
} else {
|
||||
new Notice(t('syncStatus.notice.deleteResult.success', { total }));
|
||||
}
|
||||
this.renderView();
|
||||
}
|
||||
|
||||
private getSelectedTargets(): FileStatus[] {
|
||||
if (this.selectedFiles.size === 0) { new Notice('No files selected'); return []; }
|
||||
if (this.selectedFiles.size === 0) { new Notice(t('syncStatus.notice.noFilesSelected')); return []; }
|
||||
return Array.from(this.selectedFiles)
|
||||
.map(p => this.fileStatuses.get(p))
|
||||
.filter(Boolean) as FileStatus[];
|
||||
|
|
@ -639,40 +784,90 @@ export class SyncStatusView extends ItemView {
|
|||
// recoverability; remote deletes are unconditionally permanent.
|
||||
let msg = '';
|
||||
if (localCount > 0 && remoteCount > 0) {
|
||||
msg = `Delete ${localCount} local file(s) (per your vault's trash setting) and ${remoteCount} remote file(s) (cannot be undone)?`;
|
||||
msg = t('syncStatus.confirmDelete.localAndRemote', { local: localCount, remote: remoteCount });
|
||||
} else if (localCount > 0) {
|
||||
msg = `Delete ${localCount} local file(s)? They'll be handled per your vault's "Deleted files" setting.`;
|
||||
msg = t('syncStatus.confirmDelete.localOnly', { local: localCount });
|
||||
} else {
|
||||
msg = `Delete ${remoteCount} remote file(s)? This cannot be undone.`;
|
||||
msg = t('syncStatus.confirmDelete.remoteOnly', { remote: remoteCount });
|
||||
}
|
||||
return this.showConfirmDialog(msg);
|
||||
}
|
||||
|
||||
private async performLocalDeletion(local: FileStatus[], total: number, prog: Notice, errors: string[]): Promise<void> {
|
||||
private async performLocalDeletion(local: FileStatus[], total: number, prog: Notice, errors: { path: string, message: string }[]): Promise<void> {
|
||||
let cur = 0;
|
||||
for (const s of local) {
|
||||
cur++;
|
||||
prog.setMessage(`Deleting local ${cur}/${total}: ${s.path}`);
|
||||
prog.setMessage(t('syncStatus.progress.deletingLocal', { current: cur, total, path: s.path }));
|
||||
try {
|
||||
if (s.file) await this.app.fileManager.trashFile(s.file);
|
||||
else await this.app.vault.adapter.remove(s.path);
|
||||
await this.plugin.sync.clearMetadata(s.path);
|
||||
this.fileStatuses.delete(s.path);
|
||||
this.selectedFiles.delete(s.path);
|
||||
} catch { errors.push(s.path); }
|
||||
} catch (e) {
|
||||
errors.push({ path: s.path, message: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async performRemoteDeletion(remote: FileStatus[], total: number, localCount: number, prog: Notice, errors: string[]): Promise<void> {
|
||||
private async performRemoteDeletion(remote: FileStatus[], total: number, localCount: number, prog: Notice, errors: { path: string, message: string }[]): Promise<void> {
|
||||
if (remote.length === 0) return;
|
||||
|
||||
// s.path is a vault-relative path (may carry the vaultFolder prefix); the
|
||||
// git service expects a path relative to rootPath only, so strip
|
||||
// vaultFolder first, same as every other gitService call site.
|
||||
const entries = remote.map(s => ({ status: s, repoPath: this.plugin.getNormalizedPath(s.path) }));
|
||||
|
||||
if (!this.plugin.gitService.deleteBatch) {
|
||||
await this.performRemoteDeletionSequential(entries, total, localCount, prog, errors);
|
||||
return;
|
||||
}
|
||||
|
||||
let cur = localCount;
|
||||
for (const s of remote) {
|
||||
for (const e of entries) {
|
||||
cur++;
|
||||
prog.setMessage(`Deleting remote ${cur}/${total}: ${s.path}`);
|
||||
prog.setMessage(t('syncStatus.progress.deletingRemote', { current: cur, total, path: e.status.path }));
|
||||
}
|
||||
|
||||
const branch = this.plugin.settings.branch;
|
||||
for (let i = 0; i < entries.length; i += MAX_BATCH_PUSH_SIZE) {
|
||||
const chunk = entries.slice(i, i + MAX_BATCH_PUSH_SIZE);
|
||||
try {
|
||||
await this.plugin.gitService.deleteFile(s.path, this.plugin.settings.branch, `Delete ${s.path}`);
|
||||
this.fileStatuses.delete(s.path);
|
||||
this.selectedFiles.delete(s.path);
|
||||
} catch { errors.push(s.path); }
|
||||
const message = `Delete ${chunk.length} file(s) from Obsidian`;
|
||||
await this.plugin.gitService.deleteBatch(chunk.map(e => e.repoPath), branch, message);
|
||||
for (const e of chunk) {
|
||||
this.fileStatuses.delete(e.status.path);
|
||||
this.selectedFiles.delete(e.status.path);
|
||||
}
|
||||
} catch (err) {
|
||||
// Atomic per-provider failure: none of this chunk's files were
|
||||
// actually deleted, so every path in it is failed, not dropped.
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
for (const e of chunk) errors.push({ path: e.status.path, message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Provider doesn't support a batch/atomic multi-file delete commit —
|
||||
* fall back to the original sequential per-file delete. */
|
||||
private async performRemoteDeletionSequential(
|
||||
entries: Array<{ status: FileStatus; repoPath: string }>,
|
||||
total: number,
|
||||
localCount: number,
|
||||
prog: Notice,
|
||||
errors: { path: string, message: string }[]
|
||||
): Promise<void> {
|
||||
let cur = localCount;
|
||||
for (const e of entries) {
|
||||
cur++;
|
||||
prog.setMessage(t('syncStatus.progress.deletingRemote', { current: cur, total, path: e.status.path }));
|
||||
try {
|
||||
await this.plugin.gitService.deleteFile(e.repoPath, this.plugin.settings.branch, `Delete ${e.repoPath}`);
|
||||
this.fileStatuses.delete(e.status.path);
|
||||
this.selectedFiles.delete(e.status.path);
|
||||
} catch (err) {
|
||||
errors.push({ path: e.status.path, message: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
44
src/ui/WhatsNewModal.ts
Normal file
44
src/ui/WhatsNewModal.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { App, Modal, ButtonComponent } from 'obsidian';
|
||||
import { type ChangelogRelease } from '../changelog';
|
||||
import { t } from '../i18n';
|
||||
|
||||
const CHANGELOG_URL = 'https://github.com/firstsun-dev/git-files-sync/blob/main/CHANGELOG.md';
|
||||
|
||||
export class WhatsNewModal extends Modal {
|
||||
private readonly releases: ChangelogRelease[];
|
||||
|
||||
constructor(app: App, releases: ChangelogRelease[]) {
|
||||
super(app);
|
||||
this.releases = releases;
|
||||
}
|
||||
|
||||
onOpen() {
|
||||
const { contentEl } = this;
|
||||
contentEl.createEl('h3', { text: t('whatsNew.title') });
|
||||
|
||||
for (const release of this.releases) {
|
||||
contentEl.createEl('h4', { text: `v${release.version}` });
|
||||
const list = contentEl.createEl('ul', { cls: 'ssv-whats-new-list' });
|
||||
for (const entry of release.entries) {
|
||||
const item = list.createEl('li', { cls: entry.notable ? 'ssv-whats-new-notable' : undefined });
|
||||
item.setText(entry.text);
|
||||
}
|
||||
}
|
||||
|
||||
const buttonContainer = contentEl.createDiv({ cls: 'ssv-confirm-buttons modal-button-container' });
|
||||
|
||||
new ButtonComponent(buttonContainer)
|
||||
.setButtonText(t('whatsNew.viewChangelog'))
|
||||
.onClick(() => window.open(CHANGELOG_URL, '_blank', 'noopener'));
|
||||
|
||||
new ButtonComponent(buttonContainer)
|
||||
.setButtonText(t('whatsNew.gotIt'))
|
||||
.setCta()
|
||||
.onClick(() => this.close());
|
||||
}
|
||||
|
||||
onClose() {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { setIcon, setTooltip } from 'obsidian';
|
||||
import { ICONS } from './icons';
|
||||
import { t } from '../../i18n';
|
||||
|
||||
export interface ActionBarProps {
|
||||
hasFiles: boolean;
|
||||
|
|
@ -25,17 +26,17 @@ export function renderActionBar(container: HTMLElement, props: ActionBarProps, c
|
|||
if (props.hasFiles) {
|
||||
bar.createDiv({ cls: 'ssv-bar-spacer' });
|
||||
renderSelectAllRow(bar, props.allSelected, props.indeterminate, callbacks.onSelectAll);
|
||||
renderLargeButton(bar, ICONS.push, ` Push (${props.canPush})`, `Push ${props.canPush} files`, callbacks.onPush, 'push', props.canPush === 0);
|
||||
renderLargeButton(bar, ICONS.pull, ` Pull (${props.canPull})`, `Pull ${props.canPull} files`, callbacks.onPull, 'pull', props.canPull === 0);
|
||||
renderLargeButton(bar, ICONS.delete, ` Delete (${props.canDelete})`, `Delete ${props.canDelete} files`, callbacks.onDelete, 'danger', props.canDelete === 0);
|
||||
renderLargeButton(bar, ICONS.push, t('actionBar.pushCount', { count: props.canPush }), t('actionBar.pushFiles', { count: props.canPush }), callbacks.onPush, 'push', props.canPush === 0);
|
||||
renderLargeButton(bar, ICONS.pull, t('actionBar.pullCount', { count: props.canPull }), t('actionBar.pullFiles', { count: props.canPull }), callbacks.onPull, 'pull', props.canPull === 0);
|
||||
renderLargeButton(bar, ICONS.delete, t('actionBar.deleteCount', { count: props.canDelete }), t('actionBar.deleteFiles', { count: props.canDelete }), callbacks.onDelete, 'danger', props.canDelete === 0);
|
||||
}
|
||||
}
|
||||
|
||||
function renderRefreshButton(bar: HTMLElement, onRefresh: () => void): void {
|
||||
const btn = bar.createEl('button', { cls: 'ssv-btn ssv-btn-refresh' });
|
||||
setIcon(btn.createSpan(), ICONS.refresh);
|
||||
btn.createSpan({ cls: 'ssv-btn-label', text: ' Refresh' });
|
||||
setTooltip(btn, 'Refresh all statuses');
|
||||
btn.createSpan({ cls: 'ssv-btn-label', text: t('actionBar.refresh') });
|
||||
setTooltip(btn, t('actionBar.refreshAll'));
|
||||
btn.addEventListener('click', onRefresh);
|
||||
}
|
||||
|
||||
|
|
@ -44,7 +45,7 @@ function renderSelectAllRow(bar: HTMLElement, allSelected: boolean, indeterminat
|
|||
const cb = selectRow.createEl('input', { type: 'checkbox' });
|
||||
cb.checked = allSelected;
|
||||
cb.indeterminate = indeterminate;
|
||||
selectRow.createSpan({ cls: 'ssv-select-label', text: 'Select' });
|
||||
selectRow.createSpan({ cls: 'ssv-select-label', text: t('actionBar.select') });
|
||||
cb.addEventListener('change', () => onSelectAll(cb.checked));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,25 +1,24 @@
|
|||
import { computeSideBySideDiff, type DiffSide } from '../../utils/diff';
|
||||
import { t } from '../../i18n';
|
||||
|
||||
export function renderDiffPanel(fileEl: HTMLElement, remoteContent: string, localContent: string): HTMLElement {
|
||||
const diffEl = fileEl.createDiv({ cls: 'ssv-diff' });
|
||||
/** Renders the side-by-side + unified diff body into an existing container. */
|
||||
export function renderDiffPanel(container: HTMLElement, remoteContent: string, localContent: string): void {
|
||||
const rows = computeSideBySideDiff(remoteContent, localContent);
|
||||
|
||||
const grid = diffEl.createDiv({ cls: 'ssv-diff-split' }).createDiv({ cls: 'ssv-diff-grid' });
|
||||
grid.createDiv({ cls: 'ssv-diff-hd', text: 'Remote' });
|
||||
grid.createDiv({ cls: 'ssv-diff-hd', text: 'Local' });
|
||||
const grid = container.createDiv({ cls: 'ssv-diff-split' }).createDiv({ cls: 'ssv-diff-grid' });
|
||||
grid.createDiv({ cls: 'ssv-diff-hd', text: t('diffPanel.remote') });
|
||||
grid.createDiv({ cls: 'ssv-diff-hd', text: t('diffPanel.local') });
|
||||
for (const row of rows) {
|
||||
renderDiffCell(grid, row.left);
|
||||
renderDiffCell(grid, row.right);
|
||||
}
|
||||
|
||||
const unifiedEl = diffEl.createEl('pre', { cls: 'ssv-diff-unified' });
|
||||
const unifiedEl = container.createEl('pre', { cls: 'ssv-diff-unified' });
|
||||
for (const { left, right } of rows) {
|
||||
if (left.type === 'removed') unifiedEl.createSpan({ cls: 'ssv-u-line removed' }).textContent = `- ${left.content ?? ''}\n`;
|
||||
if (right.type === 'added') unifiedEl.createSpan({ cls: 'ssv-u-line added' }).textContent = `+ ${right.content ?? ''}\n`;
|
||||
if (left.type === 'unchanged') unifiedEl.createSpan({ cls: 'ssv-u-line unchanged' }).textContent = ` ${left.content ?? ''}\n`;
|
||||
}
|
||||
|
||||
return diffEl;
|
||||
}
|
||||
|
||||
function renderDiffCell(grid: HTMLElement, side: DiffSide): void {
|
||||
|
|
|
|||
|
|
@ -2,23 +2,31 @@ import { setIcon, setTooltip } from 'obsidian';
|
|||
import { type FileStatus } from '../types';
|
||||
import { renderDiffPanel } from './DiffPanel';
|
||||
import { ICONS } from './icons';
|
||||
import { t } from '../../i18n';
|
||||
|
||||
export interface FileItemCallbacks {
|
||||
onSelect: (path: string, selected: boolean) => void;
|
||||
onPush: (fileStatus: FileStatus) => void;
|
||||
onPull: (fileStatus: FileStatus) => void;
|
||||
onDelete: (fileStatus: FileStatus) => void;
|
||||
/**
|
||||
* Called the first time a modified file's diff is expanded and its remote
|
||||
* content hasn't been fetched yet. Must fetch the content, mutate the
|
||||
* fileStatus object in place (remoteContent, localContent as needed), and
|
||||
* resolve once it's ready to render.
|
||||
*/
|
||||
onExpandDiff: (fileStatus: FileStatus) => Promise<void>;
|
||||
}
|
||||
|
||||
// `icon` is a Lucide icon id (rendered via Obsidian's setIcon) so every status
|
||||
// uses the same icon set and renders consistently across platforms.
|
||||
export function statusMeta(status: FileStatus['status']) {
|
||||
switch (status) {
|
||||
case 'synced': return { icon: ICONS.synced, label: 'Synced', iconCls: 'ssv-icon-synced', badgeCls: 'ssv-badge-synced', fileCls: 'status-synced' };
|
||||
case 'modified': return { icon: ICONS.modified, label: 'Changed', iconCls: 'ssv-icon-modified', badgeCls: 'ssv-badge-modified', fileCls: 'status-modified' };
|
||||
case 'unsynced': return { icon: ICONS.push, label: 'Local only', iconCls: 'ssv-icon-unsynced', badgeCls: 'ssv-badge-unsynced', fileCls: 'status-unsynced' };
|
||||
case 'remote-only': return { icon: ICONS.pull, label: 'Remote', iconCls: 'ssv-icon-remote', badgeCls: 'ssv-badge-remote', fileCls: 'status-remote' };
|
||||
default: return { icon: ICONS.checking, label: 'Checking', iconCls: 'ssv-icon-checking', badgeCls: 'ssv-badge-checking', fileCls: 'status-checking' };
|
||||
case 'synced': return { icon: ICONS.synced, label: t('syncStatus.tab.synced'), iconCls: 'ssv-icon-synced', badgeCls: 'ssv-badge-synced', fileCls: 'status-synced' };
|
||||
case 'modified': return { icon: ICONS.modified, label: t('syncStatus.tab.modified'), iconCls: 'ssv-icon-modified', badgeCls: 'ssv-badge-modified', fileCls: 'status-modified' };
|
||||
case 'unsynced': return { icon: ICONS.push, label: t('syncStatus.tab.unsynced'), iconCls: 'ssv-icon-unsynced', badgeCls: 'ssv-badge-unsynced', fileCls: 'status-unsynced' };
|
||||
case 'remote-only': return { icon: ICONS.pull, label: t('syncStatus.tab.remote-only'), iconCls: 'ssv-icon-remote', badgeCls: 'ssv-badge-remote', fileCls: 'status-remote' };
|
||||
default: return { icon: ICONS.checking, label: t('syncStatus.status.checking'), iconCls: 'ssv-icon-checking', badgeCls: 'ssv-badge-checking', fileCls: 'status-checking' };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -48,46 +56,63 @@ export function renderFileItem(
|
|||
function renderFileActions(fileEl: HTMLElement, fileStatus: FileStatus, callbacks: FileItemCallbacks): void {
|
||||
const actions = fileEl.createDiv({ cls: 'ssv-file-actions' });
|
||||
|
||||
if (fileStatus.status === 'modified' && fileStatus.diff) {
|
||||
renderDiffToggleButton(actions, fileEl, fileStatus);
|
||||
if (fileStatus.status === 'modified') {
|
||||
renderDiffToggleButton(actions, fileEl, fileStatus, callbacks);
|
||||
}
|
||||
|
||||
if (fileStatus.status === 'modified' || fileStatus.status === 'unsynced') {
|
||||
renderActionBtn(actions, ICONS.push, ' Push', 'Push to remote', () => callbacks.onPush(fileStatus), 'push');
|
||||
renderActionBtn(actions, ICONS.push, t('fileListItem.action.push'), t('fileListItem.tooltip.pushToRemote'), () => callbacks.onPush(fileStatus), 'push');
|
||||
}
|
||||
|
||||
if (fileStatus.status === 'modified' || fileStatus.status === 'remote-only') {
|
||||
renderActionBtn(actions, ICONS.pull, ' Pull', 'Pull from remote', () => callbacks.onPull(fileStatus), 'pull');
|
||||
renderActionBtn(actions, ICONS.pull, t('fileListItem.action.pull'), t('fileListItem.tooltip.pullFromRemote'), () => callbacks.onPull(fileStatus), 'pull');
|
||||
}
|
||||
|
||||
if (fileStatus.status === 'unsynced') {
|
||||
renderActionBtn(actions, ICONS.delete, ' Remove', 'Delete local file', () => callbacks.onDelete(fileStatus), 'danger');
|
||||
renderActionBtn(actions, ICONS.delete, t('fileListItem.action.remove'), t('fileListItem.tooltip.deleteLocalFile'), () => callbacks.onDelete(fileStatus), 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
function renderDiffToggleButton(actions: HTMLElement, fileEl: HTMLElement, fileStatus: FileStatus): void {
|
||||
function renderDiffToggleButton(actions: HTMLElement, fileEl: HTMLElement, fileStatus: FileStatus, callbacks: FileItemCallbacks): void {
|
||||
const diffBtn = actions.createEl('button', { cls: 'ssv-action-btn diff' });
|
||||
const iconEl = diffBtn.createSpan();
|
||||
setIcon(iconEl, ICONS.diff);
|
||||
const btnLabel = diffBtn.createSpan({ cls: 'ssv-btn-label', text: ' Diff' });
|
||||
|
||||
let diffEl: HTMLElement;
|
||||
if (typeof fileStatus.remoteContent === 'string' && typeof fileStatus.localContent === 'string') {
|
||||
diffEl = renderDiffPanel(fileEl, fileStatus.remoteContent, fileStatus.localContent);
|
||||
} else {
|
||||
diffEl = fileEl.createDiv({ cls: 'ssv-diff' });
|
||||
diffEl.createDiv({ cls: 'ssv-diff-binary', text: 'Binary file changed' });
|
||||
}
|
||||
|
||||
setTooltip(diffBtn, 'Toggle diff view');
|
||||
const btnLabel = diffBtn.createSpan({ cls: 'ssv-btn-label', text: t('fileListItem.action.diff') });
|
||||
|
||||
const diffEl = fileEl.createDiv({ cls: 'ssv-diff' });
|
||||
renderDiffBody(diffEl, fileStatus);
|
||||
|
||||
setTooltip(diffBtn, t('fileListItem.tooltip.toggleDiff'));
|
||||
diffBtn.addEventListener('click', () => {
|
||||
const open = diffEl.hasClass('visible');
|
||||
if (!open && needsContentFetch(fileStatus)) {
|
||||
diffEl.empty();
|
||||
diffEl.createDiv({ cls: 'ssv-diff-loading', text: t('fileListItem.diff.loading') });
|
||||
void callbacks.onExpandDiff(fileStatus).then(() => renderDiffBody(diffEl, fileStatus));
|
||||
}
|
||||
diffEl.toggleClass('visible', !open);
|
||||
btnLabel.setText(open ? ' Diff' : ' Hide');
|
||||
btnLabel.setText(open ? t('fileListItem.action.diff') : t('fileListItem.action.hide'));
|
||||
setIcon(iconEl, open ? ICONS.diff : ICONS.diffOpen);
|
||||
});
|
||||
}
|
||||
|
||||
function needsContentFetch(fileStatus: FileStatus): boolean {
|
||||
return !fileStatus.isSymlink && fileStatus.remoteContent === undefined;
|
||||
}
|
||||
|
||||
function renderDiffBody(diffEl: HTMLElement, fileStatus: FileStatus): void {
|
||||
diffEl.empty();
|
||||
if (fileStatus.isSymlink) {
|
||||
diffEl.createDiv({ cls: 'ssv-diff-binary', text: t('fileListItem.diff.symlinkChanged') });
|
||||
} else if (typeof fileStatus.remoteContent === 'string' && typeof fileStatus.localContent === 'string') {
|
||||
renderDiffPanel(diffEl, fileStatus.remoteContent, fileStatus.localContent);
|
||||
} else if (fileStatus.remoteContent === undefined) {
|
||||
diffEl.createDiv({ cls: 'ssv-diff-loading', text: t('fileListItem.diff.clickToLoad') });
|
||||
} else {
|
||||
diffEl.createDiv({ cls: 'ssv-diff-binary', text: t('fileListItem.diff.binaryChanged') });
|
||||
}
|
||||
}
|
||||
|
||||
function renderActionBtn(actions: HTMLElement, icon: string, label: string, tooltip: string, onClick: () => void, cls: string): void {
|
||||
const btn = actions.createEl('button', { cls: `ssv-action-btn ${cls}` });
|
||||
setIcon(btn.createSpan(), icon);
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@ export interface FileStatus {
|
|||
localContent?: string | ArrayBuffer;
|
||||
remoteContent?: string | ArrayBuffer;
|
||||
remoteSha?: string;
|
||||
diff?: string;
|
||||
/** True when the remote blob is a symbolic link (mode 120000). */
|
||||
isSymlink?: boolean;
|
||||
}
|
||||
|
||||
export type FilterValue = 'all' | 'synced' | 'modified' | 'unsynced' | 'remote-only';
|
||||
|
|
|
|||
21
src/utils/git-blob-sha.ts
Normal file
21
src/utils/git-blob-sha.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
/**
|
||||
* Computes a file's git blob SHA-1 locally: sha1("blob " + byteLength + "\0" + contentBytes).
|
||||
* This is exactly what `git hash-object` produces, so it can be compared directly
|
||||
* against a tree entry's SHA to classify sync status without fetching remote
|
||||
* content. Uses the Web Crypto API (crypto.subtle), available on both desktop
|
||||
* and mobile.
|
||||
*/
|
||||
export async function gitBlobSha(content: string | ArrayBuffer): Promise<string> {
|
||||
const contentBytes = typeof content === 'string' ? new TextEncoder().encode(content) : new Uint8Array(content);
|
||||
const header = new TextEncoder().encode(`blob ${contentBytes.byteLength}\0`);
|
||||
|
||||
const combined = new Uint8Array(header.byteLength + contentBytes.byteLength);
|
||||
combined.set(header, 0);
|
||||
combined.set(contentBytes, header.byteLength);
|
||||
|
||||
// SHA-1 isn't for security here — it's required because it's git's own
|
||||
// object-hashing algorithm, so this must match `git hash-object` exactly.
|
||||
// eslint-disable-next-line sonarjs/hashing -- SHA-1 is required here to match git's own object-hashing algorithm, not used for security
|
||||
const digest = await crypto.subtle.digest('SHA-1', combined);
|
||||
return Array.from(new Uint8Array(digest)).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
|
@ -14,16 +14,18 @@ import { logger } from './logger';
|
|||
// builtins (they don't exist in the mobile bundle).
|
||||
interface NodeFs {
|
||||
lstatSync(p: string): { isSymbolicLink(): boolean };
|
||||
statSync(p: string): { isDirectory(): boolean };
|
||||
readlinkSync(p: string): string;
|
||||
existsSync(p: string): boolean;
|
||||
mkdirSync(p: string, opts: { recursive: boolean }): void;
|
||||
rmSync(p: string, opts: { force: boolean }): void;
|
||||
symlinkSync(target: string, p: string): void;
|
||||
symlinkSync(target: string, p: string, type?: string): void;
|
||||
}
|
||||
|
||||
interface NodePath {
|
||||
join(...parts: string[]): string;
|
||||
dirname(p: string): string;
|
||||
isAbsolute(p: string): boolean;
|
||||
}
|
||||
|
||||
type NodeRequire = (id: string) => unknown;
|
||||
|
|
@ -88,7 +90,7 @@ export function createLocalSymlink(app: App, vaultPath: string, target: string):
|
|||
if (mods.fs.existsSync(abs) || isSymlink(mods.fs, abs)) {
|
||||
mods.fs.rmSync(abs, { force: true });
|
||||
}
|
||||
mods.fs.symlinkSync(target, abs);
|
||||
mods.fs.symlinkSync(target, abs, symlinkType(mods, abs, target));
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.warn(`Failed to create local symlink ${vaultPath} -> ${target}`, e);
|
||||
|
|
@ -103,3 +105,17 @@ function isSymlink(fs: NodeFs, abs: string): boolean {
|
|||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Node's `type` hint to symlinkSync is a no-op on POSIX but required on
|
||||
// Windows: omitting it defaults to 'file', which produces a broken link
|
||||
// whenever the target is actually a directory (as with a symlinked folder).
|
||||
function symlinkType(mods: { fs: NodeFs; path: NodePath }, abs: string, target: string): 'file' | 'dir' {
|
||||
try {
|
||||
const resolvedTarget = mods.path.isAbsolute(target)
|
||||
? target
|
||||
: mods.path.join(mods.path.dirname(abs), target);
|
||||
return mods.fs.statSync(resolvedTarget).isDirectory() ? 'dir' : 'file';
|
||||
} catch {
|
||||
return 'file';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
18
src/utils/version.ts
Normal file
18
src/utils/version.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
/**
|
||||
* Compares two dot-separated version strings numerically per segment (so
|
||||
* "1.10.0" sorts after "1.9.0", unlike a plain string comparison). Missing or
|
||||
* non-numeric segments are treated as 0. Returns <0, 0, or >0 like a standard
|
||||
* comparator.
|
||||
*/
|
||||
export function compareVersions(a: string, b: string): number {
|
||||
const partsA = a.split('.');
|
||||
const partsB = b.split('.');
|
||||
const length = Math.max(partsA.length, partsB.length);
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
const numA = Number(partsA[i]) || 0;
|
||||
const numB = Number(partsB[i]) || 0;
|
||||
if (numA !== numB) return numA - numB;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
168
styles.css
168
styles.css
|
|
@ -570,13 +570,124 @@
|
|||
display: inline;
|
||||
}
|
||||
|
||||
/* ── Settings connection status badge ──────────────────────────── */
|
||||
.gfs-connection-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
margin-bottom: 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.85em;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.gfs-connection-status::before {
|
||||
content: '';
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.gfs-connection-status.is-checking {
|
||||
color: var(--text-muted);
|
||||
background: var(--background-secondary);
|
||||
}
|
||||
|
||||
/* Background intentionally stays the same neutral --background-secondary as
|
||||
* the checking state rather than --background-modifier-success/error: some
|
||||
* themes define those modifier colors identically to --text-success/error,
|
||||
* which would make the label text invisible against its own background. */
|
||||
.gfs-connection-status.is-connected {
|
||||
color: var(--text-success);
|
||||
background: var(--background-secondary);
|
||||
}
|
||||
|
||||
.gfs-connection-status.is-disconnected {
|
||||
color: var(--text-error);
|
||||
background: var(--background-secondary);
|
||||
}
|
||||
|
||||
/* ── Global status bar connection indicator ────────────────────── */
|
||||
.gfs-status-bar-connection {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.gfs-status-bar-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.gfs-status-bar-icon svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.gfs-status-bar-connection.is-checking {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.gfs-status-bar-connection.is-connected {
|
||||
color: var(--text-success);
|
||||
}
|
||||
|
||||
.gfs-status-bar-connection.is-disconnected {
|
||||
color: var(--text-error);
|
||||
}
|
||||
|
||||
/* ── Conflict Modal ─────────────────────────────────────────────── */
|
||||
.sync-conflict-modal { max-width: 900px; }
|
||||
.sync-conflict-modal.modal {
|
||||
width: min(1100px, 92vw);
|
||||
height: min(85vh, 800px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sync-conflict-modal .modal-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.conflict-description {
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 20px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 0.9em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.conflict-tabs {
|
||||
display: none;
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.conflict-tab {
|
||||
padding: 6px 12px;
|
||||
border-radius: 5px;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
background: var(--background-secondary);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.conflict-tab.is-active {
|
||||
background: var(--interactive-accent);
|
||||
color: var(--text-on-accent);
|
||||
border-color: var(--interactive-accent);
|
||||
}
|
||||
|
||||
.conflict-content-area {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.conflict-diff-container {
|
||||
|
|
@ -588,6 +699,11 @@
|
|||
|
||||
@media (max-width: 600px) {
|
||||
.conflict-diff-container { grid-template-columns: 1fr; }
|
||||
|
||||
.conflict-tabs { display: flex; }
|
||||
|
||||
.conflict-panel { display: none; }
|
||||
.conflict-panel.is-active { display: block; }
|
||||
}
|
||||
|
||||
.conflict-section h3,
|
||||
|
|
@ -599,7 +715,6 @@
|
|||
|
||||
.conflict-content,
|
||||
.conflict-diff {
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
padding: 10px;
|
||||
background: var(--background-secondary);
|
||||
|
|
@ -620,6 +735,7 @@
|
|||
padding-top: 12px;
|
||||
border-top: 1px solid var(--background-modifier-border);
|
||||
flex-wrap: wrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
|
|
@ -637,3 +753,49 @@
|
|||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.ssv-whats-new-list {
|
||||
margin: 0 0 12px;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.ssv-whats-new-notable {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── Settings "what's new" banner ──────────────────────────────── */
|
||||
.gfs-whats-new-banner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 12px;
|
||||
border-radius: 8px;
|
||||
background: var(--background-secondary);
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
}
|
||||
|
||||
.gfs-whats-new-banner-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.gfs-whats-new-banner-list {
|
||||
margin: 4px 0 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.gfs-whats-new-banner-dismiss {
|
||||
background: none;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
font-size: 1.1em;
|
||||
line-height: 1;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
|
||||
.gfs-whats-new-banner-dismiss:hover {
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
|
|
|||
34
tests/changelog.test.ts
Normal file
34
tests/changelog.test.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { getUnseenReleases, type ChangelogRelease } from '../src/changelog';
|
||||
|
||||
describe('getUnseenReleases', () => {
|
||||
const changelog: ChangelogRelease[] = [
|
||||
{ version: '1.0.0', entries: [{ text: 'Initial release' }] },
|
||||
{ version: '1.1.0', entries: [{ text: 'Feature A' }] },
|
||||
{ version: '1.2.0', entries: [{ text: 'Feature B', notable: true }] },
|
||||
{ version: '1.10.0', entries: [{ text: 'Feature C' }] },
|
||||
];
|
||||
|
||||
it('returns only releases newer than lastSeenVersion', () => {
|
||||
const result = getUnseenReleases(changelog, '1.1.0');
|
||||
expect(result.map(r => r.version)).toEqual(['1.10.0', '1.2.0']);
|
||||
});
|
||||
|
||||
it('returns releases newest-first', () => {
|
||||
const result = getUnseenReleases(changelog, '1.0.0');
|
||||
expect(result.map(r => r.version)).toEqual(['1.10.0', '1.2.0', '1.1.0']);
|
||||
});
|
||||
|
||||
it('compares versions numerically, not lexically (1.10.0 > 1.2.0)', () => {
|
||||
const result = getUnseenReleases(changelog, '1.9.0');
|
||||
expect(result.map(r => r.version)).toEqual(['1.10.0']);
|
||||
});
|
||||
|
||||
it('returns an empty array when already on the latest version', () => {
|
||||
expect(getUnseenReleases(changelog, '1.10.0')).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns everything when lastSeenVersion is empty', () => {
|
||||
expect(getUnseenReleases(changelog, '').length).toBe(4);
|
||||
});
|
||||
});
|
||||
54
tests/i18n/index.test.ts
Normal file
54
tests/i18n/index.test.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { t, getActiveLocale } from '../../src/i18n';
|
||||
|
||||
type MomentGlobal = { moment?: { locale: () => string } };
|
||||
|
||||
function setMomentLocale(locale: string | undefined): void {
|
||||
const w = window as unknown as MomentGlobal;
|
||||
if (locale === undefined) {
|
||||
delete w.moment;
|
||||
} else {
|
||||
w.moment = { locale: () => locale };
|
||||
}
|
||||
}
|
||||
|
||||
describe('i18n', () => {
|
||||
afterEach(() => {
|
||||
setMomentLocale(undefined);
|
||||
});
|
||||
|
||||
it('falls back to English when window.moment is unavailable', () => {
|
||||
setMomentLocale(undefined);
|
||||
expect(getActiveLocale()).toBe('en');
|
||||
expect(t('confirmModal.cancel')).toBe('Cancel');
|
||||
});
|
||||
|
||||
it('falls back to English for an unsupported locale', () => {
|
||||
setMomentLocale('fr');
|
||||
expect(getActiveLocale()).toBe('en');
|
||||
expect(t('confirmModal.cancel')).toBe('Cancel');
|
||||
});
|
||||
|
||||
it('resolves zh-tw translations when moment locale is zh-tw', () => {
|
||||
setMomentLocale('zh-tw');
|
||||
expect(getActiveLocale()).toBe('zh-tw');
|
||||
expect(t('confirmModal.cancel')).toBe('取消');
|
||||
});
|
||||
|
||||
it('maps a bare "zh" locale to zh-tw', () => {
|
||||
setMomentLocale('zh');
|
||||
expect(getActiveLocale()).toBe('zh-tw');
|
||||
});
|
||||
|
||||
it('interpolates variables into the template', () => {
|
||||
setMomentLocale(undefined);
|
||||
expect(t('settings.testConnection.success', { service: 'GitHub' })).toBe('GitHub connection successful!');
|
||||
});
|
||||
|
||||
it('falls back to English text when a zh-tw key is missing a translation', () => {
|
||||
setMomentLocale('zh-tw');
|
||||
// Every key defined in en.ts should resolve to *some* string even if
|
||||
// zh-tw hasn't translated it yet, rather than throwing or returning undefined.
|
||||
expect(typeof t('confirmModal.title')).toBe('string');
|
||||
});
|
||||
});
|
||||
|
|
@ -3,8 +3,10 @@ import { describe, it, expect, vi, beforeEach, Mocked } from 'vitest';
|
|||
import { GitignoreManager } from '../../src/logic/gitignore-manager';
|
||||
import { App, DataAdapter } from 'obsidian';
|
||||
import { GitServiceInterface } from '../../src/services/git-service-interface';
|
||||
import { readLocalSymlinkTarget } from '../../src/utils/symlink';
|
||||
|
||||
vi.mock('obsidian');
|
||||
vi.mock('../../src/utils/symlink', () => ({ readLocalSymlinkTarget: vi.fn() }));
|
||||
|
||||
describe('GitignoreManager', () => {
|
||||
let manager: GitignoreManager;
|
||||
|
|
@ -14,6 +16,7 @@ describe('GitignoreManager', () => {
|
|||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(readLocalSymlinkTarget).mockReturnValue(null);
|
||||
|
||||
const mockAdapter = {
|
||||
exists: vi.fn(),
|
||||
|
|
@ -117,6 +120,26 @@ describe('GitignoreManager', () => {
|
|||
expect(manager.isIgnored('sub/root-ignored.txt')).toBe(true);
|
||||
});
|
||||
|
||||
it('scans a pre-fetched remote tree for .gitignore paths instead of calling getRepoGitignores', async () => {
|
||||
const adapter = mockApp.vault.adapter as Mocked<DataAdapter>;
|
||||
vi.mocked(adapter.exists).mockResolvedValue(false);
|
||||
vi.mocked(mockGitService.getFile).mockImplementation((path) => {
|
||||
if (path === '/.gitignore') return Promise.resolve({ content: 'node_modules/', sha: '1' });
|
||||
if (path === '/sub/.gitignore') return Promise.resolve({ content: '*.tmp', sha: '2' });
|
||||
return Promise.resolve({ content: '', sha: '' });
|
||||
});
|
||||
|
||||
await manager.loadGitignores([
|
||||
{ path: '.gitignore', symlink: false, sha: 'a' },
|
||||
{ path: 'sub/.gitignore', symlink: false, sha: 'b' },
|
||||
{ path: 'src/main.ts', symlink: false, sha: 'c' },
|
||||
]);
|
||||
|
||||
expect(mockGitService.getRepoGitignores).not.toHaveBeenCalled();
|
||||
expect(manager.isIgnored('node_modules/test.js')).toBe(true);
|
||||
expect(manager.isIgnored('sub/test.tmp')).toBe(true);
|
||||
});
|
||||
|
||||
it('should pick up local-only subdirectory .gitignore not yet on remote', async () => {
|
||||
// Remote only knows about root .gitignore; sub/.gitignore exists locally but not pushed yet
|
||||
vi.mocked(mockGitService.getRepoGitignores).mockResolvedValue(['.gitignore']);
|
||||
|
|
@ -187,6 +210,58 @@ describe('GitignoreManager', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('local ignorePatterns setting', () => {
|
||||
it('ignores files matching a local pattern even with no remote/local .gitignore', async () => {
|
||||
vi.mocked(mockGitService.getRepoGitignores).mockResolvedValue([]);
|
||||
const adapter = mockApp.vault.adapter as Mocked<DataAdapter>;
|
||||
vi.mocked(adapter.exists).mockResolvedValue(false);
|
||||
|
||||
const localManager = new GitignoreManager(mockApp, mockGitService, branch, '', '', 'secrets/\n*.private');
|
||||
await localManager.loadGitignores();
|
||||
|
||||
expect(localManager.isIgnored('secrets/key.txt')).toBe(true);
|
||||
expect(localManager.isIgnored('note.private')).toBe(true);
|
||||
expect(localManager.isIgnored('note.md')).toBe(false);
|
||||
});
|
||||
|
||||
it('applies local patterns in addition to remote .gitignore, not instead of it', async () => {
|
||||
vi.mocked(mockGitService.getRepoGitignores).mockResolvedValue(['.gitignore']);
|
||||
const adapter = mockApp.vault.adapter as Mocked<DataAdapter>;
|
||||
vi.mocked(adapter.exists).mockResolvedValue(true);
|
||||
vi.mocked(adapter.read).mockResolvedValue('*.log');
|
||||
|
||||
const localManager = new GitignoreManager(mockApp, mockGitService, branch, '', '', '*.tmp');
|
||||
await localManager.loadGitignores();
|
||||
|
||||
expect(localManager.isIgnored('test.log')).toBe(true);
|
||||
expect(localManager.isIgnored('test.tmp')).toBe(true);
|
||||
expect(localManager.isIgnored('test.md')).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores blank ignorePatterns without throwing', async () => {
|
||||
vi.mocked(mockGitService.getRepoGitignores).mockResolvedValue([]);
|
||||
const adapter = mockApp.vault.adapter as Mocked<DataAdapter>;
|
||||
vi.mocked(adapter.exists).mockResolvedValue(false);
|
||||
|
||||
const localManager = new GitignoreManager(mockApp, mockGitService, branch, '', '', ' \n ');
|
||||
await localManager.loadGitignores();
|
||||
|
||||
expect(localManager.isIgnored('note.md')).toBe(false);
|
||||
});
|
||||
|
||||
it('respects comments and negation in local patterns', async () => {
|
||||
vi.mocked(mockGitService.getRepoGitignores).mockResolvedValue([]);
|
||||
const adapter = mockApp.vault.adapter as Mocked<DataAdapter>;
|
||||
vi.mocked(adapter.exists).mockResolvedValue(false);
|
||||
|
||||
const localManager = new GitignoreManager(mockApp, mockGitService, branch, '', '', '# comment\ndraft/*\n!draft/keep.md');
|
||||
await localManager.loadGitignores();
|
||||
|
||||
expect(localManager.isIgnored('draft/scratch.md')).toBe(true);
|
||||
expect(localManager.isIgnored('draft/keep.md')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('complex patterns', () => {
|
||||
beforeEach(async () => {
|
||||
vi.mocked(mockGitService.getRepoGitignores).mockResolvedValue(['.gitignore']);
|
||||
|
|
@ -272,5 +347,24 @@ describe('GitignoreManager', () => {
|
|||
expect(adapter.list).toHaveBeenCalledWith('');
|
||||
expect(adapter.list).toHaveBeenCalledWith('.claude');
|
||||
});
|
||||
|
||||
it('should not recurse into a folder that is actually a symlink', async () => {
|
||||
// "linked" is a directory symlink (e.g. a shared skills folder) pointing
|
||||
// outside the repo; walking into it would scan an unrelated tree and
|
||||
// produce bogus .gitignore lookups for paths that don't exist remotely.
|
||||
vi.mocked(adapter.list).mockImplementation((dir: string) => {
|
||||
if (dir === '' || dir === undefined) {
|
||||
return Promise.resolve({ files: ['.gitignore'], folders: ['linked'] });
|
||||
}
|
||||
return Promise.resolve({ files: ['linked/nested/.gitignore'], folders: ['linked/nested'] });
|
||||
});
|
||||
vi.mocked(readLocalSymlinkTarget).mockImplementation((_app, path) => path === 'linked' ? '/some/other/dir' : null);
|
||||
vi.mocked(adapter.read).mockResolvedValue('*.secret');
|
||||
|
||||
await manager.loadGitignores();
|
||||
|
||||
expect(adapter.list).toHaveBeenCalledWith('');
|
||||
expect(adapter.list).not.toHaveBeenCalledWith('linked');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ describe('SyncManager Batch Operations', () => {
|
|||
exists: vi.fn(),
|
||||
read: vi.fn(),
|
||||
write: vi.fn(),
|
||||
readBinary: vi.fn(),
|
||||
writeBinary: vi.fn(),
|
||||
} as unknown as Mocked<DataAdapter>;
|
||||
|
||||
mockApp = {
|
||||
|
|
@ -40,6 +42,7 @@ describe('SyncManager Batch Operations', () => {
|
|||
getFile: vi.fn(),
|
||||
testConnection: vi.fn(),
|
||||
listFiles: vi.fn(),
|
||||
listFilesDetailed: vi.fn().mockResolvedValue([]),
|
||||
deleteFile: vi.fn(),
|
||||
getRepoGitignores: vi.fn(),
|
||||
updateConfig: vi.fn(),
|
||||
|
|
@ -100,6 +103,123 @@ describe('SyncManager Batch Operations', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('batch commit via gitService.pushBatch', () => {
|
||||
it('groups all queued files into one pushBatch call when the provider supports it', async () => {
|
||||
const files = ['a.md', 'b.md'];
|
||||
const adapter = mockApp.vault.adapter as Mocked<DataAdapter>;
|
||||
|
||||
vi.mocked(adapter.exists).mockResolvedValue(true);
|
||||
vi.mocked(adapter.read).mockImplementation(async (p) => (p === 'a.md' ? 'content-a' : 'content-b'));
|
||||
// No tree entries: both files are new, so both are queued.
|
||||
vi.mocked(mockGitService.listFilesDetailed).mockResolvedValue([]);
|
||||
mockGitService.pushBatch = vi.fn().mockResolvedValue([
|
||||
{ path: 'a.md', sha: 'sha-a' },
|
||||
{ path: 'b.md', sha: 'sha-b' },
|
||||
]);
|
||||
|
||||
const results = await manager.pushAllFiles(files);
|
||||
|
||||
expect(results.success).toBe(2);
|
||||
expect(results.failed).toBe(0);
|
||||
expect(mockGitService.pushBatch).toHaveBeenCalledTimes(1);
|
||||
expect(mockGitService.pushFile).not.toHaveBeenCalled();
|
||||
const [items] = vi.mocked(mockGitService.pushBatch).mock.calls[0]!;
|
||||
expect(items).toEqual([
|
||||
{ path: 'a.md', content: 'content-a', existedRemotely: false },
|
||||
{ path: 'b.md', content: 'content-b', existedRemotely: false },
|
||||
]);
|
||||
// syncedPaths lets the caller mark these files synced directly, without
|
||||
// a follow-up remote read that could race a provider's eventual
|
||||
// consistency window (see SyncStatusView's use of this field).
|
||||
expect(results.syncedPaths).toEqual([
|
||||
{ path: 'a.md', sha: 'sha-a' },
|
||||
{ path: 'b.md', sha: 'sha-b' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('reports syncedPaths via the sequential fallback when the provider has no pushBatch', async () => {
|
||||
const files = ['a.md', 'b.md'];
|
||||
const adapter = mockApp.vault.adapter as Mocked<DataAdapter>;
|
||||
|
||||
vi.mocked(adapter.exists).mockResolvedValue(true);
|
||||
vi.mocked(adapter.read).mockImplementation(async (p) => (p === 'a.md' ? 'content-a' : 'content-b'));
|
||||
vi.mocked(mockGitService.listFilesDetailed).mockResolvedValue([]);
|
||||
mockGitService.pushBatch = undefined;
|
||||
vi.mocked(mockGitService.pushFile).mockImplementation(async (path) => ({ path, sha: `sha-${path}` }));
|
||||
|
||||
const results = await manager.pushAllFiles(files);
|
||||
|
||||
expect(results.success).toBe(2);
|
||||
expect(mockGitService.pushFile).toHaveBeenCalledTimes(2);
|
||||
expect(results.syncedPaths).toEqual([
|
||||
{ path: 'a.md', sha: 'sha-a.md' },
|
||||
{ path: 'b.md', sha: 'sha-b.md' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('handles a mixed binary + text batch, computing blob shas for both', async () => {
|
||||
const files = ['note.md', 'image.png'];
|
||||
const adapter = mockApp.vault.adapter as Mocked<DataAdapter>;
|
||||
|
||||
vi.mocked(adapter.exists).mockResolvedValue(true);
|
||||
vi.mocked(adapter.read).mockResolvedValue('text content');
|
||||
vi.mocked(adapter.readBinary).mockResolvedValue(new Uint8Array([1, 2, 3]).buffer);
|
||||
vi.mocked(mockGitService.listFilesDetailed).mockResolvedValue([]);
|
||||
mockGitService.pushBatch = vi.fn().mockResolvedValue([
|
||||
{ path: 'note.md', sha: 'sha-note' },
|
||||
{ path: 'image.png', sha: 'sha-image' },
|
||||
]);
|
||||
|
||||
const results = await manager.pushAllFiles(files);
|
||||
|
||||
expect(results.success).toBe(2);
|
||||
expect(mockGitService.pushBatch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('marks every file in a failed chunk as failed, not dropped', async () => {
|
||||
const files = ['a.md', 'b.md'];
|
||||
const adapter = mockApp.vault.adapter as Mocked<DataAdapter>;
|
||||
|
||||
vi.mocked(adapter.exists).mockResolvedValue(true);
|
||||
vi.mocked(adapter.read).mockResolvedValue('content');
|
||||
vi.mocked(mockGitService.listFilesDetailed).mockResolvedValue([]);
|
||||
mockGitService.pushBatch = vi.fn().mockRejectedValue(new Error('commit failed'));
|
||||
|
||||
const results = await manager.pushAllFiles(files);
|
||||
|
||||
expect(results.success).toBe(0);
|
||||
expect(results.failed).toBe(2);
|
||||
expect(results.errors).toEqual([
|
||||
{ file: 'a.md', error: 'commit failed' },
|
||||
{ file: 'b.md', error: 'commit failed' },
|
||||
]);
|
||||
expect(results.syncedPaths).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips both getFile and pushBatch when the local blob sha already matches the tree', async () => {
|
||||
const path = 'unchanged.md';
|
||||
const adapter = mockApp.vault.adapter as Mocked<DataAdapter>;
|
||||
|
||||
vi.mocked(adapter.exists).mockResolvedValue(true);
|
||||
vi.mocked(adapter.read).mockResolvedValue('same content');
|
||||
|
||||
// Compute the real git blob sha for the content so it matches the tree entry.
|
||||
const { gitBlobSha } = await import('../../src/utils/git-blob-sha');
|
||||
const sha = await gitBlobSha('same content');
|
||||
vi.mocked(mockGitService.listFilesDetailed).mockResolvedValue([{ path, symlink: false, sha }]);
|
||||
mockGitService.pushBatch = vi.fn();
|
||||
|
||||
const results = await manager.pushAllFiles([path]);
|
||||
|
||||
expect(results.success).toBe(0);
|
||||
expect(results.conflicts).toBe(0);
|
||||
expect(results.failed).toBe(0);
|
||||
expect(mockGitService.getFile).not.toHaveBeenCalled();
|
||||
expect(mockGitService.pushBatch).not.toHaveBeenCalled();
|
||||
expect(mockSettings.syncMetadata[path]?.lastSyncedSha).toBe(sha);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pullAllAllFiles', () => {
|
||||
it('should pull multiple files correctly (strings and TFiles)', async () => {
|
||||
const mockFile = Object.assign(new TFile(), { path: 'file2.md', name: 'file2.md' });
|
||||
|
|
@ -161,8 +281,10 @@ describe('SyncManager Batch Operations', () => {
|
|||
|
||||
vi.mocked(adapter.exists).mockResolvedValue(true);
|
||||
vi.mocked(adapter.read).mockResolvedValue('local edit');
|
||||
// Remote sha differs from what we last synced, and content differs too.
|
||||
vi.mocked(mockGitService.getFile).mockResolvedValue({ content: 'remote edit', sha: 'sha-changed-on-remote' });
|
||||
// Remote tree entry's sha differs from what we last synced.
|
||||
vi.mocked(mockGitService.listFilesDetailed).mockResolvedValue([
|
||||
{ path, symlink: false, sha: 'sha-changed-on-remote' }
|
||||
]);
|
||||
|
||||
const results = await manager.pushAllFiles([path]);
|
||||
|
||||
|
|
@ -170,6 +292,7 @@ describe('SyncManager Batch Operations', () => {
|
|||
expect(results.conflicts).toBe(1);
|
||||
expect(results.failed).toBe(0);
|
||||
expect(mockGitService.pushFile).not.toHaveBeenCalled();
|
||||
expect(mockGitService.getFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip (not overwrite) a pull when the local file has diverged since last sync', async () => {
|
||||
|
|
@ -197,13 +320,16 @@ describe('SyncManager Batch Operations', () => {
|
|||
|
||||
vi.mocked(adapter.exists).mockResolvedValue(true);
|
||||
vi.mocked(adapter.read).mockResolvedValue('local content');
|
||||
vi.mocked(mockGitService.getFile).mockResolvedValue({ content: 'remote content', sha: 'some-sha' });
|
||||
vi.mocked(mockGitService.listFilesDetailed).mockResolvedValue([
|
||||
{ path, symlink: false, sha: 'some-sha' }
|
||||
]);
|
||||
vi.mocked(mockGitService.pushFile).mockResolvedValue({ path, sha: 'new-sha' });
|
||||
|
||||
const results = await manager.pushAllFiles([path]);
|
||||
|
||||
expect(results.success).toBe(1);
|
||||
expect(results.conflicts).toBe(0);
|
||||
expect(mockGitService.pushFile).toHaveBeenCalledWith(path, 'local content', 'main', expect.any(String), 'some-sha');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -274,10 +400,15 @@ describe('SyncManager Batch Operations', () => {
|
|||
vi.mocked(mockApp.vault.read).mockResolvedValue('unrelated content');
|
||||
vi.mocked(mockApp.vault.adapter.exists as ReturnType<typeof vi.fn>).mockResolvedValue(true);
|
||||
vi.mocked(mockGitService.pushFile).mockResolvedValue({ path: pushedPath, sha: 'new-sha' });
|
||||
// detectRename still checks the orphaned metadata entry's remote content directly.
|
||||
vi.mocked(mockGitService.getFile).mockImplementation(async (path) => {
|
||||
if (path === orphanedPath) return { content: 'totally different content', sha: 'orphaned-sha' };
|
||||
return { content: 'old content', sha: 'remote-sha' };
|
||||
return { content: '', sha: '' };
|
||||
});
|
||||
// The pushed path's own remote state comes from the pre-fetched tree, not getFile.
|
||||
vi.mocked(mockGitService.listFilesDetailed).mockResolvedValue([
|
||||
{ path: pushedPath, symlink: false, sha: 'remote-sha' }
|
||||
]);
|
||||
|
||||
const results = await manager.pushAllFiles([mockFile]);
|
||||
|
||||
|
|
|
|||
|
|
@ -53,6 +53,10 @@ const mockSettings: GitLabFilesPushSettings = {
|
|||
vaultFolder: 'Work',
|
||||
syncMetadata: {},
|
||||
symlinkHandling: 'real',
|
||||
ignorePatterns: '',
|
||||
lastSeenVersion: '',
|
||||
bannerDismissedVersion: '',
|
||||
language: 'system',
|
||||
};
|
||||
|
||||
describe('SyncManager Mapping', () => {
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ export function createSyncManagerMocks(): SyncManagerMocks {
|
|||
getFile: vi.fn(),
|
||||
testConnection: vi.fn(),
|
||||
listFiles: vi.fn(),
|
||||
listFilesDetailed: vi.fn().mockResolvedValue([]),
|
||||
deleteFile: vi.fn(),
|
||||
getRepoGitignores: vi.fn(),
|
||||
updateConfig: vi.fn(),
|
||||
|
|
|
|||
|
|
@ -59,7 +59,11 @@ const mockSettings: GitLabFilesPushSettings = {
|
|||
rootPath: '',
|
||||
syncMetadata: {},
|
||||
vaultFolder: '',
|
||||
symlinkHandling: 'real'
|
||||
symlinkHandling: 'real',
|
||||
ignorePatterns: '',
|
||||
lastSeenVersion: '',
|
||||
bannerDismissedVersion: '',
|
||||
language: 'system'
|
||||
};
|
||||
|
||||
describe('SyncManager', () => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { GitHubService } from '../../src/services/github-service';
|
||||
import { requestUrl, RequestUrlResponse } from 'obsidian';
|
||||
import { MAX_BATCH_PUSH_SIZE } from '../../src/services/git-service-base';
|
||||
|
||||
// Tests for BaseGitService protected methods exercised via GitHubService
|
||||
describe('BaseGitService', () => {
|
||||
|
|
@ -77,6 +78,30 @@ describe('BaseGitService', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('safeRequest when requestUrl() itself throws a JSON-parse-of-HTML error', () => {
|
||||
// Some Obsidian versions eagerly parse the response body as JSON inside
|
||||
// requestUrl() itself, so a proxy/login HTML page can make the whole
|
||||
// call reject with a raw SyntaxError — before `throw: false` or our own
|
||||
// status/content-type checks ever get a response object to inspect.
|
||||
it('wraps the raw "Unexpected token \'<\'... is not valid JSON" rejection with a clear message', async () => {
|
||||
vi.mocked(requestUrl).mockRejectedValue(
|
||||
new SyntaxError('Unexpected token \'<\', "<!DOCTYPE "... is not valid JSON')
|
||||
);
|
||||
await expect(service.listFiles('main')).rejects.toThrow(/received an HTML page/);
|
||||
await expect(service.listFiles('main')).rejects.not.toThrow(/Unexpected token/);
|
||||
});
|
||||
|
||||
it('wraps the older V8 phrasing ("Unexpected token < in JSON at position 0") too', async () => {
|
||||
vi.mocked(requestUrl).mockRejectedValue(new SyntaxError('Unexpected token < in JSON at position 0'));
|
||||
await expect(service.listFiles('main')).rejects.toThrow(/received an HTML page/);
|
||||
});
|
||||
|
||||
it('does not misclassify an unrelated JSON syntax error as an HTML response', async () => {
|
||||
vi.mocked(requestUrl).mockRejectedValue(new SyntaxError('Unexpected end of JSON input'));
|
||||
await expect(service.listFiles('main')).rejects.toThrow('Unexpected end of JSON input');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseJson with non-JSON (HTML) responses', () => {
|
||||
// Simulates Obsidian's real RequestUrlResponse.json getter, which throws
|
||||
// SyntaxError when the body is not valid JSON (e.g. an HTML page).
|
||||
|
|
@ -142,6 +167,12 @@ describe('BaseGitService', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('MAX_BATCH_PUSH_SIZE', () => {
|
||||
it('is a sane positive chunk size', () => {
|
||||
expect(MAX_BATCH_PUSH_SIZE).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('encodeContent / decodeContent round-trip', () => {
|
||||
it('should correctly encode and decode UTF-8 content', async () => {
|
||||
const original = 'Hello, 世界! 🌍';
|
||||
|
|
|
|||
|
|
@ -117,6 +117,40 @@ describe('GiteaService', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('pushBatch', () => {
|
||||
it('returns [] and makes no requests for an empty item list', async () => {
|
||||
const result = await service.pushBatch([], 'main', 'push nothing');
|
||||
expect(result).toEqual([]);
|
||||
expect(requestUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves branch via /branches/{branch}, then commits N files in one commit', async () => {
|
||||
vi.mocked(requestUrl)
|
||||
.mockResolvedValueOnce({ status: 200, json: { commit: { id: 'commit1' } } } as unknown as RequestUrlResponse) // resolve branch
|
||||
.mockResolvedValueOnce({ status: 200, json: { tree: { sha: 'tree1' } } } as unknown as RequestUrlResponse) // get commit
|
||||
.mockResolvedValueOnce({ status: 201, json: { sha: 'blob-a' } } as unknown as RequestUrlResponse) // blob a
|
||||
.mockResolvedValueOnce({ status: 201, json: { sha: 'tree2' } } as unknown as RequestUrlResponse) // create tree
|
||||
.mockResolvedValueOnce({ status: 201, json: { sha: 'commit2' } } as unknown as RequestUrlResponse) // create commit
|
||||
.mockResolvedValueOnce({ status: 200, json: {} } as unknown as RequestUrlResponse); // update ref
|
||||
|
||||
const result = await service.pushBatch([{ path: 'a.md', content: 'hello' }], 'main', 'Push 1 file(s) from Obsidian');
|
||||
|
||||
expect(result).toEqual([{ path: 'a.md', sha: 'blob-a' }]);
|
||||
|
||||
const calls = vi.mocked(requestUrl).mock.calls.map(c => c[0] as RequestUrlParam);
|
||||
expect(calls).toHaveLength(6);
|
||||
expect(calls[0]?.url).toBe(`${baseUrl}/api/v1/repos/${owner}/${repo}/branches/main`);
|
||||
expect(calls[1]?.url).toBe(`${baseUrl}/api/v1/repos/${owner}/${repo}/git/commits/commit1`);
|
||||
|
||||
const blobBody = JSON.parse(calls[2]?.body as string) as { content: string; encoding: string };
|
||||
expect(blobBody.encoding).toBe('base64');
|
||||
expect(atob(blobBody.content)).toBe('hello');
|
||||
|
||||
expect(calls[5]?.method).toBe('PATCH');
|
||||
expect(calls[5]?.url).toBe(`${baseUrl}/api/v1/repos/${owner}/${repo}/git/refs/heads/main`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listFiles', () => {
|
||||
const commitSha = 'abc123commit';
|
||||
|
||||
|
|
@ -184,6 +218,33 @@ describe('GiteaService', () => {
|
|||
mockRequest({ status: 404, json: { message: 'branch does not exist' }, text: 'branch does not exist' });
|
||||
await expect(service.listFiles('missing-branch')).rejects.toThrow(/Branch "missing-branch" was not found/);
|
||||
});
|
||||
|
||||
it('listFilesDetailed includes each blob\'s sha', async () => {
|
||||
vi.mocked(requestUrl)
|
||||
.mockResolvedValueOnce({ status: 200, json: { commit: { id: commitSha } } } as unknown as RequestUrlResponse)
|
||||
.mockResolvedValueOnce({ status: 200, json: { tree: [
|
||||
{ path: 'file1.md', type: 'blob', sha: 'sha-1' },
|
||||
] } } as unknown as RequestUrlResponse);
|
||||
expect(await service.listFilesDetailed('main')).toEqual([
|
||||
{ path: 'file1.md', symlink: false, sha: 'sha-1' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBlob', () => {
|
||||
it('decodes base64 blob content by sha', async () => {
|
||||
mockRequest({ status: 200, json: { content: btoa('hello world'), encoding: 'base64', sha: 'blob-sha' } });
|
||||
const result = await service.getBlob('blob-sha', 'test.md');
|
||||
expect(result.content).toBe('hello world');
|
||||
expect(result.sha).toBe('blob-sha');
|
||||
});
|
||||
|
||||
it('requests the blob endpoint by sha, not path', async () => {
|
||||
mockRequest({ status: 200, json: { content: btoa('x'), sha: 'abc123' } });
|
||||
await service.getBlob('abc123', 'test.md');
|
||||
const call = getLastRequestCall();
|
||||
expect(call.url).toBe(`${baseUrl}/api/v1/repos/${owner}/${repo}/git/blobs/abc123`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteFile', () => {
|
||||
|
|
@ -214,6 +275,58 @@ describe('GiteaService', () => {
|
|||
const deleteCall = calls[1]?.[0] as RequestUrlParam;
|
||||
expect(deleteCall.url).toBe(`${baseUrl}/api/v1/repos/${owner}/${repo}/contents/notes/test.md`);
|
||||
});
|
||||
|
||||
it('should throw instead of sending an empty sha when the pre-delete lookup 404s', async () => {
|
||||
mockRequest({ status: 404 });
|
||||
|
||||
await expect(service.deleteFile('missing.md', 'main', 'delete missing.md')).rejects.toThrow('missing.md');
|
||||
|
||||
const calls = vi.mocked(requestUrl).mock.calls;
|
||||
expect(calls).toHaveLength(1); // no DELETE request was sent
|
||||
});
|
||||
|
||||
it('should URL-encode path segments with spaces or non-ASCII characters', async () => {
|
||||
vi.mocked(requestUrl)
|
||||
.mockResolvedValueOnce({ status: 200, json: { content: btoa('content'), sha: 'file-sha' } } as unknown as RequestUrlResponse)
|
||||
.mockResolvedValueOnce({ status: 200, json: {} } as unknown as RequestUrlResponse);
|
||||
|
||||
await service.deleteFile('folder/我的 筆記.md', 'main', 'delete note');
|
||||
|
||||
const calls = vi.mocked(requestUrl).mock.calls;
|
||||
const getCall = calls[0]?.[0] as RequestUrlParam;
|
||||
expect(getCall.url).toContain('/contents/folder/');
|
||||
expect(getCall.url).not.toContain(' ');
|
||||
expect(getCall.url).not.toContain('我的');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteBatch', () => {
|
||||
it('returns and makes no requests for an empty path list', async () => {
|
||||
await service.deleteBatch([], 'main', 'delete nothing');
|
||||
expect(requestUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves branch via /branches/{branch}, then deletes N files in one commit', async () => {
|
||||
vi.mocked(requestUrl)
|
||||
.mockResolvedValueOnce({ status: 200, json: { commit: { id: 'commit1' } } } as unknown as RequestUrlResponse) // resolve branch
|
||||
.mockResolvedValueOnce({ status: 200, json: { tree: { sha: 'tree1' } } } as unknown as RequestUrlResponse) // get commit
|
||||
.mockResolvedValueOnce({ status: 201, json: { sha: 'tree2' } } as unknown as RequestUrlResponse) // create tree
|
||||
.mockResolvedValueOnce({ status: 201, json: { sha: 'commit2' } } as unknown as RequestUrlResponse) // create commit
|
||||
.mockResolvedValueOnce({ status: 200, json: {} } as unknown as RequestUrlResponse); // update ref
|
||||
|
||||
await service.deleteBatch(['a.md'], 'main', 'Delete 1 file(s) from Obsidian');
|
||||
|
||||
const calls = vi.mocked(requestUrl).mock.calls.map(c => c[0] as RequestUrlParam);
|
||||
expect(calls).toHaveLength(5);
|
||||
expect(calls[0]?.url).toBe(`${baseUrl}/api/v1/repos/${owner}/${repo}/branches/main`);
|
||||
expect(calls[1]?.url).toBe(`${baseUrl}/api/v1/repos/${owner}/${repo}/git/commits/commit1`);
|
||||
|
||||
const treeBody = JSON.parse(calls[2]?.body as string) as { tree: Array<{ path: string; sha: string | null }> };
|
||||
expect(treeBody.tree).toEqual([{ path: 'a.md', mode: '100644', type: 'blob', sha: null }]);
|
||||
|
||||
expect(calls[4]?.method).toBe('PATCH');
|
||||
expect(calls[4]?.url).toBe(`${baseUrl}/api/v1/repos/${owner}/${repo}/git/refs/heads/main`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('testConnection', () => {
|
||||
|
|
|
|||
|
|
@ -98,6 +98,131 @@ describe('GitHubService', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('pushBatch', () => {
|
||||
it('returns [] and makes no requests for an empty item list', async () => {
|
||||
const result = await service.pushBatch([], 'main', 'push nothing');
|
||||
expect(result).toEqual([]);
|
||||
expect(requestUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('commits N files in one GraphQL mutation via ref -> createCommitOnBranch -> tree', async () => {
|
||||
vi.mocked(requestUrl)
|
||||
.mockResolvedValueOnce({ status: 200, json: { object: { sha: 'commit1' } } } as unknown as RequestUrlResponse) // get ref
|
||||
.mockResolvedValueOnce({ status: 200, json: { data: { createCommitOnBranch: { commit: { oid: 'commit2' } } } } } as unknown as RequestUrlResponse) // mutation
|
||||
.mockResolvedValueOnce({ status: 200, json: { tree: [{ path: 'a.md', type: 'blob', sha: 'blob-a' }, { path: 'b.md', type: 'blob', sha: 'blob-b' }], truncated: false } } as unknown as RequestUrlResponse); // fresh tree
|
||||
|
||||
const result = await service.pushBatch(
|
||||
[{ path: 'a.md', content: 'hello' }, { path: 'b.md', content: 'world' }],
|
||||
'main',
|
||||
'Push 2 file(s) from Obsidian'
|
||||
);
|
||||
|
||||
expect(result).toEqual([{ path: 'a.md', sha: 'blob-a' }, { path: 'b.md', sha: 'blob-b' }]);
|
||||
|
||||
const calls = vi.mocked(requestUrl).mock.calls.map(c => c[0] as RequestUrlParam);
|
||||
expect(calls).toHaveLength(3);
|
||||
expect(calls[1]?.url).toBe('https://api.github.com/graphql');
|
||||
expect(calls[1]?.method).toBe('POST');
|
||||
|
||||
const mutationBody = JSON.parse(calls[1]?.body as string) as {
|
||||
variables: { input: { branch: { repositoryNameWithOwner: string; branchName: string }; message: { headline: string }; expectedHeadOid: string; fileChanges: { additions: Array<{ path: string; contents: string }> } } };
|
||||
};
|
||||
const input = mutationBody.variables.input;
|
||||
expect(input.branch).toEqual({ repositoryNameWithOwner: `${owner}/${repo}`, branchName: 'main' });
|
||||
expect(input.message).toEqual({ headline: 'Push 2 file(s) from Obsidian' });
|
||||
expect(input.expectedHeadOid).toBe('commit1');
|
||||
// contents are base64-encoded (not pushSymlink's raw utf-8 path), so binary content works too.
|
||||
expect(atob(input.fileChanges.additions[0]!.contents)).toBe('hello');
|
||||
expect(input.fileChanges.additions).toEqual([
|
||||
{ path: 'a.md', contents: btoa('hello') },
|
||||
{ path: 'b.md', contents: btoa('world') },
|
||||
]);
|
||||
});
|
||||
|
||||
it('throws when the GraphQL response reports errors on an HTTP 200', async () => {
|
||||
// GraphQL reports mutation failures (e.g. a stale expectedHeadOid) as a
|
||||
// 200 response with an `errors` array, not an HTTP error status.
|
||||
vi.mocked(requestUrl)
|
||||
.mockResolvedValueOnce({ status: 200, json: { object: { sha: 'commit1' } } } as unknown as RequestUrlResponse) // get ref
|
||||
.mockResolvedValueOnce({ status: 200, json: { errors: [{ message: 'Head sha was modified' }] } } as unknown as RequestUrlResponse); // mutation failure
|
||||
|
||||
await expect(service.pushBatch(
|
||||
[{ path: 'a.md', content: 'hello' }],
|
||||
'main',
|
||||
'Push 1 file(s) from Obsidian'
|
||||
)).rejects.toThrow('Head sha was modified');
|
||||
});
|
||||
|
||||
it('retries with a freshly re-read HEAD when the mutation reports a stale-expectedHeadOid-shaped error', async () => {
|
||||
// Regression test: a push immediately followed by another commit to the
|
||||
// same branch (e.g. push then delete) can read a HEAD that hasn't caught
|
||||
// up yet, so a file the caller expects to exist/not-exist isn't there —
|
||||
// GitHub reports this as "path does not exist in tree <oid>", not as an
|
||||
// obviously-named staleness error. A retry with a fresh HEAD self-heals.
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
vi.mocked(requestUrl)
|
||||
.mockResolvedValueOnce({ status: 200, json: { object: { sha: 'stale-commit' } } } as unknown as RequestUrlResponse) // get ref (stale)
|
||||
.mockResolvedValueOnce({ status: 200, json: { errors: [{ message: 'A path was requested for deletion, but that path does not exist in tree `stale-commit`' }] } } as unknown as RequestUrlResponse) // mutation fails
|
||||
.mockResolvedValueOnce({ status: 200, json: { object: { sha: 'fresh-commit' } } } as unknown as RequestUrlResponse) // get ref (fresh, retry)
|
||||
.mockResolvedValueOnce({ status: 200, json: { data: { createCommitOnBranch: { commit: { oid: 'commit2' } } } } } as unknown as RequestUrlResponse) // mutation succeeds
|
||||
.mockResolvedValueOnce({ status: 200, json: { tree: [{ path: 'a.md', type: 'blob', sha: 'blob-a' }], truncated: false } } as unknown as RequestUrlResponse); // fresh tree
|
||||
|
||||
const resultPromise = service.pushBatch([{ path: 'a.md', content: 'hello' }], 'main', 'Push 1 file(s) from Obsidian');
|
||||
await vi.runAllTimersAsync();
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(result).toEqual([{ path: 'a.md', sha: 'blob-a' }]);
|
||||
const calls = vi.mocked(requestUrl).mock.calls.map(c => c[0] as RequestUrlParam);
|
||||
expect(calls).toHaveLength(5);
|
||||
const firstMutation = JSON.parse(calls[1]?.body as string) as { variables: { input: { expectedHeadOid: string } } };
|
||||
const retryMutation = JSON.parse(calls[3]?.body as string) as { variables: { input: { expectedHeadOid: string } } };
|
||||
expect(firstMutation.variables.input.expectedHeadOid).toBe('stale-commit');
|
||||
expect(retryMutation.variables.input.expectedHeadOid).toBe('fresh-commit');
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not retry an unrelated GraphQL error', async () => {
|
||||
vi.mocked(requestUrl)
|
||||
.mockResolvedValueOnce({ status: 200, json: { object: { sha: 'commit1' } } } as unknown as RequestUrlResponse) // get ref
|
||||
.mockResolvedValueOnce({ status: 200, json: { errors: [{ message: 'Resource not accessible by integration' }] } } as unknown as RequestUrlResponse); // unrelated failure
|
||||
|
||||
await expect(service.pushBatch(
|
||||
[{ path: 'a.md', content: 'hello' }],
|
||||
'main',
|
||||
'Push 1 file(s) from Obsidian'
|
||||
)).rejects.toThrow('Resource not accessible by integration');
|
||||
|
||||
expect(requestUrl).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('retries the follow-up tree fetch when it is still missing a just-committed file', async () => {
|
||||
// The tree-by-branch-name read used to recover blob shas after the
|
||||
// commit succeeds is exposed to the same eventual-consistency lag as
|
||||
// the expectedHeadOid read — it can briefly omit a file that was just
|
||||
// written, rather than erroring outright.
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
vi.mocked(requestUrl)
|
||||
.mockResolvedValueOnce({ status: 200, json: { object: { sha: 'commit1' } } } as unknown as RequestUrlResponse) // get ref
|
||||
.mockResolvedValueOnce({ status: 200, json: { data: { createCommitOnBranch: { commit: { oid: 'commit2' } } } } } as unknown as RequestUrlResponse) // mutation succeeds
|
||||
.mockResolvedValueOnce({ status: 200, json: { tree: [], truncated: false } } as unknown as RequestUrlResponse) // stale tree, missing a.md
|
||||
.mockResolvedValueOnce({ status: 200, json: { tree: [{ path: 'a.md', type: 'blob', sha: 'blob-a' }], truncated: false } } as unknown as RequestUrlResponse); // fresh tree
|
||||
|
||||
const resultPromise = service.pushBatch([{ path: 'a.md', content: 'hello' }], 'main', 'Push 1 file(s) from Obsidian');
|
||||
await vi.runAllTimersAsync();
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(result).toEqual([{ path: 'a.md', sha: 'blob-a' }]);
|
||||
expect(requestUrl).toHaveBeenCalledTimes(4);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('pushFile', () => {
|
||||
it('should push new file correctly (no sha provided)', async () => {
|
||||
vi.mocked(requestUrl).mockResolvedValueOnce({
|
||||
|
|
@ -196,6 +321,33 @@ describe('GitHubService', () => {
|
|||
mockRequest({ status: 404, json: { message: 'Not Found' }, text: 'Not Found' });
|
||||
await expect(service.listFiles('missing-branch')).rejects.toThrow(/Branch "missing-branch" was not found/);
|
||||
});
|
||||
|
||||
it('listFilesDetailed includes each blob\'s sha', async () => {
|
||||
mockRequest({ status: 200, json: { tree: [
|
||||
{ path: 'file1.md', type: 'blob', sha: 'sha-1' },
|
||||
{ path: 'file2.md', type: 'blob', sha: 'sha-2' },
|
||||
] } });
|
||||
expect(await service.listFilesDetailed('main')).toEqual([
|
||||
{ path: 'file1.md', symlink: false, sha: 'sha-1' },
|
||||
{ path: 'file2.md', symlink: false, sha: 'sha-2' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBlob', () => {
|
||||
it('decodes base64 blob content by sha', async () => {
|
||||
mockRequest({ status: 200, json: { content: btoa('hello world'), encoding: 'base64', sha: 'blob-sha' } });
|
||||
const result = await service.getBlob('blob-sha', 'test.md');
|
||||
expect(result.content).toBe('hello world');
|
||||
expect(result.sha).toBe('blob-sha');
|
||||
});
|
||||
|
||||
it('requests the blob endpoint by sha, not path', async () => {
|
||||
mockRequest({ status: 200, json: { content: btoa('x'), sha: 'abc123' } });
|
||||
await service.getBlob('abc123', 'test.md');
|
||||
const call = getLastRequestCall();
|
||||
expect(call.url).toContain('/git/blobs/abc123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteFile', () => {
|
||||
|
|
@ -212,6 +364,91 @@ describe('GitHubService', () => {
|
|||
expect(deleteCall.method).toBe('DELETE');
|
||||
expect(deleteCall.body).toContain('"sha":"file-sha"');
|
||||
});
|
||||
|
||||
it('should throw instead of sending an empty sha when the pre-delete lookup 404s', async () => {
|
||||
mockRequest({ status: 404 });
|
||||
|
||||
await expect(service.deleteFile('missing.md', 'main', 'delete missing.md')).rejects.toThrow('missing.md');
|
||||
|
||||
const calls = vi.mocked(requestUrl).mock.calls;
|
||||
expect(calls).toHaveLength(1); // no DELETE request was sent
|
||||
});
|
||||
|
||||
it('should URL-encode path segments with spaces or non-ASCII characters', async () => {
|
||||
vi.mocked(requestUrl)
|
||||
.mockResolvedValueOnce({ status: 200, json: { content: btoa('content'), sha: 'file-sha' } } as unknown as RequestUrlResponse)
|
||||
.mockResolvedValueOnce({ status: 200, json: {} } as unknown as RequestUrlResponse);
|
||||
|
||||
await service.deleteFile('folder/我的 筆記.md', 'main', 'delete note');
|
||||
|
||||
const calls = vi.mocked(requestUrl).mock.calls;
|
||||
const getCall = calls[0]?.[0] as RequestUrlParam;
|
||||
expect(getCall.url).toContain('/contents/folder/');
|
||||
expect(getCall.url).not.toContain(' ');
|
||||
expect(getCall.url).not.toContain('我的');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteBatch', () => {
|
||||
it('returns and makes no requests for an empty path list', async () => {
|
||||
await service.deleteBatch([], 'main', 'delete nothing');
|
||||
expect(requestUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deletes N files in one GraphQL mutation via ref -> createCommitOnBranch', async () => {
|
||||
vi.mocked(requestUrl)
|
||||
.mockResolvedValueOnce({ status: 200, json: { object: { sha: 'commit1' } } } as unknown as RequestUrlResponse) // get ref
|
||||
.mockResolvedValueOnce({ status: 200, json: { data: { createCommitOnBranch: { commit: { oid: 'commit2' } } } } } as unknown as RequestUrlResponse); // mutation
|
||||
|
||||
await service.deleteBatch(['a.md', 'b.md'], 'main', 'Delete 2 file(s) from Obsidian');
|
||||
|
||||
const calls = vi.mocked(requestUrl).mock.calls.map(c => c[0] as RequestUrlParam);
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls[1]?.url).toBe('https://api.github.com/graphql');
|
||||
|
||||
const mutationBody = JSON.parse(calls[1]?.body as string) as {
|
||||
variables: { input: { expectedHeadOid: string; message: { headline: string }; fileChanges: { deletions: Array<{ path: string }> } } };
|
||||
};
|
||||
const input = mutationBody.variables.input;
|
||||
expect(input.expectedHeadOid).toBe('commit1');
|
||||
expect(input.message).toEqual({ headline: 'Delete 2 file(s) from Obsidian' });
|
||||
expect(input.fileChanges.deletions).toEqual([{ path: 'a.md' }, { path: 'b.md' }]);
|
||||
});
|
||||
|
||||
it('throws when the GraphQL response reports errors on an HTTP 200', async () => {
|
||||
vi.mocked(requestUrl)
|
||||
.mockResolvedValueOnce({ status: 200, json: { object: { sha: 'commit1' } } } as unknown as RequestUrlResponse) // get ref
|
||||
.mockResolvedValueOnce({ status: 200, json: { errors: [{ message: 'Head sha was modified' }] } } as unknown as RequestUrlResponse); // mutation failure
|
||||
|
||||
await expect(service.deleteBatch(['a.md'], 'main', 'Delete 1 file(s) from Obsidian'))
|
||||
.rejects.toThrow('Head sha was modified');
|
||||
});
|
||||
|
||||
it('retries with a freshly re-read HEAD when the mutation reports a stale-expectedHeadOid-shaped error', async () => {
|
||||
// Regression test for the reported bug: pushing files and immediately
|
||||
// batch-deleting them (or vice versa) can read a HEAD that hasn't
|
||||
// caught up to the just-completed write yet, so GitHub reports the
|
||||
// to-be-deleted path as not existing in that (stale) tree.
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
vi.mocked(requestUrl)
|
||||
.mockResolvedValueOnce({ status: 200, json: { object: { sha: 'stale-commit' } } } as unknown as RequestUrlResponse) // get ref (stale)
|
||||
.mockResolvedValueOnce({ status: 200, json: { errors: [{ message: 'A path was requested for deletion, but that path does not exist in tree `stale-commit`' }] } } as unknown as RequestUrlResponse) // mutation fails
|
||||
.mockResolvedValueOnce({ status: 200, json: { object: { sha: 'fresh-commit' } } } as unknown as RequestUrlResponse) // get ref (fresh, retry)
|
||||
.mockResolvedValueOnce({ status: 200, json: { data: { createCommitOnBranch: { commit: { oid: 'commit2' } } } } } as unknown as RequestUrlResponse); // mutation succeeds
|
||||
|
||||
const resultPromise = service.deleteBatch(['a.md'], 'main', 'Delete 1 file(s) from Obsidian');
|
||||
await vi.runAllTimersAsync();
|
||||
await resultPromise;
|
||||
|
||||
const calls = vi.mocked(requestUrl).mock.calls.map(c => c[0] as RequestUrlParam);
|
||||
expect(calls).toHaveLength(4);
|
||||
const retryMutation = JSON.parse(calls[3]?.body as string) as { variables: { input: { expectedHeadOid: string } } };
|
||||
expect(retryMutation.variables.input.expectedHeadOid).toBe('fresh-commit');
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('testConnection', () => {
|
||||
|
|
|
|||
|
|
@ -68,6 +68,56 @@ describe('GitLabService', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('pushBatch', () => {
|
||||
it('returns [] and makes no requests for an empty item list', async () => {
|
||||
const result = await service.pushBatch([], 'main', 'push nothing');
|
||||
expect(result).toEqual([]);
|
||||
expect(requestUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('commits via the Commits API actions array, then reads back shas from a follow-up tree fetch', async () => {
|
||||
vi.mocked(requestUrl)
|
||||
.mockResolvedValueOnce({ status: 201, json: { id: 'commit-sha' } } as unknown as RequestUrlResponse) // POST commits
|
||||
.mockResolvedValueOnce({ status: 200, json: [
|
||||
{ path: 'a.md', type: 'blob', id: 'new-sha-a' },
|
||||
{ path: 'b.md', type: 'blob', id: 'new-sha-b' },
|
||||
] } as unknown as RequestUrlResponse); // follow-up listFilesDetailed
|
||||
|
||||
const result = await service.pushBatch(
|
||||
[
|
||||
{ path: 'a.md', content: 'hello', existedRemotely: true },
|
||||
{ path: 'b.md', content: 'world', existedRemotely: false },
|
||||
],
|
||||
'main',
|
||||
'Push 2 file(s) from Obsidian'
|
||||
);
|
||||
|
||||
expect(result).toEqual([{ path: 'a.md', sha: 'new-sha-a' }, { path: 'b.md', sha: 'new-sha-b' }]);
|
||||
|
||||
const calls = vi.mocked(requestUrl).mock.calls;
|
||||
expect(calls).toHaveLength(2);
|
||||
const commitCall = calls[0]?.[0] as { url: string; method: string; body: string };
|
||||
expect(commitCall.url).toBe(`${baseUrl}/api/v4/projects/${projectId}/repository/commits`);
|
||||
expect(commitCall.method).toBe('POST');
|
||||
const body = JSON.parse(commitCall.body) as { branch: string; commit_message: string; actions: Array<{ action: string; file_path: string; content: string; encoding: string }> };
|
||||
expect(body.branch).toBe('main');
|
||||
expect(body.commit_message).toBe('Push 2 file(s) from Obsidian');
|
||||
expect(body.actions).toEqual([
|
||||
{ action: 'update', file_path: 'a.md', content: btoa('hello'), encoding: 'base64' },
|
||||
{ action: 'create', file_path: 'b.md', content: btoa('world'), encoding: 'base64' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns sha: undefined for a pushed path missing from the follow-up tree', async () => {
|
||||
vi.mocked(requestUrl)
|
||||
.mockResolvedValueOnce({ status: 201, json: { id: 'commit-sha' } } as unknown as RequestUrlResponse)
|
||||
.mockResolvedValueOnce({ status: 200, json: [] } as unknown as RequestUrlResponse);
|
||||
|
||||
const result = await service.pushBatch([{ path: 'a.md', content: 'hello' }], 'main', 'push');
|
||||
expect(result).toEqual([{ path: 'a.md', sha: undefined }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listFiles', () => {
|
||||
it('should list blob files from tree API', async () => {
|
||||
mockRequest({ status: 200, json: [
|
||||
|
|
@ -148,6 +198,38 @@ describe('GitLabService', () => {
|
|||
mockRequest({ status: 404, json: { message: '404 Branch Not Found' }, text: '404 Branch Not Found' });
|
||||
await expect(service.listFiles('missing-branch')).rejects.toThrow(/Branch "missing-branch" was not found/);
|
||||
});
|
||||
|
||||
it('listFilesDetailed maps GitLab\'s "id" field to sha', async () => {
|
||||
mockRequest({ status: 200, json: [
|
||||
{ path: 'file1.md', type: 'blob', id: 'id-as-sha-1' },
|
||||
] });
|
||||
expect(await service.listFilesDetailed('main')).toEqual([
|
||||
{ path: 'file1.md', symlink: false, sha: 'id-as-sha-1' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBlob', () => {
|
||||
it('returns the raw text content for a non-binary path', async () => {
|
||||
mockRequest({ status: 200, text: 'hello world' });
|
||||
const result = await service.getBlob('blob-sha', 'test.md');
|
||||
expect(result.content).toBe('hello world');
|
||||
expect(result.sha).toBe('blob-sha');
|
||||
});
|
||||
|
||||
it('requests the raw blob endpoint by sha', async () => {
|
||||
mockRequest({ status: 200, text: 'x' });
|
||||
await service.getBlob('abc123', 'test.md');
|
||||
const call = getLastRequestCall();
|
||||
expect(call.url).toBe(`${baseUrl}/api/v4/projects/${projectId}/repository/blobs/abc123/raw`);
|
||||
});
|
||||
|
||||
it('returns arrayBuffer content for a binary path', async () => {
|
||||
const buf = new ArrayBuffer(4);
|
||||
mockRequest({ status: 200, arrayBuffer: buf });
|
||||
const result = await service.getBlob('blob-sha', 'image.png');
|
||||
expect(result.content).toBe(buf);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteFile', () => {
|
||||
|
|
@ -160,6 +242,30 @@ describe('GitLabService', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('deleteBatch', () => {
|
||||
it('returns and makes no requests for an empty path list', async () => {
|
||||
await service.deleteBatch([], 'main', 'delete nothing');
|
||||
expect(requestUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('posts a Commits API actions array with action: delete, no content/encoding', async () => {
|
||||
mockRequest({ status: 201, json: { id: 'commit-sha' } });
|
||||
|
||||
await service.deleteBatch(['a.md', 'b.md'], 'main', 'Delete 2 file(s) from Obsidian');
|
||||
|
||||
const call = getLastRequestCall();
|
||||
expect(call.url).toBe(`${baseUrl}/api/v4/projects/${projectId}/repository/commits`);
|
||||
expect(call.method).toBe('POST');
|
||||
const body = JSON.parse(call.body as string) as { branch: string; commit_message: string; actions: Array<{ action: string; file_path: string; content?: string; encoding?: string }> };
|
||||
expect(body.branch).toBe('main');
|
||||
expect(body.commit_message).toBe('Delete 2 file(s) from Obsidian');
|
||||
expect(body.actions).toEqual([
|
||||
{ action: 'delete', file_path: 'a.md' },
|
||||
{ action: 'delete', file_path: 'b.md' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('testConnection', () => {
|
||||
sharedTestConnection(() => service);
|
||||
|
||||
|
|
|
|||
243
tests/setup.ts
243
tests/setup.ts
|
|
@ -5,38 +5,238 @@ if (typeof document === 'undefined') {
|
|||
(globalThis as unknown as { document: unknown }).document = {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
createElement: vi.fn(() => ({
|
||||
classList: { add: vi.fn(), contains: vi.fn(), toggle: vi.fn() },
|
||||
appendChild: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
setAttribute: vi.fn(),
|
||||
textContent: '',
|
||||
type: 'text',
|
||||
empty() {},
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (typeof window === 'undefined') {
|
||||
(globalThis as unknown as { window: unknown }).window = {
|
||||
setInterval: vi.fn(),
|
||||
clearInterval: vi.fn(),
|
||||
// Delegate to the global timers (not bound methods captured once) so
|
||||
// vi.useFakeTimers()/vi.useRealTimers() — which patch globalThis — keep
|
||||
// controlling window.setTimeout/clearTimeout too.
|
||||
setTimeout: (handler: (...args: unknown[]) => void, timeout?: number) => globalThis.setTimeout(handler, timeout),
|
||||
clearTimeout: (handle?: ReturnType<typeof globalThis.setTimeout>) => globalThis.clearTimeout(handle),
|
||||
};
|
||||
}
|
||||
|
||||
// Mock Obsidian API components
|
||||
function createButtonElement(): HTMLButtonElement {
|
||||
return document.createElement('button');
|
||||
}
|
||||
|
||||
export const Plugin = class {};
|
||||
export const PluginSettingTab = class {
|
||||
constructor() {}
|
||||
app: unknown;
|
||||
plugin: unknown;
|
||||
containerEl: HTMLElement;
|
||||
|
||||
constructor(app?: unknown, plugin?: unknown) {
|
||||
this.app = app;
|
||||
this.plugin = plugin;
|
||||
this.containerEl = document.createElement('div') as HTMLElement;
|
||||
}
|
||||
};
|
||||
|
||||
class BaseTextComponent<T extends HTMLInputElement | HTMLTextAreaElement> {
|
||||
inputEl: T;
|
||||
protected changeHandler?: (value: string) => void;
|
||||
|
||||
constructor(inputEl: T) {
|
||||
this.inputEl = inputEl;
|
||||
}
|
||||
|
||||
setPlaceholder(value: string) {
|
||||
this.inputEl.placeholder = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
setValue(value: string) {
|
||||
this.inputEl.value = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
onChange(handler: (value: string) => void) {
|
||||
this.changeHandler = handler;
|
||||
return this;
|
||||
}
|
||||
|
||||
triggerChange(value: string) {
|
||||
this.inputEl.value = value;
|
||||
this.changeHandler?.(value);
|
||||
}
|
||||
}
|
||||
|
||||
export const TextComponent = class extends BaseTextComponent<HTMLInputElement> {
|
||||
constructor(containerEl?: HTMLElement) {
|
||||
const inputEl = document.createElement('input');
|
||||
containerEl?.appendChild(inputEl);
|
||||
super(inputEl);
|
||||
}
|
||||
};
|
||||
|
||||
export const TextAreaComponent = class extends BaseTextComponent<HTMLTextAreaElement> {
|
||||
constructor(containerEl?: HTMLElement) {
|
||||
const inputEl = document.createElement('textarea');
|
||||
containerEl?.appendChild(inputEl);
|
||||
super(inputEl);
|
||||
}
|
||||
};
|
||||
|
||||
export const DropdownComponent = class {
|
||||
private changeHandler?: (value: string) => void;
|
||||
|
||||
addOption() { return this; }
|
||||
setValue() { return this; }
|
||||
onChange(handler: (value: string) => void) {
|
||||
this.changeHandler = handler;
|
||||
return this;
|
||||
}
|
||||
triggerChange(value: string) {
|
||||
this.changeHandler?.(value);
|
||||
}
|
||||
};
|
||||
|
||||
export const ButtonComponent = class {
|
||||
buttonEl: HTMLButtonElement;
|
||||
|
||||
constructor(containerEl?: HTMLElement) {
|
||||
this.buttonEl = createButtonElement();
|
||||
containerEl?.appendChild(this.buttonEl);
|
||||
}
|
||||
|
||||
setButtonText(text: string) {
|
||||
this.buttonEl.textContent = text;
|
||||
return this;
|
||||
}
|
||||
|
||||
setTooltip(text: string) {
|
||||
this.buttonEl.title = text;
|
||||
return this;
|
||||
}
|
||||
|
||||
setCta() {
|
||||
this.buttonEl.classList.add('mod-cta');
|
||||
return this;
|
||||
}
|
||||
|
||||
setWarning() {
|
||||
this.buttonEl.classList.add('mod-warning');
|
||||
return this;
|
||||
}
|
||||
|
||||
setDestructive() {
|
||||
this.buttonEl.classList.add('mod-destructive');
|
||||
return this;
|
||||
}
|
||||
|
||||
setIcon(icon: string) {
|
||||
this.buttonEl.dataset.icon = icon;
|
||||
return this;
|
||||
}
|
||||
|
||||
onClick(handler: () => void) {
|
||||
this.buttonEl.addEventListener('click', handler);
|
||||
return this;
|
||||
}
|
||||
};
|
||||
|
||||
export const ExtraButtonComponent = class extends ButtonComponent {};
|
||||
|
||||
export const Setting = class {
|
||||
constructor() {}
|
||||
containerEl?: HTMLElement;
|
||||
|
||||
constructor(containerEl?: HTMLElement) {
|
||||
this.containerEl = containerEl;
|
||||
}
|
||||
|
||||
setName() { return this; }
|
||||
setDesc() { return this; }
|
||||
addText() { return this; }
|
||||
setHeading() { return this; }
|
||||
addToggle() { return this; }
|
||||
addButton() { return this; }
|
||||
addText(callback?: (component: InstanceType<typeof TextComponent>) => void) {
|
||||
if (callback) callback(new TextComponent(this.containerEl));
|
||||
return this;
|
||||
}
|
||||
addTextArea(callback?: (component: InstanceType<typeof TextAreaComponent>) => void) {
|
||||
if (callback) callback(new TextAreaComponent(this.containerEl));
|
||||
return this;
|
||||
}
|
||||
addButton(callback?: (component: InstanceType<typeof ButtonComponent>) => void) {
|
||||
if (callback) callback(new ButtonComponent(this.containerEl));
|
||||
return this;
|
||||
}
|
||||
addExtraButton(callback?: (component: InstanceType<typeof ExtraButtonComponent>) => void) {
|
||||
if (callback) callback(new ExtraButtonComponent(this.containerEl));
|
||||
return this;
|
||||
}
|
||||
addDropdown(callback?: (component: InstanceType<typeof DropdownComponent>) => void) {
|
||||
if (callback) callback(new DropdownComponent());
|
||||
return this;
|
||||
}
|
||||
};
|
||||
|
||||
export const Notice = class {
|
||||
constructor() {}
|
||||
setMessage() {}
|
||||
hide() {}
|
||||
};
|
||||
|
||||
export const Modal = class {
|
||||
constructor() {}
|
||||
open() {}
|
||||
close() {}
|
||||
app: unknown;
|
||||
contentEl: HTMLElement;
|
||||
|
||||
constructor(app?: unknown) {
|
||||
this.app = app;
|
||||
this.contentEl = document.createElement('div') as HTMLElement;
|
||||
}
|
||||
|
||||
open() {
|
||||
const withOnOpen = this as unknown as { onOpen?: () => void };
|
||||
if (typeof withOnOpen.onOpen === 'function') {
|
||||
withOnOpen.onOpen();
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
const withOnClose = this as unknown as { onClose?: () => void };
|
||||
if (typeof withOnClose.onClose === 'function') {
|
||||
withOnClose.onClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const MarkdownView = class {};
|
||||
export const Editor = class {};
|
||||
export const WorkspaceLeaf = class {};
|
||||
export const ItemView = class {
|
||||
app: unknown;
|
||||
leaf: unknown;
|
||||
containerEl: HTMLElement;
|
||||
|
||||
constructor(leaf?: { app?: unknown }) {
|
||||
this.leaf = leaf;
|
||||
this.app = leaf?.app;
|
||||
// Real ItemView's containerEl has a header at children[0] and the
|
||||
// content root at children[1]; views render into the latter.
|
||||
this.containerEl = document.createElement('div') as HTMLElement;
|
||||
this.containerEl.appendChild(document.createElement('div'));
|
||||
this.containerEl.appendChild(document.createElement('div'));
|
||||
}
|
||||
|
||||
registerEvent() {}
|
||||
register() {}
|
||||
registerDomEvent() {}
|
||||
registerInterval() {}
|
||||
};
|
||||
export const App = class {
|
||||
workspace = {
|
||||
getActiveViewOfType: vi.fn(),
|
||||
|
|
@ -47,6 +247,7 @@ export const App = class {
|
|||
modify: vi.fn(),
|
||||
getFileByPath: vi.fn(),
|
||||
on: vi.fn(),
|
||||
configDir: 'mock-config-dir',
|
||||
adapter: {
|
||||
getBasePath: vi.fn().mockReturnValue('/mock/path'),
|
||||
},
|
||||
|
|
@ -54,9 +255,24 @@ export const App = class {
|
|||
};
|
||||
|
||||
export const TFile = class {};
|
||||
export const TFolder = class {};
|
||||
export const AbstractInputSuggest = class {
|
||||
app: unknown;
|
||||
constructor(app: unknown) {
|
||||
this.app = app;
|
||||
}
|
||||
setValue() {}
|
||||
getValue() { return ''; }
|
||||
close() {}
|
||||
open() {}
|
||||
};
|
||||
export const requestUrl = vi.fn();
|
||||
export const setTooltip = vi.fn();
|
||||
export const setIcon = vi.fn();
|
||||
export const Platform = { isDesktopApp: true };
|
||||
export const FileSystemAdapter = class {
|
||||
getBasePath() { return '/mock/path'; }
|
||||
};
|
||||
|
||||
vi.mock('obsidian', () => ({
|
||||
Plugin,
|
||||
|
|
@ -66,9 +282,20 @@ vi.mock('obsidian', () => ({
|
|||
Modal,
|
||||
MarkdownView,
|
||||
Editor,
|
||||
WorkspaceLeaf,
|
||||
ItemView,
|
||||
App,
|
||||
TFile,
|
||||
TFolder,
|
||||
AbstractInputSuggest,
|
||||
TextComponent,
|
||||
TextAreaComponent,
|
||||
DropdownComponent,
|
||||
ButtonComponent,
|
||||
ExtraButtonComponent,
|
||||
requestUrl,
|
||||
setTooltip,
|
||||
setIcon,
|
||||
Platform,
|
||||
FileSystemAdapter,
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -11,9 +11,9 @@ describe('renderDiffPanel', () => {
|
|||
container = createContainer();
|
||||
});
|
||||
|
||||
it('returns the diff element with ssv-diff class', () => {
|
||||
const el = renderDiffPanel(container, '', '');
|
||||
expect(el.classList.contains('ssv-diff')).toBe(true);
|
||||
it('renders the diff grid into the given container', () => {
|
||||
renderDiffPanel(container, '', '');
|
||||
expect(container.querySelector('.ssv-diff-grid')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('renders Remote and Local column headers', () => {
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ describe('renderFileItem', () => {
|
|||
onPush: vi.fn(),
|
||||
onPull: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onExpandDiff: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -119,33 +120,27 @@ describe('renderFileItem', () => {
|
|||
expect(callbacks.onPull).toHaveBeenCalledWith(fs);
|
||||
});
|
||||
|
||||
it('renders diff toggle button when diff content is present', () => {
|
||||
const fs = makeFileStatus('modified', { diff: 'some diff', localContent: 'b', remoteContent: 'a' });
|
||||
it('renders diff toggle button for any modified file, even without preloaded content', () => {
|
||||
const fs = makeFileStatus('modified', { file: mockFile });
|
||||
renderFileItem(container, fs, false, callbacks);
|
||||
expect(container.querySelector('.ssv-action-btn.diff')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('does not render diff toggle when no diff', () => {
|
||||
const fs = makeFileStatus('modified', { file: mockFile });
|
||||
renderFileItem(container, fs, false, callbacks);
|
||||
expect(container.querySelector('.ssv-action-btn.diff')).toBeNull();
|
||||
});
|
||||
|
||||
it('diff panel is not visible before toggle', () => {
|
||||
const fs = makeFileStatus('modified', { diff: 'd', localContent: 'b', remoteContent: 'a' });
|
||||
const fs = makeFileStatus('modified', { localContent: 'b', remoteContent: 'a' });
|
||||
renderFileItem(container, fs, false, callbacks);
|
||||
expect(container.querySelector('.ssv-diff')?.classList.contains('visible')).toBe(false);
|
||||
});
|
||||
|
||||
it('diff panel becomes visible on first click', () => {
|
||||
const fs = makeFileStatus('modified', { diff: 'd', localContent: 'b', remoteContent: 'a' });
|
||||
const fs = makeFileStatus('modified', { localContent: 'b', remoteContent: 'a' });
|
||||
renderFileItem(container, fs, false, callbacks);
|
||||
(container.querySelector('.ssv-action-btn.diff') as HTMLButtonElement).click();
|
||||
expect(container.querySelector('.ssv-diff')?.classList.contains('visible')).toBe(true);
|
||||
});
|
||||
|
||||
it('diff panel hides on second click', () => {
|
||||
const fs = makeFileStatus('modified', { diff: 'd', localContent: 'b', remoteContent: 'a' });
|
||||
const fs = makeFileStatus('modified', { localContent: 'b', remoteContent: 'a' });
|
||||
renderFileItem(container, fs, false, callbacks);
|
||||
const btn = container.querySelector('.ssv-action-btn.diff') as HTMLButtonElement;
|
||||
btn.click();
|
||||
|
|
@ -154,7 +149,7 @@ describe('renderFileItem', () => {
|
|||
});
|
||||
|
||||
it('diff button label toggles between " Diff" and " Hide"', () => {
|
||||
const fs = makeFileStatus('modified', { diff: 'd', localContent: 'b', remoteContent: 'a' });
|
||||
const fs = makeFileStatus('modified', { localContent: 'b', remoteContent: 'a' });
|
||||
renderFileItem(container, fs, false, callbacks);
|
||||
const btn = container.querySelector('.ssv-action-btn.diff') as HTMLButtonElement;
|
||||
const label = btn.querySelector('.ssv-btn-label') as HTMLElement;
|
||||
|
|
@ -164,6 +159,29 @@ describe('renderFileItem', () => {
|
|||
btn.click();
|
||||
expect(label.textContent).toBe(' Diff');
|
||||
});
|
||||
|
||||
it('renders preloaded diff content immediately without fetching', () => {
|
||||
const fs = makeFileStatus('modified', { localContent: 'b', remoteContent: 'a' });
|
||||
renderFileItem(container, fs, false, callbacks);
|
||||
(container.querySelector('.ssv-action-btn.diff') as HTMLButtonElement).click();
|
||||
expect(container.querySelector('.ssv-diff-grid')).not.toBeNull();
|
||||
expect(callbacks.onExpandDiff).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows a loading placeholder and fetches content on demand when not preloaded', () => {
|
||||
const fs = makeFileStatus('modified', { file: mockFile, remoteSha: 'abc123' });
|
||||
renderFileItem(container, fs, false, callbacks);
|
||||
(container.querySelector('.ssv-action-btn.diff') as HTMLButtonElement).click();
|
||||
expect(callbacks.onExpandDiff).toHaveBeenCalledWith(fs);
|
||||
});
|
||||
|
||||
it('shows a symlink message instead of a text diff for symlink entries', async () => {
|
||||
const fs = makeFileStatus('modified', { file: mockFile, remoteSha: 'abc123', isSymlink: true });
|
||||
renderFileItem(container, fs, false, callbacks);
|
||||
(container.querySelector('.ssv-action-btn.diff') as HTMLButtonElement).click();
|
||||
expect(container.querySelector('.ssv-diff-binary')?.textContent).toBe('Symlink target changed');
|
||||
expect(callbacks.onExpandDiff).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('unsynced file', () => {
|
||||
|
|
|
|||
123
tests/ui/SettingsConnectionStatus.test.ts
Normal file
123
tests/ui/SettingsConnectionStatus.test.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
/* eslint-disable @typescript-eslint/no-deprecated -- exercising the Obsidian < 1.13 display() compat fallback intentionally */
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { App } from 'obsidian';
|
||||
import { DEFAULT_SETTINGS, GitLabSyncSettingTab } from '../../src/settings';
|
||||
import GitLabFilesPush from '../../src/main';
|
||||
import type { ConnectionTestResult } from '../../src/services/git-service-base';
|
||||
import { createContainer, setupObsidianDOM } from './setup-dom';
|
||||
|
||||
vi.mock('../../src/main', () => ({
|
||||
default: class {},
|
||||
}));
|
||||
|
||||
beforeAll(() => { setupObsidianDOM(); });
|
||||
|
||||
// Mirrors the connection-status pub/sub that main.ts owns (onConnectionStatusChange
|
||||
// / testConnection), since src/main is mocked out above and the settings tab now
|
||||
// delegates to the plugin instead of running its own connection test.
|
||||
function createPluginStub(testConnection: () => Promise<ConnectionTestResult>): GitLabFilesPush {
|
||||
let connectionStatus: { state: string; detail?: string } = { state: 'checking' };
|
||||
const listeners = new Set<(status: typeof connectionStatus) => void>();
|
||||
|
||||
return {
|
||||
settings: { ...DEFAULT_SETTINGS },
|
||||
manifest: { version: '0.0.0-test' },
|
||||
saveSettings: vi.fn().mockResolvedValue(undefined),
|
||||
initializeGitService: vi.fn(),
|
||||
gitService: { testConnection },
|
||||
onConnectionStatusChange: vi.fn((listener: (status: typeof connectionStatus) => void) => {
|
||||
listeners.add(listener);
|
||||
listener(connectionStatus);
|
||||
return () => listeners.delete(listener);
|
||||
}),
|
||||
testConnection: vi.fn(async () => {
|
||||
connectionStatus = { state: 'checking' };
|
||||
for (const listener of listeners) listener(connectionStatus);
|
||||
|
||||
const result = await testConnection();
|
||||
if (!result.repoOk) {
|
||||
connectionStatus = { state: 'disconnected', detail: result.error ?? 'unreachable' };
|
||||
} else if (!result.branchOk) {
|
||||
connectionStatus = { state: 'disconnected', detail: 'branch not found' };
|
||||
} else {
|
||||
connectionStatus = { state: 'connected' };
|
||||
}
|
||||
for (const listener of listeners) listener(connectionStatus);
|
||||
return result;
|
||||
}),
|
||||
} as unknown as GitLabFilesPush;
|
||||
}
|
||||
|
||||
function scheduleConnectionTest(tab: GitLabSyncSettingTab): void {
|
||||
(tab as unknown as { scheduleConnectionTest: () => void }).scheduleConnectionTest();
|
||||
}
|
||||
|
||||
describe('GitLabSyncSettingTab connection status badge', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
it('shows checking then connected after opening the tab', async () => {
|
||||
const testConnection = vi.fn().mockResolvedValue({ repoOk: true, branchOk: true });
|
||||
const tab = new GitLabSyncSettingTab(new App(), createPluginStub(testConnection));
|
||||
tab.containerEl = createContainer();
|
||||
|
||||
tab.display();
|
||||
|
||||
const badge = tab.containerEl.querySelector('.gfs-connection-status') as HTMLElement;
|
||||
expect(badge.classList.contains('is-checking')).toBe(true);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(800);
|
||||
|
||||
expect(testConnection).toHaveBeenCalledTimes(1);
|
||||
expect(badge.classList.contains('is-connected')).toBe(true);
|
||||
expect(badge.textContent).toBe('Connected');
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('debounces repeated field edits into a single connection test', async () => {
|
||||
const testConnection = vi.fn().mockResolvedValue({ repoOk: false, branchOk: false, error: 'bad token' });
|
||||
const plugin = createPluginStub(testConnection);
|
||||
const tab = new GitLabSyncSettingTab(new App(), plugin);
|
||||
tab.containerEl = createContainer();
|
||||
|
||||
tab.display();
|
||||
await vi.advanceTimersByTimeAsync(800);
|
||||
testConnection.mockClear();
|
||||
|
||||
// Simulate rapid keystrokes in the token field via the debounced hook directly.
|
||||
scheduleConnectionTest(tab);
|
||||
scheduleConnectionTest(tab);
|
||||
scheduleConnectionTest(tab);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(799);
|
||||
expect(testConnection).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(testConnection).toHaveBeenCalledTimes(1);
|
||||
|
||||
const badge = tab.containerEl.querySelector('.gfs-connection-status') as HTMLElement;
|
||||
expect(badge.classList.contains('is-disconnected')).toBe(true);
|
||||
expect(badge.textContent).toContain('bad token');
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GitLabSyncSettingTab ignore patterns setting', () => {
|
||||
it('renders a textarea seeded with the saved ignorePatterns value', async () => {
|
||||
const plugin = createPluginStub(vi.fn().mockResolvedValue({ repoOk: true, branchOk: true }));
|
||||
plugin.settings.ignorePatterns = 'draft/\n*.tmp';
|
||||
const tab = new GitLabSyncSettingTab(new App(), plugin);
|
||||
tab.containerEl = createContainer();
|
||||
|
||||
vi.useFakeTimers();
|
||||
tab.display();
|
||||
await vi.advanceTimersByTimeAsync(800);
|
||||
vi.useRealTimers();
|
||||
|
||||
const textarea = tab.containerEl.querySelector('textarea') as HTMLTextAreaElement;
|
||||
expect(textarea.value).toBe('draft/\n*.tmp');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { applyDestructiveStyle } from '../../src/ui/SyncConflictModal';
|
||||
import { beforeAll, describe, it, expect, vi } from 'vitest';
|
||||
import { App } from 'obsidian';
|
||||
import { applyDestructiveStyle, SyncConflictModal } from '../../src/ui/SyncConflictModal';
|
||||
import { createContainer, setupObsidianDOM } from './setup-dom';
|
||||
|
||||
// Guards the backward-compatibility fix that lets the plugin run on Obsidian
|
||||
// down to minAppVersion 1.11.0. ButtonComponent.setDestructive() only exists on
|
||||
|
|
@ -26,3 +28,30 @@ describe('applyDestructiveStyle (Obsidian version compatibility)', () => {
|
|||
expect(() => applyDestructiveStyle(btn)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SyncConflictModal', () => {
|
||||
beforeAll(() => { setupObsidianDOM(); });
|
||||
|
||||
it('defaults to the diff panel and switches panels via tabs', () => {
|
||||
const modal = new SyncConflictModal(new App(), 'note.md', 'local', 'remote', vi.fn());
|
||||
modal.contentEl = createContainer();
|
||||
|
||||
modal.onOpen();
|
||||
|
||||
const contentEl = modal.contentEl;
|
||||
const tabs = Array.from(contentEl.querySelectorAll<HTMLElement>('.conflict-tab'));
|
||||
const panels = Array.from(contentEl.querySelectorAll<HTMLElement>('.conflict-panel'));
|
||||
|
||||
const activePanel = () => panels.find(panel => panel.classList.contains('is-active'));
|
||||
const activeTab = () => tabs.find(tab => tab.classList.contains('is-active'));
|
||||
|
||||
expect(activeTab()?.textContent).toBe('Diff');
|
||||
expect(activePanel()?.classList.contains('conflict-diff-section')).toBe(true);
|
||||
|
||||
const localTab = tabs.find(tab => tab.textContent === 'Local');
|
||||
localTab?.dispatchEvent(new Event('click'));
|
||||
|
||||
expect(activeTab()?.textContent).toBe('Local');
|
||||
expect(activePanel()?.classList.contains('conflict-section')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
276
tests/ui/SyncStatusView.test.ts
Normal file
276
tests/ui/SyncStatusView.test.ts
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
import { describe, it, expect, vi, beforeAll } from 'vitest';
|
||||
import { SyncStatusView } from '../../src/ui/SyncStatusView';
|
||||
import { WorkspaceLeaf, Notice } from 'obsidian';
|
||||
import type GitLabFilesPush from '../../src/main';
|
||||
import { setupObsidianDOM } from './setup-dom';
|
||||
import type { FileStatus } from '../../src/ui/types';
|
||||
import type { GitTreeEntry } from '../../src/services/git-service-interface';
|
||||
|
||||
// Minimal fake plugin: only the surface these tests actually exercise.
|
||||
function makePlugin(overrides: {
|
||||
vaultFolder?: string;
|
||||
deleteFile?: ReturnType<typeof vi.fn>;
|
||||
deleteBatch?: ReturnType<typeof vi.fn>;
|
||||
adapterExists?: ReturnType<typeof vi.fn>;
|
||||
adapterStat?: ReturnType<typeof vi.fn>;
|
||||
getAbstractFileByPath?: ReturnType<typeof vi.fn>;
|
||||
} = {}): { plugin: GitLabFilesPush; leaf: WorkspaceLeaf; deleteFile: ReturnType<typeof vi.fn> } {
|
||||
const vaultFolder = overrides.vaultFolder ?? '';
|
||||
const deleteFile = overrides.deleteFile ?? vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const app = {
|
||||
vault: {
|
||||
adapter: {
|
||||
exists: overrides.adapterExists ?? vi.fn().mockResolvedValue(false),
|
||||
stat: overrides.adapterStat ?? vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
getAbstractFileByPath: overrides.getAbstractFileByPath ?? vi.fn().mockReturnValue(null),
|
||||
},
|
||||
};
|
||||
|
||||
const plugin = {
|
||||
settings: { branch: 'main', vaultFolder },
|
||||
gitService: { deleteFile, deleteBatch: overrides.deleteBatch },
|
||||
getNormalizedPath(path: string): string {
|
||||
if (!vaultFolder) return path;
|
||||
const prefix = `${vaultFolder}/`;
|
||||
if (path.startsWith(prefix)) return path.substring(prefix.length);
|
||||
if (path === vaultFolder) return '';
|
||||
return path;
|
||||
},
|
||||
} as unknown as GitLabFilesPush;
|
||||
|
||||
const leaf = { app } as unknown as WorkspaceLeaf;
|
||||
return { plugin, leaf, deleteFile };
|
||||
}
|
||||
|
||||
describe('SyncStatusView remote deletion', () => {
|
||||
beforeAll(() => { setupObsidianDOM(); });
|
||||
|
||||
// Regression test for the bug where deleteFile() received the vault-relative
|
||||
// path (carrying the vaultFolder prefix) instead of the repo-relative path,
|
||||
// causing a spurious "file was not found on branch main" for files the UI
|
||||
// itself listed as remote-only.
|
||||
it('strips the vaultFolder prefix before calling gitService.deleteFile', async () => {
|
||||
const { plugin, leaf, deleteFile } = makePlugin({ vaultFolder: '02_Areas/blog' });
|
||||
const view = new SyncStatusView(leaf, plugin);
|
||||
|
||||
const fileStatus: FileStatus = { path: '02_Areas/blog/notes/todo.md', status: 'remote-only' };
|
||||
const errors: { path: string, message: string }[] = [];
|
||||
const prog = new Notice('', 0);
|
||||
|
||||
// performRemoteDeletion is private; called directly to isolate it from
|
||||
// the confirmation dialog and higher-level orchestration in deleteSelected().
|
||||
await (view as unknown as {
|
||||
performRemoteDeletion(remote: FileStatus[], total: number, localCount: number, prog: Notice, errors: { path: string, message: string }[]): Promise<void>
|
||||
}).performRemoteDeletion([fileStatus], 1, 0, prog, errors);
|
||||
|
||||
expect(deleteFile).toHaveBeenCalledWith('notes/todo.md', 'main', expect.any(String));
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('passes the path unchanged when no vaultFolder is configured', async () => {
|
||||
const { plugin, leaf, deleteFile } = makePlugin();
|
||||
const view = new SyncStatusView(leaf, plugin);
|
||||
|
||||
const fileStatus: FileStatus = { path: 'notes/todo.md', status: 'remote-only' };
|
||||
const errors: { path: string, message: string }[] = [];
|
||||
const prog = new Notice('', 0);
|
||||
|
||||
await (view as unknown as {
|
||||
performRemoteDeletion(remote: FileStatus[], total: number, localCount: number, prog: Notice, errors: { path: string, message: string }[]): Promise<void>
|
||||
}).performRemoteDeletion([fileStatus], 1, 0, prog, errors);
|
||||
|
||||
expect(deleteFile).toHaveBeenCalledWith('notes/todo.md', 'main', expect.any(String));
|
||||
});
|
||||
|
||||
it('records the real error message instead of swallowing it', async () => {
|
||||
const deleteFile = vi.fn().mockRejectedValue(new Error('Cannot delete "notes/todo.md": file was not found on branch "main".'));
|
||||
const { plugin, leaf } = makePlugin({ deleteFile });
|
||||
const view = new SyncStatusView(leaf, plugin);
|
||||
|
||||
const fileStatus: FileStatus = { path: 'notes/todo.md', status: 'remote-only' };
|
||||
const errors: { path: string, message: string }[] = [];
|
||||
const prog = new Notice('', 0);
|
||||
|
||||
await (view as unknown as {
|
||||
performRemoteDeletion(remote: FileStatus[], total: number, localCount: number, prog: Notice, errors: { path: string, message: string }[]): Promise<void>
|
||||
}).performRemoteDeletion([fileStatus], 1, 0, prog, errors);
|
||||
|
||||
expect(errors).toEqual([{ path: 'notes/todo.md', message: 'Cannot delete "notes/todo.md": file was not found on branch "main".' }]);
|
||||
});
|
||||
|
||||
it('groups all remote-only deletes into one gitService.deleteBatch call when the provider supports it', async () => {
|
||||
const deleteBatch = vi.fn().mockResolvedValue(undefined);
|
||||
const { plugin, leaf, deleteFile } = makePlugin({ deleteBatch });
|
||||
const view = new SyncStatusView(leaf, plugin);
|
||||
|
||||
const targets: FileStatus[] = [
|
||||
{ path: 'a.md', status: 'remote-only' },
|
||||
{ path: 'b.md', status: 'remote-only' },
|
||||
];
|
||||
const errors: { path: string, message: string }[] = [];
|
||||
const prog = new Notice('', 0);
|
||||
|
||||
await (view as unknown as {
|
||||
performRemoteDeletion(remote: FileStatus[], total: number, localCount: number, prog: Notice, errors: { path: string, message: string }[]): Promise<void>
|
||||
}).performRemoteDeletion(targets, 2, 0, prog, errors);
|
||||
|
||||
expect(deleteBatch).toHaveBeenCalledTimes(1);
|
||||
expect(deleteBatch).toHaveBeenCalledWith(['a.md', 'b.md'], 'main', expect.any(String));
|
||||
expect(deleteFile).not.toHaveBeenCalled();
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('marks every path in a failed deleteBatch chunk as failed, not dropped', async () => {
|
||||
const deleteBatch = vi.fn().mockRejectedValue(new Error('commit failed'));
|
||||
const { plugin, leaf } = makePlugin({ deleteBatch });
|
||||
const view = new SyncStatusView(leaf, plugin);
|
||||
|
||||
const targets: FileStatus[] = [
|
||||
{ path: 'a.md', status: 'remote-only' },
|
||||
{ path: 'b.md', status: 'remote-only' },
|
||||
];
|
||||
const errors: { path: string, message: string }[] = [];
|
||||
const prog = new Notice('', 0);
|
||||
|
||||
await (view as unknown as {
|
||||
performRemoteDeletion(remote: FileStatus[], total: number, localCount: number, prog: Notice, errors: { path: string, message: string }[]): Promise<void>
|
||||
}).performRemoteDeletion(targets, 2, 0, prog, errors);
|
||||
|
||||
expect(errors).toEqual([
|
||||
{ path: 'a.md', message: 'commit failed' },
|
||||
{ path: 'b.md', message: 'commit failed' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to the sequential deleteFile loop when the provider has no deleteBatch', async () => {
|
||||
const { plugin, leaf, deleteFile } = makePlugin();
|
||||
const view = new SyncStatusView(leaf, plugin);
|
||||
|
||||
const targets: FileStatus[] = [
|
||||
{ path: 'a.md', status: 'remote-only' },
|
||||
{ path: 'b.md', status: 'remote-only' },
|
||||
];
|
||||
const errors: { path: string, message: string }[] = [];
|
||||
const prog = new Notice('', 0);
|
||||
|
||||
await (view as unknown as {
|
||||
performRemoteDeletion(remote: FileStatus[], total: number, localCount: number, prog: Notice, errors: { path: string, message: string }[]): Promise<void>
|
||||
}).performRemoteDeletion(targets, 2, 0, prog, errors);
|
||||
|
||||
expect(deleteFile).toHaveBeenCalledTimes(2);
|
||||
expect(deleteFile).toHaveBeenCalledWith('a.md', 'main', expect.any(String));
|
||||
expect(deleteFile).toHaveBeenCalledWith('b.md', 'main', expect.any(String));
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SyncStatusView.identifyExtraFiles folder/remote-record collisions', () => {
|
||||
beforeAll(() => { setupObsidianDOM(); });
|
||||
|
||||
// Regression test: a local real directory (or a symlink to one) can share a
|
||||
// path with a stale remote record (e.g. a folder that used to be a pushed
|
||||
// symlink). Treating it as a readable file crashes adapter.read() with EISDIR;
|
||||
// it should be classified remote-only instead.
|
||||
it('treats a path that exists locally as a folder as remote-only, not a readable file', async () => {
|
||||
const adapterStat = vi.fn().mockResolvedValue({ type: 'folder' });
|
||||
const adapterExists = vi.fn().mockResolvedValue(true);
|
||||
const { plugin, leaf } = makePlugin({ adapterStat, adapterExists });
|
||||
const view = new SyncStatusView(leaf, plugin);
|
||||
|
||||
const remoteMap = new Map<string, GitTreeEntry>([
|
||||
['.claude/skills/polish-blog', { path: '.claude/skills/polish-blog', symlink: false }],
|
||||
]);
|
||||
|
||||
const extra = await (view as unknown as {
|
||||
identifyExtraFiles(remoteMap: Map<string, GitTreeEntry>, localFilePaths: Set<string>, allLocalFileMap: Map<string, unknown>): Promise<unknown[]>
|
||||
}).identifyExtraFiles(remoteMap, new Set(), new Map());
|
||||
|
||||
expect(extra).toEqual([]);
|
||||
const statuses = (view as unknown as { fileStatuses: Map<string, FileStatus> }).fileStatuses;
|
||||
expect(statuses.get('.claude/skills/polish-blog')).toEqual({ path: '.claude/skills/polish-blog', status: 'remote-only' });
|
||||
});
|
||||
|
||||
it('still treats a genuine local file as extra/checkable', async () => {
|
||||
const adapterStat = vi.fn().mockResolvedValue({ type: 'file' });
|
||||
const adapterExists = vi.fn().mockResolvedValue(true);
|
||||
const { plugin, leaf } = makePlugin({ adapterStat, adapterExists });
|
||||
const view = new SyncStatusView(leaf, plugin);
|
||||
|
||||
const remoteMap = new Map<string, GitTreeEntry>([
|
||||
['notes/hidden.md', { path: 'notes/hidden.md', symlink: false }],
|
||||
]);
|
||||
|
||||
const extra = await (view as unknown as {
|
||||
identifyExtraFiles(remoteMap: Map<string, GitTreeEntry>, localFilePaths: Set<string>, allLocalFileMap: Map<string, unknown>): Promise<unknown[]>
|
||||
}).identifyExtraFiles(remoteMap, new Set(), new Map());
|
||||
|
||||
expect(extra).toEqual(['notes/hidden.md']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SyncStatusView post-push status update', () => {
|
||||
beforeAll(() => { setupObsidianDOM(); });
|
||||
|
||||
// Regression test: GitHub's tree-by-branch-name read can lag a moment behind
|
||||
// a just-completed write (GraphQL createCommitOnBranch or otherwise), so
|
||||
// re-fetching the remote tree immediately after a push can misreport a file
|
||||
// that was just pushed correctly as still "modified". The fix marks
|
||||
// successfully-pushed paths 'synced' directly from the push result instead
|
||||
// of trusting an immediate remote re-read.
|
||||
it('marks pushed files synced from the push result instead of re-fetching the remote tree', async () => {
|
||||
const pushAllFiles = vi.fn().mockResolvedValue({
|
||||
success: 2, failed: 0, conflicts: 0, errors: [],
|
||||
syncedPaths: [{ path: 'a.md', sha: 'sha-a' }, { path: 'b.md', sha: 'sha-b' }],
|
||||
});
|
||||
|
||||
const plugin = {
|
||||
settings: { branch: 'main', vaultFolder: '' },
|
||||
gitService: {},
|
||||
sync: { pushAllFiles },
|
||||
} as unknown as GitLabFilesPush;
|
||||
const app = { vault: { adapter: { exists: vi.fn().mockResolvedValue(false) } } };
|
||||
const leaf = { app } as unknown as WorkspaceLeaf;
|
||||
const view = new SyncStatusView(leaf, plugin);
|
||||
|
||||
const statuses = (view as unknown as { fileStatuses: Map<string, FileStatus> }).fileStatuses;
|
||||
statuses.set('a.md', { path: 'a.md', status: 'modified', localContent: '' });
|
||||
statuses.set('b.md', { path: 'b.md', status: 'modified', localContent: '' });
|
||||
|
||||
const refreshSpy = vi.spyOn(view, 'refreshAllStatuses').mockResolvedValue(undefined);
|
||||
|
||||
await (view as unknown as {
|
||||
executeBatchOperation(filter: 'modified' | 'selected', op: 'push' | 'pull', files: Array<string>): Promise<void>
|
||||
}).executeBatchOperation('modified', 'push', ['a.md', 'b.md']);
|
||||
|
||||
expect(pushAllFiles).toHaveBeenCalledTimes(1);
|
||||
// The fix: no remote tree re-fetch right after push (that read is what
|
||||
// can lag GitHub's write and misreport the file as still modified).
|
||||
expect(refreshSpy).not.toHaveBeenCalled();
|
||||
expect(statuses.get('a.md')).toEqual({ path: 'a.md', status: 'synced', localContent: '', remoteSha: 'sha-a' });
|
||||
expect(statuses.get('b.md')).toEqual({ path: 'b.md', status: 'synced', localContent: '', remoteSha: 'sha-b' });
|
||||
});
|
||||
|
||||
it('still does a full remote refresh after a pull (unaffected by this fix)', async () => {
|
||||
const pullAllFiles = vi.fn().mockResolvedValue({ success: 1, failed: 0, conflicts: 0, errors: [] });
|
||||
|
||||
const plugin = {
|
||||
settings: { branch: 'main', vaultFolder: '' },
|
||||
gitService: {},
|
||||
sync: { pullAllFiles },
|
||||
} as unknown as GitLabFilesPush;
|
||||
const app = { vault: { adapter: { exists: vi.fn().mockResolvedValue(false) } } };
|
||||
const leaf = { app } as unknown as WorkspaceLeaf;
|
||||
const view = new SyncStatusView(leaf, plugin);
|
||||
|
||||
const refreshSpy = vi.spyOn(view, 'refreshAllStatuses').mockResolvedValue(undefined);
|
||||
|
||||
await (view as unknown as {
|
||||
executeBatchOperation(filter: 'modified' | 'selected', op: 'push' | 'pull', files: Array<string>): Promise<void>
|
||||
}).executeBatchOperation('modified', 'pull', ['a.md']);
|
||||
|
||||
expect(pullAllFiles).toHaveBeenCalledTimes(1);
|
||||
expect(refreshSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
81
tests/ui/WhatsNewModal.test.ts
Normal file
81
tests/ui/WhatsNewModal.test.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { beforeAll, describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { App } from 'obsidian';
|
||||
import { WhatsNewModal } from '../../src/ui/WhatsNewModal';
|
||||
import { createContainer, setupObsidianDOM } from './setup-dom';
|
||||
import type { ChangelogRelease } from '../../src/changelog';
|
||||
|
||||
describe('WhatsNewModal', () => {
|
||||
beforeAll(() => { setupObsidianDOM(); });
|
||||
|
||||
afterEach(() => { vi.restoreAllMocks(); });
|
||||
|
||||
const releases: ChangelogRelease[] = [
|
||||
{
|
||||
version: '1.3.0',
|
||||
entries: [
|
||||
{ text: 'Notable highlight', notable: true },
|
||||
{ text: 'Minor fix' },
|
||||
],
|
||||
},
|
||||
{
|
||||
version: '1.2.1',
|
||||
entries: [
|
||||
{ text: 'Older release note' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function openModal(rels: ChangelogRelease[]): HTMLElement {
|
||||
const modal = new WhatsNewModal(new App(), rels);
|
||||
modal.contentEl = createContainer();
|
||||
modal.onOpen();
|
||||
return modal.contentEl;
|
||||
}
|
||||
|
||||
it('renders a heading for each release version', () => {
|
||||
const contentEl = openModal(releases);
|
||||
const headings = Array.from(contentEl.querySelectorAll('h4')).map(h => h.textContent);
|
||||
expect(headings).toEqual(['v1.3.0', 'v1.2.1']);
|
||||
});
|
||||
|
||||
it('renders every entry as a list item', () => {
|
||||
const contentEl = openModal(releases);
|
||||
const items = Array.from(contentEl.querySelectorAll('li')).map(li => li.textContent);
|
||||
expect(items).toEqual(['Notable highlight', 'Minor fix', 'Older release note']);
|
||||
});
|
||||
|
||||
it('marks notable entries distinctly from non-notable ones', () => {
|
||||
const contentEl = openModal(releases);
|
||||
const items = Array.from(contentEl.querySelectorAll('li'));
|
||||
expect(items[0]?.classList.contains('ssv-whats-new-notable')).toBe(true);
|
||||
expect(items[1]?.classList.contains('ssv-whats-new-notable')).toBe(false);
|
||||
});
|
||||
|
||||
it('opens the full changelog URL when "View full changelog" is clicked', () => {
|
||||
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
|
||||
const contentEl = openModal(releases);
|
||||
const buttons = Array.from(contentEl.querySelectorAll('button'));
|
||||
const changelogBtn = buttons.find(b => b.textContent?.includes('View full changelog'));
|
||||
|
||||
changelogBtn?.click();
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
'https://github.com/firstsun-dev/git-files-sync/blob/main/CHANGELOG.md',
|
||||
'_blank',
|
||||
'noopener'
|
||||
);
|
||||
});
|
||||
|
||||
it('closes the modal when "Got it" is clicked', () => {
|
||||
const modal = new WhatsNewModal(new App(), releases);
|
||||
modal.contentEl = createContainer();
|
||||
modal.onOpen();
|
||||
const closeSpy = vi.spyOn(modal, 'close');
|
||||
|
||||
const buttons = Array.from(modal.contentEl.querySelectorAll('button'));
|
||||
const gotItBtn = buttons.find(b => b.textContent?.includes('Got it'));
|
||||
gotItBtn?.click();
|
||||
|
||||
expect(closeSpy).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
|
@ -50,6 +50,12 @@ export function setupObsidianDOM(): void {
|
|||
(this as HTMLElement).appendChild(el);
|
||||
return el as unknown as HTMLSpanElement;
|
||||
},
|
||||
addClass(cls: string): void {
|
||||
(this as HTMLElement).classList.add(cls);
|
||||
},
|
||||
removeClass(cls: string): void {
|
||||
(this as HTMLElement).classList.remove(cls);
|
||||
},
|
||||
hasClass(cls: string): boolean {
|
||||
return (this as HTMLElement).classList.contains(cls);
|
||||
},
|
||||
|
|
@ -59,6 +65,9 @@ export function setupObsidianDOM(): void {
|
|||
setText(text: string): void {
|
||||
(this as HTMLElement).textContent = text;
|
||||
},
|
||||
empty(): void {
|
||||
(this as HTMLElement).replaceChildren();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
34
tests/utils/git-blob-sha.test.ts
Normal file
34
tests/utils/git-blob-sha.test.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { gitBlobSha } from '../../src/utils/git-blob-sha';
|
||||
|
||||
// Test vectors verified against `git hash-object --stdin`.
|
||||
describe('gitBlobSha', () => {
|
||||
it('matches git for empty content', async () => {
|
||||
expect(await gitBlobSha('')).toBe('e69de29bb2d1d6434b8b29ae775ad8c2e48c5391');
|
||||
});
|
||||
|
||||
it('matches git for a string with no trailing newline', async () => {
|
||||
expect(await gitBlobSha('hello world')).toBe('95d09f2b10159347eece71399a7e2e907ea3df4f');
|
||||
});
|
||||
|
||||
it('matches git for a string with a trailing newline', async () => {
|
||||
expect(await gitBlobSha('hello world\n')).toBe('3b18e512dba79e4c8300dd08aeb37f8e728b8dad');
|
||||
});
|
||||
|
||||
it('matches git for multi-byte UTF-8 content', async () => {
|
||||
// "café" is 5 bytes in UTF-8 (é is 2 bytes) — verifies byte length, not char length, is used.
|
||||
expect(await gitBlobSha('café')).toBe('1c2e52cfe7542a64cdea57e5fec2fc1739846c03');
|
||||
});
|
||||
|
||||
it('produces the same SHA for equivalent string and ArrayBuffer content', async () => {
|
||||
const text = 'hello world';
|
||||
const bytes = new TextEncoder().encode(text);
|
||||
const arrayBuffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
||||
|
||||
expect(await gitBlobSha(arrayBuffer)).toBe(await gitBlobSha(text));
|
||||
});
|
||||
|
||||
it('produces different SHAs for different content', async () => {
|
||||
expect(await gitBlobSha('a')).not.toBe(await gitBlobSha('b'));
|
||||
});
|
||||
});
|
||||
126
tests/utils/symlink.test.ts
Normal file
126
tests/utils/symlink.test.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { App, FileSystemAdapter, Platform } from 'obsidian';
|
||||
import { canUseRealSymlinks, readLocalSymlinkTarget, createLocalSymlink } from '../../src/utils/symlink';
|
||||
|
||||
vi.mock('obsidian');
|
||||
|
||||
interface FakeFs {
|
||||
lstatSync: ReturnType<typeof vi.fn>;
|
||||
statSync: ReturnType<typeof vi.fn>;
|
||||
readlinkSync: ReturnType<typeof vi.fn>;
|
||||
existsSync: ReturnType<typeof vi.fn>;
|
||||
mkdirSync: ReturnType<typeof vi.fn>;
|
||||
rmSync: ReturnType<typeof vi.fn>;
|
||||
symlinkSync: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
describe('symlink utils', () => {
|
||||
let app: App;
|
||||
let fakeFs: FakeFs;
|
||||
const fakePath = {
|
||||
join: (...parts: string[]) => parts.join('/'),
|
||||
dirname: (p: string) => p.split('/').slice(0, -1).join('/'),
|
||||
isAbsolute: (p: string) => p.startsWith('/'),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
Platform.isDesktopApp = true;
|
||||
|
||||
app = new App();
|
||||
app.vault.adapter = new FileSystemAdapter();
|
||||
|
||||
fakeFs = {
|
||||
lstatSync: vi.fn().mockReturnValue({ isSymbolicLink: () => false }),
|
||||
statSync: vi.fn().mockReturnValue({ isDirectory: () => false }),
|
||||
readlinkSync: vi.fn().mockReturnValue(''),
|
||||
existsSync: vi.fn().mockReturnValue(false),
|
||||
mkdirSync: vi.fn(),
|
||||
rmSync: vi.fn(),
|
||||
symlinkSync: vi.fn(),
|
||||
};
|
||||
|
||||
(window as unknown as { require: (id: string) => unknown }).require = (id: string) =>
|
||||
id === 'fs' ? fakeFs : fakePath;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete (window as unknown as { require?: unknown }).require;
|
||||
});
|
||||
|
||||
describe('canUseRealSymlinks', () => {
|
||||
it('is true on desktop with a FileSystemAdapter', () => {
|
||||
expect(canUseRealSymlinks(app)).toBe(true);
|
||||
});
|
||||
|
||||
it('is false when not a desktop app', () => {
|
||||
Platform.isDesktopApp = false;
|
||||
expect(canUseRealSymlinks(app)).toBe(false);
|
||||
});
|
||||
|
||||
it('is false when the adapter is not a FileSystemAdapter', () => {
|
||||
app.vault.adapter = {} as FileSystemAdapter;
|
||||
expect(canUseRealSymlinks(app)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readLocalSymlinkTarget', () => {
|
||||
it('returns the raw target for a symlink', () => {
|
||||
fakeFs.lstatSync.mockReturnValue({ isSymbolicLink: () => true });
|
||||
fakeFs.readlinkSync.mockReturnValue('../target');
|
||||
|
||||
expect(readLocalSymlinkTarget(app, 'skills/blog-master')).toBe('../target');
|
||||
});
|
||||
|
||||
it('returns null for a non-symlink path', () => {
|
||||
fakeFs.lstatSync.mockReturnValue({ isSymbolicLink: () => false });
|
||||
expect(readLocalSymlinkTarget(app, 'notes/plain.md')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when real symlinks are unavailable', () => {
|
||||
Platform.isDesktopApp = false;
|
||||
expect(readLocalSymlinkTarget(app, 'skills/blog-master')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createLocalSymlink', () => {
|
||||
it('creates a "dir" symlink when the target resolves to a directory', () => {
|
||||
fakeFs.statSync.mockReturnValue({ isDirectory: () => true });
|
||||
|
||||
const result = createLocalSymlink(app, 'skills/blog-master', '../blog/src');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(fakeFs.symlinkSync).toHaveBeenCalledWith('../blog/src', expect.any(String), 'dir');
|
||||
});
|
||||
|
||||
it('creates a "file" symlink when the target resolves to a file', () => {
|
||||
fakeFs.statSync.mockReturnValue({ isDirectory: () => false });
|
||||
|
||||
createLocalSymlink(app, 'notes/link.md', '../shared/note.md');
|
||||
|
||||
expect(fakeFs.symlinkSync).toHaveBeenCalledWith('../shared/note.md', expect.any(String), 'file');
|
||||
});
|
||||
|
||||
it('falls back to "file" when the target cannot be stat-ed (dangling link)', () => {
|
||||
fakeFs.statSync.mockImplementation(() => { throw new Error('ENOENT'); });
|
||||
|
||||
createLocalSymlink(app, 'notes/link.md', '../missing.md');
|
||||
|
||||
expect(fakeFs.symlinkSync).toHaveBeenCalledWith('../missing.md', expect.any(String), 'file');
|
||||
});
|
||||
|
||||
it('removes an existing file or stale link before creating the new one', () => {
|
||||
fakeFs.existsSync.mockReturnValue(true);
|
||||
|
||||
createLocalSymlink(app, 'notes/link.md', '../shared/note.md');
|
||||
|
||||
expect(fakeFs.rmSync).toHaveBeenCalledWith(expect.any(String), { force: true });
|
||||
expect(fakeFs.symlinkSync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns false when real symlinks are unavailable', () => {
|
||||
Platform.isDesktopApp = false;
|
||||
expect(createLocalSymlink(app, 'notes/link.md', '../shared/note.md')).toBe(false);
|
||||
expect(fakeFs.symlinkSync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
27
tests/utils/version.test.ts
Normal file
27
tests/utils/version.test.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { compareVersions } from '../../src/utils/version';
|
||||
|
||||
describe('compareVersions', () => {
|
||||
it('returns 0 for equal versions', () => {
|
||||
expect(compareVersions('1.2.1', '1.2.1')).toBe(0);
|
||||
});
|
||||
|
||||
it('compares patch versions', () => {
|
||||
expect(compareVersions('1.2.1', '1.2.0')).toBeGreaterThan(0);
|
||||
expect(compareVersions('1.2.0', '1.2.1')).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('compares numerically, not lexically, across double-digit segments', () => {
|
||||
expect(compareVersions('1.10.0', '1.9.0')).toBeGreaterThan(0);
|
||||
expect(compareVersions('1.9.0', '1.10.0')).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('treats a missing segment as 0', () => {
|
||||
expect(compareVersions('1.2', '1.2.0')).toBe(0);
|
||||
expect(compareVersions('1.2.1', '1.2')).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('treats a non-numeric segment as 0', () => {
|
||||
expect(compareVersions('1.x.0', '1.0.0')).toBe(0);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue