mirror of
https://github.com/firstsun-dev/git-files-sync.git
synced 2026-07-22 17:20:30 +00:00
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:
parent
0cec9ad6f8
commit
4eebebc765
11 changed files with 288 additions and 2 deletions
41
src/changelog.ts
Normal file
41
src/changelog.ts
Normal 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));
|
||||
}
|
||||
28
src/main.ts
28
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<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 {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
43
src/ui/WhatsNewModal.ts
Normal file
43
src/ui/WhatsNewModal.ts
Normal 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
18
src/utils/version.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
34
tests/changelog.test.ts
Normal file
34
tests/changelog.test.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { getUnseenReleases, type ChangelogRelease } from '../src/changelog';
|
||||
|
||||
describe('getUnseenReleases', () => {
|
||||
const changelog: ChangelogRelease[] = [
|
||||
{ version: '1.0.0', entries: [{ text: 'Initial release' }] },
|
||||
{ version: '1.1.0', entries: [{ text: 'Feature A' }] },
|
||||
{ version: '1.2.0', entries: [{ text: 'Feature B', notable: true }] },
|
||||
{ version: '1.10.0', entries: [{ text: 'Feature C' }] },
|
||||
];
|
||||
|
||||
it('returns only releases newer than lastSeenVersion', () => {
|
||||
const result = getUnseenReleases(changelog, '1.1.0');
|
||||
expect(result.map(r => r.version)).toEqual(['1.10.0', '1.2.0']);
|
||||
});
|
||||
|
||||
it('returns releases newest-first', () => {
|
||||
const result = getUnseenReleases(changelog, '1.0.0');
|
||||
expect(result.map(r => r.version)).toEqual(['1.10.0', '1.2.0', '1.1.0']);
|
||||
});
|
||||
|
||||
it('compares versions numerically, not lexically (1.10.0 > 1.2.0)', () => {
|
||||
const result = getUnseenReleases(changelog, '1.9.0');
|
||||
expect(result.map(r => r.version)).toEqual(['1.10.0']);
|
||||
});
|
||||
|
||||
it('returns an empty array when already on the latest version', () => {
|
||||
expect(getUnseenReleases(changelog, '1.10.0')).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns everything when lastSeenVersion is empty', () => {
|
||||
expect(getUnseenReleases(changelog, '').length).toBe(4);
|
||||
});
|
||||
});
|
||||
|
|
@ -54,6 +54,7 @@ const mockSettings: GitLabFilesPushSettings = {
|
|||
syncMetadata: {},
|
||||
symlinkHandling: 'real',
|
||||
ignorePatterns: '',
|
||||
lastSeenVersion: '',
|
||||
};
|
||||
|
||||
describe('SyncManager Mapping', () => {
|
||||
|
|
|
|||
|
|
@ -60,7 +60,8 @@ const mockSettings: GitLabFilesPushSettings = {
|
|||
syncMetadata: {},
|
||||
vaultFolder: '',
|
||||
symlinkHandling: 'real',
|
||||
ignorePatterns: ''
|
||||
ignorePatterns: '',
|
||||
lastSeenVersion: ''
|
||||
};
|
||||
|
||||
describe('SyncManager', () => {
|
||||
|
|
|
|||
81
tests/ui/WhatsNewModal.test.ts
Normal file
81
tests/ui/WhatsNewModal.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
27
tests/utils/version.test.ts
Normal file
27
tests/utils/version.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue