Skip to content
Open
115 changes: 113 additions & 2 deletions packages/ui/src/components/file-viewer/monaco-diff-viewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,79 @@ interface MonacoDiffViewerProps {
viewMode?: "split" | "unified"
contextMode?: "expanded" | "collapsed"
wordWrap?: "on" | "off"
unifiedGutterStyle?: "compact" | "classic"
}

function getLineCount(value: string): number {
if (!value) return 1
return value.split("\n").length
}

function getDigitCount(value: number): number {
return String(Math.max(1, value)).length
}

function getUnifiedGutterSizing(options: {
unifiedGutterStyle: "compact" | "classic" | null
before: string
after: string
}) {
const beforeLineCount = getLineCount(options.before)
const afterLineCount = getLineCount(options.after)
const beforeDigitCount = getDigitCount(beforeLineCount)
const afterDigitCount = getDigitCount(afterLineCount)
const maxDigitCount = Math.max(beforeDigitCount, afterDigitCount)
const extraDigits = Math.max(0, maxDigitCount - 2)
// Reserve one extra character so the number lane keeps a visible gap before
// the +/- indicator lane once the line numbers grow beyond trivial widths.
const beforeNumberChars = Math.max(2, beforeDigitCount + 1)
const afterNumberChars = Math.max(2, afterDigitCount + 1)
const fourDigitPenalty = Math.max(0, maxDigitCount - 3)

if (options.unifiedGutterStyle === "compact") {
const sharedNumberChars = Math.max(beforeNumberChars, afterNumberChars)
return {
diffEditorLineNumbersMinChars: sharedNumberChars,
originalLineNumbersMinChars: sharedNumberChars,
modifiedLineNumbersMinChars: sharedNumberChars,
lineDecorationsWidth: 8 + extraDigits * 4 + fourDigitPenalty * 2,
}
}

if (options.unifiedGutterStyle === "classic") {
return {
diffEditorLineNumbersMinChars: Math.max(beforeNumberChars, afterNumberChars),
originalLineNumbersMinChars: beforeNumberChars,
modifiedLineNumbersMinChars: afterNumberChars,
lineDecorationsWidth: 10 + extraDigits * 4 + fourDigitPenalty * 4,
}
}

return {
diffEditorLineNumbersMinChars: 4,
originalLineNumbersMinChars: 4,
modifiedLineNumbersMinChars: 4,
lineDecorationsWidth: 12,
}
}

function getSplitGutterSizing(options: { before: string; after: string }) {
const beforeLineCount = getLineCount(options.before)
const afterLineCount = getLineCount(options.after)
const beforeDigitCount = getDigitCount(beforeLineCount)
const afterDigitCount = getDigitCount(afterLineCount)
const maxDigitCount = Math.max(beforeDigitCount, afterDigitCount)
const extraDigits = Math.max(0, maxDigitCount - 2)
const beforeNumberChars = Math.max(2, beforeDigitCount + 1)
const afterNumberChars = Math.max(2, afterDigitCount + 1)
const fourDigitPenalty = Math.max(0, maxDigitCount - 3)

return {
diffEditorLineNumbersMinChars: Math.max(beforeNumberChars, afterNumberChars),
originalLineNumbersMinChars: beforeNumberChars,
modifiedLineNumbersMinChars: afterNumberChars,
lineDecorationsWidth: 10 + extraDigits * 2 + fourDigitPenalty * 2,
}
}

export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
Expand Down Expand Up @@ -95,30 +168,68 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
monaco.editor.setTheme(isDark() ? "vs-dark" : "vs")
})

createEffect(() => {
if (!host) return
host.dataset.viewMode = props.viewMode === "split" ? "split" : "unified"
host.dataset.unifiedGutterStyle = props.unifiedGutterStyle ?? ""
})

createEffect(() => {
if (!ready() || !monaco || !diffEditor) return
const viewMode = props.viewMode === "unified" ? "unified" : "split"
const contextMode = props.contextMode === "collapsed" ? "collapsed" : "expanded"
const wordWrap = props.wordWrap === "on" ? "on" : "off"
const unifiedGutterStyle = viewMode === "unified" ? props.unifiedGutterStyle ?? null : null
const { before, after } = resolvedContent()
const sizing =
viewMode === "unified"
? getUnifiedGutterSizing({
unifiedGutterStyle,
before,
after,
})
: getSplitGutterSizing({ before, after })
const {
diffEditorLineNumbersMinChars,
originalLineNumbersMinChars,
modifiedLineNumbersMinChars,
lineDecorationsWidth,
} = sizing
const compactUnifiedGutter = unifiedGutterStyle === "compact"

diffEditor.updateOptions({
renderSideBySide: viewMode === "split",
renderSideBySideInlineBreakpoint: 0,
compactMode: compactUnifiedGutter,
renderIndicators: true,
lineNumbersMinChars: diffEditorLineNumbersMinChars,
lineDecorationsWidth,
hideUnchangedRegions:
contextMode === "collapsed"
? { enabled: true }
: { enabled: false },
wordWrap,
experimental: {
useTrueInlineView: compactUnifiedGutter,
},
})

try {
diffEditor.getOriginalEditor?.()?.updateOptions?.({ wordWrap })
diffEditor.getOriginalEditor?.()?.updateOptions?.({
wordWrap,
lineNumbersMinChars: originalLineNumbersMinChars,
lineDecorationsWidth,
})
} catch {
// ignore
}

try {
diffEditor.getModifiedEditor?.()?.updateOptions?.({ wordWrap })
diffEditor.getModifiedEditor?.()?.updateOptions?.({
wordWrap,
lineNumbersMinChars: modifiedLineNumbersMinChars,
lineDecorationsWidth,
})
} catch {
// ignore
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import type { BackgroundProcess } from "../../../../../../server/src/api-types"
import type { Session } from "../../../../types/session"
import type { DrawerViewState } from "../types"
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode, RightPanelTab } from "./types"
import { preferences, setGitDiffUnifiedGutterStyle } from "../../../../stores/preferences"

import { getDefaultWorktreeSlug, getOrCreateWorktreeClient, getWorktreeSlugForSession } from "../../../../stores/worktrees"
import { requestData } from "../../../../lib/opencode-api"
Expand Down Expand Up @@ -921,9 +922,11 @@ const RightPanel: Component<RightPanelProps> = (props) => {
diffViewMode={diffViewMode}
diffContextMode={diffContextMode}
diffWordWrapMode={diffWordWrapMode}
unifiedGutterStyle={() => preferences().gitDiffUnifiedGutterStyle}
onViewModeChange={setDiffViewMode}
onContextModeChange={setDiffContextMode}
onWordWrapModeChange={setDiffWordWrapMode}
onUnifiedGutterStyleChange={setGitDiffUnifiedGutterStyle}
onOpenFile={(path: string) => void openGitFile(path)}
onRefresh={() => void refreshGitStatus()}
listOpen={gitChangesListOpen}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { RefreshCw } from "lucide-solid"
import DiffToolbar from "../components/DiffToolbar"
import SplitFilePanel from "../components/SplitFilePanel"
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types"
import type { GitDiffUnifiedGutterStyle } from "../../../../../stores/preferences"

const LazyMonacoDiffViewer = lazy(() =>
import("../../../../file-viewer/monaco-diff-viewer").then((module) => ({ default: module.MonacoDiffViewer })),
Expand All @@ -32,9 +33,11 @@ interface GitChangesTabProps {
diffViewMode: Accessor<DiffViewMode>
diffContextMode: Accessor<DiffContextMode>
diffWordWrapMode: Accessor<DiffWordWrapMode>
unifiedGutterStyle: Accessor<GitDiffUnifiedGutterStyle>
onViewModeChange: (mode: DiffViewMode) => void
onContextModeChange: (mode: DiffContextMode) => void
onWordWrapModeChange: (mode: DiffWordWrapMode) => void
onUnifiedGutterStyleChange: (style: GitDiffUnifiedGutterStyle) => void

onOpenFile: (path: string) => void
onRefresh: () => void
Expand Down Expand Up @@ -139,6 +142,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
viewMode={props.diffViewMode()}
contextMode={props.diffContextMode()}
wordWrap={props.diffWordWrapMode()}
unifiedGutterStyle={props.unifiedGutterStyle()}
/>
</Suspense>
)}
Expand Down Expand Up @@ -264,6 +268,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
onContextModeChange={props.onContextModeChange}
onWordWrapModeChange={props.onWordWrapModeChange}
/>

</>
}
list={{ panel: renderListPanel, overlay: renderListOverlay }}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useI18n } from "../../lib/i18n"
import { useTheme, type ThemeMode } from "../../lib/theme"
import { useConfig } from "../../stores/preferences"
import { getBehaviorSettings, type BehaviorSetting } from "../../lib/settings/behavior-registry"
import type { GitDiffUnifiedGutterStyle } from "../../stores/preferences"

const themeModeOptions: Array<{ value: ThemeMode; icon: typeof Laptop }> = [
{ value: "system", icon: Laptop },
Expand All @@ -26,12 +27,21 @@ export const AppearanceSettingsSection: Component = () => {
togglePromptSubmitOnEnter,
toggleShowPromptVoiceInput,
setDiffViewMode,
setGitDiffUnifiedGutterStyle,
setToolOutputExpansion,
setDiagnosticsExpansion,
setThinkingBlocksExpansion,
setToolInputsVisibility,
} = useConfig()

const gitDiffGutterStyleOptions = createMemo<Array<{ value: GitDiffUnifiedGutterStyle; label: string }>>(() => [
{ value: "compact", label: t("settings.appearance.gitDiff.gutterMode.option.compact") },
{ value: "classic", label: t("settings.appearance.gitDiff.gutterMode.option.normal") },
])
const selectedGitDiffGutterStyle = createMemo(() =>
gitDiffGutterStyleOptions().find((option) => option.value === preferences().gitDiffUnifiedGutterStyle),
)

const behaviorSettings = createMemo(() =>
getBehaviorSettings({
preferences,
Expand Down Expand Up @@ -265,6 +275,49 @@ export const AppearanceSettingsSection: Component = () => {

<div class="settings-stack">
<For each={behaviorSettings()}>{(setting) => <BehaviorRow setting={setting} />}</For>

<div class="settings-toggle-row">
<div>
<div class="settings-toggle-title">{t("settings.appearance.gitDiff.gutterMode.title")}</div>
<div class="settings-toggle-caption">{t("settings.appearance.gitDiff.gutterMode.subtitle")}</div>
</div>
<Select<{ value: GitDiffUnifiedGutterStyle; label: string }>
value={selectedGitDiffGutterStyle()}
onChange={(opt) => {
if (!opt) return
setGitDiffUnifiedGutterStyle(opt.value)
}}
options={gitDiffGutterStyleOptions()}
optionValue="value"
optionTextValue="label"
itemComponent={(itemProps) => (
<Select.Item item={itemProps.item} class="selector-option">
<Select.ItemLabel class="selector-option-label">{itemProps.item.rawValue.label}</Select.ItemLabel>
</Select.Item>
)}
>
<Select.Trigger class="selector-trigger" aria-label={t("settings.appearance.gitDiff.gutterMode.title")}>
<div class="flex-1 min-w-0">
<Select.Value<{ value: GitDiffUnifiedGutterStyle; label: string }>>
{(state) => (
<span class="selector-trigger-primary selector-trigger-primary--align-left">
{state.selectedOption()?.label}
</span>
)}
</Select.Value>
</div>
<Select.Icon class="selector-trigger-icon">
<ChevronDown class="w-3 h-3" />
</Select.Icon>
</Select.Trigger>

<Select.Portal>
<Select.Content class="selector-popover">
<Select.Listbox class="selector-listbox" />
</Select.Content>
</Select.Portal>
</Select>
</div>
</div>
</div>
</div>
Expand Down
4 changes: 4 additions & 0 deletions packages/ui/src/lib/i18n/messages/en/instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,10 @@ export const instanceMessages = {
"instanceShell.diff.switchToUnified": "Switch to unified view",
"instanceShell.diff.enableWordWrap": "Enable word wrap",
"instanceShell.diff.disableWordWrap": "Disable word wrap",
"instanceShell.diff.switchToCompactGutter": "Switch unified gutter to compact",
"instanceShell.diff.switchToClassicGutter": "Switch unified gutter to classic",
"instanceShell.diff.gutterStyleCompact": "Compact",
"instanceShell.diff.gutterStyleClassic": "Classic",
"instanceShell.worktree.create": "+ Create worktree",

"instanceShell.plan.noSessionSelected": "Select a session to view plan.",
Expand Down
4 changes: 4 additions & 0 deletions packages/ui/src/lib/i18n/messages/en/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,10 @@ export const settingsMessages = {
"settings.behavior.diffView.subtitle": "Choose how tool-call diffs are displayed.",
"settings.behavior.diffView.option.split": "Split",
"settings.behavior.diffView.option.unified": "Unified",
"settings.appearance.gitDiff.gutterMode.title": "Git diff gutter mode",
"settings.appearance.gitDiff.gutterMode.subtitle": "Choose the Monaco gutter presentation used for Git Changes diffs.",
"settings.appearance.gitDiff.gutterMode.option.compact": "Compact",
"settings.appearance.gitDiff.gutterMode.option.normal": "Normal",
"settings.behavior.toolOutputsDefault.title": "Tool outputs default",
"settings.behavior.toolOutputsDefault.subtitle": "Choose whether tool outputs start expanded or collapsed.",
"settings.behavior.diagnosticsDefault.title": "Diagnostics default",
Expand Down
4 changes: 4 additions & 0 deletions packages/ui/src/lib/i18n/messages/es/instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,10 @@ export const instanceMessages = {
"instanceShell.gitChanges.loading": "Cargando cambios de Git...",
"instanceShell.gitChanges.empty": "Aún no hay cambios de Git.",
"instanceShell.gitChanges.deleted": "Eliminado",
"instanceShell.diff.switchToCompactGutter": "Cambiar el gutter unificado a compacto",
"instanceShell.diff.switchToClassicGutter": "Cambiar el gutter unificado a clásico",
"instanceShell.diff.gutterStyleCompact": "Compacto",
"instanceShell.diff.gutterStyleClassic": "Clásico",

"instanceShell.filesShell.fileListTitle": "Lista de archivos",
"instanceShell.filesShell.mobileSelectorLabel": "Seleccionar archivo",
Expand Down
4 changes: 4 additions & 0 deletions packages/ui/src/lib/i18n/messages/fr/instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,10 @@ export const instanceMessages = {
"instanceShell.gitChanges.loading": "Chargement des changements Git...",
"instanceShell.gitChanges.empty": "Aucun changement Git pour l'instant.",
"instanceShell.gitChanges.deleted": "Supprimé",
"instanceShell.diff.switchToCompactGutter": "Passer la gouttière unifiée en mode compact",
"instanceShell.diff.switchToClassicGutter": "Passer la gouttière unifiée en mode classique",
"instanceShell.diff.gutterStyleCompact": "Compact",
"instanceShell.diff.gutterStyleClassic": "Classique",

"instanceShell.filesShell.fileListTitle": "Liste des fichiers",
"instanceShell.filesShell.mobileSelectorLabel": "Sélectionner un fichier",
Expand Down
4 changes: 4 additions & 0 deletions packages/ui/src/lib/i18n/messages/he/instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,10 @@ export const instanceMessages = {
"instanceShell.diff.switchToUnified": "עבור לתצוגה מאוחדת",
"instanceShell.diff.enableWordWrap": "הפעל גלישת מילים",
"instanceShell.diff.disableWordWrap": "כבה גלישת מילים",
"instanceShell.diff.switchToCompactGutter": "עבור לשוליים מאוחדים קומפקטיים",
"instanceShell.diff.switchToClassicGutter": "עבור לשוליים מאוחדים קלאסיים",
"instanceShell.diff.gutterStyleCompact": "קומפקטי",
"instanceShell.diff.gutterStyleClassic": "קלאסי",
"instanceShell.worktree.create": "+ צור worktree",

"instanceShell.plan.noSessionSelected": "בחר סשן לצפייה בתוכנית.",
Expand Down
4 changes: 4 additions & 0 deletions packages/ui/src/lib/i18n/messages/ja/instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,10 @@ export const instanceMessages = {
"instanceShell.gitChanges.loading": "Git の変更を読み込み中...",
"instanceShell.gitChanges.empty": "Git の変更はまだありません。",
"instanceShell.gitChanges.deleted": "削除済み",
"instanceShell.diff.switchToCompactGutter": "統合ガターをコンパクト表示に切り替え",
"instanceShell.diff.switchToClassicGutter": "統合ガターをクラシック表示に切り替え",
"instanceShell.diff.gutterStyleCompact": "コンパクト",
"instanceShell.diff.gutterStyleClassic": "クラシック",

"instanceShell.filesShell.fileListTitle": "ファイル一覧",
"instanceShell.filesShell.mobileSelectorLabel": "ファイルを選択",
Expand Down
4 changes: 4 additions & 0 deletions packages/ui/src/lib/i18n/messages/ru/instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,10 @@ export const instanceMessages = {
"instanceShell.gitChanges.loading": "Загрузка изменений Git...",
"instanceShell.gitChanges.empty": "Изменений Git пока нет.",
"instanceShell.gitChanges.deleted": "Удалено",
"instanceShell.diff.switchToCompactGutter": "Переключить объединённую область на компактный режим",
"instanceShell.diff.switchToClassicGutter": "Переключить объединённую область на классический режим",
"instanceShell.diff.gutterStyleCompact": "Компактный",
"instanceShell.diff.gutterStyleClassic": "Классический",

"instanceShell.filesShell.fileListTitle": "Список файлов",
"instanceShell.filesShell.mobileSelectorLabel": "Выбрать файл",
Expand Down
4 changes: 4 additions & 0 deletions packages/ui/src/lib/i18n/messages/zh-Hans/instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,10 @@ export const instanceMessages = {
"instanceShell.gitChanges.loading": "正在加载 Git 更改...",
"instanceShell.gitChanges.empty": "暂无 Git 更改。",
"instanceShell.gitChanges.deleted": "已删除",
"instanceShell.diff.switchToCompactGutter": "切换统一边栏为紧凑模式",
"instanceShell.diff.switchToClassicGutter": "切换统一边栏为经典模式",
"instanceShell.diff.gutterStyleCompact": "紧凑",
"instanceShell.diff.gutterStyleClassic": "经典",

"instanceShell.filesShell.fileListTitle": "文件列表",
"instanceShell.filesShell.mobileSelectorLabel": "选择文件",
Expand Down
Loading
Loading