diff --git a/README.md b/README.md index eb38fa83..d98388b3 100644 --- a/README.md +++ b/README.md @@ -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)

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 diff --git a/docs/references/diff_example.png b/docs/references/diff_example.png new file mode 100644 index 00000000..d09bc446 Binary files /dev/null and b/docs/references/diff_example.png differ diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock index 7890db71..303a4b4e 100644 --- a/packages/desktop/src-tauri/Cargo.lock +++ b/packages/desktop/src-tauri/Cargo.lock @@ -2847,7 +2847,7 @@ dependencies = [ [[package]] name = "openchamber-desktop" -version = "1.1.0" +version = "1.1.2" dependencies = [ "anyhow", "axum", diff --git a/packages/ui/package.json b/packages/ui/package.json index 44aa69fd..2d03e5e4 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -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", diff --git a/packages/ui/src/components/layout/MainLayout.tsx b/packages/ui/src/components/layout/MainLayout.tsx index 474f2cbf..3233d964 100644 --- a/packages/ui/src/components/layout/MainLayout.tsx +++ b/packages/ui/src/components/layout/MainLayout.tsx @@ -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 ( -

- - - - setSettingsDialogOpen(false)} /> + +
+ + + + setSettingsDialogOpen(false)} /> - {isMobile ? ( + {isMobile ? ( <>
@@ -157,5 +158,6 @@ export const MainLayout: React.FC = () => {
+ ); }; diff --git a/packages/ui/src/components/views/DiffView.tsx b/packages/ui/src/components/views/DiffView.tsx index c792885b..54760b07 100644 --- a/packages/ui/src/components/views/DiffView.tsx +++ b/packages/ui/src/components/views/DiffView.tsx @@ -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 ( - {added ? ( - +{added} - ) : null} - {removed ? ( - -{removed} - ) : null} + {added ? +{added} : null} + {removed ? -{removed} : null} ); }; @@ -74,9 +70,7 @@ const FileSelector = React.memo(({ -
- ); - } - - if (!fileDiff || (!fileDiff.original && !fileDiff.modified)) { - return ( -
- No changes detected for this file +
+
); } return ( -
- - - Loading diff viewer… -
- )} - > - - +
+
); }); @@ -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(null); - const selectedFileRef = React.useRef(null); - - const [isDiffLoading, setIsDiffLoading] = React.useState(false); - const [diffError, setDiffError] = React.useState(null); - const [fileDiff, setFileDiff] = React.useState<{ original: string; modified: string } | null>(null); + const [allDiffs, setAllDiffs] = React.useState>(new Map()); + const [loadingFiles, setLoadingFiles] = React.useState>(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]) => ( + + )); + }; const renderContent = () => { if (!effectiveDirectory) { @@ -397,31 +440,18 @@ export const DiffView: React.FC = () => { ); } - if (!selectedFileEntry) { - return ( -
- Select a file to inspect its diff -
- ); - } - - 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 ( -
- +
+ {renderAllDiffViewers()} + {isCurrentFileLoading && !hasCurrentDiff && ( +
+ + Loading diff… +
+ )}
); }; @@ -444,6 +474,20 @@ export const DiffView: React.FC = () => { onSelectFile={handleSelectFile} />
+ {selectedFileEntry && ( + + )} {selectedFileEntry && currentLayoutForSelectedFile && ( = ({ - original, - modified, - language, - renderSideBySide, - allowResponsive = false, - readOnly = true, - showLineNumbers = true, -}) => { - const { currentTheme } = useThemeSystem(); - const diffEditorRef = useRef(null); - - const themeId = useMemo( - () => getMonacoThemeIdForTheme(currentTheme), - [currentTheme], - ); - - const handleBeforeMount = useCallback>( - (monacoInstance) => { - ensureMonacoThemeRegistered( - currentTheme, - monacoInstance as Parameters[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 ( - { - 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" - /> - ); -}; diff --git a/packages/ui/src/components/views/PierreDiffViewer.tsx b/packages/ui/src/components/views/PierreDiffViewer.tsx new file mode 100644 index 00000000..06e4a8a0 --- /dev/null +++ b/packages/ui/src/components/views/PierreDiffViewer.tsx @@ -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 = ({ + 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 ( + + + + ); +}; diff --git a/packages/ui/src/contexts/DiffWorkerProvider.tsx b/packages/ui/src/contexts/DiffWorkerProvider.tsx new file mode 100644 index 00000000..c6f4a3f3 --- /dev/null +++ b/packages/ui/src/contexts/DiffWorkerProvider.tsx @@ -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 = ({ 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 ( + + {children} + + ); +}; + +export { useWorkerPool }; diff --git a/packages/ui/src/index.css b/packages/ui/src/index.css index d88439fa..329ba9c8 100644 --- a/packages/ui/src/index.css +++ b/packages/ui/src/index.css @@ -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 */ diff --git a/packages/ui/src/lib/diff/workerFactory.ts b/packages/ui/src/lib/diff/workerFactory.ts new file mode 100644 index 00000000..7387d0bf --- /dev/null +++ b/packages/ui/src/lib/diff/workerFactory.ts @@ -0,0 +1,5 @@ +import WorkerUrl from '@pierre/diffs/worker/worker.js?worker&url'; + +export function workerFactory(): Worker { + return new Worker(WorkerUrl, { type: 'module' }); +} diff --git a/packages/ui/src/lib/theme/monacoThemeGenerator.ts b/packages/ui/src/lib/theme/monacoThemeGenerator.ts deleted file mode 100644 index 41aa06d2..00000000 --- a/packages/ui/src/lib/theme/monacoThemeGenerator.ts +++ /dev/null @@ -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; -} - -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; -}; diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index b985c3d7..524145d6 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -34,6 +34,7 @@ interface UIStore { diffLayoutPreference: 'dynamic' | 'inline' | 'side-by-side'; diffFileLayout: Record; + 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()( @@ -83,6 +85,7 @@ export const useUIStore = create()( showReasoningTraces: false, diffLayoutPreference: 'dynamic', diffFileLayout: {}, + diffWrapLines: false, setTheme: (theme) => { set({ theme }); @@ -204,6 +207,10 @@ export const useUIStore = create()( })); }, + setDiffWrapLines: (wrap) => { + set({ diffWrapLines: wrap }); + }, + updateProportionalSidebarWidths: () => { if (typeof window === 'undefined') { return; @@ -248,6 +255,7 @@ export const useUIStore = create()( isSettingsDialogOpen: state.isSettingsDialogOpen, showReasoningTraces: state.showReasoningTraces, diffLayoutPreference: state.diffLayoutPreference, + diffWrapLines: state.diffWrapLines, }) } ), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ec76d41a..faf3afce 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {}