Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .github/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# How `gh release create --generate-notes` (the Release workflow's "Ensure
# GitHub release exists" step) groups merged PRs. Each PR's title is its line
# in the release notes, both on GitHub and in the app's What's New dialog; its
# label decides the heading. See "Pull requests are release notes" in CLAUDE.md.
changelog:
exclude:
labels:
- internal
- documentation
categories:
- title: New
labels:
- enhancement
- title: Fixed
labels:
- bug
- title: Other changes
labels:
- "*"
12 changes: 12 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,18 @@ jobs:
TAG: ${{ inputs.tag || github.ref_name }}
run: gh release view "$TAG" >/dev/null 2>&1 || gh release create "$TAG" --title "$TAG" --generate-notes

# The notes just generated above (grouped by .github/release.yml), plus
# the last nine releases', become the app's What's New
# (main/whats-new.ts). Baked rather than fetched at runtime: works
# offline and always matches the build. `files` in package.json ships it.
- name: Bake release notes
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ inputs.tag || github.ref_name }}
run: |
scripts/release_notes.sh "$TAG" > WhatsNew.md
cat WhatsNew.md

# electron-builder 26.15/26.16's own CSC_LINK handling has a bug: the
# temp keychain it creates gets a random unlock password, but its
# `set-key-partition-list -k` call is passed CSC_KEY_PASSWORD (the
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,6 @@ renderer/mermaid.js
test/*.js

.omc

# Release notes baked in by the Release workflow (scripts/release_notes.sh)
/WhatsNew.md
29 changes: 29 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,35 @@ needs the popup↔anchor pairing or toggle-off breaks.
`tsconfig*.json` programs; run after any `.ts` edit even if you're not
about to run the full suite.

## Pull requests are release notes

Every merge to `main` is its own patch release (Deploy → Release), and
that release's notes are GitHub's generated notes: **each PR's title is
its line, and its label picks the heading** (`.github/release.yml`). The
same text, with the last nine releases', is baked into the app as
`WhatsNew.md` (`scripts/release_notes.sh`) and shown once after an update,
and from Help → What's New. A PR title is written for someone *using* the
app, not for a reviewer, and it is final at merge time — the build bakes
it in.

- **Title**: what the user can now do or no longer suffers, in the
imperative — "Find text in the markdown preview", "Keep the blame
gutter aligned after a zoom". No `feat:`/`fix:` prefix (the label says
that), no file or type names, no trailing period. Commit subjects stay
conventional — `scripts/next_version.sh` reads those, not PR titles
(merges are merge commits, so the title never becomes a subject).
- **Label, exactly one**: `enhancement` (**New**), `bug` (**Fixed**), or
`internal` for anything a user cannot notice (CI, build, refactors) and
`documentation` for docs — both left out of the notes. An unlabelled PR
lands under **Other changes**, which is the sign one was missed.
`gh pr create --label enhancement`; `gh pr edit <n> --add-label bug`.

"Seen" is keyed on `app.getVersion()` (`whatsNewSeen` in settings). A
local build has no `WhatsNew.md`, so nothing shows; to try it, drop a
`WhatsNew.md` at the repo root with a `# v<package.json version>`
header and `yarn start` (delete `whatsNewSeen` from `settings.json` to
see it again).

## Misc

- Settings persist to `settings.json` in `userData` via
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,9 @@ Three GitHub Actions workflows, same shape end to end:
Release.
3. **Release** (`.github/workflows/release.yml`) — builds, code-signs,
notarizes, and publishes the signed DMG/zip to a GitHub Release for that
tag.
tag. The release notes are generated from merged PR titles, grouped by
label (`.github/release.yml`), and baked into the app as its What's New
dialog.

Release needs these repo secrets: `CSC_LINK` / `CSC_KEY_PASSWORD` (a
base64-encoded Developer ID Application `.p12` and its password, for
Expand Down
9 changes: 9 additions & 0 deletions main/api-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,19 @@ export interface Settings {
panelWidth?: number;
panelSide?: 'left' | 'right';
commitHistory?: string[];
whatsNewSeen?: string; // app version whose release notes were last shown
}

export interface AppInfo {
name: string;
version: string;
}

export interface WhatsNewRelease {
version: string;
sections: { title: string; items: string[] }[];
}

export interface ConfirmOptions {
message: string;
detail?: string;
Expand Down Expand Up @@ -121,6 +127,9 @@ export interface DiffierApi {
gitLastMessage(): Promise<string>;
setBadge(count: number): Promise<void>;
getAppInfo(): Promise<AppInfo>;
// unseen: the releases since the one last shown, marking this one seen;
// otherwise every release baked into this build.
getWhatsNew(unseen: boolean): Promise<WhatsNewRelease[]>;

saveFile(relPath: string, content: string): Promise<void>;
revealFile(relPath: string): Promise<void>;
Expand Down
4 changes: 3 additions & 1 deletion main/keymap-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ export type ActionId =
| 'zoom-out'
| 'zoom-reset'
| 'keymap-settings'
| 'about-dialog';
| 'about-dialog'
| 'whats-new';

export interface KeymapAction {
id: ActionId;
Expand Down Expand Up @@ -87,4 +88,5 @@ export const ACTIONS: KeymapAction[] = [
{ id: 'zoom-reset', label: 'Reset Zoom', default: 'Mod+Shift+0' },
{ id: 'keymap-settings', label: 'Settings', default: 'Mod+,' },
{ id: 'about-dialog', label: 'About Diffier', default: null },
{ id: 'whats-new', label: "What's New", default: null },
];
26 changes: 26 additions & 0 deletions main/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import * as fsp from 'fs/promises';
import * as gitlib from './git';
import * as keymap from './keymap';
import { THEMES, DEFAULT_THEME } from './themes';
import * as whatsNew from './whats-new';
import type { ActionId, Binding } from './keymap';
import type { CommitOptions, ConfirmOptions, RepoInfo, RollbackTarget, Settings } from './api-types';
import type { ThemeId } from './themes';
Expand Down Expand Up @@ -66,6 +67,16 @@ function saveSettings(patch: Partial<Settings>): Settings {
return merged;
}

// Release notes baked in by the Release workflow (see main/whats-new.ts);
// absent in a local build, which leaves What's New empty.
const WHATS_NEW = (() => {
try {
return whatsNew.parse(fs.readFileSync(path.join(__dirname, '..', 'WhatsNew.md'), 'utf8'));
} catch {
return [];
}
})();

// ----------------------------------------------------------------- watcher

function stopWatching(state: WindowState): void {
Expand Down Expand Up @@ -311,6 +322,15 @@ handle('app:badge', (_state, count: number) => {
}
});
handle('app:info', () => ({ name: app.name, version: app.getVersion() }));
handle('app:whatsNew', (_state, unseen: boolean) => {
if (!unseen) return whatsNew.releasesAfter(WHATS_NEW, '0', app.getVersion());
// Every window asks at boot; the first one to ask marks the version seen,
// so only it shows the dialog.
const seen = loadSettings().whatsNewSeen;
if (!WHATS_NEW.length || seen === app.getVersion()) return [];
saveSettings({ whatsNewSeen: app.getVersion() });
return whatsNew.releasesAfter(WHATS_NEW, seen, app.getVersion());
});
handle('file:save', (state, relPath: string, content: string) =>
gitlib.saveFile(requireRepo(state), relPath, content)
);
Expand Down Expand Up @@ -530,6 +550,12 @@ function buildMenu(): void {
}),
],
},
{
label: 'Help',
role: 'help',
// Off in a local build, which has no notes baked in.
submenu: [{ ...mi('whats-new', "What's New in Diffier"), enabled: WHATS_NEW.length > 0 }],
},
];
Menu.setApplicationMenu(Menu.buildFromTemplate(template));
}
Expand Down
1 change: 1 addition & 0 deletions main/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ const api: DiffierApi = {
gitLastMessage: () => call('git:lastMessage'),
setBadge: (count) => call('app:badge', count),
getAppInfo: () => call('app:info'),
getWhatsNew: (unseen) => call('app:whatsNew', unseen),

saveFile: (relPath, content) => call('file:save', relPath, content),
revealFile: (relPath) => call('shell:reveal', relPath),
Expand Down
54 changes: 54 additions & 0 deletions main/whats-new.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
'use strict';

/* Release notes, shown once after an update and from Help → What's New.

The Release workflow bakes them into the app as WhatsNew.md
(scripts/release_notes.sh): GitHub's generated notes for this release and
the nine before it, each under a `# vX.Y.Z` line, newest first. Every merge
to main is its own patch release, so an update that skips a few would
otherwise show only the last PR. Baked rather than fetched: works offline
and always matches the build. A local build has no file, so it never shows
anything and never marks a version seen.

Zero Node deps, so test/whats-new.test.ts can exercise it directly. */

import type { WhatsNewRelease } from './api-types';

const newer = (a: string, b: string) => a.localeCompare(b, undefined, { numeric: true }) > 0;

// GitHub's generated-notes markdown cut down to what a user reads: the `###`
// categories from .github/release.yml and their bullets, minus the
// " by @author in <PR url>" tail. "New Contributors" and "Full Changelog" go.
export function parse(markdown: string): WhatsNewRelease[] {
const releases: WhatsNewRelease[] = [];
let skipping = false;
for (const raw of markdown.split('\n')) {
const line = raw.trim();
const cur = releases[releases.length - 1];
if (line.startsWith('# ')) {
releases.push({ version: line.slice(2).replace(/^v/, ''), sections: [] });
skipping = false;
} else if (line.startsWith('## ')) {
skipping = line === '## New Contributors';
} else if (skipping || !cur) {
continue;
} else if (line.startsWith('### ')) {
cur.sections.push({ title: line.slice(4), items: [] });
} else if (line.startsWith('* ') || line.startsWith('- ')) {
// A release from before .github/release.yml has no categories.
if (!cur.sections.length) cur.sections.push({ title: '', items: [] });
cur.sections[cur.sections.length - 1].items.push(line.slice(2).replace(/ by @\S+ in \S+$/, ''));
}
}
return releases
.map((r) => ({ ...r, sections: r.sections.filter((s) => s.items.length) }))
.filter((r) => r.sections.length);
}

// The releases newer than `seen`, up to the running one. Nothing seen yet — a
// fresh install, or the first update to a version with this dialog — shows
// the running release alone rather than the whole baked history.
export function releasesAfter(all: WhatsNewRelease[], seen: string | undefined, current: string): WhatsNewRelease[] {
if (seen === undefined) return all.filter((r) => r.version === current);
return all.filter((r) => newer(r.version, seen) && !newer(r.version, current));
}
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"build": "tsc -p tsconfig.main.json && tsc -p tsconfig.renderer.json && tsc -p tsconfig.test.json && yarn build:preload",
"start": "yarn build && electron .",
"smoke": "yarn build && DIFFIER_SMOKE=1 electron --no-sandbox .",
"test": "yarn build && node test/git.test.js && node test/keymap.test.js && node test/themes.test.js && node test/languages.test.js",
"test": "yarn build && node test/git.test.js && node test/keymap.test.js && node test/themes.test.js && node test/languages.test.js && node test/whats-new.test.js",
"build:highlighter": "esbuild renderer/highlighter-entry.ts --bundle --format=iife --minify --log-level=warning --outfile=renderer/highlighter.js",
"build:mermaid": "esbuild renderer/mermaid-entry.ts --bundle --format=iife --minify --log-level=warning --banner:js=\"(function(define){\" --footer:js=\"})();\" --outfile=renderer/mermaid.js",
"build:preload": "esbuild main/preload.ts --bundle --platform=node --format=cjs --external:electron --log-level=warning --outfile=main/preload.js",
Expand Down Expand Up @@ -52,7 +52,8 @@
"!renderer/global.d.ts",
"node_modules/monaco-editor/min/**",
"node_modules/monaco-editor/min-maps/**",
"package.json"
"package.json",
"WhatsNew.md"
],
"icon": "build/icon.icns",
"afterSign": "build/afterSign.js",
Expand Down
8 changes: 8 additions & 0 deletions renderer/app/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ const ACTION_IMPL: Partial<Record<ActionId, () => void>> = {
'zoom-reset': () => zoomReset(),
'keymap-settings': () => toggleKeymapDialog(),
'about-dialog': () => toggleAboutDialog(),
'whats-new': () => void openWhatsNew(false),
};

function runAction(id: ActionId | string): void {
Expand Down Expand Up @@ -163,6 +164,13 @@ window.addEventListener(
}
return;
}
if (whatsNewOpen) {
if (e.key === 'Escape') {
e.preventDefault();
closeWhatsNew();
}
return;
}

// Fixed tree navigation wins over the keymap when the tree has focus.
if (handleTreeKey(e)) {
Expand Down
1 change: 1 addition & 0 deletions renderer/app/boot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@ $('amend-checkbox').addEventListener('change', async (e) => {
applyTheme(state.settings.theme || DEFAULT_THEME);
diffEditor!.updateOptions({ renderSideBySide: state.settings.viewMode !== 'unified' });
for (const t of DIFF_TOGGLES) t.apply(diffToggleOn(t));
void openWhatsNew(true);
try {
const repo = await window.api.openLastRepo();
if (repo) await setRepo(repo);
Expand Down
60 changes: 60 additions & 0 deletions renderer/app/whats-new.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
'use strict';

/* What's New dialog: release notes baked into the build (main/whats-new.ts),
shown once after an update and from Help → What's New.
Part of the Diffier renderer — classic scripts share module scope;
load order is defined in index.html. */

let whatsNewOpen = false;

// unseen: the launch check — only what's new since the last version shown,
// and nothing at all if that's this one.
async function openWhatsNew(unseen: boolean): Promise<void> {
let releases: WhatsNewRelease[];
try {
releases = await window.api.getWhatsNew(unseen);
} catch (err) {
if (!unseen) toast("Failed to load What's New: " + errMsg(err), true);
return;
}
if (!releases.length) return;
const body = $('whatsnew-body');
body.replaceChildren();
$('whatsnew-title').textContent =
releases.length === 1 ? `What's New in ${releases[0].version}` : "What's New";
for (const r of releases) {
if (releases.length > 1) body.appendChild(whatsNewEl('h3', 'whatsnew-version', `Version ${r.version}`));
for (const s of r.sections) {
if (s.title) body.appendChild(whatsNewEl('h4', 'whatsnew-section', s.title));
const ul = body.appendChild(whatsNewEl('ul', '', ''));
for (const item of s.items) ul.appendChild(noteItem(item));
}
}
whatsNewOpen = true;
$('whatsnew-overlay').classList.remove('hidden');
}

function whatsNewEl(tag: string, cls: string, text: string): HTMLElement {
const e = document.createElement(tag);
if (cls) e.className = cls;
e.textContent = text;
return e;
}

// A PR title, as text: `backticks` become <code>, nothing else is markup.
function noteItem(item: string): HTMLElement {
const li = whatsNewEl('li', '', '');
item.split('`').forEach((part, i) => li.appendChild(i % 2 ? whatsNewEl('code', '', part) : document.createTextNode(part)));
return li;
}

function closeWhatsNew(): void {
whatsNewOpen = false;
$('whatsnew-overlay').classList.add('hidden');
treeEl.focus();
}

$('whatsnew-done').addEventListener('click', closeWhatsNew);
$('whatsnew-overlay').addEventListener('mousedown', (e) => {
if (e.target === $('whatsnew-overlay')) closeWhatsNew();
});
2 changes: 2 additions & 0 deletions renderer/global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import type {
RepoInfo as RepoInfo_,
RollbackTarget as RollbackTarget_,
Settings as Settings_,
WhatsNewRelease as WhatsNewRelease_,
} from '../main/api-types';
import type {
AheadBehind as AheadBehind_,
Expand Down Expand Up @@ -125,6 +126,7 @@ declare global {
type RepoInfo = RepoInfo_;
type RollbackTarget = RollbackTarget_;
type Settings = Settings_;
type WhatsNewRelease = WhatsNewRelease_;
type StashEntry = StashEntry_;
type StatusResult = StatusResult_;
type ActionId = ActionId_;
Expand Down
Loading
Loading