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
115 changes: 103 additions & 12 deletions packages/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,18 @@ import { SectionHeader } from "./components/SectionHeader";
import { SettingsModal } from "./components/SettingsModal";
import { ShortcutHelp } from "./components/ShortcutHelp";
import { Skeleton } from "./components/Skeleton";
import { SortControl } from "./components/SortControl";
import {
NOTIFICATION_SORT_FIELDS,
PR_SORT_FIELDS,
REVIEW_SORT_FIELDS,
compareNotifications,
comparePrs,
compareReviews,
notificationSortAtom,
prSortAtom,
reviewSortAtom,
} from "./sort";
import {
type Section,
useAllAuthoredPrs,
Expand Down Expand Up @@ -608,20 +620,36 @@ function Dashboard({ source }: { source: DashboardSource }) {
const queryClient = useQueryClient();
const { instances, prs, recentPrs, reviews, notifications } = source;
const [dismissed, setDismissed] = useAtom(dismissedReviewsAtom);
const [prSort, setPrSort] = useAtom(prSortAtom);
const [reviewSort, setReviewSort] = useAtom(reviewSortAtom);
const [notificationSort, setNotificationSort] = useAtom(notificationSortAtom);

const sortedPrs = useMemo(
() => [...prs.data].sort((a, b) => comparePrs(a, b, prSort)),
[prs.data, prSort],
);

const filteredReviews = useMemo(
() =>
reviews.data.filter(
(r) => !isDismissed(dismissed, r.repo, r.number, r.updatedAt),
reviews.data
.filter((r) => !isDismissed(dismissed, r.repo, r.number, r.updatedAt))
.sort((a, b) => compareReviews(a, b, reviewSort)),
[reviews.data, dismissed, reviewSort],
);

const sortedNotifications = useMemo(
() =>
[...notifications.data].sort((a, b) =>
compareNotifications(a, b, notificationSort),
),
[reviews.data, dismissed],
[notifications.data, notificationSort],
);

const sections: Section[] = ["prs", "reviews", "notifications"];
const itemCounts = {
prs: prs.data.length + recentPrs.data.length,
prs: sortedPrs.length + recentPrs.data.length,
reviews: filteredReviews.length,
notifications: notifications.data.length,
notifications: sortedNotifications.length,
};

const nav = useKeyboardNav(sections, itemCounts);
Expand Down Expand Up @@ -699,16 +727,16 @@ function Dashboard({ source }: { source: DashboardSource }) {
};

const navRef = useRef(nav);
const prsRef = useRef(prs.data);
const prsRef = useRef(sortedPrs);
const recentPrsRef = useRef(recentPrs.data);
const reviewsRef = useRef(filteredReviews);
const notificationsRef = useRef(notifications.data);
const notificationsRef = useRef(sortedNotifications);
const instancesRef = useRef(instances);
navRef.current = nav;
prsRef.current = prs.data;
prsRef.current = sortedPrs;
recentPrsRef.current = recentPrs.data;
reviewsRef.current = filteredReviews;
notificationsRef.current = notifications.data;
notificationsRef.current = sortedNotifications;
instancesRef.current = instances;

const getFocused = () =>
Expand Down Expand Up @@ -920,6 +948,45 @@ function Dashboard({ source }: { source: DashboardSource }) {
})
.catch(() => {});
}
} else if (e.key === "s") {
if (activeSection === "prs") {
setPrSort((cur) => {
const i = PR_SORT_FIELDS.findIndex((f) => f.field === cur.field);
const next = PR_SORT_FIELDS[(i + 1) % PR_SORT_FIELDS.length];
Comment on lines +951 to +955
return { field: next.field, dir: "desc" };
});
} else if (activeSection === "reviews") {
setReviewSort((cur) => {
const i = REVIEW_SORT_FIELDS.findIndex(
(f) => f.field === cur.field,
);
const next =
REVIEW_SORT_FIELDS[(i + 1) % REVIEW_SORT_FIELDS.length];
return { field: next.field, dir: "desc" };
});
} else if (activeSection === "notifications") {
setNotificationSort((cur) => {
const i = NOTIFICATION_SORT_FIELDS.findIndex(
(f) => f.field === cur.field,
);
const next =
NOTIFICATION_SORT_FIELDS[
(i + 1) % NOTIFICATION_SORT_FIELDS.length
];
return { field: next.field, dir: "desc" };
});
}
e.preventDefault();
} else if (e.key === "S") {
const flip = (d: "asc" | "desc") => (d === "asc" ? "desc" : "asc");
if (activeSection === "prs") {
setPrSort((cur) => ({ ...cur, dir: flip(cur.dir) }));
} else if (activeSection === "reviews") {
setReviewSort((cur) => ({ ...cur, dir: flip(cur.dir) }));
} else if (activeSection === "notifications") {
setNotificationSort((cur) => ({ ...cur, dir: flip(cur.dir) }));
}
e.preventDefault();
}
};

