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:
ClaudiaFang 2026-07-13 13:08:30 +00:00
commit 29bf3604e3
19 changed files with 848 additions and 23 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)

9
archive/2026-07.md Normal file
View 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
View 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
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"

66
progress.md Normal file
View 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
View 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.

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

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