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 {