From d0bd4f2c48cbeea3d86a290c18bedb42a2d875bd Mon Sep 17 00:00:00 2001 From: Declow Date: Wed, 6 May 2026 22:50:36 +0200 Subject: [PATCH 1/3] feat(ui): Added a filter on PR's to show closed and merge using a modal as a picker --- dev/package-smoke.ts | 2 +- src/App.tsx | 74 ++++++++++++++++++++++++++++--- src/appCommands.ts | 10 +++++ src/domain.ts | 4 ++ src/keymap/all.ts | 5 +++ src/keymap/listNav.ts | 1 + src/pullRequestViews.ts | 33 ++++++++++---- src/services/CacheService.ts | 14 ++++-- src/services/GitHubService.ts | 50 ++++++++++++++++----- src/services/MockGitHubService.ts | 18 +++++--- src/ui/modals.tsx | 72 +++++++++++++++++++++++++++++- test/appCommands.test.ts | 9 +++- test/cacheService.test.ts | 2 +- test/domain.test.ts | 24 ++++++++-- test/scrolling.test.tsx | 12 +++++ 15 files changed, 288 insertions(+), 42 deletions(-) diff --git a/dev/package-smoke.ts b/dev/package-smoke.ts index bd054e4..3eea9b9 100644 --- a/dev/package-smoke.ts +++ b/dev/package-smoke.ts @@ -54,7 +54,7 @@ const assertCacheServiceOpens = async () => { const cached = await Effect.runPromise( Effect.gen(function* () { const cache = yield* CacheService - return yield* cache.readQueue("smoke", { _tag: "Queue", mode: "authored", repository: null }) + return yield* cache.readQueue("smoke", { _tag: "Queue", mode: "authored", repository: null, stateFilter: "open" }) }).pipe(Effect.provide(CacheService.layerSqliteFile(join(dir, "cache.sqlite")))), ) assert(cached === null, "New cache database should start empty") diff --git a/src/App.tsx b/src/App.tsx index c0319a0..c366c94 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -24,10 +24,11 @@ import { type PullRequestMergeAction, type PullRequestMergeMethod, type PullRequestReviewComment, + type PullRequestStateFilter, type RepositoryMergeMethods, type SubmitPullRequestReviewInput, } from "./domain.js" -import { allowedMergeMethodList, pullRequestMergeMethods } from "./domain.js" +import { allowedMergeMethodList, pullRequestMergeMethods, pullRequestStateFilters } from "./domain.js" import { formatShortDate, formatTimestamp } from "./date.js" import { errorMessage } from "./errors.js" import { getMergeKindDefinition, mergeInfoFromPullRequest, requiresMarkReady, visibleMergeKinds } from "./mergeActions.js" @@ -45,6 +46,8 @@ import { viewLabel, viewMode, viewRepository, + viewStateKey, + viewStateFilter, } from "./pullRequestViews.js" import { BrowserOpener } from "./services/BrowserOpener.js" import { CacheService, type PullRequestCacheKey } from "./services/CacheService.js" @@ -130,6 +133,7 @@ import { initialMergeModalState, initialModal, initialOpenRepositoryModalState, + initialPullRequestFilterModalState, initialPullRequestStateModalState, initialSubmitReviewModalState, initialThemeModalState, @@ -137,6 +141,7 @@ import { MergeModal, Modal, OpenRepositoryModal, + PullRequestFilterModal, PullRequestStateModal, submitReviewOptions, SubmitReviewModal, @@ -152,6 +157,7 @@ import { type ModalState, type ModalTag, type OpenRepositoryModalState, + type PullRequestFilterModalState, type PullRequestStateModalState, type SubmitReviewModalState, type ThemeModalState, @@ -265,6 +271,7 @@ const retryProgressAtom = Atom.make(initialRetryProgress).pipe(At const activeViewAtom = Atom.make(initialPullRequestView()).pipe(Atom.keepAlive) const queueLoadCacheAtom = Atom.make>>({}).pipe(Atom.keepAlive) const queueSelectionAtom = Atom.make>>({}).pipe(Atom.keepAlive) +const stateFiltersByViewAtom = Atom.make>>({}).pipe(Atom.keepAlive) const trimQueueLoadCache = (cache: Partial>) => { const repositoryKeys = Object.keys(cache).filter((key) => key.startsWith("repository:")) if (repositoryKeys.length <= MAX_REPOSITORY_CACHE_ENTRIES) return cache @@ -279,6 +286,7 @@ const pullRequestsAtom = githubRuntime const view = yield* Atom.get(activeViewAtom) const queueMode = viewMode(view) const repository = viewRepository(view) + const stateFilter = viewStateFilter(view) const cacheKey = viewCacheKey(view) const cacheUsername = view._tag === "Repository" ? null : yield* github.getAuthenticatedUser().pipe(Effect.catch(() => Effect.succeed(null))) const cacheViewer = cacheViewerFor(view, cacheUsername) @@ -294,6 +302,7 @@ const pullRequestsAtom = githubRuntime .listOpenPullRequestPage({ mode: queueMode, repository, + stateFilter, cursor: null, pageSize: Math.min(pullRequestPageSize, config.prFetchLimit), }) @@ -698,6 +707,7 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { const pullRequestResult = useAtomValue(pullRequestsAtom) const refreshPullRequestsAtom = useAtomRefresh(pullRequestsAtom) const [activeView, setActiveView] = useAtom(activeViewAtom) + const [stateFiltersByView, setStateFiltersByView] = useAtom(stateFiltersByViewAtom) const setQueueLoadCache = useAtomSet(queueLoadCacheAtom) const setQueueSelection = useAtomSet(queueSelectionAtom) const [selectedIndex, setSelectedIndex] = useAtom(selectedIndexAtom) @@ -731,6 +741,7 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { const labelModalActive = Modal.$is("Label")(activeModal) const closeModalActive = Modal.$is("Close")(activeModal) const pullRequestStateModalActive = Modal.$is("PullRequestState")(activeModal) + const pullRequestFilterModalActive = Modal.$is("PullRequestFilter")(activeModal) const mergeModalActive = Modal.$is("Merge")(activeModal) const commentModalActive = Modal.$is("Comment")(activeModal) const deleteCommentModalActive = Modal.$is("DeleteComment")(activeModal) @@ -743,6 +754,7 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { const labelModal: LabelModalState = labelModalActive ? activeModal : initialLabelModalState const closeModal: CloseModalState = closeModalActive ? activeModal : initialCloseModalState const pullRequestStateModal: PullRequestStateModalState = pullRequestStateModalActive ? activeModal : initialPullRequestStateModalState + const pullRequestFilterModal: PullRequestFilterModalState = pullRequestFilterModalActive ? activeModal : initialPullRequestFilterModalState const mergeModal: MergeModalState = mergeModalActive ? activeModal : initialMergeModalState const commentModal: CommentModalState = commentModalActive ? activeModal : initialCommentModalState const deleteCommentModal: DeleteCommentModalState = deleteCommentModalActive ? activeModal : initialDeleteCommentModalState @@ -767,6 +779,7 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { const setLabelModal = makeModalSetter("Label") const setCloseModal = makeModalSetter("Close") const setPullRequestStateModal = makeModalSetter("PullRequestState") + const setPullRequestFilterModal = makeModalSetter("PullRequestFilter") const setMergeModal = makeModalSetter("Merge") const setCommentModal = makeModalSetter("Comment") const setDeleteCommentModal = makeModalSetter("DeleteComment") @@ -935,7 +948,10 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { const pullRequestComments = useAtomValue(pullRequestCommentsAtom) const pullRequestCommentsLoaded = useAtomValue(pullRequestCommentsLoadedAtom) const selectedRepository = viewRepository(activeView) - const activeViews = activePullRequestViews(activeView) + const activeViews = activePullRequestViews(activeView).map((view) => { + const override = stateFiltersByView[viewStateKey(view)] + return override ? { ...view, stateFilter: override } : view + }) const currentQueueCacheKey = viewCacheKey(activeView) const loadedPullRequestCount = pullRequestLoad?.data.length ?? 0 const hasMorePullRequests = Boolean(pullRequestLoad?.hasNextPage && loadedPullRequestCount < config.prFetchLimit) @@ -1089,6 +1105,11 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { if (viewEquals(view, activeView)) return refreshGenerationRef.current += 1 setQueueSelection((current) => ({ ...current, [currentQueueCacheKey]: selectedIndex })) + setStateFiltersByView((current) => ({ + ...current, + [viewStateKey(activeView)]: activeView.stateFilter, + [viewStateKey(view)]: view.stateFilter, + })) setActiveView(view) setSelectedIndex(registry.get(queueSelectionAtom)[viewCacheKey(view)] ?? 0) setRecentlyCompletedPullRequests({}) @@ -1116,6 +1137,7 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { void loadPullRequestPage({ mode: viewMode(activeView), repository: selectedRepository, + stateFilter: viewStateFilter(activeView), cursor: pullRequestLoad.endCursor, pageSize: Math.min(pullRequestPageSize, remaining), }) @@ -1471,6 +1493,13 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { setStartupLoadComplete(true) }, [startupLoadComplete, pullRequestStatus]) + useEffect(() => { + if (pullRequestFilterModalActive) return + const selectedIndex = pullRequestStateFilters.indexOf(activeView.stateFilter) + if (selectedIndex < 0) return + setPullRequestFilterModal((current) => (current.selectedIndex === selectedIndex ? current : { selectedIndex })) + }, [activeView, pullRequestFilterModalActive]) + useEffect(() => { if (pullRequestStatus !== "ready" || !selectedPullRequest) return hydratePullRequestDetails(selectedPullRequest, true) @@ -2723,6 +2752,19 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { const openRepositoryPicker = () => { setOpenRepositoryModal({ query: selectedRepository ?? "", error: null }) } + const openPullRequestFilterModal = () => { + const selectedIndex = Math.max(0, pullRequestStateFilters.indexOf(activeView.stateFilter)) + setPullRequestFilterModal({ selectedIndex }) + } + const movePullRequestFilterSelection = (delta: -1 | 1) => { + setPullRequestFilterModal((current) => ({ ...current, selectedIndex: wrapIndex(current.selectedIndex + delta, pullRequestStateFilters.length) })) + } + const confirmPullRequestFilterSelection = () => { + const selected = pullRequestStateFilters[Math.max(0, Math.min(pullRequestFilterModal.selectedIndex, pullRequestStateFilters.length - 1))] ?? "open" + closeActiveModal() + setStateFiltersByView((current) => ({ ...current, [viewStateKey(activeView)]: selected })) + switchViewTo({ ...activeView, stateFilter: selected }) + } const openRepositoryFromInput = () => { const repository = parseRepositoryInput(openRepositoryModal.query) if (!repository) { @@ -2730,7 +2772,7 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { return } closeActiveModal() - switchViewTo({ _tag: "Repository", repository }) + switchViewTo({ _tag: "Repository", repository, stateFilter: activeView.stateFilter }) flashNotice(`Opened ${repository}`) } const insertPastedText = (text: string) => { @@ -2837,6 +2879,7 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { setFilterMode(false) }, openThemeModal, + openPullRequestFilterModal, openRepositoryPicker, loadMorePullRequests, switchViewTo, @@ -2910,7 +2953,7 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { title: `Open ${repository}`, scope: "View", subtitle: "Switch to this repository", - run: () => switchViewTo({ _tag: "Repository", repository }), + run: () => switchViewTo({ _tag: "Repository", repository, stateFilter: activeView.stateFilter }), }), ] })() @@ -3037,6 +3080,7 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { const appCtx: AppCtx = { closeModalActive, pullRequestStateModalActive, + pullRequestFilterModalActive, mergeModalActive, commentThreadModalActive, changedFilesModalActive, @@ -3069,6 +3113,11 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { confirmStateChange: confirmPullRequestStateChange, moveSelection: movePullRequestStateSelection, }, + pullRequestFilterModal: { + closeModal: closeActiveModal, + confirmSelection: confirmPullRequestFilterSelection, + moveSelection: movePullRequestFilterSelection, + }, mergeModal: { availableActionCount: visibleMergeKinds(mergeModal.info, mergeModal.allowedMethods, mergeModal.selectedMethod).length, multipleMethodsAllowed: mergeModal.allowedMethods ? allowedMergeMethodList(mergeModal.allowedMethods).length > 1 : false, @@ -3426,6 +3475,11 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { const pullRequestStateModalHeight = pullRequestStateLayout.height const pullRequestStateModalLeft = pullRequestStateLayout.left const pullRequestStateModalTop = pullRequestStateLayout.top + const pullRequestFilterLayout = sizedModal(46, 68, 12, 10) + const pullRequestFilterModalWidth = pullRequestFilterLayout.width + const pullRequestFilterModalHeight = pullRequestFilterLayout.height + const pullRequestFilterModalLeft = pullRequestFilterLayout.left + const pullRequestFilterModalTop = pullRequestFilterLayout.top const commentLayout = sizedModal(46, 76, 8, 16) const commentModalWidth = commentLayout.width const commentModalHeight = commentLayout.height @@ -3721,7 +3775,8 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { closeModal.running || pullRequestStateModal.running || mergeModal.running || - submitReviewModal.running + submitReviewModal.running || + pullRequestFilterModalActive } loadingIndicator={loadingIndicator} retryProgress={retryProgress} @@ -3759,6 +3814,15 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { loadingIndicator={loadingIndicator} /> ) : null} + {pullRequestFilterModalActive ? ( + + ) : null} {commentModalActive ? ( void readonly clearFilter: () => void readonly openThemeModal: () => void + readonly openPullRequestFilterModal: () => void readonly openRepositoryPicker: () => void readonly loadMorePullRequests: () => void readonly switchViewTo: (view: PullRequestView) => void @@ -167,6 +168,15 @@ export const buildAppCommands = ({ keywords: ["colors", "appearance"], run: actions.openThemeModal, }), + defineCommand({ + id: "pull.filter-state", + title: "Filter pull requests by state", + scope: "View", + subtitle: "Show open, closed, or merged pull requests", + shortcut: "f", + keywords: ["state", "open", "closed", "merged"], + run: actions.openPullRequestFilterModal, + }), defineCommand({ id: "repository.open", title: "Open repository...", diff --git a/src/domain.ts b/src/domain.ts index 9a8c0ba..b8b07b6 100644 --- a/src/domain.ts +++ b/src/domain.ts @@ -5,6 +5,9 @@ export type LoadStatus = "loading" | "ready" | "error" export const pullRequestStates = ["open", "closed", "merged"] as const export type PullRequestState = (typeof pullRequestStates)[number] +export const pullRequestStateFilters = ["open", "closed", "merged"] as const +export type PullRequestStateFilter = (typeof pullRequestStateFilters)[number] + export const pullRequestQueueModes = ["authored", "review", "assigned", "mentioned"] as const export type PullRequestUserQueueMode = (typeof pullRequestQueueModes)[number] export type PullRequestQueueMode = "repository" | PullRequestUserQueueMode @@ -164,6 +167,7 @@ export interface PullRequestPage { export interface ListPullRequestPageInput { readonly mode: PullRequestQueueMode readonly repository: string | null + readonly stateFilter: PullRequestStateFilter readonly cursor: string | null readonly pageSize: number } diff --git a/src/keymap/all.ts b/src/keymap/all.ts index 74a8635..35972ac 100644 --- a/src/keymap/all.ts +++ b/src/keymap/all.ts @@ -13,6 +13,7 @@ import { labelModalKeymap, type LabelModalCtx } from "./labelModal.ts" import { listNavKeymap, type ListNavCtx } from "./listNav.ts" import { mergeModalKeymap, type MergeModalCtx } from "./mergeModal.ts" import { openRepositoryModalKeymap, type OpenRepositoryModalCtx } from "./openRepositoryModal.ts" +import { pullRequestFilterModalKeymap, type PullRequestFilterModalCtx } from "./pullRequestFilterModal.ts" import { pullRequestStateModalKeymap, type PullRequestStateModalCtx } from "./pullRequestStateModal.ts" import { submitReviewModalKeymap, type SubmitReviewModalCtx } from "./submitReviewModal.ts" import { themeModalKeymap, type ThemeModalCtx } from "./themeModal.ts" @@ -21,6 +22,7 @@ export interface AppCtx { // Active flags readonly closeModalActive: boolean readonly pullRequestStateModalActive: boolean + readonly pullRequestFilterModalActive: boolean readonly mergeModalActive: boolean readonly commentThreadModalActive: boolean readonly changedFilesModalActive: boolean @@ -43,6 +45,7 @@ export interface AppCtx { // Per-layer narrow contexts readonly closeModal: CloseModalCtx readonly pullRequestStateModal: PullRequestStateModalCtx + readonly pullRequestFilterModal: PullRequestFilterModalCtx readonly mergeModal: MergeModalCtx readonly commentThreadModal: CommentThreadModalCtx readonly changedFilesModal: ChangedFilesModalCtx @@ -69,6 +72,7 @@ const App = context() const modalActive = (a: AppCtx): boolean => a.closeModalActive || a.pullRequestStateModalActive || + a.pullRequestFilterModalActive || a.mergeModalActive || a.commentThreadModalActive || a.changedFilesModalActive || @@ -105,6 +109,7 @@ export const appKeymap = App( // Modal layers closeModalKeymap.scope((a) => a.closeModalActive && a.closeModal), pullRequestStateModalKeymap.scope((a) => a.pullRequestStateModalActive && a.pullRequestStateModal), + pullRequestFilterModalKeymap.scope((a) => a.pullRequestFilterModalActive && a.pullRequestFilterModal), mergeModalKeymap.scope((a) => a.mergeModalActive && a.mergeModal), commentThreadModalKeymap.scope((a) => a.commentThreadModalActive && a.commentThreadModal), changedFilesModalKeymap.scope((a) => a.changedFilesModalActive && a.changedFilesModal), diff --git a/src/keymap/listNav.ts b/src/keymap/listNav.ts index 9307fb0..fa86647 100644 --- a/src/keymap/listNav.ts +++ b/src/keymap/listNav.ts @@ -35,6 +35,7 @@ export const listNavKeymap = List( { id: "list.merge", title: "Merge", keys: ["m", "shift+m"], run: (s) => s.runCommandById("pull.merge") }, { id: "list.close-pr", title: "Close PR", keys: ["x"], run: (s) => s.runCommandById("pull.close") }, { id: "list.open-browser", title: "Open in browser", keys: ["o"], run: (s) => s.runCommandById("pull.open-browser") }, + { id: "list.state-filter", title: "Filter PR state", keys: ["f"], run: (s) => s.runCommandById("pull.filter-state") }, { id: "list.toggle-draft", title: "Toggle draft", keys: ["s", "shift+s"], run: (s) => s.runCommandById("pull.toggle-draft") }, { id: "list.copy", title: "Copy metadata", keys: ["y"], run: (s) => s.runCommandById("pull.copy-metadata") }, { id: "list.detail.open", title: "Open details", keys: ["return"], run: (s) => s.runCommandById("detail.open") }, diff --git a/src/pullRequestViews.ts b/src/pullRequestViews.ts index 92d684a..c571b1f 100644 --- a/src/pullRequestViews.ts +++ b/src/pullRequestViews.ts @@ -1,22 +1,30 @@ -import { pullRequestQueueLabels, pullRequestQueueModes, type PullRequestQueueMode, type PullRequestUserQueueMode } from "./domain.js" +import { pullRequestQueueLabels, pullRequestQueueModes, type PullRequestQueueMode, type PullRequestStateFilter, type PullRequestUserQueueMode } from "./domain.js" export type PullRequestView = - | { readonly _tag: "Repository"; readonly repository: string } - | { readonly _tag: "Queue"; readonly mode: PullRequestUserQueueMode; readonly repository: string | null } + | { readonly _tag: "Repository"; readonly repository: string; readonly stateFilter: PullRequestStateFilter } + | { readonly _tag: "Queue"; readonly mode: PullRequestUserQueueMode; readonly repository: string | null; readonly stateFilter: PullRequestStateFilter } -export const initialPullRequestView = (): PullRequestView => ({ _tag: "Queue", mode: "authored", repository: null }) +export const initialPullRequestView = (): PullRequestView => ({ _tag: "Queue", mode: "authored", repository: null, stateFilter: "open" }) export const viewMode = (view: PullRequestView): PullRequestQueueMode => (view._tag === "Repository" ? "repository" : view.mode) export const viewRepository = (view: PullRequestView) => view.repository -export const viewCacheKey = (view: PullRequestView) => (view._tag === "Repository" ? `repository:${view.repository}` : view.mode) +export const viewStateFilter = (view: PullRequestView) => view.stateFilter -export const viewEquals = (left: PullRequestView, right: PullRequestView) => left._tag === right._tag && viewMode(left) === viewMode(right) && left.repository === right.repository +export const viewStateKey = (view: PullRequestView) => (view._tag === "Repository" ? `repository:${view.repository}` : `queue:${view.mode}`) + +export const viewCacheKey = (view: PullRequestView) => (view._tag === "Repository" ? `repository:${view.repository}:${view.stateFilter}` : `${view.mode}:${view.stateFilter}`) + +export const viewEquals = (left: PullRequestView, right: PullRequestView) => + left._tag === right._tag && viewMode(left) === viewMode(right) && left.repository === right.repository && left.stateFilter === right.stateFilter export const activePullRequestViews = (view: PullRequestView): readonly PullRequestView[] => { const repository = viewRepository(view) - return [...(repository ? [{ _tag: "Repository" as const, repository }] : []), ...pullRequestQueueModes.map((mode) => ({ _tag: "Queue" as const, mode, repository }))] + return [ + ...(repository ? [{ _tag: "Repository" as const, repository, stateFilter: "open" as const }] : []), + ...pullRequestQueueModes.map((mode) => ({ _tag: "Queue" as const, mode, repository: null, stateFilter: "open" as const })), + ] } export const nextView = (view: PullRequestView, views: readonly PullRequestView[], delta: 1 | -1) => { @@ -27,7 +35,16 @@ export const nextView = (view: PullRequestView, views: readonly PullRequestView[ return views[(index + delta + views.length) % views.length]! } -export const viewLabel = (view: PullRequestView) => (view._tag === "Repository" ? view.repository : pullRequestQueueLabels[view.mode]) +const stateFilterLabel: Record = { + open: "open", + closed: "closed", + merged: "merged", +} + +export const viewLabel = (view: PullRequestView) => { + const base = view._tag === "Repository" ? view.repository : pullRequestQueueLabels[view.mode] + return view.stateFilter === "open" ? base : `${base} (${stateFilterLabel[view.stateFilter]})` +} export const parseRepositoryInput = (input: string) => { const trimmed = input.trim() diff --git a/src/services/CacheService.ts b/src/services/CacheService.ts index 38cb11c..93de6fc 100644 --- a/src/services/CacheService.ts +++ b/src/services/CacheService.ts @@ -62,8 +62,13 @@ const CachedPullRequestItemSchema = Schema.Struct({ }) const CachedPullRequestViewSchema = Schema.Union([ - Schema.Struct({ _tag: Schema.tag("Queue"), mode: Schema.Literals(pullRequestQueueModes), repository: Schema.NullOr(Schema.String) }), - Schema.Struct({ _tag: Schema.tag("Repository"), repository: Schema.String }), + Schema.Struct({ + _tag: Schema.tag("Queue"), + mode: Schema.Literals(pullRequestQueueModes), + repository: Schema.NullOr(Schema.String), + stateFilter: Schema.optionalKey(PullRequestStateSchema), + }), + Schema.Struct({ _tag: Schema.tag("Repository"), repository: Schema.String, stateFilter: Schema.optionalKey(PullRequestStateSchema) }), ]) type CachedPullRequestItem = Schema.Schema.Type @@ -166,7 +171,10 @@ const decodePullRequestViewJson = (json: string): Effect.Effect => diff --git a/src/services/GitHubService.ts b/src/services/GitHubService.ts index 9525b1f..7a14d82 100644 --- a/src/services/GitHubService.ts +++ b/src/services/GitHubService.ts @@ -13,6 +13,7 @@ import { type PullRequestMergeInfo, type PullRequestPage, type PullRequestQueueMode, + type PullRequestStateFilter, type PullRequestReviewComment, type RepositoryMergeMethods, type ReviewStatus, @@ -294,9 +295,9 @@ query PullRequests($searchQuery: String!, $first: Int!, $after: String) { ` const repositoryPullRequestsQuery = ` -query RepositoryPullRequests($owner: String!, $name: String!, $first: Int!, $after: String) { +query RepositoryPullRequests($owner: String!, $name: String!, $first: Int!, $after: String, $state: PullRequestState!) { repository(owner: $owner, name: $name) { - pullRequests(states: OPEN, first: $first, after: $after, orderBy: { field: UPDATED_AT, direction: DESC }) { + pullRequests(states: [$state], first: $first, after: $after, orderBy: { field: UPDATED_AT, direction: DESC }) { nodes {${SUMMARY_FIELDS_FRAGMENT} } pageInfo { hasNextPage endCursor } @@ -451,9 +452,15 @@ const parsePullRequest = (item: RawPullRequestNode): PullRequestItem => { } } -const searchQuery = (mode: PullRequestQueueMode, repository: string | null) => { +const searchQuery = (mode: PullRequestQueueMode, repository: string | null, stateFilter: PullRequestStateFilter) => { const sort = mode === "repository" ? "sort:updated-desc" : "sort:created-desc" - return `${pullRequestQueueSearchQualifier(mode, repository)} is:pr is:open ${sort}` + return `${pullRequestQueueSearchQualifier(mode, repository)} is:pr is:${stateFilter} ${sort}` +} + +const pullRequestStateArg = (stateFilter: PullRequestStateFilter): string => { + if (stateFilter === "open") return "OPEN" + if (stateFilter === "closed") return "CLOSED" + return "MERGED" } const pullRequestPage = (connection: PullRequestConnection, parse: (node: Item) => PullRequestItem): PullRequestPage => ({ @@ -581,9 +588,17 @@ const REVIEW_EVENT_CLI_FLAG = { export class GitHubService extends Context.Service< GitHubService, { - readonly listOpenPullRequests: (mode: PullRequestQueueMode, repository: string | null) => Effect.Effect + readonly listOpenPullRequests: ( + mode: PullRequestQueueMode, + repository: string | null, + stateFilter: PullRequestStateFilter, + ) => Effect.Effect readonly listOpenPullRequestPage: (input: ListPullRequestPageInput) => Effect.Effect - readonly listOpenPullRequestDetails: (mode: PullRequestQueueMode, repository: string | null) => Effect.Effect + readonly listOpenPullRequestDetails: ( + mode: PullRequestQueueMode, + repository: string | null, + stateFilter: PullRequestStateFilter, + ) => Effect.Effect readonly getPullRequestDetails: (repository: string, number: number) => Effect.Effect readonly getAuthenticatedUser: () => Effect.Effect readonly getPullRequestDiff: (repository: string, number: number) => Effect.Effect @@ -626,7 +641,7 @@ export class GitHubService extends Context.Service< "-f", `query=${query}`, "-F", - `searchQuery=${searchQuery(input.mode, input.repository)}`, + `searchQuery=${searchQuery(input.mode, input.repository, input.stateFilter)}`, "-F", `first=${input.pageSize}`, ...(input.cursor ? ["-F", `after=${input.cursor}`] : []), @@ -655,6 +670,8 @@ export class GitHubService extends Context.Service< "-F", `name=${repo.name}`, "-F", + `state=${pullRequestStateArg(input.stateFilter)}`, + "-F", `first=${input.pageSize}`, ...(input.cursor ? ["-F", `after=${input.cursor}`] : []), ]) @@ -675,13 +692,14 @@ export class GitHubService extends Context.Service< const paginatePages = Effect.fn("GitHubService.paginatePages")(function* ( mode: PullRequestQueueMode, repository: string | null, + stateFilter: PullRequestStateFilter, loadPage: (input: ListPullRequestPageInput) => Effect.Effect, ) { const pullRequests: PullRequestItem[] = [] let cursor: string | null = null while (pullRequests.length < config.prFetchLimit) { - const page: PullRequestPage = yield* loadPage({ mode, repository, cursor, pageSize: Math.min(100, config.prFetchLimit - pullRequests.length) }) + const page: PullRequestPage = yield* loadPage({ mode, repository, stateFilter, cursor, pageSize: Math.min(100, config.prFetchLimit - pullRequests.length) }) pullRequests.push(...page.items) if (!page.hasNextPage || !page.endCursor) break cursor = page.endCursor @@ -690,11 +708,19 @@ export class GitHubService extends Context.Service< return pullRequests }) - const listOpenPullRequests = Effect.fn("GitHubService.listOpenPullRequests")(function* (mode: PullRequestQueueMode, repository: string | null) { - return yield* paginatePages(mode, repository, listOpenPullRequestPage) + const listOpenPullRequests = Effect.fn("GitHubService.listOpenPullRequests")(function* ( + mode: PullRequestQueueMode, + repository: string | null, + stateFilter: PullRequestStateFilter, + ) { + return yield* paginatePages(mode, repository, stateFilter, listOpenPullRequestPage) }) - const listOpenPullRequestDetails = Effect.fn("GitHubService.listOpenPullRequestDetails")(function* (mode: PullRequestQueueMode, repository: string | null) { - return yield* paginatePages(mode, repository, listOpenPullRequestDetailsPage) + const listOpenPullRequestDetails = Effect.fn("GitHubService.listOpenPullRequestDetails")(function* ( + mode: PullRequestQueueMode, + repository: string | null, + stateFilter: PullRequestStateFilter, + ) { + return yield* paginatePages(mode, repository, stateFilter, listOpenPullRequestDetailsPage) }) const getPullRequestDetails = Effect.fn("GitHubService.getPullRequestDetails")(function* (repository: string, number: number) { diff --git a/src/services/MockGitHubService.ts b/src/services/MockGitHubService.ts index 92eccec..044cc5e 100644 --- a/src/services/MockGitHubService.ts +++ b/src/services/MockGitHubService.ts @@ -10,6 +10,7 @@ import type { PullRequestPage, PullRequestQueueMode, PullRequestReviewComment, + PullRequestStateFilter, ReviewStatus, } from "../domain.js" import { mergeInfoFromPullRequest } from "../mergeActions.js" @@ -88,9 +89,12 @@ export const buildMockPullRequests = (options: MockOptions): readonly PullReques return Array.from({ length: resolved.prCount }, (_, index) => buildPullRequest(index, resolved)) } -const filterByView = (mode: PullRequestQueueMode, repository: string | null, source: readonly PullRequestItem[]) => { - if (mode === "repository") return repository ? source.filter((item) => item.repository === repository) : [] - return source +const filterByState = (stateFilter: PullRequestStateFilter, source: readonly PullRequestItem[]) => source.filter((item) => item.state === stateFilter) + +const filterByView = (mode: PullRequestQueueMode, repository: string | null, stateFilter: PullRequestStateFilter, source: readonly PullRequestItem[]) => { + const filtered = filterByState(stateFilter, source) + if (mode === "repository") return repository ? filtered.filter((item) => item.repository === repository) : [] + return filtered } const pageItems = (source: readonly PullRequestItem[], cursor: string | null, pageSize: number): PullRequestPage => { @@ -191,9 +195,11 @@ export const MockGitHubService = { return Layer.succeed( GitHubService, GitHubService.of({ - listOpenPullRequests: (mode: PullRequestQueueMode, repository: string | null) => Effect.succeed(filterByView(mode, repository, summaryItems)), - listOpenPullRequestPage: (input) => Effect.succeed(pageItems(filterByView(input.mode, input.repository, summaryItems), input.cursor, input.pageSize)), - listOpenPullRequestDetails: (mode: PullRequestQueueMode, repository: string | null) => Effect.succeed(filterByView(mode, repository, items)), + listOpenPullRequests: (mode: PullRequestQueueMode, repository: string | null, stateFilter: PullRequestStateFilter) => + Effect.succeed(filterByView(mode, repository, stateFilter, summaryItems)), + listOpenPullRequestPage: (input) => Effect.succeed(pageItems(filterByView(input.mode, input.repository, input.stateFilter, summaryItems), input.cursor, input.pageSize)), + listOpenPullRequestDetails: (mode: PullRequestQueueMode, repository: string | null, stateFilter: PullRequestStateFilter) => + Effect.succeed(filterByView(mode, repository, stateFilter, items)), getPullRequestDetails: (repository, number) => Effect.succeed(findPullRequest(repository, number)), getAuthenticatedUser: () => Effect.succeed(username), getPullRequestDiff: (_repo, _number) => Effect.succeed(mockDiff), diff --git a/src/ui/modals.tsx b/src/ui/modals.tsx index 53a88db..9cfb317 100644 --- a/src/ui/modals.tsx +++ b/src/ui/modals.tsx @@ -7,9 +7,10 @@ import type { PullRequestMergeMethod, PullRequestReviewComment, PullRequestReviewEvent, + PullRequestStateFilter, RepositoryMergeMethods, } from "../domain.js" -import { allowedMergeMethodList } from "../domain.js" +import { allowedMergeMethodList, pullRequestStateFilters } from "../domain.js" import { getMergeKindDefinition, mergeKindRowTitle, visibleMergeKinds } from "../mergeActions.js" import { clampCursor, commentEditorLines, cursorLineIndexForLines } from "./commentEditor.js" import { colors, filterThemeDefinitions, oppositeThemeTone, themeDefinitions, type ThemeId, type ThemeTone } from "./colors.js" @@ -77,6 +78,10 @@ export interface PullRequestStateModalState { readonly error: string | null } +export interface PullRequestFilterModalState { + readonly selectedIndex: number +} + export type CommentModalTarget = | { readonly kind: "diff" } | { readonly kind: "issue" } @@ -367,6 +372,10 @@ export const initialPullRequestStateModalState: PullRequestStateModalState = { error: null, } +export const initialPullRequestFilterModalState: PullRequestFilterModalState = { + selectedIndex: 0, +} + export const initialCommentModalState: CommentModalState = { body: "", cursor: 0, @@ -429,6 +438,7 @@ export type Modal = Data.TaggedEnum<{ Label: LabelModalState Close: CloseModalState PullRequestState: PullRequestStateModalState + PullRequestFilter: PullRequestFilterModalState Merge: MergeModalState Comment: CommentModalState DeleteComment: DeleteCommentModalState @@ -450,6 +460,7 @@ export const modalInitialStates = { Label: initialLabelModalState, Close: initialCloseModalState, PullRequestState: initialPullRequestStateModalState, + PullRequestFilter: initialPullRequestFilterModalState, Merge: initialMergeModalState, Comment: initialCommentModalState, DeleteComment: initialDeleteCommentModalState, @@ -968,6 +979,65 @@ export const PullRequestStateModal = ({ ) } +const pullRequestStateFilterLabel: Record = { + open: "Open", + closed: "Closed", + merged: "Merged", +} + +export const PullRequestFilterModal = ({ + state, + modalWidth, + modalHeight, + offsetLeft, + offsetTop, +}: { + state: PullRequestFilterModalState + modalWidth: number + modalHeight: number + offsetLeft: number + offsetTop: number +}) => { + const { contentWidth, bodyHeight } = standardModalDims(modalWidth, modalHeight) + const selectedIndex = Math.max(0, Math.min(state.selectedIndex, pullRequestStateFilters.length - 1)) + const bottomRows = Math.max(0, bodyHeight - pullRequestStateFilters.length) + + return ( + } + bodyPadding={1} + footer={ + + } + > + {pullRequestStateFilters.map((filter, index) => { + const isSelected = index === selectedIndex + const marker = isSelected ? "›" : " " + const labelWidth = Math.max(1, contentWidth - marker.length - 1) + return ( + + {marker} + {fitCell(pullRequestStateFilterLabel[filter], labelWidth)} + + ) + })} + + + ) +} + export const CommentModal = ({ state, anchorLabel, diff --git a/test/appCommands.test.ts b/test/appCommands.test.ts index cbd5afc..802b850 100644 --- a/test/appCommands.test.ts +++ b/test/appCommands.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test" import { buildAppCommands } from "../src/appCommands.js" import type { PullRequestItem } from "../src/domain.js" -const activeView = { _tag: "Queue", mode: "review", repository: null } as const +const activeView = { _tag: "Queue", mode: "review", repository: null, stateFilter: "open" } as const const selectedPullRequest: PullRequestItem = { repository: "owner/repo", author: "kit", @@ -61,6 +61,7 @@ const buildCommands = (overrides: Partial[0] openFilter: noop, clearFilter: noop, openThemeModal: noop, + openPullRequestFilterModal: noop, openRepositoryPicker: noop, loadMorePullRequests: noop, switchViewTo: noop, @@ -110,6 +111,12 @@ describe("review UX commands", () => { expect(command.disabledReason).toBeFalsy() }) + test("state filter command is available in the view scope", () => { + const command = commandById("pull.filter-state") + expect(command.shortcut).toBe("f") + expect(command.disabledReason).toBeFalsy() + }) + test("changed-files navigator is disabled when no files are loaded", () => { expect(commandById("diff.changed-files", { readyDiffFileCount: 0 }).disabledReason).toBe("No changed files loaded.") }) diff --git a/test/cacheService.test.ts b/test/cacheService.test.ts index b1df32f..7d13c7b 100644 --- a/test/cacheService.test.ts +++ b/test/cacheService.test.ts @@ -21,7 +21,7 @@ const tempCachePath = async () => { return join(dir, "cache.sqlite") } -const view: PullRequestView = { _tag: "Queue", mode: "authored", repository: null } +const view: PullRequestView = { _tag: "Queue", mode: "authored", repository: null, stateFilter: "open" } const pullRequest = (number: number, overrides: Partial = {}): PullRequestItem => ({ repository: "owner/repo", diff --git a/test/domain.test.ts b/test/domain.test.ts index 0e0743e..c6e3c32 100644 --- a/test/domain.test.ts +++ b/test/domain.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import { pullRequestQueueSearchQualifier } from "../src/domain.js" -import { viewCacheKey } from "../src/pullRequestViews.js" +import { activePullRequestViews, viewCacheKey, viewLabel } from "../src/pullRequestViews.js" describe("pullRequestQueueSearchQualifier", () => { test("repository mode with repository → repo: qualifier", () => { @@ -30,11 +30,27 @@ describe("pullRequestQueueSearchQualifier", () => { describe("viewCacheKey", () => { test("repository view key includes repo path", () => { - expect(viewCacheKey({ _tag: "Repository", repository: "owner/name" })).toBe("repository:owner/name") + expect(viewCacheKey({ _tag: "Repository", repository: "owner/name", stateFilter: "open" })).toBe("repository:owner/name:open") }) test("queue view key is the mode literal", () => { - expect(viewCacheKey({ _tag: "Queue", mode: "authored", repository: null })).toBe("authored") - expect(viewCacheKey({ _tag: "Queue", mode: "review", repository: "owner/name" })).toBe("review") + expect(viewCacheKey({ _tag: "Queue", mode: "authored", repository: null, stateFilter: "open" })).toBe("authored:open") + expect(viewCacheKey({ _tag: "Queue", mode: "review", repository: "owner/name", stateFilter: "merged" })).toBe("review:merged") + }) +}) + +describe("activePullRequestViews", () => { + test("queue tabs stay unscoped when coming from a repository view", () => { + const views = activePullRequestViews({ _tag: "Repository", repository: "owner/name", stateFilter: "closed" }) + + expect(views[0]).toEqual({ _tag: "Repository", repository: "owner/name", stateFilter: "open" }) + expect(views.slice(1).every((view) => view._tag === "Queue" && view.repository === null && view.stateFilter === "open")).toBe(true) + }) +}) + +describe("viewLabel", () => { + test("adds state suffix for non-open filters", () => { + expect(viewLabel({ _tag: "Queue", mode: "authored", repository: null, stateFilter: "open" })).toBe("authored") + expect(viewLabel({ _tag: "Queue", mode: "authored", repository: null, stateFilter: "merged" })).toBe("authored (merged)") }) }) diff --git a/test/scrolling.test.tsx b/test/scrolling.test.tsx index 58da8d1..9d2cbe5 100644 --- a/test/scrolling.test.tsx +++ b/test/scrolling.test.tsx @@ -138,6 +138,18 @@ const numberFromIndex = (flatIndex: number) => { } describe("PR list scrolling", () => { + test("state filter modal opens and closes from list view", async () => { + const { mockInput, renderOnce, captureCharFrame, renderer } = await setupApp(100, 20) + + await press(mockInput, renderOnce, { kind: "key", name: "f" }, 2) + expect(captureCharFrame()).toContain("Pull Request State") + expect(captureCharFrame()).toContain("Filter the current tab by pull request state.") + + await press(mockInput, renderOnce, { kind: "key", name: "q" }, 2) + expect(captureCharFrame()).not.toContain("Filter the current tab by pull request state.") + renderer.destroy() + }) + test("initial selection points at first PR", async () => { const { captureCharFrame, renderer } = await setupApp(100, 20) expect(detailPaneNumber(captureCharFrame())).toBe(numberFromIndex(0)) From af16cf1ab3c1c2249a8aa1f7ac423ff2c7512a59 Mon Sep 17 00:00:00 2001 From: Declow Date: Wed, 6 May 2026 22:52:00 +0200 Subject: [PATCH 2/3] feat(ui): Added filter modal --- src/keymap/pullRequestFilterModal.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 src/keymap/pullRequestFilterModal.ts diff --git a/src/keymap/pullRequestFilterModal.ts b/src/keymap/pullRequestFilterModal.ts new file mode 100644 index 0000000..c2f660b --- /dev/null +++ b/src/keymap/pullRequestFilterModal.ts @@ -0,0 +1,16 @@ +import { context } from "@ghui/keymap" + +export interface PullRequestFilterModalCtx { + readonly closeModal: () => void + readonly confirmSelection: () => void + readonly moveSelection: (delta: -1 | 1) => void +} + +const PullRequestFilter = context() + +export const pullRequestFilterModalKeymap = PullRequestFilter( + { id: "pull-filter.cancel", title: "Cancel filter", keys: ["escape"], run: (s) => s.closeModal() }, + { id: "pull-filter.up", title: "Move up", keys: ["up", "k"], run: (s) => s.moveSelection(-1) }, + { id: "pull-filter.down", title: "Move down", keys: ["down", "j"], run: (s) => s.moveSelection(1) }, + { id: "pull-filter.confirm", title: "Apply filter", keys: ["return"], run: (s) => s.confirmSelection() }, +) From 9d058ce8361434dffe537eb999389a93ea61490e Mon Sep 17 00:00:00 2001 From: Declow Date: Wed, 6 May 2026 22:57:14 +0200 Subject: [PATCH 3/3] feat(ui): Fixed loading screen using the filter state instead of saying always open --- src/App.tsx | 26 ++++++++++++++++++++++---- src/ui/PullRequestList.tsx | 10 +++++++--- test/pullRequestList.test.ts | 19 +++++++++++++++++++ 3 files changed, 48 insertions(+), 7 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index c366c94..a2150bb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -207,8 +207,11 @@ interface DetailPlaceholderInput { readonly loadingIndicator: string readonly visibleCount: number readonly filterText: string + readonly stateFilter: PullRequestStateFilter } +const pullRequestStatePhrase = (stateFilter: PullRequestStateFilter) => `${stateFilter} pull requests` + type DiffLineColorConfig = { readonly gutter: string readonly content: string @@ -665,11 +668,12 @@ const setDiffCommentLineColor = (diff: DiffRenderable, entry: AppliedDiffLineCol } } -const getDetailPlaceholderContent = ({ status, retryProgress, loadingIndicator, visibleCount, filterText }: DetailPlaceholderInput): DetailPlaceholderContent => { +const getDetailPlaceholderContent = ({ status, retryProgress, loadingIndicator, visibleCount, filterText, stateFilter }: DetailPlaceholderInput): DetailPlaceholderContent => { + const statePhrase = pullRequestStatePhrase(stateFilter) if (status === "loading") { return { title: `${loadingIndicator} Loading pull requests`, - hint: retryProgress._tag === "Retrying" ? `Retry ${retryProgress.attempt}/${retryProgress.max}` : "Fetching latest open PRs", + hint: retryProgress._tag === "Retrying" ? `Retry ${retryProgress.attempt}/${retryProgress.max}` : `Fetching latest ${statePhrase}`, } } @@ -689,7 +693,7 @@ const getDetailPlaceholderContent = ({ status, retryProgress, loadingIndicator, if (visibleCount === 0) { return { - title: "No open pull requests", + title: `No ${statePhrase}`, hint: "Press r to refresh", } } @@ -964,11 +968,23 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { error: pullRequestError, filterText: visibleFilterText, showFilterBar: filterMode || filterQuery.length > 0, + stateFilter: activeView.stateFilter, loadedCount: loadedPullRequestCount, hasMore: hasMorePullRequests, isLoadingMore: isLoadingMorePullRequests, }), - [visibleGroups, pullRequestStatus, pullRequestError, visibleFilterText, filterMode, filterQuery, loadedPullRequestCount, hasMorePullRequests, isLoadingMorePullRequests], + [ + visibleGroups, + pullRequestStatus, + pullRequestError, + visibleFilterText, + filterMode, + filterQuery, + activeView.stateFilter, + loadedPullRequestCount, + hasMorePullRequests, + isLoadingMorePullRequests, + ], ) const selectedPullRequestRowIndex = pullRequestListRowIndex(pullRequestListRows, selectedPullRequest?.url ?? null) const selectedDiffKey = useAtomValue(selectedDiffKeyAtom) @@ -1545,6 +1561,7 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { loadingIndicator, visibleCount: visiblePullRequests.length, filterText: visibleFilterText, + stateFilter: activeView.stateFilter, }) const isSelectedPullRequestDetailLoading = selectedPullRequest !== null && !selectedPullRequest.detailLoaded const halfPage = Math.max(1, Math.floor(wideBodyHeight / 2)) @@ -3428,6 +3445,7 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { filterText: visibleFilterText, showFilterBar: filterMode || filterQuery.length > 0, isFilterEditing: filterMode, + stateFilter: activeView.stateFilter, loadedCount: loadedPullRequestCount, hasMore: hasMorePullRequests, isLoadingMore: isLoadingMorePullRequests, diff --git a/src/ui/PullRequestList.tsx b/src/ui/PullRequestList.tsx index 0a2c235..0cce9dd 100644 --- a/src/ui/PullRequestList.tsx +++ b/src/ui/PullRequestList.tsx @@ -1,6 +1,6 @@ import { TextAttributes } from "@opentui/core" import { useState } from "react" -import type { LoadStatus, PullRequestItem } from "../domain.js" +import type { LoadStatus, PullRequestItem, PullRequestStateFilter } from "../domain.js" import { daysOpen } from "../date.js" import { colors, rowHoverBackground } from "./colors.js" import { fitCell, MatchedCell, PlainLine, SectionTitle, TextLine } from "./primitives.js" @@ -53,6 +53,7 @@ export const buildPullRequestListRows = ({ error, filterText, showFilterBar, + stateFilter, loadedCount, hasMore, isLoadingMore, @@ -63,6 +64,7 @@ export const buildPullRequestListRows = ({ readonly error: string | null readonly filterText: string readonly showFilterBar: boolean + readonly stateFilter: PullRequestStateFilter readonly loadedCount: number readonly hasMore: boolean readonly isLoadingMore: boolean @@ -74,7 +76,7 @@ export const buildPullRequestListRows = ({ if (status === "loading" && itemCount === 0) rows.push({ _tag: "message", text: "- Loading pull requests...", color: colors.muted }) if (status === "error") rows.push({ _tag: "message", text: `- ${error ?? "Could not load pull requests."}`, color: colors.error }) if (status === "ready" && itemCount === 0) - rows.push({ _tag: "message", text: filterText.length > 0 ? "- No matching pull requests." : "- No open pull requests.", color: colors.muted }) + rows.push({ _tag: "message", text: filterText.length > 0 ? "- No matching pull requests." : `- No ${stateFilter} pull requests.`, color: colors.muted }) for (const [repository, pullRequests] of groups) { rows.push({ _tag: "group", repository, pullRequests }) const numberWidth = groupNumberWidth(pullRequests) @@ -148,6 +150,7 @@ export const PullRequestList = ({ filterText, showFilterBar, isFilterEditing, + stateFilter, loadedCount, hasMore, isLoadingMore, @@ -162,13 +165,14 @@ export const PullRequestList = ({ filterText: string showFilterBar: boolean isFilterEditing: boolean + stateFilter: PullRequestStateFilter loadedCount: number hasMore: boolean isLoadingMore: boolean loadingIndicator: string onSelectPullRequest: (url: string) => void }) => { - const rows = buildPullRequestListRows({ groups, status, error, filterText, showFilterBar, loadedCount, hasMore, isLoadingMore, loadingIndicator }) + const rows = buildPullRequestListRows({ groups, status, error, filterText, showFilterBar, stateFilter, loadedCount, hasMore, isLoadingMore, loadingIndicator }) const [hoveredUrl, setHoveredUrl] = useState(null) return ( diff --git a/test/pullRequestList.test.ts b/test/pullRequestList.test.ts index f7676de..367e2af 100644 --- a/test/pullRequestList.test.ts +++ b/test/pullRequestList.test.ts @@ -35,6 +35,7 @@ describe("buildPullRequestListRows", () => { error: null, filterText: "", showFilterBar: false, + stateFilter: "open", loadedCount: 50, hasMore: true, isLoadingMore: false, @@ -50,6 +51,7 @@ describe("buildPullRequestListRows", () => { error: null, filterText: "", showFilterBar: false, + stateFilter: "open", loadedCount: 50, hasMore: true, isLoadingMore: true, @@ -58,4 +60,21 @@ describe("buildPullRequestListRows", () => { expect(rows.at(-1)).toEqual({ _tag: "load-more", text: "⠋ Loading more pull requests... (50 loaded)" }) }) + + test("shows state-aware empty text when no pull requests are visible", () => { + const rows = buildPullRequestListRows({ + groups: [], + status: "ready", + error: null, + filterText: "", + showFilterBar: false, + stateFilter: "merged", + loadedCount: 0, + hasMore: false, + isLoadingMore: false, + }) + + const messageRow = rows.find((row) => row._tag === "message") + expect(messageRow).toMatchObject({ _tag: "message", text: "- No merged pull requests." }) + }) })