Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 145 additions & 3 deletions apps/desktop/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand All @@ -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();

Expand All @@ -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
Expand Down Expand Up @@ -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<any>(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();

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
}));

/**
Expand Down Expand Up @@ -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),
Expand All @@ -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
Expand All @@ -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 },
);
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -3349,10 +3478,12 @@ onUnmounted(() => {
<ImageDiffViewer v-else-if="isImagePath(repoSelectedFile) && repoFolderPath && repoSelectedFile"
:cwd="repoFolderPath" :file-path="repoSelectedFile" old-rev="HEAD"
:new-rev="repoSelectedFileStaged ? ':0' : ''" status="modified" />
<DiffViewer v-else :diff="repoDiff" :file-path="repoSelectedFile" :diff-mode="diffMode" :selectable="true"
<DiffViewer v-else ref="diffViewerRef" :diff="repoDiff" :file-path="repoSelectedFile" :diff-mode="diffMode" :selectable="true"
:findings="findingsForSelectedFile"
@update:diff-mode="onDiffModeChange" @open-file-history="openFileHistory"
@open-in-editor="handleOpenInEditor" @stage-patch="stagePatch"
@select-dir-file="(path) => repoSelectFile(path, false)" />
@select-dir-file="(path) => repoSelectFile(path, false)"
@dismiss-finding="(id) => commitReview.dismiss(id)" />
</div>

<div v-if="showCommitRail" class="sidebar-handle" :class="{ 'sidebar-handle--active': sidebarResizing }"
Expand Down Expand Up @@ -3705,6 +3836,17 @@ onUnmounted(() => {
@close="showSecretsModal = false"
/>

<!-- Commit review findings modal (v3.7.0) — opened from the RepoSidebar commit-area badge -->
<CommitReviewModal
v-if="showCommitReviewModal"
:findings="commitReview.findings.value"
:summary="commitReview.summary.value"
:truncated="commitReview.truncated.value"
@jump="onJumpToCommitReviewFinding($event)"
@dismiss="commitReview.dismiss($event)"
@close="showCommitReviewModal = false"
/>

<!-- Stash-and-switch modal (asks for a stash label before switching branches) -->
<div v-if="pendingSwitchBranch" class="switch-stash-overlay overlay-backdrop" @click.self="cancelSwitchStash">
<div class="switch-stash-modal" role="dialog" aria-modal="true">
Expand Down
Loading
Loading