diff --git a/docs/GIT_WORKFLOW.md b/docs/GIT_WORKFLOW.md index 5d86b18..7354b64 100644 --- a/docs/GIT_WORKFLOW.md +++ b/docs/GIT_WORKFLOW.md @@ -48,7 +48,7 @@ Edit → Validate → Commit → Push → (optional) Deploy → (optional) Watch ## Implemented provider foundation (PR #4) -The low-level `GitProvider` supports `inspectBranch`, `commit`, `push`, and `getCommit` for all three smart-HTTP adapters. `GitWorkflowService` provides the application/API layer below. The final Changes UI is separate work. +The low-level `GitProvider` supports `inspectBranch`, `commit`, `push`, and `getCommit` for all three smart-HTTP adapters. `GitWorkflowService` provides the application/API layer. The Changes tab commits selected drafts and can push or retry a retained commit. ## Application API contract (v0.5.0) diff --git a/docs/getting-started.md b/docs/getting-started.md index 1042ad9..9897586 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -22,8 +22,8 @@ saved tokens, the audit log, your Git author identity, password and sessions. stack-manager works with GitHub, Gitea and Forgejo over HTTPS. -1. Create an access token on your Git server for the repository. Read access is enough for now; write access will be - needed once committing from stack-manager is available. +1. Create an access token on your Git server for the repository. Read access is enough to browse and draft; write + access is needed to push commits from the **Changes** tab. 2. In stack-manager, choose **Connect repository** and fill in: - **Git provider** and the **HTTPS clone URL**. Never put the token in the URL. - **Access token** (or a saved credential), plus a **username** only if your server requires one with the token. @@ -80,7 +80,7 @@ Each stack has these tabs: | **Editor** | Browse files in the explorer, open them in tabs and edit with syntax highlighting. Compose files are validated as you type and issues appear in the problems panel. **Save draft** (Ctrl+S / ⌘S) keeps your edit on the server. | | **Docs** | Read the stack's Markdown files rendered (README first), follow links between them and edit them as drafts. | | **Environment** | Variables the Compose file references, whether `.env.example` documents them, env files it expects, and values that look like hard-coded secrets. Values are never shown. | -| **Changes** | Every draft as a line-by-line diff against the fetched commit. Discard the drafts you don't want. | +| **Changes** | Every draft as a line-by-line diff against the fetched commit. Select files, commit with your Account Git identity, and push when ready. Discard drafts you don't want. | | **Settings** | Rename the stack, change its Compose file or remove it (files and drafts are kept). | Files that look like secrets (`.env`, private keys, certificates, anything in a `secrets/` folder) are listed but @@ -91,7 +91,8 @@ can't be opened or edited. Drafts are stored in stack-manager's database and never leave the server. If the file changes in Git after you start a draft, the editor warns you, so you don't overwrite someone else's change unknowingly. -Committing drafts, safe push and Git history ship in v0.5.0; see the [roadmap](plans/MVP_PLAN.md). +Set your **Git identity** under Settings → Account before the first commit. After a successful push, drafts stay +on the server until you discard them — fetch the repository if the remote moved ahead. ## 5. Files outside stacks diff --git a/playwright.config.ts b/playwright.config.ts index 160d086..01c71e1 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -19,16 +19,31 @@ export default defineConfig({ { name: 'desktop', testMatch: /\.spec\.ts/, + // Draft-mutating Git workflow tests share the sample stack; run them after source/shell. + testIgnore: /changes-commit\.spec\.ts/, dependencies: ['onboarding'], use: { ...devices['Desktop Chrome'], viewport: { width: 1280, height: 860 } }, }, { name: 'mobile', testMatch: /\.spec\.ts/, + testIgnore: /changes-commit\.spec\.ts/, dependencies: ['onboarding'], // Pixel 7 is Chromium-based, so no WebKit download is needed. use: { ...devices['Pixel 7'] }, }, + { + name: 'git-workflow', + testMatch: /changes-commit\.spec\.ts/, + dependencies: ['desktop', 'mobile'], + use: { ...devices['Desktop Chrome'], viewport: { width: 1280, height: 860 } }, + }, + { + name: 'git-workflow-mobile', + testMatch: /changes-commit\.spec\.ts/, + dependencies: ['git-workflow'], + use: { ...devices['Pixel 7'] }, + }, ], webServer: process.env.E2E_BASE_URL ? undefined diff --git a/src/app/styles/components.css b/src/app/styles/components.css index 2676583..9a86f6f 100644 --- a/src/app/styles/components.css +++ b/src/app/styles/components.css @@ -398,7 +398,9 @@ button.list-row { input[type='text'], input[type='password'], input[type='url'], +input[type='email'], input[type='search'], +textarea, select { font: inherit; width: 100%; @@ -410,6 +412,12 @@ select { border-radius: var(--radius-sm); } +textarea { + min-height: 5.5rem; + resize: vertical; + line-height: 1.45; +} + input[aria-invalid='true'] { border-color: var(--danger); } diff --git a/src/app/styles/source.css b/src/app/styles/source.css index 97ec3d4..69998d9 100644 --- a/src/app/styles/source.css +++ b/src/app/styles/source.css @@ -740,6 +740,42 @@ border-bottom: 1px solid var(--border); } +.changes-summary { + font-size: 0.95rem; +} + +.focus-target:focus { + outline: none; +} + +.commit-panel .commit-form { + padding: var(--space-3) var(--space-4) var(--space-4); +} + +.commit-actions { + padding-block: var(--space-2) 0; +} + +.change-select { + flex: 1; + min-width: 0; +} + +.problem-list { + margin: 0.35rem 0 0; + padding-left: 1.2rem; +} + +.alert .actions { + margin-top: var(--space-3); +} + +@media (max-width: 900px) { + .commit-actions .btn { + flex: 1 1 100%; + } +} + /* ---------- Narrow screens ---------- */ @media (max-width: 900px) { .ide { diff --git a/src/app/w/[workspaceId]/repositories/[repositoryId]/changes/page.tsx b/src/app/w/[workspaceId]/repositories/[repositoryId]/changes/page.tsx index 8386772..1259548 100644 --- a/src/app/w/[workspaceId]/repositories/[repositoryId]/changes/page.tsx +++ b/src/app/w/[workspaceId]/repositories/[repositoryId]/changes/page.tsx @@ -1,7 +1,8 @@ import type { Metadata } from 'next'; import { getContainer } from '@/server/container'; import { ChangesView } from '@/ui/source/changes-view'; -import { NotFetched, repositorySourceApi } from '../../../../../_lib/repository-source'; +import { requireSession } from '@/app/_lib/session'; +import { NotFetched, repositorySourceApi } from '@/app/_lib/repository-source'; export const metadata: Metadata = { title: 'Changes' }; @@ -12,18 +13,26 @@ export default async function RepositoryChangesPage({ params: Promise<{ workspaceId: string; repositoryId: string }>; }) { const { workspaceId, repositoryId } = await params; - const { repositories, source } = getContainer(); + const session = await requireSession(); + const { repositories, source, auth } = getContainer(); const repo = await repositories.get(workspaceId, repositoryId); if (!repo.headSha) return ; - const changes = await source.changes(workspaceId, repositoryId, ''); + const [changes, identity] = await Promise.all([ + source.changes(workspaceId, repositoryId, ''), + auth.getGitIdentity(session.user.id), + ]); return (
); diff --git a/src/app/w/[workspaceId]/stacks/[stackId]/changes/page.tsx b/src/app/w/[workspaceId]/stacks/[stackId]/changes/page.tsx index 68c34ae..3db8934 100644 --- a/src/app/w/[workspaceId]/stacks/[stackId]/changes/page.tsx +++ b/src/app/w/[workspaceId]/stacks/[stackId]/changes/page.tsx @@ -1,6 +1,7 @@ import type { Metadata } from 'next'; import { getContainer } from '@/server/container'; import { ChangesView } from '@/ui/source/changes-view'; +import { requireSession } from '@/app/_lib/session'; export const metadata: Metadata = { title: 'Changes' }; @@ -10,16 +11,25 @@ export default async function StackChangesPage({ params: Promise<{ workspaceId: string; stackId: string }>; }) { const { workspaceId, stackId } = await params; - const { stacks, source } = getContainer(); + const session = await requireSession(); + const { stacks, source, auth } = getContainer(); const stack = await stacks.get(workspaceId, stackId); - const changes = await source.changes(workspaceId, stack.repository.id, stack.rootPath); + const [changes, identity] = await Promise.all([ + source.changes(workspaceId, stack.repository.id, stack.rootPath), + auth.getGitIdentity(session.user.id), + ]); return ( ); } diff --git a/src/ui/api.ts b/src/ui/api.ts index f78c17b..8ac7db9 100644 --- a/src/ui/api.ts +++ b/src/ui/api.ts @@ -1,5 +1,7 @@ 'use client'; +import type { GitWorkflowResult } from '@/shared/git-workflow'; + export class ApiError extends Error { constructor( readonly status: number, @@ -11,8 +13,7 @@ export class ApiError extends Error { } } -/** Same-origin JSON fetch. The session cookie is HTTP-only and sent automatically. */ -export async function api(path: string, init: { method?: string; body?: unknown } = {}): Promise { +async function requestJson(path: string, init: { method?: string; body?: unknown } = {}) { const res = await fetch(path, { method: init.method ?? 'GET', headers: init.body === undefined ? undefined : { 'Content-Type': 'application/json' }, @@ -20,16 +21,48 @@ export async function api(path: string, init: { method?: string; body?: unkno credentials: 'same-origin', cache: 'no-store', }); - const data = await res.json().catch(() => ({})); - if (!res.ok) { - const err = (data as { error?: { code?: string; message?: string; fields?: Record } }) - .error; - throw new ApiError( - res.status, - err?.code ?? 'error', - err?.message ?? `Request failed (${res.status}).`, - err?.fields, - ); - } + const data: unknown = await res.json().catch(() => ({})); + return { res, data }; +} + +function throwApiError(status: number, data: unknown): never { + const err = (data as { error?: { code?: string; message?: string; fields?: Record } }) + .error; + throw new ApiError( + status, + err?.code ?? 'error', + err?.message ?? `Request failed (${status}).`, + err?.fields, + ); +} + +/** Same-origin JSON fetch. The session cookie is HTTP-only and sent automatically. */ +export async function api(path: string, init: { method?: string; body?: unknown } = {}): Promise { + const { res, data } = await requestJson(path, init); + if (!res.ok) throwApiError(res.status, data); return data as T; } + +function isGitWorkflowResult(data: unknown): data is GitWorkflowResult { + return ( + !!data && + typeof data === 'object' && + 'status' in data && + 'draftsPreserved' in data && + (data as GitWorkflowResult).draftsPreserved === true + ); +} + +/** + * Git workflow routes return a structured `GitWorkflowResult` for both success and + * expected conflict/validation outcomes (409/422/502). Malformed requests still use the error envelope. + */ +export async function gitWorkflow( + path: string, + init: { method?: string; body?: unknown } = {}, +): Promise { + const { res, data } = await requestJson(path, init); + if (isGitWorkflowResult(data)) return data; + if (!res.ok) throwApiError(res.status, data); + throw new ApiError(res.status, 'error', 'Unexpected Git workflow response.'); +} diff --git a/src/ui/source/changes-view.tsx b/src/ui/source/changes-view.tsx index 38dc69d..74468a7 100644 --- a/src/ui/source/changes-view.tsx +++ b/src/ui/source/changes-view.tsx @@ -4,15 +4,24 @@ import { structuredPatch } from 'diff'; import { GitCompareArrows } from 'lucide-react'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; -import { useState } from 'react'; +import { useEffect, useId, useMemo, useRef, useState } from 'react'; +import type { GitWorkflowProblem, GitWorkflowResult } from '@/shared/git-workflow'; import { relativeTo } from '@/shared/source/paths'; +import { problemsFor } from '@/shared/source/problems'; import type { ChangeView } from '@/shared/source/types'; -import { api, ApiError } from '../api'; +import { api, ApiError, gitWorkflow } from '../api'; import { LocalTime } from '../local-time'; import { Alert } from '../primitives/alert'; +import { Button, ButtonLink } from '../primitives/button'; import { ConfirmButton } from '../primitives/confirm-button'; import { EmptyState } from '../primitives/empty-state'; +type GitIdentity = { name: string; email: string }; + +function shortSha(sha: string) { + return sha.slice(0, 7); +} + function Diff({ before, after }: { before: string; after: string }) { const patch = structuredPatch('before', 'after', before, after, '', '', { context: 3 }); if (patch.hunks.length === 0) return

No textual changes.

; @@ -60,34 +69,331 @@ function Diff({ before, after }: { before: string; after: string }) { ); } -/** Review of draft changes in a stack, as unified diffs against the version each draft started from. */ +function problemLabel(problem: GitWorkflowProblem) { + if (problem.code === 'hardcoded-secret') return 'Hard-coded secret'; + if (problem.severity === 'error') return 'Error'; + if (problem.severity === 'warning') return 'Warning'; + return 'Info'; +} + +function WorkflowAlert(props: { + result: GitWorkflowResult; + editorHref: string; + rootPath: string; + repositoryHref: string; + identityHref: string; + onAcknowledge?: () => void; +}) { + const { result } = props; + const fileLink = (path: string) => { + const rel = relativeTo(props.rootPath, path) || path; + return `${props.editorHref}?file=${encodeURIComponent(rel)}`; + }; + + if (result.status === 'push_succeeded' && result.commitSha) { + return ( + + Drafts are still on this server — review or discard them after you confirm the remote looks right. + + ); + } + if (result.status === 'commit_succeeded' && result.commitSha) { + return ( + + Drafts remain until you discard them. You can push this commit when ready. + + ); + } + if (result.status === 'validation_blocked') { + return ( + +
    + {result.problems + .filter((p) => p.severity === 'error') + .map((p, i) => ( +
  • + {relativeTo(props.rootPath, p.path) || p.path} + {p.line > 0 ? `:${p.line}` : ''} — {problemLabel(p)} +
  • + ))} +
+
+ ); + } + if (result.status === 'warnings_unacknowledged') { + return ( + +
    + {result.problems + .filter((p) => p.severity === 'warning') + .map((p, i) => ( +
  • + {relativeTo(props.rootPath, p.path) || p.path} + {p.line > 0 ? `:${p.line}` : ''} — {problemLabel(p)} +
  • + ))} +
+ {props.onAcknowledge ? ( +

Tick “Commit despite warnings” below, then try again.

+ ) : null} +
+ ); + } + if (result.status === 'git_identity_missing') { + return ( + + Set your name and email under Account settings before committing. Reads and drafts still work. +
+ + Open Git identity settings + +
+
+ ); + } + if (result.status === 'draft_outdated') { + return ( + + These files changed upstream since the draft was started. Open them, review, and save again before + committing:{' '} + {result.outdatedPaths.map((path, i) => ( + + {i > 0 ? ', ' : ''} + {relativeTo(props.rootPath, path) || path} + + ))} + . + + ); + } + if (result.status === 'remote_changed') { + return ( + + Nothing was overwritten and your drafts are still safe. Fetching only refreshes remote refs; it does + not reconcile a retained commit. Manual reconciliation must happen outside this app. + {result.commitSha ? Local commit {shortSha(result.commitSha)} is retained. : null} +
+ Open repository to fetch +
+ {result.reason ? ( +

Technical detail: {result.reason} (non-fast-forward).

+ ) : null} +
+ ); + } + if (result.status === 'push_rejected') { + return ( + + Nothing was overwritten and your drafts are still safe + {result.commitSha ? ( + + {' '} + — local commit {shortSha(result.commitSha)} is retained for retry after you review permissions or + policy. + + ) : ( + '.' + )} + {result.reason ?

Technical detail: {result.reason}.

: null} +
+ ); + } + if (result.status === 'branch_changed') { + return ( + + Nothing was overwritten and your drafts are still safe. Refresh and review before trying again. + {result.commitSha ? Local commit {shortSha(result.commitSha)} is retained. : null} + + ); + } + if (result.status === 'repository_busy') { + return ( + + Wait a moment and try again. Your drafts are still safe. + + ); + } + if (result.status === 'git_operation_failed') { + return ( + + Your drafts are still safe + {result.commitSha ? and local commit {shortSha(result.commitSha)} is retained. : '.'} + {result.reason ?

Reason: {result.reason}.

: null} +
+ ); + } + return null; +} + +/** Review of draft changes in a stack or repository, with commit and optional push. */ export function ChangesView(props: { workspaceId: string; + repositoryId: string; /** Source API of the scope (see StackEditorProps.apiBase). */ apiBase: string; rootPath: string; changes: ChangeView[]; editorHref: string; + branch: string; + gitIdentity: GitIdentity | null; + /** Registered Compose path for stack-scoped validation; omit on repository Changes. */ + composePath?: string; }) { const router = useRouter(); - const [busy, setBusy] = useState(null); + const messageId = useId(); + const alertRef = useRef(null); + const [selected, setSelected] = useState(() => new Set(props.changes.map((c) => c.path))); + const [message, setMessage] = useState(''); + /** Scope string for which selection the warning acknowledgement applies; null when unchecked. */ + const [warningAckScope, setWarningAckScope] = useState(null); + const [busy, setBusy] = useState<'discard' | 'commit' | 'commit-push' | 'push' | null>(null); + const [discardPath, setDiscardPath] = useState(null); const [error, setError] = useState(null); + const [result, setResult] = useState(null); + const [retained, setRetained] = useState<{ + commitSha: string; + expectedRemoteSha: string; + branch: string; + } | null>(null); - if (props.changes.length === 0) { - return ( - - Edits you save in the Editor or Docs appear here for review before they are committed. - - ); - } + const gitBase = `/api/workspaces/${props.workspaceId}/repositories/${props.repositoryId}/git`; + const repositoryHref = `/w/${props.workspaceId}/repositories/${props.repositoryId}`; + const identityHref = `/w/${props.workspaceId}/settings/account#ws-account-git-identity`; + + useEffect(() => { + if (result || error) alertRef.current?.focus(); + }, [result, error]); + + // Empty `selected` means the user cleared the selection. Non-empty but all-stale means refresh. + const selectedChanges = useMemo(() => { + const picked = props.changes.filter((c) => selected.has(c.path)); + if (picked.length > 0 || selected.size === 0) return picked; + return props.changes; + }, [props.changes, selected]); + const selectedPaths = useMemo(() => new Set(selectedChanges.map((c) => c.path)), [selectedChanges]); + const selectionScope = useMemo( + () => + selectedChanges + .map((c) => `${c.path}:${c.updatedAt}`) + .sort() + .join('\0'), + [selectedChanges], + ); + const acknowledgeWarnings = warningAckScope !== null && warningAckScope === selectionScope; + const clientProblems = useMemo( + () => + selectedChanges.flatMap((c) => + problemsFor(c.path, c.after, props.composePath).map((p) => ({ + path: c.path, + line: p.line, + column: p.column, + severity: p.severity, + code: (p.code ?? + (p.severity === 'error' + ? 'source_error' + : p.severity === 'warning' + ? 'source_warning' + : 'source_info')) as GitWorkflowProblem['code'], + })), + ), + [selectedChanges, props.composePath], + ); + const blockingErrors = clientProblems.filter((p) => p.severity === 'error'); + const warnings = clientProblems.filter((p) => p.severity === 'warning'); + const outdatedSelected = selectedChanges.filter((c) => c.outdated); + const canCommit = + !!props.gitIdentity && + selectedChanges.length > 0 && + selectedChanges.length <= 100 && + message.trim().length > 0 && + blockingErrors.length === 0 && + outdatedSelected.length === 0 && + busy === null; + + const toggle = (path: string) => { + setSelected((prev) => { + const hasCurrent = props.changes.some((c) => prev.has(c.path)); + const next = !hasCurrent && prev.size > 0 ? new Set(props.changes.map((c) => c.path)) : new Set(prev); + if (next.has(path)) next.delete(path); + else next.add(path); + return next; + }); + }; + + const applyResult = (next: GitWorkflowResult) => { + setResult(next); + if (next.commitSha && next.expectedRemoteSha && next.status !== 'push_succeeded') { + setRetained({ + commitSha: next.commitSha, + expectedRemoteSha: next.expectedRemoteSha, + branch: next.branch, + }); + } + if (next.status === 'push_succeeded') setRetained(null); + if (next.status === 'warnings_unacknowledged') setWarningAckScope(null); + }; const discard = async (path: string) => { - setBusy(path); + setBusy('discard'); + setDiscardPath(path); + setError(null); + try { + await api(`${props.apiBase}/drafts?path=${encodeURIComponent(path)}`, { method: 'DELETE' }); + setSelected((prev) => { + const next = new Set(prev); + next.delete(path); + return next; + }); + router.refresh(); + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Could not reach the server.'); + } finally { + setBusy(null); + setDiscardPath(null); + } + }; + + const commit = async (push: boolean) => { + setBusy(push ? 'commit-push' : 'commit'); + setError(null); + setResult(null); + try { + const next = await gitWorkflow(`${gitBase}/commit`, { + method: 'POST', + body: { + paths: selectedChanges.map((c) => c.path), + message: message.trim(), + push, + acknowledgeWarnings: acknowledgeWarnings || undefined, + }, + }); + applyResult(next); + if (next.status === 'commit_succeeded' || next.status === 'push_succeeded') { + setMessage(''); + setWarningAckScope(null); + } + router.refresh(); + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Could not reach the server.'); + } finally { + setBusy(null); + } + }; + + const pushRetained = async () => { + if (!retained) return; + setBusy('push'); setError(null); + setResult(null); try { - await api(`${props.apiBase}/drafts?path=${encodeURIComponent(path)}`, { - method: 'DELETE', + const next = await gitWorkflow(`${gitBase}/push`, { + method: 'POST', + body: { + commitSha: retained.commitSha, + expectedRemoteSha: retained.expectedRemoteSha, + }, }); + applyResult(next); router.refresh(); } catch (err) { setError(err instanceof ApiError ? err.message : 'Could not reach the server.'); @@ -96,42 +402,273 @@ export function ChangesView(props: { } }; + if (props.changes.length === 0 && !retained) { + return ( + + Edits you save in the Editor or Docs appear here for review before they are committed. + + ); + } + + const showWarningAck = + acknowledgeWarnings || warnings.length > 0 || result?.status === 'warnings_unacknowledged'; + const needsWarningAck = + (warnings.length > 0 || result?.status === 'warnings_unacknowledged') && !acknowledgeWarnings; + return ( -
- - Committing and pushing drafts to Git arrives in the next update. Until then, drafts are kept safely in - stack-manager and never leave this server. - - {error ? ( - - {error} +
+
+ {props.changes.length === 0 ? ( + No drafts left + ) : ( + <> + + {props.changes.length} draft{props.changes.length === 1 ? '' : 's'} + + {' · '} + {selectedChanges.length} selected for commit + + )} + {outdatedSelected.length ? ( + <> + {' · '} + {outdatedSelected.length} outdated + + ) : null} + {blockingErrors.length ? ( + <> + {' · '} + + {blockingErrors.length} blocking error{blockingErrors.length === 1 ? '' : 's'} + + + ) : null} + {warnings.length && !blockingErrors.length ? ( + <> + {' · '} + + {warnings.length} warning{warnings.length === 1 ? '' : 's'} + + + ) : null} +
+ + {!props.gitIdentity ? ( + + Set your name and email under Account settings before committing. Reads and drafts still work. +
+ + Open Git identity settings + +
) : null} + +
+ {error ? ( + + {error} + + ) : null} + {result ? ( + + ) : null} + {result?.commitSha && + result.status !== 'commit_succeeded' && + result.status !== 'push_succeeded' && + result.status !== 'validation_blocked' && + result.status !== 'warnings_unacknowledged' && + result.status !== 'draft_outdated' && + result.status !== 'git_identity_missing' ? ( + + Committed as {shortSha(result.commitSha)}. Your drafts are still on this server. + + ) : null} +
+ +
+
+
+ {props.changes.length === 0 && retained ? 'Push retained commit' : 'Commit'} +
+ {props.changes.length === 0 && retained ? ( + <> + Local commit {shortSha(retained.commitSha)} · branch{' '} + {retained.branch} + + ) : ( + <> + {selectedChanges.length} file{selectedChanges.length === 1 ? '' : 's'} · branch{' '} + {props.branch} + {props.gitIdentity ? ( + <> + {' '} + · author{' '} + + {props.gitIdentity.name} <{props.gitIdentity.email}> + + + ) : null} + + )} +
+
+
+ {props.changes.length === 0 && retained ? ( +
+

+ Drafts for this change set were discarded. The retained commit is still available to push. +

+
+ +
+
+ ) : ( +
{ + e.preventDefault(); + if (canCommit) void commit(false); + }} + > +
+ +