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:
ClaudiaFang 2026-07-14 21:36:08 +08:00 committed by GitHub
commit 2a76329f7d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
57 changed files with 4720 additions and 395 deletions

View file

@ -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)

View file

@ -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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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;

View file

@ -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()) {

View file

@ -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);

View file

@ -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() {

View file

@ -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

View file

@ -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>;
}

View file

@ -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}`;

View file

@ -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}`;

View file

@ -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 {

View file

@ -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();
}));
}
}

View file

@ -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
View 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);
}
}

View 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);
}
}

View file

@ -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();
}));

View file

@ -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
View 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();
}
}

View file

@ -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));
}

View file

@ -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 {

View file

@ -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);

View file

@ -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
View 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('');
}

View file

@ -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
View 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;
}

View file

@ -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
View 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
View 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');
});
});

View file

@ -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');
});
});
});

View file

@ -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]);

View file

@ -53,6 +53,10 @@ const mockSettings: GitLabFilesPushSettings = {
vaultFolder: 'Work',
syncMetadata: {},
symlinkHandling: 'real',
ignorePatterns: '',
lastSeenVersion: '',
bannerDismissedVersion: '',
language: 'system',
};
describe('SyncManager Mapping', () => {

View file

@ -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(),

View file

@ -59,7 +59,11 @@ const mockSettings: GitLabFilesPushSettings = {
rootPath: '',
syncMetadata: {},
vaultFolder: '',
symlinkHandling: 'real'
symlinkHandling: 'real',
ignorePatterns: '',
lastSeenVersion: '',
bannerDismissedVersion: '',
language: 'system'
};
describe('SyncManager', () => {

View file

@ -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, 世界! 🌍';

View file

@ -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', () => {

View file

@ -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', () => {

View file

@ -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);

View file

@ -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,
}));

View file

@ -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', () => {

View file

@ -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', () => {

View 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');
});
});

View file

@ -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);
});
});

View 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);
});
});

View 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();
});
});

View file

@ -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();
},
});
}

View 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
View 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();
});
});
});

View 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);
});
});