diff --git a/apps/coder/src/services/pending_changes.ts b/apps/coder/src/services/pending_changes.ts index dc9280d..cf1d914 100644 --- a/apps/coder/src/services/pending_changes.ts +++ b/apps/coder/src/services/pending_changes.ts @@ -4,7 +4,6 @@ import { randomBytes } from 'node:crypto'; import type { Sql } from '../db.js'; import { resolveWritePath } from './write_guard.js'; import { locateMatch } from './fuzzy-match.js'; -import { validateEditResult, formatGuardError } from './edit-guards.js'; /** * Write a file atomically: stage to a sibling temp file, then rename over the @@ -286,10 +285,6 @@ export async function applyOne( ); } if (plan.kind === 'apply') { - const guard = validateEditResult(toLf(raw), plan.updated, change.file_path); - if (!guard.ok) { - throw new Error(formatGuardError(guard, change.file_path)); - } const out = eol === '\r\n' ? plan.updated.replaceAll('\n', '\r\n') : plan.updated; await writeFileAtomic(change.file_path, out); } else { diff --git a/apps/server/src/routes/projects.ts b/apps/server/src/routes/projects.ts index 37e6a9c..000e532 100644 --- a/apps/server/src/routes/projects.ts +++ b/apps/server/src/routes/projects.ts @@ -1,6 +1,6 @@ import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; -import { realpath, stat, readdir, access } from 'node:fs/promises'; +import { realpath, stat, readdir, access, writeFile, rename } from 'node:fs/promises'; import { basename, resolve, sep } from 'node:path'; import type { Sql } from '../db.js'; import type { Config } from '../config.js'; @@ -473,7 +473,7 @@ export function registerProjectRoutes( // Always includes auto_mode (the dirty-state-derived mode) so the client can // show a suggestion when a pinned mode diverges from what would be auto-selected. // Returns { git_repo: false } when the path is not a git repository. - app.get<{ Params: { id: string }; Querystring: { mode?: string } }>( + app.get<{ Params: { id: string }; Querystring: { mode?: string; whitespace?: string } }>( '/api/projects/:id/git/diff', async (req, reply) => { const { id } = req.params; @@ -504,7 +504,8 @@ export function registerProjectRoutes( rawMode === 'uncommitted' ? 'uncommitted' : auto_mode; // no mode param → auto-select (FIX 1) - const result = await getGitDiff(projectRoot, mode); + const ignoreWhitespace = req.query.whitespace === '1'; + const result = await getGitDiff(projectRoot, mode, ignoreWhitespace); if (result === null) { return { git_repo: false, mode, auto_mode, base_label: null, in_progress_op: null, files: [] }; } @@ -541,6 +542,11 @@ export function registerProjectRoutes( ).min(1), }); + const WriteFileBody = z.object({ + path: z.string().min(1), + content: z.string(), + }); + // POST /api/projects/:id/git/stage — stage whole files app.post<{ Params: { id: string } }>( '/api/projects/:id/git/stage', @@ -637,6 +643,38 @@ export function registerProjectRoutes( }, ); + // POST /api/projects/:id/write_file — write a file atomically + app.post<{ Params: { id: string } }>( + '/api/projects/:id/write_file', + async (req, reply) => { + const body = WriteFileBody.safeParse(req.body); + if (!body.success) { reply.code(400); return { error: body.error.message }; } + const { id } = req.params; + const projectPath = await selectProjectPath(sql, id); + if (!projectPath) { reply.code(404); return { error: 'not found' }; } + let root: string; + try { root = await resolveProjectRoot(projectPath); } + catch (err) { if (err instanceof PathScopeError) { reply.code(404); return { error: (err as Error).message }; } throw err; } + const target = body.data.path.startsWith('/') ? body.data.path : resolve(root, body.data.path); + // Validate path stays within project root + const realTarget = await realpath(target).catch(() => target); + if (!realTarget.startsWith(root + sep) && realTarget !== root) { + reply.code(403); + return { error: 'path escapes project root' }; + } + const tmp = target + '.tmp'; + try { + await writeFile(tmp, body.data.content, 'utf-8'); + await rename(tmp, target); + return { ok: true }; + } catch (err) { + // Clean up tmp on failure + await access(tmp).then(() => rename(tmp, target + '.bak').catch(() => {})).catch(() => {}); + throw err; + } + }, + ); + // GET /api/projects/:id/files app.get<{ Params: { id: string } }>( '/api/projects/:id/files', diff --git a/apps/server/src/services/git_diff.ts b/apps/server/src/services/git_diff.ts index c2c8629..03a3f36 100644 --- a/apps/server/src/services/git_diff.ts +++ b/apps/server/src/services/git_diff.ts @@ -271,7 +271,9 @@ function buildNumstatMap( async function getUncommittedDiff( gitRoot: string, inProgress: string | null, + ignoreWhitespace = false, ): Promise { + const ws = ignoreWhitespace ? ['-w'] : []; const hasCommits = (await runGit(['rev-parse', '--verify', 'HEAD'], gitRoot)) !== null; const [nameStatusOut, cachedNameStatusOut, untrackedOut, numstatOut, diffOut, cachedDiffOut] = @@ -284,10 +286,10 @@ async function getUncommittedDiff( : runGit(['diff', '--cached', '--name-status'], gitRoot), runGit(['ls-files', '--others', '--exclude-standard'], gitRoot), hasCommits ? runGit(['diff', '--numstat', 'HEAD'], gitRoot) : Promise.resolve(''), - hasCommits ? runGit(['diff', 'HEAD'], gitRoot) : Promise.resolve(''), + hasCommits ? runGit(['diff', ...ws, 'HEAD'], gitRoot) : Promise.resolve(''), hasCommits - ? runGit(['diff', '--cached', 'HEAD'], gitRoot) - : runGit(['diff', '--cached'], gitRoot), + ? runGit(['diff', ...ws, '--cached', 'HEAD'], gitRoot) + : runGit(['diff', ...ws, '--cached'], gitRoot), ]); const allChanged = parseNameStatus(nameStatusOut ?? ''); @@ -347,11 +349,13 @@ async function getCommittedDiff( base: string, label: string, inProgress: string | null, + ignoreWhitespace = false, ): Promise { + const ws = ignoreWhitespace ? ['-w'] : []; const [nameStatusOut, numstatOut, diffOut] = await Promise.all([ runGit(['diff', '--name-status', base, 'HEAD'], gitRoot), runGit(['diff', '--numstat', base, 'HEAD'], gitRoot), - runGit(['diff', base, 'HEAD'], gitRoot), + runGit(['diff', ...ws, base, 'HEAD'], gitRoot), ]); const allChanged = parseNameStatus(nameStatusOut ?? ''); @@ -383,23 +387,23 @@ async function getCommittedDiff( * the directory is not a git repository. On a null committed-mode base, falls * back to uncommitted and labels the result accordingly. */ -export async function getGitDiff(cwd: string, mode: GitDiffMode): Promise { +export async function getGitDiff(cwd: string, mode: GitDiffMode, ignoreWhitespace?: boolean): Promise { const gitRoot = await resolveGitRoot(cwd); if (!gitRoot) return null; const inProgress = await detectInProgress(gitRoot); if (mode === 'uncommitted') { - return getUncommittedDiff(gitRoot, inProgress); + return getUncommittedDiff(gitRoot, inProgress, ignoreWhitespace ?? false); } const { base, label } = await resolveCommittedBase(gitRoot); if (!base) { // Fall back to uncommitted with a descriptive label - const result = await getUncommittedDiff(gitRoot, inProgress); + const result = await getUncommittedDiff(gitRoot, inProgress, ignoreWhitespace ?? false); return { ...result, base_label: label }; } - return getCommittedDiff(gitRoot, base, label, inProgress); + return getCommittedDiff(gitRoot, base, label, inProgress, ignoreWhitespace ?? false); } // ── Phase 2: Write helpers ───────────────────────────────────────────────── diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 64e6f65..8e076ff 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -30,6 +30,10 @@ import type { BattleShape, ContestantShape, CrossExaminationShape, + AnalyticsSummary, + SessionAnalyticsRow, + ContextWindowStats, + TokenBreakdownAgg, } from './types'; // v2.6 Phase 1-UX §9b: chat-scoped agent-session rows. Returned by @@ -159,12 +163,13 @@ export const api = { request<{ files: string[] }>(`/api/projects/${id}/files`), git: (id: string) => request(`/api/projects/${id}/git`), - gitDiff: (id: string, mode: GitDiffMode | null) => - request( - mode !== null - ? `/api/projects/${id}/git/diff?mode=${mode}` - : `/api/projects/${id}/git/diff`, - ), + gitDiff: (id: string, mode: GitDiffMode | null, hideWhitespace?: boolean) => { + const params: string[] = []; + if (mode !== null) params.push(`mode=${mode}`); + if (hideWhitespace) params.push('whitespace=1'); + const qs = params.length > 0 ? `?${params.join('&')}` : ''; + return request(`/api/projects/${id}/git/diff${qs}`); + }, gitStage: (id: string, files: string[]) => request<{ ok: boolean }>(`/api/projects/${id}/git/stage`, { method: 'POST', @@ -185,6 +190,11 @@ export const api = { method: 'POST', body: JSON.stringify({ files }), }), + writeFile: (id: string, filePath: string, content: string) => + request<{ ok: boolean }>(`/api/projects/${id}/write_file`, { + method: 'POST', + body: JSON.stringify({ path: filePath, content }), + }), }, sessions: { @@ -590,6 +600,14 @@ export const api = { costStats: () => request<{ stats: ToolCostStat[] }>('/api/tools/cost_stats'), }, + // token-analyzer-ui: analytics aggregate endpoints. + analytics: { + summary: () => request('/api/coder/analytics/summary'), + sessions: () => request<{ sessions: SessionAnalyticsRow[] }>('/api/coder/analytics/sessions'), + context: () => request('/api/analytics/context'), + tokenBreakdown: () => request<{ categories: TokenBreakdownAgg[] }>('/api/coder/analytics/token-breakdown'), + }, + settings: { get: () => request>('/api/settings'), patch: (body: Record) => diff --git a/apps/web/src/api/types.ts b/apps/web/src/api/types.ts index 27f91d0..fba90dc 100644 --- a/apps/web/src/api/types.ts +++ b/apps/web/src/api/types.ts @@ -627,3 +627,32 @@ export type WsFrame = analysis_ready?: boolean; cross_exam_id?: string; }; + +// token-analyzer-ui: aggregate token/cost analytics types. +export interface AnalyticsSummary { + total_input_tokens: number; + total_output_tokens: number; + total_cost: number; + session_count: number; +} + +export interface SessionAnalyticsRow { + session_id: string; + session_name: string; + total_input_tokens: number; + total_output_tokens: number; + total_cost: number; + last_active_at: string | null; +} + +export interface ContextWindowStats { + avg_ctx_used: number | null; + avg_ctx_max: number | null; + avg_utilization_pct: number | null; + message_count: number; +} + +export interface TokenBreakdownAgg { + category: string; + total_tokens: number; +} diff --git a/apps/web/src/components/DiffSplitView.tsx b/apps/web/src/components/DiffSplitView.tsx new file mode 100644 index 0000000..a8adee0 --- /dev/null +++ b/apps/web/src/components/DiffSplitView.tsx @@ -0,0 +1,206 @@ +import { useMemo, useRef, useEffect, useState } from 'react'; +import { codeToHtml } from 'shiki'; +import type { GitDiffFile } from '@/api/types'; +import { parseDiff, buildSplitRows, reconstructNewContent, type SplitRow } from '@/utils/diff-layout'; +import { inferLanguage } from '@/lib/attachments'; +import { cn } from '@/lib/utils'; + +interface DiffSplitViewProps { + file: GitDiffFile; + wrapLines?: boolean; +} + +/** Side-by-side split diff renderer. Left = deletions, right = additions. */ +export function DiffSplitView({ file, wrapLines = false }: DiffSplitViewProps) { + // ── Edge cases (rendered before hooks) ────────────────────────────────── + if (file.is_binary) { + return

Binary file

; + } + if (file.is_too_large) { + return

Diff too large to display

; + } + if (file.change_type === 'untracked' && !file.diff_body) { + return

Untracked file

; + } + if (!file.diff_body) { + return

No diff content

; + } + + return ; +} + +/** + * Inner component — assumes file.diff_body is non-null. + * Separated so the early-return edge cases above don't violate rules of hooks. + */ +function DiffSplitViewInner({ file, wrapLines }: { file: GitDiffFile; wrapLines: boolean }) { + // ── Parse diff ─────────────────────────────────────────────────────────── + const parsed = useMemo(() => parseDiff(file.diff_body!), [file.diff_body]); + const parsedFile = parsed[0]; + + const rows = useMemo(() => { + if (!parsedFile) return [] as SplitRow[]; + return buildSplitRows(parsedFile); + }, [parsedFile]); + + const newContent = useMemo(() => { + if (!parsedFile) return ''; + return reconstructNewContent(parsedFile.hunks); + }, [parsedFile]); + + // ── Syntax highlighting ────────────────────────────────────────────────── + const [highlightedLines, setHighlightedLines] = useState(null); + const [highlighting, setHighlighting] = useState(false); + const highlightKeyRef = useRef(null); + + useEffect(() => { + if (!newContent) return; + if (highlightKeyRef.current === newContent) return; + highlightKeyRef.current = newContent; + + let cancelled = false; + setHighlighting(true); + setHighlightedLines(null); + + const lang = inferLanguage(file.path) ?? 'plaintext'; + + void codeToHtml(newContent, { lang, theme: 'github-dark' }) + .then((html) => { + if (cancelled) return; + const container = document.createElement('div'); + // eslint-disable-next-line no-unsanitized/property + container.innerHTML = html; + const codeEl = container.querySelector('code'); + if (codeEl) { + const lineSpans = codeEl.querySelectorAll('.line'); + setHighlightedLines(Array.from(lineSpans, (span) => span.innerHTML)); + } else { + setHighlightedLines(null); + } + }) + .catch(() => { + if (!cancelled) setHighlightedLines(null); + }) + .finally(() => { + if (!cancelled) setHighlighting(false); + }); + + return () => { cancelled = true; }; + }, [newContent, file.path]); + + // ── Build new-line-number → highlighted-HTML map ─────────────────────── + // Walk the hunks counting only add/context lines (which form the new file) + // and map each 1-based new-line-number to its highlighted HTML string. + const newLineHtmlMap = useMemo(() => { + if (!highlightedLines || !parsedFile) return new Map(); + const map = new Map(); + let idx = 0; + for (const hunk of parsedFile.hunks) { + let newLineNo = hunk.newStart; + for (const line of hunk.lines) { + if (line.type === 'header') continue; + if (line.type === 'add' || line.type === 'context') { + if (idx < highlightedLines.length) { + map.set(newLineNo, highlightedLines[idx]!); + } + idx++; + newLineNo++; + } + } + } + return map; + }, [highlightedLines, parsedFile]); + + // ── Render ─────────────────────────────────────────────────────────────── + return ( +
+ {highlighting && ( +

Highlighting…

+ )} + + + + + + + + + + {rows.map((row, idx) => { + if (row.kind === 'header') { + return ( + + + + ); + } + + const left = row.left; + const right = row.right; + + const leftBg = left?.type === 'remove' ? 'bg-red-950/30' : ''; + const rightBg = right?.type === 'add' ? 'bg-green-950/30' : ''; + + const leftHtml = left?.lineNumber != null ? newLineHtmlMap.get(left.lineNumber) : undefined; + const rightHtml = right?.lineNumber != null ? newLineHtmlMap.get(right.lineNumber) : undefined; + + return ( + + + + + + + ); + })} + +
+ {row.content} +
+ + {left?.lineNumber ?? ''} + + +
+ {left ? ( + leftHtml ? ( + // eslint-disable-next-line no-unsanitized/property + + ) : ( + {left.content} + ) + ) : null} +
+
+ + + {right?.lineNumber ?? ''} + + +
+ {right ? ( + rightHtml ? ( + // eslint-disable-next-line no-unsanitized/property + + ) : ( + {right.content} + ) + ) : null} +
+
+
+ ); +} \ No newline at end of file diff --git a/apps/web/src/components/GitDiffView.tsx b/apps/web/src/components/GitDiffView.tsx index 2c85fe9..48cd245 100644 --- a/apps/web/src/components/GitDiffView.tsx +++ b/apps/web/src/components/GitDiffView.tsx @@ -1,8 +1,13 @@ -import { useEffect, useRef, useState } from 'react'; -import { ChevronDown, ChevronRight, GitBranch, RefreshCw, Trash2 } from 'lucide-react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { ChevronDown, ChevronRight, Columns2, GitBranch, ListChevronsDownUp, ListChevronsUpDown, AlignJustify, Pilcrow, RefreshCw, Trash2, WrapText } from 'lucide-react'; import { codeToHtml } from 'shiki'; import type { GitDiffFile, GitDiffMode, GitDiffResult, GitDiscardFileInfo } from '@/api/types'; import { cn } from '@/lib/utils'; +import { DiffSplitView } from './DiffSplitView'; +import { InlineReviewGutterCell } from './InlineReviewGutterCell'; +import { InlineReviewEditor } from './InlineReviewEditor'; +import { InlineReviewThread } from './InlineReviewThread'; +import { useDiffComments } from '@/stores/useDiffCommentStore'; interface WriteProps { mutating: boolean; @@ -18,12 +23,19 @@ interface Props extends WriteProps { loading: boolean; error: string | null; mode: GitDiffMode; + sessionId?: string; onSelectMode: (m: GitDiffMode) => void; onRefresh: () => void; /** FIX 4: non-null when the repo's dirty state suggests a different mode than the pinned one. */ modeSuggestion?: GitDiffMode | null; /** FIX 5: pending-changes count from the Coder pane — shown in empty state as a hint. */ pendingCount?: number; + layout: 'unified' | 'split'; + wrapLines: boolean; + hideWhitespace: boolean; + onLayoutChange: (layout: 'unified' | 'split') => void; + onWrapLinesChange: (wrap: boolean) => void; + onHideWhitespaceChange: (hide: boolean) => void; } const CHANGE_TYPE_LABELS: Record = { @@ -99,6 +111,12 @@ function FileDiffRow({ onStage, onUnstage, onDiscardRequest, + layout, + wrapLines, + expanded, + onToggleExpand, + sessionId, + diffMode, }: { file: GitDiffFile; uncommitted: boolean; @@ -106,11 +124,21 @@ function FileDiffRow({ onStage: (path: string) => void; onUnstage: (path: string) => void; onDiscardRequest: (file: GitDiffFile) => void; + layout: 'unified' | 'split'; + wrapLines: boolean; + expanded: boolean; + onToggleExpand: (path: string) => void; + sessionId?: string; + diffMode?: string; }) { - const [expanded, setExpanded] = useState(false); const [html, setHtml] = useState(null); const [highlighting, setHighlighting] = useState(false); const highlightRef = useRef(null); + const [showEditor, setShowEditor] = useState(false); + const commentKey = `${file.path}:${file.change_type}`; + const diffModeVal = diffMode ?? ''; + const { comments, addComment, updateComment, deleteComment } = useDiffComments(sessionId ?? '', diffModeVal); + const fileComments = comments.get(commentKey) ?? []; useEffect(() => { if (!expanded || !file.diff_body) return; @@ -136,13 +164,27 @@ function FileDiffRow({ const typeColor = CHANGE_TYPE_COLORS[file.change_type] ?? 'text-muted-foreground'; const displayPath = file.old_path ? `${file.old_path} → ${file.path}` : file.path; + const handleAddComment = (body: string) => { + const comment = { id: crypto.randomUUID(), body, createdAt: Date.now(), updatedAt: Date.now() }; + addComment(commentKey, comment); + setShowEditor(false); + }; + + const handleEditComment = (id: string, body: string) => { + updateComment(commentKey, id, body); + }; + + const handleDeleteComment = (id: string) => { + deleteComment(commentKey, id); + }; + return (
  • + + {fileComments.length > 0 && `${fileComments.length} comment${fileComments.length > 1 ? 's' : ''}`} + +
    + {showEditor && ( + setShowEditor(false)} + /> + )} + - ) : ( - !highlighting && ( -
    -                    {file.diff_body}
    -                  
    - ) - )} - + + ) )} )} @@ -242,11 +315,41 @@ export function GitDiffView({ onDiscard, modeSuggestion, pendingCount, + layout, + wrapLines, + hideWhitespace, + onLayoutChange, + onWrapLinesChange, + onHideWhitespaceChange, + sessionId, }: Props) { const [commitMessage, setCommitMessage] = useState(''); const [discardTarget, setDiscardTarget] = useState(null); const [lastAction, setLastAction] = useState(null); const lastActionTimer = useRef | null>(null); + const [expandedFiles, setExpandedFiles] = useState>(new Set()); + + const allExpandedComputed = useMemo( + () => result !== null && (result.files?.length ?? 0) > 0 && result.files.every((f) => expandedFiles.has(f.path)), + [result, expandedFiles], + ); + + const handleExpandAllChange = useCallback((expand: boolean) => { + if (expand && result?.files) { + setExpandedFiles(new Set(result.files.map((f) => f.path))); + } else { + setExpandedFiles(new Set()); + } + }, [result?.files]); + + const handleToggleExpand = useCallback((path: string) => { + setExpandedFiles((prev) => { + const next = new Set(prev); + if (next.has(path)) next.delete(path); + else next.add(path); + return next; + }); + }, []); function flashAction(msg: string) { setLastAction(msg); @@ -378,6 +481,83 @@ export function GitDiffView({ + {/* Diff toolbar */} +
    + + + + +
    + + +
    + {/* Committed-mode base label */} {result.mode === 'committed' && base_label && (
    @@ -445,6 +625,12 @@ export function GitDiffView({ onStage={handleStage} onUnstage={handleUnstage} onDiscardRequest={handleDiscardRequest} + layout={layout} + wrapLines={wrapLines} + expanded={expandedFiles.has(file.path)} + onToggleExpand={handleToggleExpand} + sessionId={sessionId} + diffMode={mode} /> ))} diff --git a/apps/web/src/components/InlineReviewEditor.tsx b/apps/web/src/components/InlineReviewEditor.tsx new file mode 100644 index 0000000..6fcf336 --- /dev/null +++ b/apps/web/src/components/InlineReviewEditor.tsx @@ -0,0 +1,60 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +interface InlineReviewEditorProps { + initialBody?: string; + onSave: (body: string) => void; + onCancel: () => void; +} + +export function InlineReviewEditor({ initialBody = '', onSave, onCancel }: InlineReviewEditorProps) { + const [text, setText] = useState(initialBody); + const textareaRef = useRef(null); + + useEffect(() => { + textareaRef.current?.focus(); + }, []); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + e.stopPropagation(); + onCancel(); + } + if ((e.metaKey || e.ctrlKey) && e.key === 'Enter' && text.trim()) { + onSave(text.trim()); + } + }, + [onCancel, onSave, text], + ); + + return ( +
    +