Expand All @@ -939,21 +1006,28 @@ function Dashboard({ source }: { source: DashboardSource }) {
nav.setActiveSection("prs");
nav.setFocusIndex(0);
}}
right={
<SortControl
fields={PR_SORT_FIELDS}
value={prSort}
onChange={setPrSort}
/>
}
>
{prs.isLoading ? (
<Skeleton />
) : prs.error ? (
<ErrorMessage message={prs.error.message} />
) : (
<PrList
prs={prs.data}
prs={sortedPrs}
focusIndex={nav.focusIndex}
isFocusedSection={nav.activeSection === "prs"}
togglingDraftId={togglingDraftId ?? undefined}
recentPrs={recentPrs.data}
editingPrNumber={editingPrNumber ?? undefined}
onSaveTitle={async (prNumber, title) => {
const pr = prs.data.find((p) => p.number === prNumber);
const pr = sortedPrs.find((p) => p.number === prNumber);
if (!pr) return;
const instanceId = pr.instanceId ?? instances[0]?.id;
if (!instanceId) return;
Expand All @@ -980,6 +1054,13 @@ function Dashboard({ source }: { source: DashboardSource }) {
nav.setActiveSection("reviews");
nav.setFocusIndex(0);
}}
right={
<SortControl
fields={REVIEW_SORT_FIELDS}
value={reviewSort}
onChange={setReviewSort}
/>
}
>
{reviews.isLoading ? (
<Skeleton />
Expand All @@ -1004,14 +1085,21 @@ function Dashboard({ source }: { source: DashboardSource }) {
nav.setActiveSection("notifications");
nav.setFocusIndex(0);
}}
right={
<SortControl
fields={NOTIFICATION_SORT_FIELDS}
value={notificationSort}
onChange={setNotificationSort}
/>
}
>
{notifications.isLoading ? (
<Skeleton />
) : notifications.error ? (
<ErrorMessage message={notifications.error.message} />
) : (
<NotificationList
notifications={notifications.data}
notifications={sortedNotifications}
focusIndex={nav.focusIndex}
isFocusedSection={nav.activeSection === "notifications"}
/>
Expand Down Expand Up @@ -1202,6 +1290,7 @@ function Column({
isActive,
isFetching,
onActivate,
right,
children,
}: {
section: Section;
Expand All @@ -1210,6 +1299,7 @@ function Column({
isActive: boolean;
isFetching: boolean;
onActivate: () => void;
right?: React.ReactNode;
children: React.ReactNode;
}) {
return (
Expand All @@ -1222,6 +1312,7 @@ function Column({
isActive={isActive}
isFetching={isFetching}
onClick={onActivate}
right={right}
/>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-4 pt-2 pb-4 scroll-pt-2 scroll-pb-2">
Expand Down
2 changes: 1 addition & 1 deletion packages/web/src/components/PrCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ export function PrCard({
variant="tertiary"
className="text-[10px]"
>
opened
created
</Text>
<TimeAgo date={createdAt} className="text-[10px]" />
</span>
Expand Down
21 changes: 16 additions & 5 deletions packages/web/src/components/SectionHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { ReactNode } from "react";
import type { Section } from "../hooks";
import { Text } from "./Text";

Expand All @@ -8,12 +9,19 @@ interface Props {
isActive: boolean;
isFetching: boolean;
onClick: () => void;
right?: ReactNode;
}

export function SectionHeader({ label, count, isFetching, onClick }: Props) {
export function SectionHeader({
label,
count,
isFetching,
onClick,
right,
}: Props) {
return (
<div
className="flex cursor-pointer items-center justify-between"
className="flex cursor-pointer items-center justify-between gap-3"
onClick={onClick}
>
<div className="flex items-baseline gap-2">
Expand All @@ -29,9 +37,12 @@ export function SectionHeader({ label, count, isFetching, onClick }: Props) {
{count}
</Text>
</div>
{isFetching && (
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-muted-foreground/60" />
)}
<div className="flex items-center gap-2">
{right}
{isFetching && (
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-muted-foreground/60" />
)}
</div>
</div>
);
}
2 changes: 2 additions & 0 deletions packages/web/src/components/ShortcutHelp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ const sections: { title: string; shortcuts: [string, string][] }[] = [
["a", "Approve PR"],
["c", "Close PR"],
["e", "Dismiss review / notification"],
["s", "Cycle sort dimension"],
["S", "Toggle sort direction"],
],
},
{
Expand Down
50 changes: 50 additions & 0 deletions packages/web/src/components/SortControl.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { cn } from "@/lib/utils";
import type { SortFieldOption, SortState } from "../sort";

interface Props<F extends string> {
fields: readonly SortFieldOption<F>[];
value: SortState<F>;
onChange: (next: SortState<F>) => void;
}

export function SortControl<F extends string>({
fields,
value,
onChange,
}: Props<F>) {
return (
<div className="flex items-center gap-2">
{fields.map((opt) => {
const active = opt.field === value.field;
return (
<button
key={opt.field}
type="button"
className={cn(
"text-[10px] uppercase tracking-tight tabular-nums",
active
Comment on lines +20 to +25
? "text-foreground"
: "text-muted-foreground/70 hover:text-foreground",
)}
onClick={(e) => {
e.stopPropagation();
if (active) {
onChange({
field: opt.field,
dir: value.dir === "asc" ? "desc" : "asc",
});
} else {
onChange({ field: opt.field, dir: "desc" });
}
}}
>
{opt.label}
{active && (
<span className="ml-0.5">{value.dir === "asc" ? "↑" : "↓"}</span>
)}
</button>
);
})}
</div>
);
}
Loading
Loading