Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: diff-view-v2-no-conflict #1335

Merged
merged 5 commits into from
Feb 21, 2025

Conversation

Toddyclipsgg
Copy link

Overview

This pull request introduces a new DiffView component (app/components/workbench/DiffView.tsx) to the Workbench, enabling users to visually compare changes in files. The component supports both inline and side-by-side diff views, handles binary files, large files, and identical files gracefully, and integrates with the application's file history and state management. It uses the diff library for efficient diffing and shiki for syntax highlighting.

Key Features & Functionality

  • Visual Comparison: Allows users to visually compare the original content of a file with its current state, highlighting additions, deletions, and unchanged lines.
  • Inline and Side-by-Side Modes: Provides two viewing modes:
    • Inline: Shows changes in a single, unified view, with added lines marked in green and removed lines in red.
    • Side-by-Side: Displays the original and current versions of the file in two separate panels, with highlighted changes.
  • Binary and Large File Handling: Detects binary files (using a regular expression and size check) and large files (over 1MB) and displays an appropriate message instead of attempting to render a diff.
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);
};
  • Identical File Handling: Detects when files are identical after normalization (trimming and converting line endings) and displays a "Files are identical" message with a preview of the current content.
    // Normalizar quebras de linha para evitar falsos positivos
    const normalizedBefore = beforeCode.replace(/\r\n/g, '\n').trim();
    const normalizedAfter = afterCode.replace(/\r\n/g, '\n').trim();

    // Se os conteúdos são idênticos após normalização, não há mudanças
    if (normalizedBefore === normalizedAfter) {
      return {
        beforeLines: normalizedBefore.split('\n'),
        afterLines: normalizedAfter.split('\n'),
        hasChanges: false,
        lineChanges: { before: new Set(), after: new Set() },
        unifiedBlocks: []
      };
    }
  • Error Handling: Gracefully handles errors during diff processing, displaying an error message.
  } 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
    };
  }
  • Syntax Highlighting: Integrates with shiki to provide syntax highlighting for the diff view, improving readability.
  useEffect(() => {
    getHighlighter({
      themes: ['github-dark'],
      langs: ['typescript', 'javascript', 'json', 'html', 'css', 'jsx', 'tsx']
    }).then(setHighlighter);
  }, []);
  • Fullscreen Mode: Includes a button to toggle fullscreen mode for better viewing of large diffs.
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>
));
  • File History Integration: Tracks file changes and versions, storing up to 100 changes and 10 versions. This history is used to provide the "before" state for the diff.
  useEffect(() => {
    if (selectedFile && currentDocument) {
      const file = files[selectedFile];
      if (!file || !('content' in file)) return;

      const existingHistory = fileHistory[selectedFile];
      const currentContent = currentDocument.value;
      
      const relativePath = extractRelativePath(selectedFile);
      const unifiedDiff = diffFiles(
        relativePath,
        existingHistory?.originalContent || file.content,
        currentContent
      );

      if (unifiedDiff) {
        const newChanges = diffLines(
          existingHistory?.originalContent || file.content,
          currentContent
        );

        const newHistory: FileHistory = {
          originalContent: existingHistory?.originalContent || file.content,
          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]);
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';
}
  • Automatic Updates: Automatically updates the diff view when the selected file or its content changes.
  • Component Structure: The main DiffView component conditionally renders either InlineDiffComparison or SideBySideComparison based on the diffViewMode prop. These sub-components handle the actual rendering of the diffs.
  try {
    return (
      <div className="h-full overflow-hidden">
        {diffViewMode === 'inline' ? (
          <InlineDiffComparison
            beforeCode={effectiveOriginalContent}
            afterCode={currentContent}
            language={language}
            filename={selectedFile}
            lightTheme="github-light"
            darkTheme="github-dark"
          />
        ) : (
          <SideBySideComparison
            beforeCode={effectiveOriginalContent}
            afterCode={currentContent}
            language={language}
            filename={selectedFile}
            lightTheme="github-light"
            darkTheme="github-dark"
          />
        )}
      </div>
  • No Changes View: If there are no changes, a NoChangesView is rendered.
const NoChangesView = memo(({ beforeCode, language, highlighter }: { 
  beforeCode: string;
  language: string;
  highlighter: any;
}) => (
  <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: 'github-dark' })
                    .replace(/<\/?pre[^>]*>/g, '')
                    .replace(/<\/?code[^>]*>/g, '') 
                  : line 
              }} />
            </div>
          </div>
        ))}
      </div>
    </div>
  </div>
));

