From 89475f4fed0052dfcac26d6c2d82cc550f5758d5 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sat, 31 Oct 2026 09:07:16 +0800 Subject: [PATCH] feat(github): show relative dates in pull requests Pull request lists, timelines, commits and replies show how long ago something happened ("just now", "8 hours ago", "yesterday", "3 days ago") and the date after 30 days ("on Oct 12"). Hovering a timeline date shows the full date and time. With pretty formatting off, the plain date and time are shown. --- src/features/github/create/ChangesPanel.tsx | 6 +- src/features/github/details/CommitPicker.tsx | 4 +- .../github/timeline/comments/ChatItem.tsx | 8 ++- .../github/timeline/comments/ThreadView.tsx | 8 ++- src/features/github/timeline/items.tsx | 8 ++- src/features/github/ui/prettyDate.ts | 69 +++++++++++-------- src/features/github/ui/ui.test.tsx | 25 +++---- src/platform/locales/text/zh.json | 4 ++ 8 files changed, 81 insertions(+), 51 deletions(-) diff --git a/src/features/github/create/ChangesPanel.tsx b/src/features/github/create/ChangesPanel.tsx index 758b3470a..5d03a01cc 100644 --- a/src/features/github/create/ChangesPanel.tsx +++ b/src/features/github/create/ChangesPanel.tsx @@ -12,7 +12,7 @@ import type { Commit } from "@/platform/ipc"; import { tx } from "@/platform/i18n"; import { Icon, IconButton, Spinner, T, Tree, fileIconName, showContextMenu, type MenuItem } from "@/ui"; import { ChooserPopup } from "../ui/ChooserPopup"; -import { prettyDateTime } from "../ui/prettyDate"; +import { prettyDate } from "../ui/prettyDate"; import { commitDescription } from "./model"; import { selectCommit, useCreatePr, type CreateChange } from "./store"; import styles from "./Create.module.css"; @@ -119,7 +119,7 @@ function CommitChooser({ commits, selected }: { commits: Commit[]; selected: Com c ? { text: c.summary, - detail: `${c.author}, ${c.timeMs ? prettyDateTime(c.timeMs) : ""}`, + detail: `${c.author}, ${c.timeMs ? prettyDate(c.timeMs) : ""}`, icon: selected?.id === c.id ? "check" : "commit", } : { text: tx`All commits (${commits.length})`, icon: selected ? "commit" : "check" } @@ -150,7 +150,7 @@ function CommitDetails({ commit }: { commit: Commit }) { {details && description &&
{actorLink(actor)} - {author?.date && <> {prettyDateTime(author.date)}>} + {author?.date && ( + <> + {prettyDate(author.date)} + > + )}
)} diff --git a/src/features/github/ui/prettyDate.ts b/src/features/github/ui/prettyDate.ts index 1eb8d284f..3941c5a22 100644 --- a/src/features/github/ui/prettyDate.ts +++ b/src/features/github/ui/prettyDate.ts @@ -1,46 +1,59 @@ /** - * IntelliJ's `DateFormatUtil.formatPrettyDate` / `formatPrettyDateTime`, honouring - * Settings › Date Formats: "Moments ago", "N minutes ago" within the hour, "Today" / - * "Yesterday" (with the time for `prettyDateTime`), else the date. With pretty - * formatting off, the plain date (and time). + * GitHub-style relative dates: "just now", "N minutes ago", "N hours ago", + * "yesterday", "N days ago", then "on Oct 12" (with the year when it differs). + * With Settings › Date Formats › pretty formatting off, the plain date and time. */ -import { dateFormatOptions, formatClock, formatDay, type DateFormatOptions } from "@/platform/dateFormat"; +import { + dateFormatOptions, + formatClock, + formatDay, + formatPattern, + type DateFormatOptions, +} from "@/platform/dateFormat"; import { tx } from "@/platform/i18n"; const MINUTE = 60_000; const HOUR = 60 * MINUTE; +const DAY = 24 * HOUR; -function pretty(ms: number, withTime: boolean, now: number, o: DateFormatOptions): string { - const d = new Date(ms); - const time = withTime ? ` ${formatClock(d, o)}` : ""; - if (o.pretty) { - const delta = now - ms; - const today = new Date(now); - if (d.toDateString() === today.toDateString()) { - if (delta >= 0 && delta < MINUTE) return tx("Moments ago"); - if (delta >= 0 && delta <= HOUR) { - const n = Math.floor(delta / MINUTE); - return n === 1 ? tx("1 minute ago") : tx`${n} minutes ago`; - } - return withTime ? tx`Today ${formatClock(d, o)}` : tx("Today"); - } - if (d.toDateString() === new Date(now - 86_400_000).toDateString()) - return withTime ? tx`Yesterday ${formatClock(d, o)}` : tx("Yesterday"); - } - return `${formatDay(d, o)}${time}`; +function onDay(d: Date, now: number, o: DateFormatOptions): string { + if (o.overrideSystem) return tx`on ${formatPattern(d, o.pattern)}`; + const sameYear = d.getFullYear() === new Date(now).getFullYear(); + const day = d.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + ...(sameYear ? {} : { year: "numeric" }), + }); + return tx`on ${day}`; } const toMs = (date: string | number | Date) => typeof date === "number" ? date : date instanceof Date ? date.getTime() : Date.parse(date); -/** `formatPrettyDate`: list rows ("created {date}"). Empty for an invalid date. */ +/** List rows, timeline headers, commits and replies. Empty for an invalid date. */ export function prettyDate(date: string | number | Date, now = Date.now(), o = dateFormatOptions()): string { const ms = toMs(date); - return Number.isNaN(ms) ? "" : pretty(ms, false, now, o); + if (Number.isNaN(ms)) return ""; + const d = new Date(ms); + if (!o.pretty) return `${formatDay(d, o)} ${formatClock(d, o)}`; + const delta = Math.max(0, now - ms); + if (delta < MINUTE) return tx("just now"); + if (delta < HOUR) { + const n = Math.floor(delta / MINUTE); + return n === 1 ? tx("1 minute ago") : tx`${n} minutes ago`; + } + if (delta < DAY) { + const n = Math.floor(delta / HOUR); + return n === 1 ? tx("1 hour ago") : tx`${n} hours ago`; + } + const days = Math.floor(delta / DAY); + if (days === 1) return tx("yesterday"); + if (days < 30) return tx`${days} days ago`; + return onDay(d, now, o); } -/** `formatPrettyDateTime`: timeline headers, commits, replies. */ -export function prettyDateTime(date: string | number | Date, now = Date.now(), o = dateFormatOptions()): string { +/** The full date and time, for a tooltip on a relative date. */ +export function fullDateTime(date: string | number | Date, o = dateFormatOptions()): string { const ms = toMs(date); - return Number.isNaN(ms) ? "" : pretty(ms, true, now, o); + return Number.isNaN(ms) ? "" : `${formatDay(new Date(ms), o)} ${formatClock(new Date(ms), o)}`; } diff --git a/src/features/github/ui/ui.test.tsx b/src/features/github/ui/ui.test.tsx index 9e55131b3..747cf4a6d 100644 --- a/src/features/github/ui/ui.test.tsx +++ b/src/features/github/ui/ui.test.tsx @@ -6,27 +6,28 @@ import { Avatar, AvatarStack, avatarSrc, reviewerRingColor } from "./Avatar"; import { BranchChip, LabelChip, StateTag, labelForeground } from "./chips"; import { ChooserPopup, chooserItems } from "./ChooserPopup"; import { Markdown } from "./Markdown"; -import { prettyDate, prettyDateTime } from "./prettyDate"; +import { fullDateTime, prettyDate } from "./prettyDate"; describe("prettyDate", () => { const o = { ...DEFAULT_DATE_FORMAT, overrideSystem: true, pattern: "dd.MM.yyyy", use24Hour: true }; - const now = new Date(2026, 9, 30, 15, 0).getTime(); + const now = new Date(2026, 9, 31, 9, 2).getTime(); - it("formats like DateFormatUtil.formatPrettyDate", () => { - expect(prettyDate(now - 20_000, now, o)).toBe("Moments ago"); + it("formats relative dates like GitHub", () => { + expect(prettyDate(now - 20_000, now, o)).toBe("just now"); + expect(prettyDate(now + 20_000, now, o)).toBe("just now"); expect(prettyDate(now - 60_000, now, o)).toBe("1 minute ago"); expect(prettyDate(now - 42 * 60_000, now, o)).toBe("42 minutes ago"); - expect(prettyDate(now - 3 * 3_600_000, now, o)).toBe("Today"); - expect(prettyDate(new Date(2026, 9, 29, 9, 5).getTime(), now, o)).toBe("Yesterday"); - expect(prettyDate(new Date(2026, 8, 2, 9, 5).getTime(), now, o)).toBe("02.09.2026"); + expect(prettyDate(now - 3_600_000, now, o)).toBe("1 hour ago"); + expect(prettyDate(new Date(2026, 9, 31, 0, 27).getTime(), now, o)).toBe("8 hours ago"); + expect(prettyDate(new Date(2026, 9, 30, 8, 0).getTime(), now, o)).toBe("yesterday"); + expect(prettyDate(new Date(2026, 9, 21, 9, 5).getTime(), now, o)).toBe("9 days ago"); + expect(prettyDate(new Date(2026, 8, 2, 9, 5).getTime(), now, o)).toBe("on 02.09.2026"); expect(prettyDate("not a date", now, o)).toBe(""); }); - it("adds the time with formatPrettyDateTime, and honours pretty formatting off", () => { - expect(prettyDateTime(new Date(2026, 9, 30, 9, 5).getTime(), now, o)).toBe("Today 09:05"); - expect(prettyDateTime(new Date(2026, 9, 29, 9, 5).getTime(), now, o)).toBe("Yesterday 09:05"); - expect(prettyDateTime(new Date(2026, 8, 2, 9, 5).getTime(), now, o)).toBe("02.09.2026 09:05"); - expect(prettyDate(now - 20_000, now, { ...o, pretty: false })).toBe("30.10.2026"); + it("shows the plain date and time with pretty formatting off", () => { + expect(prettyDate(now - 20_000, now, { ...o, pretty: false })).toBe("31.10.2026 09:01"); + expect(fullDateTime(new Date(2026, 9, 31, 0, 27).getTime(), o)).toBe("31.10.2026 00:27"); }); }); diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index ad96e2166..7f3471076 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -4976,6 +4976,10 @@ "Merge commit message:": "合并提交消息:", "Merge commit message": "合并提交消息", "1 minute ago": "1 分钟前", + "1 hour ago": "1 小时前", + "{n} hours ago": "{n} 小时前", + "{n} days ago": "{n} 天前", + "yesterday": "昨天", "Today": "今天", "Yesterday": "昨天", "Select pull request base and head to view changes": "选择拉取请求的基础分支和头分支以查看更改",