mirror of
https://github.com/stackblitz-labs/bolt.diy
synced 2025-05-09 14:30:51 +00:00
Merge pull request #1367 from Toddyclipsgg/diff-view-v2
feat: diff view v3
This commit is contained in:
commit
5d1816be9d
@ -1,2 +1,2 @@
|
||||
nodejs 20.15.1
|
||||
pnpm 9.4.0
|
||||
pnpm 9.4.0
|
@ -4,12 +4,10 @@
|
||||
|
||||
Welcome to bolt.diy, the official open source version of Bolt.new (previously known as oTToDev and bolt.new ANY LLM), which allows you to choose the LLM that you use for each prompt! Currently, you can use OpenAI, Anthropic, Ollama, OpenRouter, Gemini, LMStudio, Mistral, xAI, HuggingFace, DeepSeek, or Groq models - and it is easily extended to use any other model supported by the Vercel AI SDK! See the instructions below for running this locally and extending it to include more models.
|
||||
|
||||
---
|
||||
|
||||
-----
|
||||
Check the [bolt.diy Docs](https://stackblitz-labs.github.io/bolt.diy/) for more offical installation instructions and more informations.
|
||||
|
||||
---
|
||||
|
||||
-----
|
||||
Also [this pinned post in our community](https://thinktank.ottomator.ai/t/videos-tutorial-helpful-content/3243) has a bunch of incredible resources for running and deploying bolt.diy yourself!
|
||||
|
||||
We have also launched an experimental agent called the "bolt.diy Expert" that can answer common questions about bolt.diy. Find it here on the [oTTomator Live Agent Studio](https://studio.ottomator.ai/).
|
||||
@ -81,6 +79,7 @@ project, please check the [project management guide](./PROJECT.md) to get starte
|
||||
- ✅ Add Starter Template Options (@thecodacus)
|
||||
- ✅ Perplexity Integration (@meetpateltech)
|
||||
- ✅ AWS Bedrock Integration (@kunjabijukchhe)
|
||||
- ✅ Add a "Diff View" to see the changes (@toddyclipsgg)
|
||||
- ⬜ **HIGH PRIORITY** - Prevent bolt from rewriting files as often (file locking and diffs)
|
||||
- ⬜ **HIGH PRIORITY** - Better prompting for smaller LLMs (code window sometimes doesn't start)
|
||||
- ⬜ **HIGH PRIORITY** - Run agents in the backend as opposed to a single model call
|
||||
|
@ -34,6 +34,7 @@ import ChatAlert from './ChatAlert';
|
||||
import type { ModelInfo } from '~/lib/modules/llm/types';
|
||||
import ProgressCompilation from './ProgressCompilation';
|
||||
import type { ProgressAnnotation } from '~/types/context';
|
||||
import type { ActionRunner } from '~/lib/runtime/action-runner';
|
||||
import { LOCAL_PROVIDERS } from '~/lib/stores/settings';
|
||||
|
||||
const TEXTAREA_MIN_HEIGHT = 76;
|
||||
@ -69,6 +70,7 @@ interface BaseChatProps {
|
||||
actionAlert?: ActionAlert;
|
||||
clearAlert?: () => void;
|
||||
data?: JSONValue[] | undefined;
|
||||
actionRunner?: ActionRunner;
|
||||
}
|
||||
|
||||
export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
@ -104,6 +106,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
actionAlert,
|
||||
clearAlert,
|
||||
data,
|
||||
actionRunner,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
@ -310,7 +313,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
data-chat-visible={showChat}
|
||||
>
|
||||
<ClientOnly>{() => <Menu />}</ClientOnly>
|
||||
<div className="flex flex-col lg:flex-row overflow-y-auto w-full h-full">
|
||||
<div ref={scrollRef} className="flex flex-col lg:flex-row overflow-y-auto w-full h-full">
|
||||
<div className={classNames(styles.Chat, 'flex flex-col flex-grow lg:min-w-[var(--chat-min-width)] h-full')}>
|
||||
{!chatStarted && (
|
||||
<div id="intro" className="mt-[16vh] max-w-chat mx-auto text-center px-4 lg:px-0">
|
||||
@ -324,40 +327,39 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
)}
|
||||
<div
|
||||
className={classNames('pt-6 px-2 sm:px-6', {
|
||||
'h-full flex flex-col pb-4 overflow-y-auto': chatStarted,
|
||||
'h-full flex flex-col': chatStarted,
|
||||
})}
|
||||
ref={scrollRef}
|
||||
>
|
||||
<ClientOnly>
|
||||
{() => {
|
||||
return chatStarted ? (
|
||||
<div className="flex-1 w-full max-w-chat pb-6 mx-auto z-1">
|
||||
<Messages
|
||||
ref={messageRef}
|
||||
className="flex flex-col "
|
||||
messages={messages}
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
</div>
|
||||
<Messages
|
||||
ref={messageRef}
|
||||
className="flex flex-col w-full flex-1 max-w-chat pb-6 mx-auto z-1"
|
||||
messages={messages}
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
) : null;
|
||||
}}
|
||||
</ClientOnly>
|
||||
<div
|
||||
className={classNames('flex flex-col w-full max-w-chat mx-auto z-prompt', {
|
||||
className={classNames('flex flex-col gap-4 w-full max-w-chat mx-auto z-prompt mb-6', {
|
||||
'sticky bottom-2': chatStarted,
|
||||
'position-absolute': chatStarted,
|
||||
})}
|
||||
>
|
||||
{actionAlert && (
|
||||
<ChatAlert
|
||||
alert={actionAlert}
|
||||
clearAlert={() => clearAlert?.()}
|
||||
postMessage={(message) => {
|
||||
sendMessage?.({} as any, message);
|
||||
clearAlert?.();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="bg-bolt-elements-background-depth-2">
|
||||
{actionAlert && (
|
||||
<ChatAlert
|
||||
alert={actionAlert}
|
||||
clearAlert={() => clearAlert?.()}
|
||||
postMessage={(message) => {
|
||||
sendMessage?.({} as any, message);
|
||||
clearAlert?.();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{progressAnnotations && <ProgressCompilation data={progressAnnotations} />}
|
||||
<div
|
||||
className={classNames(
|
||||
@ -591,16 +593,15 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!chatStarted && (
|
||||
<div className="flex flex-col justify-center mt-6 gap-5">
|
||||
<div className="flex flex-col justify-center gap-5">
|
||||
{!chatStarted && (
|
||||
<div className="flex justify-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{ImportButtons(importChat)}
|
||||
<GitCloneButton importChat={importChat} className="min-w-[120px]" />
|
||||
</div>
|
||||
{ImportButtons(importChat)}
|
||||
<GitCloneButton importChat={importChat} />
|
||||
</div>
|
||||
|
||||
{ExamplePrompts((event, messageInput) => {
|
||||
)}
|
||||
{!chatStarted &&
|
||||
ExamplePrompts((event, messageInput) => {
|
||||
if (isStreaming) {
|
||||
handleStop?.();
|
||||
return;
|
||||
@ -608,11 +609,18 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
|
||||
handleSendMessage?.(event, messageInput);
|
||||
})}
|
||||
<StarterTemplates />
|
||||
</div>
|
||||
)}
|
||||
{!chatStarted && <StarterTemplates />}
|
||||
</div>
|
||||
</div>
|
||||
<ClientOnly>{() => <Workbench chatStarted={chatStarted} isStreaming={isStreaming} />}</ClientOnly>
|
||||
<ClientOnly>
|
||||
{() => (
|
||||
<Workbench
|
||||
actionRunner={actionRunner ?? ({} as ActionRunner)}
|
||||
chatStarted={chatStarted}
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
)}
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -9,10 +9,11 @@ interface SliderOption<T> {
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface SliderOptions<T> {
|
||||
left: SliderOption<T>;
|
||||
right: SliderOption<T>;
|
||||
}
|
||||
export type SliderOptions<T> = {
|
||||
left: { value: T; text: string };
|
||||
middle?: { value: T; text: string };
|
||||
right: { value: T; text: string };
|
||||
};
|
||||
|
||||
interface SliderProps<T> {
|
||||
selected: T;
|
||||
@ -21,14 +22,23 @@ interface SliderProps<T> {
|
||||
}
|
||||
|
||||
export const Slider = genericMemo(<T,>({ selected, options, setSelected }: SliderProps<T>) => {
|
||||
const isLeftSelected = selected === options.left.value;
|
||||
const hasMiddle = !!options.middle;
|
||||
const isLeftSelected = hasMiddle ? selected === options.left.value : selected === options.left.value;
|
||||
const isMiddleSelected = hasMiddle && options.middle ? selected === options.middle.value : false;
|
||||
|
||||
return (
|
||||
<div className="flex items-center flex-wrap shrink-0 gap-1 bg-bolt-elements-background-depth-1 overflow-hidden rounded-full p-1">
|
||||
<SliderButton selected={isLeftSelected} setSelected={() => setSelected?.(options.left.value)}>
|
||||
{options.left.text}
|
||||
</SliderButton>
|
||||
<SliderButton selected={!isLeftSelected} setSelected={() => setSelected?.(options.right.value)}>
|
||||
|
||||
{options.middle && (
|
||||
<SliderButton selected={isMiddleSelected} setSelected={() => setSelected?.(options.middle!.value)}>
|
||||
{options.middle.text}
|
||||
</SliderButton>
|
||||
)}
|
||||
|
||||
<SliderButton selected={!isLeftSelected && !isMiddleSelected} setSelected={() => setSelected?.(options.right.value)}>
|
||||
{options.right.text}
|
||||
</SliderButton>
|
||||
</div>
|
||||
|
713
app/components/workbench/DiffView.tsx
Normal file
713
app/components/workbench/DiffView.tsx
Normal file
@ -0,0 +1,713 @@
|
||||
import { memo, useMemo, useState, useEffect, useCallback } from 'react';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { workbenchStore } from '~/lib/stores/workbench';
|
||||
import type { FileMap } from '~/lib/stores/files';
|
||||
import type { EditorDocument } from '~/components/editor/codemirror/CodeMirrorEditor';
|
||||
import { diffLines, type Change } from 'diff';
|
||||
import { getHighlighter } from 'shiki';
|
||||
import '~/styles/diff-view.css';
|
||||
import { diffFiles, extractRelativePath } from '~/utils/diff';
|
||||
import { ActionRunner } from '~/lib/runtime/action-runner';
|
||||
import type { FileHistory } from '~/types/actions';
|
||||
import { getLanguageFromExtension } from '~/utils/getLanguageFromExtension';
|
||||
import { themeStore } from '~/lib/stores/theme';
|
||||
|
||||
interface CodeComparisonProps {
|
||||
beforeCode: string;
|
||||
afterCode: string;
|
||||
language: string;
|
||||
filename: string;
|
||||
lightTheme: string;
|
||||
darkTheme: string;
|
||||
}
|
||||
|
||||
interface DiffBlock {
|
||||
lineNumber: number;
|
||||
content: string;
|
||||
type: 'added' | 'removed' | 'unchanged';
|
||||
correspondingLine?: number;
|
||||
charChanges?: Array<{
|
||||
value: string;
|
||||
type: 'added' | 'removed' | 'unchanged';
|
||||
}>;
|
||||
}
|
||||
|
||||
interface FullscreenButtonProps {
|
||||
onClick: () => void;
|
||||
isFullscreen: boolean;
|
||||
}
|
||||
|
||||
const FullscreenButton = memo(({ onClick, isFullscreen }: FullscreenButtonProps) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="ml-4 p-1 rounded hover:bg-bolt-elements-background-depth-3 text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary transition-colors"
|
||||
title={isFullscreen ? "Exit Fullscreen" : "Enter Fullscreen"}
|
||||
>
|
||||
<div className={isFullscreen ? "i-ph:corners-in" : "i-ph:corners-out"} />
|
||||
</button>
|
||||
));
|
||||
|
||||
const FullscreenOverlay = memo(({ isFullscreen, children }: { isFullscreen: boolean; children: React.ReactNode }) => {
|
||||
if (!isFullscreen) return <>{children}</>;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[9999] bg-black/50 flex items-center justify-center p-6">
|
||||
<div className="w-full h-full max-w-[90vw] max-h-[90vh] bg-bolt-elements-background-depth-2 rounded-lg border border-bolt-elements-borderColor shadow-xl overflow-hidden">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const MAX_FILE_SIZE = 1024 * 1024; // 1MB
|
||||
const BINARY_REGEX = /[\x00-\x08\x0E-\x1F]/;
|
||||
|
||||
const isBinaryFile = (content: string) => {
|
||||
return content.length > MAX_FILE_SIZE || BINARY_REGEX.test(content);
|
||||
};
|
||||
|
||||
const processChanges = (beforeCode: string, afterCode: string) => {
|
||||
try {
|
||||
if (isBinaryFile(beforeCode) || isBinaryFile(afterCode)) {
|
||||
return {
|
||||
beforeLines: [],
|
||||
afterLines: [],
|
||||
hasChanges: false,
|
||||
lineChanges: { before: new Set(), after: new Set() },
|
||||
unifiedBlocks: [],
|
||||
isBinary: true
|
||||
};
|
||||
}
|
||||
|
||||
// Normalize line endings and content
|
||||
const normalizeContent = (content: string): string[] => {
|
||||
return content
|
||||
.replace(/\r\n/g, '\n')
|
||||
.split('\n')
|
||||
.map(line => line.trimEnd());
|
||||
};
|
||||
|
||||
const beforeLines = normalizeContent(beforeCode);
|
||||
const afterLines = normalizeContent(afterCode);
|
||||
|
||||
// Early return if files are identical
|
||||
if (beforeLines.join('\n') === afterLines.join('\n')) {
|
||||
return {
|
||||
beforeLines,
|
||||
afterLines,
|
||||
hasChanges: false,
|
||||
lineChanges: { before: new Set(), after: new Set() },
|
||||
unifiedBlocks: [],
|
||||
isBinary: false
|
||||
};
|
||||
}
|
||||
|
||||
const lineChanges = {
|
||||
before: new Set<number>(),
|
||||
after: new Set<number>()
|
||||
};
|
||||
|
||||
const unifiedBlocks: DiffBlock[] = [];
|
||||
|
||||
// Compare lines directly for more accurate diff
|
||||
let i = 0, j = 0;
|
||||
while (i < beforeLines.length || j < afterLines.length) {
|
||||
if (i < beforeLines.length && j < afterLines.length && beforeLines[i] === afterLines[j]) {
|
||||
// Unchanged line
|
||||
unifiedBlocks.push({
|
||||
lineNumber: j,
|
||||
content: afterLines[j],
|
||||
type: 'unchanged',
|
||||
correspondingLine: i
|
||||
});
|
||||
i++;
|
||||
j++;
|
||||
} else {
|
||||
// Look ahead for potential matches
|
||||
let matchFound = false;
|
||||
const lookAhead = 3; // Number of lines to look ahead
|
||||
|
||||
// Try to find matching lines ahead
|
||||
for (let k = 1; k <= lookAhead && i + k < beforeLines.length && j + k < afterLines.length; k++) {
|
||||
if (beforeLines[i + k] === afterLines[j]) {
|
||||
// Found match in after lines - mark lines as removed
|
||||
for (let l = 0; l < k; l++) {
|
||||
lineChanges.before.add(i + l);
|
||||
unifiedBlocks.push({
|
||||
lineNumber: i + l,
|
||||
content: beforeLines[i + l],
|
||||
type: 'removed',
|
||||
correspondingLine: j,
|
||||
charChanges: [{ value: beforeLines[i + l], type: 'removed' }]
|
||||
});
|
||||
}
|
||||
i += k;
|
||||
matchFound = true;
|
||||
break;
|
||||
} else if (beforeLines[i] === afterLines[j + k]) {
|
||||
// Found match in before lines - mark lines as added
|
||||
for (let l = 0; l < k; l++) {
|
||||
lineChanges.after.add(j + l);
|
||||
unifiedBlocks.push({
|
||||
lineNumber: j + l,
|
||||
content: afterLines[j + l],
|
||||
type: 'added',
|
||||
correspondingLine: i,
|
||||
charChanges: [{ value: afterLines[j + l], type: 'added' }]
|
||||
});
|
||||
}
|
||||
j += k;
|
||||
matchFound = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!matchFound) {
|
||||
// No match found - try to find character-level changes
|
||||
if (i < beforeLines.length && j < afterLines.length) {
|
||||
const beforeLine = beforeLines[i];
|
||||
const afterLine = afterLines[j];
|
||||
|
||||
// Find common prefix and suffix
|
||||
let prefixLength = 0;
|
||||
while (prefixLength < beforeLine.length &&
|
||||
prefixLength < afterLine.length &&
|
||||
beforeLine[prefixLength] === afterLine[prefixLength]) {
|
||||
prefixLength++;
|
||||
}
|
||||
|
||||
let suffixLength = 0;
|
||||
while (suffixLength < beforeLine.length - prefixLength &&
|
||||
suffixLength < afterLine.length - prefixLength &&
|
||||
beforeLine[beforeLine.length - 1 - suffixLength] ===
|
||||
afterLine[afterLine.length - 1 - suffixLength]) {
|
||||
suffixLength++;
|
||||
}
|
||||
|
||||
const prefix = beforeLine.slice(0, prefixLength);
|
||||
const beforeMiddle = beforeLine.slice(prefixLength, beforeLine.length - suffixLength);
|
||||
const afterMiddle = afterLine.slice(prefixLength, afterLine.length - suffixLength);
|
||||
const suffix = beforeLine.slice(beforeLine.length - suffixLength);
|
||||
|
||||
if (beforeMiddle || afterMiddle) {
|
||||
// There are character-level changes
|
||||
if (beforeMiddle) {
|
||||
lineChanges.before.add(i);
|
||||
unifiedBlocks.push({
|
||||
lineNumber: i,
|
||||
content: beforeLine,
|
||||
type: 'removed',
|
||||
correspondingLine: j,
|
||||
charChanges: [
|
||||
{ value: prefix, type: 'unchanged' },
|
||||
{ value: beforeMiddle, type: 'removed' },
|
||||
{ value: suffix, type: 'unchanged' }
|
||||
]
|
||||
});
|
||||
i++;
|
||||
}
|
||||
if (afterMiddle) {
|
||||
lineChanges.after.add(j);
|
||||
unifiedBlocks.push({
|
||||
lineNumber: j,
|
||||
content: afterLine,
|
||||
type: 'added',
|
||||
correspondingLine: i - 1,
|
||||
charChanges: [
|
||||
{ value: prefix, type: 'unchanged' },
|
||||
{ value: afterMiddle, type: 'added' },
|
||||
{ value: suffix, type: 'unchanged' }
|
||||
]
|
||||
});
|
||||
j++;
|
||||
}
|
||||
} else {
|
||||
// No character-level changes found, treat as regular line changes
|
||||
if (i < beforeLines.length) {
|
||||
lineChanges.before.add(i);
|
||||
unifiedBlocks.push({
|
||||
lineNumber: i,
|
||||
content: beforeLines[i],
|
||||
type: 'removed',
|
||||
correspondingLine: j,
|
||||
charChanges: [{ value: beforeLines[i], type: 'removed' }]
|
||||
});
|
||||
i++;
|
||||
}
|
||||
if (j < afterLines.length) {
|
||||
lineChanges.after.add(j);
|
||||
unifiedBlocks.push({
|
||||
lineNumber: j,
|
||||
content: afterLines[j],
|
||||
type: 'added',
|
||||
correspondingLine: i - 1,
|
||||
charChanges: [{ value: afterLines[j], type: 'added' }]
|
||||
});
|
||||
j++;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Handle remaining lines
|
||||
if (i < beforeLines.length) {
|
||||
lineChanges.before.add(i);
|
||||
unifiedBlocks.push({
|
||||
lineNumber: i,
|
||||
content: beforeLines[i],
|
||||
type: 'removed',
|
||||
correspondingLine: j,
|
||||
charChanges: [{ value: beforeLines[i], type: 'removed' }]
|
||||
});
|
||||
i++;
|
||||
}
|
||||
if (j < afterLines.length) {
|
||||
lineChanges.after.add(j);
|
||||
unifiedBlocks.push({
|
||||
lineNumber: j,
|
||||
content: afterLines[j],
|
||||
type: 'added',
|
||||
correspondingLine: i - 1,
|
||||
charChanges: [{ value: afterLines[j], type: 'added' }]
|
||||
});
|
||||
j++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort blocks by line number
|
||||
const processedBlocks = unifiedBlocks.sort((a, b) => a.lineNumber - b.lineNumber);
|
||||
|
||||
return {
|
||||
beforeLines,
|
||||
afterLines,
|
||||
hasChanges: lineChanges.before.size > 0 || lineChanges.after.size > 0,
|
||||
lineChanges,
|
||||
unifiedBlocks: processedBlocks,
|
||||
isBinary: false
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error processing changes:', error);
|
||||
return {
|
||||
beforeLines: [],
|
||||
afterLines: [],
|
||||
hasChanges: false,
|
||||
lineChanges: { before: new Set(), after: new Set() },
|
||||
unifiedBlocks: [],
|
||||
error: true,
|
||||
isBinary: false
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const lineNumberStyles = "w-9 shrink-0 pl-2 py-1 text-left font-mono text-bolt-elements-textTertiary border-r border-bolt-elements-borderColor bg-bolt-elements-background-depth-1";
|
||||
const lineContentStyles = "px-1 py-1 font-mono whitespace-pre flex-1 group-hover:bg-bolt-elements-background-depth-2 text-bolt-elements-textPrimary";
|
||||
const diffPanelStyles = "h-full overflow-auto diff-panel-content";
|
||||
|
||||
// Updated color styles for better consistency
|
||||
const diffLineStyles = {
|
||||
added: 'bg-green-500/10 dark:bg-green-500/20 border-l-4 border-green-500',
|
||||
removed: 'bg-red-500/10 dark:bg-red-500/20 border-l-4 border-red-500',
|
||||
unchanged: ''
|
||||
};
|
||||
|
||||
const changeColorStyles = {
|
||||
added: 'text-green-700 dark:text-green-500 bg-green-500/10 dark:bg-green-500/20',
|
||||
removed: 'text-red-700 dark:text-red-500 bg-red-500/10 dark:bg-red-500/20',
|
||||
unchanged: 'text-bolt-elements-textPrimary'
|
||||
};
|
||||
|
||||
const renderContentWarning = (type: 'binary' | 'error') => (
|
||||
<div className="h-full flex items-center justify-center p-4">
|
||||
<div className="text-center text-bolt-elements-textTertiary">
|
||||
<div className={`i-ph:${type === 'binary' ? 'file-x' : 'warning-circle'} text-4xl text-red-400 mb-2 mx-auto`} />
|
||||
<p className="font-medium text-bolt-elements-textPrimary">
|
||||
{type === 'binary' ? 'Binary file detected' : 'Error processing file'}
|
||||
</p>
|
||||
<p className="text-sm mt-1">
|
||||
{type === 'binary'
|
||||
? 'Diff view is not available for binary files'
|
||||
: 'Could not generate diff preview'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const NoChangesView = memo(({ beforeCode, language, highlighter, theme }: {
|
||||
beforeCode: string;
|
||||
language: string;
|
||||
highlighter: any;
|
||||
theme: string;
|
||||
}) => (
|
||||
<div className="h-full flex flex-col items-center justify-center p-4">
|
||||
<div className="text-center text-bolt-elements-textTertiary">
|
||||
<div className="i-ph:files text-4xl text-green-400 mb-2 mx-auto" />
|
||||
<p className="font-medium text-bolt-elements-textPrimary">Files are identical</p>
|
||||
<p className="text-sm mt-1">Both versions match exactly</p>
|
||||
</div>
|
||||
<div className="mt-4 w-full max-w-2xl bg-bolt-elements-background-depth-1 rounded-lg border border-bolt-elements-borderColor overflow-hidden">
|
||||
<div className="p-2 text-xs font-bold text-bolt-elements-textTertiary border-b border-bolt-elements-borderColor">
|
||||
Current Content
|
||||
</div>
|
||||
<div className="overflow-auto max-h-96">
|
||||
{beforeCode.split('\n').map((line, index) => (
|
||||
<div key={index} className="flex group min-w-fit">
|
||||
<div className={lineNumberStyles}>{index + 1}</div>
|
||||
<div className={lineContentStyles}>
|
||||
<span className="mr-2"> </span>
|
||||
<span dangerouslySetInnerHTML={{
|
||||
__html: highlighter ?
|
||||
highlighter.codeToHtml(line, { lang: language, theme: theme === 'dark' ? 'github-dark' : 'github-light' })
|
||||
.replace(/<\/?pre[^>]*>/g, '')
|
||||
.replace(/<\/?code[^>]*>/g, '')
|
||||
: line
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
));
|
||||
|
||||
// Otimização do processamento de diferenças com memoização
|
||||
const useProcessChanges = (beforeCode: string, afterCode: string) => {
|
||||
return useMemo(() => processChanges(beforeCode, afterCode), [beforeCode, afterCode]);
|
||||
};
|
||||
|
||||
// Componente otimizado para renderização de linhas de código
|
||||
const CodeLine = memo(({
|
||||
lineNumber,
|
||||
content,
|
||||
type,
|
||||
highlighter,
|
||||
language,
|
||||
block,
|
||||
theme
|
||||
}: {
|
||||
lineNumber: number;
|
||||
content: string;
|
||||
type: 'added' | 'removed' | 'unchanged';
|
||||
highlighter: any;
|
||||
language: string;
|
||||
block: DiffBlock;
|
||||
theme: string;
|
||||
}) => {
|
||||
const bgColor = diffLineStyles[type];
|
||||
|
||||
const renderContent = () => {
|
||||
if (type === 'unchanged' || !block.charChanges) {
|
||||
const highlightedCode = highlighter ?
|
||||
highlighter.codeToHtml(content, { lang: language, theme: theme === 'dark' ? 'github-dark' : 'github-light' })
|
||||
.replace(/<\/?pre[^>]*>/g, '')
|
||||
.replace(/<\/?code[^>]*>/g, '')
|
||||
: content;
|
||||
return <span dangerouslySetInnerHTML={{ __html: highlightedCode }} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{block.charChanges.map((change, index) => {
|
||||
const changeClass = changeColorStyles[change.type];
|
||||
|
||||
const highlightedCode = highlighter ?
|
||||
highlighter.codeToHtml(change.value, { lang: language, theme: theme === 'dark' ? 'github-dark' : 'github-light' })
|
||||
.replace(/<\/?pre[^>]*>/g, '')
|
||||
.replace(/<\/?code[^>]*>/g, '')
|
||||
: change.value;
|
||||
|
||||
return (
|
||||
<span
|
||||
key={index}
|
||||
className={changeClass}
|
||||
dangerouslySetInnerHTML={{ __html: highlightedCode }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex group min-w-fit">
|
||||
<div className={lineNumberStyles}>{lineNumber + 1}</div>
|
||||
<div className={`${lineContentStyles} ${bgColor}`}>
|
||||
<span className="mr-2 text-bolt-elements-textTertiary">
|
||||
{type === 'added' && <span className="text-green-700 dark:text-green-500">+</span>}
|
||||
{type === 'removed' && <span className="text-red-700 dark:text-red-500">-</span>}
|
||||
{type === 'unchanged' && ' '}
|
||||
</span>
|
||||
{renderContent()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
// Componente para exibir informações sobre o arquivo
|
||||
const FileInfo = memo(({
|
||||
filename,
|
||||
hasChanges,
|
||||
onToggleFullscreen,
|
||||
isFullscreen,
|
||||
beforeCode,
|
||||
afterCode
|
||||
}: {
|
||||
filename: string;
|
||||
hasChanges: boolean;
|
||||
onToggleFullscreen: () => void;
|
||||
isFullscreen: boolean;
|
||||
beforeCode: string;
|
||||
afterCode: string;
|
||||
}) => {
|
||||
// Calculate additions and deletions from the current document
|
||||
const { additions, deletions } = useMemo(() => {
|
||||
if (!hasChanges) return { additions: 0, deletions: 0 };
|
||||
|
||||
const changes = diffLines(beforeCode, afterCode, {
|
||||
newlineIsToken: false,
|
||||
ignoreWhitespace: true,
|
||||
ignoreCase: false
|
||||
});
|
||||
|
||||
return changes.reduce((acc: { additions: number; deletions: number }, change: Change) => {
|
||||
if (change.added) {
|
||||
acc.additions += change.value.split('\n').length;
|
||||
}
|
||||
if (change.removed) {
|
||||
acc.deletions += change.value.split('\n').length;
|
||||
}
|
||||
return acc;
|
||||
}, { additions: 0, deletions: 0 });
|
||||
}, [hasChanges, beforeCode, afterCode]);
|
||||
|
||||
const showStats = additions > 0 || deletions > 0;
|
||||
|
||||
return (
|
||||
<div className="flex items-center bg-bolt-elements-background-depth-1 p-2 text-sm text-bolt-elements-textPrimary shrink-0">
|
||||
<div className="i-ph:file mr-2 h-4 w-4 shrink-0" />
|
||||
<span className="truncate">{filename}</span>
|
||||
<span className="ml-auto shrink-0 flex items-center gap-2">
|
||||
{hasChanges ? (
|
||||
<>
|
||||
{showStats && (
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
{additions > 0 && (
|
||||
<span className="text-green-700 dark:text-green-500">+{additions}</span>
|
||||
)}
|
||||
{deletions > 0 && (
|
||||
<span className="text-red-700 dark:text-red-500">-{deletions}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<span className="text-yellow-600 dark:text-yellow-400">Modified</span>
|
||||
<span className="text-bolt-elements-textTertiary text-xs">
|
||||
{new Date().toLocaleTimeString()}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-green-700 dark:text-green-400">No Changes</span>
|
||||
)}
|
||||
<FullscreenButton onClick={onToggleFullscreen} isFullscreen={isFullscreen} />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const InlineDiffComparison = memo(({ beforeCode, afterCode, filename, language, lightTheme, darkTheme }: CodeComparisonProps) => {
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [highlighter, setHighlighter] = useState<any>(null);
|
||||
const theme = useStore(themeStore);
|
||||
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
setIsFullscreen(prev => !prev);
|
||||
}, []);
|
||||
|
||||
const { unifiedBlocks, hasChanges, isBinary, error } = useProcessChanges(beforeCode, afterCode);
|
||||
|
||||
useEffect(() => {
|
||||
getHighlighter({
|
||||
themes: ['github-dark', 'github-light'],
|
||||
langs: ['typescript', 'javascript', 'json', 'html', 'css', 'jsx', 'tsx']
|
||||
}).then(setHighlighter);
|
||||
}, []);
|
||||
|
||||
if (isBinary || error) return renderContentWarning(isBinary ? 'binary' : 'error');
|
||||
|
||||
return (
|
||||
<FullscreenOverlay isFullscreen={isFullscreen}>
|
||||
<div className="w-full h-full flex flex-col">
|
||||
<FileInfo
|
||||
filename={filename}
|
||||
hasChanges={hasChanges}
|
||||
onToggleFullscreen={toggleFullscreen}
|
||||
isFullscreen={isFullscreen}
|
||||
beforeCode={beforeCode}
|
||||
afterCode={afterCode}
|
||||
/>
|
||||
<div className={diffPanelStyles}>
|
||||
{hasChanges ? (
|
||||
<div className="overflow-x-auto min-w-full">
|
||||
{unifiedBlocks.map((block, index) => (
|
||||
<CodeLine
|
||||
key={`${block.lineNumber}-${index}`}
|
||||
lineNumber={block.lineNumber}
|
||||
content={block.content}
|
||||
type={block.type}
|
||||
highlighter={highlighter}
|
||||
language={language}
|
||||
block={block}
|
||||
theme={theme}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<NoChangesView
|
||||
beforeCode={beforeCode}
|
||||
language={language}
|
||||
highlighter={highlighter}
|
||||
theme={theme}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</FullscreenOverlay>
|
||||
);
|
||||
});
|
||||
|
||||
interface DiffViewProps {
|
||||
fileHistory: Record<string, FileHistory>;
|
||||
setFileHistory: React.Dispatch<React.SetStateAction<Record<string, FileHistory>>>;
|
||||
actionRunner: ActionRunner;
|
||||
}
|
||||
|
||||
export const DiffView = memo(({ fileHistory, setFileHistory, actionRunner }: DiffViewProps) => {
|
||||
const files = useStore(workbenchStore.files) as FileMap;
|
||||
const selectedFile = useStore(workbenchStore.selectedFile);
|
||||
const currentDocument = useStore(workbenchStore.currentDocument) as EditorDocument;
|
||||
const unsavedFiles = useStore(workbenchStore.unsavedFiles);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedFile && currentDocument) {
|
||||
const file = files[selectedFile];
|
||||
if (!file || !('content' in file)) return;
|
||||
|
||||
const existingHistory = fileHistory[selectedFile];
|
||||
const currentContent = currentDocument.value;
|
||||
|
||||
// Normalizar o conteúdo para comparação
|
||||
const normalizedCurrentContent = currentContent.replace(/\r\n/g, '\n').trim();
|
||||
const normalizedOriginalContent = (existingHistory?.originalContent || file.content).replace(/\r\n/g, '\n').trim();
|
||||
|
||||
// Se não há histórico existente, criar um novo apenas se houver diferenças
|
||||
if (!existingHistory) {
|
||||
if (normalizedCurrentContent !== normalizedOriginalContent) {
|
||||
const newChanges = diffLines(file.content, currentContent);
|
||||
setFileHistory(prev => ({
|
||||
...prev,
|
||||
[selectedFile]: {
|
||||
originalContent: file.content,
|
||||
lastModified: Date.now(),
|
||||
changes: newChanges,
|
||||
versions: [{
|
||||
timestamp: Date.now(),
|
||||
content: currentContent
|
||||
}],
|
||||
changeSource: 'auto-save'
|
||||
}
|
||||
}));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Se já existe histórico, verificar se há mudanças reais desde a última versão
|
||||
const lastVersion = existingHistory.versions[existingHistory.versions.length - 1];
|
||||
const normalizedLastContent = lastVersion?.content.replace(/\r\n/g, '\n').trim();
|
||||
|
||||
if (normalizedCurrentContent === normalizedLastContent) {
|
||||
return; // Não criar novo histórico se o conteúdo é o mesmo
|
||||
}
|
||||
|
||||
// Verificar se há mudanças significativas usando diffFiles
|
||||
const relativePath = extractRelativePath(selectedFile);
|
||||
const unifiedDiff = diffFiles(
|
||||
relativePath,
|
||||
existingHistory.originalContent,
|
||||
currentContent
|
||||
);
|
||||
|
||||
if (unifiedDiff) {
|
||||
const newChanges = diffLines(
|
||||
existingHistory.originalContent,
|
||||
currentContent
|
||||
);
|
||||
|
||||
// Verificar se as mudanças são significativas
|
||||
const hasSignificantChanges = newChanges.some(change =>
|
||||
(change.added || change.removed) && change.value.trim().length > 0
|
||||
);
|
||||
|
||||
if (hasSignificantChanges) {
|
||||
const newHistory: FileHistory = {
|
||||
originalContent: existingHistory.originalContent,
|
||||
lastModified: Date.now(),
|
||||
changes: [
|
||||
...existingHistory.changes,
|
||||
...newChanges
|
||||
].slice(-100), // Limitar histórico de mudanças
|
||||
versions: [
|
||||
...existingHistory.versions,
|
||||
{
|
||||
timestamp: Date.now(),
|
||||
content: currentContent
|
||||
}
|
||||
].slice(-10), // Manter apenas as 10 últimas versões
|
||||
changeSource: 'auto-save'
|
||||
};
|
||||
|
||||
setFileHistory(prev => ({ ...prev, [selectedFile]: newHistory }));
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [selectedFile, currentDocument?.value, files, setFileHistory, unsavedFiles]);
|
||||
|
||||
if (!selectedFile || !currentDocument) {
|
||||
return (
|
||||
<div className="flex w-full h-full justify-center items-center bg-bolt-elements-background-depth-1 text-bolt-elements-textPrimary">
|
||||
Select a file to view differences
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const file = files[selectedFile];
|
||||
const originalContent = file && 'content' in file ? file.content : '';
|
||||
const currentContent = currentDocument.value;
|
||||
|
||||
const history = fileHistory[selectedFile];
|
||||
const effectiveOriginalContent = history?.originalContent || originalContent;
|
||||
const language = getLanguageFromExtension(selectedFile.split('.').pop() || '');
|
||||
|
||||
try {
|
||||
return (
|
||||
<div className="h-full overflow-hidden">
|
||||
<InlineDiffComparison
|
||||
beforeCode={effectiveOriginalContent}
|
||||
afterCode={currentContent}
|
||||
language={language}
|
||||
filename={selectedFile}
|
||||
lightTheme="github-light"
|
||||
darkTheme="github-dark"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('DiffView render error:', error);
|
||||
return (
|
||||
<div className="flex w-full h-full justify-center items-center bg-bolt-elements-background-depth-1 text-red-400">
|
||||
<div className="text-center">
|
||||
<div className="i-ph:warning-circle text-4xl mb-2" />
|
||||
<p>Failed to render diff view</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
@ -12,6 +12,7 @@ import {
|
||||
import { PanelHeader } from '~/components/ui/PanelHeader';
|
||||
import { PanelHeaderButton } from '~/components/ui/PanelHeaderButton';
|
||||
import type { FileMap } from '~/lib/stores/files';
|
||||
import type { FileHistory } from '~/types/actions';
|
||||
import { themeStore } from '~/lib/stores/theme';
|
||||
import { WORK_DIR } from '~/utils/constants';
|
||||
import { renderLogger } from '~/utils/logger';
|
||||
@ -27,6 +28,7 @@ interface EditorPanelProps {
|
||||
editorDocument?: EditorDocument;
|
||||
selectedFile?: string | undefined;
|
||||
isStreaming?: boolean;
|
||||
fileHistory?: Record<string, FileHistory>;
|
||||
onEditorChange?: OnEditorChange;
|
||||
onEditorScroll?: OnEditorScroll;
|
||||
onFileSelect?: (value?: string) => void;
|
||||
@ -45,6 +47,7 @@ export const EditorPanel = memo(
|
||||
editorDocument,
|
||||
selectedFile,
|
||||
isStreaming,
|
||||
fileHistory,
|
||||
onFileSelect,
|
||||
onEditorChange,
|
||||
onEditorScroll,
|
||||
@ -83,6 +86,7 @@ export const EditorPanel = memo(
|
||||
files={files}
|
||||
hideRoot
|
||||
unsavedFiles={unsavedFiles}
|
||||
fileHistory={fileHistory}
|
||||
rootFolder={WORK_DIR}
|
||||
selectedFile={selectedFile}
|
||||
onFileSelect={onFileSelect}
|
||||
|
@ -3,6 +3,8 @@ import type { FileMap } from '~/lib/stores/files';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { createScopedLogger, renderLogger } from '~/utils/logger';
|
||||
import * as ContextMenu from '@radix-ui/react-context-menu';
|
||||
import type { FileHistory } from '~/types/actions';
|
||||
import { diffLines, type Change } from 'diff';
|
||||
|
||||
const logger = createScopedLogger('FileTree');
|
||||
|
||||
@ -19,6 +21,7 @@ interface Props {
|
||||
allowFolderSelection?: boolean;
|
||||
hiddenFiles?: Array<string | RegExp>;
|
||||
unsavedFiles?: Set<string>;
|
||||
fileHistory?: Record<string, FileHistory>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@ -34,6 +37,7 @@ export const FileTree = memo(
|
||||
hiddenFiles,
|
||||
className,
|
||||
unsavedFiles,
|
||||
fileHistory = {},
|
||||
}: Props) => {
|
||||
renderLogger.trace('FileTree');
|
||||
|
||||
@ -138,6 +142,7 @@ export const FileTree = memo(
|
||||
selected={selectedFile === fileOrFolder.fullPath}
|
||||
file={fileOrFolder}
|
||||
unsavedChanges={unsavedFiles?.has(fileOrFolder.fullPath)}
|
||||
fileHistory={fileHistory}
|
||||
onCopyPath={() => {
|
||||
onCopyPath(fileOrFolder);
|
||||
}}
|
||||
@ -253,19 +258,55 @@ interface FileProps {
|
||||
file: FileNode;
|
||||
selected: boolean;
|
||||
unsavedChanges?: boolean;
|
||||
fileHistory?: Record<string, FileHistory>;
|
||||
onCopyPath: () => void;
|
||||
onCopyRelativePath: () => void;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function File({
|
||||
file: { depth, name },
|
||||
file: { depth, name, fullPath },
|
||||
onClick,
|
||||
onCopyPath,
|
||||
onCopyRelativePath,
|
||||
selected,
|
||||
unsavedChanges = false,
|
||||
fileHistory = {},
|
||||
}: FileProps) {
|
||||
const fileModifications = fileHistory[fullPath];
|
||||
const hasModifications = fileModifications !== undefined;
|
||||
|
||||
// Calculate added and removed lines from the most recent changes
|
||||
const { additions, deletions } = useMemo(() => {
|
||||
if (!fileModifications?.originalContent) return { additions: 0, deletions: 0 };
|
||||
|
||||
// Usar a mesma lógica do DiffView para processar as mudanças
|
||||
const normalizedOriginal = fileModifications.originalContent.replace(/\r\n/g, '\n');
|
||||
const normalizedCurrent = fileModifications.versions[fileModifications.versions.length - 1]?.content.replace(/\r\n/g, '\n') || '';
|
||||
|
||||
if (normalizedOriginal === normalizedCurrent) {
|
||||
return { additions: 0, deletions: 0 };
|
||||
}
|
||||
|
||||
const changes = diffLines(normalizedOriginal, normalizedCurrent, {
|
||||
newlineIsToken: false,
|
||||
ignoreWhitespace: true,
|
||||
ignoreCase: false
|
||||
});
|
||||
|
||||
return changes.reduce((acc: { additions: number; deletions: number }, change: Change) => {
|
||||
if (change.added) {
|
||||
acc.additions += change.value.split('\n').length;
|
||||
}
|
||||
if (change.removed) {
|
||||
acc.deletions += change.value.split('\n').length;
|
||||
}
|
||||
return acc;
|
||||
}, { additions: 0, deletions: 0 });
|
||||
}, [fileModifications]);
|
||||
|
||||
const showStats = additions > 0 || deletions > 0;
|
||||
|
||||
return (
|
||||
<FileContextMenu onCopyPath={onCopyPath} onCopyRelativePath={onCopyRelativePath}>
|
||||
<NodeButton
|
||||
@ -286,7 +327,21 @@ function File({
|
||||
})}
|
||||
>
|
||||
<div className="flex-1 truncate pr-2">{name}</div>
|
||||
{unsavedChanges && <span className="i-ph:circle-fill scale-68 shrink-0 text-orange-500" />}
|
||||
<div className="flex items-center gap-1">
|
||||
{showStats && (
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
{additions > 0 && (
|
||||
<span className="text-green-500">+{additions}</span>
|
||||
)}
|
||||
{deletions > 0 && (
|
||||
<span className="text-red-500">-{deletions}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{unsavedChanges && (
|
||||
<span className="i-ph:circle-fill scale-68 shrink-0 text-orange-500" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</NodeButton>
|
||||
</FileContextMenu>
|
||||
|
@ -1,8 +1,15 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { motion, type HTMLMotionProps, type Variants } from 'framer-motion';
|
||||
import { computed } from 'nanostores';
|
||||
import { memo, useCallback, useEffect, useState } from 'react';
|
||||
import { memo, useCallback, useEffect, useState, useMemo } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Popover, Transition } from '@headlessui/react';
|
||||
import { diffLines, type Change } from 'diff';
|
||||
import { formatDistanceToNow as formatDistance } from 'date-fns';
|
||||
import { ActionRunner } from '~/lib/runtime/action-runner';
|
||||
import { getLanguageFromExtension } from '~/utils/getLanguageFromExtension';
|
||||
import type { FileHistory } from '~/types/actions';
|
||||
import { DiffView } from './DiffView';
|
||||
import {
|
||||
type OnChangeCallback as OnEditorChange,
|
||||
type OnScrollCallback as OnEditorScroll,
|
||||
@ -18,10 +25,16 @@ import { EditorPanel } from './EditorPanel';
|
||||
import { Preview } from './Preview';
|
||||
import useViewport from '~/lib/hooks';
|
||||
import { PushToGitHubDialog } from '~/components/@settings/tabs/connections/components/PushToGitHubDialog';
|
||||
import Cookies from 'js-cookie';
|
||||
|
||||
interface WorkspaceProps {
|
||||
chatStarted?: boolean;
|
||||
isStreaming?: boolean;
|
||||
actionRunner: ActionRunner;
|
||||
metadata?: {
|
||||
gitUrl?: string;
|
||||
};
|
||||
updateChatMestaData?: (metadata: any) => void;
|
||||
}
|
||||
|
||||
const viewTransition = { ease: cubicEasingFn };
|
||||
@ -31,6 +44,10 @@ const sliderOptions: SliderOptions<WorkbenchViewType> = {
|
||||
value: 'code',
|
||||
text: 'Code',
|
||||
},
|
||||
middle: {
|
||||
value: 'diff',
|
||||
text: 'Diff',
|
||||
},
|
||||
right: {
|
||||
value: 'preview',
|
||||
text: 'Preview',
|
||||
@ -54,11 +71,200 @@ const workbenchVariants = {
|
||||
},
|
||||
} satisfies Variants;
|
||||
|
||||
export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) => {
|
||||
const FileModifiedDropdown = memo(({
|
||||
fileHistory,
|
||||
onSelectFile,
|
||||
}: {
|
||||
fileHistory: Record<string, FileHistory>,
|
||||
onSelectFile: (filePath: string) => void,
|
||||
}) => {
|
||||
const modifiedFiles = Object.entries(fileHistory);
|
||||
const hasChanges = modifiedFiles.length > 0;
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const filteredFiles = useMemo(() => {
|
||||
return modifiedFiles.filter(([filePath]) =>
|
||||
filePath.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
}, [modifiedFiles, searchQuery]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Popover className="relative">
|
||||
{({ open }: { open: boolean }) => (
|
||||
<>
|
||||
<Popover.Button className="flex items-center gap-2 px-3 py-1.5 text-sm rounded-lg bg-bolt-elements-background-depth-2 hover:bg-bolt-elements-background-depth-3 transition-colors text-bolt-elements-textPrimary border border-bolt-elements-borderColor">
|
||||
<span className="font-medium">File Changes</span>
|
||||
{hasChanges && (
|
||||
<span className="w-5 h-5 rounded-full bg-accent-500/20 text-accent-500 text-xs flex items-center justify-center border border-accent-500/30">
|
||||
{modifiedFiles.length}
|
||||
</span>
|
||||
)}
|
||||
</Popover.Button>
|
||||
<Transition
|
||||
show={open}
|
||||
enter="transition duration-100 ease-out"
|
||||
enterFrom="transform scale-95 opacity-0"
|
||||
enterTo="transform scale-100 opacity-100"
|
||||
leave="transition duration-75 ease-out"
|
||||
leaveFrom="transform scale-100 opacity-100"
|
||||
leaveTo="transform scale-95 opacity-0"
|
||||
>
|
||||
<Popover.Panel className="absolute right-0 z-20 mt-2 w-80 origin-top-right rounded-xl bg-bolt-elements-background-depth-2 shadow-xl border border-bolt-elements-borderColor">
|
||||
<div className="p-2">
|
||||
<div className="relative mx-2 mb-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search files..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-8 pr-3 py-1.5 text-sm rounded-lg bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor focus:outline-none focus:ring-2 focus:ring-blue-500/50"
|
||||
/>
|
||||
<div className="absolute left-2 top-1/2 -translate-y-1/2 text-bolt-elements-textTertiary">
|
||||
<div className="i-ph:magnifying-glass" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-60 overflow-y-auto">
|
||||
{filteredFiles.length > 0 ? (
|
||||
filteredFiles.map(([filePath, history]) => {
|
||||
const extension = filePath.split('.').pop() || '';
|
||||
const language = getLanguageFromExtension(extension);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={filePath}
|
||||
onClick={() => onSelectFile(filePath)}
|
||||
className="w-full px-3 py-2 text-left rounded-md hover:bg-bolt-elements-background-depth-1 transition-colors group bg-transparent"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="shrink-0 w-5 h-5 text-bolt-elements-textTertiary">
|
||||
{['typescript', 'javascript', 'jsx', 'tsx'].includes(language) && <div className="i-ph:file-js" />}
|
||||
{['css', 'scss', 'less'].includes(language) && <div className="i-ph:paint-brush" />}
|
||||
{language === 'html' && <div className="i-ph:code" />}
|
||||
{language === 'json' && <div className="i-ph:brackets-curly" />}
|
||||
{language === 'python' && <div className="i-ph:file-text" />}
|
||||
{language === 'markdown' && <div className="i-ph:article" />}
|
||||
{['yaml', 'yml'].includes(language) && <div className="i-ph:file-text" />}
|
||||
{language === 'sql' && <div className="i-ph:database" />}
|
||||
{language === 'dockerfile' && <div className="i-ph:cube" />}
|
||||
{language === 'shell' && <div className="i-ph:terminal" />}
|
||||
{!['typescript', 'javascript', 'css', 'html', 'json', 'python', 'markdown', 'yaml', 'yml', 'sql', 'dockerfile', 'shell', 'jsx', 'tsx', 'scss', 'less'].includes(language) && <div className="i-ph:file-text" />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="truncate text-sm font-medium text-bolt-elements-textPrimary">
|
||||
{filePath.split('/').pop()}
|
||||
</span>
|
||||
<span className="truncate text-xs text-bolt-elements-textTertiary">
|
||||
{filePath}
|
||||
</span>
|
||||
</div>
|
||||
{(() => {
|
||||
// Calculate diff stats
|
||||
const { additions, deletions } = (() => {
|
||||
if (!history.originalContent) return { additions: 0, deletions: 0 };
|
||||
|
||||
const normalizedOriginal = history.originalContent.replace(/\r\n/g, '\n');
|
||||
const normalizedCurrent = history.versions[history.versions.length - 1]?.content.replace(/\r\n/g, '\n') || '';
|
||||
|
||||
if (normalizedOriginal === normalizedCurrent) {
|
||||
return { additions: 0, deletions: 0 };
|
||||
}
|
||||
|
||||
const changes = diffLines(normalizedOriginal, normalizedCurrent, {
|
||||
newlineIsToken: false,
|
||||
ignoreWhitespace: true,
|
||||
ignoreCase: false
|
||||
});
|
||||
|
||||
return changes.reduce((acc: { additions: number; deletions: number }, change: Change) => {
|
||||
if (change.added) {
|
||||
acc.additions += change.value.split('\n').length;
|
||||
}
|
||||
if (change.removed) {
|
||||
acc.deletions += change.value.split('\n').length;
|
||||
}
|
||||
return acc;
|
||||
}, { additions: 0, deletions: 0 });
|
||||
})();
|
||||
|
||||
const showStats = additions > 0 || deletions > 0;
|
||||
|
||||
return showStats && (
|
||||
<div className="flex items-center gap-1 text-xs shrink-0">
|
||||
{additions > 0 && (
|
||||
<span className="text-green-500">+{additions}</span>
|
||||
)}
|
||||
{deletions > 0 && (
|
||||
<span className="text-red-500">-{deletions}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center p-4 text-center">
|
||||
<div className="w-12 h-12 mb-2 text-bolt-elements-textTertiary">
|
||||
<div className="i-ph:file-dashed" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-bolt-elements-textPrimary">
|
||||
{searchQuery ? 'No matching files' : 'No modified files'}
|
||||
</p>
|
||||
<p className="text-xs text-bolt-elements-textTertiary mt-1">
|
||||
{searchQuery ? 'Try another search' : 'Changes will appear here as you edit'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasChanges && (
|
||||
<div className="border-t border-bolt-elements-borderColor p-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
filteredFiles.map(([filePath]) => filePath).join('\n')
|
||||
);
|
||||
toast('File list copied to clipboard', {
|
||||
icon: <div className="i-ph:check-circle text-accent-500" />
|
||||
});
|
||||
}}
|
||||
className="w-full flex items-center justify-center gap-2 px-3 py-1.5 text-sm rounded-lg bg-bolt-elements-background-depth-1 hover:bg-bolt-elements-background-depth-3 transition-colors text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary"
|
||||
>
|
||||
Copy File List
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const Workbench = memo(({
|
||||
chatStarted,
|
||||
isStreaming,
|
||||
actionRunner,
|
||||
metadata,
|
||||
updateChatMestaData
|
||||
}: WorkspaceProps) => {
|
||||
renderLogger.trace('Workbench');
|
||||
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
const [isPushDialogOpen, setIsPushDialogOpen] = useState(false);
|
||||
const [fileHistory, setFileHistory] = useState<Record<string, FileHistory>>({});
|
||||
|
||||
const modifiedFiles = Array.from(useStore(workbenchStore.unsavedFiles).keys());
|
||||
|
||||
const hasPreview = useStore(computed(workbenchStore.previews, (previews) => previews.length > 0));
|
||||
const showWorkbench = useStore(workbenchStore.showWorkbench);
|
||||
@ -121,6 +327,11 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSelectFile = useCallback((filePath: string) => {
|
||||
workbenchStore.setSelectedFile(filePath);
|
||||
workbenchStore.currentView.set('diff');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
chatStarted && (
|
||||
<motion.div
|
||||
@ -175,6 +386,12 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
|
||||
</PanelHeaderButton>
|
||||
</div>
|
||||
)}
|
||||
{selectedView === 'diff' && (
|
||||
<FileModifiedDropdown
|
||||
fileHistory={fileHistory}
|
||||
onSelectFile={handleSelectFile}
|
||||
/>
|
||||
)}
|
||||
<IconButton
|
||||
icon="i-ph:x-circle"
|
||||
className="-mr-1"
|
||||
@ -186,8 +403,8 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
|
||||
</div>
|
||||
<div className="relative flex-1 overflow-hidden">
|
||||
<View
|
||||
initial={{ x: selectedView === 'code' ? 0 : '-100%' }}
|
||||
animate={{ x: selectedView === 'code' ? 0 : '-100%' }}
|
||||
initial={{ x: '0%' }}
|
||||
animate={{ x: selectedView === 'code' ? '0%' : '-100%' }}
|
||||
>
|
||||
<EditorPanel
|
||||
editorDocument={currentDocument}
|
||||
@ -195,6 +412,7 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
|
||||
selectedFile={selectedFile}
|
||||
files={files}
|
||||
unsavedFiles={unsavedFiles}
|
||||
fileHistory={fileHistory}
|
||||
onFileSelect={onFileSelect}
|
||||
onEditorScroll={onEditorScroll}
|
||||
onEditorChange={onEditorChange}
|
||||
@ -203,8 +421,18 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
|
||||
/>
|
||||
</View>
|
||||
<View
|
||||
initial={{ x: selectedView === 'preview' ? 0 : '100%' }}
|
||||
animate={{ x: selectedView === 'preview' ? 0 : '100%' }}
|
||||
initial={{ x: '100%' }}
|
||||
animate={{ x: selectedView === 'diff' ? '0%' : selectedView === 'code' ? '100%' : '-100%' }}
|
||||
>
|
||||
<DiffView
|
||||
fileHistory={fileHistory}
|
||||
setFileHistory={setFileHistory}
|
||||
actionRunner={actionRunner}
|
||||
/>
|
||||
</View>
|
||||
<View
|
||||
initial={{ x: '100%' }}
|
||||
animate={{ x: selectedView === 'preview' ? '0%' : '100%' }}
|
||||
>
|
||||
<Preview />
|
||||
</View>
|
||||
@ -215,14 +443,24 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
|
||||
<PushToGitHubDialog
|
||||
isOpen={isPushDialogOpen}
|
||||
onClose={() => setIsPushDialogOpen(false)}
|
||||
onPush={async (repoName, username, token, isPrivate) => {
|
||||
onPush={async (repoName, username, token) => {
|
||||
try {
|
||||
const repoUrl = await workbenchStore.pushToGitHub(repoName, undefined, username, token, isPrivate);
|
||||
const commitMessage = prompt('Please enter a commit message:', 'Initial commit') || 'Initial commit';
|
||||
await workbenchStore.pushToGitHub(repoName, commitMessage, username, token);
|
||||
const repoUrl = `https://github.com/${username}/${repoName}`;
|
||||
|
||||
if (updateChatMestaData && !metadata?.gitUrl) {
|
||||
updateChatMestaData({
|
||||
...(metadata || {}),
|
||||
gitUrl: repoUrl,
|
||||
});
|
||||
}
|
||||
|
||||
return repoUrl;
|
||||
} catch (error) {
|
||||
console.error('Error pushing to GitHub:', error);
|
||||
toast.error('Failed to push to GitHub');
|
||||
throw error; // Rethrow to let PushToGitHubDialog handle the error state
|
||||
throw error;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
@ -1,7 +1,7 @@
|
||||
import type { WebContainer } from '@webcontainer/api';
|
||||
import { path } from '~/utils/path';
|
||||
import { path as nodePath } from '~/utils/path';
|
||||
import { atom, map, type MapStore } from 'nanostores';
|
||||
import type { ActionAlert, BoltAction } from '~/types/actions';
|
||||
import type { ActionAlert, BoltAction, FileHistory } from '~/types/actions';
|
||||
import { createScopedLogger } from '~/utils/logger';
|
||||
import { unreachable } from '~/utils/unreachable';
|
||||
import type { ActionCallbackData } from './message-parser';
|
||||
@ -284,9 +284,9 @@ export class ActionRunner {
|
||||
}
|
||||
|
||||
const webcontainer = await this.#webcontainer;
|
||||
const relativePath = path.relative(webcontainer.workdir, action.filePath);
|
||||
const relativePath = nodePath.relative(webcontainer.workdir, action.filePath);
|
||||
|
||||
let folder = path.dirname(relativePath);
|
||||
let folder = nodePath.dirname(relativePath);
|
||||
|
||||
// remove trailing slashes
|
||||
folder = folder.replace(/\/+$/g, '');
|
||||
@ -307,12 +307,40 @@ export class ActionRunner {
|
||||
logger.error('Failed to write file\n\n', error);
|
||||
}
|
||||
}
|
||||
|
||||
#updateAction(id: string, newState: ActionStateUpdate) {
|
||||
const actions = this.actions.get();
|
||||
|
||||
this.actions.setKey(id, { ...actions[id], ...newState });
|
||||
}
|
||||
|
||||
async getFileHistory(filePath: string): Promise<FileHistory | null> {
|
||||
try {
|
||||
const webcontainer = await this.#webcontainer;
|
||||
const historyPath = this.#getHistoryPath(filePath);
|
||||
const content = await webcontainer.fs.readFile(historyPath, 'utf-8');
|
||||
return JSON.parse(content);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async saveFileHistory(filePath: string, history: FileHistory) {
|
||||
const webcontainer = await this.#webcontainer;
|
||||
const historyPath = this.#getHistoryPath(filePath);
|
||||
|
||||
await this.#runFileAction({
|
||||
type: 'file',
|
||||
filePath: historyPath,
|
||||
content: JSON.stringify(history),
|
||||
changeSource: 'auto-save'
|
||||
} as any);
|
||||
}
|
||||
|
||||
#getHistoryPath(filePath: string) {
|
||||
return nodePath.join('.history', filePath);
|
||||
}
|
||||
|
||||
async #runBuildAction(action: ActionState) {
|
||||
if (action.type !== 'build') {
|
||||
unreachable('Expected build action');
|
||||
@ -339,7 +367,7 @@ export class ActionRunner {
|
||||
}
|
||||
|
||||
// Get the build output directory path
|
||||
const buildDir = path.join(webcontainer.workdir, 'dist');
|
||||
const buildDir = nodePath.join(webcontainer.workdir, 'dist');
|
||||
|
||||
return {
|
||||
path: buildDir,
|
||||
@ -347,4 +375,4 @@ export class ActionRunner {
|
||||
output,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
@ -19,7 +19,6 @@ import Cookies from 'js-cookie';
|
||||
import { createSampler } from '~/utils/sampler';
|
||||
import type { ActionAlert } from '~/types/actions';
|
||||
|
||||
// Destructure saveAs from the CommonJS module
|
||||
const { saveAs } = fileSaver;
|
||||
|
||||
export interface ArtifactState {
|
||||
@ -34,7 +33,7 @@ export type ArtifactUpdateState = Pick<ArtifactState, 'title' | 'closed'>;
|
||||
|
||||
type Artifacts = MapStore<Record<string, ArtifactState>>;
|
||||
|
||||
export type WorkbenchViewType = 'code' | 'preview';
|
||||
export type WorkbenchViewType = 'code' | 'diff' | 'preview';
|
||||
|
||||
export class WorkbenchStore {
|
||||
#previewsStore = new PreviewsStore(webcontainer);
|
||||
@ -437,13 +436,7 @@ export class WorkbenchStore {
|
||||
return syncedFiles;
|
||||
}
|
||||
|
||||
async pushToGitHub(
|
||||
repoName: string,
|
||||
commitMessage?: string,
|
||||
githubUsername?: string,
|
||||
ghToken?: string,
|
||||
isPrivate: boolean = false,
|
||||
) {
|
||||
async pushToGitHub(repoName: string, commitMessage?: string, githubUsername?: string, ghToken?: string) {
|
||||
try {
|
||||
// Use cookies if username and token are not provided
|
||||
const githubToken = ghToken || Cookies.get('githubToken');
|
||||
@ -467,7 +460,7 @@ export class WorkbenchStore {
|
||||
// Repository doesn't exist, so create a new one
|
||||
const { data: newRepo } = await octokit.repos.createForAuthenticatedUser({
|
||||
name: repoName,
|
||||
private: isPrivate,
|
||||
private: false,
|
||||
auto_init: true,
|
||||
});
|
||||
repo = newRepo;
|
||||
@ -545,7 +538,7 @@ export class WorkbenchStore {
|
||||
sha: newCommit.sha,
|
||||
});
|
||||
|
||||
return repo.html_url; // Return the URL instead of showing alert
|
||||
alert(`Repository created and code pushed: ${repo.html_url}`);
|
||||
} catch (error) {
|
||||
console.error('Error pushing to GitHub:', error);
|
||||
throw error; // Rethrow the error for further handling
|
||||
|
72
app/styles/diff-view.css
Normal file
72
app/styles/diff-view.css
Normal file
@ -0,0 +1,72 @@
|
||||
.diff-panel-content {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(155, 155, 155, 0.5) transparent;
|
||||
}
|
||||
|
||||
.diff-panel-content::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.diff-panel-content::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.diff-panel-content::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(155, 155, 155, 0.5);
|
||||
border-radius: 4px;
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
|
||||
.diff-panel-content::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(155, 155, 155, 0.7);
|
||||
}
|
||||
|
||||
/* Hide scrollbar for the left panel when not hovered */
|
||||
.diff-panel:not(:hover) .diff-panel-content::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.diff-panel:not(:hover) .diff-panel-content {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
/* Estilos para as linhas de diff */
|
||||
.diff-block-added {
|
||||
@apply bg-green-500/20 border-l-4 border-green-500;
|
||||
}
|
||||
|
||||
.diff-block-removed {
|
||||
@apply bg-red-500/20 border-l-4 border-red-500;
|
||||
}
|
||||
|
||||
/* Melhorar contraste para mudanças */
|
||||
.diff-panel-content .group:hover .diff-block-added {
|
||||
@apply bg-green-500/30;
|
||||
}
|
||||
|
||||
.diff-panel-content .group:hover .diff-block-removed {
|
||||
@apply bg-red-500/30;
|
||||
}
|
||||
|
||||
/* Estilos unificados para ambas as visualizações */
|
||||
.diff-line {
|
||||
@apply flex group min-w-fit transition-colors duration-150;
|
||||
}
|
||||
|
||||
.diff-line-number {
|
||||
@apply w-12 shrink-0 pl-2 py-0.5 text-left font-mono text-bolt-elements-textTertiary border-r border-bolt-elements-borderColor bg-bolt-elements-background-depth-1;
|
||||
}
|
||||
|
||||
.diff-line-content {
|
||||
@apply px-4 py-0.5 font-mono whitespace-pre flex-1 group-hover:bg-bolt-elements-background-depth-2 text-bolt-elements-textPrimary;
|
||||
}
|
||||
|
||||
/* Cores específicas para adições/remoções */
|
||||
.diff-added {
|
||||
@apply bg-green-500/20 border-l-4 border-green-500;
|
||||
}
|
||||
|
||||
.diff-removed {
|
||||
@apply bg-red-500/20 border-l-4 border-red-500;
|
||||
}
|
@ -1,3 +1,5 @@
|
||||
import type { Change } from 'diff';
|
||||
|
||||
export type ActionType = 'file' | 'shell';
|
||||
|
||||
export interface BaseAction {
|
||||
@ -32,3 +34,15 @@ export interface ActionAlert {
|
||||
content: string;
|
||||
source?: 'terminal' | 'preview'; // Add source to differentiate between terminal and preview errors
|
||||
}
|
||||
|
||||
export interface FileHistory {
|
||||
originalContent: string;
|
||||
lastModified: number;
|
||||
changes: Change[];
|
||||
versions: {
|
||||
timestamp: number;
|
||||
content: string;
|
||||
}[];
|
||||
// Novo campo para rastrear a origem das mudanças
|
||||
changeSource?: 'user' | 'auto-save' | 'external';
|
||||
}
|
||||
|
24
app/utils/getLanguageFromExtension.ts
Normal file
24
app/utils/getLanguageFromExtension.ts
Normal file
@ -0,0 +1,24 @@
|
||||
export const getLanguageFromExtension = (ext: string): string => {
|
||||
const map: Record<string, string> = {
|
||||
js: "javascript",
|
||||
jsx: "jsx",
|
||||
ts: "typescript",
|
||||
tsx: "tsx",
|
||||
json: "json",
|
||||
html: "html",
|
||||
css: "css",
|
||||
py: "python",
|
||||
java: "java",
|
||||
rb: "ruby",
|
||||
cpp: "cpp",
|
||||
c: "c",
|
||||
cs: "csharp",
|
||||
go: "go",
|
||||
rs: "rust",
|
||||
php: "php",
|
||||
swift: "swift",
|
||||
md: "plaintext",
|
||||
sh: "bash",
|
||||
};
|
||||
return map[ext] || "typescript";
|
||||
};
|
@ -78,6 +78,7 @@
|
||||
"@remix-run/cloudflare-pages": "^2.15.2",
|
||||
"@remix-run/node": "^2.15.2",
|
||||
"@remix-run/react": "^2.15.2",
|
||||
"@tanstack/react-virtual": "^3.13.0",
|
||||
"@types/react-beautiful-dnd": "^13.1.8",
|
||||
"@uiw/codemirror-theme-vscode": "^4.23.6",
|
||||
"@unocss/reset": "^0.61.9",
|
||||
@ -133,6 +134,8 @@
|
||||
"@iconify-json/ph": "^1.2.1",
|
||||
"@iconify/types": "^2.0.0",
|
||||
"@remix-run/dev": "^2.15.2",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.2.0",
|
||||
"@types/diff": "^5.2.3",
|
||||
"@types/dom-speech-recognition": "^0.0.4",
|
||||
"@types/file-saver": "^2.0.7",
|
||||
@ -140,9 +143,11 @@
|
||||
"@types/path-browserify": "^1.0.3",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"fast-glob": "^3.3.2",
|
||||
"husky": "9.1.7",
|
||||
"is-ci": "^3.0.1",
|
||||
"jsdom": "^26.0.0",
|
||||
"node-fetch": "^3.3.2",
|
||||
"pnpm": "^9.14.4",
|
||||
"prettier": "^3.4.1",
|
||||
|
1224
pnpm-lock.yaml
1224
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
@ -3,4 +3,4 @@ name = "bolt"
|
||||
compatibility_flags = ["nodejs_compat"]
|
||||
compatibility_date = "2024-07-01"
|
||||
pages_build_output_dir = "./build/client"
|
||||
send_metrics = false
|
||||
send_metrics = false
|
Loading…
Reference in New Issue
Block a user