Skip to content
24 changes: 23 additions & 1 deletion shell/ui/src/page/EditorPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
remounted per file (key=path) and rehydrates from the cache instead
of re-reading. */

import { CodeEditor, type Host } from '@iii-dev/console-ui'
import { CodeEditor, type Host, IconButton } from '@iii-dev/console-ui'
import { Code, Eye } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { errorMessage, formatBytes } from '../lib/format'
import {
Expand All @@ -16,6 +17,7 @@ import {
coderWriteFile,
joinPath,
} from './coder'
import { isRichPreviewPath, richPreviewNode } from './rich-preview'

/** File-extension → Monaco language id (unknown ids render plain). */
export function monacoLangFromPath(path: string): string {
Expand Down Expand Up @@ -131,17 +133,26 @@ interface EditorPaneProps {
onSaved: () => void
/** Dirty-flag transitions — the page pins the tab on first edit. */
onDirtyChange: (relPath: string, dirty: boolean) => void
/** Global review-pane preference; the header toggle overrides it per file. */
richPreview?: boolean
}

export function EditorPane({
host,
root,
relPath,
cache,
richPreview = false,
onSaved,
onDirtyChange,
}: EditorPaneProps) {
const absPath = joinPath(root, relPath)
const previewable = isRichPreviewPath(relPath)
const [previewChoice, setPreviewChoice] = useState<boolean | null>(null)
useEffect(() => {
setPreviewChoice(null)
}, [richPreview, relPath])
const showPreview = previewable && (previewChoice ?? richPreview)
const [pane, setPane] = useState<PaneState>({ phase: 'loading' })
const [draft, setDraftState] = useState('')
const [savedContent, setSavedContent] = useState('')
Expand Down Expand Up @@ -296,6 +307,15 @@ export function EditorPane({
) : null}
<span className="spacer" />
{saveError ? <span className="shui-ro-note warn">{saveError}</span> : null}
{previewable ? (
<IconButton
label={showPreview ? 'Show source' : 'Show preview'}
aria-pressed={showPreview}
onClick={() => setPreviewChoice(!showPreview)}
>
{showPreview ? <Code aria-hidden /> : <Eye aria-hidden />}
</IconButton>
) : null}
<button
type="button"
className="shui-save-btn"
Expand All @@ -316,6 +336,8 @@ export function EditorPane({
<div className="shui-image-wrap">
<img src={entry.image} alt={relPath} />
</div>
) : showPreview ? (
<div className="shui-editor-preview">{richPreviewNode(relPath, draft)}</div>
) : (
<CodeEditor
value={draft}
Expand Down
1 change: 1 addition & 0 deletions shell/ui/src/page/FilesTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ export function FilesTab({
const { model } = useFileTree({
fileTreeSearchMode: 'hide-non-matches',
flattenEmptyDirectories: true,
icons: { set: 'complete', colored: true },
itemHeight: 29,
paths: tree?.paths ?? [],
search: false,
Expand Down
87 changes: 46 additions & 41 deletions shell/ui/src/page/ReviewPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,7 @@ import {
type Host,
} from '@iii-dev/console-ui'
import {
ChevronDown,
ChevronRight,
FileCode2,
Pencil,
Save,
X,
Expand All @@ -15,10 +13,14 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { errorMessage } from '../lib/format'
import {
coderReadFile,
coderReadFileBase64,
coderWriteFile,
joinPath,
type ReadFileResponse,
} from './coder'
import { imageMimeFromPath } from './EditorPane'
import { FileTypeIcon } from './file-type-icon'
import { richPreviewNode } from './rich-preview'
import { diffLines, diffTotals } from './diff'
import { gitHeadBaseline, gitReadSource, gitShowHead } from './git'
import type { ReviewEntry } from './review'
Expand Down Expand Up @@ -350,6 +352,9 @@ export interface ReviewContents {
newContents: string
worktreeRevision?: string
mode?: number | null
/** Raster image rows: data URL of the working copy, `null` once deleted.
Text fields stay empty because the bytes are not diffable. */
image?: string | null
/** Set when the old side is the last commit rather than this turn's
pre-turn snapshot. */
baselineSource?: 'committed'
Expand Down Expand Up @@ -382,11 +387,31 @@ async function loadCommittedFallback(
}
}

async function loadImageContents(
host: Host,
root: string,
entry: ReviewEntry,
mime: string,
): Promise<ReviewContents> {
const path = reviewEntryWorktreePath(entry)
if (path === null) return { oldContents: '', newContents: '', image: null }
const out = await coderReadFileBase64(host, joinPath(root, path))
return {
oldContents: '',
newContents: '',
worktreeRevision: out.revision ?? undefined,
mode: out.mode,
image: `data:${mime};base64,${out.content ?? ''}`,
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}

export async function loadReviewContents(
host: Host,
root: string,
entry: ReviewEntry,
): Promise<ReviewContents> {
const mime = imageMimeFromPath(entry.path)
if (mime !== null) return loadImageContents(host, root, entry, mime)
if (entry.before !== undefined && entry.after !== undefined) {
const oldSide = gitReadSource(host, root, entry.before)
if (entry.after.kind === 'worktree') {
Expand Down Expand Up @@ -934,7 +959,8 @@ function ReviewFile({
const editable =
reviewEntryWorktreePath(entry) !== null &&
state.phase === 'ready' &&
state.worktreeRevision !== undefined
state.worktreeRevision !== undefined &&
state.image === undefined
const dirty = editing && draft !== editBaseContents
const changedOnDisk =
editing &&
Expand Down Expand Up @@ -1058,9 +1084,7 @@ function ReviewFile({
const editStatus = reviewEditStatus(editing, editorState, saving)

const rich =
renderBody && state.phase === 'ready'
? richPreviewFor(entry.path, state.newContents)
: null
renderBody && state.phase === 'ready' ? richPreviewFor(entry.path, state) : null
return (
<section
ref={sectionRef}
Expand All @@ -1085,8 +1109,8 @@ function ReviewFile({
onClick={onToggle}
aria-expanded={!collapsed}
>
{collapsed ? <ChevronRight aria-hidden /> : <ChevronDown aria-hidden />}
<FileCode2 aria-hidden className="file-icon" />
<ChevronRight aria-hidden className={collapsed ? 'chevron' : 'chevron open'} />
<FileTypeIcon path={entry.path} className="file-icon" />
<span className="path" title={entry.path}>
{entry.change.from ? `${entry.change.from} → ${entry.path}` : entry.path}
</span>
Expand Down Expand Up @@ -1174,10 +1198,15 @@ function ReviewFile({
<div className="shui-review-message warn">{state.message}</div>
) : !editing && options.richPreview && rich !== null ? (
rich
) : !editing && state.image !== undefined ? (
<div className="shui-review-message">
{state.image === null ? 'image deleted' : 'binary image; enable rich preview to view it'}
</div>
) : !editing && totals?.add === 0 && totals.del === 0 ? (
<div className="shui-review-message">no line changes</div>
) : (
<FileDiff
key={options.diffStyle}
oldFile={{ name: entry.change.from ?? entry.path, contents: state.oldContents }}
newFile={{ name: entry.path, contents: editing ? draft : state.newContents }}
diffStyle={options.diffStyle}
Expand All @@ -1200,38 +1229,14 @@ function ReviewFile({
)
}

function richPreviewFor(path: string, contents: string): React.ReactNode | null {
const lower = path.toLowerCase()
if (lower.endsWith('.html') || lower.endsWith('.htm')) {
return <iframe className="shui-rich-preview" title={`preview ${path}`} sandbox="" srcDoc={contents} />
}
if (lower.endsWith('.svg')) {
const src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(contents)}`
return <img className="shui-rich-preview-image" src={src} alt={`preview ${path}`} />
}
if (lower.endsWith('.md') || lower.endsWith('.markdown')) {
return <MarkdownPreview contents={contents} />
function richPreviewFor(
path: string,
state: Extract<FileState, { phase: 'ready' }>,
): React.ReactNode | null {
if (state.image !== undefined) {
return state.image === null ? null : (
<img className="shui-rich-preview-image" src={state.image} alt={`preview ${path}`} />
)
}
return null
}

function MarkdownPreview({ contents }: { contents: string }) {
return (
<article className="shui-markdown-preview">
{contents.split('\n').map((line, index) => {
const heading = /^(#{1,4})\s+(.*)$/.exec(line)
if (heading) {
const level = heading[1].length
const text = heading[2]
if (level === 1) return <h1 key={index}>{text}</h1>
if (level === 2) return <h2 key={index}>{text}</h2>
if (level === 3) return <h3 key={index}>{text}</h3>
return <h4 key={index}>{text}</h4>
}
if (/^[-*]\s+/.test(line)) return <li key={index}>{line.slice(2)}</li>
if (line.trim() === '') return <br key={index} />
return <p key={index}>{line}</p>
})}
</article>
)
return richPreviewNode(path, state.newContents)
}
25 changes: 25 additions & 0 deletions shell/ui/src/page/ReviewScopePicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,12 @@ import {
ChevronDown,
ChevronLeft,
ChevronRight,
FilePen,
GitBranch,
GitCommitHorizontal,
History,
Layers,
ListChecks,
} from 'lucide-react'
import { useEffect, useRef, useState } from 'react'

Expand Down Expand Up @@ -44,6 +48,23 @@ export function reviewScopeLabel(scope: ReviewScopeSelection): string {
}
}

function scopeIcon(scope: ReviewScopeSelection) {
switch (scope.kind) {
case 'last-turn':
return <History aria-hidden className="menu-icon" />
case 'uncommitted':
return <FilePen aria-hidden className="menu-icon" />
case 'unstaged':
return <ListChecks aria-hidden className="menu-icon" />
case 'staged':
return <Layers aria-hidden className="menu-icon" />
case 'commit':
return <GitCommitHorizontal aria-hidden className="menu-icon" />
case 'branch':
return <GitBranch aria-hidden className="menu-icon" />
}
}

function scopeMatches(left: ReviewScopeSelection, right: ReviewScopeSelection): boolean {
if (left.kind !== right.kind) return false
if (left.kind === 'commit' && right.kind === 'commit') return left.sha === right.sha
Expand Down Expand Up @@ -120,6 +141,7 @@ export function ReviewScopePicker({
if (next) onOpen()
}}
>
{scopeIcon(value)}
<span>{reviewScopeLabel(value)}</span>
<ChevronDown aria-hidden />
</button>
Expand All @@ -136,17 +158,20 @@ export function ReviewScopePicker({
aria-checked={scopeMatches(value, scope)}
onClick={() => choose(scope)}
>
{scopeIcon(scope)}
<span>{reviewScopeLabel(scope)}</span>
{scopeMatches(value, scope) ? <Check aria-hidden /> : <span className="menu-icon-gap" />}
</button>
</div>
))}
<div className="shui-review-menu-separator" />
<button type="button" role="menuitem" onClick={() => setSubMenu('committed')}>
<GitCommitHorizontal aria-hidden className="menu-icon" />
<span>Committed</span>
<ChevronRight aria-hidden />
</button>
<button type="button" role="menuitem" onClick={() => setSubMenu('branch')}>
<GitBranch aria-hidden className="menu-icon" />
<span>Branch</span>
<ChevronRight aria-hidden />
</button>
Expand Down
43 changes: 43 additions & 0 deletions shell/ui/src/page/__tests__/ReviewPane.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'

vi.mock('@iii-dev/console-ui', () => ({
FileDiff: () => null,
Markdown: () => null,
}))
vi.mock('react', () => ({
useCallback: (callback: unknown) => callback,
Expand Down Expand Up @@ -520,3 +521,45 @@ describe('loadReviewContents without a captured baseline', () => {
})
})
})

describe('loadReviewContents for raster images', () => {
it('loads the working copy as a data URL instead of a text diff', async () => {
const { host, trigger } = execHost({
'coder::read-file': { content: 'iVBORw0KGgo=', revision: 'r9', mode: 420, size: 8 },
})
const image: ReviewEntry = {
path: 'docs/logo.png',
change: { path: 'docs/logo.png', status: 'modified', staged: false },
baseline: 'old bytes',
}

await expect(loadReviewContents(host, '/root', image)).resolves.toEqual({
oldContents: '',
newContents: '',
worktreeRevision: 'r9',
mode: 420,
image: 'data:image/png;base64,iVBORw0KGgo=',
})
expect(trigger).toHaveBeenCalledWith(
'coder::read-file',
expect.objectContaining({ path: '/root/docs/logo.png', encoding: 'base64' }),
)
expect(trigger).toHaveBeenCalledTimes(1)
})

it('marks a deleted image without reading anything', async () => {
const { host, trigger } = execHost({})
const deleted: ReviewEntry = {
path: 'docs/logo.png',
change: { path: 'docs/logo.png', status: 'deleted', staged: false },
baseline: 'old bytes',
}

await expect(loadReviewContents(host, '/root', deleted)).resolves.toEqual({
oldContents: '',
newContents: '',
image: null,
})
expect(trigger).not.toHaveBeenCalled()
})
})
Loading
Loading