Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 24 additions & 3 deletions src/components/rule-preview/RulePreview.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,48 @@
import React, { useCallback, useEffect, useState } from 'react';
import { RulesBuilderService } from '../../services/rules-builder/RulesBuilderService.ts';
import { useProjectStore } from '../../store/projectStore';
import { useProjectStore, adaptableFileEnvironments } from '../../store/projectStore';
import { useTechStackStore } from '../../store/techStackStore';
import { useDependencyUpload } from '../rule-parser/useDependencyUpload';
import { RulePreviewTopbar } from './RulePreviewTopbar';
import { DependencyUpload } from './DependencyUpload.tsx';
import { MarkdownContentRenderer } from './MarkdownContentRenderer.tsx';
import type { RulesContent } from '../../services/rules-builder/RulesBuilderTypes.ts';
import { JsonContentRenderer } from '../settings-preview/SettingsPreview.tsx';
import { AIEnvironmentName } from '@/data/ai-environments.ts';

export const RulePreview: React.FC = () => {
const { selectedLibraries } = useTechStackStore();
const { projectName, projectDescription, isMultiFileEnvironment } = useProjectStore();
const { projectName, projectDescription, isMultiFileEnvironment, selectedEnvironment } =
useProjectStore();
const [markdownContent, setMarkdownContent] = useState<RulesContent[]>([]);
const [settingsContent, setSettingsContent] = useState<RulesContent[]>([]);
const [isDragging, setIsDragging] = useState(false);
const { uploadStatus, uploadDependencyFile } = useDependencyUpload();

useEffect(() => {
const shouldDisplaySettings =
adaptableFileEnvironments.has(selectedEnvironment) && isMultiFileEnvironment;

const extension =
AIEnvironmentName.GitHub && isMultiFileEnvironment ? 'instructions.md' : 'mdc';
Copy link
Author

Choose a reason for hiding this comment

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

Actually wondering whether it's a good solution or should we rather move it into some map for multiFileEnvironment paths for AIEnvironments


const settings = RulesBuilderService.generateSettingsContent(selectedLibraries);
const markdowns = RulesBuilderService.generateRulesContent(
projectName,
projectDescription,
selectedLibraries,
isMultiFileEnvironment,
extension,
);
setMarkdownContent(markdowns);
}, [selectedLibraries, projectName, projectDescription, isMultiFileEnvironment]);
setSettingsContent(shouldDisplaySettings ? settings : []);
}, [
selectedLibraries,
projectName,
projectDescription,
isMultiFileEnvironment,
selectedEnvironment,
]);

// Handle drag events
const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
Expand Down Expand Up @@ -68,6 +87,8 @@ export const RulePreview: React.FC = () => {
<RulePreviewTopbar rulesContent={markdownContent} />
{/* Dropzone overlay */}
<DependencyUpload isDragging={isDragging} uploadStatus={uploadStatus} />
{/* Settings content */}
<JsonContentRenderer jsonContent={settingsContent} />
{/* Markdown content */}
<MarkdownContentRenderer markdownContent={markdownContent} />
</div>
Expand Down
41 changes: 38 additions & 3 deletions src/components/rule-preview/RulePreviewTopbar.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from 'react';
import { useProjectStore } from '../../store/projectStore';
import { useProjectStore, adaptableFileEnvironments } from '../../store/projectStore';
import { RulesPath } from './RulesPath';
import { RulesPreviewActions } from './RulesPreviewActions';
import type { RulesContent } from '../../services/rules-builder/RulesBuilderTypes.ts';
Expand Down Expand Up @@ -37,8 +37,13 @@ const EnvButton: React.FC<EnvButtonProps> = ({
};

export const RulePreviewTopbar: React.FC<RulePreviewTopbarProps> = ({ rulesContent }) => {
const { selectedEnvironment, setSelectedEnvironment, isMultiFileEnvironment, isHydrated } =
useProjectStore();
const {
selectedEnvironment,
setSelectedEnvironment,
isMultiFileEnvironment,
isHydrated,
setMultiFileEnvironment,
} = useProjectStore();

// If state hasn't been hydrated from storage yet, don't render the selector
// This prevents the "blinking" effect when loading persisted state
Expand Down Expand Up @@ -85,6 +90,36 @@ export const RulePreviewTopbar: React.FC<RulePreviewTopbarProps> = ({ rulesConte

{/* Path display */}
<RulesPath />

{adaptableFileEnvironments.has(selectedEnvironment) && (
<div
className="flex items-center space-x-2"
role="group"
aria-labelledby="multi-file-label"
>
<div className="relative flex items-center">
<input
type="checkbox"
id="multiFileToggle"
name="multiFileToggle"
checked={isMultiFileEnvironment}
onChange={(e) => setMultiFileEnvironment(e.target.checked)}
aria-describedby="multi-file-description"
className="w-4 h-4 rounded border-gray-600 bg-gray-700 text-indigo-500 focus:ring-indigo-500 focus:ring-2 focus:ring-offset-gray-800"
/>
<label
id="multi-file-label"
htmlFor="multiFileToggle"
className="ml-2 text-sm text-gray-300 select-none cursor-pointer"
>
Split instructions into domain files
</label>
</div>
<span id="multi-file-description" className="sr-only">
When enabled, instructions will be split into separate domain-specific files
</span>
</div>
)}
</div>

{/* Right side: Action buttons */}
Expand Down
15 changes: 12 additions & 3 deletions src/components/rule-preview/RulesPath.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
import React from 'react';
import { useProjectStore } from '../../store/projectStore';
import { adaptableFileEnvironments, useProjectStore } from '../../store/projectStore';
import { aiEnvironmentConfig } from '../../data/ai-environments.ts';

export const RulesPath: React.FC = () => {
const { selectedEnvironment } = useProjectStore();
const { selectedEnvironment, isMultiFileEnvironment } = useProjectStore();

// Get the appropriate file path based on the selected format
const getFilePath = (): string => aiEnvironmentConfig[selectedEnvironment].filePath;
const shouldUseAlternativePath =
isMultiFileEnvironment && adaptableFileEnvironments.has(selectedEnvironment);

const getFilePath = (): string => {
const config = aiEnvironmentConfig[selectedEnvironment];

return shouldUseAlternativePath && config.alternativeFilePath
? config.alternativeFilePath
: config.filePath;
};

return (
<div className="text-sm text-gray-400 w-full break-all">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ import { useProjectStore } from '../../store/projectStore';

interface RulesPreviewCopyDownloadActionsProps {
rulesContent: RulesContent[];
filePath?: string;
}

export const RulesPreviewCopyDownloadActions: React.FC<RulesPreviewCopyDownloadActionsProps> = ({
rulesContent,
filePath,
}) => {
const { selectedEnvironment, isMultiFileEnvironment } = useProjectStore();
const [showCopiedMessage, setShowCopiedMessage] = useState(false);
Expand Down Expand Up @@ -85,7 +87,7 @@ export const RulesPreviewCopyDownloadActions: React.FC<RulesPreviewCopyDownloadA
} else {
content = rulesContent[0].markdown;
blob = new Blob([content], { type: 'text/markdown;charset=utf-8' });
download = getFilePath().split('/').pop() || `${selectedEnvironment}-rules.md`;
download = filePath || getFilePath().split('/').pop() || `${selectedEnvironment}-rules.md`;
}

const url = URL.createObjectURL(blob);
Expand Down
57 changes: 57 additions & 0 deletions src/components/settings-preview/SettingsPreview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import React from 'react';
import type { RulesContent } from '../../services/rules-builder/RulesBuilderTypes.ts';
import RulesPreviewCopyDownloadActions from '../rule-preview/RulesPreviewCopyDownloadActions.tsx';

// Helper function to format JSON with proper indentation and syntax highlighting
const formatJsonWithSyntaxHighlighting = (jsonContent: string): JSX.Element => {
try {
// Parse and re-stringify for proper formatting
const parsed = JSON.parse(jsonContent);
const formatted = JSON.stringify(parsed, null, 2);

// Basic syntax highlighting
return (
<code className="font-mono text-sm whitespace-pre-wrap">
{formatted.split('\n').map((line, i) => {
// Highlight keys in quotes
const highlightedLine = line.replace(
/"([^"]+)":/g,
'<span class="text-yellow-500">"$1"</span>:',
);

return <div key={i} dangerouslySetInnerHTML={{ __html: highlightedLine }} />;
})}
</code>
);
} catch (e) {
// Return error message if JSON parsing fails
return <span className="text-red-500">Invalid JSON: {String(e)}</span>;
}
};

// Component for rendering JSON content
export const JsonContentRenderer: React.FC<{ jsonContent: RulesContent[] }> = ({ jsonContent }) => {
return (
<div>
{jsonContent.map((rule, index) => (
<div
key={'jsonContent-' + index}
className="overflow-y-auto relative flex-1 p-4 mt-4 h-full min-h-0 bg-gray-900 rounded-lg"
>
<div className="absolute top-4 right-4 flex flex-wrap gap-2">
<RulesPreviewCopyDownloadActions
rulesContent={[rule]}
filePath=".vscode/settings.json"
/>
</div>

<pre className="font-mono text-sm text-gray-300 whitespace-pre-wrap">
{formatJsonWithSyntaxHighlighting(rule.markdown)}
</pre>
</div>
))}
</div>
);
};

export default JsonContentRenderer;
2 changes: 2 additions & 0 deletions src/data/ai-environments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,15 @@ export type AIEnvironment = `${AIEnvironmentName}`;
type AIEnvironmentConfig = {
[key in AIEnvironmentName]: {
filePath: string;
alternativeFilePath?: string;
docsUrl: string;
};
};

export const aiEnvironmentConfig: AIEnvironmentConfig = {
github: {
filePath: '.github/copilot-instructions.md',
alternativeFilePath: '.github/{rule}.instructions.md',
docsUrl:
'https://docs.github.com/en/copilot/customizing-copilot/adding-repository-custom-instructions-for-github-copilot',
},
Expand Down
38 changes: 37 additions & 1 deletion src/services/rules-builder/RulesBuilderService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
getLayerByStack,
getStacksByLibrary,
} from '../../data/dictionaries.ts';
import type { RulesContent } from './RulesBuilderTypes.ts';
import type { RulesContent, SettingsContent } from './RulesBuilderTypes.ts';
import type { RulesGenerationStrategy } from './RulesGenerationStrategy.ts';
import { MultiFileRulesStrategy } from './rules-generation-strategies/MultiFileRulesStrategy.ts';
import { SingleFileRulesStrategy } from './rules-generation-strategies/SingleFileRulesStrategy.ts';
Expand All @@ -28,6 +28,7 @@ export class RulesBuilderService {
projectDescription: string,
selectedLibraries: Library[],
multiFile?: boolean,
extension?: string,
): RulesContent[] {
// Group libraries by stack and layer
const librariesByStack = this.groupLibrariesByStack(selectedLibraries);
Expand All @@ -43,9 +44,44 @@ export class RulesBuilderService {
selectedLibraries,
stacksByLayer,
librariesByStack,
extension,
);
}

static generateSettingsContent(selectedLibraries: Library[]): SettingsContent[] {
const strategy = new MultiFileRulesStrategy();
const librariesByStack = this.groupLibrariesByStack(selectedLibraries);

return [
{
markdown: JSON.stringify([
{
'github.copilot.chat.codeGeneration.instructions': [
selectedLibraries.map((library) => {
const stackKey = Object.keys(librariesByStack).find((key) =>
librariesByStack[key as Stack].includes(library),
) as Stack | undefined;

const layer = stackKey ? getLayerByStack(stackKey) : '';

const fileName = strategy.createFileName({
label: `${layer} - ${stackKey} - ${library}`,
extension: 'instructions.md',
});

return {
file: `.github/${fileName}`,
};
}),
],
},
]),
label: 'Settings',
fileName: 'settings.json',
},
];
}

/**
* Groups libraries by their stack
*
Expand Down
8 changes: 7 additions & 1 deletion src/services/rules-builder/RulesBuilderTypes.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
export interface RulesContent {
markdown: string;
label: string;
fileName: `${string}.mdc`;
fileName: `${string}`;
}

export interface SettingsContent {
markdown: string;
label: string;
fileName: `${string}`;
}
1 change: 1 addition & 0 deletions src/services/rules-builder/RulesGenerationStrategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,6 @@ export interface RulesGenerationStrategy {
selectedLibraries: Library[],
stacksByLayer: Record<Layer, Stack[]>,
librariesByStack: Record<Stack, Library[]>,
extension?: string,
): RulesContent[];
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,12 @@ export class MultiFileRulesStrategy implements RulesGenerationStrategy {
selectedLibraries: Library[],
stacksByLayer: Record<Layer, Stack[]>,
librariesByStack: Record<Stack, Library[]>,
extension = 'mdc',
): RulesContent[] {
const projectMarkdown = `# AI Rules for ${projectName}\n\n${projectDescription}\n\n`;
const noSelectedLibrariesMarkdown = `---\n\n👈 Use the Rule Builder on the left or drop dependency file here`;
const projectLabel = 'Project',
projectFileName = 'project.mdc';
projectFileName = `project.${extension}`;

const markdowns: RulesContent[] = [];

Expand All @@ -38,6 +39,7 @@ export class MultiFileRulesStrategy implements RulesGenerationStrategy {
stack,
library,
libraryRules: getRulesForLibrary(library),
extension,
}),
);
});
Expand All @@ -47,19 +49,26 @@ export class MultiFileRulesStrategy implements RulesGenerationStrategy {
return markdowns;
}

createFileName = ({ label, extension = 'mdc' }: { label: string; extension?: string }) => {
const fileName: RulesContent['fileName'] = `${slugify(label)}.${extension}`;
return fileName;
};

private buildRulesContent({
libraryRules,
layer,
stack,
library,
extension = 'mdc',
}: {
libraryRules: string[];
layer: string;
stack: string;
library: string;
extension?: string;
}): RulesContent {
const label = `${layer} - ${stack} - ${library}`;
const fileName: RulesContent['fileName'] = `${slugify(`${layer}-${stack}-${library}`)}.mdc`;
const fileName = this.createFileName({ label, extension });
const content =
libraryRules.length > 0
? `${libraryRules.map((rule) => `- ${rule}`).join('\n')}`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,12 @@ export class SingleFileRulesStrategy implements RulesGenerationStrategy {
selectedLibraries: Library[],
stacksByLayer: Record<Layer, Stack[]>,
librariesByStack: Record<Stack, Library[]>,
extension = 'mdc',
): RulesContent[] {
const projectMarkdown = `# AI Rules for ${projectName}\n\n${projectDescription}\n\n`;
const noSelectedLibrariesMarkdown = `---\n\n👈 Use the Rule Builder on the left or drop dependency file here`;
const projectLabel = 'Project',
projectFileName = 'project.mdc';
projectFileName = `project.${extension}`;

let markdown = projectMarkdown;

Expand All @@ -27,7 +28,7 @@ export class SingleFileRulesStrategy implements RulesGenerationStrategy {
}

markdown += this.generateLibraryMarkdown(stacksByLayer, librariesByStack);
return [{ markdown, label: 'All Rules', fileName: 'rules.mdc' }];
return [{ markdown, label: 'All Rules', fileName: `rules.${extension}` }];
}

private generateLibraryMarkdown(
Expand Down
Loading
Loading