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
16 changes: 9 additions & 7 deletions docs/features/updates.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,15 +120,17 @@ the update card steps aside (`updateCardOf`): it would repeat them, over the dia
note per pull request, its title without the "by @user in #12" it ends with (`withoutAuthorTail`); their "New
Contributors" are left out. Notes written any other way show as plain Markdown (`WhatsNewRelease.plain`). The
closing "**Full Changelog**" line is left out: the footer links to every release instead.
- **Filter** (`filterChips`): chips for All and each kind the notes hold, with how many notes each has across every
release the update spans, shown only when the notes hold more than one kind. A kind with no notes has no chip.
- **Filter** (`filterSegments`): the app's segmented control (`SegmentedControl`), as its other views pick what they
show: All and each kind the notes hold, with how many notes each has across every release the update spans, shown
only when the notes hold more than one kind. A kind with no notes has no segment.
- **Releases** (`releaseSections`): the update's own release is open under the title; each older one is a row ("0.2.3
· 6 notes") the user unfolds (`toggledRelease`). A release with nothing to show is left out; when that's the
update's own, the first release shown opens in its place, named by its row. Each group's tag takes its kind's tone
(`NoteGroup.tone`). A filter shows its kind from every release at once, each older
release named by its row (no longer a toggle), so "Fixed" lists every fix since the running version; releases with
none of that kind are left out, and so is what a release says outside its notes. Going back to All keeps the
releases the user had unfolded.
update's own, the first release shown opens in its place, named by its row. Each group is titled as the app titles
a section (`SectionTitle`, as Home's "Recent"): its name, small and uppercase, and how many notes it holds; no color
of its own. A
filter shows its kind from every release at once, each older release named by its row (no longer a toggle), so
"Fixed" lists every fix since the running version; releases with none of that kind are left out, and so is what a
release says outside its notes. Going back to All keeps the releases the user had unfolded.
- **Footer**: "All releases", the repository's releases page (`AppInfo.releasesUrl`), and "Later" with the install
button once the update is ready (`installLabel`), or just "Close".

Expand Down
15 changes: 1 addition & 14 deletions src/renderer/src/app/home/Home.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -200,23 +200,10 @@
gap: 2px;
}

/* Where a section's title (`SectionTitle`) sits: in line with the rows under it. */
.sectionTitle {
display: flex;
align-items: center;
gap: var(--space-2);
margin: 0 0 var(--space-1);
padding: 0 var(--space-3);
color: var(--text-tertiary);
font-size: var(--text-xs);
font-weight: var(--weight-semibold);
letter-spacing: 0.06em;
text-transform: uppercase;
}

.sectionCount {
font-weight: var(--weight-medium);
font-variant-numeric: tabular-nums;
letter-spacing: 0;
}

