diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index f24546e6..d7ba20bd 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -70,6 +70,7 @@ const EditCommitOverlay = defineAsyncComponent(() => import("./components/EditCo const SplitCommitModal = defineAsyncComponent(() => import("./components/SplitCommitModal.vue")); const BranchDirtySwitchModal = defineAsyncComponent(() => import("./components/BranchDirtySwitchModal.vue")); const SecretsFindingsModal = defineAsyncComponent(() => import("./components/SecretsFindingsModal.vue")); +const CommitReviewModal = defineAsyncComponent(() => import("./components/CommitReviewModal.vue")); import { useStashMessage } from "./composables/useStashMessage"; import { useAIProvider } from "./composables/useAIProvider"; import { usePrPanel, PR_PANEL_KEY } from "./composables/usePrPanel"; @@ -95,6 +96,9 @@ import { useScheduler } from "./composables/useScheduler"; import { useRepoPoller } from "./composables/useRepoPoller"; import { useLaunchpadPoller } from "./composables/useLaunchpadPoller"; import { useSecretsScanner } from "./composables/useSecretsScanner"; +import { useCommitReview } from "./composables/useCommitReview"; +import { useCommitReviewNav } from "./composables/useCommitReviewNav"; +import { resolveCommitReviewShortcut } from "./composables/commitReviewKeymap"; import { useLaunchpadPrs } from "./composables/useLaunchpadPrs"; import { diffLaunchpad, isBotAuthor, type LaunchpadEvent } from "./composables/useLaunchpadNotifications"; import { osNotify } from "./composables/useOsNotification"; @@ -116,7 +120,7 @@ import { import { gitStash, gitStashPop, gitStashList, openInEditor, setGitConfig, gitDiscard, gitAddToGitignore, gitDeleteBranch, gitDeleteTag, gitDeleteRemoteTag, gitRemoteInfo, gitUnpushedTags, gitPushTags, gitMergeBase, gitResetToCommit, gitCommitSubmoduleChanges, gitSubmoduleCheckUpdates, scratchWorktreeCreate, scratchWorktreeDiscard, scratchWorktreeMergeBack, gitWorktreeList, gitWorktreeRemove, type CommitSubmoduleChange } from "./utils/backend"; import { useCommitActions } from "./composables/useCommitActions"; -const { t } = useI18n(); +const { t, locale } = useI18n(); const { settings, refreshSettings } = useSettings(); const { saveMemory } = useResolutionMemory(); @@ -126,6 +130,10 @@ const showSecretsModal = ref(false); /** Trailers of the last commit attempt — reused by the findings modal's "Commit anyway" so it * doesn't need its own copy of RepoSidebar's Signed-off-by trailer logic. */ let lastAttemptedCommitTrailers = ""; + +// v3.7.0 — Commit Review (local, opt-in, off by default; see useCommitReview.ts). +const commitReview = useCommitReview(); +const showCommitReviewModal = ref(false); // `useNetworkStatus` covers `navigator.onLine` — kept around because // `useScheduler` already consumes it and we don't want to retire that path // in this commit. `useConnectivity` (F1) adds a real probe-based signal @@ -267,6 +275,71 @@ const { worktreeBranches, } = useGitRepo({ confirm: askConfirm }); +// v3.7.0 — Commit Review: template ref to the mounted DiffViewer (its +// `scrollToFinding` is called by Task 2's nav composable), and the findings +// for the file currently displayed. Findings are index-scoped (reviewed the +// staged diff, not the working tree), so never paint them on an unstaged diff. +const diffViewerRef = ref(null); +const findingsForSelectedFile = computed(() => + repoSelectedFileStaged.value && repoSelectedFile.value + ? commitReview.findings.value.filter((f) => f.path === repoSelectedFile.value) + : [], +); + +/** Task 2 (v3.7.0) — `n`/`p` cycle every finding across staged files, + * switching the selected file and scrolling the diff to each. */ +const commitReviewNav = useCommitReviewNav({ + findings: commitReview.findings, + selectFile: (path, staged) => repoSelectFile(path, staged), + diffHandle: diffViewerRef, + onDismiss: (id) => commitReview.dismiss(id), + onHelp: () => showCommitReviewNavHelp(), +}); + +/** Shared transient toast for Commit Review's own non-error feedback (the + * `?` help reminder, a clean-pass confirmation) — reuses the existing toast + * affordance rather than inventing a second one (verifier issue #5). */ +function showCommitReviewToast(title: string, detail: string) { + if (successTimer != null) { window.clearTimeout(successTimer); successTimer = null; } + successToastLeaving.value = false; + successToast.value = title; + successToastDetail.value = detail; + successTimer = window.setTimeout(dismissToast, 3000); +} + +/** `?` — a one-line toast (per the plan: reuse the existing toast + * affordance, no new help modal). */ +function showCommitReviewNavHelp() { + showCommitReviewToast( + t("commitReview.navHelp"), + `${t("commitReview.navNext")} (N) · ${t("commitReview.navPrev")} (P) · ${t("commitReview.dismiss")} (X)`, + ); +} + +/** A completed review with zero findings otherwise produces no feedback at + * all, indistinguishable from "didn't run" or "failed" (verifier issue + * #5) — `commitReview.summaryClean` already exists but was unreachable. + * Called from `reviewStaged` below only when the run actually completed + * (not skipped, not superseded) and produced no error. */ +function showCommitReviewCleanToast() { + showCommitReviewToast(t("commitReview.summaryClean"), ""); +} + +/** Task 2 (v3.7.0) — the commit-review keymap is only "active" (bare-letter + * n/p/x reserved) in the Changes view, with the feature on and at least + * one finding to navigate. */ +const commitReviewShortcutActive = computed( + () => viewMode.value === "changes" && settings.value.commitReviewEnabled && commitReview.findings.value.length > 0, +); + +/** Verifier issue #4 — a failed review must never fail silently. Reuse the + * existing error-toast banner (`repoError`) rather than inventing a second + * one; this mirrors every other place in this file that funnels a failure + * into `repoError.value`. */ +watch(commitReview.lastError, (val) => { + if (val) repoError.value = val; +}); + // Monorepo scope (v2.21.0) — restore persisted scope on repo open. const { loadScope } = useWorkspaceScope(); @@ -331,6 +404,12 @@ const prPanel = usePrPanel(prCwd, { await repoRefresh(); await loadBranches(); }, + // v3.7.0 — Commit Review: resume its queue on the same visibilitychange + // → visible edge usePrPanel already reuses for the PR pre-review queue, + // instead of standing up a second listener. Without this, starting a + // review then hiding the tab would leave the queue paused on + // document.hidden forever. + onVisibilityResume: () => commitReview.resume(), }); provide(PR_PANEL_KEY, prPanel); const issuePanel = useIssuePanel(prCwd); @@ -1016,6 +1095,11 @@ const repoSidebarProps = computed(() => ({ visibleFileIdx: historyVisibleFileIdx.value, gitUser: currentGitUser.value, secretFindingsCount: secretsScanner.activeFindings.value.length, + commitReviewEnabled: settings.value.commitReviewEnabled, + commitReviewRunning: commitReview.running.value, + commitReviewFindingsCount: commitReview.findings.value.length, + commitReviewProgress: commitReview.progress.value, + reviewFindingsByFile: commitReview.findingsByFile.value, })); /** @@ -1065,6 +1149,33 @@ function onSecretsCommitAnyway() { void doCommit(lastAttemptedCommitTrailers); } +/** v3.7.0 — "Review staged changes" button handler. Shows a brief clean-pass + * confirmation when the run actually completed (not skipped, not + * superseded by a newer run) with no error and zero findings — otherwise a + * clean review is indistinguishable from "didn't run" or "failed" + * (verifier issue #5). A failed run is separately surfaced via the + * `commitReview.lastError` watcher above (issue #4). */ +async function onReviewStagedClicked() { + const ran = await commitReview.run(repoFolderPath.value ?? "", locale.value); + if (ran && !commitReview.lastError.value && commitReview.findings.value.length === 0) { + showCommitReviewCleanToast(); + } +} + +/** v3.7.0 — "Jump to" in the findings modal: select the finding's file + * (always staged — findings are index-scoped) and scroll the diff to it. */ +function onJumpToCommitReviewFinding(id: string) { + const finding = commitReview.findings.value.find((f) => f.id === id); + if (!finding) return; + if (repoSelectedFile.value !== finding.path || !repoSelectedFileStaged.value) { + repoSelectFile(finding.path, true); + } + showCommitReviewModal.value = false; + void nextTick(() => { + diffViewerRef.value?.scrollToFinding?.(finding.line, finding.side); + }); +} + const repoSidebarListeners = { select: (path: string, staged: boolean) => onRepoFileSelect(path, staged), changeView: (mode: ViewMode) => onViewModeChange(mode), @@ -1088,6 +1199,8 @@ const repoSidebarListeners = { deleteBranch: (name: string, hasLocal: boolean, hasRemote: boolean, remoteName?: string) => handleDeleteBranchRequest(name, hasLocal, hasRemote, remoteName), openSecrets: () => { showSecretsModal.value = true; }, + reviewStaged: () => { void onReviewStagedClicked(); }, + openCommitReview: () => { showCommitReviewModal.value = true; }, }; // Trigger a (debounced) secrets scan whenever the staged set changes, or when a repo is @@ -1100,6 +1213,12 @@ watch( } else { secretsScanner.findings.value = []; } + // v3.7.0 — Commit Review: a staged-set/repo change invalidates whatever + // findings are on screen (the diff they reviewed no longer exists). + // Never auto-run here — the pass only runs on the explicit "Review + // staged changes" click (decision D5; the one-shot re-review after a + // "Fix with agent" handoff is Task 3, out of scope for this PR). + commitReview.reset(); }, { immediate: true }, ); @@ -2810,6 +2929,16 @@ function onKeyDown(e: KeyboardEvent) { } return; } + // Task 2 (v3.7.0) — n/p/x cycle/dismiss commit-review findings in the + // Changes view. Checked early, before the mod-key ladder, and the + // resolver itself guards editable targets (the commit summary/description + // fields live in this same view) and inactivity — see commitReviewKeymap.ts. + const commitReviewAction = resolveCommitReviewShortcut(e, { active: commitReviewShortcutActive.value }); + if (commitReviewAction) { + e.preventDefault(); + commitReviewNav.dispatch(commitReviewAction); + return; + } if (mod && e.key === "t") { // Cmd+T — new tab (open folder picker) e.preventDefault(); @@ -3349,10 +3478,12 @@ onUnmounted(() => { - + @select-dir-file="(path) => repoSelectFile(path, false)" + @dismiss-finding="(id) => commitReview.dismiss(id)" />