Skip to content
Open
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
2 changes: 1 addition & 1 deletion .github/ISSUE_TEMPLATE/bug_report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ body:
id: version
attributes:
label: Cotect version
placeholder: '0.3.0'
placeholder: '0.4.0'
validations:
required: true
- type: dropdown
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.

## [Unreleased]

## [0.4.0] - 2026-06-16

### Added

- Settings: a "What's new" tab showing the release notes for your installed version, with a link to the full changelog.

## [0.3.0] - 2026-06-16

### Added
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "cotect",
"private": true,
"version": "0.3.0",
"version": "0.4.0",
"license": "Apache-2.0",
"type": "module",
"scripts": {
Expand Down
79 changes: 79 additions & 0 deletions src/components/Settings/WhatsNewSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { useEffect, useMemo, useState } from 'react'
import type { MouseEvent } from 'react'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import changelogText from '../../../CHANGELOG.md?raw'
import { getPlatform } from '@/services/platform'
import { sectionForVersion } from '@/lib/changelog'

const RELEASES_URL = 'https://github.com/cotect-dev/cotect/releases'
const PLUGINS = [remarkGfm]

// react-markdown emits bare HTML tags; style them as descendants so the notes
// match Settings' typography instead of falling back to browser defaults.
const PROSE =
'flex flex-col gap-1 ' +
'[&_h3]:text-xs [&_h3]:font-semibold [&_h3]:text-foreground [&_h3]:mt-4 [&_h3]:mb-1 ' +
'[&_ul]:flex [&_ul]:flex-col [&_ul]:gap-1 [&_ul]:pl-4 [&_ul]:list-disc [&_li]:marker:text-muted-foreground/40 ' +
'[&_li]:text-xs [&_li]:text-muted-foreground [&_li]:leading-relaxed ' +
'[&_p]:text-xs [&_p]:text-muted-foreground [&_p]:leading-relaxed ' +
'[&_a]:text-foreground [&_a]:underline [&_a]:underline-offset-2 ' +
'[&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-[11px]'

export default function WhatsNewSection() {
const [version, setVersion] = useState('')

useEffect(() => {
let active = true
getPlatform()
.app.info()
.then((i) => active && setVersion(i.version))
.catch(() => {})
return () => {
active = false
}
}, [])

const section = useMemo(() => sectionForVersion(changelogText, version), [version])

// Links inside the notes would navigate the WebView itself; route them to the
// OS browser like the rest of the app does.
const handleClick = (e: MouseEvent<HTMLDivElement>) => {
const anchor = (e.target as HTMLElement).closest('a')
if (anchor?.href) {
e.preventDefault()
void getPlatform().app.openExternal(anchor.href)
}
}

return (
<div className="flex flex-col gap-4">
<section className="flex flex-col gap-1">
<h2 className="text-sm font-semibold text-foreground">What&apos;s new</h2>
{section && (
<div className="text-[11px] text-muted-foreground">
Version {section.version}
{section.date ? ` · ${section.date}` : ''}
</div>
)}
</section>

{section && section.body ? (
<div className={PROSE} onClick={handleClick}>
<Markdown remarkPlugins={PLUGINS}>{section.body}</Markdown>
</div>
) : (
<div className="text-xs text-muted-foreground">
No release notes are bundled for this build yet.
</div>
)}

<button
onClick={() => void getPlatform().app.openExternal(RELEASES_URL)}
className="self-start text-xs text-muted-foreground hover:text-foreground underline-offset-2 hover:underline"
>
View full changelog
</button>
</div>
)
}
8 changes: 6 additions & 2 deletions src/components/Settings/index.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { useState } from 'react'
import GeneralSection from './GeneralSection'
import EditorSection from './EditorSection'
import WhatsNewSection from './WhatsNewSection'

type SectionId = 'general' | 'editor'
type SectionId = 'general' | 'whatsnew' | 'editor'

const NAV: { id: SectionId; label: string }[] = [
{ id: 'general', label: 'General' },
{ id: 'whatsnew', label: "What's new" },
{ id: 'editor', label: 'Editor' },
]

Expand All @@ -32,7 +34,9 @@ export default function Settings() {

<div className="flex-1 min-w-0 overflow-y-auto px-6 py-6">
<div className="max-w-2xl">
{active === 'general' ? <GeneralSection /> : <EditorSection />}
{active === 'general' && <GeneralSection />}
{active === 'whatsnew' && <WhatsNewSection />}
{active === 'editor' && <EditorSection />}
</div>
</div>
</div>
Expand Down
63 changes: 63 additions & 0 deletions src/lib/changelog.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { describe, it, expect } from 'vitest'
import { parseChangelog, sectionForVersion } from './changelog'

const SAMPLE = `# Changelog

All notable changes are documented here.

## [Unreleased]

## [0.4.0] - 2026-06-16

### Added

- In-app changelog viewer.

## [0.3.0] - 2026-06-15

### Fixed

- A scroll bug.

## [0.2.0]

### Added

- Something with no date.
`

describe('parseChangelog', () => {
it('extracts released versions in file order and skips Unreleased', () => {
const sections = parseChangelog(SAMPLE)
expect(sections.map((s) => s.version)).toEqual(['0.4.0', '0.3.0', '0.2.0'])
})

it('captures the date when present and null when omitted', () => {
const sections = parseChangelog(SAMPLE)
expect(sections[0].date).toBe('2026-06-16')
expect(sections[2].date).toBeNull()
})

it('captures the trimmed body between headings', () => {
const [latest] = parseChangelog(SAMPLE)
expect(latest.body).toBe('### Added\n\n- In-app changelog viewer.')
})

it('returns an empty array when there are no released sections', () => {
expect(parseChangelog('# Changelog\n\n## [Unreleased]\n')).toEqual([])
})
})

describe('sectionForVersion', () => {
it('returns the exact version match', () => {
expect(sectionForVersion(SAMPLE, '0.3.0')?.version).toBe('0.3.0')
})

it('falls back to the newest section for an unknown version', () => {
expect(sectionForVersion(SAMPLE, 'dev')?.version).toBe('0.4.0')
})

it('returns null when there are no released sections', () => {
expect(sectionForVersion('## [Unreleased]\n', '0.4.0')).toBeNull()
})
})
54 changes: 54 additions & 0 deletions src/lib/changelog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// Parse CHANGELOG.md (Keep a Changelog format) into per-version sections so the
// app can show the release notes for the running build. The file is bundled raw
// at build time, so this only ever runs over our own changelog.

export interface ChangelogSection {
version: string
/** Release date as written in the heading (e.g. "2026-06-16"), or null. */
date: string | null
/** Markdown between this version's heading and the next, trimmed. */
body: string
}

// "## [1.2.3] - 2026-06-16" or "## [1.2.3]" (date optional). The version is
// digits-only, so "## [Unreleased]" never matches and is skipped.
const HEADING = /^##\s+\[(\d+\.\d+\.\d+)\](?:\s*-\s*(.+?))?\s*$/

export function parseChangelog(md: string): ChangelogSection[] {
const sections: ChangelogSection[] = []
let current: { version: string; date: string | null; bodyLines: string[] } | null = null

const flush = () => {
if (current) {
sections.push({
version: current.version,
date: current.date,
body: current.bodyLines.join('\n').trim(),
})
}
}

for (const line of md.split('\n')) {
const m = HEADING.exec(line)
if (m) {
flush()
current = { version: m[1], date: m[2]?.trim() ?? null, bodyLines: [] }
} else if (current) {
current.bodyLines.push(line)
}
}
flush()
return sections
}

/**
* The changelog section to show for `version`. An exact version match wins;
* when the running build has no entry (dev and web builds report "dev"), fall
* back to the newest released section so there is always something to read.
* Returns null only when the changelog lists no released versions at all.
*/
export function sectionForVersion(md: string, version: string): ChangelogSection | null {
const sections = parseChangelog(md)
if (sections.length === 0) return null
return sections.find((s) => s.version === version) ?? sections[0]
}
2 changes: 1 addition & 1 deletion tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "cotect"
version = "0.3.0"
version = "0.4.0"
edition = "2021"
license = "Apache-2.0"

Expand Down
2 changes: 1 addition & 1 deletion tauri/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/nicedoc/tauri-schema/main/schema/2.0/tauri.conf.json",
"productName": "Cotect",
"version": "0.3.0",
"version": "0.4.0",
"identifier": "com.cotect.app",
"build": {
"frontendDist": "../dist",
Expand Down