.sectionEmpty {
Expand Down
6 changes: 3 additions & 3 deletions src/renderer/src/app/home/WorkspaceList.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { forwardRef, useImperativeHandle, useRef, type KeyboardEvent, type ReactNode } from 'react';
import { focusFirstItem, moveRovingFocus } from '../../lib/rovingFocus';
import { HighlightQuery } from '../../ui/Highlight';
import { SectionTitle } from '../../ui/SectionTitle';
import { windowAskedBy, workspaceOpener } from '../workspace/workspaceWindowChoice';
import type { WorkspaceEntry } from './recentWorkspaces';
import { firstMatch, searchedSections } from './searchedSections';
Expand Down Expand Up @@ -66,10 +67,9 @@ export const WorkspaceList = forwardRef<WorkspaceListHandle, WorkspaceListProps>
{shown.map((section) => (
<section key={section.id} className={styles.section} aria-label={section.title}>
{section.title && (
<h2 className={styles.sectionTitle}>
<SectionTitle className={styles.sectionTitle} count={section.entries.length > 0 ? section.entries.length : undefined}>
{section.title}
{section.entries.length > 0 && <span className={styles.sectionCount}>{section.entries.length}</span>}
</h2>
</SectionTitle>
)}
{section.entries.length === 0
? section.empty
Expand Down
28 changes: 2 additions & 26 deletions src/renderer/src/app/updates/ReleaseNotesDialog.module.css
Original file line number Diff line number Diff line change
@@ -1,39 +1,15 @@
/* The chips stay in view while the notes scroll under them: they sit at the body's top, over its padding. */
.chips {
/* The filter stays in view while the notes scroll under it: it sits at the body's top, over its padding. */
.filter {
position: sticky;
top: calc(-1 * var(--space-5));
z-index: 1;
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin: calc(-1 * var(--space-5)) 0 calc(-1 * var(--space-2));
padding: var(--space-4) 0 var(--space-3);
border-bottom: 1px solid var(--border-subtle);
background: var(--bg-surface-raised);
}

.swatch {
width: 7px;
height: 7px;
border-radius: var(--radius-full);
}

.swatch[data-kind='new'] {
background: var(--status-added);
}

.swatch[data-kind='improved'] {
background: var(--accent-text);
}

.swatch[data-kind='fixed'] {
background: var(--status-changed);
}

.swatch[data-kind='other'] {
background: var(--text-tertiary);
}

.count {
color: var(--text-tertiary);
font-variant-numeric: tabular-nums;
Expand Down
59 changes: 31 additions & 28 deletions src/renderer/src/app/updates/ReleaseNotesDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,22 @@ import { api } from '../../api/client';
import { AppMark } from '../../components/AppMark';
import { MarkdownBlocks, MarkdownInlines } from '../../components/Markdown';
import { Button } from '../../ui/Button';
import { DetailsBadge } from '../../ui/DetailsBadge';
import { SectionTitle } from '../../ui/SectionTitle';
import { SegmentedControl } from '../../ui/SegmentedControl';
import { Dialog } from '../../ui/dialog/Dialog';
import { openDialog } from '../../ui/dialog/dialogStore';
import { Spinner } from '../../ui/Spinner';
import { ToggleChip } from '../../ui/ToggleChip';
import { appInfoQuery } from '../about/appInfoQuery';
import { installLabel } from './updateCardStatus';
import { releaseNotesQuery } from './updateReleaseNotes';
import { installUpdate, setReleaseNotesOpen, useUpdateStore } from './updateStore';
import { whatsNewRelease, type WhatsNewNote } from './whatsNewRelease';
import {
filterChips,
filterSegments,
toggledRelease,
whatsNewBody,
whatsNewSubtitle,
type FilterChip,
type FilterSegment,
type NoteGroup,
type NotesFilter,
type ReleaseRow,
Expand Down Expand Up @@ -72,7 +72,7 @@ function ReleaseNotesDialog({ version, onClose }: { version: string; onClose: ()
{notes ? (
<WhatsNewBodyView
body={whatsNewBody(releases, filter, unfolded)}
chips={filterChips(releases)}
segments={filterSegments(releases)}
filter={filter}
onFilter={setFilter}
onToggle={(version) => setUnfolded((current) => toggledRelease(current, version))}
Expand All @@ -86,40 +86,43 @@ function ReleaseNotesDialog({ version, onClose }: { version: string; onClose: ()

interface WhatsNewBodyViewProps {
body: WhatsNewBody;
chips: FilterChip[];
segments: FilterSegment[];
filter: NotesFilter;
onFilter: (filter: NotesFilter) => void;
onToggle: (version: string) => void;
}

function WhatsNewBodyView({ body, chips, filter, onFilter, onToggle }: WhatsNewBodyViewProps) {
function WhatsNewBodyView({ body, segments, filter, onFilter, onToggle }: WhatsNewBodyViewProps) {
if (body.kind === 'empty') return <p className={styles.empty}>Nothing more to show for this update.</p>;
return (
<>
<FilterChips chips={chips} filter={filter} onChange={onFilter} />
<NotesFilterControl segments={segments} filter={filter} onChange={onFilter} />
{body.sections.map((section) => (
<Release key={section.version} section={section} onToggle={() => onToggle(section.version)} />
))}
</>
);
}

function FilterChips({ chips, filter, onChange }: { chips: FilterChip[]; filter: NotesFilter; onChange: (filter: NotesFilter) => void }) {
if (chips.length === 0) return null;
/** The app's segmented control, as its other views pick what they show: each kind with its count, quietly. */
function NotesFilterControl({ segments, filter, onChange }: { segments: FilterSegment[]; filter: NotesFilter; onChange: (filter: NotesFilter) => void }) {
if (segments.length === 0) return null;
return (
<div className={styles.chips} role="group" aria-label="Show">
{chips.map((chip) => (
<ToggleChip
key={chip.filter}
size="small"
pressed={chip.filter === filter}
onChange={() => onChange(chip.filter)}
icon={chip.filter !== 'all' && <span className={styles.swatch} data-kind={chip.filter} aria-hidden />}
>
{chip.label}
<span className={styles.count}>{chip.count}</span>
</ToggleChip>
))}
<div className={styles.filter}>
<SegmentedControl<NotesFilter>
label="Show"
value={filter}
onChange={onChange}
segments={segments.map((segment) => ({
value: segment.filter,
label: (
<>
{segment.label}
<span className={styles.count}>{segment.count}</span>
</>
),
}))}
/>
</div>
);
}
Expand Down Expand Up @@ -161,9 +164,9 @@ function ReleaseRowHeading({ row, open, onToggle }: { row: ReleaseRow; open: boo
function NoteGroupView({ group }: { group: NoteGroup }) {
return (
<div className={styles.group}>
<div>
<DetailsBadge tone={group.tone}>{group.label}</DetailsBadge>
</div>
<SectionTitle as="h4" count={group.notes.length}>
{group.label}
</SectionTitle>
{group.notes.map((note, index) => (
<Note key={index} note={note} />
))}
Expand All @@ -175,9 +178,9 @@ function Note({ note }: { note: WhatsNewNote }) {
return (
<article className={styles.note}>
{note.title.length > 0 && (
<h4 className={styles.noteTitle}>
<h5 className={styles.noteTitle}>
<MarkdownInlines inlines={note.title} />
</h4>
</h5>
)}
{note.description.length > 0 && (
<div className={styles.description}>
Expand Down
26 changes: 7 additions & 19 deletions src/renderer/src/app/updates/whatsNewView.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest';
import { fullChangelogLine, generatedItem, generatedNotes, group, note, paragraph } from './testing/releaseNotesHtml';
import { whatsNewRelease, type WhatsNewRelease } from './whatsNewRelease';
import { filterChips, hasWhatsNew, releaseSections, toggledRelease, whatsNewBody, whatsNewSubtitle } from './whatsNewView';
import { filterSegments, hasWhatsNew, releaseSections, toggledRelease, whatsNewBody, whatsNewSubtitle } from './whatsNewView';

const newest = whatsNewRelease({
version: '0.2.5',
Expand All @@ -23,19 +23,19 @@ const summary = (releases: WhatsNewRelease[], filter: Parameters<typeof releaseS

describe("What's New's filter", () => {
it('counts each kind of note across every release, All first, and other changes only when there are some', () => {
expect(filterChips([newest, onlyFixes])).toEqual([
expect(filterSegments([newest, onlyFixes])).toEqual([
{ filter: 'all', label: 'All', count: 6 },
{ filter: 'new', label: 'New', count: 2 },
{ filter: 'improved', label: 'Improved', count: 1 },
{ filter: 'fixed', label: 'Fixed', count: 3 },
]);
expect(filterChips([newest, onlyFixes, generated]).at(-1)).toEqual({ filter: 'other', label: 'Other changes', count: 1 });
expect(filterSegments([newest, onlyFixes, generated]).at(-1)).toEqual({ filter: 'other', label: 'Other changes', count: 1 });
});

it('shows no chips when the notes hold one kind, or none', () => {
expect(filterChips([onlyFixes])).toEqual([]);
expect(filterChips([generated, plain])).toEqual([]);
expect(filterChips([])).toEqual([]);
it('shows no filter when the notes hold one kind, or none', () => {
expect(filterSegments([onlyFixes])).toEqual([]);
expect(filterSegments([generated, plain])).toEqual([]);
expect(filterSegments([])).toEqual([]);
});
});

Expand Down Expand Up @@ -76,18 +76,6 @@ describe("What's New's releases", () => {
expect(summary([newest, onlyFixes], 'all', unfolded)[1]!.open).toBe(true);
expect(toggledRelease(unfolded, '0.2.4')).toEqual(new Set());
});

it('tones each group by its kind', () => {
const [section] = releaseSections([newest, generated], 'all', new Set());
const unfoldedGenerated = releaseSections([newest, generated], 'all', new Set(['0.2.3']))[1]!;

expect(section!.groups.map(({ kind, tone }) => ({ kind, tone }))).toEqual([
{ kind: 'new', tone: 'success' },
{ kind: 'improved', tone: 'accent' },
{ kind: 'fixed', tone: 'warning' },
]);
expect(unfoldedGenerated.groups[0]!.tone).toBe('neutral');
});
});

describe('releases with nothing to show', () => {
Expand Down
16 changes: 5 additions & 11 deletions src/renderer/src/app/updates/whatsNewView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,32 +5,26 @@ import { NOTE_KIND_LABELS, NOTE_KINDS, type NoteKind, type WhatsNewNote, type Wh
/** Which notes What's New shows: every one, or one kind. */
export type NotesFilter = 'all' | NoteKind;

export interface FilterChip {
export interface FilterSegment {
filter: NotesFilter;
label: string;
count: number;
}

/**
* The chips that filter the notes, each with how many notes it shows across every release the update spans: All,
* The segments that filter the notes, each with how many notes it shows across every release the update spans: All,
* then each kind the notes hold. None when the notes hold fewer than two kinds, where a filter would show the same.
*/
export function filterChips(releases: readonly WhatsNewRelease[]): FilterChip[] {
export function filterSegments(releases: readonly WhatsNewRelease[]): FilterSegment[] {
const notes = releases.flatMap((release) => release.notes);
const kinds = NOTE_KINDS.map((kind) => ({ filter: kind, label: NOTE_KIND_LABELS[kind], count: notes.filter((note) => note.kind === kind).length }));
const present = kinds.filter((chip) => chip.count > 0);
const present = kinds.filter((segment) => segment.count > 0);
return present.length < 2 ? [] : [{ filter: 'all', label: 'All', count: notes.length }, ...present];
}

/** The color of a group's tag (`DetailsBadge`'s tones): new is green, improved blue, fixed amber, the rest grey. */
export type NoteGroupTone = 'success' | 'accent' | 'warning' | 'neutral';

const KIND_TONES: Record<NoteKind, NoteGroupTone> = { new: 'success', improved: 'accent', fixed: 'warning', other: 'neutral' };

export interface NoteGroup {
kind: NoteKind;
label: string;
tone: NoteGroupTone;
notes: WhatsNewNote[];
}

Expand Down Expand Up @@ -98,7 +92,7 @@ export function releaseSections(releases: readonly WhatsNewRelease[], filter: No
function groupsOf(notes: readonly WhatsNewNote[]): NoteGroup[] {
return NOTE_KINDS.flatMap((kind) => {
const ofKind = notes.filter((note) => note.kind === kind);
return ofKind.length > 0 ? [{ kind, label: NOTE_KIND_LABELS[kind], tone: KIND_TONES[kind], notes: ofKind }] : [];
return ofKind.length > 0 ? [{ kind, label: NOTE_KIND_LABELS[kind], notes: ofKind }] : [];
});
}

Expand Down
22 changes: 22 additions & 0 deletions src/renderer/src/ui/SectionTitle.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
.title {
display: flex;
align-items: center;
gap: var(--space-2);
color: var(--text-tertiary);
font-size: var(--text-xs);
font-weight: var(--weight-semibold);
letter-spacing: 0.06em;
text-transform: uppercase;
}

.count {
font-weight: var(--weight-medium);
font-variant-numeric: tabular-nums;
letter-spacing: 0;
}

/* A heading's own margins gone, at no specificity (`:where`), so the class that places the title sets its own,
whichever stylesheet loads first. */
:where(.title) {
margin: 0;
}
23 changes: 23 additions & 0 deletions src/renderer/src/ui/SectionTitle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { ReactNode } from 'react';
import { classNames } from '../lib/classNames';
import styles from './SectionTitle.module.css';

interface SectionTitleProps {
/** The heading level it takes in the outline around it. */
as?: 'h2' | 'h3' | 'h4';
/** How many items the section holds, shown quietly after its name; none when it says nothing worth showing. */
count?: number;
/** Where it sits in its layout (margins, padding); the look is the title's own. */
className?: string;
children: ReactNode;
}

/** A section's name as the app titles a section (Home's "Recent", What's New's groups): small, quiet, uppercase. */
export function SectionTitle({ as: Heading = 'h2', count, className, children }: SectionTitleProps) {
return (
<Heading className={classNames(styles.title, className)}>
{children}
{count !== undefined && <span className={styles.count}>{count}</span>}
</Heading>
);
}
Loading