feat: Replaced Monaco diff editor with Pierre/diffs for better performance

This commit is contained in:
Bohdan Triapitsyn
2025-12-14 02:29:46 +02:00
parent 2d98e8b2b3
commit 0b92bbc487
14 changed files with 516 additions and 900 deletions
+3 -1
View File
@@ -16,6 +16,7 @@ The whole project was built entirely with AI coding agents under my supervision.
![Tool Output](docs/references/tool_output_example.png)
![Settings](docs/references/settings_example.png)
![Web Version](docs/references/web_version_example.png)
![Diff View](docs/references/diff_example.png)
![VS Code Extension](docs/references/vscode_extension.png)
<p>
<img src="docs/references/pwa_chat_example.png" width="45%" alt="PWA Chat">
@@ -41,7 +42,7 @@ The whole project was built entirely with AI coding agents under my supervision.
- Rich permission cards with syntax-highlighted operation previews
- Smart tool visualization (inline diffs, file trees, results highlighting)
- Per-agent permission mode control (ask, allow, full) adjustable per-session
- Familiar diff viewer like you're used to in VSCode
- Beautiful diff viewer with syntax highlighting, line wrap, and responsive layout
- Built-in OpenCode agent/command management
## Installation
@@ -97,6 +98,7 @@ Independent project, not affiliated with OpenCode team.
- [OpenCode](https://opencode.ai) - For the excellent API and extensible architecture
- [Flexoki](https://github.com/kepano/flexoki) - Beautiful color scheme by [Steph Ango](https://stephango.com/flexoki)
- [Pierre](https://pierrejs-docs.vercel.app/) - Fast, beautiful diff viewer with syntax highlighting
- [Tauri](https://github.com/tauri-apps/tauri) - Desktop application framework
- [David Hill](https://x.com/iamdavidhill) - who inspired me to release this without [overthinking](https://x.com/iamdavidhill/status/1993648326450020746?s=20)
- My wife, who created a beautiful firework animation for the app while testing it for the first time
Binary file not shown.

After

Width:  |  Height:  |  Size: 600 KiB

+1 -1
View File
@@ -2847,7 +2847,7 @@ dependencies = [
[[package]]
name = "openchamber-desktop"
version = "1.1.0"
version = "1.1.2"
dependencies = [
"anyhow",
"axum",
+1 -1
View File
@@ -15,6 +15,7 @@
"@fontsource/ibm-plex-sans": "^5.1.1",
"@ibm/plex": "^6.4.1",
"@opencode-ai/sdk": "^1.0.65",
"@pierre/diffs": "^1.0.0",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
@@ -38,7 +39,6 @@
"node-pty": "^1.0.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"@monaco-editor/react": "^4.6.0",
"react-syntax-highlighter": "^15.6.6",
"simple-git": "^3.28.0",
"sonner": "^2.0.7",
@@ -8,6 +8,7 @@ import { HelpDialog } from '../ui/HelpDialog';
import { SessionSidebar } from '@/components/session/SessionSidebar';
import { SessionDialogs } from '@/components/session/SessionDialogs';
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
import { DiffWorkerProvider } from '@/contexts/DiffWorkerProvider';
import { useUIStore } from '@/stores/useUIStore';
import { useDeviceInfo } from '@/lib/device';
@@ -58,7 +59,6 @@ export const MainLayout: React.FC = () => {
let timeoutId: number | undefined;
const handleResize = () => {
if (timeoutId !== undefined) {
window.clearTimeout(timeoutId);
}
@@ -94,19 +94,20 @@ export const MainLayout: React.FC = () => {
const isChatActive = activeMainTab === 'chat';
return (
<div
className={cn(
'main-content-safe-area h-[100dvh]',
isMobile ? 'flex flex-col' : 'flex',
isDesktopRuntime ? 'bg-transparent' : 'bg-background'
)}
>
<CommandPalette />
<HelpDialog />
<SessionDialogs />
<SettingsDialog isOpen={isSettingsDialogOpen} onClose={() => setSettingsDialogOpen(false)} />
<DiffWorkerProvider>
<div
className={cn(
'main-content-safe-area h-[100dvh]',
isMobile ? 'flex flex-col' : 'flex',
isDesktopRuntime ? 'bg-transparent' : 'bg-background'
)}
>
<CommandPalette />
<HelpDialog />
<SessionDialogs />
<SettingsDialog isOpen={isSettingsDialogOpen} onClose={() => setSettingsDialogOpen(false)} />
{isMobile ? (
{isMobile ? (
<>
<Header />
<div className="flex flex-1 overflow-hidden bg-background">
@@ -157,5 +158,6 @@ export const MainLayout: React.FC = () => {
<FixedSessionsButton />
</div>
</DiffWorkerProvider>
);
};
+249 -205
View File
@@ -1,12 +1,11 @@
import React from 'react';
import { RiGitCommitLine, RiLoader4Line } from '@remixicon/react';
import { RiGitCommitLine, RiLoader4Line, RiTextWrap } from '@remixicon/react';
import { useSessionStore } from '@/stores/useSessionStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useUIStore } from '@/stores/useUIStore';
import { useGitStore, useGitStatus, useIsGitRepo, useGitFileCount } from '@/stores/useGitStore';
import type { GitStatus } from '@/lib/api/types';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
@@ -15,15 +14,15 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { RiArrowDownSLine } from '@remixicon/react';
import { toast } from 'sonner';
import { getLanguageFromExtension } from '@/lib/toolHelpers';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { DiffViewToggle } from '@/components/chat/message/DiffViewToggle';
import type { DiffViewMode } from '@/components/chat/message/types';
import { PierreDiffViewer } from './PierreDiffViewer';
import { useDeviceInfo } from '@/lib/device';
const LazyMonacoDiffViewer = React.lazy(() =>
import('./MonacoDiffViewer').then((mod) => ({ default: mod.MonacoDiffViewer }))
);
// Minimum width for side-by-side diff view (px)
const SIDE_BY_SIDE_MIN_WIDTH = 1100;
type FileEntry = GitStatus['files'][number] & {
insertions: number;
@@ -31,9 +30,10 @@ type FileEntry = GitStatus['files'][number] & {
isNew: boolean;
};
type DiffData = { original: string; modified: string };
const isNewStatusFile = (file: GitStatus['files'][number]): boolean => {
const { index, working_dir: workingDir } = file;
return index === 'A' || workingDir === 'A' || index === '?' || workingDir === '?';
};
@@ -43,12 +43,8 @@ const formatDiffTotals = (insertions?: number, deletions?: number) => {
if (!added && !removed) return null;
return (
<span className="typography-meta flex flex-shrink-0 items-center gap-1 text-xs whitespace-nowrap">
{added ? (
<span style={{ color: 'var(--status-success)' }}>+{added}</span>
) : null}
{removed ? (
<span style={{ color: 'var(--status-error)' }}>-{removed}</span>
) : null}
{added ? <span style={{ color: 'var(--status-success)' }}>+{added}</span> : null}
{removed ? <span style={{ color: 'var(--status-error)' }}>-{removed}</span> : null}
</span>
);
};
@@ -74,9 +70,7 @@ const FileSelector = React.memo<FileSelectorProps>(({
<button className="flex h-8 items-center gap-2 rounded-lg border border-input bg-transparent px-2 typography-ui-label text-foreground outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring">
{selectedFileEntry ? (
<div className="flex items-center gap-3">
<span className="truncate typography-meta">
{selectedFileEntry.path}
</span>
<span className="truncate typography-meta">{selectedFileEntry.path}</span>
{formatDiffTotals(selectedFileEntry.insertions, selectedFileEntry.deletions)}
</div>
) : (
@@ -90,9 +84,7 @@ const FileSelector = React.memo<FileSelectorProps>(({
{changedFiles.map((file) => (
<DropdownMenuRadioItem key={file.path} value={file.path}>
<div className="flex w-full items-center justify-between gap-3">
<span className="truncate typography-meta">
{file.path}
</span>
<span className="truncate typography-meta">{file.path}</span>
{formatDiffTotals(file.insertions, file.deletions)}
</div>
</DropdownMenuRadioItem>
@@ -103,76 +95,54 @@ const FileSelector = React.memo<FileSelectorProps>(({
);
});
interface DiffContentProps {
fileDiff: { original: string; modified: string } | null;
activeFilePath: string;
isDiffLoading: boolean;
diffError: string | null;
onRetry: () => void;
// Single diff viewer instance - stays mounted
interface SingleDiffViewerProps {
filePath: string;
diff: DiffData;
isVisible: boolean;
renderSideBySide: boolean;
allowResponsive: boolean;
wrapLines: boolean;
}
const DiffContent = React.memo<DiffContentProps>(({
fileDiff,
activeFilePath,
isDiffLoading,
diffError,
onRetry,
const SingleDiffViewer = React.memo<SingleDiffViewerProps>(({
filePath,
diff,
isVisible,
renderSideBySide,
allowResponsive,
wrapLines,
}) => {
const language = React.useMemo(
() => (activeFilePath ? getLanguageFromExtension(activeFilePath) || 'text' : 'text'),
[activeFilePath]
() => getLanguageFromExtension(filePath) || 'text',
[filePath]
);
if (isDiffLoading) {
// Use display:none for hidden diffs to exclude from layout calculations during resize
// This is faster for resize than visibility:hidden which keeps elements in layout flow
if (!isVisible) {
return (
<div className="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground">
<RiLoader4Line size={16} className="animate-spin" />
Loading diff
</div>
);
}
if (diffError) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 text-center">
<p className="text-sm text-destructive">{diffError}</p>
<Button size="sm" onClick={onRetry}>
Retry
</Button>
</div>
);
}
if (!fileDiff || (!fileDiff.original && !fileDiff.modified)) {
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
No changes detected for this file
<div className="absolute inset-0 hidden">
<PierreDiffViewer
original={diff.original}
modified={diff.modified}
language={language}
fileName={filePath}
renderSideBySide={renderSideBySide}
wrapLines={wrapLines}
/>
</div>
);
}
return (
<div className="flex h-full w-full">
<React.Suspense
fallback={(
<div className="flex flex-1 items-center justify-center gap-2 text-sm text-muted-foreground">
<RiLoader4Line size={16} className="animate-spin" />
Loading diff viewer
</div>
)}
>
<LazyMonacoDiffViewer
original={fileDiff.original}
modified={fileDiff.modified}
language={language}
renderSideBySide={renderSideBySide}
allowResponsive={allowResponsive}
/>
</React.Suspense>
<div className="absolute inset-0" style={{ contain: 'size layout' }}>
<PierreDiffViewer
original={diff.original}
modified={diff.modified}
language={language}
fileName={filePath}
renderSideBySide={renderSideBySide}
wrapLines={wrapLines}
/>
</div>
);
});
@@ -191,6 +161,7 @@ const useEffectiveDirectory = () => {
export const DiffView: React.FC = () => {
const { git } = useRuntimeAPIs();
const effectiveDirectory = useEffectiveDirectory();
const { screenWidth } = useDeviceInfo();
const isGitRepo = useIsGitRepo(effectiveDirectory ?? null);
const status = useGitStatus(effectiveDirectory ?? null);
@@ -198,73 +169,21 @@ export const DiffView: React.FC = () => {
const { setActiveDirectory, fetchStatus, setDiff } = useGitStore();
const [selectedFile, setSelectedFile] = React.useState<string | null>(null);
const selectedFileRef = React.useRef<string | null>(null);
const [isDiffLoading, setIsDiffLoading] = React.useState(false);
const [diffError, setDiffError] = React.useState<string | null>(null);
const [fileDiff, setFileDiff] = React.useState<{ original: string; modified: string } | null>(null);
const [allDiffs, setAllDiffs] = React.useState<Map<string, DiffData>>(new Map());
const [loadingFiles, setLoadingFiles] = React.useState<Set<string>>(new Set());
const pendingDiffFile = useUIStore((state) => state.pendingDiffFile);
const setPendingDiffFile = useUIStore((state) => state.setPendingDiffFile);
const diffLayoutPreference = useUIStore((state) => state.diffLayoutPreference);
const diffFileLayout = useUIStore((state) => state.diffFileLayout);
const setDiffFileLayout = useUIStore((state) => state.setDiffFileLayout);
const diffWrapLines = useUIStore((state) => state.diffWrapLines);
const setDiffWrapLines = useUIStore((state) => state.setDiffWrapLines);
const lastStatusChange = useGitStore(React.useCallback((state) => {
if (!effectiveDirectory) return 0;
return state.directories.get(effectiveDirectory)?.lastStatusChange ?? 0;
}, [effectiveDirectory]));
const getCachedDiffIfFresh = React.useCallback((directory: string | undefined, filePath: string) => {
if (!directory) return null;
const dirState = useGitStore.getState().directories.get(directory);
if (!dirState) return null;
const cached = dirState.diffCache.get(filePath);
if (!cached) return null;
if (cached.fetchedAt < (dirState.lastStatusChange || 0)) {
return null;
}
return cached;
}, []);
const handleSelectFile = React.useCallback((value: string) => {
selectedFileRef.current = value;
setSelectedFile(value);
setDiffError(null);
const cached = getCachedDiffIfFresh(effectiveDirectory, value);
if (cached) {
setFileDiff({ original: cached.original, modified: cached.modified });
setIsDiffLoading(false);
} else {
setFileDiff(null);
setIsDiffLoading(true);
}
}, [effectiveDirectory, getCachedDiffIfFresh]);
React.useEffect(() => {
if (effectiveDirectory) {
setActiveDirectory(effectiveDirectory);
const dirState = useGitStore.getState().directories.get(effectiveDirectory);
if (!dirState?.status) {
fetchStatus(effectiveDirectory, git);
}
}
}, [effectiveDirectory, setActiveDirectory, fetchStatus, git]);
React.useEffect(() => {
if (!pendingDiffFile) return;
handleSelectFile(pendingDiffFile);
setPendingDiffFile(null);
}, [pendingDiffFile, handleSelectFile, setPendingDiffFile]);
React.useEffect(() => {
selectedFileRef.current = selectedFile;
}, [selectedFile]);
const changedFiles: FileEntry[] = React.useMemo(() => {
if (!status?.files) return [];
const diffStats = status.diffStats ?? {};
@@ -287,81 +206,205 @@ export const DiffView: React.FC = () => {
const currentLayoutForSelectedFile = React.useMemo<'inline' | 'side-by-side' | null>(() => {
if (!selectedFileEntry) return null;
// Per-file override takes priority
const override = diffFileLayout[selectedFileEntry.path];
if (override) return override;
if (diffLayoutPreference === 'inline' || diffLayoutPreference === 'side-by-side') {
return diffLayoutPreference;
// Explicit user preference - respect it regardless of screen width
if (diffLayoutPreference === 'inline') {
return 'inline';
}
return selectedFileEntry.isNew ? 'inline' : 'side-by-side';
}, [selectedFileEntry, diffFileLayout, diffLayoutPreference]);
if (diffLayoutPreference === 'side-by-side') {
return 'side-by-side';
}
// Dynamic mode: auto-switch based on file type and screen width
const isNarrow = screenWidth < SIDE_BY_SIDE_MIN_WIDTH;
if (selectedFileEntry.isNew || isNarrow) {
return 'inline';
}
return 'side-by-side';
}, [selectedFileEntry, diffFileLayout, diffLayoutPreference, screenWidth]);
// Fetch git status on mount
React.useEffect(() => {
if (!selectedFile && !pendingDiffFile && changedFiles.length > 0) {
const nextPath = changedFiles[0].path;
handleSelectFile(nextPath);
if (effectiveDirectory) {
setActiveDirectory(effectiveDirectory);
const dirState = useGitStore.getState().directories.get(effectiveDirectory);
if (!dirState?.status) {
fetchStatus(effectiveDirectory, git);
}
}
}, [changedFiles, selectedFile, pendingDiffFile, handleSelectFile]);
}, [effectiveDirectory, setActiveDirectory, fetchStatus, git]);
// Handle pending diff file from external navigation
React.useEffect(() => {
if (pendingDiffFile) {
setSelectedFile(pendingDiffFile);
setPendingDiffFile(null);
}
}, [pendingDiffFile, setPendingDiffFile]);
// Auto-select first file
React.useEffect(() => {
if (!selectedFile && changedFiles.length > 0) {
setSelectedFile(changedFiles[0].path);
}
}, [changedFiles, selectedFile]);
// Clear selection if file no longer exists
React.useEffect(() => {
if (selectedFile && changedFiles.length > 0) {
const stillExists = changedFiles.some((f) => f.path === selectedFile);
if (!stillExists) {
setSelectedFile(null);
selectedFileRef.current = null;
setFileDiff(null);
setDiffError(null);
setSelectedFile(changedFiles[0]?.path ?? null);
}
}
}, [changedFiles, selectedFile]);
const loadDiff = React.useCallback(async () => {
if (!effectiveDirectory || !selectedFileEntry) {
setFileDiff(null);
setDiffError(null);
setIsDiffLoading(false);
return;
}
// PRE-FETCH ALL DIFFS when changedFiles changes
React.useEffect(() => {
if (!effectiveDirectory || changedFiles.length === 0) return;
const cacheKey = selectedFileEntry.path;
const fetchAllDiffs = async () => {
const filesToFetch = changedFiles.filter((file) => !allDiffs.has(file.path));
if (filesToFetch.length === 0) return;
const cached = getCachedDiffIfFresh(effectiveDirectory, cacheKey);
if (cached) {
setFileDiff({ original: cached.original, modified: cached.modified });
setDiffError(null);
setIsDiffLoading(false);
return;
}
setIsDiffLoading(true);
setDiffError(null);
try {
const response = await git.getGitFileDiff(effectiveDirectory, {
path: selectedFileEntry.path,
// Mark all as loading
setLoadingFiles((prev) => {
const next = new Set(prev);
filesToFetch.forEach((f) => next.add(f.path));
return next;
});
const diff = {
original: response.original ?? '',
modified: response.modified ?? '',
};
setDiff(effectiveDirectory, cacheKey, diff);
setFileDiff(diff);
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to load diff';
setDiffError(message);
toast.error(message);
} finally {
setIsDiffLoading(false);
}
}, [effectiveDirectory, git, selectedFileEntry, getCachedDiffIfFresh, setDiff, lastStatusChange]);
// Fetch all in parallel
const results = await Promise.allSettled(
filesToFetch.map(async (file) => {
const dirState = useGitStore.getState().directories.get(effectiveDirectory);
const cached = dirState?.diffCache.get(file.path);
if (cached && cached.fetchedAt >= (dirState?.lastStatusChange || 0)) {
return { path: file.path, diff: { original: cached.original, modified: cached.modified } };
}
const response = await git.getGitFileDiff(effectiveDirectory, { path: file.path });
const diff = { original: response.original ?? '', modified: response.modified ?? '' };
setDiff(effectiveDirectory, file.path, diff);
return { path: file.path, diff };
})
);
// Update state with all fetched diffs
setAllDiffs((prev) => {
const next = new Map(prev);
results.forEach((result) => {
if (result.status === 'fulfilled') {
next.set(result.value.path, result.value.diff);
}
});
return next;
});
// Clear loading state
setLoadingFiles((prev) => {
const next = new Set(prev);
filesToFetch.forEach((f) => next.delete(f.path));
return next;
});
};
fetchAllDiffs();
}, [effectiveDirectory, changedFiles, git, setDiff]); // eslint-disable-line react-hooks/exhaustive-deps
// Clear all diffs when directory changes
React.useEffect(() => {
loadDiff();
}, [loadDiff]);
setAllDiffs(new Map());
setLoadingFiles(new Set());
}, [effectiveDirectory]);
const activeFilePath = selectedFileEntry?.path ?? '';
// Re-fetch stale diffs when status changes (don't clear - keep old ones visible while fetching)
React.useEffect(() => {
if (!effectiveDirectory || !lastStatusChange || changedFiles.length === 0) return;
const refetchStaleDiffs = async () => {
const dirState = useGitStore.getState().directories.get(effectiveDirectory);
if (!dirState) return;
// Find files that need refetching (stale cache or no cache)
const staleFiles = changedFiles.filter((file) => {
const cached = dirState.diffCache.get(file.path);
return !cached || cached.fetchedAt < lastStatusChange;
});
if (staleFiles.length === 0) return;
// Mark as loading
setLoadingFiles((prev) => {
const next = new Set(prev);
staleFiles.forEach((f) => next.add(f.path));
return next;
});
// Fetch in parallel
const results = await Promise.allSettled(
staleFiles.map(async (file) => {
const response = await git.getGitFileDiff(effectiveDirectory, { path: file.path });
const diff = { original: response.original ?? '', modified: response.modified ?? '' };
setDiff(effectiveDirectory, file.path, diff);
return { path: file.path, diff };
})
);
// Update diffs in place (don't clear old ones first)
setAllDiffs((prev) => {
const next = new Map(prev);
results.forEach((result) => {
if (result.status === 'fulfilled') {
next.set(result.value.path, result.value.diff);
}
});
// Remove diffs for files that no longer exist in changedFiles
for (const [filePath] of prev) {
if (!changedFiles.some((f) => f.path === filePath)) {
next.delete(filePath);
}
}
return next;
});
// Clear loading state
setLoadingFiles((prev) => {
const next = new Set(prev);
staleFiles.forEach((f) => next.delete(f.path));
return next;
});
};
refetchStaleDiffs();
}, [effectiveDirectory, lastStatusChange, changedFiles, git, setDiff]);
const handleSelectFile = React.useCallback((value: string) => {
setSelectedFile(value);
}, []);
const renderSideBySide = (currentLayoutForSelectedFile ?? 'side-by-side') === 'side-by-side';
// Render all diff viewers - they stay mounted
const renderAllDiffViewers = () => {
if (allDiffs.size === 0) return null;
return Array.from(allDiffs.entries()).map(([filePath, diff]) => (
<SingleDiffViewer
key={filePath}
filePath={filePath}
diff={diff}
isVisible={filePath === selectedFile}
renderSideBySide={renderSideBySide}
wrapLines={diffWrapLines}
/>
));
};
const renderContent = () => {
if (!effectiveDirectory) {
@@ -397,31 +440,18 @@ export const DiffView: React.FC = () => {
);
}
if (!selectedFileEntry) {
return (
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
Select a file to inspect its diff
</div>
);
}
const effectiveLayout = currentLayoutForSelectedFile ?? 'side-by-side';
const renderSideBySide = effectiveLayout === 'side-by-side';
const hasFileOverride = !!diffFileLayout[selectedFileEntry.path];
const allowResponsive =
diffLayoutPreference === 'dynamic' && !hasFileOverride;
const isCurrentFileLoading = selectedFile && loadingFiles.has(selectedFile);
const hasCurrentDiff = selectedFile && allDiffs.has(selectedFile);
return (
<div className="flex flex-1 min-h-0 px-3 py-3">
<DiffContent
fileDiff={fileDiff}
activeFilePath={activeFilePath}
isDiffLoading={isDiffLoading}
diffError={diffError}
onRetry={loadDiff}
renderSideBySide={renderSideBySide}
allowResponsive={allowResponsive}
/>
<div className="flex flex-1 min-h-0 overflow-hidden px-3 py-3 relative">
{renderAllDiffViewers()}
{isCurrentFileLoading && !hasCurrentDiff && (
<div className="absolute inset-0 flex items-center justify-center gap-2 text-sm text-muted-foreground">
<RiLoader4Line size={16} className="animate-spin" />
Loading diff
</div>
)}
</div>
);
};
@@ -444,6 +474,20 @@ export const DiffView: React.FC = () => {
onSelectFile={handleSelectFile}
/>
<div className="flex-1" />
{selectedFileEntry && (
<button
type="button"
onClick={() => setDiffWrapLines(!diffWrapLines)}
className={`flex items-center justify-center size-5 rounded-sm transition-opacity ${
diffWrapLines
? 'text-foreground opacity-100'
: 'text-muted-foreground opacity-60 hover:opacity-100'
}`}
title={diffWrapLines ? 'Disable line wrap' : 'Enable line wrap'}
>
<RiTextWrap className="size-4" />
</button>
)}
{selectedFileEntry && currentLayoutForSelectedFile && (
<DiffViewToggle
mode={currentLayoutForSelectedFile === 'side-by-side' ? 'side-by-side' : 'unified'}
@@ -1,151 +0,0 @@
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { DiffEditor, type DiffEditorProps, type MonacoDiffEditor } from '@monaco-editor/react';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { ensureMonacoThemeRegistered, getMonacoThemeIdForTheme } from '@/lib/theme/monacoThemeGenerator';
interface MonacoDiffViewerProps {
original: string;
modified: string;
language: string;
renderSideBySide: boolean;
allowResponsive?: boolean;
readOnly?: boolean;
showLineNumbers?: boolean;
}
export const MonacoDiffViewer: React.FC<MonacoDiffViewerProps> = ({
original,
modified,
language,
renderSideBySide,
allowResponsive = false,
readOnly = true,
showLineNumbers = true,
}) => {
const { currentTheme } = useThemeSystem();
const diffEditorRef = useRef<MonacoDiffEditor | null>(null);
const themeId = useMemo(
() => getMonacoThemeIdForTheme(currentTheme),
[currentTheme],
);
const handleBeforeMount = useCallback<NonNullable<DiffEditorProps['beforeMount']>>(
(monacoInstance) => {
ensureMonacoThemeRegistered(
currentTheme,
monacoInstance as Parameters<typeof ensureMonacoThemeRegistered>[1],
);
},
[currentTheme],
);
const options = useMemo(
(): DiffEditorProps['options'] => ({
readOnly,
renderSideBySide,
automaticLayout: true,
minimap: { enabled: false },
wordWrap: 'off',
lineNumbers: showLineNumbers ? 'on' : 'off',
renderIndicators: false,
scrollBeyondLastLine: false,
renderLineHighlight: 'none',
selectionHighlight: false,
occurrencesHighlight: 'off',
roundedSelection: false,
contextmenu: false,
folding: false,
glyphMargin: false,
renderMarginRevertIcon: false,
scrollbar: {
vertical: 'visible',
horizontal: 'visible',
verticalScrollbarSize: 6,
horizontalScrollbarSize: 6,
useShadows: false,
},
hover: {
enabled: false,
},
overviewRulerLanes: 1,
useInlineViewWhenSpaceIsLimited: allowResponsive,
}),
[allowResponsive, readOnly, renderSideBySide, showLineNumbers],
);
useEffect(() => {
if (typeof window === 'undefined') return;
const monaco = (window as typeof window & { monaco?: { editor: { setTheme: (id: string) => void } } }).monaco;
if (!monaco?.editor?.setTheme) return;
ensureMonacoThemeRegistered(currentTheme);
monaco.editor.setTheme(themeId);
}, [currentTheme, themeId]);
useEffect(() => {
const diffEditor = diffEditorRef.current;
if (!diffEditor) return;
const originalEditor = diffEditor.getOriginalEditor();
const modifiedEditor = diffEditor.getModifiedEditor();
const disablePopups = {
hover: { enabled: false },
quickSuggestions: false,
parameterHints: { enabled: false },
suggestOnTriggerCharacters: false,
} as const;
originalEditor.updateOptions(disablePopups);
modifiedEditor.updateOptions(disablePopups);
}, [themeId, readOnly, renderSideBySide]);
useEffect(() => {
return () => {
const diffEditor = diffEditorRef.current;
if (diffEditor) {
try {
diffEditor.dispose?.();
} catch (error) {
console.warn('Error disposing DiffEditor:', error);
}
}
diffEditorRef.current = null;
};
}, []);
if (typeof window === 'undefined') {
return null;
}
return (
<DiffEditor
original={original}
modified={modified}
originalLanguage={language}
modifiedLanguage={language}
theme={themeId}
options={options}
beforeMount={handleBeforeMount}
onMount={(diffEditor: MonacoDiffEditor) => {
diffEditorRef.current = diffEditor;
const original = diffEditor.getOriginalEditor();
const modified = diffEditor.getModifiedEditor();
const disablePopups = {
hover: { enabled: false },
quickSuggestions: false,
parameterHints: { enabled: false },
suggestOnTriggerCharacters: false,
} as const;
original.updateOptions(disablePopups);
modified.updateOptions(disablePopups);
}}
loading={null}
className="monaco-diff-wrapper"
/>
);
};
@@ -0,0 +1,123 @@
import React, { useMemo, useRef } from 'react';
import { FileDiff } from '@pierre/diffs/react';
import { parseDiffFromFile, type FileContents, type FileDiffMetadata } from '@pierre/diffs';
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
interface PierreDiffViewerProps {
original: string;
modified: string;
language: string;
fileName?: string;
renderSideBySide: boolean;
wrapLines?: boolean;
}
// CSS injected into Pierre's Shadow DOM for WebKit scroll optimization
// Note: avoid will-change and contain:paint as they break resize behavior
const WEBKIT_SCROLL_FIX_CSS = `
:host, pre, [data-diffs], [data-code] {
transform: translateZ(0);
-webkit-transform: translateZ(0);
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
}
[data-code] {
-webkit-overflow-scrolling: touch;
}
`;
// Fast cache key - use length + samples instead of full hash
function getCacheKey(fileName: string, original: string, modified: string): string {
// Sample a few characters instead of hashing entire content
const sampleOriginal = original.length > 100
? `${original.slice(0, 50)}${original.slice(-50)}`
: original;
const sampleModified = modified.length > 100
? `${modified.slice(0, 50)}${modified.slice(-50)}`
: modified;
return `${fileName}:${original.length}:${modified.length}:${sampleOriginal.length}:${sampleModified.length}`;
}
export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
original,
modified,
language,
fileName = 'file',
renderSideBySide,
wrapLines = false,
}) => {
const themeSystem = useOptionalThemeSystem();
const isDark = themeSystem?.currentTheme?.metadata?.variant === 'dark';
// Cache the last computed diff to avoid recomputing on every render
const diffCacheRef = useRef<{
key: string;
fileDiff: FileDiffMetadata;
} | null>(null);
// Pre-parse the diff with cacheKey for worker pool caching
const fileDiff = useMemo(() => {
const cacheKey = getCacheKey(fileName, original, modified);
// Return cached diff if inputs haven't changed
if (diffCacheRef.current?.key === cacheKey) {
return diffCacheRef.current.fileDiff;
}
const oldFile: FileContents = {
name: fileName,
contents: original,
lang: language as FileContents['lang'],
cacheKey: `old-${cacheKey}`,
};
const newFile: FileContents = {
name: fileName,
contents: modified,
lang: language as FileContents['lang'],
cacheKey: `new-${cacheKey}`,
};
const diff = parseDiffFromFile(oldFile, newFile);
// Cache the result
diffCacheRef.current = { key: cacheKey, fileDiff: diff };
return diff;
}, [fileName, original, modified, language]);
const options = useMemo(() => ({
theme: {
dark: 'vitesse-dark' as const,
light: 'vitesse-light' as const,
},
themeType: isDark ? ('dark' as const) : ('light' as const),
diffStyle: renderSideBySide ? ('split' as const) : ('unified' as const),
diffIndicators: 'none' as const,
hunkSeparators: 'line-info' as const,
lineDiffType: 'word-alt' as const,
overflow: wrapLines ? ('wrap' as const) : ('scroll' as const),
disableFileHeader: true,
enableLineSelection: false,
enableHoverUtility: false,
unsafeCSS: WEBKIT_SCROLL_FIX_CSS,
}), [isDark, renderSideBySide, wrapLines]);
if (typeof window === 'undefined') {
return null;
}
return (
<ScrollableOverlay
outerClassName="pierre-diff-wrapper size-full"
disableHorizontal={false}
>
<FileDiff
fileDiff={fileDiff}
options={options}
/>
</ScrollableOverlay>
);
};
@@ -0,0 +1,47 @@
import React, { useMemo } from 'react';
import { WorkerPoolContextProvider, useWorkerPool } from '@pierre/diffs/react';
import type { SupportedLanguages } from '@pierre/diffs';
import { useOptionalThemeSystem } from './useThemeSystem';
import { workerFactory } from '@/lib/diff/workerFactory';
// Only preload the most common languages - others load on demand
const PRELOAD_LANGS: SupportedLanguages[] = [
'typescript',
'javascript',
'tsx',
'json',
];
interface DiffWorkerProviderProps {
children: React.ReactNode;
}
export const DiffWorkerProvider: React.FC<DiffWorkerProviderProps> = ({ children }) => {
const themeSystem = useOptionalThemeSystem();
const isDark = themeSystem?.currentTheme?.metadata?.variant === 'dark';
const highlighterOptions = useMemo(() => ({
theme: {
dark: 'vitesse-dark' as const,
light: 'vitesse-light' as const,
},
themeType: isDark ? ('dark' as const) : ('light' as const),
langs: PRELOAD_LANGS,
}), [isDark]);
return (
<WorkerPoolContextProvider
poolOptions={{
workerFactory,
poolSize: 4,
totalASTLRUCacheSize: 200,
}}
highlighterOptions={highlighterOptions}
>
{children}
</WorkerPoolContextProvider>
);
};
export { useWorkerPool };
+24 -39
View File
@@ -706,49 +706,34 @@ html:not(.dark) .chat-scroll {
margin: 0;
}
/* Monaco diff editor scrollbar styling (match overlay look) */
.monaco-diff-wrapper {
--vscode-scrollbarSlider-background: var(--oc-scrollbar-thumb);
--vscode-scrollbarSlider-hoverBackground: var(--oc-scrollbar-thumb-hover);
--vscode-scrollbarSlider-activeBackground: var(--oc-scrollbar-thumb-hover);
--vscode-scrollbar-background: transparent;
--vscode-scrollbar-shadow: transparent;
/* Pierre diff viewer styling */
.pierre-diff-wrapper {
--diffs-font-family: var(--font-mono, 'IBM Plex Mono', monospace);
--diffs-font-size: var(--text-code, 13px);
--diffs-line-height: 1.5;
--diffs-tab-size: 2;
--diffs-header-font-family: var(--font-sans, 'IBM Plex Sans', sans-serif);
--diffs-min-number-column-width: 4ch;
--diffs-gap-inline: 0;
--diffs-gap-block: 0;
}
.monaco-diff-wrapper .monaco-scrollable-element > .scrollbar {
background: transparent;
/* WebKit scroll rendering optimizations */
/* Note: avoid will-change and contain as they break resize behavior */
.pierre-diff-wrapper,
.pierre-diff-wrapper diffs-container {
/* Force GPU compositing layer */
transform: translateZ(0);
-webkit-transform: translateZ(0);
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
/* WebKit touch scroll optimization */
-webkit-overflow-scrolling: touch;
}
.monaco-diff-wrapper .monaco-scrollable-element > .scrollbar > .slider {
border-radius: 9999px;
}
.monaco-diff-wrapper .monaco-scrollable-element > .scrollbar.vertical {
width: 6px !important;
}
.monaco-diff-wrapper .monaco-scrollable-element > .scrollbar.horizontal {
height: 6px !important;
}
.monaco-diff-wrapper .monaco-scrollable-element > .scrollbar.vertical > .slider {
width: 6px !important;
}
.monaco-diff-wrapper .monaco-scrollable-element > .scrollbar.horizontal > .slider {
height: 6px !important;
}
/* Overview ruler (change/minimap lane) width and padding */
.monaco-diff-wrapper .decorationsOverviewRuler {
width: 6px !important;
right: 2px !important; /* small gap from editor edge */
}
/* Hide Monaco hover widgets globally (tooltips over buttons, etc.) */
.monaco-hover,
.monaco-editor .hoverWidget {
display: none !important;
.pierre-diff-wrapper diffs-container {
display: block;
}
/* Agent color system using theme variables */
@@ -0,0 +1,5 @@
import WorkerUrl from '@pierre/diffs/worker/worker.js?worker&url';
export function workerFactory(): Worker {
return new Worker(WorkerUrl, { type: 'module' });
}
@@ -1,445 +0,0 @@
import type { Theme } from '@/types/theme';
interface MonacoTokenRule {
token: string;
foreground?: string;
fontStyle?: string;
}
interface MonacoThemeData {
base: 'vs' | 'vs-dark';
inherit: boolean;
rules: MonacoTokenRule[];
colors: Record<string, string>;
}
type Monaco = {
editor: {
defineTheme: (themeName: string, themeData: MonacoThemeData) => void;
};
};
const MONACO_LIGHT_THEME_ID = 'openchamber-flexoki-light';
const MONACO_DARK_THEME_ID = 'openchamber-flexoki-dark';
let lastRegisteredThemeId: string | null = null;
const getMonacoFromGlobal = (): Monaco | null => {
if (typeof window === 'undefined') return null;
const anyWindow = window as typeof window & { monaco?: Monaco };
if (anyWindow.monaco?.editor?.defineTheme) return anyWindow.monaco;
return null;
};
const flexokiDarkTheme: MonacoThemeData = {
base: 'vs-dark',
inherit: true,
rules: [
{ token: '', foreground: 'CECDC3' },
{ token: 'source', foreground: 'CECDC3' },
{ token: 'comment', foreground: '878580' },
{ token: 'comment.block', foreground: '878580' },
{ token: 'comment.line', foreground: '878580' },
{ token: 'comment.block.documentation', foreground: '575653' },
{ token: 'string', foreground: '3AA99F' },
{ token: 'string.quoted', foreground: '3AA99F' },
{ token: 'string.template', foreground: '3AA99F' },
{ token: 'string.regexp', foreground: '3AA99F' },
{ token: 'string.escape', foreground: 'CECDC3' },
{ token: 'constant.character.escape', foreground: 'CECDC3' },
{ token: 'number', foreground: '8B7EC8' },
{ token: 'number.hex', foreground: '8B7EC8' },
{ token: 'number.float', foreground: '8B7EC8' },
{ token: 'constant.numeric', foreground: '8B7EC8' },
{ token: 'constant.language', foreground: 'D0A215' },
{ token: 'constant.language.boolean', foreground: 'D0A215' },
{ token: 'constant.language.null', foreground: 'D0A215' },
{ token: 'keyword', foreground: '4385BE' },
{ token: 'keyword.control', foreground: '4385BE' },
{ token: 'keyword.other', foreground: '4385BE' },
{ token: 'keyword.control.import', foreground: 'D14D41' },
{ token: 'keyword.control.from', foreground: 'D14D41' },
{ token: 'keyword.control.export', foreground: 'D14D41' },
{ token: 'keyword.control.exception', foreground: 'CE5D97' },
{ token: 'keyword.control.trycatch', foreground: 'CE5D97' },
{ token: 'keyword.operator', foreground: 'D14D41' },
{ token: 'operator', foreground: 'D14D41' },
{ token: 'storage', foreground: '4385BE' },
{ token: 'storage.type', foreground: '4385BE' },
{ token: 'storage.modifier', foreground: '4385BE' },
{ token: 'entity.name.function', foreground: 'DA702C', fontStyle: 'bold' },
{ token: 'support.function', foreground: 'DA702C', fontStyle: 'bold' },
{ token: 'meta.function-call', foreground: 'DA702C' },
{ token: 'entity.name.function.method', foreground: '879A39' },
{ token: 'entity.name.class', foreground: 'DA702C' },
{ token: 'entity.name.type.class', foreground: 'DA702C' },
{ token: 'support.class', foreground: 'DA702C' },
{ token: 'entity.name.type', foreground: 'D0A215' },
{ token: 'entity.name.type.interface', foreground: 'D0A215' },
{ token: 'support.type', foreground: 'D0A215' },
{ token: 'entity.name.type.struct', foreground: 'DA702C' },
{ token: 'entity.name.type.enum', foreground: 'DA702C' },
{ token: 'entity.name.type.parameter', foreground: 'DA702C' },
{ token: 'variable', foreground: 'CECDC3' },
{ token: 'variable.other', foreground: 'CECDC3' },
{ token: 'variable.parameter', foreground: 'CECDC3' },
{ token: 'variable.other.object', foreground: '879A39' },
{ token: 'variable.other.readwrite.alias', foreground: '879A39' },
{ token: 'variable.language', foreground: 'CE5D97' },
{ token: 'variable.language.this', foreground: 'CE5D97' },
{ token: 'variable.language.super', foreground: 'CE5D97' },
{ token: 'variable.other.property', foreground: '4385BE' },
{ token: 'support.variable.property', foreground: '4385BE' },
{ token: 'variable.other.constant', foreground: 'CECDC3' },
{ token: 'meta.object-literal.key', foreground: 'DA702C' },
{ token: 'support.type.property-name', foreground: 'DA702C' },
{ token: 'entity.name.tag', foreground: '4385BE' },
{ token: 'tag', foreground: '4385BE' },
{ token: 'support.class.component', foreground: 'CE5D97' },
{ token: 'entity.other.attribute-name', foreground: 'D0A215' },
{ token: 'entity.name.namespace', foreground: 'D0A215' },
{ token: 'entity.name.module', foreground: 'D14D41' },
{ token: 'meta.decorator', foreground: 'D0A215' },
{ token: 'entity.name.function.decorator', foreground: 'D0A215' },
{ token: 'entity.name.label', foreground: 'CE5D97' },
{ token: 'meta.preprocessor', foreground: 'CE5D97' },
{ token: 'entity.name.function.preprocessor', foreground: '4385BE' },
{ token: 'punctuation', foreground: '878580' },
{ token: 'delimiter', foreground: '878580' },
{ token: 'delimiter.bracket', foreground: '878580' },
{ token: 'markup.heading', foreground: 'D0A215' },
{ token: 'markup.bold', foreground: 'D0A215', fontStyle: 'bold' },
{ token: 'markup.italic', foreground: '3AA99F', fontStyle: 'italic' },
{ token: 'markup.underline.link', foreground: '4385BE' },
{ token: 'markup.inline.raw', foreground: '3AA99F' },
{ token: 'invalid', foreground: 'D14D41' },
{ token: 'invalid.illegal', foreground: 'D14D41' },
{ token: 'string.key.json', foreground: 'DA702C' },
{ token: 'string.value.json', foreground: '3AA99F' },
{ token: 'support.type.property-name.css', foreground: 'CECDC3' },
{ token: 'support.constant.property-value.css', foreground: '3AA99F' },
{ token: 'type', foreground: 'D0A215' },
{ token: 'type.identifier', foreground: 'D0A215' },
{ token: 'identifier', foreground: 'CECDC3' },
],
colors: {
'editor.background': '#100F0F',
'editor.foreground': '#CECDC3',
'editor.lineHighlightBackground': '#1C1B1A',
'editor.selectionBackground': '#CECDC333',
'editor.selectionHighlightBackground': '#CECDC333',
'editor.inactiveSelectionBackground': '#282726',
'editor.findMatchBackground': '#AD8301',
'editor.findMatchHighlightBackground': '#AD8301cc',
'editor.hoverHighlightBackground': '#343331',
'editor.rangeHighlightBackground': '#403E3C',
'editorCursor.foreground': '#CECDC3',
'editorLineNumber.foreground': '#403E3C',
'editorLineNumber.activeForeground': '#CECDC3',
'editorGutter.background': '#100F0F',
'editorGutter.modifiedBackground': '#3AA99F',
'editorGutter.addedBackground': '#879A39',
'editorGutter.deletedBackground': '#D14D41',
'diffEditor.insertedTextBackground': '#66800B25',
'diffEditor.removedTextBackground': '#AF302925',
'diffEditor.insertedLineBackground': '#66800B15',
'diffEditor.removedLineBackground': '#AF302915',
'diffEditor.insertedTextBorder': '#00000000',
'diffEditor.removedTextBorder': '#00000000',
'editorBracketMatch.background': '#282726',
'editorBracketMatch.border': '#343331',
'editorWhitespace.foreground': '#403E3C',
'editorIndentGuide.background1': '#343331',
'editorIndentGuide.activeBackground1': '#575653',
'editorWidget.background': '#1C1B1A',
'editorWidget.border': '#343331',
'editorSuggestWidget.background': '#100F0F',
'editorSuggestWidget.border': '#343331',
'editorSuggestWidget.foreground': '#CECDC3',
'editorSuggestWidget.selectedBackground': '#343331',
'editorHoverWidget.background': '#282726',
'editorHoverWidget.border': '#343331',
'editorInlayHint.foreground': '#878580',
'editorInlayHint.background': '#343331',
'editorError.foreground': '#D14D41',
'editorWarning.foreground': '#DA702C',
'editorInfo.foreground': '#4385BE',
'input.background': '#1C1B1A',
'input.foreground': '#CECDC3',
'input.border': '#343331',
'input.placeholderForeground': '#878580',
'dropdown.background': '#1C1B1A',
'dropdown.foreground': '#CECDC3',
'dropdown.border': '#343331',
'dropdown.listBackground': '#100F0F',
'focusBorder': '#343331',
'scrollbarSlider.background': '#34333180',
'scrollbarSlider.hoverBackground': '#403E3C',
'scrollbarSlider.activeBackground': '#575653',
},
};
const flexokiLightTheme: MonacoThemeData = {
base: 'vs',
inherit: true,
rules: [
{ token: '', foreground: '100F0F' },
{ token: 'source', foreground: '100F0F' },
{ token: 'comment', foreground: '6F6E69' },
{ token: 'comment.block', foreground: '6F6E69' },
{ token: 'comment.line', foreground: '6F6E69' },
{ token: 'comment.block.documentation', foreground: 'B7B5AC' },
{ token: 'string', foreground: '24837B' },
{ token: 'string.quoted', foreground: '24837B' },
{ token: 'string.template', foreground: '24837B' },
{ token: 'string.regexp', foreground: '24837B' },
{ token: 'string.escape', foreground: '100F0F' },
{ token: 'constant.character.escape', foreground: '100F0F' },
{ token: 'number', foreground: '5E409D' },
{ token: 'number.hex', foreground: '5E409D' },
{ token: 'number.float', foreground: '5E409D' },
{ token: 'constant.numeric', foreground: '5E409D' },
{ token: 'constant.language', foreground: 'AD8301' },
{ token: 'constant.language.boolean', foreground: 'AD8301' },
{ token: 'constant.language.null', foreground: 'AD8301' },
{ token: 'keyword', foreground: '205EA6' },
{ token: 'keyword.control', foreground: '205EA6' },
{ token: 'keyword.other', foreground: '205EA6' },
{ token: 'keyword.control.import', foreground: 'AF3029' },
{ token: 'keyword.control.from', foreground: 'AF3029' },
{ token: 'keyword.control.export', foreground: 'AF3029' },
{ token: 'keyword.control.exception', foreground: 'A02F6F' },
{ token: 'keyword.control.trycatch', foreground: 'A02F6F' },
{ token: 'keyword.operator', foreground: 'AF3029' },
{ token: 'operator', foreground: 'AF3029' },
{ token: 'storage', foreground: '205EA6' },
{ token: 'storage.type', foreground: '205EA6' },
{ token: 'storage.modifier', foreground: '205EA6' },
{ token: 'entity.name.function', foreground: 'BC5215', fontStyle: 'bold' },
{ token: 'support.function', foreground: 'BC5215', fontStyle: 'bold' },
{ token: 'meta.function-call', foreground: 'BC5215' },
{ token: 'entity.name.function.method', foreground: '66800B' },
{ token: 'entity.name.class', foreground: 'BC5215' },
{ token: 'entity.name.type.class', foreground: 'BC5215' },
{ token: 'support.class', foreground: 'BC5215' },
{ token: 'entity.name.type', foreground: 'AD8301' },
{ token: 'entity.name.type.interface', foreground: 'AD8301' },
{ token: 'support.type', foreground: 'AD8301' },
{ token: 'entity.name.type.struct', foreground: 'BC5215' },
{ token: 'entity.name.type.enum', foreground: 'BC5215' },
{ token: 'entity.name.type.parameter', foreground: 'BC5215' },
{ token: 'variable', foreground: '100F0F' },
{ token: 'variable.other', foreground: '100F0F' },
{ token: 'variable.parameter', foreground: '100F0F' },
{ token: 'variable.other.object', foreground: '66800B' },
{ token: 'variable.other.readwrite.alias', foreground: '66800B' },
{ token: 'variable.language', foreground: 'A02F6F' },
{ token: 'variable.language.this', foreground: 'A02F6F' },
{ token: 'variable.language.super', foreground: 'A02F6F' },
{ token: 'variable.other.property', foreground: '205EA6' },
{ token: 'support.variable.property', foreground: '205EA6' },
{ token: 'variable.other.constant', foreground: '100F0F' },
{ token: 'meta.object-literal.key', foreground: 'BC5215' },
{ token: 'support.type.property-name', foreground: 'BC5215' },
{ token: 'entity.name.tag', foreground: '205EA6' },
{ token: 'tag', foreground: '205EA6' },
{ token: 'support.class.component', foreground: 'A02F6F' },
{ token: 'entity.other.attribute-name', foreground: 'AD8301' },
{ token: 'entity.name.namespace', foreground: 'AD8301' },
{ token: 'entity.name.module', foreground: 'AF3029' },
{ token: 'meta.decorator', foreground: 'AD8301' },
{ token: 'entity.name.function.decorator', foreground: 'AD8301' },
{ token: 'entity.name.label', foreground: 'A02F6F' },
{ token: 'meta.preprocessor', foreground: 'A02F6F' },
{ token: 'entity.name.function.preprocessor', foreground: '205EA6' },
{ token: 'punctuation', foreground: '6F6E69' },
{ token: 'delimiter', foreground: '6F6E69' },
{ token: 'delimiter.bracket', foreground: '6F6E69' },
{ token: 'markup.heading', foreground: 'AD8301' },
{ token: 'markup.bold', foreground: 'AD8301', fontStyle: 'bold' },
{ token: 'markup.italic', foreground: '24837B', fontStyle: 'italic' },
{ token: 'markup.underline.link', foreground: '205EA6' },
{ token: 'markup.inline.raw', foreground: '24837B' },
{ token: 'invalid', foreground: 'AF3029' },
{ token: 'invalid.illegal', foreground: 'AF3029' },
{ token: 'string.key.json', foreground: 'BC5215' },
{ token: 'string.value.json', foreground: '24837B' },
{ token: 'support.type.property-name.css', foreground: '100F0F' },
{ token: 'support.constant.property-value.css', foreground: '24837B' },
{ token: 'type', foreground: 'AD8301' },
{ token: 'type.identifier', foreground: 'AD8301' },
{ token: 'identifier', foreground: '100F0F' },
],
colors: {
'editor.background': '#FFFCF0',
'editor.foreground': '#100F0F',
'editor.lineHighlightBackground': '#F2F0E5',
'editor.selectionBackground': '#100F0F44',
'editor.selectionHighlightBackground': '#100F0F44',
'editor.inactiveSelectionBackground': '#E6E4D9',
'editor.findMatchBackground': '#D0A215',
'editor.findMatchHighlightBackground': '#D0A215cc',
'editor.hoverHighlightBackground': '#DAD8CE',
'editor.rangeHighlightBackground': '#CECDC3',
'editorCursor.foreground': '#100F0F',
'editorLineNumber.foreground': '#CECDC3',
'editorLineNumber.activeForeground': '#100F0F',
'editorGutter.background': '#FFFCF0',
'editorGutter.modifiedBackground': '#24837B',
'editorGutter.addedBackground': '#66800B',
'editorGutter.deletedBackground': '#AF3029',
'diffEditor.insertedTextBackground': '#66800B25',
'diffEditor.removedTextBackground': '#AF302925',
'diffEditor.insertedLineBackground': '#66800B15',
'diffEditor.removedLineBackground': '#AF302915',
'diffEditor.insertedTextBorder': '#00000000',
'diffEditor.removedTextBorder': '#00000000',
'editorBracketMatch.background': '#E6E4D9',
'editorBracketMatch.border': '#DAD8CE',
'editorWhitespace.foreground': '#CECDC3',
'editorIndentGuide.background1': '#DAD8CE',
'editorIndentGuide.activeBackground1': '#B7B5AC',
'editorWidget.background': '#F2F0E5',
'editorWidget.border': '#DAD8CE',
'editorSuggestWidget.background': '#FFFCF0',
'editorSuggestWidget.border': '#DAD8CE',
'editorSuggestWidget.foreground': '#100F0F',
'editorSuggestWidget.selectedBackground': '#DAD8CE',
'editorHoverWidget.background': '#E6E4D9',
'editorHoverWidget.border': '#DAD8CE',
'editorInlayHint.foreground': '#6F6E69',
'editorInlayHint.background': '#DAD8CE',
'editorError.foreground': '#AF3029',
'editorWarning.foreground': '#BC5215',
'editorInfo.foreground': '#205EA6',
'input.background': '#F2F0E5',
'input.foreground': '#100F0F',
'input.border': '#DAD8CE',
'input.placeholderForeground': '#6F6E69',
'dropdown.background': '#F2F0E5',
'dropdown.foreground': '#100F0F',
'dropdown.border': '#DAD8CE',
'dropdown.listBackground': '#FFFCF0',
'focusBorder': '#DAD8CE',
'scrollbarSlider.background': '#DAD8CE80',
'scrollbarSlider.hoverBackground': '#CECDC3',
'scrollbarSlider.activeBackground': '#B7B5AC',
},
};
export const getMonacoThemeIdForTheme = (theme: Theme): string => {
return theme.metadata.variant === 'dark' ? MONACO_DARK_THEME_ID : MONACO_LIGHT_THEME_ID;
};
export const ensureMonacoThemeRegistered = (theme: Theme, monacoOverride?: Monaco): void => {
const monaco = monacoOverride ?? getMonacoFromGlobal();
if (!monaco) return;
const themeId = getMonacoThemeIdForTheme(theme);
if (lastRegisteredThemeId === themeId) return;
const themeData = theme.metadata.variant === 'dark' ? flexokiDarkTheme : flexokiLightTheme;
monaco.editor.defineTheme(themeId, themeData);
lastRegisteredThemeId = themeId;
};
+8
View File
@@ -34,6 +34,7 @@ interface UIStore {
diffLayoutPreference: 'dynamic' | 'inline' | 'side-by-side';
diffFileLayout: Record<string, 'inline' | 'side-by-side'>;
diffWrapLines: boolean;
setTheme: (theme: 'light' | 'dark' | 'system') => void;
toggleSidebar: () => void;
@@ -58,6 +59,7 @@ interface UIStore {
updateProportionalSidebarWidths: () => void;
setDiffLayoutPreference: (mode: 'dynamic' | 'inline' | 'side-by-side') => void;
setDiffFileLayout: (filePath: string, mode: 'inline' | 'side-by-side') => void;
setDiffWrapLines: (wrap: boolean) => void;
}
export const useUIStore = create<UIStore>()(
@@ -83,6 +85,7 @@ export const useUIStore = create<UIStore>()(
showReasoningTraces: false,
diffLayoutPreference: 'dynamic',
diffFileLayout: {},
diffWrapLines: false,
setTheme: (theme) => {
set({ theme });
@@ -204,6 +207,10 @@ export const useUIStore = create<UIStore>()(
}));
},
setDiffWrapLines: (wrap) => {
set({ diffWrapLines: wrap });
},
updateProportionalSidebarWidths: () => {
if (typeof window === 'undefined') {
return;
@@ -248,6 +255,7 @@ export const useUIStore = create<UIStore>()(
isSettingsDialogOpen: state.isSettingsDialogOpen,
showReasoningTraces: state.showReasoningTraces,
diffLayoutPreference: state.diffLayoutPreference,
diffWrapLines: state.diffWrapLines,
})
}
),
+40 -44
View File
@@ -259,12 +259,12 @@ importers:
'@ibm/plex':
specifier: ^6.4.1
version: 6.4.1
'@monaco-editor/react':
specifier: ^4.6.0
version: 4.7.0(monaco-editor@0.55.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@opencode-ai/sdk':
specifier: ^1.0.65
version: 1.0.65
'@pierre/diffs':
specifier: ^1.0.0
version: 1.0.0(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-collapsible':
specifier: ^1.1.12
version: 1.1.12(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
@@ -1348,16 +1348,6 @@ packages:
'@mermaid-js/parser@0.6.3':
resolution: {integrity: sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA==}
'@monaco-editor/loader@1.7.0':
resolution: {integrity: sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==}
'@monaco-editor/react@4.7.0':
resolution: {integrity: sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==}
peerDependencies:
monaco-editor: '>= 0.25.0 < 1'
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'}
@@ -1379,6 +1369,12 @@ packages:
'@opencode-ai/sdk@1.0.65':
resolution: {integrity: sha512-35aOmXhRHNPlx0ThhYwudfZhP1Sg8y94Wsqm+XsgGfHATllQvQjNVOVPfSp3TW3xCAoJ0/PXJlcYjPS3kZDeNA==}
'@pierre/diffs@1.0.0':
resolution: {integrity: sha512-aF/hbdGI46HMTOMNYhEyXpiyb1J0Ldf2o2dBa1fYxI688t7SfCfzvBIlJm74O6+GuMg20EWhhQM+Nky7LuwLIg==}
peerDependencies:
react: ^18.3.1 || ^19.0.0
react-dom: ^18.3.1 || ^19.0.0
'@pkgjs/parseargs@0.11.0':
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
@@ -2008,6 +2004,9 @@ packages:
'@shikijs/themes@3.19.0':
resolution: {integrity: sha512-H36qw+oh91Y0s6OlFfdSuQ0Ld+5CgB/VE6gNPK+Hk4VRbVG/XQgkjnt4KzfnnoO6tZPtKJKHPjwebOCfjd6F8A==}
'@shikijs/transformers@3.19.0':
resolution: {integrity: sha512-e6vwrsyw+wx4OkcrDbL+FVCxwx8jgKiCoXzakVur++mIWVcgpzIi8vxf4/b4dVTYrV/nUx5RjinMf4tq8YV8Fw==}
'@shikijs/types@3.19.0':
resolution: {integrity: sha512-Z2hdeEQlzuntf/BZpFG8a+Fsw9UVXdML7w0o3TgSXV3yNESGon+bs9ITkQb3Ki7zxoXOOu5oJWqZ2uto06V9iQ==}
@@ -3288,6 +3287,10 @@ packages:
devlop@1.1.0:
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
diff@8.0.2:
resolution: {integrity: sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==}
engines: {node: '>=0.3.1'}
dir-compare@3.3.0:
resolution: {integrity: sha512-J7/et3WlGUCxjdnD3HAAzQ6nsnc0WL6DD7WcwJb7c39iH1+AWfg+9OqzJNaI6PkBwBvm1mhZNL9iY/nRiZXlPg==}
@@ -4355,6 +4358,9 @@ packages:
resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==}
engines: {node: '>=10'}
lru_map@0.4.1:
resolution: {integrity: sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg==}
lucide-react@0.542.0:
resolution: {integrity: sha512-w3hD8/SQB7+lzU2r4VdFyzzOzKnUjTZIF/MQJGSSvni7Llewni4vuViRppfRAa2guOsY5k4jZyxw/i9DQHv+dw==}
peerDependencies:
@@ -4370,11 +4376,6 @@ packages:
markdown-table@3.0.4:
resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
marked@14.0.0:
resolution: {integrity: sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==}
engines: {node: '>= 18'}
hasBin: true
marked@16.4.2:
resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==}
engines: {node: '>= 20'}
@@ -4666,9 +4667,6 @@ packages:
mlly@1.8.0:
resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==}
monaco-editor@0.55.1:
resolution: {integrity: sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==}
motion-dom@12.23.23:
resolution: {integrity: sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==}
@@ -5389,9 +5387,6 @@ packages:
resolution: {integrity: sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==}
engines: {node: '>= 6'}
state-local@1.0.7:
resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==}
statuses@2.0.1:
resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}
engines: {node: '>= 0.8'}
@@ -6658,17 +6653,6 @@ snapshots:
dependencies:
langium: 3.3.1
'@monaco-editor/loader@1.7.0':
dependencies:
state-local: 1.0.7
'@monaco-editor/react@4.7.0(monaco-editor@0.55.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
dependencies:
'@monaco-editor/loader': 1.7.0
monaco-editor: 0.55.1
react: 19.1.1
react-dom: 19.1.1(react@19.1.1)
'@nodelib/fs.scandir@2.1.5':
dependencies:
'@nodelib/fs.stat': 2.0.5
@@ -6687,6 +6671,18 @@ snapshots:
'@opencode-ai/sdk@1.0.65': {}
'@pierre/diffs@1.0.0(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
dependencies:
'@shikijs/core': 3.19.0
'@shikijs/engine-javascript': 3.19.0
'@shikijs/transformers': 3.19.0
diff: 8.0.2
hast-util-to-html: 9.0.5
lru_map: 0.4.1
react: 19.1.1
react-dom: 19.1.1(react@19.1.1)
shiki: 3.19.0
'@pkgjs/parseargs@0.11.0':
optional: true
@@ -7344,6 +7340,11 @@ snapshots:
dependencies:
'@shikijs/types': 3.19.0
'@shikijs/transformers@3.19.0':
dependencies:
'@shikijs/core': 3.19.0
'@shikijs/types': 3.19.0
'@shikijs/types@3.19.0':
dependencies:
'@shikijs/vscode-textmate': 10.0.2
@@ -8803,6 +8804,8 @@ snapshots:
dependencies:
dequal: 2.0.3
diff@8.0.2: {}
dir-compare@3.3.0:
dependencies:
buffer-equal: 1.0.1
@@ -10074,6 +10077,8 @@ snapshots:
dependencies:
yallist: 4.0.0
lru_map@0.4.1: {}
lucide-react@0.542.0(react@19.1.1):
dependencies:
react: 19.1.1
@@ -10093,8 +10098,6 @@ snapshots:
markdown-table@3.0.4: {}
marked@14.0.0: {}
marked@16.4.2: {}
matcher@3.0.0:
@@ -10609,11 +10612,6 @@ snapshots:
pkg-types: 1.3.1
ufo: 1.6.1
monaco-editor@0.55.1:
dependencies:
dompurify: 3.2.7
marked: 14.0.0
motion-dom@12.23.23:
dependencies:
motion-utils: 12.23.6
@@ -11468,8 +11466,6 @@ snapshots:
stat-mode@1.0.0: {}
state-local@1.0.7: {}
statuses@2.0.1: {}
statuses@2.0.2: {}