mirror of
https://github.com/firstsun-dev/git-files-sync.git
synced 2026-07-22 17:20:30 +00:00
Merge remote-tracking branch 'origin/claude/settings-ux-improvements-260713' into claude/fix-directory-symlink-pull-260713
# Conflicts: # src/settings.ts # tests/setup.ts
This commit is contained in:
commit
29bf3604e3
19 changed files with 848 additions and 23 deletions
26
AGENTS.md
26
AGENTS.md
|
|
@ -1,5 +1,31 @@
|
|||
# Project Agent Design & Hierarchy (Synchronized with Skills)
|
||||
|
||||
## Startup Workflow
|
||||
|
||||
Before writing code:
|
||||
- Read `feature_list.json` (active/next-up work — GitHub Issues on `firstsun-dev/git-files-sync`, Project #6, is the real source of truth; re-sync stale entries) and `progress.md` (what's currently open).
|
||||
- Read `session-handoff.md` for the previous session's exact stopping point.
|
||||
- Run `./init.sh` to confirm a clean, green baseline before editing.
|
||||
|
||||
## Definition of Done
|
||||
|
||||
A feature is done only when all of the following hold, with evidence recorded (command + result) in `progress.md`:
|
||||
- `npx eslint .` — 0 errors
|
||||
- `npm run build` — passes (tsc + Obsidian 1.11.0 compat typecheck + esbuild)
|
||||
- `npx vitest run` — passes
|
||||
- Manual verification in Obsidian, when the change has a runtime UI surface
|
||||
|
||||
## Stay in Scope
|
||||
|
||||
- One feature at a time: pick a single `feature_list.json` entry and finish it (with recorded evidence) before starting the next.
|
||||
- Don't expand scope mid-task — file a new GitHub issue via the `firstsun-pm` skill instead of quietly bundling unrelated work.
|
||||
|
||||
## End of Session
|
||||
|
||||
Before ending a session:
|
||||
- Overwrite `session-handoff.md` (don't append) with the new stopping point so the repo stays restartable.
|
||||
- Move finished items from `progress.md` into `archive/YYYY-MM.md` (current month) — next steps in `progress.md` should read as a short list, not a changelog.
|
||||
|
||||
## Agent Tiers
|
||||
|
||||
### 1. High-Tier (Red/Orange Group)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,15 @@
|
|||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Agent Workflow
|
||||
|
||||
- **Startup**: read `feature_list.json` (active/next-up work; GitHub Issues on `firstsun-dev/git-files-sync`, Project #6, is the actual source of truth — re-sync before trusting stale entries) and `progress.md` (what's open right now), then `session-handoff.md` for the previous session's exact stopping point.
|
||||
- **Before editing**: run `./init.sh` (installs deps, then lint + test + build) to confirm you're starting from a green baseline.
|
||||
- **Definition of done**: `npx eslint .` has 0 errors, `npm run build` passes (includes the Obsidian 1.11.0 compat typecheck), and `npx vitest run` passes, *and* evidence of that run is recorded (one line: command + result) in `progress.md` or the PR description — not just claimed.
|
||||
- **Scope**: work one `feature_list.json` entry at a time; don't start the next until the current one's evidence is recorded.
|
||||
- **End of session**: overwrite `session-handoff.md` with the new stopping point, move finished items from `progress.md` into `archive/YYYY-MM.md` (current month).
|
||||
- Issue/PR conventions (Conventional Commits titles, Project #6 fields, English-only for this public plugin repo) are defined in the `firstsun-pm` skill, not duplicated here.
|
||||
|
||||
## Development Commands
|
||||
- Build: `npm run build` (runs type check and esbuild in production mode)
|
||||
- Dev: `npm run dev` (builds in watch mode using esbuild)
|
||||
|
|
|
|||
9
archive/2026-07.md
Normal file
9
archive/2026-07.md
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
# 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)
|
||||
46
feature_list.json
Normal file
46
feature_list.json
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
{
|
||||
"_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.",
|
||||
"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": "in-review",
|
||||
"evidence": "Commit 28f4f8e on branch claude/settings-ux-improvements-260713 - lint/build/test pass (254 tests); pushed to origin, PR not yet opened"
|
||||
},
|
||||
{
|
||||
"id": "feat-003",
|
||||
"name": "fix: symbolic link pull fails (issue #33)",
|
||||
"description": "Bug report with screenshot; needs repro/investigation before a fix can be scoped",
|
||||
"dependencies": [],
|
||||
"status": "not-started",
|
||||
"evidence": ""
|
||||
},
|
||||
{
|
||||
"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": "not-started",
|
||||
"evidence": ""
|
||||
}
|
||||
],
|
||||
"_evidenceStyle": "Keep evidence to one line: commit hash + short pointer (e.g. 'Commit abc1234 - added X, tests pass'). Debugging narrative and design discussion belong in the commit message, not this file."
|
||||
}
|
||||
24
init.sh
Executable file
24
init.sh
Executable file
|
|
@ -0,0 +1,24 @@
|
|||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "=== Harness Initialization ==="
|
||||
|
||||
echo "=== npm install ==="
|
||||
npm install
|
||||
|
||||
echo "=== npm run lint ==="
|
||||
npm run lint
|
||||
|
||||
echo "=== npm test ==="
|
||||
npm test
|
||||
|
||||
echo "=== npm run build ==="
|
||||
npm run build
|
||||
|
||||
echo "=== Verification Complete ==="
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo "1. Read feature_list.json to see current feature state"
|
||||
echo "2. Pick ONE unfinished feature to work on"
|
||||
echo "3. Implement only that feature"
|
||||
echo "4. Re-run verification before claiming done"
|
||||
66
progress.md
Normal file
66
progress.md
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
# 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-13 11:40
|
||||
**Session ID:** session_01YYCTyZw7gUmJ7oh1VTmAqh
|
||||
**Active Feature:** feat-002 - Settings UX bundle (issues #40, #41, #42)
|
||||
|
||||
## Status
|
||||
|
||||
### What's Done
|
||||
|
||||
- [x] feat-001 - Project Setup verified (see archive/2026-07.md)
|
||||
- [x] feat-002 implementation - all three sub-features coded, tested, linted, built (see archive/2026-07.md)
|
||||
|
||||
### What's In Progress
|
||||
|
||||
- [ ] feat-002 - Open a PR for branch `claude/settings-ux-improvements-260713` and get it merged
|
||||
- Details: commit 28f4f8e is pushed to origin; no PR opened yet
|
||||
- Blockers: none, just needs `gh pr create`
|
||||
|
||||
### What's Next
|
||||
|
||||
1. Open PR for `claude/settings-ux-improvements-260713`, close issues #40/#41/#42 on merge
|
||||
2. Pick up feat-003 (issue #33, symlink pull fails) — needs repro investigation first
|
||||
3. Re-sync this file's backlog entries against `gh issue list --repo firstsun-dev/git-files-sync --state open` since new issues may have been filed (e.g. #48 folder picker was added mid-session)
|
||||
|
||||
## Blockers / Risks
|
||||
|
||||
- None currently.
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- **Discarded a stale local WIP for Obsidian 1.12.x compatibility**: origin/main already shipped this via PR #46 (`applyDestructiveStyle`, `typecheck-compat.mjs`) with a different mechanism. Local main was fast-forwarded to origin/main and the new features (#40/#41/#42) were manually re-applied on top of the correct base rather than merged via `git stash pop` (which would have conflicted/duplicated the compat layer).
|
||||
- **feat-002 bundles three issues in one commit**: #40/#41/#42 touch overlapping files (`src/settings.ts`, `styles.css`, `tests/setup.ts`) closely enough that splitting into three atomic commits wasn't worth the risk of an intermediate broken state.
|
||||
|
||||
## Files Modified This Session
|
||||
|
||||
- `src/settings.ts` - connection status badge (#41), ignore patterns setting (#40)
|
||||
- `src/ui/SyncConflictModal.ts` - Diff/Local/Remote tab switcher (#42)
|
||||
- `src/logic/gitignore-manager.ts`, `src/main.ts` - local ignore pattern matching (#40)
|
||||
- `styles.css` - badge + modal + tab styles
|
||||
- `tests/setup.ts`, `tests/ui/setup-dom.ts` - expanded Obsidian mocks (TextAreaComponent, removeClass, configDir, etc.)
|
||||
- `tests/logic/gitignore-manager.test.ts`, `tests/logic/sync-manager*.test.ts`, `tests/ui/SyncConflictModal.test.ts`, `tests/ui/SettingsConnectionStatus.test.ts` - new/updated tests
|
||||
|
||||
## Evidence of Completion
|
||||
|
||||
- [x] Tests pass: `npx vitest run` → 254/254 passed
|
||||
- [x] Type check clean: `npm run build` (tsc + Obsidian 1.11.0 compat typecheck + esbuild) → clean
|
||||
- [x] Lint clean: `npx eslint .` → 0 errors
|
||||
- [ ] Manual verification in Obsidian: not done (no Obsidian instance available in this environment)
|
||||
|
||||
## Notes for Next Session
|
||||
|
||||
- Branch `claude/settings-ux-improvements-260713` is pushed but has no PR yet — check with the user before opening one (they were asked and hadn't confirmed as of session end).
|
||||
- `feature_list.json`'s backlog (feat-003..005) is a snapshot from 2026-07-13; re-check `gh issue list` before trusting it.
|
||||
59
session-handoff.md
Normal file
59
session-handoff.md
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
# 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
|
||||
|
||||
- Goal: Implement issues #40 (local ignore patterns), #41 (settings connection status badge), #42 (resize conflict modal)
|
||||
- Current status: Implemented, tested, linted, built clean; committed and pushed. PR not yet opened (pending user confirmation).
|
||||
- Branch / commit: `claude/settings-ux-improvements-260713` @ 28f4f8e
|
||||
|
||||
## Completed This Session
|
||||
|
||||
- [x] #42 - Resized conflict modal (`min(1100px,92vw) x min(85vh,800px)`, flex layout, removed 280px content cap, added Diff/Local/Remote tab switcher on narrow screens)
|
||||
- [x] #41 - Persistent connection status badge in settings tab (Checking/Connected/Not connected), 800ms debounce on token/branch/URL/owner/repo edits, updates in place (no focus-stealing re-render)
|
||||
- [x] #40 - "Ignore patterns" setting (multi-line, .gitignore-style), applied in `GitignoreManager.isIgnored()` additively alongside remote/local `.gitignore`
|
||||
- [x] Filed issue #48 (folder picker for root path / vault folder settings) at user's request mid-session
|
||||
- [x] Rebased local `main` onto `origin/main` to adopt the already-shipped Obsidian 1.12.x compat work (PR #46), discarding a stale duplicate local WIP, then re-applied the above three features cleanly on top
|
||||
|
||||
## Verification Evidence
|
||||
|
||||
| Check | Command | Result | Notes |
|
||||
|---|---|---|---|
|
||||
| Lint | `npx eslint .` | 0 errors | Repo-wide, no exceptions |
|
||||
| Type check + compat | `npm run build` | Pass | Includes `typecheck-compat.mjs` against Obsidian 1.11.0 |
|
||||
| Tests | `npx vitest run` | 254/254 passed | 17 test files |
|
||||
| Manual (in Obsidian) | — | Not done | No Obsidian instance available in this environment |
|
||||
|
||||
## Files Changed
|
||||
|
||||
- `src/settings.ts`, `src/ui/SyncConflictModal.ts`, `src/logic/gitignore-manager.ts`, `src/main.ts`, `styles.css`
|
||||
- `tests/setup.ts`, `tests/ui/setup-dom.ts` (expanded Obsidian test mocks)
|
||||
- `tests/logic/gitignore-manager.test.ts`, `tests/logic/sync-manager.test.ts`, `tests/logic/sync-manager-mapping.test.ts`
|
||||
- `tests/ui/SyncConflictModal.test.ts`, `tests/ui/SettingsConnectionStatus.test.ts` (new)
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- Bundled #40/#41/#42 into a single commit rather than three, since they touch overlapping files closely enough that atomic per-issue commits risked an intermediate broken build.
|
||||
- Discarded the local uncommitted 1.12.x-compat WIP (user confirmed it was superseded by origin/main's PR #46) rather than trying to merge two different compat mechanisms.
|
||||
|
||||
## Blockers / Risks
|
||||
|
||||
- None blocking. Open item: PR for the pushed branch hasn't been created — user was asked "要接著幫你開 PR 嗎?" and the session moved to harness setup before they answered.
|
||||
|
||||
## Next Session Startup
|
||||
|
||||
1. Read `CLAUDE.md`.
|
||||
2. Read `feature_list.json` and `progress.md`.
|
||||
3. Review this handoff.
|
||||
4. Run `./init.sh` before editing.
|
||||
5. Check whether the user wants a PR opened for `claude/settings-ux-improvements-260713` before starting new work.
|
||||
|
||||
## Recommended Next Step
|
||||
|
||||
- Ask the user whether to open the PR for the pushed branch; if yes, use `gh pr create` per the repo's PR conventions (see `CLAUDE.md` / firstsun-pm workflow) and reference issues #40/#41/#42 so they auto-close on merge.
|
||||
|
|
@ -11,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 {
|
||||
|
|
@ -159,6 +164,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()) {
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ export default class GitLabFilesPush extends Plugin {
|
|||
});
|
||||
|
||||
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.pushRibbonEl = this.addRibbonIcon('upload-cloud', this.pushRibbonLabel(), async () => {
|
||||
|
|
|
|||
107
src/settings.ts
107
src/settings.ts
|
|
@ -1,6 +1,7 @@
|
|||
import {App, PluginSettingTab, Setting, Notice, TextComponent} from 'obsidian';
|
||||
import GitLabFilesPush from "./main";
|
||||
import {FolderSuggest} from "./ui/FolderSuggest";
|
||||
import { ConnectionTestResult } from "./services/git-service-base";
|
||||
|
||||
// Minimal shape of Obsidian >= 1.13's SettingDefinitionItem. Declared locally so
|
||||
// the plugin still type-checks against older Obsidian typings (minAppVersion
|
||||
|
|
@ -45,6 +46,8 @@ 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;
|
||||
}
|
||||
|
||||
export function getServiceName(settings: GitLabFilesPushSettings): string {
|
||||
|
|
@ -82,11 +85,19 @@ export const DEFAULT_SETTINGS: GitLabFilesPushSettings = {
|
|||
branch: 'main',
|
||||
syncMetadata: {},
|
||||
vaultFolder: '',
|
||||
symlinkHandling: 'real'
|
||||
symlinkHandling: 'real',
|
||||
ignorePatterns: ''
|
||||
}
|
||||
|
||||
type ConnectionStatusState = 'checking' | 'connected' | 'disconnected';
|
||||
|
||||
const CONNECTION_TEST_DEBOUNCE_MS = 800;
|
||||
|
||||
export class GitLabSyncSettingTab extends PluginSettingTab {
|
||||
plugin: GitLabFilesPush;
|
||||
private statusBadgeEl: HTMLElement | null = null;
|
||||
private connectionTestTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private connectionTestSeq = 0;
|
||||
|
||||
constructor(app: App, plugin: GitLabFilesPush) {
|
||||
super(app, plugin);
|
||||
|
|
@ -121,9 +132,75 @@ export class GitLabSyncSettingTab extends PluginSettingTab {
|
|||
}
|
||||
}
|
||||
|
||||
// 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().
|
||||
private renderConnectionStatus(containerEl: HTMLElement): void {
|
||||
this.statusBadgeEl = containerEl.createDiv({ cls: 'gfs-connection-status' });
|
||||
this.setStatusBadge('checking');
|
||||
}
|
||||
|
||||
private setStatusBadge(state: ConnectionStatusState, detail?: string): void {
|
||||
const badge = this.statusBadgeEl;
|
||||
if (!badge) return;
|
||||
|
||||
badge.removeClass('is-checking');
|
||||
badge.removeClass('is-connected');
|
||||
badge.removeClass('is-disconnected');
|
||||
badge.addClass(`is-${state}`);
|
||||
|
||||
const labels: Record<ConnectionStatusState, string> = {
|
||||
checking: 'Checking…',
|
||||
connected: 'Connected',
|
||||
disconnected: 'Not connected'
|
||||
};
|
||||
const label = labels[state];
|
||||
badge.setText(detail ? `${label} — ${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) {
|
||||
clearTimeout(this.connectionTestTimer);
|
||||
}
|
||||
this.connectionTestTimer = setTimeout(() => {
|
||||
this.connectionTestTimer = null;
|
||||
void this.testConnectionSilently();
|
||||
}, CONNECTION_TEST_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
private async testConnectionSilently(): Promise<ConnectionTestResult> {
|
||||
const seq = ++this.connectionTestSeq;
|
||||
this.setStatusBadge('checking');
|
||||
|
||||
try {
|
||||
const result = await this.plugin.gitService.testConnection(this.plugin.settings.branch);
|
||||
if (seq !== this.connectionTestSeq) return result;
|
||||
|
||||
if (!result.repoOk) {
|
||||
this.setStatusBadge('disconnected', result.error ?? 'could not reach the repository');
|
||||
} else if (!result.branchOk) {
|
||||
this.setStatusBadge('disconnected', `branch "${this.plugin.settings.branch}" not found`);
|
||||
} else {
|
||||
this.setStatusBadge('connected');
|
||||
}
|
||||
return result;
|
||||
} catch (e: unknown) {
|
||||
if (seq === this.connectionTestSeq) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
this.setStatusBadge('disconnected', message);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private renderSettings(containerEl: HTMLElement): void {
|
||||
containerEl.empty();
|
||||
|
||||
this.renderConnectionStatus(containerEl);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Git service')
|
||||
.setDesc('Choose your Git hosting service')
|
||||
|
|
@ -158,6 +235,7 @@ export class GitLabSyncSettingTab extends PluginSettingTab {
|
|||
.onChange((value) => {
|
||||
this.plugin.settings.branch = value || 'main';
|
||||
void this.plugin.saveSettings();
|
||||
this.scheduleConnectionTest();
|
||||
}));
|
||||
|
||||
new Setting(containerEl)
|
||||
|
|
@ -187,6 +265,19 @@ export class GitLabSyncSettingTab extends PluginSettingTab {
|
|||
FolderSuggest.attach(this.app, text.inputEl);
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Ignore patterns')
|
||||
.setDesc('Optional: .gitignore-style patterns (one per line) to exclude local files from sync, in addition to the repository\'s own .gitignore.')
|
||||
.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';
|
||||
|
|
@ -214,7 +305,7 @@ export class GitLabSyncSettingTab extends PluginSettingTab {
|
|||
.setButtonText('Test connection')
|
||||
.onClick(async () => {
|
||||
try {
|
||||
const result = await this.plugin.gitService.testConnection(this.plugin.settings.branch);
|
||||
const result = await this.testConnectionSilently();
|
||||
if (!result.repoOk) {
|
||||
new Notice(`Connection failed: ${result.error ?? 'could not reach the repository'}`);
|
||||
} else if (!result.branchOk) {
|
||||
|
|
@ -231,6 +322,8 @@ export class GitLabSyncSettingTab extends PluginSettingTab {
|
|||
new Notice(`Connection failed: ${message}`);
|
||||
}
|
||||
}));
|
||||
|
||||
this.scheduleConnectionTest();
|
||||
}
|
||||
|
||||
// Token fields are masked like a password input (with a toggle to reveal
|
||||
|
|
@ -270,6 +363,7 @@ export class GitLabSyncSettingTab extends PluginSettingTab {
|
|||
this.plugin.settings.gitlabToken = value;
|
||||
void this.plugin.saveSettings();
|
||||
this.plugin.initializeGitService();
|
||||
this.scheduleConnectionTest();
|
||||
}
|
||||
);
|
||||
|
||||
|
|
@ -283,6 +377,7 @@ 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)
|
||||
|
|
@ -295,6 +390,7 @@ export class GitLabSyncSettingTab extends PluginSettingTab {
|
|||
this.plugin.settings.projectId = value;
|
||||
void this.plugin.saveSettings();
|
||||
this.plugin.initializeGitService();
|
||||
this.scheduleConnectionTest();
|
||||
}));
|
||||
}
|
||||
|
||||
|
|
@ -308,6 +404,7 @@ export class GitLabSyncSettingTab extends PluginSettingTab {
|
|||
this.plugin.settings.giteaToken = value;
|
||||
void this.plugin.saveSettings();
|
||||
this.plugin.initializeGitService();
|
||||
this.scheduleConnectionTest();
|
||||
}
|
||||
);
|
||||
|
||||
|
|
@ -321,6 +418,7 @@ export class GitLabSyncSettingTab extends PluginSettingTab {
|
|||
this.plugin.settings.giteaBaseUrl = value;
|
||||
void this.plugin.saveSettings();
|
||||
this.plugin.initializeGitService();
|
||||
this.scheduleConnectionTest();
|
||||
}));
|
||||
|
||||
new Setting(containerEl)
|
||||
|
|
@ -333,6 +431,7 @@ export class GitLabSyncSettingTab extends PluginSettingTab {
|
|||
this.plugin.settings.giteaOwner = value;
|
||||
void this.plugin.saveSettings();
|
||||
this.plugin.initializeGitService();
|
||||
this.scheduleConnectionTest();
|
||||
}));
|
||||
|
||||
new Setting(containerEl)
|
||||
|
|
@ -345,6 +444,7 @@ export class GitLabSyncSettingTab extends PluginSettingTab {
|
|||
this.plugin.settings.giteaRepo = value;
|
||||
void this.plugin.saveSettings();
|
||||
this.plugin.initializeGitService();
|
||||
this.scheduleConnectionTest();
|
||||
}));
|
||||
}
|
||||
|
||||
|
|
@ -358,6 +458,7 @@ export class GitLabSyncSettingTab extends PluginSettingTab {
|
|||
this.plugin.settings.githubToken = value;
|
||||
void this.plugin.saveSettings();
|
||||
this.plugin.initializeGitService();
|
||||
this.scheduleConnectionTest();
|
||||
}
|
||||
);
|
||||
|
||||
|
|
@ -371,6 +472,7 @@ export class GitLabSyncSettingTab extends PluginSettingTab {
|
|||
this.plugin.settings.githubOwner = value;
|
||||
void this.plugin.saveSettings();
|
||||
this.plugin.initializeGitService();
|
||||
this.scheduleConnectionTest();
|
||||
}));
|
||||
|
||||
new Setting(containerEl)
|
||||
|
|
@ -383,6 +485,7 @@ export class GitLabSyncSettingTab extends PluginSettingTab {
|
|||
this.plugin.settings.githubRepo = value;
|
||||
void this.plugin.saveSettings();
|
||||
this.plugin.initializeGitService();
|
||||
this.scheduleConnectionTest();
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { App, Modal, Setting } from 'obsidian';
|
||||
|
||||
type ConflictPanelName = 'diff' | 'local' | 'remote';
|
||||
|
||||
/**
|
||||
* Apply the "destructive" button style, but only when the running Obsidian
|
||||
* supports it. ButtonComponent.setDestructive() was added in Obsidian 1.13; on
|
||||
|
|
@ -39,22 +41,47 @@ export class SyncConflictModal extends Modal {
|
|||
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' });
|
||||
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: 'Diff', local: 'Local', remote: '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: 'Local version' });
|
||||
const localPre = localSection.createEl('pre', { cls: 'conflict-content' });
|
||||
localPre.createEl('code', { text: this.localContent });
|
||||
panels.local = localSection;
|
||||
|
||||
const remoteSection = diffContainer.createDiv({ cls: 'conflict-section' });
|
||||
const remoteSection = diffContainer.createDiv({ cls: 'conflict-section conflict-panel' });
|
||||
remoteSection.createEl('h3', { text: 'Remote version' });
|
||||
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' });
|
||||
const diffSection = contentArea.createDiv({ cls: 'conflict-diff-section conflict-panel' });
|
||||
diffSection.createEl('h3', { text: 'Differences' });
|
||||
const diffPre = diffSection.createEl('pre', { cls: 'conflict-diff' });
|
||||
this.renderDiff(diffPre);
|
||||
panels.diff = diffSection;
|
||||
|
||||
setActivePanel('diff');
|
||||
|
||||
const buttonContainer = contentEl.createDiv({ cls: 'conflict-buttons' });
|
||||
|
||||
|
|
|
|||
88
styles.css
88
styles.css
|
|
@ -570,13 +570,90 @@
|
|||
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);
|
||||
}
|
||||
|
||||
.gfs-connection-status.is-connected {
|
||||
color: var(--text-success);
|
||||
background: var(--background-modifier-success);
|
||||
}
|
||||
|
||||
.gfs-connection-status.is-disconnected {
|
||||
color: var(--text-error);
|
||||
background: var(--background-modifier-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 +665,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 +681,6 @@
|
|||
|
||||
.conflict-content,
|
||||
.conflict-diff {
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
padding: 10px;
|
||||
background: var(--background-secondary);
|
||||
|
|
@ -620,6 +701,7 @@
|
|||
padding-top: 12px;
|
||||
border-top: 1px solid var(--background-modifier-border);
|
||||
flex-wrap: wrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
|
|
|
|||
|
|
@ -190,6 +190,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']);
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ const mockSettings: GitLabFilesPushSettings = {
|
|||
vaultFolder: 'Work',
|
||||
syncMetadata: {},
|
||||
symlinkHandling: 'real',
|
||||
ignorePatterns: '',
|
||||
};
|
||||
|
||||
describe('SyncManager Mapping', () => {
|
||||
|
|
|
|||
|
|
@ -59,7 +59,8 @@ const mockSettings: GitLabFilesPushSettings = {
|
|||
rootPath: '',
|
||||
syncMetadata: {},
|
||||
vaultFolder: '',
|
||||
symlinkHandling: 'real'
|
||||
symlinkHandling: 'real',
|
||||
ignorePatterns: ''
|
||||
};
|
||||
|
||||
describe('SyncManager', () => {
|
||||
|
|
|
|||
196
tests/setup.ts
196
tests/setup.ts
|
|
@ -5,6 +5,16 @@ 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') {
|
||||
|
|
@ -14,27 +24,191 @@ if (typeof window === 'undefined') {
|
|||
};
|
||||
}
|
||||
|
||||
// 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 App = class {
|
||||
|
|
@ -47,6 +221,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'),
|
||||
},
|
||||
|
|
@ -85,6 +260,11 @@ vi.mock('obsidian', () => ({
|
|||
TFile,
|
||||
TFolder,
|
||||
AbstractInputSuggest,
|
||||
TextComponent,
|
||||
TextAreaComponent,
|
||||
DropdownComponent,
|
||||
ButtonComponent,
|
||||
ExtraButtonComponent,
|
||||
requestUrl,
|
||||
setTooltip,
|
||||
setIcon,
|
||||
|
|
|
|||
95
tests/ui/SettingsConnectionStatus.test.ts
Normal file
95
tests/ui/SettingsConnectionStatus.test.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
/* 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 { createContainer, setupObsidianDOM } from './setup-dom';
|
||||
|
||||
vi.mock('../../src/main', () => ({
|
||||
default: class {},
|
||||
}));
|
||||
|
||||
beforeAll(() => { setupObsidianDOM(); });
|
||||
|
||||
function createPluginStub(testConnection: ReturnType<typeof vi.fn>): GitLabFilesPush {
|
||||
return {
|
||||
settings: { ...DEFAULT_SETTINGS },
|
||||
saveSettings: vi.fn().mockResolvedValue(undefined),
|
||||
initializeGitService: vi.fn(),
|
||||
gitService: { testConnection },
|
||||
} as unknown as GitLabFilesPush;
|
||||
}
|
||||
|
||||
function scheduleConnectionTest(tab: GitLabSyncSettingTab): void {
|
||||
(tab as unknown as { scheduleConnectionTest: () => void }).scheduleConnectionTest();
|
||||
}
|
||||
|
||||
describe('GitLabSyncSettingTab connection status badge', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
it('shows checking then connected after opening the tab', async () => {
|
||||
const testConnection = vi.fn().mockResolvedValue({ repoOk: true, branchOk: true });
|
||||
const tab = new GitLabSyncSettingTab(new App(), createPluginStub(testConnection));
|
||||
tab.containerEl = createContainer();
|
||||
|
||||
tab.display();
|
||||
|
||||
const badge = tab.containerEl.querySelector('.gfs-connection-status') as HTMLElement;
|
||||
expect(badge.classList.contains('is-checking')).toBe(true);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(800);
|
||||
|
||||
expect(testConnection).toHaveBeenCalledTimes(1);
|
||||
expect(badge.classList.contains('is-connected')).toBe(true);
|
||||
expect(badge.textContent).toBe('Connected');
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('debounces repeated field edits into a single connection test', async () => {
|
||||
const testConnection = vi.fn().mockResolvedValue({ repoOk: false, branchOk: false, error: 'bad token' });
|
||||
const plugin = createPluginStub(testConnection);
|
||||
const tab = new GitLabSyncSettingTab(new App(), plugin);
|
||||
tab.containerEl = createContainer();
|
||||
|
||||
tab.display();
|
||||
await vi.advanceTimersByTimeAsync(800);
|
||||
testConnection.mockClear();
|
||||
|
||||
// Simulate rapid keystrokes in the token field via the debounced hook directly.
|
||||
scheduleConnectionTest(tab);
|
||||
scheduleConnectionTest(tab);
|
||||
scheduleConnectionTest(tab);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(799);
|
||||
expect(testConnection).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(testConnection).toHaveBeenCalledTimes(1);
|
||||
|
||||
const badge = tab.containerEl.querySelector('.gfs-connection-status') as HTMLElement;
|
||||
expect(badge.classList.contains('is-disconnected')).toBe(true);
|
||||
expect(badge.textContent).toContain('bad token');
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GitLabSyncSettingTab ignore patterns setting', () => {
|
||||
it('renders a textarea seeded with the saved ignorePatterns value', async () => {
|
||||
const plugin = createPluginStub(vi.fn().mockResolvedValue({ repoOk: true, branchOk: true }));
|
||||
plugin.settings.ignorePatterns = 'draft/\n*.tmp';
|
||||
const tab = new GitLabSyncSettingTab(new App(), plugin);
|
||||
tab.containerEl = createContainer();
|
||||
|
||||
vi.useFakeTimers();
|
||||
tab.display();
|
||||
await vi.advanceTimersByTimeAsync(800);
|
||||
vi.useRealTimers();
|
||||
|
||||
const textarea = tab.containerEl.querySelector('textarea') as HTMLTextAreaElement;
|
||||
expect(textarea.value).toBe('draft/\n*.tmp');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { applyDestructiveStyle } from '../../src/ui/SyncConflictModal';
|
||||
import { beforeAll, describe, it, expect, vi } from 'vitest';
|
||||
import { App } from 'obsidian';
|
||||
import { applyDestructiveStyle, SyncConflictModal } from '../../src/ui/SyncConflictModal';
|
||||
import { createContainer, setupObsidianDOM } from './setup-dom';
|
||||
|
||||
// Guards the backward-compatibility fix that lets the plugin run on Obsidian
|
||||
// down to minAppVersion 1.11.0. ButtonComponent.setDestructive() only exists on
|
||||
|
|
@ -26,3 +28,30 @@ describe('applyDestructiveStyle (Obsidian version compatibility)', () => {
|
|||
expect(() => applyDestructiveStyle(btn)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SyncConflictModal', () => {
|
||||
beforeAll(() => { setupObsidianDOM(); });
|
||||
|
||||
it('defaults to the diff panel and switches panels via tabs', () => {
|
||||
const modal = new SyncConflictModal(new App(), 'note.md', 'local', 'remote', vi.fn());
|
||||
modal.contentEl = createContainer();
|
||||
|
||||
modal.onOpen();
|
||||
|
||||
const contentEl = modal.contentEl;
|
||||
const tabs = Array.from(contentEl.querySelectorAll<HTMLElement>('.conflict-tab'));
|
||||
const panels = Array.from(contentEl.querySelectorAll<HTMLElement>('.conflict-panel'));
|
||||
|
||||
const activePanel = () => panels.find(panel => panel.classList.contains('is-active'));
|
||||
const activeTab = () => tabs.find(tab => tab.classList.contains('is-active'));
|
||||
|
||||
expect(activeTab()?.textContent).toBe('Diff');
|
||||
expect(activePanel()?.classList.contains('conflict-diff-section')).toBe(true);
|
||||
|
||||
const localTab = tabs.find(tab => tab.textContent === 'Local');
|
||||
localTab?.dispatchEvent(new Event('click'));
|
||||
|
||||
expect(activeTab()?.textContent).toBe('Local');
|
||||
expect(activePanel()?.classList.contains('conflict-section')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue