From 780167324563b69513acf641de9aa64bd54d051d Mon Sep 17 00:00:00 2001 From: Oliver Joseph Ash Date: Mon, 6 Jul 2026 21:06:08 +0100 Subject: [PATCH] Add support for moving commits below branches --- apps/lite/ui/src/operations/operation.ts | 75 ++++++++++++------- .../$id/workspace/OperationControls.tsx | 2 +- .../project/$id/workspace/OperationTarget.tsx | 18 ++++- .../$id/workspace/OutlineTree/OutlineTree.tsx | 3 + .../project/$id/workspace/WorkspacePage.tsx | 8 +- 5 files changed, 70 insertions(+), 36 deletions(-) diff --git a/apps/lite/ui/src/operations/operation.ts b/apps/lite/ui/src/operations/operation.ts index 62348312508..5d0ce99e526 100644 --- a/apps/lite/ui/src/operations/operation.ts +++ b/apps/lite/ui/src/operations/operation.ts @@ -18,6 +18,8 @@ import { DiffSpec, InsertSide, RelativeTo } from "@gitbutler/but-sdk"; import { Operand, operandEquals, operandFileParent } from "#ui/operands.ts"; import { resolveDiffSpecs, useResolveDiffSpecs } from "#ui/operations/diff-specs.ts"; import { decodeBytes } from "#ui/api/bytes.ts"; +import type { HeadInfoIndex } from "#ui/api/ref-info.ts"; +import { segmentBottomRelativeTo } from "#ui/api/stack.ts"; import { useAppDispatch } from "#ui/store.ts"; import { useParams } from "@tanstack/react-router"; import { errorMessageForToast } from "#ui/errors.ts"; @@ -58,6 +60,9 @@ type Operation = | ({ _tag: "MoveBranch" } & MoveBranchOperation); type OperationWithLabel = { operation: Operation; label: string }; +type OperationContext = { + headInfoIndex?: HeadInfoIndex; +}; const commitAmendOperation = (operation: CommitAmendOperation): Operation => ({ _tag: "CommitAmend", @@ -423,11 +428,12 @@ const moveOperation = ({ source, target, side, + headInfoIndex, }: { source: Operand; target: Operand; side: InsertSide; -}): OperationWithLabel | null => { +} & OperationContext): OperationWithLabel | null => { const branchMoveOperation = Match.value({ source, target, side }).pipe( // This should support `relativeTo`: // https://linear.app/gitbutler/issue/GB-1161/refsbranches-should-use-bytes-instead-of-strings @@ -460,34 +466,41 @@ const moveOperation = ({ Match.when( { target: { _tag: "Branch" }, - // We use the branch operand as the source/target for the branch - // contents. However, `RelativeTo` is interpreted to mean just the - // branch reference rather than the branch bucket, meaning `side: - // "below"` won't work as expected. side: "above", }, ({ target }): RelativeTo | null => ({ type: "referenceBytes", subject: target.branchRef }), ), + Match.when( + { + target: { _tag: "Branch" }, + side: "below", + }, + ({ target }): RelativeTo | null => { + const segment = headInfoIndex?.branchContextByRefBytes(target.branchRef)?.segment; + return segment ? segmentBottomRelativeTo(segment) : null; + }, + ), Match.orElse((): RelativeTo | null => null), ); if (!relativeTo) return null; return Match.value({ source, sourceFileParent: operandFileParent(source) }).pipe( - Match.when( - { source: { _tag: "Commit" } }, - ({ source }): OperationWithLabel => ({ - operation: commitMoveOperation({ - subjectCommitIds: [source.commitId], - relativeTo, - side, - }), - label: Match.value(side).pipe( - Match.when("above", () => "Move above"), - Match.when("below", () => "Move below"), - Match.exhaustive, - ), - }), + Match.when({ source: { _tag: "Commit" } }, ({ source }): OperationWithLabel | null => + relativeTo.type === "commit" && relativeTo.subject === source.commitId + ? null + : { + operation: commitMoveOperation({ + subjectCommitIds: [source.commitId], + relativeTo, + side, + }), + label: Match.value(side).pipe( + Match.when("above", () => "Move above"), + Match.when("below", () => "Move below"), + Match.exhaustive, + ), + }, ), Match.when( { sourceFileParent: { _tag: "UncommittedChanges" } }, @@ -535,7 +548,11 @@ const isOperationSourceEnabled = (source: Operand): boolean => export type OperationsByType = Record; -export const getOperations = (source: Operand, target: Operand): OperationsByType => { +export const getOperations = ( + source: Operand, + target: Operand, + context: OperationContext = {}, +): OperationsByType => { if (operandEquals(source, target) || !isOperationSourceEnabled(source)) return { into: null, @@ -544,17 +561,19 @@ export const getOperations = (source: Operand, target: Operand): OperationsByTyp }; return { into: intoOperation({ source, target }), - above: moveOperation({ source, target, side: "above" }), - below: moveOperation({ source, target, side: "below" }), + above: moveOperation({ source, target, side: "above", ...context }), + below: moveOperation({ source, target, side: "below", ...context }), }; }; -export const getOperation = (x: { - source: Operand; - target: Operand; - operationType: OperationType; -}): OperationWithLabel | null => { - const { into, above, below } = getOperations(x.source, x.target); +export const getOperation = ( + x: { + source: Operand; + target: Operand; + operationType: OperationType; + } & OperationContext, +): OperationWithLabel | null => { + const { into, above, below } = getOperations(x.source, x.target, x); return Match.value(x.operationType).pipe( Match.when("into", () => into), Match.when("above", () => above), diff --git a/apps/lite/ui/src/routes/project/$id/workspace/OperationControls.tsx b/apps/lite/ui/src/routes/project/$id/workspace/OperationControls.tsx index 272460e222a..b52a0aa74f8 100644 --- a/apps/lite/ui/src/routes/project/$id/workspace/OperationControls.tsx +++ b/apps/lite/ui/src/routes/project/$id/workspace/OperationControls.tsx @@ -301,7 +301,7 @@ const TransferKeyboardOperationControls: FC<{ const target = selection; - const operations = getOperations(mode.source, target); + const operations = getOperations(mode.source, target, { headInfoIndex }); const operation = operations[mode.operationType]; const run = () => { diff --git a/apps/lite/ui/src/routes/project/$id/workspace/OperationTarget.tsx b/apps/lite/ui/src/routes/project/$id/workspace/OperationTarget.tsx index 37f9a27f170..9ba8bf7bba5 100644 --- a/apps/lite/ui/src/routes/project/$id/workspace/OperationTarget.tsx +++ b/apps/lite/ui/src/routes/project/$id/workspace/OperationTarget.tsx @@ -1,4 +1,6 @@ import { operandEquals, type Operand } from "#ui/operands.ts"; +import { getHeadInfoIndex } from "#ui/api/ref-info.ts"; +import { headInfoQueryOptions } from "#ui/api/queries.ts"; import { parseDragData } from "./DragData.ts"; import styles from "./OperationTarget.module.css"; import { @@ -19,6 +21,7 @@ import { mergeProps, Tooltip, useRender } from "@base-ui/react"; import { Match, pipe } from "effect"; import { FC, useEffect, useEffectEvent, useRef } from "react"; import { TooltipPopup } from "#ui/components/Tooltip.tsx"; +import { useQuery } from "@tanstack/react-query"; type DropTargetParams = Parameters[0]; type GetDataArgs = Parameters>[0]; @@ -51,12 +54,16 @@ const useOperationDropTarget = ({ const dispatch = useAppDispatch(); const { mutate: runOperation } = useRunOperation(); const dropRef = useRef(null); + const { data: headInfoIndex } = useQuery({ + ...headInfoQueryOptions(projectId), + select: getHeadInfoIndex, + }); const getData = useEffectEvent(({ input, element, source }: GetDataArgs) => { const dragData = parseDragData(source.data); if (!dragData) return {}; - const { into, above, below } = getOperations(dragData.source, target); + const { into, above, below } = getOperations(dragData.source, target, { headInfoIndex }); return attachInstruction( {}, { @@ -120,6 +127,7 @@ const useOperationDropTarget = ({ source: dragData.source, target, operationType, + headInfoIndex, }) : null; @@ -132,9 +140,9 @@ const useOperationDropTarget = ({ runOperation(operation.operation); }, }); - }, [dispatch, projectId, runOperation, target]); + }, [dispatch, headInfoIndex, projectId, runOperation, target]); - return { dropRef }; + return { dropRef, headInfoIndex }; }; export type OperationTargetOutline = "inside" | "outside"; @@ -149,7 +157,7 @@ export const OperationTarget: FC< outline: OperationTargetOutline; } & useRender.ComponentProps<"div"> > = ({ enabled, target, projectId, isSelected, isAbsorptionTarget, outline, render, ...props }) => { - const { dropRef } = useOperationDropTarget({ enabled, target, projectId }); + const { dropRef, headInfoIndex } = useOperationDropTarget({ enabled, target, projectId }); const activeTargetOperationType = useAppSelector((state) => { const outlineMode = selectProjectOutlineModeState(state, projectId); @@ -203,6 +211,7 @@ export const OperationTarget: FC< source: mode.source, target: mode.target, operationType: mode.operationType, + headInfoIndex, })?.label : undefined, ), @@ -213,6 +222,7 @@ export const OperationTarget: FC< source: mode.source, target, operationType: mode.operationType, + headInfoIndex, })?.label, ), Match.orElse(() => undefined), diff --git a/apps/lite/ui/src/routes/project/$id/workspace/OutlineTree/OutlineTree.tsx b/apps/lite/ui/src/routes/project/$id/workspace/OutlineTree/OutlineTree.tsx index 8925964e187..7592da60596 100644 --- a/apps/lite/ui/src/routes/project/$id/workspace/OutlineTree/OutlineTree.tsx +++ b/apps/lite/ui/src/routes/project/$id/workspace/OutlineTree/OutlineTree.tsx @@ -510,6 +510,7 @@ const Stacks: FC<{ }> = ({ projectId, commitTarget }) => { const navigationIndex = assert(use(NavigationIndexContext)); const { data: headInfo } = useQuery(headInfoQueryOptions(projectId)); + const headInfoIndex = headInfo ? getHeadInfoIndex(headInfo) : undefined; const selection = useOutlineSelection({ projectId, navigationIndex }); const outlineMode = useAppSelector((state) => selectProjectOutlineModeState(state, projectId)); @@ -520,6 +521,7 @@ const Stacks: FC<{ source: mode.source, target: mode.target, operationType: mode.operationType, + headInfoIndex, })?.operation : undefined, ), @@ -529,6 +531,7 @@ const Stacks: FC<{ source: mode.source, target: selection, operationType: mode.operationType, + headInfoIndex, })?.operation : undefined, ), diff --git a/apps/lite/ui/src/routes/project/$id/workspace/WorkspacePage.tsx b/apps/lite/ui/src/routes/project/$id/workspace/WorkspacePage.tsx index 1e728da1dec..3a2e2225abe 100644 --- a/apps/lite/ui/src/routes/project/$id/workspace/WorkspacePage.tsx +++ b/apps/lite/ui/src/routes/project/$id/workspace/WorkspacePage.tsx @@ -59,6 +59,7 @@ import { buildIndexByKey, type NavigationIndex } from "#ui/workspace/navigation- import { reverse } from "effect/Array"; import { OperationControls } from "#ui/routes/project/$id/workspace/OperationControls.tsx"; import { WorkspacePageErrorBoundary } from "./WorkspacePageErrorBoundary.tsx"; +import { getHeadInfoIndex, type HeadInfoIndex } from "#ui/api/ref-info.ts"; // This must be unique as to not collide with other IDs, and stable because it's // stored in local storage. @@ -185,8 +186,8 @@ const outlineNavigationItems = ({ ]; }; -const hasAnyOperation = (source: Operand, target: Operand) => { - const operations = getOperations(source, target); +const hasAnyOperation = (source: Operand, target: Operand, headInfoIndex?: HeadInfoIndex) => { + const operations = getOperations(source, target, { headInfoIndex }); return !!operations.into || !!operations.above || !!operations.below; }; @@ -199,6 +200,7 @@ const useOutlineNavigationIndex = ({ }): NavigationIndex => { const { data: headInfo } = useQuery(headInfoQueryOptions(projectId)); const { data: worktreeChanges } = useQuery(changesInWorktreeQueryOptions(projectId)); + const headInfoIndex = headInfo ? getHeadInfoIndex(headInfo) : undefined; const outlineMode = useAppSelector((state) => selectProjectOutlineModeState(state, projectId)); @@ -219,7 +221,7 @@ const useOutlineNavigationIndex = ({ items.filter( (operand) => operandContains(operand, activeMode.value.source) || - hasAnyOperation(activeMode.value.source, operand), + hasAnyOperation(activeMode.value.source, operand, headInfoIndex), ), RenameBranch: (x) => items.filter((operand) => operandEquals(operand, branchOperand(x.operand))),