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/diff-hunk-palette.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kitlangton/ghui": minor
---

Add diff hunk navigation, selected-hunk copy, and selected-file diff copy commands to the command palette and diff keymap.
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,9 @@ running.
- `f`: open the changed-files navigator while viewing a diff
- `left` / `right`: choose the deleted or added side while in split diff comment mode
- `[` / `]`: switch files while viewing or commenting on a diff
- `{` / `}`: navigate diff hunks; the active hunk shows a thin gutter line
- `y`: copy the selected diff hunk
- `Y`: copy the selected file diff
- `s`: toggle draft or ready-for-review state
- `m`: merge
- `x`: close with confirmation
Expand Down
76 changes: 76 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ import {
} from "./ui/commentEditor.js"
import {
buildStackedDiffFiles,
buildStackedDiffHunks,
diffAnchorOnSide,
diffCommentAnchorLabel,
diffCommentLineLabel,
Expand All @@ -82,12 +83,14 @@ import {
getStackedDiffCommentAnchors,
minimizeWhitespaceDiffFiles,
nearestDiffAnchorForLocation,
nearestStackedDiffHunkIndexForFile,
PullRequestDiffState,
pullRequestDiffKey,
safeDiffFileIndex,
scrollTopForVisibleLine,
splitPatchFiles,
stackedDiffFileIndexAtLine,
stackedDiffHunkIndexAtLine,
type DiffCommentAnchor,
type DiffCommentKind,
type DiffView,
Expand Down Expand Up @@ -353,6 +356,7 @@ const diffFullViewAtom = Atom.make(false)
const commentsViewActiveAtom = Atom.make(false)
const commentsViewSelectionAtom = Atom.make(0)
const diffFileIndexAtom = Atom.make(0)
const diffHunkIndexAtom = Atom.make(0)
const diffScrollTopAtom = Atom.make(0)
const diffRenderViewAtom = Atom.make<DiffView>("split")
const diffWrapModeAtom = Atom.make<DiffWrapMode>("none")
Expand Down Expand Up @@ -721,6 +725,7 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => {
const [commentsViewActive, setCommentsViewActive] = useAtom(commentsViewActiveAtom)
const [commentsViewSelection, setCommentsViewSelection] = useAtom(commentsViewSelectionAtom)
const [diffFileIndex, setDiffFileIndex] = useAtom(diffFileIndexAtom)
const [diffHunkIndex, setDiffHunkIndex] = useAtom(diffHunkIndexAtom)
const [diffScrollTop, setDiffScrollTop] = useAtom(diffScrollTopAtom)
const [diffRenderView, setDiffRenderView] = useAtom(diffRenderViewAtom)
const [diffWrapMode, setDiffWrapMode] = useAtom(diffWrapModeAtom)
Expand Down Expand Up @@ -998,6 +1003,12 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => {
() => buildStackedDiffFiles(readyDiffFiles, effectiveDiffRenderView, diffWrapMode, contentWidth),
[readyDiffFiles, effectiveDiffRenderView, diffWrapMode, contentWidth],
)
const stackedDiffHunks = useMemo(
() => buildStackedDiffHunks(stackedDiffFiles, effectiveDiffRenderView, diffWrapMode, contentWidth),
[stackedDiffFiles, effectiveDiffRenderView, diffWrapMode, contentWidth],
)
const selectedDiffHunkIndex = Math.max(0, Math.min(diffHunkIndex, stackedDiffHunks.length - 1))
const selectedDiffHunk = stackedDiffHunks[selectedDiffHunkIndex] ?? null
const diffCommentAnchors = useMemo(
() => (diffFullView ? getStackedDiffCommentAnchors(stackedDiffFiles, effectiveDiffRenderView, diffWrapMode, contentWidth) : []),
[diffFullView, stackedDiffFiles, effectiveDiffRenderView, diffWrapMode, contentWidth],
Expand Down Expand Up @@ -1368,6 +1379,7 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => {

useEffect(() => {
setDiffFileIndex(0)
setDiffHunkIndex(0)
setDiffScrollTop(0)
setDiffCommentAnchorIndex(0)
setDiffPreferredSide(null)
Expand All @@ -1379,6 +1391,13 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => {
setDiffFileIndex((current) => safeDiffFileIndex(readyDiffFiles, current))
}, [readyDiffFiles.length])

useEffect(() => {
setDiffHunkIndex((current) => {
if (stackedDiffHunks.length === 0) return 0
return Math.max(0, Math.min(current, stackedDiffHunks.length - 1))
})
}, [stackedDiffHunks.length])

useEffect(() => {
setDiffCommentAnchorIndex((current) => {
if (diffCommentAnchors.length === 0) return 0
Expand Down Expand Up @@ -1781,6 +1800,8 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => {
setDiffScrollTop((current) => (current === scrollTop ? current : scrollTop))
const nextIndex = Math.max(0, stackedDiffFileIndexAtLine(stackedDiffFiles, scrollTop))
setDiffFileIndex((current) => (current === nextIndex ? current : nextIndex))
const nextHunkIndex = stackedDiffHunkIndexAtLine(stackedDiffHunks, scrollTop + DIFF_STICKY_HEADER_LINES)
if (nextHunkIndex >= 0) setDiffHunkIndex((current) => (current === nextHunkIndex ? current : nextHunkIndex))
}

const scrollDetailPreviewBy = (y: number) => {
Expand All @@ -1807,10 +1828,40 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => {
return () => globalThis.clearInterval(interval)
}, [diffFullView, stackedDiffFiles])

const selectDiffHunk = (index: number) => {
if (stackedDiffHunks.length === 0) return
const nextIndex = Math.max(0, Math.min(index, stackedDiffHunks.length - 1))
const hunk = stackedDiffHunks[nextIndex]
if (!hunk) return
setDiffHunkIndex(nextIndex)
setDiffFileIndex(hunk.fileIndex)
setDiffCommentRangeStartIndex(null)
const targetSide = diffPreferredSide ?? selectedDiffCommentAnchor?.side
const nextAnchor =
diffCommentAnchors.find((anchor) => anchor.fileIndex === hunk.fileIndex && anchor.side === targetSide && anchor.renderLine >= hunk.renderLine) ??
diffCommentAnchors.find((anchor) => anchor.fileIndex === hunk.fileIndex && anchor.renderLine >= hunk.renderLine) ??
diffCommentAnchors.find((anchor) => anchor.fileIndex === hunk.fileIndex && anchor.side === targetSide) ??
diffCommentAnchors.find((anchor) => anchor.fileIndex === hunk.fileIndex)
if (nextAnchor) setDiffCommentAnchorIndex(diffCommentAnchors.indexOf(nextAnchor))
const scroll = diffScrollRef.current
if (scroll) {
const nextTop = Math.max(0, hunk.renderLine - DIFF_STICKY_HEADER_LINES)
suppressNextDiffCommentScrollRef.current = true
scroll.scrollTo({ x: 0, y: nextTop })
syncDiffScrollState()
}
}

const moveDiffHunk = (delta: number) => {
selectDiffHunk(selectedDiffHunkIndex + delta)
}

const selectDiffFile = (index: number) => {
if (readyDiffFiles.length === 0) return
const nextIndex = safeDiffFileIndex(readyDiffFiles, index)
setDiffFileIndex(nextIndex)
const nextHunkIndex = nearestStackedDiffHunkIndexForFile(stackedDiffHunks, nextIndex)
if (nextHunkIndex !== null) setDiffHunkIndex(nextHunkIndex)
setDiffCommentRangeStartIndex(null)
const targetSide = diffPreferredSide ?? selectedDiffCommentAnchor?.side
const nextAnchor =
Expand Down Expand Up @@ -2423,6 +2474,21 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => {
.catch((error) => flashNotice(errorMessage(error)))
}

const copySelectedDiffHunk = () => {
if (!selectedDiffHunk) return
void copyToClipboard(selectedDiffHunk.patch)
.then(() => flashNotice("Copied hunk diff"))
.catch((error) => flashNotice(errorMessage(error)))
}

const copySelectedDiffFile = () => {
const file = selectedDiffHunk?.file ?? readyDiffFiles[safeDiffFileIndex(readyDiffFiles, diffFileIndex)]
if (!file) return
void copyToClipboard(file.patch)
.then(() => flashNotice(`Copied ${file.name} diff`))
.catch((error) => flashNotice(errorMessage(error)))
}

const openPullRequestStateModal = () => {
if (!selectedPullRequest || selectedPullRequest.state !== "open") return
setPullRequestStateModal({
Expand Down Expand Up @@ -2941,6 +3007,8 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => {
diffWhitespaceMode,
readyDiffFileCount: readyDiffFiles.length,
diffFileIndex,
readyDiffHunkCount: stackedDiffHunks.length,
diffHunkIndex: selectedDiffHunkIndex,
diffRangeActive: diffCommentRangeActive,
selectedDiffCommentAnchorLabel: selectedDiffCommentLabel,
selectedDiffCommentThreadCount: selectedDiffCommentThread.length,
Expand Down Expand Up @@ -2990,6 +3058,9 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => {
toggleDiffWhitespaceMode,
openChangedFilesModal,
jumpDiffFile,
moveDiffHunk,
copySelectedDiffHunk,
copySelectedDiffFile,
openSelectedDiffComment,
toggleDiffCommentRange,
moveDiffCommentThread,
Expand Down Expand Up @@ -3309,6 +3380,10 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => {
reload: () => runCommandById("diff.reload"),
nextThread: () => runCommandById("diff.next-thread"),
previousThread: () => runCommandById("diff.previous-thread"),
nextHunk: () => runCommandById("diff.next-hunk"),
previousHunk: () => runCommandById("diff.previous-hunk"),
copyHunk: () => runCommandById("diff.copy-hunk"),
copyFileDiff: () => runCommandById("diff.copy-file"),
moveAnchor: moveDiffCommentAnchor,
moveAnchorToBoundary: moveDiffCommentToBoundary,
alignAnchor: alignSelectedDiffCommentAnchor,
Expand Down Expand Up @@ -3631,6 +3706,7 @@ export const App = ({ systemThemeGeneration = 0 }: AppProps) => {
selectedCommentAnchor={selectedDiffCommentAnchor}
selectedCommentLabel={selectedDiffCommentLabel}
selectedCommentThread={selectedDiffCommentThread}
selectedHunk={selectedDiffHunk}
onSelectCommentLine={selectDiffCommentLine}
themeId={themeId}
themeGeneration={systemThemeGeneration}
Expand Down
48 changes: 48 additions & 0 deletions src/appCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ interface AppCommandActions {
readonly toggleDiffWhitespaceMode: () => void
readonly openChangedFilesModal: () => void
readonly jumpDiffFile: (delta: 1 | -1) => void
readonly moveDiffHunk: (delta: 1 | -1) => void
readonly copySelectedDiffHunk: () => void
readonly copySelectedDiffFile: () => void
readonly openSelectedDiffComment: () => void
readonly toggleDiffCommentRange: () => void
readonly moveDiffCommentThread: (delta: 1 | -1) => void
Expand Down Expand Up @@ -65,6 +68,8 @@ interface BuildAppCommandsInput {
readonly diffWhitespaceMode: DiffWhitespaceMode
readonly readyDiffFileCount: number
readonly diffFileIndex: number
readonly readyDiffHunkCount: number
readonly diffHunkIndex: number
readonly diffRangeActive: boolean
readonly selectedDiffCommentAnchorLabel: string | null
readonly selectedDiffCommentThreadCount: number
Expand Down Expand Up @@ -94,6 +99,8 @@ export const buildAppCommands = ({
diffWhitespaceMode,
readyDiffFileCount,
diffFileIndex,
readyDiffHunkCount,
diffHunkIndex,
diffRangeActive,
selectedDiffCommentAnchorLabel,
selectedDiffCommentThreadCount,
Expand All @@ -108,6 +115,7 @@ export const buildAppCommands = ({
const selectedDiffLineReason = diffFullView && diffReady ? (selectedDiffCommentAnchorLabel ? null : "No diff line selected.") : diffOpenReadyReason
const diffThreadReason = diffFullView && diffReady ? (hasDiffCommentThreads ? null : "No diff comments loaded.") : diffOpenReadyReason
const changedFilesReason = diffFullView && diffReady ? (readyDiffFileCount > 0 ? null : "No changed files loaded.") : diffOpenReadyReason
const diffHunksReason = diffFullView && diffReady ? (readyDiffHunkCount > 0 ? null : "No diff hunks loaded.") : diffOpenReadyReason
const selectedCommentReason = selectedPullRequest ? (commentsViewActive ? (hasSelectedComment ? null : "No comment selected.") : "Open comments first.") : noPullRequestReason
const ownCommentReason = selectedCommentReason ?? (canEditSelectedComment ? null : "Only your own (synced) comments can be edited or deleted.")
const loadMoreDisabledReason = isLoadingMorePullRequests ? "Already loading more pull requests." : hasMorePullRequests ? null : "No more pull requests loaded by this view."
Expand Down Expand Up @@ -339,6 +347,46 @@ export const buildAppCommands = ({
disabledReason: changedFilesReason,
run: () => actions.jumpDiffFile(-1),
}),
defineCommand({
id: "diff.next-hunk",
title: "Next diff hunk",
scope: "Diff",
subtitle: readyDiffHunkCount > 0 ? `${diffHunkIndex + 1}/${readyDiffHunkCount}` : "No diff hunks loaded",
shortcut: "}",
disabledReason: diffHunksReason,
keywords: ["patch", "section"],
run: () => actions.moveDiffHunk(1),
}),
defineCommand({
id: "diff.previous-hunk",
title: "Previous diff hunk",
scope: "Diff",
subtitle: readyDiffHunkCount > 0 ? `${diffHunkIndex + 1}/${readyDiffHunkCount}` : "No diff hunks loaded",
shortcut: "{",
disabledReason: diffHunksReason,
keywords: ["patch", "section"],
run: () => actions.moveDiffHunk(-1),
}),
defineCommand({
id: "diff.copy-hunk",
title: "Copy selected hunk diff",
scope: "Diff",
subtitle: readyDiffHunkCount > 0 ? `${diffHunkIndex + 1}/${readyDiffHunkCount}` : "No diff hunks loaded",
shortcut: "y",
disabledReason: diffHunksReason,
keywords: ["clipboard", "patch"],
run: actions.copySelectedDiffHunk,
}),
defineCommand({
id: "diff.copy-file",
title: "Copy selected file diff",
scope: "Diff",
subtitle: readyDiffFileCount > 0 ? `${diffFileIndex + 1}/${readyDiffFileCount}` : "No diff files loaded",
shortcut: "shift-y",
disabledReason: changedFilesReason,
keywords: ["clipboard", "patch"],
run: actions.copySelectedDiffFile,
}),
defineCommand({
id: "diff.open-comment-target",
title: selectedDiffCommentThreadCount > 0 ? "Open selected diff thread" : "Comment on selected diff line",
Expand Down
8 changes: 8 additions & 0 deletions src/keymap/diffView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ export interface DiffViewCtx {
readonly reload: () => void
readonly nextThread: () => void
readonly previousThread: () => void
readonly nextHunk: () => void
readonly previousHunk: () => void
readonly copyHunk: () => void
readonly copyFileDiff: () => void
readonly moveAnchor: (delta: number, opts?: { preserveViewportRow?: boolean }) => void
readonly moveAnchorToBoundary: (boundary: "first" | "last") => void
readonly alignAnchor: (align: DiffAlign) => void
Expand All @@ -36,6 +40,10 @@ export const diffViewKeymap = Diff(
{ id: "diff.reload", title: "Reload diff", keys: ["r"], run: (s) => s.reload() },
{ id: "diff.next-thread", title: "Next thread", keys: ["n"], run: (s) => s.nextThread() },
{ id: "diff.previous-thread", title: "Previous thread", keys: ["p"], run: (s) => s.previousThread() },
{ id: "diff.next-hunk", title: "Next hunk", keys: ["}"], run: (s) => s.nextHunk() },
{ id: "diff.previous-hunk", title: "Previous hunk", keys: ["{"], run: (s) => s.previousHunk() },
{ id: "diff.copy-hunk", title: "Copy hunk", keys: ["y"], run: (s) => s.copyHunk() },
{ id: "diff.copy-file", title: "Copy file diff", keys: ["shift+y"], run: (s) => s.copyFileDiff() },

// Half-page anchor moves preserve viewport row (true vim semantics)
{
Expand Down
1 change: 1 addition & 0 deletions src/ui/FooterHints.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ const diffViewHints = (ctx: HintsContext): readonly HintItem[] => [
{ key: "v", label: ctx.diffRangeActive ? "clear" : "range" },
{ key: "w", label: "wrap" },
{ key: "[]", label: "files" },
{ key: "{}", label: "hunks" },
{ key: "r", label: "reload" },
]

Expand Down
Loading