Skip to content

Commit d0f9fcf

Browse files
authored
Merge pull request #14083 from gitbutlerapp/frontend-sbm
Some plain git mode gui follow-ups
2 parents 5937cf5 + 4860bc0 commit d0f9fcf

10 files changed

Lines changed: 98 additions & 44 deletions

File tree

apps/desktop/src/components/forge/StackedPullRequestCard.svelte

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
prNumber: number;
2121
}
2222
23-
const { projectId, branchName, parent, child, isPushed, prNumber }: Props = $props();
23+
const { projectId, branchName, parent, child, isPushed, poll, prNumber }: Props = $props();
2424
2525
const baseBranchService = inject(BASE_BRANCH_SERVICE);
2626
const forge = inject(DEFAULT_FORGE_FACTORY);
@@ -87,7 +87,7 @@
8787
{hasParent}
8888
{parentIsPushed}
8989
{baseIsTargetBranch}
90-
poll
90+
{poll}
9191
>
9292
{#snippet button({ pr, mergeStatus, reopenStatus, setDraft })}
9393
{#if pr.state === "open"}

apps/desktop/src/components/views/BranchList.svelte

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
import { REORDER_DROPZONE_FACTORY } from "$lib/dragging/stackingReorderDropzoneManager";
1818
import { DEFAULT_FORGE_FACTORY } from "$lib/forge/forgeFactory.svelte";
1919
import { createBranchSelection } from "$lib/selection/key";
20-
import { segmentContext } from "$lib/stacks/segmentContext";
20+
import { precomputeStack, segmentContext } from "$lib/stacks/segmentContext";
2121
import { getStackContext } from "$lib/stacks/stackController.svelte";
2222
import { STACK_SERVICE } from "$lib/stacks/stackService.svelte";
2323
import { ensureValue } from "$lib/utils/validation";
@@ -78,11 +78,15 @@
7878
const canPublishPR = $derived(forge.current.authenticated);
7979
const baseBranchNameResponse = $derived(baseBranchService.baseBranchShortName(projectId));
8080
const baseBranchName = $derived(baseBranchNameResponse.response);
81+
82+
// Compute stack-wide derived values once per render so the per-iteration
83+
// segmentContext() call below stays O(1) instead of O(n) per index.
84+
const stackPrecomputed = $derived(precomputeStack(segments));
8185
</script>
8286

8387
<div class="branches-wrapper">
8488
{#each segments as segment, i}
85-
{@const ctx = segmentContext(segments, i)}
89+
{@const ctx = segmentContext(segments, i, stackPrecomputed)}
8690
{@const branchName = segment.refName?.displayName}
8791
{@const branchLabel = branchName ?? "Unnamed segment"}
8892
{@const remoteTrackingBranch = segment.remoteTrackingRefName

apps/desktop/src/components/views/StackDetails.svelte

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
} from "$lib/dragging/dropHandlers/commitDropHandler";
3232
3333
import { UNCOMMITTED_SERVICE } from "$lib/selection/uncommittedService.svelte";
34-
import { segmentContext } from "$lib/stacks/segmentContext";
34+
import { precomputeStack, segmentContext } from "$lib/stacks/segmentContext";
3535
import { getStackContext } from "$lib/stacks/stackController.svelte";
3636
import { STACK_SERVICE } from "$lib/stacks/stackService.svelte";
3737
import { inject } from "@gitbutler/core/context";
@@ -112,8 +112,9 @@
112112
branchName ? segments.findIndex((s) => s.refName?.displayName === branchName) : -1,
113113
);
114114
const selectedSegment = $derived(selectedIndex >= 0 ? segments[selectedIndex] : undefined);
115+
const stackPrecomputed = $derived(precomputeStack(segments));
115116
const selectedContext = $derived(
116-
selectedIndex >= 0 ? segmentContext(segments, selectedIndex) : undefined,
117+
selectedIndex >= 0 ? segmentContext(segments, selectedIndex, stackPrecomputed) : undefined,
117118
);
118119
119120
// If the selected branch is no longer in the stack (e.g. it was renamed
@@ -158,7 +159,9 @@
158159
runHooks: $runHooks,
159160
okWithForce: true,
160161
onCommitIdChange: (newId) => {
161-
if (stackId && branchName && selection) {
162+
// branchName may be undefined for commits in anonymous segments
163+
// (refName === null); the selection state already tolerates that.
164+
if (selection) {
162165
const previewOpen = selection.previewOpen ?? true;
163166
controller.laneState.selection.set({
164167
branchName,

apps/desktop/src/lib/dragging/dropHandlers/stackDropHandler.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ import { toCommitMovePlacement } from "$lib/stacks/commitMovePlacement";
1313
import StackMacros from "$lib/stacks/macros";
1414
import { toMoveBranchWarning } from "$lib/stacks/stack";
1515
import { withStackBusy } from "$lib/state/uiState.svelte";
16-
import { ensureValue } from "$lib/utils/validation";
1716
import { untrack } from "svelte";
1817
import type { DropResult } from "$lib/dragging/dropResult";
1918
import type { DropzoneHandler } from "$lib/dragging/handler";
@@ -95,7 +94,7 @@ export class OutsideLaneDzHandler implements DropzoneHandler {
9594
if (sourceStackId) {
9695
const diffSpec = changesToDiffSpec(await data.treeChanges());
9796
await this.macros.moveChangesToNewCommit(
98-
ensureValue(stack.id),
97+
stack.id,
9998
outcome.newCommit,
10099
sourceStackId,
101100
sourceCommitId,
@@ -122,7 +121,7 @@ export class OutsideLaneDzHandler implements DropzoneHandler {
122121
.map((h) => ({
123122
hunkHeader: h.hunkHeader,
124123
pathBytes: h.pathBytes,
125-
target: this.stackTarget(ensureValue(stack.id)),
124+
target: this.stackTarget(stack.id),
126125
}));
127126
await this.diffService.assignHunk({
128127
projectId: this.projectId,
@@ -151,7 +150,7 @@ export class OutsideLaneDzHandler implements DropzoneHandler {
151150
: null;
152151

153152
await this.macros.moveChangesToNewCommit(
154-
ensureValue(stack.id),
153+
stack.id,
155154
outcome.newCommit,
156155
data.stackId,
157156
data.commitId,
@@ -195,7 +194,7 @@ export class OutsideLaneDzHandler implements DropzoneHandler {
195194
{
196195
hunkHeader: assignment.hunkHeader,
197196
pathBytes: assignment.pathBytes,
198-
target: this.stackTarget(ensureValue(stack.id)),
197+
target: this.stackTarget(stack.id),
199198
},
200199
],
201200
});
@@ -219,8 +218,10 @@ export class OutsideLaneDzHandler implements DropzoneHandler {
219218
branch: { name: undefined },
220219
});
221220

222-
const stackId = ensureValue(stack.id);
223-
const branchName = ensureValue(stack.heads.at(0)?.name);
221+
const stackId = stack.id;
222+
// Freshly-created stacks always have at least one head per the
223+
// StackEntryNoOpt invariant ("list is never empty").
224+
const branchName = stack.heads[0]!.name;
224225

225226
const { relativeTo, side } = toCommitMovePlacement({
226227
targetBranchName: branchName,

apps/desktop/src/lib/stacks/macros.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { ensureValue } from "$lib/utils/validation";
21
import type { CreateCommitRequestWorktreeChanges } from "$lib/stacks/stackEndpoints";
32
import type { StackService } from "$lib/stacks/stackService.svelte";
43
import type { RejectionReason } from "$lib/state/uiState.svelte";
@@ -33,7 +32,6 @@ export default class StackMacros {
3332
params.branchName,
3433
params.commitMessage,
3534
);
36-
if (!stack.id) return;
3735
if (outcome.newCommit) {
3836
this.uiState.lane(stack.id).selection.set({
3937
branchName,
@@ -55,7 +53,10 @@ export default class StackMacros {
5553
projectId: this.projectId,
5654
branch: { name },
5755
});
58-
const branchName = ensureValue(stack.heads.at(0)?.name);
56+
// The backend guarantees a non-empty heads array on freshly-created
57+
// stacks (see StackEntryNoOpt + the "list is never empty" invariant
58+
// on legacy ui::StackEntry).
59+
const branchName = stack.heads[0]!.name;
5960
const outcome = await this.stackService.createCommitMutation({
6061
projectId: this.projectId,
6162
stackBranchName: branchName,

apps/desktop/src/lib/stacks/segmentContext.ts

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { partialStackRequestsForcePush } from "$lib/stacks/stack";
1+
import { branchRequiresForcePush } from "$lib/stacks/stack";
22
import type { Segment } from "@gitbutler/but-sdk";
33

44
/**
@@ -14,13 +14,46 @@ export interface SegmentContext {
1414
stackPrNumbers: (number | undefined)[];
1515
}
1616

17-
export function segmentContext(segments: Segment[], index: number): SegmentContext {
18-
const branchName = segments[index]?.refName?.displayName;
17+
/**
18+
* Stack-wide data shared by every SegmentContext in a stack. Compute
19+
* this ONCE per stack (e.g. via `$derived`) and pass it to
20+
* `segmentContext(...)` for each index — that keeps the per-segment work
21+
* O(1) instead of O(N) per call.
22+
*/
23+
export interface StackPrecomputed {
24+
stackPrNumbers: (number | undefined)[];
25+
/**
26+
* `withForceFromIndex[i]` is true when *any* segment at index `>= i`
27+
* needs a force push. Pre-computed via a single suffix-OR pass over
28+
* the stack so per-segment lookup is O(1).
29+
*/
30+
withForceFromIndex: boolean[];
31+
}
32+
33+
export function precomputeStack(segments: Segment[]): StackPrecomputed {
34+
const stackPrNumbers = segments.map((s) => s.metadata?.review.pullRequest ?? undefined);
35+
36+
const withForceFromIndex = new Array<boolean>(segments.length);
37+
let anyForceFromHere = false;
38+
for (let i = segments.length - 1; i >= 0; i--) {
39+
const s = segments[i];
40+
if (s && branchRequiresForcePush(s)) anyForceFromHere = true;
41+
withForceFromIndex[i] = anyForceFromHere;
42+
}
43+
44+
return { stackPrNumbers, withForceFromIndex };
45+
}
46+
47+
export function segmentContext(
48+
segments: Segment[],
49+
index: number,
50+
precomputed: StackPrecomputed,
51+
): SegmentContext {
1952
return {
2053
branchIndex: index,
2154
parent: segments[index + 1],
2255
child: segments[index - 1],
23-
withForce: branchName ? partialStackRequestsForcePush(branchName, segments) : false,
24-
stackPrNumbers: segments.map((s) => s.metadata?.review.pullRequest ?? undefined),
56+
withForce: precomputed.withForceFromIndex[index] ?? false,
57+
stackPrNumbers: precomputed.stackPrNumbers,
2558
};
2659
}

apps/desktop/src/lib/stacks/stack.ts

Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -117,26 +117,6 @@ export function branchRequiresForcePush(branch: Segment): boolean {
117117
return branch.pushStatus === "unpushedCommitsRequiringForce";
118118
}
119119

120-
/**
121-
* Does the branch or other branch this depends on require a force push?
122-
*
123-
* @param branchName The name of the branch to check
124-
* @param allBranches Complete list of branches in the stack. The order is expected to be child-to-parent
125-
*/
126-
export function partialStackRequestsForcePush(branchName: string, allBranches: Segment[]): boolean {
127-
let foundBranch = false;
128-
129-
for (const branch of allBranches) {
130-
if (branch.refName?.displayName === branchName && !foundBranch) {
131-
foundBranch = true;
132-
}
133-
if (!foundBranch) continue;
134-
if (branchRequiresForcePush(branch)) return true;
135-
}
136-
137-
return false;
138-
}
139-
140120
export function stackHasConflicts(stack: Stack): boolean {
141121
return stack.segments.at(0)?.commits.some((commit) => commit.hasConflicts) ?? false;
142122
}

apps/desktop/src/lib/stacks/stackEndpoints.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ import type {
4545
InsertSide,
4646
RelativeTo,
4747
RefInfo,
48-
StackEntry,
48+
StackEntryNoOpt,
4949
} from "@gitbutler/but-sdk";
5050

5151
export type BranchParams = {
@@ -215,7 +215,7 @@ export function buildStackEndpoints(build: BackendEndpointBuilder) {
215215
return transformWorkspaceDetails(response);
216216
},
217217
}),
218-
createStack: build.mutation<StackEntry, { projectId: string; branch: BranchParams }>({
218+
createStack: build.mutation<StackEntryNoOpt, { projectId: string; branch: BranchParams }>({
219219
extraOptions: {
220220
command: "create_virtual_branch",
221221
actionName: "Create Stack",

crates/but-workspace/src/legacy/ui.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,22 +77,33 @@ but_schemars::register_sdk_type!(StackEntry);
7777
/// **Temporary type to help transitioning to the optional version of stack-entry** and ultimately, to [`crate::RefInfo`].
7878
/// WARNING: for use by parts in the code that can rely on having a non-optional `stack_id`. The goal is to have none of these.
7979
#[derive(Debug, Clone, Serialize)]
80+
#[cfg_attr(feature = "export-schema", derive(schemars::JsonSchema))]
8081
#[serde(rename_all = "camelCase")]
8182
pub struct StackEntryNoOpt {
8283
/// The ID of the stack.
84+
#[cfg_attr(
85+
feature = "export-schema",
86+
schemars(schema_with = "but_schemars::stack_id")
87+
)]
8388
pub id: StackId,
8489
/// The list of the branch information that are part of the stack.
8590
/// The list is never empty.
8691
/// The first entry in the list is always the most recent branch on top the stack.
8792
pub heads: Vec<StackHeadInfo>,
8893
/// The tip of the top-most branch, i.e., the most recent commit that would become the parent of new commits of the topmost stack branch.
8994
#[serde(with = "but_serde::object_id")]
95+
#[cfg_attr(
96+
feature = "export-schema",
97+
schemars(schema_with = "but_schemars::object_id")
98+
)]
9099
pub tip: gix::ObjectId,
91100
/// The zero-based index for sorting stacks.
92101
pub order: Option<usize>,
93102
/// If `true`, then any head in this stack is checked directly so `HEAD` points to it, and this is only ever `true` for a single stack.
94103
pub is_checked_out: bool,
95104
}
105+
#[cfg(feature = "export-schema")]
106+
but_schemars::register_sdk_type!(StackEntryNoOpt);
96107

97108
impl From<StackEntryNoOpt> for crate::commit::Stack {
98109
fn from(value: StackEntryNoOpt) -> Self {

packages/but-sdk/src/generated/index.d.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1898,6 +1898,27 @@ export type StackEntry = {
18981898
isCheckedOut: boolean;
18991899
};
19001900

1901+
/**
1902+
* **Temporary type to help transitioning to the optional version of stack-entry** and ultimately, to [`crate::RefInfo`].
1903+
* WARNING: for use by parts in the code that can rely on having a non-optional `stack_id`. The goal is to have none of these.
1904+
*/
1905+
export type StackEntryNoOpt = {
1906+
/** The ID of the stack. */
1907+
id: string;
1908+
/**
1909+
* The list of the branch information that are part of the stack.
1910+
* The list is never empty.
1911+
* The first entry in the list is always the most recent branch on top the stack.
1912+
*/
1913+
heads: Array<StackHeadInfo>;
1914+
/** The tip of the top-most branch, i.e., the most recent commit that would become the parent of new commits of the topmost stack branch. */
1915+
tip: string;
1916+
/** The zero-based index for sorting stacks. */
1917+
order: number | null;
1918+
/** If `true`, then any head in this stack is checked directly so `HEAD` points to it, and this is only ever `true` for a single stack. */
1919+
isCheckedOut: boolean;
1920+
};
1921+
19011922
/** The information about the branch inside a stack */
19021923
export type StackHeadInfo = {
19031924
/** The name of the branch. */

0 commit comments

Comments
 (0)