State Management and Data Flow

  • Uses nanostores for state management (workbenchStore).
  • Accesses the file map, selected file, current document, and unsaved files from the workbenchStore.
  • Manages file history using a local state (fileHistory) and updates it whenever the current document changes.
  • The processChanges function calculates the diff and determines if there are changes, handling binary/large files and identical 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
      };
    }

    // Normalizar quebras de linha para evitar falsos positivos
    const normalizedBefore = beforeCode.replace(/\r\n/g, '\n').trim();
    const normalizedAfter = afterCode.replace(/\r\n/g, '\n').trim();

    // Se os conteúdos são idênticos após normalização, não há mudanças
    if (normalizedBefore === normalizedAfter) {
      return {
        beforeLines: normalizedBefore.split('\n'),
        afterLines: normalizedAfter.split('\n'),
        hasChanges: false,
        lineChanges: { before: new Set(), after: new Set() },
        unifiedBlocks: []
      };
    }

    // Processar as diferenças com configurações mais precisas
    const changes = diffLines(normalizedBefore, normalizedAfter, {
      newlineIsToken: true,
      ignoreWhitespace: false,
      ignoreCase: false
    });

    // Mapear as mudanças com mais precisão
    const beforeLines = normalizedBefore.split('\n');
    const afterLines = normalizedAfter.split('\n');
    const lineChanges = {
      before: new Set<number>(),
      after: new Set<number>()
    };

    let beforeLineNumber = 0;
    let afterLineNumber = 0;

    const unifiedBlocks = changes.map(change => {
      const lines = change.value.split('\n').filter(line => line.length > 0);
      
      if (change.added) {
        lines.forEach((_, i) => lineChanges.after.add(afterLineNumber + i));
        const block = lines.map((line, i) => ({
          lineNumber: afterLineNumber + i,
          content: line,
          type: 'added' as const
        }));
        afterLineNumber += lines.length;
        return block;
      }

      if (change.removed) {
        lines.forEach((_, i) => lineChanges.before.add(beforeLineNumber + i));
        const block = lines.map((line, i) => ({
          lineNumber: beforeLineNumber + i,
          content: line,
          type: 'removed' as const
        }));
        beforeLineNumber += lines.length;
        return block;
      }

      const block = lines.map((line, i) => ({
        lineNumber: afterLineNumber + i,
        content: line,
        type: 'unchanged' as const,
        correspondingLine: beforeLineNumber + i
      }));
      beforeLineNumber += lines.length;
      afterLineNumber += lines.length;
      return block;
    }).flat();

    return {
      beforeLines,
      afterLines,
      hasChanges: lineChanges.before.size > 0 || lineChanges.after.size > 0,
      lineChanges,
      unifiedBlocks,
      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
    };
  }
};

Styling

  • Uses custom CSS classes defined in app/styles/diff-view.css for styling the diff view.
  • Provides distinct visual cues for added, removed, and unchanged lines.
  • Handles scrollbar styling for the diff panels.

Visual Diagram

graph LR
    subgraph Workbench
        A[User Interacts with Editor] --> B(Workbench.client.tsx);
        B --> C{workbenchStore};
        C --> D[Selected File];
        C --> E[Current Document];
        C --> F[File Map];
        C --> G[Unsaved Files];
        B --> H[DiffView.tsx];
    end

    subgraph DiffView.tsx
        H --> I{diffViewMode?};
        I -- Inline --> J[InlineDiffComparison];
        I -- Side-by-Side --> K[SideBySideComparison];
        J --> L((processChanges));
        K --> L;
        L --> M{Has Changes?};
        M -- Yes --> N[Render Diff Blocks];
        M -- No --> O[NoChangesView];
        N --> P[Syntax Highlighting (shiki)];
        O --> P
        J --> R[FullscreenOverlay]
        K --> R
        style R fill:#f9f,stroke:#333,stroke-width:2px
    end
    
     subgraph External Libraries
        P --> S[shiki];
        L --> T[diffLines (diff library)];
    end

    D --> H;
    E --> H;
    F --> H;
    G --> H;
    H --> U[FileHistory State];

Explanation of the Diagram:

  1. User Interaction: The user interacts with the editor within the Workbench.client.tsx component.
  2. Workbench State: Workbench.client.tsx interacts with the workbenchStore, which holds the application's state, including the selected file, current document, file map, and unsaved files.
  3. DiffView Trigger: Workbench.client.tsx renders the DiffView.tsx component.
  4. DiffView Logic: DiffView.tsx receives data from the workbenchStore and determines which diff mode to display (inline or side-by-side).
  5. Diff Processing: The processChanges function (using the diff library) calculates the diff between the original and current file content.
  6. Conditional Rendering: Based on whether changes exist, either the diff blocks (using shiki for highlighting) or the NoChangesView are rendered.
  7. Fullscreen: Both Inline and Side-by-side views can be displayed in fullscreen.
  8. File History: DiffView.tsx also manages and interacts with the FileHistory state.
Meu.Video-1.mp4

- Implemented a new Diff view in the Workbench to track file changes
- Added file history tracking with version control and change tracking
- Created a FileModifiedDropdown to browse and manage modified files
- Enhanced ActionRunner to support file history persistence
- Updated Workbench and BaseChat components to support new diff view functionality
- Added support for inline and side-by-side diff view modes
@Toddyclipsgg Toddyclipsgg mentioned this pull request Feb 17, 2025
@Toddyclipsgg
Copy link
Author

I'm done here if you have anything to talk about now is the time! @thecodacus @leex279 @coleam00 @wonderwhy-er @dustinwloring1988 @Stijnus

@leex279 leex279 requested review from leex279 and xKevIsDev February 21, 2025 08:07
Copy link
Collaborator

@leex279 leex279 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me. Everything working :) Nice work.

@xKevIsDev can you verfiy/check the code part as well and merge?

@xKevIsDev
Copy link
Collaborator

that is pretty cool, testing this out just now

Copy link
Collaborator

@xKevIsDev xKevIsDev left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good on my end. This is a awesome feature thanks @Toddyclipsgg, Merging now.

@xKevIsDev xKevIsDev merged commit 871aefb into stackblitz-labs:main Feb 21, 2025
1 check passed
xKevIsDev added a commit that referenced this pull request Feb 21, 2025
This reverts commit 871aefb, reversing
changes made to 8c72ed7.
@xKevIsDev
Copy link
Collaborator

Thanks for your contribution @Toddyclipsgg! I had to revert this PR due to a build issue, but the fix is straightforward. Could you please submit a new PR with these changes:

  1. Remove this line from vite.config.ts:
'module': {},
  1. Add back these files that were accidentally removed:
- .tool-versions
- wrangler.toml

Everything else in your changes looks great! The new PR should be identical to this one with these adjustments.

Please make sure to test the build locally before submitting. Let me know if you need any help with the new PR. 👍

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants