feat: show new feature tips after update

Adds a "what's new" modal shown once after the plugin updates to a new
version, so users don't miss what changed:

- New src/changelog.ts: a hand-curated CHANGELOG array (distinct from
  the auto-generated CHANGELOG.md, which lists every commit) where
  entries can be marked `notable` so they're called out separately
  from minor fixes, per the issue's clarification comment.
- New src/utils/version.ts: compareVersions() does numeric per-segment
  comparison (so "1.10.0" sorts after "1.9.0", unlike a plain string
  compare).
- getUnseenReleases() filters+sorts the changelog to what's newer than
  a given "last seen" version, extracted as a pure/testable function
  rather than inlined in main.ts (which isn't unit-tested in this repo).
- New settings field lastSeenVersion, persisted the same way as other
  settings. On a fresh install (empty lastSeenVersion) it's just
  recorded silently — no modal, since there's nothing to compare
  against. On an actual version bump, WhatsNewModal shows the unseen
  releases' highlights, with a "View full changelog" link out to
  CHANGELOG.md and a "Got it" dismiss; the version is recorded either
  way so the tip only ever shows once per upgrade.
- The whole check is wrapped in try/catch so a malformed version
  string can never break plugin startup.

Closes #39
This commit is contained in:
ClaudiaFang 2026-07-13 13:56:38 +00:00
parent 0cec9ad6f8
commit 4eebebc765
11 changed files with 288 additions and 2 deletions

41
src/changelog.ts Normal file
View file

@ -0,0 +1,41 @@
import { compareVersions } from './utils/version';
/**
* Hand-curated, user-facing highlights shown in the "what's new" modal after an
* update. Distinct from the auto-generated CHANGELOG.md (which lists every
* commit for developers) keep entries short and skimmable, and mark the ones
* worth calling out as `notable` so they aren't buried among minor fixes.
*
* Add an entry here as part of cutting a release; versions are matched against
* manifest.json's version by exact string, so keep them in sync.
*/
export interface ChangelogEntry {
text: string;
notable?: boolean;
}
export interface ChangelogRelease {
version: string;
entries: ChangelogEntry[];
}
export const CHANGELOG: ChangelogRelease[] = [
{
version: '1.2.1',
entries: [
{ text: 'Fixed compatibility with Obsidian versions back to 1.11.0', notable: true },
],
},
];
/**
* Releases newer than `lastSeenVersion`, newest first what a "what's new"
* modal should show after an update. Returns everything (unsorted by recency
* concerns) when `lastSeenVersion` is empty, since callers are expected to
* only invoke this once they've already decided an upgrade happened.
*/
export function getUnseenReleases(changelog: ChangelogRelease[], lastSeenVersion: string): ChangelogRelease[] {
return changelog
.filter(release => compareVersions(release.version, lastSeenVersion) > 0)
.sort((a, b) => compareVersions(b.version, a.version));
}

View file

@ -9,6 +9,9 @@ import { SyncStatusView, SYNC_STATUS_VIEW_TYPE } from './ui/SyncStatusView';
import { GitignoreManager } from './logic/gitignore-manager'; import { GitignoreManager } from './logic/gitignore-manager';
import { logger } from './utils/logger'; import { logger } from './utils/logger';
import { ConfirmModal } from './ui/ConfirmModal'; import { ConfirmModal } from './ui/ConfirmModal';
import { WhatsNewModal } from './ui/WhatsNewModal';
import { CHANGELOG, getUnseenReleases } from './changelog';
import { compareVersions } from './utils/version';
export default class GitLabFilesPush extends Plugin { export default class GitLabFilesPush extends Plugin {
settings: GitLabFilesPushSettings; settings: GitLabFilesPushSettings;
@ -109,6 +112,31 @@ export default class GitLabFilesPush extends Plugin {
} }
}) })
); );
await this.checkForUpdateNotice();
}
private async checkForUpdateNotice(): Promise<void> {
try {
const currentVersion = this.manifest.version;
const lastSeen = this.settings.lastSeenVersion;
// A fresh install has nothing to compare against — just record the
// current version silently rather than showing a "what's new" tip.
if (lastSeen && compareVersions(currentVersion, lastSeen) > 0) {
const newReleases = getUnseenReleases(CHANGELOG, lastSeen);
if (newReleases.length > 0) {
new WhatsNewModal(this.app, newReleases).open();
}
}
if (lastSeen !== currentVersion) {
this.settings.lastSeenVersion = currentVersion;
await this.saveSettings();
}
} catch (e) {
logger.warn('Failed to check for update notice', e);
}
} }
private get serviceName(): string { private get serviceName(): string {

View file

@ -48,6 +48,8 @@ export interface GitLabFilesPushSettings {
symlinkHandling: SymlinkHandling; symlinkHandling: SymlinkHandling;
/** Multi-line, .gitignore-style patterns applied locally, in addition to the remote repo's .gitignore rules. */ /** Multi-line, .gitignore-style patterns applied locally, in addition to the remote repo's .gitignore rules. */
ignorePatterns: string; ignorePatterns: string;
/** Plugin version last seen by this vault, used to show a "what's new" tip after an update. */
lastSeenVersion: string;
} }
export function getServiceName(settings: GitLabFilesPushSettings): string { export function getServiceName(settings: GitLabFilesPushSettings): string {
@ -86,7 +88,8 @@ export const DEFAULT_SETTINGS: GitLabFilesPushSettings = {
syncMetadata: {}, syncMetadata: {},
vaultFolder: '', vaultFolder: '',
symlinkHandling: 'real', symlinkHandling: 'real',
ignorePatterns: '' ignorePatterns: '',
lastSeenVersion: ''
} }
type ConnectionStatusState = 'checking' | 'connected' | 'disconnected'; type ConnectionStatusState = 'checking' | 'connected' | 'disconnected';

43
src/ui/WhatsNewModal.ts Normal file
View file

@ -0,0 +1,43 @@
import { App, Modal, ButtonComponent } from 'obsidian';
import { type ChangelogRelease } from '../changelog';
const CHANGELOG_URL = 'https://github.com/firstsun-dev/git-files-sync/blob/main/CHANGELOG.md';
export class WhatsNewModal extends Modal {
private readonly releases: ChangelogRelease[];
constructor(app: App, releases: ChangelogRelease[]) {
super(app);
this.releases = releases;
}
onOpen() {
const { contentEl } = this;
contentEl.createEl('h3', { text: "What's new" });
for (const release of this.releases) {
contentEl.createEl('h4', { text: `v${release.version}` });
const list = contentEl.createEl('ul', { cls: 'ssv-whats-new-list' });
for (const entry of release.entries) {
const item = list.createEl('li', { cls: entry.notable ? 'ssv-whats-new-notable' : undefined });
item.setText(entry.text);
}
}
const buttonContainer = contentEl.createDiv({ cls: 'ssv-confirm-buttons modal-button-container' });
new ButtonComponent(buttonContainer)
.setButtonText('View full changelog')
.onClick(() => window.open(CHANGELOG_URL, '_blank', 'noopener'));
new ButtonComponent(buttonContainer)
.setButtonText('Got it')
.setCta()
.onClick(() => this.close());
}
onClose() {
const { contentEl } = this;
contentEl.empty();
}
}

18
src/utils/version.ts Normal file
View file

@ -0,0 +1,18 @@
/**
* Compares two dot-separated version strings numerically per segment (so
* "1.10.0" sorts after "1.9.0", unlike a plain string comparison). Missing or
* non-numeric segments are treated as 0. Returns <0, 0, or >0 like a standard
* comparator.
*/
export function compareVersions(a: string, b: string): number {
const partsA = a.split('.');
const partsB = b.split('.');
const length = Math.max(partsA.length, partsB.length);
for (let i = 0; i < length; i++) {
const numA = Number(partsA[i]) || 0;
const numB = Number(partsB[i]) || 0;
if (numA !== numB) return numA - numB;
}
return 0;
}

View file

@ -719,3 +719,12 @@
display: flex; display: flex;
gap: 10px; gap: 10px;
} }
.ssv-whats-new-list {
margin: 0 0 12px;
padding-left: 20px;
}
.ssv-whats-new-notable {
font-weight: 600;
}

