mirror of
https://github.com/stackblitz-labs/bolt.diy
synced 2025-06-26 18:26:38 +00:00
refactor: track additions and deletions in file history
- Add additions and deletions fields to FileHistory interface - Calculate diff stats when creating/updating file history - Use pre-calculated stats in UI instead of recalculating - Add fallback for contextFiles to continue with the llm request in event of an error. - Remove FileModifiedDropdown to its own component - Add refreshAllPreviews class in previews store for refreshing previews on file change - Add auto switch to preview once isStreaming goes from true to false - Add actionType to files to now show whether a file is being created or updated and modified message-parser and artifact.tsx accordingly
This commit is contained in:
parent
e7910790af
commit
335f7c6077
@ -229,7 +229,7 @@ const ActionList = memo(({ actions }: ActionListProps) => {
|
||||
<div className="i-ph:x"></div>
|
||||
) : null}
|
||||
</div>
|
||||
{type === 'file' ? (
|
||||
{type === 'file' && action.actionType === 'create' ? (
|
||||
<div>
|
||||
Create{' '}
|
||||
<code
|
||||
@ -239,6 +239,16 @@ const ActionList = memo(({ actions }: ActionListProps) => {
|
||||
{action.filePath}
|
||||
</code>
|
||||
</div>
|
||||
) : type === 'file' && action.actionType === 'update' ? (
|
||||
<div>
|
||||
Update{' '}
|
||||
<code
|
||||
className="bg-bolt-elements-artifacts-inlineCode-background text-bolt-elements-artifacts-inlineCode-text px-1.5 py-1 rounded-md text-bolt-elements-item-contentAccent hover:underline cursor-pointer"
|
||||
onClick={() => openArtifactInWorkbench(action.filePath)}
|
||||
>
|
||||
{action.filePath}
|
||||
</code>
|
||||
</div>
|
||||
) : type === 'shell' ? (
|
||||
<div className="flex items-center w-full min-h-[28px]">
|
||||
<span className="flex-1">Run command</span>
|
||||
|
@ -98,9 +98,6 @@ async function chatAction({ context, request }: ActionFunctionArgs) {
|
||||
message: 'Analysing Request',
|
||||
} satisfies ProgressAnnotation);
|
||||
|
||||
// Create a summary of the chat
|
||||
console.log(`Messages count: ${messages.length}`);
|
||||
|
||||
summary = await createSummary({
|
||||
messages: [...messages],
|
||||
env: context.cloudflare?.env,
|
||||
@ -142,7 +139,6 @@ async function chatAction({ context, request }: ActionFunctionArgs) {
|
||||
} satisfies ProgressAnnotation);
|
||||
|
||||
// Select context files
|
||||
console.log(`Messages count: ${messages.length}`);
|
||||
filteredFiles = await selectContext({
|
||||
messages: [...messages],
|
||||
env: context.cloudflare?.env,
|
||||
|
@ -180,7 +180,11 @@ export async function selectContext(props: {
|
||||
const updateContextBuffer = response.match(/<updateContextBuffer>([\s\S]*?)<\/updateContextBuffer>/);
|
||||
|
||||
if (!updateContextBuffer) {
|
||||
throw new Error('Invalid response. Please follow the response format');
|
||||
logger.error('Invalid response format. Response:', response);
|
||||
logger.warn('Falling back to current context files');
|
||||
|
||||
// Return current context instead of throwing
|
||||
return contextFiles;
|
||||
}
|
||||
|
||||
const includeFiles =
|
||||
|
@ -161,7 +161,12 @@ The year is 2025.
|
||||
Action Types:
|
||||
- shell: Running commands (use --yes for npx/npm create, && for sequences, NEVER re-run dev servers)
|
||||
- start: Starting project (use ONLY for project startup, LAST action)
|
||||
- file: Creating/updating files (add filePath and contentType attributes)
|
||||
- file:
|
||||
- Two actionTypes are available for file actions:
|
||||
- Create for when creating a new file - Example: <boltAction type="file" actionType="create" filePath="/home/project/src/App.tsx">
|
||||
- Update for when updating an existing file - Example: <boltAction type="file" actionType="update" filePath="/home/project/src/App.tsx">
|
||||
- Creating/updating files (add filePath and contentType attributes)
|
||||
- If updating a file it should have an update actionType attribute
|
||||
|
||||
File Action Rules:
|
||||
- Only include new/modified files
|
||||
|
@ -357,12 +357,14 @@ export class StreamingMessageParser {
|
||||
}
|
||||
} else if (actionType === 'file') {
|
||||
const filePath = this.#extractAttribute(actionTag, 'filePath') as string;
|
||||
const actionType = this.#extractAttribute(actionTag, 'actionType') as 'create' | 'update';
|
||||
|
||||
if (!filePath) {
|
||||
logger.debug('File path not specified');
|
||||
}
|
||||
|
||||
(actionAttributes as FileAction).filePath = filePath;
|
||||
(actionAttributes as FileAction).actionType = actionType || 'create'; // default to 'create' if not specified
|
||||
} else if (!['shell', 'start'].includes(actionType)) {
|
||||
logger.warn(`Unknown action type '${actionType}'`);
|
||||
}
|
||||
|
@ -9,6 +9,7 @@ export interface BaseAction {
|
||||
export interface FileAction extends BaseAction {
|
||||
type: 'file';
|
||||
filePath: string;
|
||||
actionType: 'create' | 'update';
|
||||
}
|
||||
|
||||
export interface ShellAction extends BaseAction {
|
||||
@ -70,6 +71,8 @@ export interface FileHistory {
|
||||
timestamp: number;
|
||||
content: string;
|
||||
}[];
|
||||
additions?: number;
|
||||
deletions?: number;
|
||||
|
||||
// Novo campo para rastrear a origem das mudanças
|
||||
changeSource?: 'user' | 'auto-save' | 'external';
|
||||
|
@ -1,11 +1,8 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { motion, type HTMLMotionProps, type Variants } from 'framer-motion';
|
||||
import { computed } from 'nanostores';
|
||||
import { memo, useCallback, useEffect, useState, useMemo } from 'react';
|
||||
import { memo, useCallback, useEffect, useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Popover, Transition } from '@headlessui/react';
|
||||
import { diffLines, type Change } from 'diff';
|
||||
import { getLanguageFromExtension } from '~/shared/utils/getLanguageFromExtension';
|
||||
import type { FileHistory } from '~/shared/types/actions';
|
||||
import { DiffView } from './ui/DiffView';
|
||||
import {
|
||||
@ -26,6 +23,7 @@ import { PushToGitHubDialog } from '~/settings/tabs/connections/components/PushT
|
||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
||||
import { chatStore } from '~/chat/stores/chat';
|
||||
import type { ElementInfo } from './ui/Inspector';
|
||||
import { FileModifiedDropdown } from './ui/FileModifiedDropdown';
|
||||
|
||||
interface WorkspaceProps {
|
||||
chatStarted?: boolean;
|
||||
@ -71,212 +69,6 @@ const workbenchVariants = {
|
||||
},
|
||||
} satisfies Variants;
|
||||
|
||||
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-item-contentDefault">
|
||||
<span>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, metadata, updateChatMestaData, setSelectedElement }: WorkspaceProps) => {
|
||||
renderLogger.trace('Workbench');
|
||||
@ -296,6 +88,7 @@ export const Workbench = memo(
|
||||
const selectedView = useStore(workbenchStore.currentView);
|
||||
const { showChat } = useStore(chatStore);
|
||||
const canHideChat = showWorkbench || !showChat;
|
||||
const [wasStreaming, setWasStreaming] = useState(false);
|
||||
|
||||
const isSmallViewport = useViewport(1024);
|
||||
|
||||
@ -303,6 +96,17 @@ export const Workbench = memo(
|
||||
workbenchStore.currentView.set(view);
|
||||
};
|
||||
|
||||
// Track streaming state changes
|
||||
useEffect(() => {
|
||||
if (isStreaming) {
|
||||
setWasStreaming(true);
|
||||
} else if (wasStreaming && hasPreview) {
|
||||
// Streaming just stopped and we have a preview
|
||||
setSelectedView('preview');
|
||||
setWasStreaming(false);
|
||||
}
|
||||
}, [isStreaming, wasStreaming, hasPreview]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasPreview) {
|
||||
setSelectedView('preview');
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { memo, useMemo, useState, useEffect, useCallback } from 'react';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { workbenchStore } from '~/workbench/stores/workbench';
|
||||
import { debounce } from '~/shared/utils/debounce'; // Import debounce
|
||||
import type { FileMap } from '~/workbench/stores/files';
|
||||
import type { EditorDocument } from '~/workbench/components/editor/codemirror/CodeMirrorEditor';
|
||||
import { diffLines, type Change } from 'diff';
|
||||
@ -671,6 +672,13 @@ export const DiffView = memo(({ fileHistory, setFileHistory }: DiffViewProps) =>
|
||||
const currentDocument = useStore(workbenchStore.currentDocument) as EditorDocument;
|
||||
const unsavedFiles = useStore(workbenchStore.unsavedFiles);
|
||||
|
||||
const debouncedSetFileHistory = useCallback(
|
||||
debounce((newHistoryEntry: FileHistory) => {
|
||||
setFileHistory((prev) => ({ ...prev, [selectedFile!]: newHistoryEntry }));
|
||||
}, 300), // 300ms debounce
|
||||
[setFileHistory, selectedFile],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedFile && currentDocument) {
|
||||
const file = files[selectedFile];
|
||||
@ -692,21 +700,34 @@ export const DiffView = memo(({ fileHistory, setFileHistory }: DiffViewProps) =>
|
||||
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',
|
||||
const stats = newChanges.reduce(
|
||||
(acc, change) => {
|
||||
if (change.added) {
|
||||
acc.additions += change.count || 0;
|
||||
}
|
||||
|
||||
if (change.removed) {
|
||||
acc.deletions += change.count || 0;
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
}));
|
||||
{ additions: 0, deletions: 0 },
|
||||
);
|
||||
debouncedSetFileHistory({
|
||||
originalContent: file.content,
|
||||
lastModified: Date.now(),
|
||||
changes: newChanges,
|
||||
versions: [
|
||||
{
|
||||
timestamp: Date.now(),
|
||||
content: currentContent,
|
||||
},
|
||||
],
|
||||
additions: stats.additions,
|
||||
deletions: stats.deletions,
|
||||
changeSource: 'auto-save',
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
@ -733,6 +754,22 @@ export const DiffView = memo(({ fileHistory, setFileHistory }: DiffViewProps) =>
|
||||
);
|
||||
|
||||
if (hasSignificantChanges) {
|
||||
const overallChanges = diffLines(existingHistory.originalContent, currentContent);
|
||||
const stats = overallChanges.reduce(
|
||||
(acc, change) => {
|
||||
if (change.added) {
|
||||
acc.additions += change.count || 0;
|
||||
}
|
||||
|
||||
if (change.removed) {
|
||||
acc.deletions += change.count || 0;
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
{ additions: 0, deletions: 0 },
|
||||
);
|
||||
|
||||
const newHistory: FileHistory = {
|
||||
originalContent: existingHistory.originalContent,
|
||||
lastModified: Date.now(),
|
||||
@ -744,14 +781,16 @@ export const DiffView = memo(({ fileHistory, setFileHistory }: DiffViewProps) =>
|
||||
content: currentContent,
|
||||
},
|
||||
].slice(-10), // Manter apenas as 10 últimas versões
|
||||
additions: stats.additions,
|
||||
deletions: stats.deletions,
|
||||
changeSource: 'auto-save',
|
||||
};
|
||||
|
||||
setFileHistory((prev) => ({ ...prev, [selectedFile]: newHistory }));
|
||||
debouncedSetFileHistory(newHistory);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [selectedFile, currentDocument?.value, files, setFileHistory, unsavedFiles]);
|
||||
}, [selectedFile, currentDocument?.value, files, debouncedSetFileHistory, unsavedFiles]); // Added debouncedSetFileHistory to deps
|
||||
|
||||
if (!selectedFile || !currentDocument) {
|
||||
return (
|
||||
|
172
app/workbench/components/ui/FileModifiedDropdown.tsx
Normal file
172
app/workbench/components/ui/FileModifiedDropdown.tsx
Normal file
@ -0,0 +1,172 @@
|
||||
import { memo, useState, useMemo } from 'react';
|
||||
import { Popover, Transition } from '@headlessui/react';
|
||||
import { getLanguageFromExtension } from '~/shared/utils/getLanguageFromExtension';
|
||||
import type { FileHistory } from '~/shared/types/actions';
|
||||
import { toast } from '~/shared/components/ui/use-toast';
|
||||
|
||||
export 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-item-contentDefault">
|
||||
<span>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>
|
||||
{/* Use pre-calculated stats from history object */}
|
||||
{(history.additions !== undefined || history.deletions !== undefined) &&
|
||||
(history.additions! > 0 || history.deletions! > 0) ? (
|
||||
<div className="flex items-center gap-1 text-xs shrink-0">
|
||||
{history.additions! > 0 && (
|
||||
<span className="text-green-500">+{history.additions}</span>
|
||||
)}
|
||||
{history.deletions! > 0 && (
|
||||
<span className="text-red-500">-{history.deletions}</span>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</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>
|
||||
);
|
||||
},
|
||||
);
|
@ -68,6 +68,18 @@ export class PreviewsStore {
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
refreshAllPreviews() {
|
||||
const previews = this.previews.get();
|
||||
|
||||
previews.forEach((preview) => {
|
||||
const previewId = this.getPreviewId(preview.baseUrl);
|
||||
|
||||
if (previewId) {
|
||||
this.refreshPreview(previewId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Method to refresh a specific preview
|
||||
refreshPreview(previewId: string) {
|
||||
// Clear any pending refresh for this preview
|
||||
|
@ -238,6 +238,7 @@ export class WorkbenchStore {
|
||||
newUnsavedFiles.delete(filePath);
|
||||
|
||||
this.unsavedFiles.set(newUnsavedFiles);
|
||||
this.#previewsStore.refreshAllPreviews();
|
||||
}
|
||||
|
||||
async saveCurrentDocument() {
|
||||
@ -581,6 +582,7 @@ export class WorkbenchStore {
|
||||
|
||||
if (!isStreaming) {
|
||||
await artifact.runner.runAction(data);
|
||||
await this.saveFile(fullPath);
|
||||
this.resetAllFileModifications();
|
||||
}
|
||||
} else {
|
||||
|
Loading…
Reference in New Issue
Block a user