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
5 changes: 5 additions & 0 deletions .changeset/stack-tree-modal.md
Original file line number Diff line number Diff line change
@@ -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.
91 changes: 91 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -132,16 +132,20 @@ import {
initialModal,
initialOpenRepositoryModalState,
initialPullRequestStateModalState,
initialStackTreeModalState,
initialSubmitReviewModalState,
initialThemeModalState,
LabelModal,
MergeModal,
Modal,
OpenRepositoryModal,
PullRequestStateModal,
StackTreeModal,
stackTreeSelectableUrls,
submitReviewOptions,
SubmitReviewModal,
ThemeModal,
buildStackTreeRows,
type ChangedFilesModalState,
type CloseModalState,
type CommandPaletteState,
Expand All @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -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 extends Exclude<ModalTag, "None">>(tag: Tag) =>
(next: ModalState<Tag> | ((prev: ModalState<Tag>) => ModalState<Tag>)) =>
Expand All @@ -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)
Expand Down Expand Up @@ -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<string, Map<string, PullRequestItem>>()
for (const pr of visiblePullRequests) {
let repoMap = headByRepo.get(pr.repository)
if (!repoMap) {
repoMap = new Map<string, PullRequestItem>()
headByRepo.set(pr.repository, repoMap)
}
if (pr.headRefName) repoMap.set(pr.headRefName, pr)
}
const result = new Map<string, PullRequestItem>()
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)
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -2959,6 +2999,7 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => {
},
openThemeModal,
openRepositoryPicker,
openStackTree,
loadMorePullRequests,
switchViewTo,
openDetails: () => {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -3168,6 +3231,7 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => {
commentModalActive,
deleteCommentModalActive,
commandPaletteActive,
stackTreeModalActive,
filterMode,
diffFullView,
detailFullView,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -3504,6 +3575,7 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => {
hasMore: hasMorePullRequests,
isLoadingMore: isLoadingMorePullRequests,
loadingIndicator,
stackParents: stackParentsByUrl,
onSelectPullRequest: selectPullRequestByUrl,
} as const
const widePullRequestList = (
Expand Down Expand Up @@ -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 (
<box width={terminalWidth} height={terminalHeight} flexDirection="column" backgroundColor={colors.background}>
Expand Down Expand Up @@ -3966,6 +4044,19 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => {
onRunCommand={runCommandPaletteCommand}
/>
) : null}
{stackTreeModalActive ? (
<StackTreeModal
rows={stackTreeRows}
totalPullRequests={visiblePullRequests.length}
selectableCount={stackTreeSelectableUrlList.length}
selectedIndex={stackTreeModal.selectedIndex}
modalWidth={stackTreeModalWidth}
modalHeight={stackTreeModalHeight}
offsetLeft={stackTreeModalLeft}
offsetTop={stackTreeModalTop}
viewLabel={stackTreeViewLabel}
/>
) : null}
</box>
)
}
10 changes: 10 additions & 0 deletions src/appCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}`,
Expand Down
1 change: 1 addition & 0 deletions src/domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion src/keymap/all.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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
Expand All @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions src/keymap/listNav.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) },
Expand Down
26 changes: 26 additions & 0 deletions src/keymap/stackTreeModal.ts
Original file line number Diff line number Diff line change
@@ -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<StackTreeModalCtx>()

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) },
)
3 changes: 3 additions & 0 deletions src/services/CacheService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions src/services/GitHubService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ const RawPullRequestSummaryFields = {
author: RawAuthorSchema,
headRefOid: Schema.String,
headRefName: Schema.String,
baseRefName: Schema.String,
repository: RawRepositorySchema,
} as const

Expand Down Expand Up @@ -237,6 +238,7 @@ const SUMMARY_FIELDS_FRAGMENT = `
author { login }
headRefOid
headRefName
baseRefName
repository { nameWithOwner }${STATUS_CHECK_FRAGMENT}`

const DETAIL_FIELDS_FRAGMENT = `
Expand All @@ -257,6 +259,7 @@ const DETAIL_FIELDS_FRAGMENT = `
author { login }
headRefOid
headRefName
baseRefName
repository { nameWithOwner }
labels(first: 20) { nodes { name color } }${STATUS_CHECK_FRAGMENT}`

Expand Down Expand Up @@ -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: "",
Expand Down
7 changes: 6 additions & 1 deletion src/services/MockGitHubService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,17 @@ const buildPullRequest = (index: number, options: Required<MockOptions>): 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.`,
Expand Down
Loading