34
tests/changelog.test.ts Normal file
View file

@ -0,0 +1,34 @@
import { describe, it, expect } from 'vitest';
import { getUnseenReleases, type ChangelogRelease } from '../src/changelog';
describe('getUnseenReleases', () => {
const changelog: ChangelogRelease[] = [
{ version: '1.0.0', entries: [{ text: 'Initial release' }] },
{ version: '1.1.0', entries: [{ text: 'Feature A' }] },
{ version: '1.2.0', entries: [{ text: 'Feature B', notable: true }] },
{ version: '1.10.0', entries: [{ text: 'Feature C' }] },
];
it('returns only releases newer than lastSeenVersion', () => {
const result = getUnseenReleases(changelog, '1.1.0');
expect(result.map(r => r.version)).toEqual(['1.10.0', '1.2.0']);
});
it('returns releases newest-first', () => {
const result = getUnseenReleases(changelog, '1.0.0');
expect(result.map(r => r.version)).toEqual(['1.10.0', '1.2.0', '1.1.0']);
});
it('compares versions numerically, not lexically (1.10.0 > 1.2.0)', () => {
const result = getUnseenReleases(changelog, '1.9.0');
expect(result.map(r => r.version)).toEqual(['1.10.0']);
});
it('returns an empty array when already on the latest version', () => {
expect(getUnseenReleases(changelog, '1.10.0')).toEqual([]);
});
it('returns everything when lastSeenVersion is empty', () => {
expect(getUnseenReleases(changelog, '').length).toBe(4);
});
});

