|
4 | 4 | import Button from '$lib/components/Button.svelte';
|
5 | 5 | import Checkbox from '$lib/components/Checkbox.svelte';
|
6 | 6 | import DropDownButton from '$lib/components/DropDownButton.svelte';
|
| 7 | + import Icon from '$lib/components/Icon.svelte'; |
7 | 8 | import ContextMenu from '$lib/components/contextmenu/ContextMenu.svelte';
|
8 | 9 | import ContextMenuItem from '$lib/components/contextmenu/ContextMenuItem.svelte';
|
9 | 10 | import ContextMenuSection from '$lib/components/contextmenu/ContextMenuSection.svelte';
|
|
12 | 13 | projectCommitGenerationExtraConcise,
|
13 | 14 | projectCommitGenerationUseEmojis,
|
14 | 15 | projectRunCommitHooks,
|
15 |
| - projectCurrentCommitMessage |
| 16 | + persistedCommitMessage |
16 | 17 | } from '$lib/config/config';
|
17 | 18 | import { persisted } from '$lib/persisted/persisted';
|
18 | 19 | import * as toasts from '$lib/utils/toasts';
|
19 | 20 | import { tooltip } from '$lib/utils/tooltip';
|
20 |
| - import { useAutoHeight } from '$lib/utils/useAutoHeight'; |
21 |
| - import { invoke } from '@tauri-apps/api/tauri'; |
| 21 | + import { setAutoHeight } from '$lib/utils/useAutoHeight'; |
| 22 | + import { useResize } from '$lib/utils/useResize'; |
22 | 23 | import { createEventDispatcher } from 'svelte';
|
23 | 24 | import { quintOut } from 'svelte/easing';
|
24 |
| - import { get } from 'svelte/store'; |
25 |
| - import { slide } from 'svelte/transition'; |
| 25 | + import { fly, slide } from 'svelte/transition'; |
26 | 26 | import type { User, getCloudApiClient } from '$lib/backend/cloud';
|
27 | 27 | import type { BranchController } from '$lib/vbranches/branchController';
|
28 | 28 | import type { Ownership } from '$lib/vbranches/ownership';
|
|
39 | 39 | export let cloud: ReturnType<typeof getCloudApiClient>;
|
40 | 40 | export let user: User | undefined;
|
41 | 41 | export let selectedOwnership: Writable<Ownership>;
|
| 42 | + export const expanded = persisted<boolean>(false, 'commitBoxExpanded_' + branch.id); |
42 | 43 |
|
43 | 44 | const aiGenEnabled = projectAiGenEnabled(projectId);
|
44 | 45 | const runCommitHooks = projectRunCommitHooks(projectId);
|
45 |
| - const currentCommitMessage = projectCurrentCommitMessage(projectId, branch.id); |
46 |
| - export const expanded = persisted<boolean>(false, 'commitBoxExpanded_' + branch.id); |
| 46 | + const commitMessage = persistedCommitMessage(projectId, branch.id); |
| 47 | + const commitGenerationExtraConcise = projectCommitGenerationExtraConcise(projectId); |
| 48 | + const commitGenerationUseEmojis = projectCommitGenerationUseEmojis(projectId); |
47 | 49 |
|
48 |
| - let commitMessage: string = get(currentCommitMessage) || ''; |
49 | 50 | let isCommitting = false;
|
50 |
| - let textareaElement: HTMLTextAreaElement; |
51 |
| - $: if (textareaElement && commitMessage && expanded) { |
52 |
| - textareaElement.style.height = 'auto'; |
53 |
| - textareaElement.style.height = `${textareaElement.scrollHeight + 2}px`; |
| 51 | + let aiLoading = false; |
| 52 | +
|
| 53 | + let contextMenu: ContextMenu; |
| 54 | + let summarizer: Summarizer | undefined; |
| 55 | +
|
| 56 | + let titleTextArea: HTMLTextAreaElement; |
| 57 | + let descriptionTextArea: HTMLTextAreaElement; |
| 58 | +
|
| 59 | + $: [title, description] = splitMessage($commitMessage); |
| 60 | + $: if ($commitMessage) updateHeights(); |
| 61 | + $: if (user) { |
| 62 | + const aiProvider = new ButlerAIProvider(cloud, user); |
| 63 | + summarizer = new Summarizer(aiProvider); |
54 | 64 | }
|
55 | 65 |
|
56 |
| - function focusTextareaOnMount(el: HTMLTextAreaElement) { |
57 |
| - if (el) el.focus(); |
| 66 | + function splitMessage(message: string) { |
| 67 | + const parts = message.split(/\n+(.*)/s); |
| 68 | + return [parts[0] || '', parts[1] || '']; |
58 | 69 | }
|
59 | 70 |
|
60 |
| - function commit() { |
61 |
| - if (!commitMessage) return; |
62 |
| - isCommitting = true; |
63 |
| - branchController |
64 |
| - .commitBranch(branch.id, commitMessage, $selectedOwnership.toString(), $runCommitHooks) |
65 |
| - .then(() => { |
66 |
| - commitMessage = ''; |
67 |
| - currentCommitMessage.set(''); |
68 |
| - }) |
69 |
| - .finally(() => (isCommitting = false)); |
| 71 | + function concatMessage(title: string, description: string) { |
| 72 | + return `${title}\n\n${description}`; |
70 | 73 | }
|
71 | 74 |
|
72 |
| - export function git_get_config(params: { key: string }) { |
73 |
| - return invoke<string>('git_get_global_config', params); |
| 75 | + function focusTextareaOnMount(el: HTMLTextAreaElement) { |
| 76 | + el.focus(); |
74 | 77 | }
|
75 | 78 |
|
76 |
| - let summarizer: Summarizer | undefined; |
77 |
| - $: if (user) { |
78 |
| - const aiProvider = new ButlerAIProvider(cloud, user); |
| 79 | + function updateHeights() { |
| 80 | + setAutoHeight(titleTextArea); |
| 81 | + setAutoHeight(descriptionTextArea); |
| 82 | + } |
79 | 83 |
|
80 |
| - summarizer = new Summarizer(aiProvider); |
| 84 | + async function commit() { |
| 85 | + const message = concatMessage(title, description); |
| 86 | + isCommitting = true; |
| 87 | + try { |
| 88 | + await branchController.commitBranch( |
| 89 | + branch.id, |
| 90 | + message.trim(), |
| 91 | + $selectedOwnership.toString(), |
| 92 | + $runCommitHooks |
| 93 | + ); |
| 94 | + $commitMessage = ''; |
| 95 | + } finally { |
| 96 | + isCommitting = false; |
| 97 | + } |
81 | 98 | }
|
82 | 99 |
|
83 |
| - let isGeneratingCommitMessage = false; |
84 | 100 | async function generateCommitMessage(files: LocalFile[]) {
|
| 101 | + if (!user || !summarizer) return; |
85 | 102 | const diff = files
|
86 | 103 | .map((f) => f.hunks.filter((h) => $selectedOwnership.containsHunk(f.id, h.id)))
|
87 | 104 | .flat()
|
|
90 | 107 | .join('\n')
|
91 | 108 | .slice(0, 5000);
|
92 | 109 |
|
93 |
| - if (!user) return; |
94 |
| - if (!summarizer) return; |
95 |
| -
|
96 | 110 | // Branches get their names generated only if there are at least 4 lines of code
|
97 | 111 | // If the change is a 'one-liner', the branch name is either left as "virtual branch"
|
98 | 112 | // or the user has to manually trigger the name generation from the meatball menu
|
|
101 | 115 | dispatch('action', 'generate-branch-name');
|
102 | 116 | }
|
103 | 117 |
|
104 |
| - isGeneratingCommitMessage = true; |
105 |
| - summarizer |
106 |
| - .commit(diff, $commitGenerationUseEmojis, $commitGenerationExtraConcise) |
107 |
| - .then((message) => { |
108 |
| - commitMessage = message; |
109 |
| - currentCommitMessage.set(message); |
110 |
| -
|
111 |
| - setTimeout(() => { |
112 |
| - textareaElement.focus(); |
113 |
| - }, 0); |
114 |
| - }) |
115 |
| - .catch(() => { |
116 |
| - toasts.error('Failed to generate commit message'); |
117 |
| - }) |
118 |
| - .finally(() => { |
119 |
| - isGeneratingCommitMessage = false; |
120 |
| - }); |
121 |
| - } |
122 |
| - const commitGenerationExtraConcise = projectCommitGenerationExtraConcise(projectId); |
123 |
| - const commitGenerationUseEmojis = projectCommitGenerationUseEmojis(projectId); |
| 118 | + aiLoading = true; |
| 119 | + try { |
| 120 | + $commitMessage = await summarizer.commit( |
| 121 | + diff, |
| 122 | + $commitGenerationUseEmojis, |
| 123 | + $commitGenerationExtraConcise |
| 124 | + ); |
| 125 | + } catch { |
| 126 | + toasts.error('Failed to generate commit message'); |
| 127 | + } finally { |
| 128 | + aiLoading = false; |
| 129 | + } |
124 | 130 |
|
125 |
| - let contextMenu: ContextMenu; |
| 131 | + setTimeout(() => { |
| 132 | + updateHeights(); |
| 133 | + descriptionTextArea.focus(); |
| 134 | + }, 0); |
| 135 | + } |
126 | 136 | </script>
|
127 | 137 |
|
128 | 138 | <div class="commit-box" class:commit-box__expanded={$expanded}>
|
129 | 139 | {#if $expanded}
|
130 | 140 | <div class="commit-box__expander" transition:slide={{ duration: 150, easing: quintOut }}>
|
131 |
| - <div class="commit-box__textarea-wrapper"> |
| 141 | + <div class="commit-box__textarea-wrapper text-input"> |
132 | 142 | <textarea
|
133 |
| - bind:this={textareaElement} |
134 |
| - bind:value={commitMessage} |
| 143 | + value={title} |
| 144 | + placeholder="Commit summary" |
| 145 | + disabled={aiLoading} |
| 146 | + class="text-base-body-13 text-semibold commit-box__textarea commit-box__textarea__title" |
| 147 | + class:commit-box__textarea_bottom-padding={title.length == 0 && description.length == 0} |
| 148 | + spellcheck="false" |
| 149 | + rows="1" |
| 150 | + bind:this={titleTextArea} |
135 | 151 | use:focusTextareaOnMount
|
136 |
| - on:input={useAutoHeight} |
137 |
| - on:focus={useAutoHeight} |
138 |
| - on:change={() => currentCommitMessage.set(commitMessage)} |
| 152 | + use:useResize={() => { |
| 153 | + setAutoHeight(titleTextArea); |
| 154 | + setAutoHeight(descriptionTextArea); |
| 155 | + }} |
| 156 | + on:focus={(e) => setAutoHeight(e.currentTarget)} |
| 157 | + on:input={(e) => { |
| 158 | + $commitMessage = concatMessage(e.currentTarget.value, description); |
| 159 | + }} |
139 | 160 | on:keydown={(e) => {
|
140 |
| - if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { |
141 |
| - commit(); |
| 161 | + if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') commit(); |
| 162 | + if (e.key === 'Tab' || e.key === 'Enter') { |
| 163 | + e.preventDefault(); |
| 164 | + descriptionTextArea.focus(); |
142 | 165 | }
|
143 | 166 | }}
|
144 |
| - spellcheck={false} |
145 |
| - class="text-input text-base-body-13 commit-box__textarea" |
146 |
| - rows="1" |
147 |
| - disabled={isGeneratingCommitMessage} |
148 |
| - placeholder="Your commit message here" |
149 | 167 | />
|
150 | 168 |
|
| 169 | + {#if title.length > 0} |
| 170 | + <textarea |
| 171 | + value={description} |
| 172 | + disabled={aiLoading} |
| 173 | + placeholder="Commit description (optional)" |
| 174 | + class="text-base-body-13 commit-box__textarea commit-box__textarea__description" |
| 175 | + class:commit-box__textarea_bottom-padding={description.length > 0 || title.length > 0} |
| 176 | + spellcheck="false" |
| 177 | + rows="1" |
| 178 | + bind:this={descriptionTextArea} |
| 179 | + on:focus={(e) => setAutoHeight(e.currentTarget)} |
| 180 | + on:input={(e) => { |
| 181 | + $commitMessage = concatMessage(title, e.currentTarget.value); |
| 182 | + }} |
| 183 | + on:keydown={(e) => { |
| 184 | + const value = e.currentTarget.value; |
| 185 | + if (e.key == 'Backspace' && value.length == 0) { |
| 186 | + e.preventDefault(); |
| 187 | + titleTextArea.focus(); |
| 188 | + setAutoHeight(e.currentTarget); |
| 189 | + } else if (e.key == 'a' && (e.metaKey || e.ctrlKey) && value.length == 0) { |
| 190 | + // select previous textarea on cmd+a if this textarea is empty |
| 191 | + e.preventDefault(); |
| 192 | + titleTextArea.select(); |
| 193 | + } |
| 194 | + }} |
| 195 | + /> |
| 196 | + {/if} |
| 197 | + |
| 198 | + {#if title.length > 50} |
| 199 | + <div |
| 200 | + transition:fly={{ y: 2, duration: 150 }} |
| 201 | + class="commit-box__textarea-tooltip" |
| 202 | + use:tooltip={{ |
| 203 | + text: '50 characters or less is best. Extra info can be added in the description.', |
| 204 | + delay: 200 |
| 205 | + }} |
| 206 | + > |
| 207 | + <Icon name="blitz" /> |
| 208 | + </div> |
| 209 | + {/if} |
| 210 | + |
151 | 211 | <div
|
152 | 212 | class="commit-box__texarea-actions"
|
153 | 213 | use:tooltip={$aiGenEnabled && user
|
|
159 | 219 | icon="ai-small"
|
160 | 220 | color="neutral"
|
161 | 221 | disabled={!$aiGenEnabled || !user}
|
162 |
| - loading={isGeneratingCommitMessage} |
| 222 | + loading={aiLoading} |
163 | 223 | on:click={() => generateCommitMessage(branch.files)}
|
164 | 224 | >
|
165 | 225 | Generate message
|
|
203 | 263 | color="primary"
|
204 | 264 | kind="filled"
|
205 | 265 | loading={isCommitting}
|
206 |
| - disabled={(isCommitting || !commitMessage || $selectedOwnership.isEmpty()) && $expanded} |
| 266 | + disabled={(isCommitting || !title || $selectedOwnership.isEmpty()) && $expanded} |
207 | 267 | id="commit-to-branch"
|
208 | 268 | on:click={() => {
|
209 | 269 | if ($expanded) {
|
|
228 | 288 | transition: background-color var(--transition-medium);
|
229 | 289 | border-radius: 0 0 var(--radius-m) var(--radius-m);
|
230 | 290 | }
|
| 291 | +
|
231 | 292 | .commit-box__expander {
|
232 | 293 | display: flex;
|
233 | 294 | flex-direction: column;
|
234 | 295 | margin-bottom: var(--space-12);
|
235 | 296 | }
|
| 297 | +
|
236 | 298 | .commit-box__textarea-wrapper {
|
237 | 299 | position: relative;
|
238 | 300 | display: flex;
|
239 | 301 | flex-direction: column;
|
| 302 | + gap: var(--space-4); |
| 303 | + padding: 0; |
240 | 304 | }
|
| 305 | +
|
241 | 306 | .commit-box__textarea {
|
242 | 307 | overflow: hidden;
|
243 | 308 | display: flex;
|
244 | 309 | flex-direction: column;
|
245 |
| -
|
246 |
| - padding: var(--space-12) var(--space-12) var(--space-48) var(--space-12); |
247 | 310 | align-items: flex-end;
|
248 | 311 | gap: var(--space-16);
|
249 |
| -
|
| 312 | + background: none; |
250 | 313 | resize: none;
|
| 314 | + &:focus { |
| 315 | + outline: none; |
| 316 | + } |
251 | 317 | }
|
| 318 | +
|
| 319 | + .commit-box__textarea-tooltip { |
| 320 | + position: absolute; |
| 321 | + display: flex; |
| 322 | + bottom: var(--space-12); |
| 323 | + left: var(--space-12); |
| 324 | + padding: var(--space-2); |
| 325 | + border-radius: 100%; |
| 326 | + background: var(--clr-theme-container-pale); |
| 327 | + color: var(--clr-theme-scale-ntrl-40); |
| 328 | + } |
| 329 | +
|
| 330 | + .commit-box__textarea__title { |
| 331 | + padding: var(--space-12) var(--space-12) 0 var(--space-12); |
| 332 | + } |
| 333 | +
|
| 334 | + .commit-box__textarea__description { |
| 335 | + padding: 0 var(--space-12) var(--space-12) var(--space-12); |
| 336 | + } |
| 337 | +
|
| 338 | + .commit-box__textarea_bottom-padding { |
| 339 | + padding-bottom: var(--space-48); |
| 340 | + } |
| 341 | +
|
252 | 342 | .commit-box__texarea-actions {
|
253 | 343 | position: absolute;
|
254 | 344 | display: flex;
|
|
262 | 352 | gap: var(--space-6);
|
263 | 353 | }
|
264 | 354 |
|
265 |
| - /* modifiers */ |
266 | 355 | .commit-box__expanded {
|
267 | 356 | background-color: var(--clr-theme-container-pale);
|
268 | 357 | }
|
|
0 commit comments