From df635a7437ff84c807ef9160c87c4d4dfdb39013 Mon Sep 17 00:00:00 2001 From: Amey Pawar <138877912+ameyypawar@users.noreply.github.com> Date: Sun, 21 Jun 2026 23:29:49 +0530 Subject: [PATCH 1/2] fix(desktop): preserve commit reword drafts across navigation Reword (edit commit message) drafts lived only in the editor component's local state, so switching context unmounted the editor and discarded the draft. New-commit drafts already survive via `lane.newCommitMessage`. Persist the reword draft per-commit in lane UI state: seed the editor from it, save it on every change, and clear it on save or explicit cancel. The draft now survives navigating away and back; only Cancel discards it. Fixes #13287 --- .../src/lib/commits/editCommitDraft.test.ts | 60 +++++++++++++++++++ .../src/lib/commits/editCommitDraft.ts | 44 ++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 apps/desktop/src/lib/commits/editCommitDraft.test.ts create mode 100644 apps/desktop/src/lib/commits/editCommitDraft.ts diff --git a/apps/desktop/src/lib/commits/editCommitDraft.test.ts b/apps/desktop/src/lib/commits/editCommitDraft.test.ts new file mode 100644 index 00000000000..3287f158d6f --- /dev/null +++ b/apps/desktop/src/lib/commits/editCommitDraft.test.ts @@ -0,0 +1,60 @@ +import { applyEditDraftUpdate, resolveEditDraft } from "$lib/commits/editCommitDraft"; +import { describe, expect, test } from "vitest"; + +const original = { title: "Original title", description: "Original body" }; + +describe("resolveEditDraft", () => { + test("uses the original message when there is no draft", () => { + expect(resolveEditDraft(undefined, "c1", original)).toEqual(original); + }); + + test("uses the original message when the draft is for another commit", () => { + const draft = { commitId: "c2", title: "Other", description: "Other body" }; + expect(resolveEditDraft(draft, "c1", original)).toEqual(original); + }); + + test("uses the draft when it belongs to the commit", () => { + const draft = { commitId: "c1", title: "Draft title", description: "Draft body" }; + expect(resolveEditDraft(draft, "c1", original)).toEqual({ + title: "Draft title", + description: "Draft body", + }); + }); +}); + +describe("applyEditDraftUpdate", () => { + test("seeds from the original message on the first change", () => { + expect(applyEditDraftUpdate(undefined, "c1", original, { title: "New title" })).toEqual({ + commitId: "c1", + title: "New title", + description: "Original body", + }); + }); + + test("merges a description-only change, keeping the existing title", () => { + const draft = { commitId: "c1", title: "A", description: "B" }; + expect(applyEditDraftUpdate(draft, "c1", original, { description: "B2" })).toEqual({ + commitId: "c1", + title: "A", + description: "B2", + }); + }); + + test("restarts from the original when the draft is for another commit", () => { + const draft = { commitId: "c2", title: "A", description: "B" }; + expect(applyEditDraftUpdate(draft, "c1", original, { title: "New" })).toEqual({ + commitId: "c1", + title: "New", + description: "Original body", + }); + }); + + test("keeps an explicitly cleared (empty) field", () => { + const draft = { commitId: "c1", title: "A", description: "B" }; + expect(applyEditDraftUpdate(draft, "c1", original, { title: "" })).toEqual({ + commitId: "c1", + title: "", + description: "B", + }); + }); +}); diff --git a/apps/desktop/src/lib/commits/editCommitDraft.ts b/apps/desktop/src/lib/commits/editCommitDraft.ts new file mode 100644 index 00000000000..b6d31b98e69 --- /dev/null +++ b/apps/desktop/src/lib/commits/editCommitDraft.ts @@ -0,0 +1,44 @@ +/** + * In-progress reword draft for a single commit. Persisted in lane UI state so it + * survives navigating away from the commit and back, instead of living only in + * the editor component's local state. See issue #13287. + */ +export type EditCommitMessage = { + commitId: string; + title: string; + description: string; +}; + +/** + * Picks the title/description to seed the reword editor with: the persisted + * draft when it belongs to `commitId`, otherwise the commit's own message. + */ +export function resolveEditDraft( + draft: EditCommitMessage | undefined, + commitId: string, + original: { title: string; description: string }, +): { title: string; description: string } { + if (draft?.commitId === commitId) { + return { title: draft.title, description: draft.description }; + } + return original; +} + +/** + * Merges a partial editor change into the draft for `commitId`. The editor emits + * one field at a time, so the untouched field is carried over from the existing + * draft, or seeded from the original message when starting fresh. + */ +export function applyEditDraftUpdate( + draft: EditCommitMessage | undefined, + commitId: string, + original: { title: string; description: string }, + update: { title?: string; description?: string }, +): EditCommitMessage { + const base = draft?.commitId === commitId ? draft : { commitId, ...original }; + return { + commitId, + title: update.title ?? base.title, + description: update.description ?? base.description, + }; +} From 65a450782c3e523d72ba00dad0373977dfd022f7 Mon Sep 17 00:00:00 2001 From: Amey Pawar <138877912+ameyypawar@users.noreply.github.com> Date: Sun, 21 Jun 2026 23:32:46 +0530 Subject: [PATCH 2/2] fix(desktop): persist reword draft in lane state and wire CommitView Add a per-commit `editCommitMessage` slot to lane UI state, and make CommitView seed the reword editor from it, persist on every change, and clear it on save / explicit cancel. Fixes #13287 --- .../src/components/commit/CommitView.svelte | 28 +++++++++++++++++-- apps/desktop/src/lib/state/uiState.svelte.ts | 3 ++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/components/commit/CommitView.svelte b/apps/desktop/src/components/commit/CommitView.svelte index 189a4329fea..a82549c5c8d 100644 --- a/apps/desktop/src/components/commit/CommitView.svelte +++ b/apps/desktop/src/components/commit/CommitView.svelte @@ -6,6 +6,7 @@ import { isLocalAndRemoteCommit } from "$components/lib"; import Drawer from "$components/shared/Drawer.svelte"; import { splitMessage } from "$lib/commits/commitMessage"; + import { applyEditDraftUpdate, resolveEditDraft } from "$lib/commits/editCommitDraft"; import { rewrapCommitMessage } from "$lib/config/uiFeatureFlags"; import { commitUrl, FORGE_INFO_SERVICE } from "$lib/forge/forgeInfo.svelte"; import { MODE_SERVICE } from "$lib/mode/modeService"; @@ -81,6 +82,20 @@ const parsedMessage = $derived(splitMessage(commit.message)); + const originalMessage = $derived({ + title: parsedMessage?.title || "", + description: parsedMessage?.description || "", + }); + const editorSeed = $derived( + resolveEditDraft(laneState.editCommitMessage.current, commit.id, originalMessage), + ); + + function persistDraft(update: { title?: string; description?: string }) { + laneState.editCommitMessage.set( + applyEditDraftUpdate(laneState.editCommitMessage.current, commit.id, originalMessage, update), + ); + } + function combineParts(title?: string, description?: string): string { if (!title) { return ""; @@ -112,6 +127,7 @@ if (stackId) { uiState.lane(stackId).selection.set({ branchName, commitId: newCommitId, previewOpen: true }); } + laneState.editCommitMessage.set(undefined); setMode("view"); } @@ -140,6 +156,11 @@ function cancelEdit() { setMode("view"); } + + function discardEdit() { + laneState.editCommitMessage.set(undefined); + setMode("view"); + } saveCommitMessage(title, description)} actionLabel="Save changes" - onCancel={cancelEdit} + onChange={persistDraft} + onCancel={discardEdit} floatingBoxHeader="Reword commit" loading={messageUpdateQuery.current.isLoading} existingCommitId={commit.id} - title={parsedMessage?.title || ""} - description={parsedMessage?.description || ""} + title={editorSeed.title} + description={editorSeed.description} /> {:else} diff --git a/apps/desktop/src/lib/state/uiState.svelte.ts b/apps/desktop/src/lib/state/uiState.svelte.ts index b9d0153a28b..92b3d3775e6 100644 --- a/apps/desktop/src/lib/state/uiState.svelte.ts +++ b/apps/desktop/src/lib/state/uiState.svelte.ts @@ -3,6 +3,7 @@ import { InjectionToken } from "@gitbutler/core/context"; import { reactive } from "@gitbutler/shared/reactiveUtils.svelte"; import { type Reactive } from "@gitbutler/shared/storeUtils"; import { createEntityAdapter, createSlice, type EntityState } from "@reduxjs/toolkit"; +import type { EditCommitMessage } from "$lib/commits/editCommitDraft"; import type { TerminalService } from "$lib/settings/terminalService"; import type { AppDispatch } from "$lib/state/clientState.svelte"; import type { ScrollbarVisilitySettings } from "@gitbutler/ui"; @@ -50,6 +51,7 @@ export type NewCommitMessage = { export type StackState = { selection: StackSelection | undefined; newCommitMessage: NewCommitMessage; + editCommitMessage: EditCommitMessage | undefined; }; export type ExclusiveAction = @@ -217,6 +219,7 @@ export class UiState { readonly lane = this.buildScopedProps(this.scopesCache.lanes, { selection: undefined, newCommitMessage: { title: "", description: "" }, + editCommitMessage: undefined, }); /** Properties scoped to a specific project. */