diff --git a/src/App.test.tsx b/src/App.test.tsx index 040f758..5a4028e 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -4687,6 +4687,45 @@ describe("Git commit sidebar", () => { expect(commitButton).toBeDisabled(); }); + it("explains why Cmd+Enter did nothing and clears the hint once the user acts", async () => { + render(); + + expect(await treeButton("README.md")).toBeInTheDocument(); + fireEvent.click(screen.getByTitle("Source control")); + + const panel = await screen.findByLabelText("Git commit panel"); + await waitFor(() => expect(within(panel).getByText("2 / 2")).toBeInTheDocument()); + const message = within(panel).getByPlaceholderText("Commit message"); + + fireEvent.click(within(panel).getByLabelText("Deselect all changes")); + fireEvent.change(message, { target: { value: "Update readme" } }); + fireEvent.keyDown(message, { key: "Enter", metaKey: true }); + expect( + await within(panel).findByText("Select at least one file to commit."), + ).toBeInTheDocument(); + + fireEvent.click(within(panel).getByLabelText("Select all changes")); + await waitFor(() => + expect( + within(panel).queryByText("Select at least one file to commit."), + ).not.toBeInTheDocument(), + ); + + fireEvent.change(message, { target: { value: " " } }); + // Ctrl instead of Cmd here so both halves of metaKey || ctrlKey get covered. + fireEvent.keyDown(message, { key: "Enter", ctrlKey: true }); + expect( + await within(panel).findByText("Enter a commit message first."), + ).toBeInTheDocument(); + + fireEvent.change(message, { target: { value: "Update readme" } }); + await waitFor(() => + expect( + within(panel).queryByText("Enter a commit message first."), + ).not.toBeInTheDocument(), + ); + }); + it("commits only the selected paths and refreshes the status afterward", async () => { render(); diff --git a/src/App.tsx b/src/App.tsx index 923ae7f..1ea6d31 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -688,6 +688,9 @@ export default function App() { const [gitCommitInFlight, setGitCommitInFlight] = useState(false); const [gitCommitError, setGitCommitError] = useState(); const [gitCommitSuccess, setGitCommitSuccess] = useState(); + // Why-nothing-happened hint for Cmd/Ctrl+Enter in the message box — the + // Commit button disables itself, but the shortcut has no visual state. + const [gitCommitHint, setGitCommitHint] = useState(); const [gitSyncInFlight, setGitSyncInFlight] = useState(false); const [gitSyncResult, setGitSyncResult] = useState(); const [gitSyncError, setGitSyncError] = useState(); @@ -1652,6 +1655,19 @@ export default function App() { return () => window.clearTimeout(timeoutId); }, [gitCommitSuccess]); + // The commit hint answers "why did Cmd+Enter do nothing" — the moment the + // user changes the message or the file selection, the answer is stale. + // Keyed on a sorted-path signature rather than the Set itself: background + // status polls rebuild the Set with identical contents, and clearing on + // reference identity would dismiss the hint without any user action. + const gitCommitSelectionSignature = useMemo( + () => [...gitCommitSelectedPaths].sort().join("\n"), + [gitCommitSelectedPaths], + ); + useEffect(() => { + setGitCommitHint(undefined); + }, [gitCommitMessage, gitCommitSelectionSignature]); + // Sync notices are scoped to a commit-panel session; clear them on the way out // so reopening the panel doesn't show a stale "Synced"/conflict line. useEffect(() => { @@ -1660,6 +1676,7 @@ export default function App() { setGitSyncError(undefined); setGitMergeError(undefined); setGitMergeSuccess(undefined); + setGitCommitHint(undefined); }, [commitModeActive]); // Clear the "Completed merge" line after a beat, matching the commit-success @@ -1736,8 +1753,24 @@ export default function App() { const handleGitCommit = useCallback(async () => { const trimmedMessage = gitCommitMessage.trim(); const selectedPaths = changedFilePaths.filter((path) => gitCommitSelectedPaths.has(path)); - if (!trimmedMessage || selectedPaths.length === 0 || gitCommitInFlight) return; + if (gitCommitInFlight) return; + // A failed precondition also clears the previous attempt's outcome lines — + // a leftover "Committed …" or error next to the warning reads as if this + // attempt did something. + if (selectedPaths.length === 0) { + setGitCommitError(undefined); + setGitCommitSuccess(undefined); + setGitCommitHint("Select at least one file to commit."); + return; + } + if (!trimmedMessage) { + setGitCommitError(undefined); + setGitCommitSuccess(undefined); + setGitCommitHint("Enter a commit message first."); + return; + } + setGitCommitHint(undefined); setGitCommitInFlight(true); setGitCommitError(undefined); setGitCommitSuccess(undefined); @@ -5453,6 +5486,12 @@ export default function App() { {gitCommitSuccess} ) : null} + {gitCommitHint ? ( +
+ + {gitCommitHint} +
+ ) : null} )} diff --git a/src/styles.css b/src/styles.css index 8a9efc8..86c0046 100644 --- a/src/styles.css +++ b/src/styles.css @@ -976,6 +976,20 @@ button { color: var(--text); } +.commit-panel__notice--warning { + display: flex; + align-items: center; + gap: 6px; + border: 1px solid color-mix(in oklch, var(--warning) 42%, var(--border)); + background: color-mix(in oklch, var(--warning) 8%, var(--surface)); + color: var(--text); +} + +.commit-panel__notice--warning svg { + flex-shrink: 0; + color: var(--warning); +} + /* Transient success toasts (sync result, completed merge) hold, then ease out over the last stretch of their ~5s life so removal doesn't just blink away. The JS timeout clears the element at 5s; this only animates the opacity. */ @@ -1110,19 +1124,6 @@ button { font-weight: 600; } -.commit-panel__sync-conflicts { - margin: 0; - padding-left: 18px; - display: flex; - flex-direction: column; - gap: 2px; -} - -.commit-panel__sync-conflicts li { - overflow-wrap: anywhere; - word-break: break-word; -} - /* Live merge-resolution block: a bordered call-out that replaces the normal sync notice while a merge is unfinished. */ .commit-panel__merge {