From 4eebebc765b1495ebc49baf38f8f08eff9bf3520 Mon Sep 17 00:00:00 2001 From: ClaudiaFang Date: Mon, 13 Jul 2026 13:56:38 +0000 Subject: [PATCH] feat: show new feature tips after update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/changelog.ts | 41 ++++++++++++ src/main.ts | 28 ++++++++ src/settings.ts | 5 +- src/ui/WhatsNewModal.ts | 43 +++++++++++++ src/utils/version.ts | 18 ++++++ styles.css | 9 +++ tests/changelog.test.ts | 34 ++++++++++ tests/logic/sync-manager-mapping.test.ts | 1 + tests/logic/sync-manager.test.ts | 3 +- tests/ui/WhatsNewModal.test.ts | 81 ++++++++++++++++++++++++ tests/utils/version.test.ts | 27 ++++++++ 11 files changed, 288 insertions(+), 2 deletions(-) create mode 100644 src/changelog.ts create mode 100644 src/ui/WhatsNewModal.ts create mode 100644 src/utils/version.ts create mode 100644 tests/changelog.test.ts create mode 100644 tests/ui/WhatsNewModal.test.ts create mode 100644 tests/utils/version.test.ts diff --git a/src/changelog.ts b/src/changelog.ts new file mode 100644 index 0000000..ce1ddba --- /dev/null +++ b/src/changelog.ts @@ -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)); +} diff --git a/src/main.ts b/src/main.ts index b55b522..ccd9e9f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -9,6 +9,9 @@ import { SyncStatusView, SYNC_STATUS_VIEW_TYPE } from './ui/SyncStatusView'; import { GitignoreManager } from './logic/gitignore-manager'; import { logger } from './utils/logger'; import { ConfirmModal } from './ui/ConfirmModal'; +import { WhatsNewModal } from './ui/WhatsNewModal'; +import { CHANGELOG, getUnseenReleases } from './changelog'; +import { compareVersions } from './utils/version'; export default class GitLabFilesPush extends Plugin { settings: GitLabFilesPushSettings; @@ -109,6 +112,31 @@ export default class GitLabFilesPush extends Plugin { } }) ); + + await this.checkForUpdateNotice(); + } + + private async checkForUpdateNotice(): Promise { + 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 { diff --git a/src/settings.ts b/src/settings.ts index 1310fe5..b5f01f0 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -48,6 +48,8 @@ export interface GitLabFilesPushSettings { symlinkHandling: SymlinkHandling; /** Multi-line, .gitignore-style patterns applied locally, in addition to the remote repo's .gitignore rules. */ ignorePatterns: string; + /** Plugin version last seen by this vault, used to show a "what's new" tip after an update. */ + lastSeenVersion: string; } export function getServiceName(settings: GitLabFilesPushSettings): string { @@ -86,7 +88,8 @@ export const DEFAULT_SETTINGS: GitLabFilesPushSettings = { syncMetadata: {}, vaultFolder: '', symlinkHandling: 'real', - ignorePatterns: '' + ignorePatterns: '', + lastSeenVersion: '' } type ConnectionStatusState = 'checking' | 'connected' | 'disconnected'; diff --git a/src/ui/WhatsNewModal.ts b/src/ui/WhatsNewModal.ts new file mode 100644 index 0000000..c7814cc --- /dev/null +++ b/src/ui/WhatsNewModal.ts @@ -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(); + } +} diff --git a/src/utils/version.ts b/src/utils/version.ts new file mode 100644 index 0000000..a9b5b3c --- /dev/null +++ b/src/utils/version.ts @@ -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; +} diff --git a/styles.css b/styles.css index 1072e1e..aec6684 100644 --- a/styles.css +++ b/styles.css @@ -719,3 +719,12 @@ display: flex; gap: 10px; } + +.ssv-whats-new-list { + margin: 0 0 12px; + padding-left: 20px; +} + +.ssv-whats-new-notable { + font-weight: 600; +} diff --git a/tests/changelog.test.ts b/tests/changelog.test.ts new file mode 100644 index 0000000..1aa346a --- /dev/null +++ b/tests/changelog.test.ts @@ -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); + }); +}); diff --git a/tests/logic/sync-manager-mapping.test.ts b/tests/logic/sync-manager-mapping.test.ts index a1f8b6d..b8fe60b 100644 --- a/tests/logic/sync-manager-mapping.test.ts +++ b/tests/logic/sync-manager-mapping.test.ts @@ -54,6 +54,7 @@ const mockSettings: GitLabFilesPushSettings = { syncMetadata: {}, symlinkHandling: 'real', ignorePatterns: '', + lastSeenVersion: '', }; describe('SyncManager Mapping', () => { diff --git a/tests/logic/sync-manager.test.ts b/tests/logic/sync-manager.test.ts index b476a5b..e68fcca 100644 --- a/tests/logic/sync-manager.test.ts +++ b/tests/logic/sync-manager.test.ts @@ -60,7 +60,8 @@ const mockSettings: GitLabFilesPushSettings = { syncMetadata: {}, vaultFolder: '', symlinkHandling: 'real', - ignorePatterns: '' + ignorePatterns: '', + lastSeenVersion: '' }; describe('SyncManager', () => { diff --git a/tests/ui/WhatsNewModal.test.ts b/tests/ui/WhatsNewModal.test.ts new file mode 100644 index 0000000..9ab6a83 --- /dev/null +++ b/tests/ui/WhatsNewModal.test.ts @@ -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(); + }); +}); diff --git a/tests/utils/version.test.ts b/tests/utils/version.test.ts new file mode 100644 index 0000000..5b1c0c0 --- /dev/null +++ b/tests/utils/version.test.ts @@ -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); + }); +});