From d99f9df95cd2ba8770392903958612ef84e2aff6 Mon Sep 17 00:00:00 2001 From: phall1 Date: Sun, 10 May 2026 08:56:49 -0400 Subject: [PATCH] feat: stack tree modal Adds a new modal that visualizes pull requests grouped by repository as base/head branch forests, making stacked PRs easy to browse and jump to from a single view. - src/stack.ts builds a StackForest per repository from PR head/base refs - src/ui/modals.tsx renders StackTreeModal with PR rows, branch segments, and review-status coloring - new keymap (src/keymap/stackTreeModal.ts) and app command wiring - MockGitHubService and CacheService updates to support stack data - test/stack.test.ts covers forest construction --- .changeset/stack-tree-modal.md | 5 + src/App.tsx | 91 +++++++++++++++ src/appCommands.ts | 10 ++ src/domain.ts | 1 + src/keymap/all.ts | 7 +- src/keymap/listNav.ts | 1 + src/keymap/stackTreeModal.ts | 26 +++++ src/services/CacheService.ts | 3 + src/services/GitHubService.ts | 4 + src/services/MockGitHubService.ts | 7 +- src/stack.ts | 79 +++++++++++++ src/ui/DetailsPane.tsx | 15 ++- src/ui/PullRequestList.tsx | 41 ++++++- src/ui/modals.tsx | 185 ++++++++++++++++++++++++++++++ test/stack.test.ts | 109 ++++++++++++++++++ 15 files changed, 576 insertions(+), 8 deletions(-) create mode 100644 .changeset/stack-tree-modal.md create mode 100644 src/keymap/stackTreeModal.ts create mode 100644 src/stack.ts create mode 100644 test/stack.test.ts diff --git a/.changeset/stack-tree-modal.md b/.changeset/stack-tree-modal.md new file mode 100644 index 0000000..c71f391 --- /dev/null +++ b/.changeset/stack-tree-modal.md @@ -0,0 +1,5 @@ +--- +"@kitlangton/ghui": minor +--- + +Add a stack tree modal that visualizes pull requests grouped by repository as base/head branch forests, so stacked PRs can be browsed and jumped to from a single view. diff --git a/src/App.tsx b/src/App.tsx index c4fdbad..b1d4eea 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -132,6 +132,7 @@ import { initialModal, initialOpenRepositoryModalState, initialPullRequestStateModalState, + initialStackTreeModalState, initialSubmitReviewModalState, initialThemeModalState, LabelModal, @@ -139,9 +140,12 @@ import { Modal, OpenRepositoryModal, PullRequestStateModal, + StackTreeModal, + stackTreeSelectableUrls, submitReviewOptions, SubmitReviewModal, ThemeModal, + buildStackTreeRows, type ChangedFilesModalState, type CloseModalState, type CommandPaletteState, @@ -154,10 +158,12 @@ import { type ModalTag, type OpenRepositoryModalState, type PullRequestStateModalState, + type StackTreeModalState, type SubmitReviewModalState, type ThemeModalState, } from "./ui/modals.js" import { groupBy, pullRequestMetadataText } from "./ui/pullRequests.js" +import { buildStackForests } from "./stack.js" import { quotedReplyBody } from "./ui/comments.js" import { CommentsPane, commentsViewRowCount, orderCommentsForDisplay } from "./ui/CommentsPane.js" import { PullRequestDiffPane } from "./ui/PullRequestDiffPane.js" @@ -750,6 +756,7 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { const themeModalActive = Modal.$is("Theme")(activeModal) const commandPaletteActive = Modal.$is("CommandPalette")(activeModal) const openRepositoryModalActive = Modal.$is("OpenRepository")(activeModal) + const stackTreeModalActive = Modal.$is("StackTree")(activeModal) const labelModal: LabelModalState = labelModalActive ? activeModal : initialLabelModalState const closeModal: CloseModalState = closeModalActive ? activeModal : initialCloseModalState const pullRequestStateModal: PullRequestStateModalState = pullRequestStateModalActive ? activeModal : initialPullRequestStateModalState @@ -762,6 +769,7 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { const themeModal: ThemeModalState = themeModalActive ? activeModal : initialThemeModalState const commandPalette: CommandPaletteState = commandPaletteActive ? activeModal : initialCommandPaletteState const openRepositoryModal: OpenRepositoryModalState = openRepositoryModalActive ? activeModal : initialOpenRepositoryModalState + const stackTreeModal: StackTreeModalState = stackTreeModalActive ? activeModal : initialStackTreeModalState const makeModalSetter = >(tag: Tag) => (next: ModalState | ((prev: ModalState) => ModalState)) => @@ -785,6 +793,7 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { const setSubmitReviewModal = makeModalSetter("SubmitReview") const setThemeModal = makeModalSetter("Theme") const setCommandPalette = makeModalSetter("CommandPalette") + const setStackTreeModal = makeModalSetter("StackTree") const setOpenRepositoryModal = makeModalSetter("OpenRepository") const themeIdRef = useRef(themeId) const themeConfigRef = useRef(themeConfig) @@ -950,6 +959,28 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { const visibleFilterText = filterMode ? filterDraft : filterQuery const visibleGroups = useAtomValue(visibleGroupsAtom) const visiblePullRequests = useAtomValue(visiblePullRequestsAtom) + const stackTreeForests = useMemo(() => buildStackForests(visiblePullRequests), [visiblePullRequests]) + const stackTreeRows = useMemo(() => buildStackTreeRows(stackTreeForests), [stackTreeForests]) + const stackTreeSelectableUrlList = useMemo(() => stackTreeSelectableUrls(stackTreeRows), [stackTreeRows]) + const stackParentsByUrl = useMemo(() => { + const headByRepo = new Map>() + for (const pr of visiblePullRequests) { + let repoMap = headByRepo.get(pr.repository) + if (!repoMap) { + repoMap = new Map() + headByRepo.set(pr.repository, repoMap) + } + if (pr.headRefName) repoMap.set(pr.headRefName, pr) + } + const result = new Map() + for (const pr of visiblePullRequests) { + const repoMap = headByRepo.get(pr.repository) + if (!repoMap || !pr.baseRefName) continue + const parent = repoMap.get(pr.baseRefName) + if (parent && parent.url !== pr.url) result.set(pr.url, parent) + } + return result + }, [visiblePullRequests]) const selectedPullRequest = useAtomValue(selectedPullRequestAtom) const pullRequestComments = useAtomValue(pullRequestCommentsAtom) const pullRequestCommentsLoaded = useAtomValue(pullRequestCommentsLoadedAtom) @@ -1831,6 +1862,15 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { }) } + const openStackTree = () => { + const forests = buildStackForests(visiblePullRequests) + const rows = buildStackTreeRows(forests) + const urls = stackTreeSelectableUrls(rows) + const anchorUrl = selectedPullRequest?.url ?? null + const initialIndex = anchorUrl ? Math.max(0, urls.indexOf(anchorUrl)) : 0 + setStackTreeModal({ selectedIndex: initialIndex, anchorUrl }) + } + const selectChangedFile = () => { const selectedIndex = changedFileResults.length === 0 ? 0 : Math.max(0, Math.min(changedFilesModal.selectedIndex, changedFileResults.length - 1)) const entry = changedFileResults[selectedIndex] @@ -2959,6 +2999,7 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { }, openThemeModal, openRepositoryPicker, + openStackTree, loadMorePullRequests, switchViewTo, openDetails: () => { @@ -3074,6 +3115,28 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { const selectedIndex = wrapIndex(current.selectedIndex + delta, changedFileResults.length) return selectedIndex === current.selectedIndex ? current : { ...current, selectedIndex } }) + const moveStackTreeSelection = (delta: -1 | 1) => + setStackTreeModal((current) => { + if (stackTreeSelectableUrlList.length === 0) return current + const selectedIndex = wrapIndex(current.selectedIndex + delta, stackTreeSelectableUrlList.length) + return selectedIndex === current.selectedIndex ? current : { ...current, selectedIndex } + }) + const moveStackTreeSelectionPage = (delta: -1 | 1) => + setStackTreeModal((current) => { + if (stackTreeSelectableUrlList.length === 0) return current + const step = Math.max(1, halfPage) + const next = Math.max(0, Math.min(stackTreeSelectableUrlList.length - 1, current.selectedIndex + delta * step)) + return next === current.selectedIndex ? current : { ...current, selectedIndex: next } + }) + const selectStackTreePullRequest = () => { + if (stackTreeSelectableUrlList.length === 0) return + const safeIndex = Math.max(0, Math.min(stackTreeModal.selectedIndex, stackTreeSelectableUrlList.length - 1)) + const targetUrl = stackTreeSelectableUrlList[safeIndex] + if (!targetUrl) return + const targetIndex = visiblePullRequests.findIndex((pr) => pr.url === targetUrl) + if (targetIndex >= 0) setSelectedIndex(targetIndex) + closeActiveModal() + } const moveSubmitReviewActionSelection = (delta: -1 | 1) => setSubmitReviewModal((current) => { const selectedIndex = wrapIndex(current.selectedIndex + delta, submitReviewOptions.length) @@ -3168,6 +3231,7 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { commentModalActive, deleteCommentModalActive, commandPaletteActive, + stackTreeModalActive, filterMode, diffFullView, detailFullView, @@ -3286,6 +3350,13 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { }, moveSelection: moveCommandPaletteSelection, }, + stackTreeModal: { + hasResults: stackTreeSelectableUrlList.length > 0, + closeModal: closeActiveModal, + selectPullRequest: selectStackTreePullRequest, + moveSelection: moveStackTreeSelection, + moveSelectionPage: moveStackTreeSelectionPage, + }, filterModeCtx: { cancel: () => { setFilterDraft(filterQuery) @@ -3504,6 +3575,7 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { hasMore: hasMorePullRequests, isLoadingMore: isLoadingMorePullRequests, loadingIndicator, + stackParents: stackParentsByUrl, onSelectPullRequest: selectPullRequestByUrl, } as const const widePullRequestList = ( @@ -3590,6 +3662,12 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { const commandPaletteHeight = commandPaletteLayout.height const commandPaletteLeft = commandPaletteLayout.left const commandPaletteTop = commandPaletteLayout.top + const stackTreeLayout = sizedModal(60, 120, 6, 32) + const stackTreeModalWidth = stackTreeLayout.width + const stackTreeModalHeight = stackTreeLayout.height + const stackTreeModalLeft = stackTreeLayout.left + const stackTreeModalTop = stackTreeLayout.top + const stackTreeViewLabel = viewLabel(activeView) return ( @@ -3966,6 +4044,19 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => { onRunCommand={runCommandPaletteCommand} /> ) : null} + {stackTreeModalActive ? ( + + ) : null} ) } diff --git a/src/appCommands.ts b/src/appCommands.ts index abb6557..a4603fe 100644 --- a/src/appCommands.ts +++ b/src/appCommands.ts @@ -11,6 +11,7 @@ interface AppCommandActions { readonly clearFilter: () => void readonly openThemeModal: () => void readonly openRepositoryPicker: () => void + readonly openStackTree: () => void readonly loadMorePullRequests: () => void readonly switchViewTo: (view: PullRequestView) => void readonly openDetails: () => void @@ -175,6 +176,15 @@ export const buildAppCommands = ({ keywords: ["repo", "repository", "owner", "github"], run: actions.openRepositoryPicker, }), + defineCommand({ + id: "stack.open", + title: "Show stack tree", + scope: "View", + subtitle: "Tree of open PRs grouped by base/head branch relationships", + shortcut: "b", + keywords: ["stack", "tree", "branches", "graphite", "stacked", "base"], + run: actions.openStackTree, + }), ...activeViews.map((view) => defineCommand({ id: view._tag === "Repository" ? "view.repository" : `view.${view.mode}`, diff --git a/src/domain.ts b/src/domain.ts index 9a8c0ba..0cf6897 100644 --- a/src/domain.ts +++ b/src/domain.ts @@ -136,6 +136,7 @@ export interface PullRequestItem { readonly author: string readonly headRefOid: string readonly headRefName: string + readonly baseRefName: string readonly number: number readonly title: string readonly body: string diff --git a/src/keymap/all.ts b/src/keymap/all.ts index 74a8635..4c7cd4b 100644 --- a/src/keymap/all.ts +++ b/src/keymap/all.ts @@ -14,6 +14,7 @@ import { listNavKeymap, type ListNavCtx } from "./listNav.ts" import { mergeModalKeymap, type MergeModalCtx } from "./mergeModal.ts" import { openRepositoryModalKeymap, type OpenRepositoryModalCtx } from "./openRepositoryModal.ts" import { pullRequestStateModalKeymap, type PullRequestStateModalCtx } from "./pullRequestStateModal.ts" +import { stackTreeModalKeymap, type StackTreeModalCtx } from "./stackTreeModal.ts" import { submitReviewModalKeymap, type SubmitReviewModalCtx } from "./submitReviewModal.ts" import { themeModalKeymap, type ThemeModalCtx } from "./themeModal.ts" @@ -31,6 +32,7 @@ export interface AppCtx { readonly commentModalActive: boolean readonly deleteCommentModalActive: boolean readonly commandPaletteActive: boolean + readonly stackTreeModalActive: boolean readonly filterMode: boolean readonly diffFullView: boolean readonly detailFullView: boolean @@ -53,6 +55,7 @@ export interface AppCtx { readonly commentModal: CommentModalCtx readonly deleteCommentModal: DeleteCommentModalCtx readonly commandPalette: CommandPaletteCtx + readonly stackTreeModal: StackTreeModalCtx readonly filterModeCtx: FilterModeCtx readonly diff: DiffViewCtx readonly detail: DetailViewCtx @@ -78,7 +81,8 @@ const modalActive = (a: AppCtx): boolean => a.openRepositoryModalActive || a.commentModalActive || a.deleteCommentModalActive || - a.commandPaletteActive + a.commandPaletteActive || + a.stackTreeModalActive const inListMode = (a: AppCtx): boolean => !modalActive(a) && !a.filterMode && !a.diffFullView && !a.detailFullView && !a.commentsViewActive @@ -115,6 +119,7 @@ export const appKeymap = App( commentModalKeymap.scope((a) => a.commentModalActive && a.commentModal), deleteCommentModalKeymap.scope((a) => a.deleteCommentModalActive && a.deleteCommentModal), commandPaletteKeymap.scope((a) => a.commandPaletteActive && a.commandPalette), + stackTreeModalKeymap.scope((a) => a.stackTreeModalActive && a.stackTreeModal), filterModeKeymap.scope((a) => a.filterMode && a.filterModeCtx), // Full-view layers (only when no modal is on top) diff --git a/src/keymap/listNav.ts b/src/keymap/listNav.ts index 9307fb0..d3d75e5 100644 --- a/src/keymap/listNav.ts +++ b/src/keymap/listNav.ts @@ -38,6 +38,7 @@ export const listNavKeymap = List( { 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") }, + { id: "list.stack-tree", title: "Stack tree", keys: ["b"], run: (s) => s.runCommandById("stack.open") }, // Queue mode tabs { id: "list.next-tab", title: "Next view", keys: ["tab"], run: (s) => s.switchQueueMode(1) }, diff --git a/src/keymap/stackTreeModal.ts b/src/keymap/stackTreeModal.ts new file mode 100644 index 0000000..fb5f6e4 --- /dev/null +++ b/src/keymap/stackTreeModal.ts @@ -0,0 +1,26 @@ +import { context } from "@ghui/keymap" + +export interface StackTreeModalCtx { + readonly hasResults: boolean + readonly closeModal: () => void + readonly selectPullRequest: () => void + readonly moveSelection: (delta: -1 | 1) => void + readonly moveSelectionPage: (delta: -1 | 1) => void +} + +const StackTree = context() + +export const stackTreeModalKeymap = StackTree( + { id: "stack-tree.close", title: "Close", keys: ["escape", "q", "b"], run: (s) => s.closeModal() }, + { + id: "stack-tree.select", + title: "Jump to pull request", + keys: ["return"], + enabled: (s) => (s.hasResults ? true : "No pull requests in this view."), + run: (s) => s.selectPullRequest(), + }, + { id: "stack-tree.up", title: "Up", keys: ["k", "up", "ctrl+p", "ctrl+k"], run: (s) => s.moveSelection(-1) }, + { id: "stack-tree.down", title: "Down", keys: ["j", "down", "ctrl+n", "ctrl+j"], run: (s) => s.moveSelection(1) }, + { id: "stack-tree.page-up", title: "Page up", keys: ["pageup", "ctrl+u"], run: (s) => s.moveSelectionPage(-1) }, + { id: "stack-tree.page-down", title: "Page down", keys: ["pagedown", "ctrl+d"], run: (s) => s.moveSelectionPage(1) }, +) diff --git a/src/services/CacheService.ts b/src/services/CacheService.ts index 38cb11c..c027dfe 100644 --- a/src/services/CacheService.ts +++ b/src/services/CacheService.ts @@ -42,6 +42,7 @@ const CachedPullRequestItemSchema = Schema.Struct({ author: Schema.String, headRefOid: Schema.String, headRefName: Schema.optionalKey(Schema.String), + baseRefName: Schema.optionalKey(Schema.String), number: Schema.Number, title: Schema.String, body: Schema.String, @@ -109,6 +110,7 @@ const cachedPullRequestToDomain = (cached: CachedPullRequestItem): PullRequestIt author: cached.author, headRefOid: cached.headRefOid, headRefName: cached.headRefName ?? "", + baseRefName: cached.baseRefName ?? "", number: cached.number, title: cached.title, body: cached.body, @@ -134,6 +136,7 @@ const encodePullRequest = (pullRequest: PullRequestItem): CachedPullRequestItem author: pullRequest.author, headRefOid: pullRequest.headRefOid, headRefName: pullRequest.headRefName, + baseRefName: pullRequest.baseRefName, number: pullRequest.number, title: pullRequest.title, body: pullRequest.body, diff --git a/src/services/GitHubService.ts b/src/services/GitHubService.ts index 9525b1f..c075f24 100644 --- a/src/services/GitHubService.ts +++ b/src/services/GitHubService.ts @@ -64,6 +64,7 @@ const RawPullRequestSummaryFields = { author: RawAuthorSchema, headRefOid: Schema.String, headRefName: Schema.String, + baseRefName: Schema.String, repository: RawRepositorySchema, } as const @@ -237,6 +238,7 @@ const SUMMARY_FIELDS_FRAGMENT = ` author { login } headRefOid headRefName + baseRefName repository { nameWithOwner }${STATUS_CHECK_FRAGMENT}` const DETAIL_FIELDS_FRAGMENT = ` @@ -257,6 +259,7 @@ const DETAIL_FIELDS_FRAGMENT = ` author { login } headRefOid headRefName + baseRefName repository { nameWithOwner } labels(first: 20) { nodes { name color } }${STATUS_CHECK_FRAGMENT}` @@ -412,6 +415,7 @@ const parsePullRequestSummary = (item: RawPullRequestSummaryNode): PullRequestIt author: item.author.login, headRefOid: item.headRefOid, headRefName: item.headRefName, + baseRefName: item.baseRefName, number: item.number, title: item.title, body: "", diff --git a/src/services/MockGitHubService.ts b/src/services/MockGitHubService.ts index 92eccec..8488615 100644 --- a/src/services/MockGitHubService.ts +++ b/src/services/MockGitHubService.ts @@ -54,12 +54,17 @@ const buildPullRequest = (index: number, options: Required): PullRe const passed = total - (index % 3 === 0 ? 1 : 0) const review = REVIEW_CYCLE[index % REVIEW_CYCLE.length]! const createdAt = new Date(Date.now() - index * 86_400_000) + const isStackedRepo = repoIndex === 0 + const repoSiblings = Math.floor(index / options.repoCount) + const headRefName = `mock-branch-${index}` + const baseRefName = isStackedRepo && repoSiblings > 0 ? `mock-branch-${index - options.repoCount}` : "main" return { repository, author: options.username, headRefOid: `deadbeef${index.toString(16).padStart(8, "0")}`, - headRefName: `mock-branch-${index}`, + headRefName, + baseRefName, number, title: `Mock PR ${number}: example change ${index}`, body: `This is mock pull request #${number}.\n\nLine A.\nLine B.`, diff --git a/src/stack.ts b/src/stack.ts new file mode 100644 index 0000000..91fed78 --- /dev/null +++ b/src/stack.ts @@ -0,0 +1,79 @@ +import type { PullRequestItem } from "./domain.js" + +export interface StackNode { + readonly pullRequest: PullRequestItem + readonly children: readonly StackNode[] + readonly depth: number +} + +export interface StackForest { + readonly repository: string + readonly roots: readonly StackNode[] + readonly orphanCount: number +} + +interface MutableStackNode { + readonly pullRequest: PullRequestItem + readonly children: MutableStackNode[] + depth: number +} + +const setDepth = (node: MutableStackNode, depth: number): void => { + node.depth = depth + for (const child of node.children) setDepth(child, depth + 1) +} + +const sortByNumber = (left: PullRequestItem, right: PullRequestItem) => left.number - right.number + +const buildRepoForest = (repository: string, pullRequests: readonly PullRequestItem[]): StackForest => { + const sorted = [...pullRequests].sort(sortByNumber) + const byHead = new Map() + const nodes = sorted.map((pr): MutableStackNode => ({ pullRequest: pr, children: [], depth: 0 })) + for (const node of nodes) { + const head = node.pullRequest.headRefName + if (head) byHead.set(head, node) + } + const roots: MutableStackNode[] = [] + for (const node of nodes) { + const base = node.pullRequest.baseRefName + const parent = base ? byHead.get(base) : undefined + if (parent && parent !== node) parent.children.push(node) + else roots.push(node) + } + for (const root of roots) setDepth(root, 0) + return { repository, roots, orphanCount: roots.length } +} + +export const buildStackForests = (pullRequests: readonly PullRequestItem[]): readonly StackForest[] => { + const byRepo = new Map() + for (const pr of pullRequests) { + const list = byRepo.get(pr.repository) + if (list) list.push(pr) + else byRepo.set(pr.repository, [pr]) + } + const repos = [...byRepo.keys()].sort((a, b) => a.localeCompare(b)) + return repos.map((repository) => buildRepoForest(repository, byRepo.get(repository) ?? [])) +} + +export const flattenStackForest = (forest: StackForest): readonly StackNode[] => { + const result: StackNode[] = [] + const walk = (node: StackNode) => { + result.push(node) + for (const child of node.children) walk(child) + } + for (const root of forest.roots) walk(root) + return result +} + +export const isStacked = (forest: StackForest): boolean => forest.roots.some((root) => root.children.length > 0) + +export const stackParentBranch = (pullRequests: readonly PullRequestItem[], pullRequest: PullRequestItem): PullRequestItem | null => { + const base = pullRequest.baseRefName + if (!base) return null + for (const candidate of pullRequests) { + if (candidate === pullRequest) continue + if (candidate.repository !== pullRequest.repository) continue + if (candidate.headRefName === base) return candidate + } + return null +} diff --git a/src/ui/DetailsPane.tsx b/src/ui/DetailsPane.tsx index abfe580..0ae9381 100644 --- a/src/ui/DetailsPane.tsx +++ b/src/ui/DetailsPane.tsx @@ -521,6 +521,19 @@ export const getDetailsPaneHeight = ({ ? getDetailHeaderHeight(pullRequest, paneWidth, showChecks, comments, commentsStatus) + getDetailBodyHeight(pullRequest, contentWidth, bodyLines) : bodyLines + DETAIL_PLACEHOLDER_ROWS + 1 +const formatBranchHeader = (head: string, base: string, available: number): string => { + if (available < 4 || !head) return "" + if (!base) return trimCell(head, available) + const arrow = " → " + const full = `${head}${arrow}${base}` + if (full.length <= available) return full + const minBaseRoom = 4 + if (head.length + arrow.length + minBaseRoom <= available) { + return `${head}${arrow}${trimCell(base, available - head.length - arrow.length)}` + } + return trimCell(head, available) +} + export const DetailHeader = ({ pullRequest, viewerUsername, @@ -554,7 +567,7 @@ export const DetailHeader = ({ const statusParts = [review].filter((part): part is string => Boolean(part)) const rightSide = statusParts.length > 0 ? `${statusParts.join(" ")} ${opened}` : opened const branchBudget = Math.max(0, contentWidth - (1 + number.length + author.length) - rightSide.length - 3) - const branch = pullRequest.headRefName && branchBudget >= 4 ? trimCell(pullRequest.headRefName, branchBudget) : "" + const branch = formatBranchHeader(pullRequest.headRefName, pullRequest.baseRefName, branchBudget) const leftWidth = 1 + number.length + (branch.length > 0 ? 1 + branch.length : 0) + author.length const gap = Math.max(2, contentWidth - leftWidth - rightSide.length) diff --git a/src/ui/PullRequestList.tsx b/src/ui/PullRequestList.tsx index 1bd017f..fdb3351 100644 --- a/src/ui/PullRequestList.tsx +++ b/src/ui/PullRequestList.tsx @@ -18,12 +18,28 @@ export type PullRequestListRow = const GROUP_ICON = "◆" -const getRowLayout = (contentWidth: number, numberWidth: number, ageWidth: number) => { +const DEFAULT_BASE_BRANCHES = new Set(["main", "master", "develop", "trunk", "default"]) + +interface StackHint { + readonly text: string + readonly fg: string +} + +const stackHintFor = (pullRequest: PullRequestItem, parent: PullRequestItem | null | undefined): StackHint | null => { + if (parent) return { text: `↪ #${parent.number}`, fg: colors.accent } + const base = pullRequest.baseRefName + if (!base) return null + if (DEFAULT_BASE_BRANCHES.has(base)) return null + return { text: `→ ${base}`, fg: colors.muted } +} + +const getRowLayout = (contentWidth: number, numberWidth: number, ageWidth: number, hintWidth: number) => { const reviewWidth = 1 const checkWidth = 2 - const fixedWidth = reviewWidth + 1 + numberWidth + 1 + checkWidth + ageWidth + const hintGap = hintWidth > 0 ? 1 : 0 + const fixedWidth = reviewWidth + 1 + numberWidth + 1 + hintGap + hintWidth + checkWidth + ageWidth const titleWidth = Math.max(8, contentWidth - fixedWidth) - return { reviewWidth, checkWidth, ageWidth, numberWidth, titleWidth } + return { reviewWidth, checkWidth, ageWidth, numberWidth, titleWidth, hintWidth, hintGap } } const groupNumberWidth = (pullRequests: readonly PullRequestItem[]) => { @@ -101,6 +117,7 @@ const PullRequestRow = ({ numWidth, ageColWidth, filterText, + stackParent, onSelect, onHoverChange, }: { @@ -111,15 +128,20 @@ const PullRequestRow = ({ numWidth: number ageColWidth: number filterText: string + stackParent: PullRequestItem | null onSelect: () => void onHoverChange: (hovered: boolean) => void }) => { const ageText = `${daysOpen(pullRequest.createdAt)}d` - const { reviewWidth, checkWidth, ageWidth, numberWidth, titleWidth } = getRowLayout(contentWidth, numWidth, ageColWidth) - const rowWidth = reviewWidth + 1 + numberWidth + 1 + titleWidth + checkWidth + ageWidth + const stackHint = stackHintFor(pullRequest, stackParent) + const hintLength = stackHint?.text.length ?? 0 + const maxHintWidth = Math.min(hintLength, Math.max(0, Math.floor(contentWidth * 0.32))) + const { reviewWidth, checkWidth, ageWidth, numberWidth, titleWidth, hintWidth, hintGap } = getRowLayout(contentWidth, numWidth, ageColWidth, maxHintWidth) + const rowWidth = reviewWidth + 1 + numberWidth + 1 + titleWidth + hintGap + hintWidth + checkWidth + ageWidth const fillerWidth = Math.max(0, contentWidth - rowWidth) const display = pullRequestRowDisplay(pullRequest, selected) const rowBg = selected ? colors.selectedBg : hovered ? rowHoverBackground() : undefined + const hintFg = stackHint ? (selected ? colors.selectedText : stackHint.fg) : null return ( onHoverChange(true)} onMouseOut={() => onHoverChange(false)}> @@ -132,6 +154,12 @@ const PullRequestRow = ({ + {stackHint && hintWidth > 0 ? ( + <> + + {fitCell(stackHint.text, hintWidth)} + + ) : null} {fitCell(ageText, ageWidth, "right")} {fitCell(display.checkText, checkWidth, "right")} {fillerWidth > 0 ? {" ".repeat(fillerWidth)} : null} @@ -152,6 +180,7 @@ export const PullRequestList = ({ hasMore, isLoadingMore, loadingIndicator, + stackParents, onSelectPullRequest, }: { groups: PullRequestGroups @@ -166,6 +195,7 @@ export const PullRequestList = ({ hasMore: boolean isLoadingMore: boolean loadingIndicator: string + stackParents: ReadonlyMap onSelectPullRequest: (url: string) => void }) => { const rows = buildPullRequestListRows({ groups, status, error, filterText, showFilterBar, loadedCount, hasMore, isLoadingMore, loadingIndicator }) @@ -199,6 +229,7 @@ export const PullRequestList = ({ numWidth={row.numberWidth} ageColWidth={row.ageWidth} filterText={filterText} + stackParent={stackParents.get(pullRequestUrl) ?? null} onSelect={() => onSelectPullRequest(pullRequestUrl)} onHoverChange={(hovered) => setHoveredUrl((current) => (hovered ? (current === pullRequestUrl ? current : pullRequestUrl) : current === pullRequestUrl ? null : current)) diff --git a/src/ui/modals.tsx b/src/ui/modals.tsx index 53a88db..6b2d731 100644 --- a/src/ui/modals.tsx +++ b/src/ui/modals.tsx @@ -1,6 +1,7 @@ import { TextAttributes } from "@opentui/core" import { Data } from "effect" import type { + PullRequestItem, PullRequestLabel, PullRequestMergeInfo, PullRequestMergeKind, @@ -9,6 +10,8 @@ import type { PullRequestReviewEvent, RepositoryMergeMethods, } from "../domain.js" +import type { StackForest, StackNode } from "../stack.js" +import { reviewIcon } from "./pullRequests.js" import { allowedMergeMethodList } from "../domain.js" import { getMergeKindDefinition, mergeKindRowTitle, visibleMergeKinds } from "../mergeActions.js" import { clampCursor, commentEditorLines, cursorLineIndexForLines } from "./commentEditor.js" @@ -140,6 +143,11 @@ export interface OpenRepositoryModalState { readonly error: string | null } +export interface StackTreeModalState { + readonly selectedIndex: number + readonly anchorUrl: string | null +} + export const filterLabels = (labels: readonly PullRequestLabel[], query: string) => { const normalized = query.trim().toLowerCase() if (normalized.length === 0) return labels @@ -419,6 +427,11 @@ export const initialCommandPaletteState: CommandPaletteState = { selectedIndex: 0, } +export const initialStackTreeModalState: StackTreeModalState = { + selectedIndex: 0, + anchorUrl: null, +} + export const initialOpenRepositoryModalState: OpenRepositoryModalState = { query: "", error: null, @@ -438,6 +451,7 @@ export type Modal = Data.TaggedEnum<{ Theme: ThemeModalState CommandPalette: CommandPaletteState OpenRepository: OpenRepositoryModalState + StackTree: StackTreeModalState }> export const Modal = Data.taggedEnum() @@ -459,6 +473,7 @@ export const modalInitialStates = { Theme: initialThemeModalState, CommandPalette: initialCommandPaletteState, OpenRepository: initialOpenRepositoryModalState, + StackTree: initialStackTreeModalState, } as const satisfies { [Tag in Exclude]: ModalState } export const OpenRepositoryModal = ({ @@ -1377,3 +1392,173 @@ export const ThemeModal = ({ ) } + +export type StackTreeRow = + | { readonly kind: "repo-header"; readonly repository: string; readonly prCount: number; readonly isStacked: boolean } + | { readonly kind: "pr"; readonly node: StackNode; readonly prefix: string; readonly isLast: boolean } + | { readonly kind: "spacer" } + +export const buildStackTreeRows = (forests: readonly StackForest[]): readonly StackTreeRow[] => { + const rows: StackTreeRow[] = [] + forests.forEach((forest, forestIndex) => { + if (forestIndex > 0) rows.push({ kind: "spacer" }) + const flatCount = forest.roots.reduce((sum, root) => sum + countNodes(root), 0) + rows.push({ kind: "repo-header", repository: forest.repository, prCount: flatCount, isStacked: forest.roots.some((r) => r.children.length > 0) }) + const walk = (node: StackNode, ancestorIsLast: readonly boolean[], isLast: boolean) => { + const ancestorPrefix = ancestorIsLast.map((wasLast) => (wasLast ? " " : "│ ")).join("") + const connector = ancestorIsLast.length > 0 ? (isLast ? "└─ " : "├─ ") : "" + rows.push({ kind: "pr", node, prefix: `${ancestorPrefix}${connector}`, isLast }) + node.children.forEach((child, childIndex) => walk(child, [...ancestorIsLast, isLast], childIndex === node.children.length - 1)) + } + forest.roots.forEach((root, rootIndex) => walk(root, [], rootIndex === forest.roots.length - 1)) + }) + return rows +} + +const countNodes = (node: StackNode): number => 1 + node.children.reduce((sum, child) => sum + countNodes(child), 0) + +export const stackTreeSelectableUrls = (rows: readonly StackTreeRow[]): readonly string[] => rows.flatMap((row) => (row.kind === "pr" ? [row.node.pullRequest.url] : [])) + +export const stackTreeRowIndexForUrl = (rows: readonly StackTreeRow[], url: string | null): number | null => { + if (!url) return null + const index = stackTreeSelectableUrls(rows).indexOf(url) + return index >= 0 ? index : null +} + +const stackTreeReviewColor = (pr: PullRequestItem): string => { + if (pr.state === "merged") return colors.status.passing + if (pr.state === "closed") return colors.muted + if (pr.autoMergeEnabled) return colors.accent + return colors.status[pr.reviewStatus] ?? colors.text +} + +const stackTreeBranchSegment = (head: string, base: string, available: number): { readonly text: string; readonly truncated: boolean } => { + const arrow = " → " + if (available <= 0) return { text: "", truncated: false } + const full = `${head}${arrow}${base}` + if (full.length <= available) return { text: full, truncated: false } + const reserve = arrow.length + const each = Math.max(3, Math.floor((available - reserve) / 2)) + const truncatedHead = head.length > each ? `${head.slice(0, each - 1)}…` : head + const remaining = Math.max(3, available - reserve - truncatedHead.length) + const truncatedBase = base.length > remaining ? `${base.slice(0, remaining - 1)}…` : base + return { text: `${truncatedHead}${arrow}${truncatedBase}`, truncated: true } +} + +interface StackTreePullRequestRowProps { + readonly row: Extract + readonly width: number + readonly selected: boolean +} + +const StackTreePullRequestRow = ({ row, width, selected }: StackTreePullRequestRowProps) => { + const pr = row.node.pullRequest + const numberText = `#${pr.number}` + const reviewIconText = reviewIcon(pr) + const branchTarget = Math.min(40, Math.max(12, Math.floor(width * 0.36))) + const reservedFixed = row.prefix.length + 1 + 1 + numberText.length + 1 + const branchAvailable = Math.max(0, Math.min(branchTarget, width - reservedFixed - 6)) + const { text: branchText } = stackTreeBranchSegment(pr.headRefName || "?", pr.baseRefName || "?", branchAvailable) + const branchWidth = branchText.length + const titleWidth = Math.max(1, width - reservedFixed - (branchWidth > 0 ? branchWidth + 2 : 0)) + const reviewFg = stackTreeReviewColor(pr) + const titleFg = selected ? colors.selectedText : pr.state === "open" ? colors.text : colors.muted + const numberFg = selected ? colors.accent : colors.count + return ( + + {row.prefix} + {reviewIconText} + + {numberText} + + {fitCell(pr.title, titleWidth)} + {branchWidth > 0 ? ( + <> + {" "} + {branchText} + + ) : null} + + ) +} + +export const StackTreeModal = ({ + rows, + totalPullRequests, + selectableCount, + selectedIndex, + modalWidth, + modalHeight, + offsetLeft, + offsetTop, + viewLabel, +}: { + rows: readonly StackTreeRow[] + totalPullRequests: number + selectableCount: number + selectedIndex: number + modalWidth: number + modalHeight: number + offsetLeft: number + offsetTop: number + viewLabel: string +}) => { + const { bodyHeight: maxVisible, contentWidth, rowWidth } = searchModalDims(modalWidth, modalHeight) + const messageTopRows = Math.max(0, Math.floor((maxVisible - 1) / 2)) + const messageBottomRows = Math.max(0, maxVisible - messageTopRows - 1) + const safeSelected = Math.max(0, Math.min(selectedIndex, Math.max(0, selectableCount - 1))) + const selectableRowIndexes = rows.flatMap((row, index) => (row.kind === "pr" ? [index] : [])) + const targetRowIndex = selectableRowIndexes[safeSelected] ?? 0 + const scrollStart = Math.min(Math.max(0, rows.length - maxVisible), Math.max(0, targetRowIndex - Math.floor(maxVisible / 2))) + const visibleRows = rows.slice(scrollStart, scrollStart + maxVisible) + const title = "Stack tree" + const countText = totalPullRequests > 0 ? `${selectableCount}/${totalPullRequests} PRs` : "" + + return ( + + } + > + {rows.length === 0 ? ( + <> + + + + + ) : ( + visibleRows.map((row, visibleIndex) => { + const actualRowIndex = scrollStart + visibleIndex + if (row.kind === "spacer") return + if (row.kind === "repo-header") { + const detail = row.isStacked ? `${row.prCount} PRs · stacked` : `${row.prCount} PR${row.prCount === 1 ? "" : "s"}` + return ( + + + ◆ {row.repository} + + {` · ${detail}`} + + ) + } + const selectableIndex = selectableRowIndexes.indexOf(actualRowIndex) + return + }) + )} + + ) +} diff --git a/test/stack.test.ts b/test/stack.test.ts new file mode 100644 index 0000000..2623777 --- /dev/null +++ b/test/stack.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, test } from "bun:test" +import type { PullRequestItem } from "../src/domain.js" +import { buildStackForests, flattenStackForest, isStacked, stackParentBranch } from "../src/stack.js" + +const pr = (number: number, headRefName: string, baseRefName: string, repository = "acme/app"): PullRequestItem => ({ + repository, + author: "alice", + headRefOid: `oid-${number}`, + headRefName, + baseRefName, + number, + title: `PR ${number}`, + body: "", + labels: [], + additions: 0, + deletions: 0, + changedFiles: 0, + state: "open", + reviewStatus: "none", + checkStatus: "none", + checkSummary: null, + checks: [], + autoMergeEnabled: false, + detailLoaded: false, + createdAt: new Date(), + closedAt: null, + url: `https://github.com/${repository}/pull/${number}`, +}) + +describe("buildStackForests", () => { + test("PRs targeting main are roots", () => { + const forests = buildStackForests([pr(1, "feat-a", "main"), pr(2, "feat-b", "main")]) + expect(forests).toHaveLength(1) + expect(forests[0]!.repository).toBe("acme/app") + expect(forests[0]!.roots.map((r) => r.pullRequest.number)).toEqual([1, 2]) + expect(isStacked(forests[0]!)).toBe(false) + }) + + test("child PR whose base matches another PR's head becomes a child", () => { + const items = [pr(1, "feat-a", "main"), pr(2, "feat-b", "feat-a"), pr(3, "feat-c", "feat-b")] + const forests = buildStackForests(items) + const repo = forests[0]! + expect(repo.roots).toHaveLength(1) + const root = repo.roots[0]! + expect(root.pullRequest.number).toBe(1) + expect(root.children.map((c) => c.pullRequest.number)).toEqual([2]) + expect(root.children[0]!.children.map((c) => c.pullRequest.number)).toEqual([3]) + expect(isStacked(repo)).toBe(true) + }) + + test("depth is assigned correctly", () => { + const items = [pr(1, "a", "main"), pr(2, "b", "a"), pr(3, "c", "b")] + const flat = flattenStackForest(buildStackForests(items)[0]!) + expect(flat.map((n) => [n.pullRequest.number, n.depth])).toEqual([ + [1, 0], + [2, 1], + [3, 2], + ]) + }) + + test("PRs from different repos do not stack on each other", () => { + const items = [pr(1, "feat-a", "main", "acme/app"), pr(2, "feat-b", "feat-a", "other/repo")] + const forests = buildStackForests(items) + expect(forests.map((f) => f.repository)).toEqual(["acme/app", "other/repo"]) + expect(forests[0]!.roots.map((r) => r.pullRequest.number)).toEqual([1]) + expect(forests[1]!.roots.map((r) => r.pullRequest.number)).toEqual([2]) + expect(forests[1]!.roots[0]!.children).toHaveLength(0) + }) + + test("orphan PR whose base branch is not in the open set is treated as a root", () => { + const items = [pr(1, "feat-a", "deleted-branch")] + const forests = buildStackForests(items) + const repo = forests[0]! + expect(repo.roots).toHaveLength(1) + expect(repo.roots[0]!.pullRequest.number).toBe(1) + expect(repo.roots[0]!.children).toHaveLength(0) + }) + + test("self-referencing PR (head == base) is treated as a root, not its own child", () => { + const items = [pr(1, "loop", "loop")] + const forests = buildStackForests(items) + const repo = forests[0]! + expect(repo.roots).toHaveLength(1) + expect(repo.roots[0]!.children).toHaveLength(0) + }) + + test("repos are listed alphabetically", () => { + const items = [pr(1, "a", "main", "z/z"), pr(2, "b", "main", "a/a")] + const forests = buildStackForests(items) + expect(forests.map((f) => f.repository)).toEqual(["a/a", "z/z"]) + }) +}) + +describe("stackParentBranch", () => { + const items = [pr(1, "feat-a", "main"), pr(2, "feat-b", "feat-a"), pr(3, "feat-c", "main", "other/repo")] + + test("returns parent PR when base matches another PR's head", () => { + expect(stackParentBranch(items, items[1]!)?.number).toBe(1) + }) + + test("returns null when base is the default branch", () => { + expect(stackParentBranch(items, items[0]!)).toBeNull() + }) + + test("does not cross repository boundaries", () => { + const cross = pr(4, "feat-d", "feat-a", "other/repo") + expect(stackParentBranch([...items, cross], cross)).toBeNull() + }) +})