feat(editor): Changes view mode — in-tab HEAD-vs-working-tree diff (#1353)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson 2026-05-03 16:17:07 -07:00 committed by GitHub
parent 7edcdca699
commit eaebd08ff9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 639 additions and 118 deletions

View File

@ -290,7 +290,8 @@ export async function resolveGitDir(worktreePath: string): Promise<string> {
export async function getDiff(
worktreePath: string,
filePath: string,
staged: boolean
staged: boolean,
compareAgainstHead = false
): Promise<GitDiffResult> {
let originalContent = ''
let modifiedContent = ''
@ -300,7 +301,9 @@ export async function getDiff(
try {
const leftBlob = staged
? await readGitBlobAtOidPath(worktreePath, 'HEAD', filePath)
: await readUnstagedLeftBlob(worktreePath, filePath)
: compareAgainstHead
? await readGitBlobAtOidPath(worktreePath, 'HEAD', filePath)
: await readUnstagedLeftBlob(worktreePath, filePath)
originalContent = leftBlob.content
originalIsBinary = leftBlob.isBinary

View File

@ -479,18 +479,29 @@ export function registerFilesystemHandlers(store: Store): void {
'git:diff',
async (
_event,
args: { worktreePath: string; filePath: string; staged: boolean; connectionId?: string }
args: {
worktreePath: string
filePath: string
staged: boolean
compareAgainstHead?: boolean
connectionId?: string
}
): Promise<GitDiffResult> => {
if (args.connectionId) {
const provider = getSshGitProvider(args.connectionId)
if (!provider) {
throw new Error(`No git provider for connection "${args.connectionId}"`)
}
return provider.getDiff(args.worktreePath, args.filePath, args.staged)
return provider.getDiff(
args.worktreePath,
args.filePath,
args.staged,
args.compareAgainstHead
)
}
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
const filePath = validateGitRelativeFilePath(worktreePath, args.filePath)
return getDiff(worktreePath, filePath, args.staged)
return getDiff(worktreePath, filePath, args.staged, args.compareAgainstHead)
}
)

View File

@ -26,11 +26,17 @@ export class SshGitProvider implements IGitProvider {
return (await this.mux.request('git.status', { worktreePath })) as GitStatusResult
}
async getDiff(worktreePath: string, filePath: string, staged: boolean): Promise<GitDiffResult> {
async getDiff(
worktreePath: string,
filePath: string,
staged: boolean,
compareAgainstHead?: boolean
): Promise<GitDiffResult> {
return (await this.mux.request('git.diff', {
worktreePath,
filePath,
staged
staged,
compareAgainstHead
})) as GitDiffResult
}

View File

@ -132,7 +132,12 @@ export type IFilesystemProvider = {
export type IGitProvider = {
getStatus(worktreePath: string): Promise<GitStatusResult>
getDiff(worktreePath: string, filePath: string, staged: boolean): Promise<GitDiffResult>
getDiff(
worktreePath: string,
filePath: string,
staged: boolean,
compareAgainstHead?: boolean
): Promise<GitDiffResult>
stageFile(worktreePath: string, filePath: string): Promise<void>
unstageFile(worktreePath: string, filePath: string): Promise<void>
bulkStageFiles(worktreePath: string, filePaths: string[]): Promise<void>

View File

@ -763,6 +763,7 @@ export type PreloadApi = {
worktreePath: string
filePath: string
staged: boolean
compareAgainstHead?: boolean
connectionId?: string
}) => Promise<GitDiffResult>
branchCompare: (args: {

View File

@ -1247,6 +1247,7 @@ const api = {
worktreePath: string
filePath: string
staged: boolean
compareAgainstHead?: boolean
connectionId?: string
}): Promise<unknown> => ipcRenderer.invoke('git:diff', args),
branchCompare: (args: {

View File

@ -73,7 +73,8 @@ export async function computeDiff(
git: GitBufferExec,
worktreePath: string,
filePath: string,
staged: boolean
staged: boolean,
compareAgainstHead = false
) {
let originalContent = ''
let modifiedContent = ''
@ -90,7 +91,9 @@ export async function computeDiff(
modifiedContent = right.content
modifiedIsBinary = right.isBinary
} else {
const left = await readUnstagedLeft(git, worktreePath, filePath)
const left = compareAgainstHead
? await readBlobAtOid(git, worktreePath, 'HEAD', filePath)
: await readUnstagedLeft(git, worktreePath, filePath)
originalContent = left.content
originalIsBinary = left.isBinary

View File

@ -146,8 +146,13 @@ export class GitHandler {
if (rel.startsWith('..') || path.isAbsolute(rel)) {
throw new Error(`Path "${filePath}" resolves outside the worktree`)
}
const staged = params.staged as boolean
return computeDiff(this.gitBuffer.bind(this), worktreePath, filePath, staged)
return computeDiff(
this.gitBuffer.bind(this),
worktreePath,
filePath,
params.staged as boolean,
params.compareAgainstHead as boolean | undefined
)
}
private async stage(params: Record<string, unknown>) {

View File

@ -0,0 +1,102 @@
import React, { lazy } from 'react'
import type { OpenFile } from '@/store/slices/editor'
import type { GitDiffResult, GitStatusEntry } from '../../../../shared/types'
import { ConflictBanner } from './ConflictComponents'
const DiffViewer = lazy(() => import('./DiffViewer'))
function getContentSignature(content: string): string {
let hash = 2166136261
for (let i = 0; i < content.length; i += 1) {
hash ^= content.charCodeAt(i)
hash = Math.imul(hash, 16777619)
}
return (hash >>> 0).toString(16)
}
// Why: Changes view mode renders an edit-mode tab as a HEAD-vs-working-tree
// diff without creating a separate diff-tab object. The draft is the live
// source on the modified side; onContentChange is the same callback as normal
// edit mode so dirty tracking, autosave, and close-prompt plumbing all continue
// to work unchanged. See reviews/changes-view-mode-plan.md.
export function ChangesModeView({
activeFile,
dc,
modifiedContent,
activeConflictEntry,
resolvedLanguage,
sideBySide,
viewStateScopeId,
diffViewStateKey,
onContentChange,
onSave
}: {
activeFile: OpenFile
dc: GitDiffResult | undefined
modifiedContent: string
activeConflictEntry: GitStatusEntry | null
resolvedLanguage: string
sideBySide: boolean
viewStateScopeId: string
diffViewStateKey: string
onContentChange: (content: string) => void
onSave: (content: string) => Promise<void>
}): React.JSX.Element {
if (!dc) {
return (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
Loading diff...
</div>
)
}
if (dc.kind === 'binary') {
return (
<div className="flex h-full items-center justify-center px-6 text-center">
<div className="space-y-2">
<div className="text-sm font-medium text-foreground">Binary file</div>
<div className="text-xs text-muted-foreground">
Text diff is unavailable for this file.
</div>
</div>
</div>
)
}
// Why: Monaco renders an empty diff when the two sides match, which reads as
// a broken view. Surface an inline banner so the user knows Changes mode is
// active but there is simply nothing to diff right now.
const isIdentical = dc.originalContent === modifiedContent
// Why: after a terminal commit/pull/rebase, Changes mode refreshes the
// HEAD-side blob in React state, but Monaco can keep painting the previous
// diff if we reuse the same kept model identities. Rotate only the
// original-side model identity so Monaco rebuilds the stale HEAD snapshot
// without throwing away the modified-side undo history.
const headContentSignature = getContentSignature(dc.originalContent)
const originalModelKey = `${diffViewStateKey}:original:${headContentSignature}`
return (
<div className="flex flex-1 min-h-0 flex-col">
{activeFile.conflict && <ConflictBanner file={activeFile} entry={activeConflictEntry} />}
{isIdentical && (
<div className="border-b border-border/60 bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
No uncommitted changes.
</div>
)}
<div className="flex min-h-0 flex-1 flex-col">
<DiffViewer
key={viewStateScopeId}
modelKey={diffViewStateKey}
originalModelKey={originalModelKey}
originalContent={dc.originalContent}
modifiedContent={modifiedContent}
language={resolvedLanguage}
filePath={activeFile.filePath}
relativePath={activeFile.relativePath}
sideBySide={sideBySide}
editable={true}
worktreeId={activeFile.worktreeId}
onContentChange={onContentChange}
onSave={onSave}
/>
</div>
</div>
)
}

View File

@ -14,6 +14,8 @@ import type { DiffComment } from '../../../../shared/types'
type DiffViewerProps = {
modelKey: string
originalModelKey?: string
modifiedModelKey?: string
originalContent: string
modifiedContent: string
language: string
@ -38,6 +40,8 @@ type DiffViewerProps = {
export default function DiffViewer({
modelKey,
originalModelKey,
modifiedModelKey,
originalContent,
modifiedContent,
language,
@ -167,6 +171,8 @@ export default function DiffViewer({
const propsRef = useRef({ relativePath, language, onSave })
propsRef.current = { relativePath, language, onSave }
const resolvedOriginalModelKey = originalModelKey ?? modelKey
const resolvedModifiedModelKey = modifiedModelKey ?? modelKey
const handleMount: DiffOnMount = useCallback(
(diffEditor, monaco) => {
@ -268,8 +274,11 @@ export default function DiffViewer({
// (staged, unstaged, branch compare versions). The kept Monaco models
// must therefore key off the tab identity, not the raw file path, or
// one diff tab can incorrectly reuse another tab's model contents.
originalModelPath={`diff:original:${modelKey}`}
modifiedModelPath={`diff:modified:${modelKey}`}
// Why: Changes mode sometimes needs to rotate only the original-side
// model after HEAD moves, while preserving the modified-side model's
// undo stack for continued editing.
originalModelPath={`diff:original:${resolvedOriginalModelKey}`}
modifiedModelPath={`diff:modified:${resolvedModifiedModelKey}`}
keepCurrentOriginalModel
keepCurrentModifiedModel
options={{

View File

@ -1,10 +1,13 @@
/* eslint-disable max-lines -- Why: this component is the central dispatcher
that maps (language, viewMode, binary, conflict) onto the correct editor
surface. Splitting the branches across files would force the view-mode state
machine to live behind indirection that obscures the exhaustive conditionals. */
/* eslint-disable max-lines -- Why: EditorContent is the dispatch surface for
every editor mode (edit, diff, conflict, markdown-preview, combined-diff, and
now Changes view mode). Keeping the mode-selection branches colocated is easier
to reason about than scattering the switch across per-mode wrappers. Individual
renderers (MonacoEditor, DiffViewer, ChangesModeView, MarkdownPreview, etc.)
already live in their own modules. */
import React, { lazy } from 'react'
import { detectLanguage } from '@/lib/language-detect'
import { useAppStore } from '@/store'
import { ChangesModeView } from './ChangesModeView'
import { ConflictBanner, ConflictPlaceholderView, ConflictReviewPanel } from './ConflictComponents'
import type { MarkdownViewMode, OpenFile } from '@/store/slices/editor'
import type { GitStatusEntry, GitDiffResult } from '../../../../shared/types'
@ -49,6 +52,7 @@ export function EditorContent({
isMermaid,
isCsv,
mdViewMode,
isChangesMode,
sideBySide,
pendingEditorReveal,
handleContentChange,
@ -66,6 +70,7 @@ export function EditorContent({
isMermaid: boolean
isCsv: boolean
mdViewMode: MarkdownViewMode
isChangesMode: boolean
sideBySide: boolean
pendingEditorReveal: {
filePath?: string
@ -344,6 +349,22 @@ export function EditorContent({
</div>
)
}
if (isChangesMode) {
return (
<ChangesModeView
activeFile={activeFile}
dc={diffContents[activeFile.id]}
modifiedContent={editBuffers[activeFile.id] ?? fc.content}
activeConflictEntry={activeConflictEntry}
resolvedLanguage={resolvedLanguage}
sideBySide={sideBySide}
viewStateScopeId={viewStateScopeId}
diffViewStateKey={diffViewStateKey}
onContentChange={handleContentChange}
onSave={isMarkdown ? md.mdSave : handleSave}
/>
)
}
return (
<div className="flex flex-1 min-h-0 flex-col">
{activeFile.conflict && <ConflictBanner file={activeFile} entry={activeConflictEntry} />}

View File

@ -23,7 +23,7 @@ import {
} from '@/components/ui/dropdown-menu'
import { CLOSE_ALL_CONTEXT_MENUS_EVENT } from '../tab-bar/SortableTab'
import type { MarkdownViewMode, OpenFile } from '@/store/slices/editor'
import MarkdownViewToggle, { CSV_VIEW_MODE_METADATA } from './MarkdownViewToggle'
import EditorViewToggle, { CSV_VIEW_MODE_METADATA } from './EditorViewToggle'
import { EditorContent } from './EditorContent'
import { scrollTopCache, cursorPositionCache, diffViewStateCache } from '@/lib/scroll-cache'
import type { GitDiffResult } from '../../../../shared/types'
@ -42,10 +42,12 @@ import { exportActiveMarkdownToPdf } from './export-active-markdown'
import {
canOpenMarkdownPreview,
getDefaultMarkdownViewMode,
getEditorToggleModes,
getMarkdownPreviewShortcutLabel,
getMarkdownViewModes,
isMarkdownPreviewShortcut
} from './markdown-preview-controls'
import type { EditorToggleValue } from './EditorViewToggle'
const isMac = navigator.userAgent.includes('Mac')
const isLinux = navigator.userAgent.includes('Linux')
@ -111,7 +113,11 @@ function inFlightReadKey(connectionId: string | undefined, filePath: string): st
return `${connectionId ?? ''}::${filePath}`
}
function inFlightDiffKey(file: OpenFile, connectionId: string | undefined): string {
function inFlightDiffKey(
file: OpenFile,
connectionId: string | undefined,
compareAgainstHead = false
): string {
// Why: diff content depends on the file path AND which diff source is
// being rendered (unstaged/staged/branch). Branch diffs further depend
// on the base+head oids so switching compare points doesn't alias, and
@ -121,7 +127,7 @@ function inFlightDiffKey(file: OpenFile, connectionId: string | undefined): stri
file.diffSource === 'branch' && file.branchCompare
? `${file.branchCompare.baseOid ?? ''}..${file.branchCompare.headOid ?? ''}::${file.branchOldPath ?? ''}`
: ''
return `${connectionId ?? ''}::${file.diffSource ?? ''}::${file.filePath}::${branch}`
return `${connectionId ?? ''}::${file.diffSource ?? ''}::${compareAgainstHead ? 'head' : 'default'}::${file.filePath}::${branch}`
}
function EditorPanelInner({
@ -140,6 +146,8 @@ function EditorPanelInner({
const gitBranchChangesByWorktree = useAppStore((s) => s.gitBranchChangesByWorktree)
const markdownViewMode = useAppStore((s) => s.markdownViewMode)
const setMarkdownViewMode = useAppStore((s) => s.setMarkdownViewMode)
const editorViewMode = useAppStore((s) => s.editorViewMode)
const setEditorViewMode = useAppStore((s) => s.setEditorViewMode)
const openFile = useAppStore((s) => s.openFile)
const openMarkdownPreview = useAppStore((s) => s.openMarkdownPreview)
const closeFile = useAppStore((s) => s.closeFile)
@ -155,9 +163,21 @@ function EditorPanelInner({
const activeFileMode = activeFile?.mode ?? null
const activeFileDiffSource = activeFile?.diffSource
const activeViewStateId = activeViewStateIdProp ?? activeFileId
const [fileContents, setFileContents] = useState<Record<string, FileContent>>({})
const [diffContents, setDiffContents] = useState<Record<string, DiffContent>>({})
// Why: Changes view mode only applies on top of a regular edit-mode tab. It
// swaps the MonacoEditor for a DiffViewer (HEAD vs working tree incl. unsaved
// draft) without creating a new tab. Transient tabs (diff, conflict-review,
// markdown-preview) keep their own rendering pipeline.
// Binary content short-circuits to the binary placeholder in EditorContent
// before isChangesMode is consulted, so we must also exclude binary files
// here — otherwise the header toggle would still show Changes as selected
// and expose the inline/side-by-side toggle even though no diff is rendered.
const isChangesMode =
!!activeFile &&
activeFile.mode === 'edit' &&
editorViewMode[activeFile.id] === 'changes' &&
!fileContents[activeFile.id]?.isBinary
const [copiedPathToast, setCopiedPathToast] = useState<{ fileId: string; token: number } | null>(
null
)
@ -191,6 +211,13 @@ function EditorPanelInner({
const openFilesRef = useRef(openFiles)
openFilesRef.current = openFiles
// Why: the external-file-change handler below needs to consult the latest
// editorViewMode, but we do not want to re-register its window listener
// every time an unrelated editor-mode toggle flips. A ref lets the handler
// read the current value without adding editorViewMode to the effect deps.
const editorViewModeRef = useRef(editorViewMode)
editorViewModeRef.current = editorViewMode
useEffect(() => {
const closeMenu = (): void => setPathMenuOpen(false)
window.addEventListener(CLOSE_ALL_CONTEXT_MENUS_EVENT, closeMenu)
@ -286,10 +313,15 @@ function EditorPanelInner({
if (activeFile.conflict?.kind === 'conflict-placeholder') {
return
}
if (fileContents[activeFile.id]) {
return
if (!fileContents[activeFile.id]) {
void loadFileContent(activeFile.filePath, activeFile.id, activeFile.worktreeId)
}
// Why: Changes view mode needs the HEAD-side blob as well as the
// working-tree content. Kick off the diff load alongside the normal
// file read so both are ready by the time DiffViewer mounts.
if (isChangesMode && !diffContents[activeFile.id]) {
void loadDiffContent(activeFile)
}
void loadFileContent(activeFile.filePath, activeFile.id, activeFile.worktreeId)
} else if (
activeFile.mode === 'diff' &&
activeFile.diffSource !== undefined &&
@ -301,7 +333,7 @@ function EditorPanelInner({
}
void loadDiffContent(activeFile)
}
}, [activeFile?.id]) // eslint-disable-line react-hooks/exhaustive-deps
}, [activeFile?.id, isChangesMode]) // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (!copiedPathToast) {
@ -362,7 +394,20 @@ function EditorPanelInner({
? file.branchCompare
: null
const connectionId = getConnectionId(file.worktreeId) ?? undefined
const key = inFlightDiffKey(file, connectionId)
// Why: Changes view mode runs on top of an edit-mode tab and asks git
// for an unstaged diff against HEAD for that file. Use the 'unstaged'
// diff-source key so multiple Changes tabs across split panes share one
// IPC round-trip with any open unstaged diff-tab for the same path.
// Compute this once and reuse it for both the dedup key and the IPC
// branch selection so the two can never drift apart.
const effectiveDiffSource: typeof file.diffSource =
file.mode === 'edit' ? 'unstaged' : file.diffSource
const compareAgainstHead = file.mode === 'edit'
const key = inFlightDiffKey(
{ ...file, diffSource: effectiveDiffSource },
connectionId,
compareAgainstHead
)
// Why: same rationale as inFlightFileReads above — a single external
// change fans out to every mounted EditorPanel, and two split panes
// showing the same diff tab should share one git.diff IPC instead of
@ -370,7 +415,7 @@ function EditorPanelInner({
let pending = inFlightDiffReads.get(key)
if (!pending) {
pending = (
file.diffSource === 'branch' && branchCompare
effectiveDiffSource === 'branch' && branchCompare
? window.api.git.branchDiff({
worktreePath,
compare: {
@ -386,7 +431,8 @@ function EditorPanelInner({
: window.api.git.diff({
worktreePath,
filePath: file.relativePath,
staged: file.diffSource === 'staged',
staged: effectiveDiffSource === 'staged',
compareAgainstHead,
connectionId
})
) as Promise<DiffContent>
@ -413,6 +459,40 @@ function EditorPanelInner({
}
}, [])
// Why: refetch the HEAD-side blob for Changes mode when the worktree's git
// status array identity changes. A commit, pull, or rebase updates the
// status poll result, which is the cheapest signal we have that HEAD moved
// — without this, users see a stale diff after committing from Changes mode.
// Subscribing to the status array keeps parity with the Changes sidebar.
const changesStatusEntries = activeFile?.worktreeId
? gitStatusByWorktree[activeFile.worktreeId]
: undefined
// Why: depend on the primitive identifiers of the active file rather than
// the `activeFile` object. `openFiles` is rebuilt on any store update that
// touches an open file (dirty flips, saves, status polling), so the
// `activeFile` object reference changes on many unrelated renders. Each
// identity change would otherwise retrigger the effect and dispatch a
// spurious git.diff IPC that the in-flight dedup map cannot coalesce
// across time. Resolve the current file via `openFilesRef` inside the
// effect so we still pass a live OpenFile to loadDiffContent.
useEffect(() => {
if (!isChangesMode || !activeFile?.id) {
return
}
const current = openFilesRef.current.find((f) => f.id === activeFile.id)
if (!current) {
return
}
void loadDiffContent(current)
}, [
changesStatusEntries,
isChangesMode,
activeFile?.id,
activeFile?.worktreeId,
activeFile?.relativePath,
loadDiffContent
])
const handleContentChange = useCallback(
(content: string) => {
if (!activeFile) {
@ -484,6 +564,31 @@ function EditorPanelInner({
[activeFile, openFiles]
)
// Why: hooks must run unconditionally, so this useCallback lives above the
// `if (!activeFile) return null` guard; the callback itself no-ops when
// no file is active. Memoised to match the other editor handlers in this
// file and avoid churning EditorViewToggle's onChange identity.
const handleEditorToggleChange = useCallback(
(next: EditorToggleValue): void => {
const fileId = activeFile?.id
if (!fileId) {
return
}
if (next === 'changes') {
setEditorViewMode(fileId, 'changes')
return
}
// Why: selecting any non-Changes segment implicitly exits Changes mode.
// For markdown/mermaid files, also persist the chosen language sub-mode
// so that next time Changes is toggled off, the file returns to that view.
setEditorViewMode(fileId, 'edit')
if (next !== 'edit') {
setMarkdownViewMode(fileId, next)
}
},
[activeFile?.id, setEditorViewMode, setMarkdownViewMode]
)
// Why: global Cmd+S (from Terminal.tsx) dispatches this event when
// focus is outside the editor content area. Delegate to handleSave
// so untitled files still show the rename dialog.
@ -541,6 +646,16 @@ function EditorPanelInner({
for (const file of matchingFiles) {
if (file.mode === 'edit' || file.mode === 'markdown-preview') {
void loadFileContent(file.filePath, file.id, file.worktreeId)
// Why: if this edit tab is currently in Changes view mode, the
// rendered DiffViewer also depends on the HEAD-side blob. An
// external write (e.g. a git checkout) can change both the working
// tree *and* shift the reference blob, so refetch the diff too.
// Read through a ref so the handler reflects the subscribed store
// value without forcing the listener to re-register on every mode
// toggle.
if (editorViewModeRef.current[file.id] === 'changes') {
void loadDiffContent(file)
}
} else if (
file.mode === 'diff' &&
file.diffSource !== 'combined-uncommitted' &&
@ -795,6 +910,9 @@ function EditorPanelInner({
activeFile.diffSource !== undefined &&
activeFile.diffSource !== 'combined-uncommitted' &&
activeFile.diffSource !== 'combined-branch'
// Why: Changes view mode renders a DiffViewer, so expose the same inline /
// side-by-side toggle the diff-tab path already offers.
const isDiffSurface = isSingleDiff || isChangesMode
const isCombinedDiff =
activeFile.mode === 'diff' &&
(activeFile.diffSource === 'combined-uncommitted' ||
@ -869,6 +987,33 @@ function EditorPanelInner({
markdownViewModes.includes(storedMarkdownViewMode)
? storedMarkdownViewMode
: defaultMarkdownViewMode
// Why: the header toggle surfaces both the language-specific view mode
// (Source / Rich / Preview) and the orthogonal Changes view mode in one
// segmented control. Plain code files (no language-specific modes) still get
// an Edit | Changes toggle because Changes applies to every editable tab.
const editorToggleModes = getEditorToggleModes({
language: resolvedLanguage,
mode: activeFile.mode,
diffSource: activeFile.diffSource
})
const isBinaryEditSurface =
activeFile.mode === 'edit' && fileContents[activeFile.id]?.isBinary === true
// Why: edit-mode binary/image tabs already have their own dedicated renderers
// and cannot enter the Changes diff surface. Hide that segment rather than
// offering a toggle state the renderer will immediately ignore.
const availableEditorToggleModes = isBinaryEditSurface
? editorToggleModes.filter((mode) => mode !== 'changes')
: editorToggleModes
// Why: a toggle with a single option is just a decorative pill with nothing
// to switch to. Binary plain-code tabs end up here after 'changes' is
// stripped — on main they had no header toggle at all, so requiring >1 mode
// preserves that behavior instead of leaving a lone "Edit" segment.
const hasEditorToggle = availableEditorToggleModes.length > 1
const effectiveToggleValue: EditorToggleValue = isChangesMode
? 'changes'
: hasViewModeToggle
? mdViewMode
: 'edit'
const canShowMarkdownPreview = canOpenMarkdownPreview({
language: resolvedLanguage,
mode: activeFile.mode,
@ -1021,7 +1166,7 @@ function EditorPanelInner({
</Tooltip>
</TooltipProvider>
)}
{isSingleDiff && (
{isDiffSurface && (
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
@ -1038,11 +1183,11 @@ function EditorPanelInner({
</Tooltip>
</TooltipProvider>
)}
{hasViewModeToggle && (
<MarkdownViewToggle
mode={mdViewMode}
modes={markdownViewModes}
onChange={(mode) => setMarkdownViewMode(activeFile.id, mode)}
{hasEditorToggle && (
<EditorViewToggle
value={effectiveToggleValue}
modes={availableEditorToggleModes}
onChange={handleEditorToggleChange}
metadataOverride={isCsv ? CSV_VIEW_MODE_METADATA : undefined}
/>
)}
@ -1092,6 +1237,7 @@ function EditorPanelInner({
isMermaid={isMermaid}
isCsv={isCsv}
mdViewMode={mdViewMode}
isChangesMode={isChangesMode}
sideBySide={sideBySide}
pendingEditorReveal={pendingEditorReveal}
handleContentChange={handleContentChange}

View File

@ -0,0 +1,112 @@
import React from 'react'
import {
Code,
Eye,
FileText,
GitCompareArrows,
Pencil,
Table as TableIcon,
type LucideIcon
} from 'lucide-react'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import type { MarkdownViewMode } from '@/store/slices/editor'
// Why: 'changes' is not a MarkdownViewMode in the store — it lives on the
// orthogonal editorViewMode slice. This toggle unifies both dimensions into a
// single segmented control because they are mutually exclusive at render time:
// a file can show Source, Rich, Preview, Edit, OR Changes, but never two at
// once. 'edit' is the code-file counterpart to markdown's 'source' — it means
// "the normal editor for this file" without implying the markdown source/raw
// distinction. See reviews/changes-view-mode-plan.md.
export type EditorToggleValue = MarkdownViewMode | 'edit' | 'changes'
type ViewModeMetadata = { label: string; icon: LucideIcon; title?: string }
const DEFAULT_VIEW_MODE_METADATA: Record<EditorToggleValue, ViewModeMetadata> = {
source: {
label: 'Source',
icon: Code
},
rich: {
label: 'Rich Editor',
icon: Pencil
},
preview: {
label: 'Preview',
icon: Eye
},
edit: {
label: 'Edit',
icon: FileText
},
changes: {
label: 'Changes',
icon: GitCompareArrows,
// Why: "Changes" collides with the Source Control sidebar's "Branch
// Changes" section, which diffs against the base ref. This toggle shows
// uncommitted changes (working tree vs HEAD), so disambiguate in the
// hover title without repeating the button label.
title: 'Uncommitted changes'
}
}
// Why: CSV/TSV files reuse the 'rich' view mode slot but the rendered surface
// is a read-only table, not an editor. The Pencil icon implies editability,
// which we don't offer, so callers can override the per-mode presentation.
export const CSV_VIEW_MODE_METADATA: Partial<Record<MarkdownViewMode, ViewModeMetadata>> = {
rich: {
label: 'Table',
icon: TableIcon
}
}
type EditorViewToggleProps = {
value: EditorToggleValue
modes: readonly EditorToggleValue[]
onChange: (value: EditorToggleValue) => void
metadataOverride?: Partial<Record<MarkdownViewMode, ViewModeMetadata>>
}
export default function EditorViewToggle({
value,
modes,
onChange,
metadataOverride
}: EditorViewToggleProps): React.JSX.Element {
return (
<ToggleGroup
type="single"
size="sm"
className="h-6 [&_[data-slot=toggle-group-item]]:h-7 [&_[data-slot=toggle-group-item]]:min-w-5 [&_[data-slot=toggle-group-item]]:px-2.5"
variant="outline"
value={value}
onValueChange={(v) => {
if (v) {
onChange(v as EditorToggleValue)
}
}}
>
{modes.map((viewMode) => {
// Why: metadataOverride is keyed by MarkdownViewMode (source/rich/preview)
// because only those slots have language-specific presentation variants
// (e.g. CSV's "Table" label on the 'rich' slot). 'edit'/'changes' are
// orthogonal toggle values and always use the default metadata.
const override = (
metadataOverride as Partial<Record<EditorToggleValue, ViewModeMetadata>> | undefined
)?.[viewMode]
const metadata = override ?? DEFAULT_VIEW_MODE_METADATA[viewMode]
const Icon = metadata.icon
return (
<ToggleGroupItem
key={viewMode}
value={viewMode}
aria-label={metadata.label}
title={metadata.title ?? metadata.label}
>
<Icon className="h-3 w-3" />
</ToggleGroupItem>
)
})}
</ToggleGroup>
)
}

View File

@ -1,75 +0,0 @@
import React from 'react'
import { Code, Eye, Pencil, Table as TableIcon, type LucideIcon } from 'lucide-react'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import type { MarkdownViewMode } from '@/store/slices/editor'
type ViewModeMetadata = { label: string; icon: LucideIcon }
const DEFAULT_VIEW_MODE_METADATA: Record<MarkdownViewMode, ViewModeMetadata> = {
source: {
label: 'Source',
icon: Code
},
rich: {
label: 'Rich Editor',
icon: Pencil
},
preview: {
label: 'Preview',
icon: Eye
}
}
// Why: CSV/TSV files reuse the 'rich' view mode slot but the rendered surface
// is a read-only table, not an editor. The Pencil icon implies editability,
// which we don't offer, so callers can override the per-mode presentation.
export const CSV_VIEW_MODE_METADATA: Partial<Record<MarkdownViewMode, ViewModeMetadata>> = {
rich: {
label: 'Table',
icon: TableIcon
}
}
type MarkdownViewToggleProps = {
mode: MarkdownViewMode
modes: readonly MarkdownViewMode[]
onChange: (mode: MarkdownViewMode) => void
metadataOverride?: Partial<Record<MarkdownViewMode, ViewModeMetadata>>
}
export default function MarkdownViewToggle({
mode,
modes,
onChange,
metadataOverride
}: MarkdownViewToggleProps): React.JSX.Element {
return (
<ToggleGroup
type="single"
size="sm"
className="h-6 [&_[data-slot=toggle-group-item]]:h-7 [&_[data-slot=toggle-group-item]]:min-w-5 [&_[data-slot=toggle-group-item]]:px-2.5"
variant="outline"
value={mode}
onValueChange={(v) => {
if (v) {
onChange(v as MarkdownViewMode)
}
}}
>
{modes.map((viewMode) => {
const metadata = metadataOverride?.[viewMode] ?? DEFAULT_VIEW_MODE_METADATA[viewMode]
const Icon = metadata.icon
return (
<ToggleGroupItem
key={viewMode}
value={viewMode}
aria-label={metadata.label}
title={metadata.label}
>
<Icon className="h-3 w-3" />
</ToggleGroupItem>
)
})}
</ToggleGroup>
)
}

View File

@ -1,4 +1,5 @@
import type { MarkdownViewMode, OpenFile } from '@/store/slices/editor'
import type { EditorToggleValue } from './EditorViewToggle'
type MarkdownPreviewTarget = Pick<OpenFile, 'mode' | 'diffSource'> & {
language: string
@ -13,6 +14,25 @@ const MERMAID_VIEW_MODES = ['source', 'rich'] as const satisfies readonly Markdo
const CSV_VIEW_MODES = ['source', 'rich'] as const satisfies readonly MarkdownViewMode[]
const NO_VIEW_MODES = [] as const satisfies readonly MarkdownViewMode[]
// Why: every editable file (markdown, mermaid, or plain code) can flip into
// Changes view mode. The toggle surfaces this alongside any language-specific
// modes so there is one UI control per pane, not two. Non-edit tabs (diff,
// conflict) do NOT get Changes because they are already a diff/review surface.
// Plain code files have no markdown-style sub-modes, so their toggle is just
// Edit | Changes.
const CODE_EDIT_TOGGLE_MODES = ['edit', 'changes'] as const satisfies readonly EditorToggleValue[]
export function getEditorToggleModes(target: MarkdownPreviewTarget): readonly EditorToggleValue[] {
if (target.mode !== 'edit') {
return getMarkdownViewModes(target)
}
const languageModes = getMarkdownViewModes(target)
if (languageModes.length > 0) {
return [...languageModes, 'changes']
}
return CODE_EDIT_TOGGLE_MODES
}
export function getMarkdownViewModes(target: MarkdownPreviewTarget): readonly MarkdownViewMode[] {
if (target.language === 'markdown') {
if (target.mode === 'edit') {

View File

@ -121,6 +121,8 @@ function SourceControlInner(): React.JSX.Element {
const revealInExplorer = useAppStore((s) => s.revealInExplorer)
const trackConflictPath = useAppStore((s) => s.trackConflictPath)
const openDiff = useAppStore((s) => s.openDiff)
const openFile = useAppStore((s) => s.openFile)
const setEditorViewMode = useAppStore((s) => s.setEditorViewMode)
const openConflictFile = useAppStore((s) => s.openConflictFile)
const openConflictReview = useAppStore((s) => s.openConflictReview)
const openBranchDiff = useAppStore((s) => s.openBranchDiff)
@ -341,15 +343,38 @@ function SourceControlInner(): React.JSX.Element {
openConflictFile(activeWorktreeId, worktreePath, entry, detectLanguage(entry.path))
return
}
openDiff(
activeWorktreeId,
joinPath(worktreePath, entry.path),
entry.path,
detectLanguage(entry.path),
entry.area === 'staged'
)
const language = detectLanguage(entry.path)
const filePath = joinPath(worktreePath, entry.path)
// Why: unstaged markdown diffs open as a normal edit tab in Changes
// view mode rather than a dedicated diff tab. This unifies sidebar
// clicks with the header's Edit|Changes toggle: there is exactly one
// tab per markdown file, and the sidebar click flips that tab's view
// mode. Staged diffs still open as a separate diff tab because the
// staged content is not what the editor would be editing. Non-markdown
// files keep the existing diff-tab flow until the diff-tab type is
// eventually collapsed (see reviews/changes-view-mode-plan.md §"Follow-up").
if (language === 'markdown' && entry.area === 'unstaged') {
openFile({
filePath,
relativePath: entry.path,
worktreeId: activeWorktreeId,
language,
mode: 'edit'
})
setEditorViewMode(filePath, 'changes')
return
}
openDiff(activeWorktreeId, filePath, entry.path, language, entry.area === 'staged')
},
[activeWorktreeId, worktreePath, trackConflictPath, openConflictFile, openDiff]
[
activeWorktreeId,
worktreePath,
trackConflictPath,
openConflictFile,
openDiff,
openFile,
setEditorViewMode
]
)
const { selectedKeys, handleSelect, handleContextMenu, clearSelection } =

View File

@ -144,6 +144,50 @@ describe('createEditorSlice markdown view state', () => {
})
})
describe('createEditorSlice editor view mode', () => {
it('stores changes mode as an explicit entry keyed by fileId', () => {
const store = createEditorStore()
store.getState().setEditorViewMode('/repo/app.ts', 'changes')
expect(store.getState().editorViewMode).toEqual({ '/repo/app.ts': 'changes' })
})
it('deletes the entry when mode resets to edit', () => {
const store = createEditorStore()
store.getState().setEditorViewMode('/repo/app.ts', 'changes')
store.getState().setEditorViewMode('/repo/app.ts', 'edit')
expect(store.getState().editorViewMode).toEqual({})
})
it('is a no-op when resetting a file that was never in changes mode', () => {
const store = createEditorStore()
const before = store.getState().editorViewMode
store.getState().setEditorViewMode('/repo/app.ts', 'edit')
expect(store.getState().editorViewMode).toBe(before)
})
it('drops editor view mode when the file is closed', () => {
const store = createEditorStore()
store.getState().openFile({
filePath: '/repo/app.ts',
relativePath: 'app.ts',
worktreeId: 'wt-1',
language: 'typescript',
mode: 'edit'
})
store.getState().setEditorViewMode('/repo/app.ts', 'changes')
store.getState().closeFile('/repo/app.ts')
expect(store.getState().editorViewMode).toEqual({})
})
})
describe('createEditorSlice openMarkdownPreview', () => {
it('opens markdown preview as a separate read-only tab', () => {
const store = createEditorStore()

View File

@ -124,6 +124,13 @@ export type ActivityBarPosition = 'top' | 'side'
export type MarkdownViewMode = 'source' | 'rich' | 'preview'
// Why: orthogonal to MarkdownViewMode. 'changes' flips the editor tab to a
// diff-against-HEAD rendering (working tree incl. unsaved draft vs HEAD) in
// place of the normal editor, without creating a separate tab. The per-tab
// Tab.contentType stays 'editor' for the whole lifetime; this slice drives
// what EditorPanel *renders* for that tab. See reviews/changes-view-mode-plan.md.
export type EditorViewMode = 'edit' | 'changes'
/** Enough state to restore a tab via `openFile` after `closeFile` (id is always filePath). */
export type ClosedEditorTabSnapshot = Omit<OpenFile, 'id' | 'isDirty'>
@ -143,6 +150,12 @@ export type EditorSlice = {
markdownViewMode: Record<string, MarkdownViewMode>
setMarkdownViewMode: (fileId: string, mode: MarkdownViewMode) => void
// Editor view mode per file (fileId -> mode). Orthogonal to markdownViewMode:
// a markdown file can be in Raw+Changes, Rendered+Changes, etc. Absent entry
// means 'edit'.
editorViewMode: Record<string, EditorViewMode>
setEditorViewMode: (fileId: string, mode: EditorViewMode) => void
// Right sidebar
rightSidebarOpen: boolean
rightSidebarWidth: number
@ -367,6 +380,24 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
markdownViewMode: { ...s.markdownViewMode, [fileId]: mode }
})),
// Editor view mode (edit vs changes-diff). See EditorViewMode.
editorViewMode: {},
setEditorViewMode: (fileId, mode) =>
set((s) => {
// Why: default is 'edit'. Writing 'edit' explicitly when no entry exists
// would grow the record unnecessarily; delete instead so the shape stays
// minimal and hydration round-trips cleanly.
if (mode === 'edit') {
if (!(fileId in s.editorViewMode)) {
return s
}
const next = { ...s.editorViewMode }
delete next[fileId]
return { editorViewMode: next }
}
return { editorViewMode: { ...s.editorViewMode, [fileId]: mode } }
}),
// Right sidebar
rightSidebarOpen: false,
rightSidebarWidth: 280,
@ -520,6 +551,14 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
([fileId]) => fileId !== replacedPreview.id
)
)
const nextEditorViewMode =
replacedPreview.id === id
? s.editorViewMode
: Object.fromEntries(
Object.entries(s.editorViewMode).filter(
([fileId]) => fileId !== replacedPreview.id
)
)
// Why: editorCursorLine entries accumulate per file; clean up the
// evicted preview's entry so it does not leak across tab replacements.
const nextEditorCursorLine =
@ -566,6 +605,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
editorDrafts: nextEditorDrafts,
editorCursorLine: nextEditorCursorLine,
markdownViewMode: nextMarkdownViewMode,
editorViewMode: nextEditorViewMode,
recentlyClosedEditorTabsByWorktree: nextRecentlyClosed,
...previewTabBarUpdate,
...activeResult
@ -738,6 +778,8 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
delete newEditorDrafts[fileId]
const newMarkdownViewMode = { ...s.markdownViewMode }
delete newMarkdownViewMode[fileId]
const newEditorViewMode = { ...s.editorViewMode }
delete newEditorViewMode[fileId]
// Why: editorCursorLine entries are keyed by fileId and accumulate on
// every cursor move. Without cleanup they grow without bound across a
// long session as files are opened and closed.
@ -862,6 +904,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
activeFileIdByWorktree: newActiveFileIdByWorktree,
activeTabTypeByWorktree: newActiveTabTypeByWorktree,
markdownViewMode: newMarkdownViewMode,
editorViewMode: newEditorViewMode,
tabBarOrderByWorktree: nextTabBarOrderByWorktree,
pendingEditorReveal: null,
recentlyClosedEditorTabsByWorktree: nextRecentlyClosed
@ -948,6 +991,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
activeFileId: null,
activeTabType: 'terminal',
markdownViewMode: {},
editorViewMode: {},
pendingEditorReveal: null
}
}
@ -960,6 +1004,9 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
const newMarkdownViewMode = Object.fromEntries(
Object.entries(s.markdownViewMode).filter(([fileId]) => remainingFileIds.has(fileId))
)
const newEditorViewMode = Object.fromEntries(
Object.entries(s.editorViewMode).filter(([fileId]) => remainingFileIds.has(fileId))
)
const newEditorCursorLine = Object.fromEntries(
Object.entries(s.editorCursorLine).filter(([fileId]) => remainingFileIds.has(fileId))
)
@ -1021,6 +1068,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
: s.activeBrowserTabId,
activeTabType: browserTabsForWorktree.length > 0 ? 'browser' : 'terminal',
markdownViewMode: newMarkdownViewMode,
editorViewMode: newEditorViewMode,
activeFileIdByWorktree: newActiveFileIdByWorktree,
activeTabTypeByWorktree: newActiveTabTypeByWorktree,
tabBarOrderByWorktree: nextTabBarOrderByWorktree,

View File

@ -49,6 +49,7 @@ function createTestStore() {
openFiles: [],
editorDrafts: {},
markdownViewMode: {},
editorViewMode: {},
expandedDirs: {},
gitStatusByWorktree: {},
gitConflictOperationByWorktree: {},
@ -233,6 +234,35 @@ describe('removeWorktree state cleanup', () => {
expect(store.getState().markdownViewMode).toEqual({ 'file-2': 'source' })
})
it('cleans up editorViewMode for files in the removed worktree', async () => {
const store = createTestStore()
const wt = makeWorktree({ id: 'repo1::/path/wt1', repoId: 'repo1', path: '/path/wt1' })
store.setState({
worktreesByRepo: { repo1: [wt] },
openFiles: [
{
id: 'file-1',
worktreeId: 'repo1::/path/wt1',
filePath: '/path/wt1/app.ts',
relativePath: 'app.ts',
language: 'typescript',
isDirty: false,
isPreview: false,
mode: 'edit' as const
}
],
editorViewMode: {
'file-1': 'changes' as const,
'file-2': 'changes' as const
}
} as unknown as Partial<AppState>)
await store.getState().removeWorktree('repo1::/path/wt1')
expect(store.getState().editorViewMode).toEqual({ 'file-2': 'changes' })
})
it('cleans up expandedDirs for the removed worktree', async () => {
const store = createTestStore()
const wt = makeWorktree({ id: 'repo1::/path/wt1', repoId: 'repo1', path: '/path/wt1' })

View File

@ -334,10 +334,13 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
const nextEditorDrafts = removedFileIds.size > 0 ? { ...s.editorDrafts } : s.editorDrafts
const nextMarkdownViewMode =
removedFileIds.size > 0 ? { ...s.markdownViewMode } : s.markdownViewMode
const nextEditorViewMode =
removedFileIds.size > 0 ? { ...s.editorViewMode } : s.editorViewMode
if (removedFileIds.size > 0) {
for (const fileId of removedFileIds) {
delete nextEditorDrafts[fileId]
delete nextMarkdownViewMode[fileId]
delete nextEditorViewMode[fileId]
}
}
const nextExpandedDirs = { ...s.expandedDirs }
@ -382,6 +385,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
activeGroupIdByWorktree: nextActiveGroupIdByWorktree,
editorDrafts: nextEditorDrafts,
markdownViewMode: nextMarkdownViewMode,
editorViewMode: nextEditorViewMode,
expandedDirs: nextExpandedDirs,
gitStatusByWorktree: nextGitStatusByWorktree,
gitConflictOperationByWorktree: nextGitConflictOperationByWorktree,