feat: implement editable diff functionality with save and discard options
- Added support for editing file content during reviews, allowing users to save changes directly to disk. - Introduced new IPC channels for saving edited files and handling content updates. - Enhanced the review toolbar with actions for saving and discarding edits, along with indicators for edited files. - Implemented keyboard shortcuts for improved navigation and editing experience. - Integrated a new component for rendering CLI logs in a rich format, replacing the previous raw JSON display. - Updated state management to track edited contents and handle file edits effectively.
This commit is contained in:
parent
ada5d8359a
commit
1d7e55e89a
19 changed files with 719 additions and 156 deletions
|
|
@ -6,6 +6,7 @@ This project follows the Contributor Covenant Code of Conduct.
|
|||
- Be respectful and constructive.
|
||||
- Assume good intent and discuss ideas, not people.
|
||||
- Give actionable feedback and accept feedback gracefully.
|
||||
- If a change significantly affects the UI, discuss the design approach with maintainers or the community first so we can align on the best direction.
|
||||
|
||||
## Unacceptable Behavior
|
||||
- Harassment, discrimination, or personal attacks.
|
||||
|
|
@ -13,7 +14,7 @@ This project follows the Contributor Covenant Code of Conduct.
|
|||
- Publishing private information without explicit permission.
|
||||
|
||||
## Enforcement
|
||||
Project maintainers are responsible for clarifying and enforcing this code of conduct and may take corrective action for unacceptable behavior.
|
||||
Maintainers are here to help keep our community welcoming. They’ll clarify expectations when needed and, if necessary, take steps to address behavior that goes against these standards.
|
||||
|
||||
## Reporting
|
||||
Please report incidents privately to the maintainers through the security/contact channel listed in `SECURITY.md`.
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@
|
|||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "^6.10.2",
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
"@codemirror/lang-html": "^6.4.11",
|
||||
"@codemirror/lang-javascript": "^6.2.4",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@ importers:
|
|||
|
||||
.:
|
||||
dependencies:
|
||||
'@codemirror/commands':
|
||||
specifier: ^6.10.2
|
||||
version: 6.10.2
|
||||
'@codemirror/lang-css':
|
||||
specifier: ^6.3.1
|
||||
version: 6.3.1
|
||||
|
|
@ -396,6 +399,9 @@ packages:
|
|||
'@codemirror/autocomplete@6.20.0':
|
||||
resolution: {integrity: sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg==}
|
||||
|
||||
'@codemirror/commands@6.10.2':
|
||||
resolution: {integrity: sha512-vvX1fsih9HledO1c9zdotZYUZnE4xV0m6i3m25s5DIfXofuprk6cRcLUZvSk3CASUbwjQX21tOGbkY2BH8TpnQ==}
|
||||
|
||||
'@codemirror/lang-css@6.3.1':
|
||||
resolution: {integrity: sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==}
|
||||
|
||||
|
|
@ -5562,6 +5568,13 @@ snapshots:
|
|||
'@codemirror/view': 6.39.15
|
||||
'@lezer/common': 1.5.1
|
||||
|
||||
'@codemirror/commands@6.10.2':
|
||||
dependencies:
|
||||
'@codemirror/language': 6.12.1
|
||||
'@codemirror/state': 6.5.4
|
||||
'@codemirror/view': 6.39.15
|
||||
'@lezer/common': 1.5.1
|
||||
|
||||
'@codemirror/lang-css@6.3.1':
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.0
|
||||
|
|
|
|||
|
|
@ -551,6 +551,13 @@ function createWindow(): void {
|
|||
return;
|
||||
}
|
||||
|
||||
// Prevent Cmd+N from opening new window; forward to renderer for review shortcuts
|
||||
if (input.meta && input.key.toLowerCase() === 'n') {
|
||||
event.preventDefault();
|
||||
mainWindow.webContents.send('review:cmdN');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!input.meta) return;
|
||||
|
||||
const currentLevel = mainWindow.webContents.getZoomLevel();
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
REVIEW_PREVIEW_REJECT,
|
||||
REVIEW_REJECT_FILE,
|
||||
REVIEW_REJECT_HUNKS,
|
||||
REVIEW_SAVE_EDITED_FILE,
|
||||
// eslint-disable-next-line boundaries/element-types -- IPC channel constants are shared between main and preload by design
|
||||
} from '@preload/constants/ipcChannels';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
|
@ -89,6 +90,8 @@ export function registerReviewHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.handle(REVIEW_PREVIEW_REJECT, handlePreviewReject);
|
||||
ipcMain.handle(REVIEW_APPLY_DECISIONS, handleApplyDecisions);
|
||||
ipcMain.handle(REVIEW_GET_FILE_CONTENT, handleGetFileContent);
|
||||
// Editable diff
|
||||
ipcMain.handle(REVIEW_SAVE_EDITED_FILE, handleSaveEditedFile);
|
||||
// Phase 4
|
||||
ipcMain.handle(REVIEW_GET_GIT_FILE_LOG, handleGetGitFileLog);
|
||||
}
|
||||
|
|
@ -105,6 +108,8 @@ export function removeReviewHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.removeHandler(REVIEW_PREVIEW_REJECT);
|
||||
ipcMain.removeHandler(REVIEW_APPLY_DECISIONS);
|
||||
ipcMain.removeHandler(REVIEW_GET_FILE_CONTENT);
|
||||
// Editable diff
|
||||
ipcMain.removeHandler(REVIEW_SAVE_EDITED_FILE);
|
||||
// Phase 4
|
||||
ipcMain.removeHandler(REVIEW_GET_GIT_FILE_LOG);
|
||||
}
|
||||
|
|
@ -229,6 +234,19 @@ async function handleGetFileContent(
|
|||
);
|
||||
}
|
||||
|
||||
// --- Editable diff Handlers ---
|
||||
|
||||
async function handleSaveEditedFile(
|
||||
_event: IpcMainInvokeEvent,
|
||||
filePath: string,
|
||||
content: string
|
||||
): Promise<IpcResult<{ success: boolean }>> {
|
||||
if (!filePath || typeof content !== 'string') {
|
||||
return { success: false, error: 'Invalid parameters' };
|
||||
}
|
||||
return wrapReviewHandler('saveEditedFile', () => getApplier().saveEditedFile(filePath, content));
|
||||
}
|
||||
|
||||
// --- Phase 4 Handlers ---
|
||||
|
||||
async function handleGetGitFileLog(
|
||||
|
|
|
|||
|
|
@ -310,6 +310,14 @@ export class ReviewApplierService {
|
|||
return { applied, skipped, conflicts, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Save edited file content directly to disk.
|
||||
*/
|
||||
async saveEditedFile(filePath: string, content: string): Promise<{ success: boolean }> {
|
||||
await writeFile(filePath, content, 'utf8');
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ── Private: Rejection strategies ──
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -320,5 +320,8 @@ export const REVIEW_GET_FILE_CONTENT = 'review:getFileContent';
|
|||
|
||||
// Phase 4 — Git fallback
|
||||
|
||||
/** Save edited file content to disk */
|
||||
export const REVIEW_SAVE_EDITED_FILE = 'review:saveEditedFile';
|
||||
|
||||
/** Get git file change log */
|
||||
export const REVIEW_GET_GIT_FILE_LOG = 'review:getGitFileLog';
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import {
|
|||
REVIEW_PREVIEW_REJECT,
|
||||
REVIEW_REJECT_FILE,
|
||||
REVIEW_REJECT_HUNKS,
|
||||
REVIEW_SAVE_EDITED_FILE,
|
||||
SSH_CONNECT,
|
||||
SSH_DISCONNECT,
|
||||
SSH_GET_CONFIG_HOSTS,
|
||||
|
|
@ -755,6 +756,17 @@ const electronAPI: ElectronAPI = {
|
|||
snippets
|
||||
);
|
||||
},
|
||||
// Editable diff
|
||||
saveEditedFile: async (filePath: string, content: string) => {
|
||||
return invokeIpcWithResult<{ success: boolean }>(REVIEW_SAVE_EDITED_FILE, filePath, content);
|
||||
},
|
||||
onCmdN: (callback: () => void): (() => void) => {
|
||||
const handler = () => callback();
|
||||
ipcRenderer.on('review:cmdN', handler);
|
||||
return () => {
|
||||
ipcRenderer.removeListener('review:cmdN', handler);
|
||||
};
|
||||
},
|
||||
// Phase 4
|
||||
getGitFileLog: async (projectPath: string, filePath: string) => {
|
||||
return invokeIpcWithResult<{ hash: string; timestamp: string; message: string }[]>(
|
||||
|
|
|
|||
|
|
@ -807,6 +807,10 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
previewReject: async () => {
|
||||
throw new Error('Review is not available in browser mode');
|
||||
},
|
||||
// Editable diff stubs
|
||||
saveEditedFile: async () => {
|
||||
throw new Error('Review is not available in browser mode');
|
||||
},
|
||||
// Phase 4 stubs
|
||||
getGitFileLog: async () => {
|
||||
throw new Error('Review is not available in browser mode');
|
||||
|
|
|
|||
161
src/renderer/components/team/CliLogsRichView.tsx
Normal file
161
src/renderer/components/team/CliLogsRichView.tsx
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
/**
|
||||
* CliLogsRichView
|
||||
*
|
||||
* Renders CLI stream-json logs using the same rich components as session views:
|
||||
* thinking blocks, tool call cards, markdown text output.
|
||||
*
|
||||
* Replaces raw JSON display in ProvisioningProgressBlock.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { DisplayItemList } from '@renderer/components/chat/DisplayItemList';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
import { parseStreamJsonToGroups } from '@renderer/utils/streamJsonParser';
|
||||
import { Bot, ChevronRight } from 'lucide-react';
|
||||
|
||||
import type { StreamJsonGroup } from '@renderer/utils/streamJsonParser';
|
||||
|
||||
interface CliLogsRichViewProps {
|
||||
cliLogsTail: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single collapsible group of assistant items.
|
||||
*/
|
||||
const StreamGroup = ({
|
||||
group,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
expandedItemIds,
|
||||
onItemClick,
|
||||
}: {
|
||||
group: StreamJsonGroup;
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
expandedItemIds: Set<string>;
|
||||
onItemClick: (itemId: string) => void;
|
||||
}): React.JSX.Element => (
|
||||
<div className="rounded border border-[var(--color-border)] bg-[var(--color-surface)]">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 px-2.5 py-1.5 text-left transition-colors hover:bg-[var(--color-surface-raised)]"
|
||||
onClick={onToggle}
|
||||
>
|
||||
<ChevronRight
|
||||
size={12}
|
||||
className={cn(
|
||||
'shrink-0 text-[var(--color-text-muted)] transition-transform duration-150',
|
||||
isExpanded && 'rotate-90'
|
||||
)}
|
||||
/>
|
||||
<Bot size={13} className="shrink-0 text-[var(--color-text-muted)]" />
|
||||
<span className="min-w-0 truncate text-[11px] text-[var(--color-text-secondary)]">
|
||||
{group.summary}
|
||||
</span>
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div className="border-t border-[var(--color-border)] p-2">
|
||||
<DisplayItemList
|
||||
items={group.items}
|
||||
onItemClick={onItemClick}
|
||||
expandedItemIds={expandedItemIds}
|
||||
aiGroupId={group.id}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const CliLogsRichView = ({
|
||||
cliLogsTail,
|
||||
className,
|
||||
}: CliLogsRichViewProps): React.JSX.Element => {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
// Tracks groups manually collapsed by user (default: all auto-expanded)
|
||||
const [collapsedGroupIds, setCollapsedGroupIds] = useState<Set<string>>(new Set());
|
||||
const [expandedItemIds, setExpandedItemIds] = useState<Set<string>>(new Set());
|
||||
|
||||
const groups = useMemo(() => parseStreamJsonToGroups(cliLogsTail), [cliLogsTail]);
|
||||
|
||||
// Derive expanded state: all groups expanded unless manually collapsed
|
||||
const expandedGroupIds = useMemo(() => {
|
||||
const expanded = new Set<string>();
|
||||
for (const group of groups) {
|
||||
if (!collapsedGroupIds.has(group.id)) {
|
||||
expanded.add(group.id);
|
||||
}
|
||||
}
|
||||
return expanded;
|
||||
}, [groups, collapsedGroupIds]);
|
||||
|
||||
// Auto-scroll to bottom on new content
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [cliLogsTail]);
|
||||
|
||||
const handleGroupToggle = useCallback((groupId: string) => {
|
||||
setCollapsedGroupIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(groupId)) {
|
||||
next.delete(groupId);
|
||||
} else {
|
||||
next.add(groupId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleItemClick = useCallback((itemId: string) => {
|
||||
setExpandedItemIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(itemId)) {
|
||||
next.delete(itemId);
|
||||
} else {
|
||||
next.add(itemId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (groups.length === 0) {
|
||||
// cliLogsTail has data but no parseable assistant messages — show raw text fallback
|
||||
const hasContent = cliLogsTail.trim().length > 0;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded border border-[var(--color-border)] bg-[var(--color-surface)]',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{hasContent ? (
|
||||
<pre className="max-h-[400px] overflow-y-auto p-2 font-mono text-[11px] leading-relaxed text-[var(--color-text-secondary)]">
|
||||
{cliLogsTail}
|
||||
</pre>
|
||||
) : (
|
||||
<p className="p-3 text-center text-[11px] italic text-[var(--color-text-muted)]">
|
||||
Waiting for CLI output...
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={scrollRef} className={cn('max-h-[400px] space-y-1.5 overflow-y-auto', className)}>
|
||||
{groups.map((group) => (
|
||||
<StreamGroup
|
||||
key={group.id}
|
||||
group={group}
|
||||
isExpanded={expandedGroupIds.has(group.id)}
|
||||
onToggle={() => handleGroupToggle(group.id)}
|
||||
expandedItemIds={expandedItemIds}
|
||||
onItemClick={handleItemClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { Badge } from '@renderer/components/ui/badge';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
|
|
@ -7,59 +7,11 @@ import { ChevronDown, ChevronRight, Loader2 } from 'lucide-react';
|
|||
|
||||
import { MarkdownViewer } from '../chat/viewers/MarkdownViewer';
|
||||
|
||||
import { CliLogsRichView } from './CliLogsRichView';
|
||||
import { STEP_LABELS, STEP_ORDER } from './provisioningSteps';
|
||||
|
||||
import type { ProvisioningStep } from './provisioningSteps';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON syntax-highlighted CLI logs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const JSON_KEY_COLOR = 'var(--syntax-property, #7dd3fc)';
|
||||
const JSON_STRING_COLOR = 'var(--syntax-string, #86efac)';
|
||||
const JSON_NUMBER_COLOR = 'var(--syntax-number, #fde68a)';
|
||||
const JSON_BOOL_NULL_COLOR = 'var(--syntax-keyword, #c4b5fd)';
|
||||
const JSON_BRACKET_COLOR = 'var(--color-text-muted)';
|
||||
|
||||
function syntaxHighlightJson(json: string): string {
|
||||
return (
|
||||
json
|
||||
.replace(/("(?:\\.|[^"\\])*")\s*:/g, `<span style="color:${JSON_KEY_COLOR}">$1</span>:`)
|
||||
.replace(/:\s*("(?:\\.|[^"\\])*")/g, (match, str: string) =>
|
||||
match.replace(str, `<span style="color:${JSON_STRING_COLOR}">${str}</span>`)
|
||||
)
|
||||
// eslint-disable-next-line security/detect-unsafe-regex -- number format is bounded, input is our JSON
|
||||
.replace(/:\s*(-?\d+(?:\.\d{1,20})?(?:[eE][+-]?\d{1,5})?)/g, (match, num: string) =>
|
||||
match.replace(num, `<span style="color:${JSON_NUMBER_COLOR}">${num}</span>`)
|
||||
)
|
||||
.replace(/:\s*(true|false|null)/g, (match, kw: string) =>
|
||||
match.replace(kw, `<span style="color:${JSON_BOOL_NULL_COLOR}">${kw}</span>`)
|
||||
)
|
||||
.replace(/([{}[\]])/g, `<span style="color:${JSON_BRACKET_COLOR}">$1</span>`)
|
||||
);
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function prettyFormatLogs(raw: string): string {
|
||||
return raw
|
||||
.split('\n')
|
||||
.map((line) => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return '';
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as unknown;
|
||||
const pretty = escapeHtml(JSON.stringify(parsed, null, 2));
|
||||
return syntaxHighlightJson(pretty);
|
||||
} catch {
|
||||
return escapeHtml(trimmed);
|
||||
}
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
export interface ProvisioningProgressBlockProps {
|
||||
/** Title above the steps, e.g. "Launching team" */
|
||||
title: string;
|
||||
|
|
@ -125,19 +77,7 @@ export const ProvisioningProgressBlock = ({
|
|||
}: ProvisioningProgressBlockProps): React.JSX.Element => {
|
||||
const elapsed = useElapsedTimer(startedAt);
|
||||
const [logsOpen, setLogsOpen] = useState(false);
|
||||
const logsRef = useRef<HTMLPreElement>(null);
|
||||
const outputScrollRef = useRef<HTMLDivElement>(null);
|
||||
const prettyLogs = useMemo(
|
||||
() => (cliLogsTail ? prettyFormatLogs(cliLogsTail) : ''),
|
||||
[cliLogsTail]
|
||||
);
|
||||
|
||||
// Auto-scroll CLI logs
|
||||
useEffect(() => {
|
||||
if (logsOpen && logsRef.current) {
|
||||
logsRef.current.scrollTop = logsRef.current.scrollHeight;
|
||||
}
|
||||
}, [logsOpen, cliLogsTail]);
|
||||
|
||||
// Auto-scroll assistant output
|
||||
useEffect(() => {
|
||||
|
|
@ -231,14 +171,7 @@ export const ProvisioningProgressBlock = ({
|
|||
{logsOpen ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
CLI logs
|
||||
</button>
|
||||
{logsOpen ? (
|
||||
<pre
|
||||
ref={logsRef}
|
||||
className="mt-1 max-h-[400px] overflow-y-auto rounded border border-[var(--color-border)] bg-[var(--color-surface)] p-2 font-mono text-[11px] leading-relaxed text-[var(--color-text-secondary)]"
|
||||
// Content is HTML-escaped via escapeHtml() before syntax highlighting spans are added
|
||||
dangerouslySetInnerHTML={{ __html: prettyLogs }}
|
||||
/>
|
||||
) : null}
|
||||
{logsOpen ? <CliLogsRichView cliLogsTail={cliLogsTail} className="mt-1" /> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { goToNextChunk, rejectChunk } from '@codemirror/merge';
|
||||
import { useDiffNavigation } from '@renderer/hooks/useDiffNavigation';
|
||||
import { useViewedFiles } from '@renderer/hooks/useViewedFiles';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
|
|
@ -74,11 +75,18 @@ export const ChangeReviewDialog = ({
|
|||
acceptAll,
|
||||
rejectAll,
|
||||
applyReview,
|
||||
// Editable diff
|
||||
editedContents,
|
||||
updateEditedContent,
|
||||
discardFileEdits,
|
||||
saveEditedFile,
|
||||
} = useStore();
|
||||
|
||||
const editorViewRef = useRef<EditorView | null>(null);
|
||||
const [autoViewed, setAutoViewed] = useState(true);
|
||||
const [timelineOpen, setTimelineOpen] = useState(false);
|
||||
// Counter to force editor rebuild on discard
|
||||
const [discardCounter, setDiscardCounter] = useState(0);
|
||||
|
||||
// Build scope key for viewed storage
|
||||
const scopeKey = mode === 'task' ? `task:${taskId ?? ''}` : `agent:${memberName ?? ''}`;
|
||||
|
|
@ -99,6 +107,23 @@ export const ChangeReviewDialog = ({
|
|||
progress: viewedProgress,
|
||||
} = useViewedFiles(teamName, scopeKey, allFilePaths);
|
||||
|
||||
// Editable diff computed values
|
||||
const editedCount = Object.keys(editedContents).length;
|
||||
const hasCurrentFileEdits = !!(
|
||||
selectedReviewFilePath && selectedReviewFilePath in editedContents
|
||||
);
|
||||
|
||||
const handleSaveCurrentFile = useCallback(() => {
|
||||
if (selectedReviewFilePath) void saveEditedFile(selectedReviewFilePath);
|
||||
}, [selectedReviewFilePath, saveEditedFile]);
|
||||
|
||||
const handleDiscardCurrentFile = useCallback(() => {
|
||||
if (selectedReviewFilePath) {
|
||||
discardFileEdits(selectedReviewFilePath);
|
||||
setDiscardCounter((c) => c + 1);
|
||||
}
|
||||
}, [selectedReviewFilePath, discardFileEdits]);
|
||||
|
||||
const diffNav = useDiffNavigation(
|
||||
activeChangeSet?.files ?? [],
|
||||
selectedReviewFilePath,
|
||||
|
|
@ -107,7 +132,8 @@ export const ChangeReviewDialog = ({
|
|||
open,
|
||||
(filePath, hunkIndex) => setHunkDecision(filePath, hunkIndex, 'accepted'),
|
||||
(filePath, hunkIndex) => setHunkDecision(filePath, hunkIndex, 'rejected'),
|
||||
() => onOpenChange(false)
|
||||
() => onOpenChange(false),
|
||||
handleSaveCurrentFile
|
||||
);
|
||||
|
||||
// Auto-viewed callback
|
||||
|
|
@ -147,6 +173,19 @@ export const ChangeReviewDialog = ({
|
|||
return () => document.removeEventListener('keydown', handler);
|
||||
}, [open, onOpenChange]);
|
||||
|
||||
// Cmd+N IPC listener (forwarded from main process)
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const cleanup = window.electronAPI?.review.onCmdN?.(() => {
|
||||
const view = editorViewRef.current;
|
||||
if (view) {
|
||||
rejectChunk(view);
|
||||
requestAnimationFrame(() => goToNextChunk(view));
|
||||
}
|
||||
});
|
||||
return cleanup ?? undefined;
|
||||
}, [open]);
|
||||
|
||||
// Lazy-load file content when file selected
|
||||
useEffect(() => {
|
||||
if (!open || !selectedReviewFilePath) return;
|
||||
|
|
@ -268,6 +307,11 @@ export const ChangeReviewDialog = ({
|
|||
onApply={handleApply}
|
||||
onDiffViewModeChange={setDiffViewMode}
|
||||
onCollapseUnchangedChange={setCollapseUnchanged}
|
||||
editedCount={editedCount}
|
||||
hasCurrentFileEdits={hasCurrentFileEdits}
|
||||
saving={applying}
|
||||
onSaveCurrentFile={handleSaveCurrentFile}
|
||||
onDiscardCurrentFile={handleDiscardCurrentFile}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
@ -399,9 +443,11 @@ export const ChangeReviewDialog = ({
|
|||
newString={fileContent.modifiedFullContent}
|
||||
>
|
||||
<CodeMirrorDiffView
|
||||
key={`${selectedFile.filePath}:${discardCounter}`}
|
||||
original={fileContent.originalFullContent ?? ''}
|
||||
modified={fileContent.modifiedFullContent}
|
||||
fileName={selectedFile.relativePath}
|
||||
readOnly={false}
|
||||
showMergeControls={true}
|
||||
collapseUnchanged={collapseUnchanged}
|
||||
onHunkAccepted={(idx) =>
|
||||
|
|
@ -412,6 +458,9 @@ export const ChangeReviewDialog = ({
|
|||
}
|
||||
onFullyViewed={handleFullyViewed}
|
||||
editorViewRef={editorViewRef}
|
||||
onContentChanged={(content) => {
|
||||
updateEditedContent(selectedFile.filePath, content);
|
||||
}}
|
||||
/>
|
||||
</DiffErrorBoundary>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
|
||||
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
|
||||
import { css } from '@codemirror/lang-css';
|
||||
import { html } from '@codemirror/lang-html';
|
||||
import { javascript } from '@codemirror/lang-javascript';
|
||||
|
|
@ -32,6 +33,8 @@ interface CodeMirrorDiffViewProps {
|
|||
onFullyViewed?: () => void;
|
||||
/** Ref to expose the EditorView for external navigation */
|
||||
editorViewRef?: React.RefObject<EditorView | null>;
|
||||
/** Called when editor content changes (debounced, only when readOnly=false) */
|
||||
onContentChanged?: (content: string) => void;
|
||||
}
|
||||
|
||||
/** Detect language extension from file name */
|
||||
|
|
@ -183,7 +186,7 @@ export const CodeMirrorDiffView = ({
|
|||
modified,
|
||||
fileName,
|
||||
maxHeight = '100%',
|
||||
readOnly = true,
|
||||
readOnly = false,
|
||||
showMergeControls = false,
|
||||
collapseUnchanged: collapseUnchangedProp = true,
|
||||
collapseMargin = 3,
|
||||
|
|
@ -191,6 +194,7 @@ export const CodeMirrorDiffView = ({
|
|||
onHunkRejected,
|
||||
onFullyViewed,
|
||||
editorViewRef: externalViewRef,
|
||||
onContentChanged,
|
||||
}: CodeMirrorDiffViewProps) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const viewRef = useRef<EditorView | null>(null);
|
||||
|
|
@ -201,11 +205,21 @@ export const CodeMirrorDiffView = ({
|
|||
// Stabilize callbacks via useEffect (cannot update refs during render)
|
||||
const onAcceptRef = useRef(onHunkAccepted);
|
||||
const onRejectRef = useRef(onHunkRejected);
|
||||
const onContentChangedRef = useRef(onContentChanged);
|
||||
const debounceTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||
useEffect(() => {
|
||||
onAcceptRef.current = onHunkAccepted;
|
||||
onRejectRef.current = onHunkRejected;
|
||||
onContentChangedRef.current = onContentChanged;
|
||||
externalViewRefHolder.current = externalViewRef;
|
||||
}, [onHunkAccepted, onHunkRejected, externalViewRef]);
|
||||
}, [onHunkAccepted, onHunkRejected, onContentChanged, externalViewRef]);
|
||||
|
||||
// Auto-scroll to next chunk after accept/reject (deferred to let CM recalculate)
|
||||
const scrollToNextChunk = useCallback(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if (viewRef.current) goToNextChunk(viewRef.current);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const langExtension = useMemo(() => getLanguageExtension(fileName), [fileName]);
|
||||
|
||||
|
|
@ -216,13 +230,42 @@ export const CodeMirrorDiffView = ({
|
|||
EditorState.readOnly.of(readOnly),
|
||||
];
|
||||
|
||||
// Undo/redo support and standard editing keybindings
|
||||
if (!readOnly) {
|
||||
extensions.push(history());
|
||||
extensions.push(keymap.of([...defaultKeymap, ...historyKeymap]));
|
||||
}
|
||||
|
||||
if (langExtension) {
|
||||
extensions.push(langExtension);
|
||||
}
|
||||
|
||||
// Keyboard shortcuts for chunk navigation
|
||||
// Keyboard shortcuts for chunk navigation and accept/reject
|
||||
extensions.push(
|
||||
keymap.of([
|
||||
{
|
||||
key: 'Mod-y',
|
||||
run: (view) => {
|
||||
acceptChunk(view);
|
||||
requestAnimationFrame(() => goToNextChunk(view));
|
||||
return true;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'Mod-n',
|
||||
run: (view) => {
|
||||
rejectChunk(view);
|
||||
requestAnimationFrame(() => goToNextChunk(view));
|
||||
return true;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'Alt-j',
|
||||
run: (view) => {
|
||||
goToNextChunk(view);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'Ctrl-Alt-ArrowDown',
|
||||
run: goToNextChunk,
|
||||
|
|
@ -234,6 +277,20 @@ export const CodeMirrorDiffView = ({
|
|||
])
|
||||
);
|
||||
|
||||
// Debounced content change listener (only when editable)
|
||||
if (!readOnly) {
|
||||
extensions.push(
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) {
|
||||
clearTimeout(debounceTimer.current);
|
||||
debounceTimer.current = setTimeout(() => {
|
||||
onContentChangedRef.current?.(update.state.doc.toString());
|
||||
}, 300);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Unified merge view
|
||||
const mergeConfig: Parameters<typeof unifiedMergeView>[0] = {
|
||||
original,
|
||||
|
|
@ -265,6 +322,7 @@ export const CodeMirrorDiffView = ({
|
|||
const hunkIndex = computeHunkIndexAtPos(view.state, pos);
|
||||
action(e);
|
||||
onAcceptRef.current?.(hunkIndex);
|
||||
scrollToNextChunk();
|
||||
}
|
||||
};
|
||||
} else {
|
||||
|
|
@ -279,6 +337,7 @@ export const CodeMirrorDiffView = ({
|
|||
const hunkIndex = computeHunkIndexAtPos(view.state, pos);
|
||||
action(e);
|
||||
onRejectRef.current?.(hunkIndex);
|
||||
scrollToNextChunk();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -290,7 +349,15 @@ export const CodeMirrorDiffView = ({
|
|||
extensions.push(unifiedMergeView(mergeConfig));
|
||||
|
||||
return extensions;
|
||||
}, [original, readOnly, langExtension, showMergeControls, collapseUnchangedProp, collapseMargin]);
|
||||
}, [
|
||||
original,
|
||||
readOnly,
|
||||
langExtension,
|
||||
showMergeControls,
|
||||
collapseUnchangedProp,
|
||||
collapseMargin,
|
||||
scrollToNextChunk,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
|
|
|||
|
|
@ -6,13 +6,10 @@ interface KeyboardShortcutsHelpProps {
|
|||
}
|
||||
|
||||
const shortcuts = [
|
||||
{ keys: ['j', '\u2193'], action: 'Next hunk' },
|
||||
{ keys: ['k', '\u2191'], action: 'Previous hunk' },
|
||||
{ keys: ['n'], action: 'Next file' },
|
||||
{ keys: ['p', 'Shift+N'], action: 'Previous file' },
|
||||
{ keys: ['a'], action: 'Accept current hunk' },
|
||||
{ keys: ['x'], action: 'Reject current hunk' },
|
||||
{ keys: ['?'], action: 'Toggle shortcuts help' },
|
||||
{ keys: ['\u2325+J'], action: 'Next change' },
|
||||
{ keys: ['\u2318+Y'], action: 'Accept change' },
|
||||
{ keys: ['\u2318+N'], action: 'Reject change' },
|
||||
{ keys: ['\u2318+\u21A9'], action: 'Save file' },
|
||||
{ keys: ['Esc'], action: 'Close dialog' },
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,17 @@
|
|||
import { cn } from '@renderer/lib/utils';
|
||||
import { Check, Columns2, Eye, EyeOff, GitMerge, Loader2, Rows2, X } from 'lucide-react';
|
||||
import {
|
||||
Check,
|
||||
Columns2,
|
||||
Eye,
|
||||
EyeOff,
|
||||
GitMerge,
|
||||
Loader2,
|
||||
Pencil,
|
||||
Rows2,
|
||||
Save,
|
||||
Undo2,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
|
||||
import type { ChangeStats } from '@shared/types';
|
||||
|
||||
|
|
@ -16,6 +28,12 @@ interface ReviewToolbarProps {
|
|||
onApply: () => void;
|
||||
onDiffViewModeChange: (mode: 'unified' | 'split') => void;
|
||||
onCollapseUnchangedChange: (collapse: boolean) => void;
|
||||
// Editable diff props
|
||||
editedCount?: number;
|
||||
hasCurrentFileEdits?: boolean;
|
||||
saving?: boolean;
|
||||
onSaveCurrentFile?: () => void;
|
||||
onDiscardCurrentFile?: () => void;
|
||||
}
|
||||
|
||||
export const ReviewToolbar = ({
|
||||
|
|
@ -31,6 +49,11 @@ export const ReviewToolbar = ({
|
|||
onApply,
|
||||
onDiffViewModeChange,
|
||||
onCollapseUnchangedChange,
|
||||
editedCount = 0,
|
||||
hasCurrentFileEdits = false,
|
||||
saving = false,
|
||||
onSaveCurrentFile,
|
||||
onDiscardCurrentFile,
|
||||
}: ReviewToolbarProps) => {
|
||||
const hasRejected = stats.rejected > 0;
|
||||
const canApply = hasRejected && !applying;
|
||||
|
|
@ -120,6 +143,35 @@ export const ReviewToolbar = ({
|
|||
|
||||
<div className="h-4 w-px bg-border" />
|
||||
|
||||
{/* Edited files indicator + actions */}
|
||||
{hasCurrentFileEdits && (
|
||||
<>
|
||||
<button
|
||||
onClick={onSaveCurrentFile}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-1 rounded bg-green-500/15 px-2 py-1 text-xs text-green-400 transition-colors hover:bg-green-500/25 disabled:opacity-50"
|
||||
title="Save file to disk (Cmd+Enter)"
|
||||
>
|
||||
{saving ? <Loader2 className="size-3 animate-spin" /> : <Save className="size-3" />}
|
||||
Save File
|
||||
</button>
|
||||
<button
|
||||
onClick={onDiscardCurrentFile}
|
||||
className="flex items-center gap-1 rounded bg-orange-500/15 px-2 py-1 text-xs text-orange-400 transition-colors hover:bg-orange-500/25"
|
||||
title="Discard edits for this file"
|
||||
>
|
||||
<Undo2 className="size-3" /> Discard
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{editedCount > 0 && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-amber-500/20 px-2 py-0.5 text-xs text-amber-400">
|
||||
<Pencil className="size-3" /> {editedCount} edited
|
||||
</span>
|
||||
)}
|
||||
|
||||
{(hasCurrentFileEdits || editedCount > 0) && <div className="h-4 w-px bg-border" />}
|
||||
|
||||
{/* Actions */}
|
||||
<button
|
||||
onClick={onAcceptAll}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { goToNextChunk, goToPreviousChunk } from '@codemirror/merge';
|
||||
import { acceptChunk, goToNextChunk, goToPreviousChunk } from '@codemirror/merge';
|
||||
|
||||
import type { EditorView } from '@codemirror/view';
|
||||
import type { FileChangeSummary } from '@shared/types/review';
|
||||
|
|
@ -27,7 +27,8 @@ export function useDiffNavigation(
|
|||
isDialogOpen: boolean,
|
||||
onHunkAccepted?: (filePath: string, hunkIndex: number) => void,
|
||||
onHunkRejected?: (filePath: string, hunkIndex: number) => void,
|
||||
onClose?: () => void
|
||||
onClose?: () => void,
|
||||
onSaveFile?: () => void
|
||||
): DiffNavigationState {
|
||||
// Track hunk index keyed by file path to auto-reset on file change
|
||||
const [hunkState, setHunkState] = useState<{ filePath: string | null; index: number }>({
|
||||
|
|
@ -105,95 +106,67 @@ export function useDiffNavigation(
|
|||
}, [selectedFilePath, currentHunkIndex, onHunkRejected]);
|
||||
|
||||
// Store refs for stable closure
|
||||
const goToNextHunkRef = useRef(goToNextHunk);
|
||||
const goToPrevHunkRef = useRef(goToPrevHunk);
|
||||
const goToNextFileRef = useRef(goToNextFile);
|
||||
const goToPrevFileRef = useRef(goToPrevFile);
|
||||
const acceptCurrentHunkRef = useRef(acceptCurrentHunk);
|
||||
const rejectCurrentHunkRef = useRef(rejectCurrentHunk);
|
||||
const onCloseRef = useRef(onClose);
|
||||
const onSaveFileRef = useRef(onSaveFile);
|
||||
|
||||
useEffect(() => {
|
||||
goToNextHunkRef.current = goToNextHunk;
|
||||
goToPrevHunkRef.current = goToPrevHunk;
|
||||
goToNextFileRef.current = goToNextFile;
|
||||
goToPrevFileRef.current = goToPrevFile;
|
||||
acceptCurrentHunkRef.current = acceptCurrentHunk;
|
||||
rejectCurrentHunkRef.current = rejectCurrentHunk;
|
||||
onCloseRef.current = onClose;
|
||||
}, [
|
||||
goToNextHunk,
|
||||
goToPrevHunk,
|
||||
goToNextFile,
|
||||
goToPrevFile,
|
||||
acceptCurrentHunk,
|
||||
rejectCurrentHunk,
|
||||
onClose,
|
||||
]);
|
||||
onSaveFileRef.current = onSaveFile;
|
||||
}, [onClose, onSaveFile]);
|
||||
|
||||
// Keyboard handler
|
||||
// Keyboard handler — new shortcuts for editable diff
|
||||
useEffect(() => {
|
||||
if (!isDialogOpen) return;
|
||||
|
||||
const handler = (event: KeyboardEvent) => {
|
||||
// Don't intercept when focus is in input/textarea
|
||||
// Skip if CM keymap already handled this event
|
||||
if (event.defaultPrevented) return;
|
||||
// Skip inputs/textareas
|
||||
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.key) {
|
||||
case 'j':
|
||||
case 'ArrowDown':
|
||||
if (!event.metaKey && !event.ctrlKey && !event.altKey) {
|
||||
event.preventDefault();
|
||||
goToNextHunkRef.current();
|
||||
}
|
||||
break;
|
||||
case 'k':
|
||||
case 'ArrowUp':
|
||||
if (!event.metaKey && !event.ctrlKey && !event.altKey) {
|
||||
event.preventDefault();
|
||||
goToPrevHunkRef.current();
|
||||
}
|
||||
break;
|
||||
case 'n':
|
||||
if (!event.shiftKey) {
|
||||
event.preventDefault();
|
||||
goToNextFileRef.current();
|
||||
} else {
|
||||
event.preventDefault();
|
||||
goToPrevFileRef.current();
|
||||
}
|
||||
break;
|
||||
case 'p':
|
||||
const isMeta = event.metaKey || event.ctrlKey;
|
||||
|
||||
// Alt+J -> next change
|
||||
if (event.altKey && event.key.toLowerCase() === 'j') {
|
||||
event.preventDefault();
|
||||
const view = editorViewRef.current;
|
||||
if (view) goToNextChunk(view);
|
||||
return;
|
||||
}
|
||||
|
||||
// Cmd+Enter -> save file
|
||||
if (isMeta && event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
onSaveFileRef.current?.();
|
||||
return;
|
||||
}
|
||||
|
||||
// Cmd+Y -> accept + scroll (fallback when editor not focused)
|
||||
if (isMeta && event.key.toLowerCase() === 'y') {
|
||||
event.preventDefault();
|
||||
const view = editorViewRef.current;
|
||||
if (view) {
|
||||
acceptChunk(view);
|
||||
requestAnimationFrame(() => goToNextChunk(view));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Escape handling
|
||||
if (event.key === 'Escape') {
|
||||
if (showShortcutsHelp) {
|
||||
event.preventDefault();
|
||||
goToPrevFileRef.current();
|
||||
break;
|
||||
case 'a':
|
||||
event.preventDefault();
|
||||
acceptCurrentHunkRef.current();
|
||||
break;
|
||||
case 'x':
|
||||
event.preventDefault();
|
||||
rejectCurrentHunkRef.current();
|
||||
break;
|
||||
case '?':
|
||||
event.preventDefault();
|
||||
setShowShortcutsHelp((prev) => !prev);
|
||||
break;
|
||||
case 'Escape':
|
||||
if (showShortcutsHelp) {
|
||||
event.preventDefault();
|
||||
setShowShortcutsHelp(false);
|
||||
}
|
||||
// Note: main Escape handling for closing dialog is in ChangeReviewDialog itself
|
||||
break;
|
||||
setShowShortcutsHelp(false);
|
||||
}
|
||||
// Note: main Escape handling for closing dialog is in ChangeReviewDialog itself
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handler);
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
}, [isDialogOpen, showShortcutsHelp]);
|
||||
}, [isDialogOpen, showShortcutsHelp, editorViewRef]);
|
||||
|
||||
return {
|
||||
currentHunkIndex,
|
||||
|
|
|
|||
|
|
@ -42,6 +42,9 @@ export interface ChangeReviewSlice {
|
|||
applyError: string | null;
|
||||
applying: boolean;
|
||||
|
||||
// Editable diff state
|
||||
editedContents: Record<string, string>;
|
||||
|
||||
// Phase 1 actions
|
||||
fetchAgentChanges: (teamName: string, memberName: string) => Promise<void>;
|
||||
fetchTaskChanges: (teamName: string, taskId: string) => Promise<void>;
|
||||
|
|
@ -65,6 +68,12 @@ export interface ChangeReviewSlice {
|
|||
) => Promise<void>;
|
||||
applyReview: (teamName: string, taskId?: string, memberName?: string) => Promise<void>;
|
||||
invalidateChangeStats: (teamName: string) => void;
|
||||
|
||||
// Editable diff actions
|
||||
updateEditedContent: (filePath: string, content: string) => void;
|
||||
discardFileEdits: (filePath: string) => void;
|
||||
discardAllEdits: () => void;
|
||||
saveEditedFile: (filePath: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeReviewSlice> = (
|
||||
|
|
@ -88,6 +97,9 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
|
|||
applyError: null,
|
||||
applying: false,
|
||||
|
||||
// Editable diff initial state
|
||||
editedContents: {},
|
||||
|
||||
fetchAgentChanges: async (teamName: string, memberName: string) => {
|
||||
set({ changeSetLoading: true, changeSetError: null });
|
||||
try {
|
||||
|
|
@ -136,6 +148,7 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
|
|||
fileContentsLoading: {},
|
||||
applyError: null,
|
||||
applying: false,
|
||||
editedContents: {},
|
||||
});
|
||||
},
|
||||
|
||||
|
|
@ -350,6 +363,40 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
|
|||
}
|
||||
},
|
||||
|
||||
// ── Editable diff actions ──
|
||||
|
||||
updateEditedContent: (filePath: string, content: string) => {
|
||||
set((s) => ({
|
||||
editedContents: { ...s.editedContents, [filePath]: content },
|
||||
}));
|
||||
},
|
||||
|
||||
discardFileEdits: (filePath: string) => {
|
||||
set((s) => {
|
||||
const next = { ...s.editedContents };
|
||||
delete next[filePath];
|
||||
return { editedContents: next };
|
||||
});
|
||||
},
|
||||
|
||||
discardAllEdits: () => set({ editedContents: {} }),
|
||||
|
||||
saveEditedFile: async (filePath: string) => {
|
||||
const content = get().editedContents[filePath];
|
||||
if (!(filePath in get().editedContents)) return;
|
||||
set({ applying: true, applyError: null });
|
||||
try {
|
||||
await api.review.saveEditedFile(filePath, content);
|
||||
set((s) => {
|
||||
const next = { ...s.editedContents };
|
||||
delete next[filePath];
|
||||
return { editedContents: next, applying: false };
|
||||
});
|
||||
} catch (error) {
|
||||
set({ applying: false, applyError: mapReviewError(error) });
|
||||
}
|
||||
},
|
||||
|
||||
invalidateChangeStats: (teamName: string) => {
|
||||
set((state) => {
|
||||
const newCache = { ...state.changeStatsCache };
|
||||
|
|
|
|||
214
src/renderer/utils/streamJsonParser.ts
Normal file
214
src/renderer/utils/streamJsonParser.ts
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
/**
|
||||
* Stream-JSON Parser
|
||||
*
|
||||
* Parses CLI stream-json stdout lines into AIGroupDisplayItem[] for rich rendering.
|
||||
* Used by CliLogsRichView to replace raw JSON display with beautiful components.
|
||||
*/
|
||||
|
||||
import { getToolSummary } from '@renderer/utils/toolRendering/toolSummaryHelpers';
|
||||
|
||||
import type { AIGroupDisplayItem, LinkedToolItem } from '@renderer/types/groups';
|
||||
|
||||
/**
|
||||
* A group of display items from one or more consecutive assistant messages.
|
||||
*/
|
||||
export interface StreamJsonGroup {
|
||||
/** Unique group ID */
|
||||
id: string;
|
||||
/** Display items within this group */
|
||||
items: AIGroupDisplayItem[];
|
||||
/** Human-readable summary (e.g. "1 thinking, 2 tool calls") */
|
||||
summary: string;
|
||||
/** Timestamp of first message in group */
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
interface ContentBlock {
|
||||
type: string;
|
||||
text?: string;
|
||||
thinking?: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
input?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to extract the content array from a parsed stream-json line.
|
||||
* Handles both `{ type: "assistant", content: [...] }` and
|
||||
* `{ message: { type: "assistant", content: [...] } }` formats.
|
||||
*/
|
||||
function extractContentBlocks(parsed: unknown): ContentBlock[] | null {
|
||||
if (!parsed || typeof parsed !== 'object') return null;
|
||||
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
|
||||
// Direct format: { type: "assistant", content: [...] }
|
||||
if (obj.type === 'assistant' && Array.isArray(obj.content)) {
|
||||
return obj.content as ContentBlock[];
|
||||
}
|
||||
|
||||
// Wrapped format: { message: { type: "assistant", content: [...] } }
|
||||
if (obj.message && typeof obj.message === 'object') {
|
||||
const msg = obj.message as Record<string, unknown>;
|
||||
if (msg.type === 'assistant' && Array.isArray(msg.content)) {
|
||||
return msg.content as ContentBlock[];
|
||||
}
|
||||
}
|
||||
|
||||
// Result format: { type: "result", result: { type: "assistant", content: [...] } }
|
||||
if (obj.type === 'result' && obj.result && typeof obj.result === 'object') {
|
||||
const result = obj.result as Record<string, unknown>;
|
||||
if (Array.isArray(result.content)) {
|
||||
return result.content as ContentBlock[];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts content blocks from a single assistant message into display items.
|
||||
* @param lineIndex - stable line position for deterministic fallback IDs
|
||||
*/
|
||||
function contentBlocksToDisplayItems(
|
||||
blocks: ContentBlock[],
|
||||
timestamp: Date,
|
||||
lineIndex: number
|
||||
): AIGroupDisplayItem[] {
|
||||
const items: AIGroupDisplayItem[] = [];
|
||||
|
||||
for (let blockIdx = 0; blockIdx < blocks.length; blockIdx++) {
|
||||
const block = blocks[blockIdx];
|
||||
switch (block.type) {
|
||||
case 'thinking': {
|
||||
const text = block.thinking ?? '';
|
||||
if (text.trim()) {
|
||||
items.push({ type: 'thinking', content: text, timestamp });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'text': {
|
||||
const text = block.text ?? '';
|
||||
if (text.trim()) {
|
||||
items.push({ type: 'output', content: text, timestamp });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'tool_use': {
|
||||
const input = block.input ?? {};
|
||||
const toolName = block.name ?? 'Unknown';
|
||||
const linkedTool: LinkedToolItem = {
|
||||
id: block.id ?? `stream-tool-L${lineIndex}-B${blockIdx}`,
|
||||
name: toolName,
|
||||
input,
|
||||
inputPreview: getToolSummary(toolName, input),
|
||||
startTime: timestamp,
|
||||
isOrphaned: true,
|
||||
};
|
||||
items.push({ type: 'tool', tool: linkedTool });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a human-readable summary string from display items.
|
||||
*/
|
||||
function buildGroupSummary(items: AIGroupDisplayItem[]): string {
|
||||
let thinkingCount = 0;
|
||||
let toolCount = 0;
|
||||
let outputCount = 0;
|
||||
|
||||
for (const item of items) {
|
||||
switch (item.type) {
|
||||
case 'thinking':
|
||||
thinkingCount++;
|
||||
break;
|
||||
case 'tool':
|
||||
toolCount++;
|
||||
break;
|
||||
case 'output':
|
||||
outputCount++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
if (thinkingCount > 0) parts.push(`${thinkingCount} thinking`);
|
||||
if (toolCount > 0) parts.push(`${toolCount} tool call${toolCount > 1 ? 's' : ''}`);
|
||||
if (outputCount > 0) parts.push(`${outputCount} output${outputCount > 1 ? 's' : ''}`);
|
||||
|
||||
return parts.join(', ') || 'empty';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses stream-json CLI output lines into structured groups for rich rendering.
|
||||
*
|
||||
* Each group represents one or more consecutive assistant messages.
|
||||
* Non-assistant lines (markers, errors, etc.) are silently skipped.
|
||||
*/
|
||||
export function parseStreamJsonToGroups(cliLogsTail: string): StreamJsonGroup[] {
|
||||
if (!cliLogsTail.trim()) return [];
|
||||
|
||||
const lines = cliLogsTail.split('\n');
|
||||
const groups: StreamJsonGroup[] = [];
|
||||
let currentItems: AIGroupDisplayItem[] = [];
|
||||
let currentTimestamp: Date | null = null;
|
||||
let groupCounter = 0;
|
||||
// Stable timestamp for the entire parse (deterministic across re-renders)
|
||||
const parseTimestamp = new Date();
|
||||
|
||||
const flushGroup = (): void => {
|
||||
if (currentItems.length > 0 && currentTimestamp) {
|
||||
groups.push({
|
||||
id: `stream-group-${groupCounter++}`,
|
||||
items: currentItems,
|
||||
summary: buildGroupSummary(currentItems),
|
||||
timestamp: currentTimestamp,
|
||||
});
|
||||
currentItems = [];
|
||||
currentTimestamp = null;
|
||||
}
|
||||
};
|
||||
|
||||
for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
|
||||
const trimmed = lines[lineIndex].trim();
|
||||
|
||||
// Skip empty lines and stream markers
|
||||
if (!trimmed || trimmed.startsWith('[stdout]') || trimmed.startsWith('[stderr]')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try to parse as JSON
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(trimmed);
|
||||
} catch {
|
||||
// Non-JSON line (truncated, marker, etc.) — flush and skip
|
||||
flushGroup();
|
||||
continue;
|
||||
}
|
||||
|
||||
const blocks = extractContentBlocks(parsed);
|
||||
if (!blocks) {
|
||||
// Valid JSON but not an assistant message — flush and skip
|
||||
flushGroup();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!currentTimestamp) currentTimestamp = parseTimestamp;
|
||||
|
||||
const items = contentBlocksToDisplayItems(blocks, parseTimestamp, lineIndex);
|
||||
currentItems.push(...items);
|
||||
}
|
||||
|
||||
// Flush remaining items
|
||||
flushGroup();
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
|
@ -463,6 +463,9 @@ export interface ReviewAPI {
|
|||
hunkIndices: number[],
|
||||
snippets: SnippetDiff[]
|
||||
) => Promise<{ preview: string; hasConflicts: boolean }>;
|
||||
// Editable diff
|
||||
saveEditedFile: (filePath: string, content: string) => Promise<{ success: boolean }>;
|
||||
onCmdN?: (callback: () => void) => (() => void) | undefined;
|
||||
// Phase 4
|
||||
getGitFileLog: (
|
||||
projectPath: string,
|
||||
|
|
|
|||
Loading…
Reference in a new issue