Skip to content

Commit e0beb31

Browse files
authored
Merge pull request #13754 from Byron/feat-normalize-branch-name
normalize new branch names with the backend
2 parents a95c193 + da8c8e1 commit e0beb31

5 files changed

Lines changed: 120 additions & 32 deletions

File tree

apps/desktop/src/components/branch/AddDependentBranchModal.svelte

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,17 +18,18 @@
1818
1919
let modal = $state<Modal>();
2020
let branchName = $state<string>();
21-
let slugifiedRefName: string | undefined = $state();
21+
let normalizedRefName: string | undefined = $state();
22+
let isBranchNameValid = $state(false);
2223
2324
async function handleAddDependentBranch(close: () => void) {
24-
if (!slugifiedRefName) return;
25+
if (!normalizedRefName) return;
2526
2627
await createNewBranch({
2728
projectId,
2829
stackId,
2930
request: {
3031
targetPatch: undefined,
31-
name: slugifiedRefName,
32+
name: normalizedRefName,
3233
},
3334
});
3435
@@ -52,7 +53,8 @@
5253
placeholder="Branch name"
5354
bind:value={branchName}
5455
autofocus
55-
onslugifiedvalue={(value) => (slugifiedRefName = value)}
56+
onnormalizedvalue={(value) => (normalizedRefName = value)}
57+
onvalidationchange={(isValid) => (isBranchNameValid = isValid)}
5658
/>
5759
</div>
5860
{#snippet controls(close)}
@@ -61,7 +63,7 @@
6163
testId={TestId.BranchHeaderAddDependanttBranchModal_ActionButton}
6264
style="pop"
6365
type="submit"
64-
disabled={!slugifiedRefName}
66+
disabled={!isBranchNameValid}
6567
loading={branchCreation.current.isLoading}>Add branch</Button
6668
>
6769
{/snippet}
Lines changed: 90 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,117 @@
11
<script lang="ts">
2-
import { Textbox } from "@gitbutler/ui";
3-
import { slugify } from "@gitbutler/ui/utils/string";
2+
import { STACK_SERVICE } from "$lib/stacks/stackService.svelte";
3+
import { debounce } from "$lib/utils/debounce";
4+
import { inject } from "@gitbutler/core/context";
5+
import { Icon, Textbox } from "@gitbutler/ui";
6+
import { onDestroy } from "svelte";
47
58
type Props = {
69
value?: string;
710
helperText?: string;
8-
onslugifiedvalue?: (slugified: string | undefined) => void;
11+
onnormalizedvalue?: (normalized: string | undefined) => void;
12+
onvalidationchange?: (isValid: boolean) => void;
913
[key: string]: any;
1014
};
1115
1216
let {
1317
value = $bindable(),
1418
helperText,
15-
16-
onslugifiedvalue,
19+
onnormalizedvalue,
20+
onvalidationchange,
1721
...restProps
1822
}: Props = $props();
1923
24+
const stackService = inject(STACK_SERVICE);
25+
2026
let textbox = $state<ReturnType<typeof Textbox>>();
27+
let isValidating = $state(false);
28+
let validationError = $state<string | undefined>();
29+
let validationCounter = $state(0);
30+
let isDestroyed = false;
31+
32+
let normalizedResult = $state<{ fromValue: string; normalized: string } | undefined>();
33+
34+
const isValidState = $derived(
35+
!isValidating &&
36+
!validationError &&
37+
!!value &&
38+
!!normalizedResult?.normalized &&
39+
normalizedResult.fromValue === value,
40+
);
41+
$effect(() => {
42+
onvalidationchange?.(isValidState);
43+
});
2144
22-
const slugifiedName = $derived(value && slugify(value));
23-
const namesDiverge = $derived(!!value && slugifiedName !== value);
45+
const namesDiverge = $derived(
46+
!!normalizedResult && normalizedResult.normalized !== normalizedResult.fromValue,
47+
);
2448
const computedHelperText = $derived(
25-
namesDiverge ? `Will be created as '${slugifiedName}'` : helperText,
49+
namesDiverge && normalizedResult
50+
? `Will be created as '${normalizedResult.normalized}'`
51+
: helperText,
2652
);
2753
54+
const debouncedNormalize = debounce(async (inputValue: string) => {
55+
if (isDestroyed) return;
56+
57+
if (!inputValue) {
58+
isValidating = false;
59+
validationError = undefined;
60+
normalizedResult = undefined;
61+
onnormalizedvalue?.(undefined);
62+
return;
63+
}
64+
65+
const currentValidation = ++validationCounter;
66+
isValidating = true;
67+
validationError = undefined;
68+
69+
try {
70+
const result = await stackService.normalizeBranchName(inputValue);
71+
// Only update if the value hasn't changed during the async call
72+
// and no newer validation has started
73+
if (!isDestroyed && value === inputValue && currentValidation === validationCounter) {
74+
normalizedResult = { fromValue: inputValue, normalized: result };
75+
onnormalizedvalue?.(result);
76+
validationError = undefined;
77+
}
78+
} catch {
79+
if (!isDestroyed && value === inputValue && currentValidation === validationCounter) {
80+
normalizedResult = undefined;
81+
onnormalizedvalue?.(undefined);
82+
validationError = "Invalid branch name";
83+
}
84+
} finally {
85+
if (!isDestroyed && currentValidation === validationCounter) {
86+
isValidating = false;
87+
}
88+
}
89+
}, 100);
90+
2891
$effect(() => {
29-
onslugifiedvalue?.(slugifiedName);
92+
debouncedNormalize(value || "");
3093
});
3194
3295
export async function selectAll() {
3396
await textbox?.selectAll();
3497
}
98+
99+
onDestroy(() => {
100+
isDestroyed = true;
101+
validationCounter++;
102+
});
35103
</script>
36104

37-
<Textbox bind:this={textbox} bind:value helperText={computedHelperText} {...restProps} />
105+
<Textbox
106+
bind:this={textbox}
107+
bind:value
108+
helperText={computedHelperText}
109+
error={validationError}
110+
{...restProps}
111+
>
112+
{#snippet customIconRight()}
113+
{#if isValidating}
114+
<Icon name="spinner" />
115+
{/if}
116+
{/snippet}
117+
</Textbox>

apps/desktop/src/components/branch/BranchRenameModal.svelte

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@
2020
const [renameBranch, renameQuery] = stackService.updateBranchName;
2121
2222
let newName: string | undefined = $state();
23-
let slugifiedRefName: string | undefined = $state();
23+
let normalizedRefName: string | undefined = $state();
24+
let isBranchNameValid = $state(false);
2425
let modal: Modal | undefined = $state();
2526
2627
let branchNameInput = $state<ReturnType<typeof BranchNameTextbox>>();
@@ -40,8 +41,8 @@
4041
type={isPushed ? "warning" : "info"}
4142
bind:this={modal}
4243
onSubmit={async (close) => {
43-
if (slugifiedRefName) {
44-
renameBranch({ projectId, stackId, laneId, branchName, newName: slugifiedRefName });
44+
if (normalizedRefName) {
45+
renameBranch({ projectId, stackId, laneId, branchName, newName: normalizedRefName });
4546
}
4647
close();
4748
}}
@@ -52,7 +53,8 @@
5253
id={ElementId.NewBranchNameInput}
5354
bind:value={newName}
5455
autofocus
55-
onslugifiedvalue={(value) => (slugifiedRefName = value)}
56+
onnormalizedvalue={(value) => (normalizedRefName = value)}
57+
onvalidationchange={(isValid) => (isBranchNameValid = isValid)}
5658
/>
5759

5860
{#if isPushed}
@@ -68,7 +70,7 @@
6870
testId={TestId.BranchHeaderRenameModal_ActionButton}
6971
style="pop"
7072
type="submit"
71-
disabled={!slugifiedRefName}
73+
disabled={!isBranchNameValid}
7274
loading={renameQuery.current.isLoading}>Rename</Button
7375
>
7476
{/snippet}

apps/desktop/src/components/branch/CreateBranchModal.svelte

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@
4242
// Persisted preference for branch placement
4343
const addToLeftmost = persisted<boolean>(false, "branch-placement-leftmost");
4444
45-
let slugifiedRefName: string | undefined = $state();
45+
let normalizedRefName: string | undefined = $state();
46+
let isBranchNameValid = $state(false);
4647
4748
// Get all stacks in the workspace
4849
const allStacksQuery = $derived(stackService.stacks(projectId));
@@ -91,22 +92,21 @@
9192
await createNewStack({
9293
projectId,
9394
branch: {
94-
name: slugifiedRefName,
95+
name: normalizedRefName,
9596
// If addToLeftmost is true, place at position 0 (leftmost)
9697
// Otherwise, leave undefined to append to the right
9798
order: $addToLeftmost ? 0 : undefined,
9899
},
99100
});
100101
createRefModal?.close();
101102
} else {
102-
if (!selectedStackId || !slugifiedRefName) {
103-
// TODO: Add input validation.
103+
if (!selectedStackId || !normalizedRefName) {
104104
return;
105105
}
106106
await createNewBranch({
107107
projectId,
108108
stackId: selectedStackId,
109-
request: { targetPatch: undefined, name: slugifiedRefName },
109+
request: { targetPatch: undefined, name: normalizedRefName },
110110
});
111111
createRefModal?.close();
112112
}
@@ -145,7 +145,8 @@
145145
id={ElementId.NewBranchNameInput}
146146
value={createRefName}
147147
autofocus
148-
onslugifiedvalue={(value) => (slugifiedRefName = value)}
148+
onnormalizedvalue={(value) => (normalizedRefName = value)}
149+
onvalidationchange={(isValid) => (isBranchNameValid = isValid)}
149150
/>
150151

151152
<div class="options-wrap" role="radiogroup" aria-label="Branch type selection">
@@ -269,7 +270,7 @@
269270
style="pop"
270271
type="submit"
271272
onclick={addNew}
272-
disabled={!createRefName || (createRefType === "dependent" && !selectedStackId)}
273+
disabled={!isBranchNameValid || (createRefType === "dependent" && !selectedStackId)}
273274
loading={isAddingNew}
274275
testId={TestId.ConfirmSubmit}
275276
>

apps/desktop/src/components/workspace/StashIntoBranchModal.svelte

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,13 @@
3838
3939
let modal: ReturnType<typeof Modal> | undefined;
4040
let stashBranchName = $state<string>();
41-
let slugifiedRefName: string | undefined = $state();
41+
let normalizedRefName: string | undefined = $state();
42+
let isStashBranchNameValid = $state(false);
4243
let stashBranchNameInput = $state<ReturnType<typeof BranchNameTextbox>>();
4344
4445
export async function show(item: ChangedFilesItem) {
45-
slugifiedRefName = undefined;
46+
normalizedRefName = undefined;
47+
isStashBranchNameValid = false;
4648
modal?.show(item);
4749
stashBranchName = await stackService.fetchNewBranchName(projectId);
4850
if ($autoSelectBranchCreationFeature) {
@@ -72,7 +74,8 @@
7274
placeholder="Enter your branch name..."
7375
bind:value={stashBranchName}
7476
autofocus
75-
onslugifiedvalue={(value) => (slugifiedRefName = value)}
77+
onnormalizedvalue={(value) => (normalizedRefName = value)}
78+
onvalidationchange={(isValid) => (isStashBranchNameValid = isValid)}
7679
/>
7780
<div class="explanation">
7881
<p class="primary-text">
@@ -98,10 +101,10 @@
98101
<Button kind="outline" type="reset" onclick={close}>Cancel</Button>
99102
<AsyncButton
100103
style="pop"
101-
disabled={!slugifiedRefName}
104+
disabled={!isStashBranchNameValid}
102105
type="submit"
103106
action={async () => {
104-
if (isChangedFilesItem(item)) await confirmStashIntoBranch(item, slugifiedRefName);
107+
if (isChangedFilesItem(item)) await confirmStashIntoBranch(item, normalizedRefName);
105108
}}
106109
>
107110
Stash into branch

0 commit comments

Comments
 (0)