View file

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

View file

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

View file

@ -0,0 +1,81 @@
import { beforeAll, describe, it, expect, vi, afterEach } from 'vitest';
import { App } from 'obsidian';
import { WhatsNewModal } from '../../src/ui/WhatsNewModal';
import { createContainer, setupObsidianDOM } from './setup-dom';
import type { ChangelogRelease } from '../../src/changelog';
describe('WhatsNewModal', () => {
beforeAll(() => { setupObsidianDOM(); });
afterEach(() => { vi.restoreAllMocks(); });
const releases: ChangelogRelease[] = [
{
version: '1.3.0',
entries: [
{ text: 'Notable highlight', notable: true },
{ text: 'Minor fix' },
],
},
{
version: '1.2.1',
entries: [
{ text: 'Older release note' },
],
},
];
function openModal(rels: ChangelogRelease[]): HTMLElement {
const modal = new WhatsNewModal(new App(), rels);
modal.contentEl = createContainer();
modal.onOpen();
return modal.contentEl;
}
it('renders a heading for each release version', () => {
const contentEl = openModal(releases);
const headings = Array.from(contentEl.querySelectorAll('h4')).map(h => h.textContent);
expect(headings).toEqual(['v1.3.0', 'v1.2.1']);
});
it('renders every entry as a list item', () => {
const contentEl = openModal(releases);
const items = Array.from(contentEl.querySelectorAll('li')).map(li => li.textContent);
expect(items).toEqual(['Notable highlight', 'Minor fix', 'Older release note']);
});
it('marks notable entries distinctly from non-notable ones', () => {
const contentEl = openModal(releases);
const items = Array.from(contentEl.querySelectorAll('li'));
expect(items[0]?.classList.contains('ssv-whats-new-notable')).toBe(true);
expect(items[1]?.classList.contains('ssv-whats-new-notable')).toBe(false);
});
it('opens the full changelog URL when "View full changelog" is clicked', () => {
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
const contentEl = openModal(releases);
const buttons = Array.from(contentEl.querySelectorAll('button'));
const changelogBtn = buttons.find(b => b.textContent?.includes('View full changelog'));
changelogBtn?.click();
expect(openSpy).toHaveBeenCalledWith(
'https://github.com/firstsun-dev/git-files-sync/blob/main/CHANGELOG.md',
'_blank',
'noopener'
);
});
it('closes the modal when "Got it" is clicked', () => {
const modal = new WhatsNewModal(new App(), releases);
modal.contentEl = createContainer();
modal.onOpen();
const closeSpy = vi.spyOn(modal, 'close');
const buttons = Array.from(modal.contentEl.querySelectorAll('button'));
const gotItBtn = buttons.find(b => b.textContent?.includes('Got it'));
gotItBtn?.click();
expect(closeSpy).toHaveBeenCalledOnce();
});
});

View file

@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest';
import { compareVersions } from '../../src/utils/version';
describe('compareVersions', () => {
it('returns 0 for equal versions', () => {
expect(compareVersions('1.2.1', '1.2.1')).toBe(0);
});
it('compares patch versions', () => {
expect(compareVersions('1.2.1', '1.2.0')).toBeGreaterThan(0);
expect(compareVersions('1.2.0', '1.2.1')).toBeLessThan(0);
});
it('compares numerically, not lexically, across double-digit segments', () => {
expect(compareVersions('1.10.0', '1.9.0')).toBeGreaterThan(0);
expect(compareVersions('1.9.0', '1.10.0')).toBeLessThan(0);
});
it('treats a missing segment as 0', () => {
expect(compareVersions('1.2', '1.2.0')).toBe(0);
expect(compareVersions('1.2.1', '1.2')).toBeGreaterThan(0);
});
it('treats a non-numeric segment as 0', () => {
expect(compareVersions('1.x.0', '1.0.0')).toBe(0);
});
});