mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-22 11:43:07 +00:00
* fix(copilot): correct tool.execution_complete event handling The CopilotProvider was using incorrect event type and data structure for tool execution completion events from the @github/copilot-sdk, causing tool call outputs to be empty. Changes: - Update event type from 'tool.execution_end' to 'tool.execution_complete' - Fix data structure to use nested result.content instead of flat result - Fix error structure to use error.message instead of flat error - Add success field to match SDK event structure - Add tests for empty and missing result handling This aligns with the official @github/copilot-sdk v0.1.16 types defined in session-events.d.ts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test(copilot): add edge case test for error with code field Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor(copilot): improve error handling and code quality Code review improvements: - Extract magic string '[ERROR]' to TOOL_ERROR_PREFIX constant - Add null-safe error handling with direct error variable assignment - Include error codes in error messages for better debugging - Add JSDoc documentation for tool.execution_complete handler - Update tests to verify error codes are displayed - Add missing tool_use_id assertion in error test These changes improve: - Code maintainability (no magic strings) - Debugging experience (error codes now visible) - Type safety (explicit null checks) - Test coverage (verify error code formatting) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Changes from fix/bug-fixes-1-0 * test(copilot): add edge case test for error with code field Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Changes from fix/bug-fixes-1-0 * fix: Handle detached HEAD state in worktree discovery and recovery * fix: Remove unused isDevServerStarting prop and md: breakpoint classes * fix: Add missing dependency and sanitize persisted cache data * feat: Ensure NODE_ENV is set to test in vitest configs * feat: Configure Playwright to run only E2E tests * fix: Improve PR tracking and dev server lifecycle management * feat: Add settings-based defaults for planning mode, model config, and custom providers. Fixes #816 * feat: Add worktree and branch selector to graph view * fix: Add timeout and error handling for worktree HEAD ref resolution * fix: use absolute icon path and place icon outside asar on Linux The hicolor icon theme index only lists sizes up to 512x512, so an icon installed only at 1024x1024 is invisible to GNOME/KDE's theme resolver, causing both the app launcher and taskbar to show a generic icon. Additionally, BrowserWindow.icon cannot be read by the window manager when the file is inside app.asar. - extraResources: copy logo_larger.png to resources/ (outside asar) so it lands at /opt/Automaker/resources/logo_larger.png on install - linux.desktop.Icon: set to the absolute resources path, bypassing the hicolor theme lookup and its size constraints entirely - icon-manager.ts: on Linux production use process.resourcesPath so BrowserWindow receives a real filesystem path the WM can read directly Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: use linux.desktop.entry for custom desktop Icon field electron-builder v26 rejects arbitrary keys in linux.desktop — the correct schema wraps custom .desktop overrides inside desktop.entry. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: set desktop name on Linux so taskbar uses the correct app icon Without app.setDesktopName(), the window manager cannot associate the running Electron process with automaker.desktop. GNOME/KDE fall back to _NET_WM_ICON which defaults to Electron's own bundled icon. Calling app.setDesktopName('automaker.desktop') before any window is created sets the _GTK_APPLICATION_ID hint and XDG app_id so the WM picks up the desktop entry's Icon for the taskbar. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix: memory and context views mobile friendly (#818) * Changes from fix/memory-and-context-mobile-friendly * fix: Improve file extension detection and add path traversal protection * refactor: Extract file extension utilities and add path traversal guards Code review improvements: - Extract isMarkdownFilename and isImageFilename to shared image-utils.ts - Remove duplicated code from context-view.tsx and memory-view.tsx - Add path traversal guard for context fixture utilities (matching memory) - Add 7 new tests for context fixture path traversal protection - Total 61 tests pass Addresses code review feedback from PR #813 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: Add e2e tests for profiles crud and board background persistence * Update apps/ui/playwright.config.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * fix: Add robust test navigation handling and file filtering * fix: Format NODE_OPTIONS configuration on single line * test: Update profiles and board background persistence tests * test: Replace iPhone 13 Pro with Pixel 5 for mobile test consistency * Update apps/ui/src/components/views/context-view.tsx Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * chore: Remove test project directory * feat: Filter context files by type and improve mobile menu visibility --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * fix: Improve test reliability and localhost handling * chore: Use explicit TEST_USE_EXTERNAL_BACKEND env var for server cleanup * feat: Add E2E/CI mock mode for provider factory and auth verification * feat: Add remoteBranch parameter to pull and rebase operations * chore: Enhance E2E testing setup with worker isolation and auth state management - Updated .gitignore to include worker-specific test fixtures. - Modified e2e-tests.yml to implement test sharding for improved CI performance. - Refactored global setup to authenticate once and save session state for reuse across tests. - Introduced worker-isolated fixture paths to prevent conflicts during parallel test execution. - Improved test navigation and loading handling for better reliability. - Updated various test files to utilize new auth state management and fixture paths. * fix: Update Playwright configuration and improve test reliability - Increased the number of workers in Playwright configuration for better parallelism in CI environments. - Enhanced the board background persistence test to ensure dropdown stability by waiting for the list to populate before interaction, improving test reliability. * chore: Simplify E2E test configuration and enhance mock implementations - Updated e2e-tests.yml to run tests in a single shard for streamlined CI execution. - Enhanced unit tests for worktree list handling by introducing a mock for execGitCommand, improving test reliability and coverage. - Refactored setup functions to better manage command mocks for git operations in tests. - Improved error handling in mkdirSafe function to account for undefined stats in certain environments. * refactor: Improve test configurations and enhance error handling - Updated Playwright configuration to clear VITE_SERVER_URL, ensuring the frontend uses the Vite proxy and preventing cookie domain mismatches. - Enhanced MergeRebaseDialog logic to normalize selectedBranch for better handling of various ref formats. - Improved global setup with a more robust backend health check, throwing an error if the backend is not healthy after retries. - Refactored project creation tests to handle file existence checks more reliably. - Added error handling for missing E2E source fixtures to guide setup process. - Enhanced memory navigation to handle sandbox dialog visibility more effectively. * refactor: Enhance Git command execution and improve test configurations - Updated Git command execution to merge environment paths correctly, ensuring proper command execution context. - Refactored the Git initialization process to handle errors more gracefully and ensure user configuration is set before creating the initial commit. - Improved test configurations by updating Playwright test identifiers for better clarity and consistency across different project states. - Enhanced cleanup functions in tests to handle directory removal more robustly, preventing errors during test execution. * fix: Resolve React hooks errors from duplicate instances in dependency tree * style: Format alias configuration for improved readability --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: DhanushSantosh <dhanushsantoshs05@gmail.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
1295 lines
46 KiB
TypeScript
1295 lines
46 KiB
TypeScript
import { useEffect, useState, useCallback, useMemo, useRef } from 'react';
|
|
import { createLogger } from '@automaker/utils/logger';
|
|
import { useAppStore } from '@/store/app-store';
|
|
import { getElectronAPI } from '@/lib/electron';
|
|
import { getHttpApiClient } from '@/lib/http-api-client';
|
|
import { toast } from 'sonner';
|
|
import { Button } from '@/components/ui/button';
|
|
import { HotkeyButton } from '@/components/ui/hotkey-button';
|
|
import { Card } from '@/components/ui/card';
|
|
import {
|
|
HeaderActionsPanel,
|
|
HeaderActionsPanelTrigger,
|
|
} from '@/components/ui/header-actions-panel';
|
|
import {
|
|
FileText,
|
|
Image as ImageIcon,
|
|
Trash2,
|
|
Save,
|
|
Upload,
|
|
File,
|
|
BookOpen,
|
|
Eye,
|
|
Pencil,
|
|
FilePlus,
|
|
FileUp,
|
|
MoreVertical,
|
|
ArrowLeft,
|
|
} from 'lucide-react';
|
|
import { Spinner } from '@/components/ui/spinner';
|
|
import { useIsMobile } from '@/hooks/use-media-query';
|
|
import {
|
|
useKeyboardShortcuts,
|
|
useKeyboardShortcutsConfig,
|
|
KeyboardShortcut,
|
|
} from '@/hooks/use-keyboard-shortcuts';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
} from '@/components/ui/dialog';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { cn } from '@/lib/utils';
|
|
import { sanitizeFilename, isMarkdownFilename, isImageFilename } from '@/lib/image-utils';
|
|
import { Markdown } from '../ui/markdown';
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from '@/components/ui/dropdown-menu';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
|
|
const logger = createLogger('ContextView');
|
|
|
|
// Responsive layout classes
|
|
const FILE_LIST_BASE_CLASSES = 'border-r border-border flex flex-col overflow-hidden';
|
|
const FILE_LIST_DESKTOP_CLASSES = 'w-64';
|
|
const FILE_LIST_EXPANDED_CLASSES = 'flex-1';
|
|
const FILE_LIST_MOBILE_NO_SELECTION_CLASSES = 'w-full border-r-0';
|
|
const FILE_LIST_MOBILE_SELECTION_CLASSES = 'hidden';
|
|
|
|
const EDITOR_PANEL_BASE_CLASSES = 'flex-1 flex flex-col overflow-hidden';
|
|
const EDITOR_PANEL_MOBILE_HIDDEN_CLASSES = 'hidden';
|
|
|
|
interface ContextFile {
|
|
name: string;
|
|
type: 'text' | 'image';
|
|
content?: string;
|
|
path: string;
|
|
description?: string;
|
|
}
|
|
|
|
interface ContextMetadata {
|
|
files: Record<string, { description: string }>;
|
|
}
|
|
|
|
export function ContextView() {
|
|
const { currentProject } = useAppStore();
|
|
const shortcuts = useKeyboardShortcutsConfig();
|
|
const [contextFiles, setContextFiles] = useState<ContextFile[]>([]);
|
|
const [selectedFile, setSelectedFile] = useState<ContextFile | null>(null);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
const [hasChanges, setHasChanges] = useState(false);
|
|
const [editedContent, setEditedContent] = useState('');
|
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
|
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
|
|
const [renameFileName, setRenameFileName] = useState('');
|
|
const [isDropHovering, setIsDropHovering] = useState(false);
|
|
const [isPreviewMode, setIsPreviewMode] = useState(false);
|
|
const [isUploading, setIsUploading] = useState(false);
|
|
const [uploadingFileName, setUploadingFileName] = useState<string | null>(null);
|
|
|
|
// Create Markdown modal state
|
|
const [isCreateMarkdownOpen, setIsCreateMarkdownOpen] = useState(false);
|
|
const [newMarkdownName, setNewMarkdownName] = useState('');
|
|
const [newMarkdownDescription, setNewMarkdownDescription] = useState('');
|
|
const [newMarkdownContent, setNewMarkdownContent] = useState('');
|
|
|
|
// Track files with generating descriptions (async)
|
|
const [generatingDescriptions, setGeneratingDescriptions] = useState<Set<string>>(new Set());
|
|
|
|
// Edit description modal state
|
|
const [isEditDescriptionOpen, setIsEditDescriptionOpen] = useState(false);
|
|
const [editDescriptionValue, setEditDescriptionValue] = useState('');
|
|
const [editDescriptionFileName, setEditDescriptionFileName] = useState('');
|
|
|
|
// Actions panel state (for tablet/mobile)
|
|
const [showActionsPanel, setShowActionsPanel] = useState(false);
|
|
|
|
// File input ref for import
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
// Mobile detection
|
|
const isMobile = useIsMobile();
|
|
|
|
// Keyboard shortcuts for this view
|
|
const contextShortcuts: KeyboardShortcut[] = useMemo(
|
|
() => [
|
|
{
|
|
key: shortcuts.addContextFile,
|
|
action: () => setIsCreateMarkdownOpen(true),
|
|
description: 'Create new markdown file',
|
|
},
|
|
],
|
|
[shortcuts]
|
|
);
|
|
useKeyboardShortcuts(contextShortcuts);
|
|
|
|
// Get context directory path for user-added context files
|
|
const getContextPath = useCallback(() => {
|
|
if (!currentProject) return null;
|
|
return `${currentProject.path}/.automaker/context`;
|
|
}, [currentProject]);
|
|
|
|
// Load context metadata
|
|
const loadMetadata = useCallback(async (): Promise<ContextMetadata> => {
|
|
const contextPath = getContextPath();
|
|
if (!contextPath) return { files: {} };
|
|
|
|
try {
|
|
const api = getElectronAPI();
|
|
const metadataPath = `${contextPath}/context-metadata.json`;
|
|
const result = await api.readFile(metadataPath);
|
|
if (result.success && result.content) {
|
|
return JSON.parse(result.content);
|
|
}
|
|
} catch {
|
|
// Metadata file doesn't exist yet
|
|
}
|
|
return { files: {} };
|
|
}, [getContextPath]);
|
|
|
|
// Save context metadata
|
|
const saveMetadata = useCallback(
|
|
async (metadata: ContextMetadata) => {
|
|
const contextPath = getContextPath();
|
|
if (!contextPath) return;
|
|
|
|
try {
|
|
const api = getElectronAPI();
|
|
const metadataPath = `${contextPath}/context-metadata.json`;
|
|
await api.writeFile(metadataPath, JSON.stringify(metadata, null, 2));
|
|
} catch (error) {
|
|
logger.error('Failed to save metadata:', error);
|
|
}
|
|
},
|
|
[getContextPath]
|
|
);
|
|
|
|
// Load context files
|
|
const loadContextFiles = useCallback(async () => {
|
|
const contextPath = getContextPath();
|
|
if (!contextPath) return;
|
|
|
|
setIsLoading(true);
|
|
try {
|
|
const api = getElectronAPI();
|
|
|
|
// Ensure context directory exists
|
|
await api.mkdir(contextPath);
|
|
|
|
// Ensure metadata file exists (create empty one if not)
|
|
const metadataPath = `${contextPath}/context-metadata.json`;
|
|
const metadataExists = await api.exists(metadataPath);
|
|
if (!metadataExists) {
|
|
await api.writeFile(metadataPath, JSON.stringify({ files: {} }, null, 2));
|
|
}
|
|
|
|
// Load metadata for descriptions
|
|
const metadata = await loadMetadata();
|
|
|
|
// Read directory contents
|
|
const result = await api.readdir(contextPath);
|
|
if (result.success && result.entries) {
|
|
const files: ContextFile[] = result.entries
|
|
.filter(
|
|
(entry) =>
|
|
entry.isFile &&
|
|
entry.name !== 'context-metadata.json' &&
|
|
(isMarkdownFilename(entry.name) || isImageFilename(entry.name))
|
|
)
|
|
.map((entry) => ({
|
|
name: entry.name,
|
|
type: isImageFilename(entry.name) ? 'image' : 'text',
|
|
path: `${contextPath}/${entry.name}`,
|
|
description: metadata.files[entry.name]?.description,
|
|
}));
|
|
setContextFiles(files);
|
|
}
|
|
} catch (error) {
|
|
logger.error('Failed to load context files:', error);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, [getContextPath, loadMetadata]);
|
|
|
|
useEffect(() => {
|
|
loadContextFiles();
|
|
}, [loadContextFiles]);
|
|
|
|
// Load selected file content
|
|
const loadFileContent = useCallback(async (file: ContextFile) => {
|
|
try {
|
|
const api = getElectronAPI();
|
|
const result = await api.readFile(file.path);
|
|
if (result.success && result.content !== undefined) {
|
|
setEditedContent(result.content);
|
|
setSelectedFile({ ...file, content: result.content });
|
|
setHasChanges(false);
|
|
}
|
|
} catch (error) {
|
|
logger.error('Failed to load file content:', error);
|
|
}
|
|
}, []);
|
|
|
|
// Select a file
|
|
const handleSelectFile = (file: ContextFile) => {
|
|
// Note: Unsaved changes warning could be added here in the future
|
|
// For now, silently proceed to avoid disrupting mobile UX flow
|
|
// Set selected file immediately for responsive UI feedback,
|
|
// then load content asynchronously
|
|
setSelectedFile(file);
|
|
setEditedContent(file.content || '');
|
|
setHasChanges(false);
|
|
setIsPreviewMode(isMarkdownFilename(file.name));
|
|
loadFileContent(file);
|
|
};
|
|
|
|
// Save current file
|
|
const saveFile = async () => {
|
|
if (!selectedFile) return;
|
|
|
|
setIsSaving(true);
|
|
try {
|
|
const api = getElectronAPI();
|
|
await api.writeFile(selectedFile.path, editedContent);
|
|
setSelectedFile({ ...selectedFile, content: editedContent });
|
|
setHasChanges(false);
|
|
} catch (error) {
|
|
logger.error('Failed to save file:', error);
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
};
|
|
|
|
// Handle content change
|
|
const handleContentChange = (value: string) => {
|
|
setEditedContent(value);
|
|
setHasChanges(true);
|
|
};
|
|
|
|
// Generate description for a file
|
|
const generateDescription = async (
|
|
filePath: string,
|
|
fileName: string,
|
|
isImage: boolean
|
|
): Promise<string | undefined> => {
|
|
try {
|
|
const httpClient = getHttpApiClient();
|
|
const result = isImage
|
|
? await httpClient.context.describeImage(filePath)
|
|
: await httpClient.context.describeFile(filePath);
|
|
|
|
if (result.success && result.description) {
|
|
return result.description;
|
|
}
|
|
|
|
const message =
|
|
result.error || `Automaker couldn't generate a description for “${fileName}”.`;
|
|
toast.error('Failed to generate description', { description: message });
|
|
} catch (error) {
|
|
logger.error('Failed to generate description:', error);
|
|
const message =
|
|
error instanceof Error
|
|
? error.message
|
|
: 'An unexpected error occurred while generating the description.';
|
|
toast.error('Failed to generate description', { description: message });
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
// Generate description in background and update metadata
|
|
const generateDescriptionAsync = useCallback(
|
|
async (filePath: string, fileName: string, isImage: boolean) => {
|
|
// Add to generating set
|
|
setGeneratingDescriptions((prev) => new Set(prev).add(fileName));
|
|
|
|
try {
|
|
const description = await generateDescription(filePath, fileName, isImage);
|
|
|
|
if (description) {
|
|
const metadata = await loadMetadata();
|
|
metadata.files[fileName] = { description };
|
|
await saveMetadata(metadata);
|
|
|
|
// Reload files to update UI with new description
|
|
await loadContextFiles();
|
|
|
|
// Also update selectedFile if it's the one that just got described
|
|
setSelectedFile((current) => {
|
|
if (current?.name === fileName) {
|
|
return { ...current, description };
|
|
}
|
|
return current;
|
|
});
|
|
}
|
|
} catch (error) {
|
|
logger.error('Failed to generate description:', error);
|
|
} finally {
|
|
// Remove from generating set
|
|
setGeneratingDescriptions((prev) => {
|
|
const next = new Set(prev);
|
|
next.delete(fileName);
|
|
return next;
|
|
});
|
|
}
|
|
},
|
|
[loadMetadata, saveMetadata, loadContextFiles]
|
|
);
|
|
|
|
// Upload a file and generate description asynchronously
|
|
const uploadFile = async (file: globalThis.File) => {
|
|
const contextPath = getContextPath();
|
|
if (!contextPath) return;
|
|
|
|
setIsUploading(true);
|
|
setUploadingFileName(file.name);
|
|
|
|
try {
|
|
const api = getElectronAPI();
|
|
const isImage = isImageFilename(file.name);
|
|
|
|
let filePath: string;
|
|
let fileName: string;
|
|
let imagePathForDescription: string | undefined;
|
|
|
|
if (isImage) {
|
|
// For images: sanitize filename, store in .automaker/images
|
|
fileName = sanitizeFilename(file.name);
|
|
|
|
// Read file as base64
|
|
const dataUrl = await new Promise<string>((resolve) => {
|
|
const reader = new FileReader();
|
|
reader.onload = (event) => resolve(event.target?.result as string);
|
|
reader.readAsDataURL(file);
|
|
});
|
|
|
|
// Extract base64 data without the data URL prefix
|
|
const base64Data = dataUrl.split(',')[1] || dataUrl;
|
|
|
|
// Determine mime type from original file
|
|
const mimeType = file.type || 'image/png';
|
|
|
|
// Use saveImageToTemp to properly save as binary file in .automaker/images
|
|
const saveResult = await api.saveImageToTemp?.(
|
|
base64Data,
|
|
fileName,
|
|
mimeType,
|
|
currentProject!.path
|
|
);
|
|
|
|
if (!saveResult?.success || !saveResult.path) {
|
|
throw new Error(saveResult?.error || 'Failed to save image');
|
|
}
|
|
|
|
// The saved image path is used for description
|
|
imagePathForDescription = saveResult.path;
|
|
|
|
// Also save to context directory for display in the UI
|
|
// (as a data URL for inline display)
|
|
filePath = `${contextPath}/${fileName}`;
|
|
await api.writeFile(filePath, dataUrl);
|
|
} else {
|
|
// For non-images: keep original behavior
|
|
fileName = file.name;
|
|
filePath = `${contextPath}/${fileName}`;
|
|
|
|
const content = await new Promise<string>((resolve) => {
|
|
const reader = new FileReader();
|
|
reader.onload = (event) => resolve(event.target?.result as string);
|
|
reader.readAsText(file);
|
|
});
|
|
|
|
await api.writeFile(filePath, content);
|
|
}
|
|
|
|
// Reload files immediately (file appears in list without description)
|
|
await loadContextFiles();
|
|
|
|
// Start description generation in background (don't await)
|
|
// For images, use the path in the images directory
|
|
generateDescriptionAsync(imagePathForDescription || filePath, fileName, isImage);
|
|
} catch (error) {
|
|
logger.error('Failed to upload file:', error);
|
|
toast.error('Failed to upload file', {
|
|
description: error instanceof Error ? error.message : 'Unknown error',
|
|
});
|
|
} finally {
|
|
setIsUploading(false);
|
|
setUploadingFileName(null);
|
|
}
|
|
};
|
|
|
|
// Handle file drop
|
|
const handleDrop = async (e: React.DragEvent<HTMLDivElement>) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setIsDropHovering(false);
|
|
|
|
const files = Array.from(e.dataTransfer.files);
|
|
if (files.length === 0) return;
|
|
|
|
// Process files sequentially
|
|
for (const file of files) {
|
|
await uploadFile(file);
|
|
}
|
|
};
|
|
|
|
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setIsDropHovering(true);
|
|
};
|
|
|
|
const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setIsDropHovering(false);
|
|
};
|
|
|
|
// Handle file import via button
|
|
const handleImportClick = () => {
|
|
fileInputRef.current?.click();
|
|
};
|
|
|
|
const handleFileInputChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const files = e.target.files;
|
|
if (!files || files.length === 0) return;
|
|
|
|
for (const file of Array.from(files)) {
|
|
await uploadFile(file);
|
|
}
|
|
|
|
// Reset input
|
|
if (fileInputRef.current) {
|
|
fileInputRef.current.value = '';
|
|
}
|
|
};
|
|
|
|
// Handle create markdown
|
|
const handleCreateMarkdown = async () => {
|
|
const contextPath = getContextPath();
|
|
if (!contextPath || !newMarkdownName.trim()) return;
|
|
|
|
try {
|
|
const api = getElectronAPI();
|
|
let filename = newMarkdownName.trim();
|
|
|
|
// Add .md extension if not provided
|
|
if (!filename.includes('.')) {
|
|
filename += '.md';
|
|
}
|
|
|
|
const filePath = `${contextPath}/${filename}`;
|
|
|
|
// Write markdown file
|
|
await api.writeFile(filePath, newMarkdownContent);
|
|
|
|
// Save description if provided
|
|
if (newMarkdownDescription.trim()) {
|
|
const metadata = await loadMetadata();
|
|
metadata.files[filename] = { description: newMarkdownDescription.trim() };
|
|
await saveMetadata(metadata);
|
|
}
|
|
|
|
// Reload files
|
|
await loadContextFiles();
|
|
|
|
// Reset and close modal
|
|
setIsCreateMarkdownOpen(false);
|
|
setNewMarkdownName('');
|
|
setNewMarkdownDescription('');
|
|
setNewMarkdownContent('');
|
|
} catch (error) {
|
|
logger.error('Failed to create markdown:', error);
|
|
// Close dialog and reset state even on error to avoid stuck dialog
|
|
setIsCreateMarkdownOpen(false);
|
|
setNewMarkdownName('');
|
|
setNewMarkdownDescription('');
|
|
setNewMarkdownContent('');
|
|
toast.error('Failed to create markdown file', {
|
|
description: error instanceof Error ? error.message : 'Unknown error occurred',
|
|
});
|
|
}
|
|
};
|
|
|
|
// Delete selected file
|
|
const handleDeleteFile = async () => {
|
|
if (!selectedFile) return;
|
|
|
|
try {
|
|
const api = getElectronAPI();
|
|
await api.deleteFile(selectedFile.path);
|
|
|
|
// Remove from metadata
|
|
const metadata = await loadMetadata();
|
|
delete metadata.files[selectedFile.name];
|
|
await saveMetadata(metadata);
|
|
|
|
// Refresh file list before closing dialog so UI is updated when dialog dismisses
|
|
await loadContextFiles();
|
|
|
|
setIsDeleteDialogOpen(false);
|
|
setSelectedFile(null);
|
|
setEditedContent('');
|
|
setHasChanges(false);
|
|
} catch (error) {
|
|
logger.error('Failed to delete file:', error);
|
|
}
|
|
};
|
|
|
|
// Rename selected file
|
|
const handleRenameFile = async () => {
|
|
const contextPath = getContextPath();
|
|
if (!selectedFile || !contextPath || !renameFileName.trim()) return;
|
|
|
|
const newName = renameFileName.trim();
|
|
if (newName === selectedFile.name) {
|
|
setIsRenameDialogOpen(false);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const api = getElectronAPI();
|
|
const newPath = `${contextPath}/${newName}`;
|
|
|
|
// Check if file with new name already exists
|
|
const exists = await api.exists(newPath);
|
|
if (exists) {
|
|
logger.error('A file with this name already exists');
|
|
return;
|
|
}
|
|
|
|
// Read current file content
|
|
const result = await api.readFile(selectedFile.path);
|
|
if (!result.success || result.content === undefined) {
|
|
logger.error('Failed to read file for rename');
|
|
return;
|
|
}
|
|
|
|
// Write to new path
|
|
await api.writeFile(newPath, result.content);
|
|
|
|
// Delete old file
|
|
await api.deleteFile(selectedFile.path);
|
|
|
|
// Update metadata
|
|
const metadata = await loadMetadata();
|
|
if (metadata.files[selectedFile.name]) {
|
|
metadata.files[newName] = metadata.files[selectedFile.name];
|
|
delete metadata.files[selectedFile.name];
|
|
await saveMetadata(metadata);
|
|
}
|
|
|
|
setIsRenameDialogOpen(false);
|
|
setRenameFileName('');
|
|
|
|
// Reload files and select the renamed file
|
|
await loadContextFiles();
|
|
|
|
// Update selected file with new name and path
|
|
const renamedFile: ContextFile = {
|
|
name: newName,
|
|
type: isImageFilename(newName) ? 'image' : 'text',
|
|
path: newPath,
|
|
content: result.content,
|
|
description: metadata.files[newName]?.description,
|
|
};
|
|
setSelectedFile(renamedFile);
|
|
} catch (error) {
|
|
logger.error('Failed to rename file:', error);
|
|
}
|
|
};
|
|
|
|
// Save edited description
|
|
const handleSaveDescription = async () => {
|
|
if (!editDescriptionFileName) return;
|
|
|
|
try {
|
|
const metadata = await loadMetadata();
|
|
metadata.files[editDescriptionFileName] = { description: editDescriptionValue.trim() };
|
|
await saveMetadata(metadata);
|
|
|
|
// Update selected file if it's the one being edited
|
|
if (selectedFile?.name === editDescriptionFileName) {
|
|
setSelectedFile({ ...selectedFile, description: editDescriptionValue.trim() });
|
|
}
|
|
|
|
// Reload files to update list
|
|
await loadContextFiles();
|
|
|
|
setIsEditDescriptionOpen(false);
|
|
setEditDescriptionValue('');
|
|
setEditDescriptionFileName('');
|
|
} catch (error) {
|
|
logger.error('Failed to save description:', error);
|
|
}
|
|
};
|
|
|
|
// Open edit description dialog
|
|
const handleEditDescription = (file: ContextFile) => {
|
|
setEditDescriptionFileName(file.name);
|
|
setEditDescriptionValue(file.description || '');
|
|
setIsEditDescriptionOpen(true);
|
|
};
|
|
|
|
// Delete file from list (used by dropdown)
|
|
const handleDeleteFromList = async (file: ContextFile) => {
|
|
try {
|
|
const api = getElectronAPI();
|
|
await api.deleteFile(file.path);
|
|
|
|
// Remove from metadata
|
|
const metadata = await loadMetadata();
|
|
delete metadata.files[file.name];
|
|
await saveMetadata(metadata);
|
|
|
|
// Clear selection if this was the selected file
|
|
if (selectedFile?.path === file.path) {
|
|
setSelectedFile(null);
|
|
setEditedContent('');
|
|
setHasChanges(false);
|
|
}
|
|
|
|
await loadContextFiles();
|
|
} catch (error) {
|
|
logger.error('Failed to delete file:', error);
|
|
}
|
|
};
|
|
|
|
if (!currentProject) {
|
|
return (
|
|
<div
|
|
className="flex-1 flex items-center justify-center"
|
|
data-testid="context-view-no-project"
|
|
>
|
|
<p className="text-muted-foreground">No project selected</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex-1 flex items-center justify-center" data-testid="context-view-loading">
|
|
<Spinner size="lg" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex-1 flex flex-col overflow-hidden content-bg" data-testid="context-view">
|
|
{/* Hidden file input for import */}
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
multiple
|
|
className="hidden"
|
|
onChange={handleFileInputChange}
|
|
data-testid="file-import-input"
|
|
/>
|
|
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between p-4 border-b border-border bg-glass backdrop-blur-md">
|
|
<div className="flex items-center gap-3">
|
|
<BookOpen className="w-5 h-5 text-muted-foreground" />
|
|
<div>
|
|
<h1 className="text-xl font-bold">Context Files</h1>
|
|
<p className="text-sm text-muted-foreground">
|
|
Add context files to include in AI prompts
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{/* Desktop: show actions inline */}
|
|
<div className="hidden lg:flex gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={handleImportClick}
|
|
disabled={isUploading}
|
|
data-testid="import-file-button"
|
|
>
|
|
<FileUp className="w-4 h-4 mr-2" />
|
|
Import File
|
|
</Button>
|
|
<HotkeyButton
|
|
size="sm"
|
|
onClick={() => setIsCreateMarkdownOpen(true)}
|
|
hotkey={shortcuts.addContextFile}
|
|
hotkeyActive={false}
|
|
data-testid="create-markdown-button"
|
|
>
|
|
<FilePlus className="w-4 h-4 mr-2" />
|
|
Create Markdown
|
|
</HotkeyButton>
|
|
</div>
|
|
{/* Tablet/Mobile: show trigger for actions panel */}
|
|
<HeaderActionsPanelTrigger
|
|
isOpen={showActionsPanel}
|
|
onToggle={() => setShowActionsPanel(!showActionsPanel)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Actions Panel (tablet/mobile) */}
|
|
<HeaderActionsPanel
|
|
isOpen={showActionsPanel}
|
|
onClose={() => setShowActionsPanel(false)}
|
|
title="Context Actions"
|
|
>
|
|
<Button
|
|
variant="outline"
|
|
className="w-full justify-start"
|
|
onClick={() => {
|
|
handleImportClick();
|
|
setShowActionsPanel(false);
|
|
}}
|
|
disabled={isUploading}
|
|
data-testid="import-file-button-mobile"
|
|
>
|
|
<FileUp className="w-4 h-4 mr-2" />
|
|
Import File
|
|
</Button>
|
|
<Button
|
|
className="w-full justify-start"
|
|
onClick={() => {
|
|
setIsCreateMarkdownOpen(true);
|
|
setShowActionsPanel(false);
|
|
}}
|
|
data-testid="create-markdown-button-mobile"
|
|
>
|
|
<FilePlus className="w-4 h-4 mr-2" />
|
|
Create Markdown
|
|
</Button>
|
|
</HeaderActionsPanel>
|
|
|
|
{/* Main content area with file list and editor */}
|
|
<div
|
|
className={cn(
|
|
'flex-1 flex overflow-hidden relative',
|
|
isDropHovering && 'ring-2 ring-primary ring-inset'
|
|
)}
|
|
onDrop={handleDrop}
|
|
onDragOver={handleDragOver}
|
|
onDragLeave={handleDragLeave}
|
|
data-testid="context-drop-zone"
|
|
>
|
|
{/* Drop overlay */}
|
|
{isDropHovering && (
|
|
<div className="absolute inset-0 bg-primary/10 z-50 flex items-center justify-center pointer-events-none">
|
|
<div className="flex flex-col items-center text-primary">
|
|
<Upload className="w-12 h-12 mb-2" />
|
|
<span className="text-lg font-medium">Drop files to upload</span>
|
|
<span className="text-sm text-muted-foreground">
|
|
Files will be analyzed automatically
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Uploading overlay */}
|
|
{isUploading && (
|
|
<div className="absolute inset-0 bg-background/80 z-50 flex items-center justify-center">
|
|
<div className="flex flex-col items-center">
|
|
<Spinner size="xl" className="mb-2" />
|
|
<span className="text-sm font-medium">Uploading {uploadingFileName}...</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Left Panel - File List */}
|
|
{/* Mobile: Full width, hidden when file is selected (full-screen editor) */}
|
|
{/* Desktop: Fixed width w-64, expands to fill space when no file selected */}
|
|
<div
|
|
className={cn(
|
|
FILE_LIST_BASE_CLASSES,
|
|
FILE_LIST_DESKTOP_CLASSES,
|
|
!selectedFile && FILE_LIST_EXPANDED_CLASSES,
|
|
isMobile && !selectedFile && FILE_LIST_MOBILE_NO_SELECTION_CLASSES,
|
|
isMobile && selectedFile && FILE_LIST_MOBILE_SELECTION_CLASSES
|
|
)}
|
|
>
|
|
<div className="p-3 border-b border-border">
|
|
<h2 className="text-sm font-semibold text-muted-foreground">
|
|
Context Files ({contextFiles.length})
|
|
</h2>
|
|
</div>
|
|
<div className="flex-1 overflow-y-auto p-2" data-testid="context-file-list">
|
|
{contextFiles.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center h-full text-center p-4">
|
|
<Upload className="w-8 h-8 text-muted-foreground mb-2" />
|
|
<p className="text-sm text-muted-foreground">
|
|
No context files yet.
|
|
<br />
|
|
Drop files here or use the buttons above.
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-1">
|
|
{contextFiles.map((file) => {
|
|
const isGenerating = generatingDescriptions.has(file.name);
|
|
return (
|
|
<div
|
|
key={file.path}
|
|
onClick={() => handleSelectFile(file)}
|
|
className={cn(
|
|
'group w-full flex items-center gap-2 px-3 py-2 rounded-lg transition-colors cursor-pointer',
|
|
selectedFile?.path === file.path
|
|
? 'bg-primary/20 text-foreground border border-primary/30'
|
|
: 'text-muted-foreground hover:bg-accent hover:text-foreground'
|
|
)}
|
|
data-testid={`context-file-${file.name}`}
|
|
>
|
|
{file.type === 'image' ? (
|
|
<ImageIcon className="w-4 h-4 flex-shrink-0" />
|
|
) : (
|
|
<FileText className="w-4 h-4 flex-shrink-0" />
|
|
)}
|
|
<div className="min-w-0 flex-1">
|
|
<span className="truncate text-sm block">{file.name}</span>
|
|
{isGenerating ? (
|
|
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
|
<Spinner size="xs" />
|
|
Generating description...
|
|
</span>
|
|
) : file.description ? (
|
|
<span className="truncate text-xs text-muted-foreground block">
|
|
{file.description}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<button
|
|
onClick={(e) => e.stopPropagation()}
|
|
className={cn(
|
|
'p-1 hover:bg-accent rounded transition-opacity',
|
|
isMobile ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
|
|
)}
|
|
aria-label={`Actions for ${file.name}`}
|
|
aria-haspopup="menu"
|
|
data-testid={`context-file-menu-${file.name}`}
|
|
>
|
|
<MoreVertical className="w-4 h-4" />
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
<DropdownMenuItem
|
|
onClick={() => {
|
|
setRenameFileName(file.name);
|
|
setSelectedFile(file);
|
|
setIsRenameDialogOpen(true);
|
|
}}
|
|
data-testid={`rename-context-file-${file.name}`}
|
|
>
|
|
<Pencil className="w-4 h-4 mr-2" />
|
|
Rename
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
onClick={() => handleDeleteFromList(file)}
|
|
className="text-red-500 focus:text-red-500"
|
|
data-testid={`delete-context-file-${file.name}`}
|
|
>
|
|
<Trash2 className="w-4 h-4 mr-2" />
|
|
Delete
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right Panel - Editor/Preview */}
|
|
{/* Mobile: Hidden when no file selected (file list shows full screen) */}
|
|
<div
|
|
className={cn(
|
|
EDITOR_PANEL_BASE_CLASSES,
|
|
isMobile && !selectedFile && EDITOR_PANEL_MOBILE_HIDDEN_CLASSES
|
|
)}
|
|
>
|
|
{selectedFile ? (
|
|
<>
|
|
{/* File toolbar */}
|
|
<div className="flex items-center justify-between p-3 border-b border-border bg-card">
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
{/* Mobile-only: Back button to return to file list */}
|
|
{isMobile && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setSelectedFile(null)}
|
|
className="shrink-0 -ml-1"
|
|
aria-label="Back"
|
|
title="Back"
|
|
>
|
|
<ArrowLeft className="h-4 w-4" />
|
|
</Button>
|
|
)}
|
|
{selectedFile.type === 'image' ? (
|
|
<ImageIcon className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
|
) : (
|
|
<FileText className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
|
)}
|
|
<span className="text-sm font-medium truncate">{selectedFile.name}</span>
|
|
</div>
|
|
<div className={cn('flex gap-2', isMobile && 'gap-1')}>
|
|
{/* Mobile: Icon-only buttons with aria-labels for accessibility */}
|
|
{selectedFile.type === 'text' && isMarkdownFilename(selectedFile.name) && (
|
|
<Button
|
|
variant={'outline'}
|
|
size="sm"
|
|
onClick={() => setIsPreviewMode(!isPreviewMode)}
|
|
data-testid="toggle-preview-mode"
|
|
aria-label={isPreviewMode ? 'Edit' : 'Preview'}
|
|
title={isPreviewMode ? 'Edit' : 'Preview'}
|
|
>
|
|
{isPreviewMode ? (
|
|
<>
|
|
<Pencil className="w-4 h-4" />
|
|
{!isMobile && <span className="ml-2">Edit</span>}
|
|
</>
|
|
) : (
|
|
<>
|
|
<Eye className="w-4 h-4" />
|
|
{!isMobile && <span className="ml-2">Preview</span>}
|
|
</>
|
|
)}
|
|
</Button>
|
|
)}
|
|
{selectedFile.type === 'text' && (
|
|
<Button
|
|
size="sm"
|
|
onClick={saveFile}
|
|
disabled={!hasChanges || isSaving}
|
|
data-testid="save-context-file"
|
|
aria-label="Save"
|
|
title="Save"
|
|
>
|
|
<Save className="w-4 h-4" />
|
|
{!isMobile && (
|
|
<span className="ml-2">
|
|
{isSaving ? 'Saving...' : hasChanges ? 'Save' : 'Saved'}
|
|
</span>
|
|
)}
|
|
</Button>
|
|
)}
|
|
{/* Desktop-only: Delete button (use dropdown on mobile to save space) */}
|
|
{!isMobile && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setIsDeleteDialogOpen(true)}
|
|
className="text-red-500 hover:text-red-400 hover:border-red-500/50"
|
|
data-testid="delete-context-file"
|
|
aria-label="Delete"
|
|
title="Delete"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Description section */}
|
|
<div className="px-4 pt-4 pb-2">
|
|
<div className="bg-muted/50 rounded-lg p-3 border border-border">
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="flex-1 min-w-0">
|
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
|
Description
|
|
</span>
|
|
{generatingDescriptions.has(selectedFile.name) ? (
|
|
<div className="flex items-center gap-2 mt-1 text-sm text-muted-foreground">
|
|
<Spinner size="sm" />
|
|
<span>Generating description with AI...</span>
|
|
</div>
|
|
) : selectedFile.description ? (
|
|
<p className="text-sm mt-1">{selectedFile.description}</p>
|
|
) : (
|
|
<p className="text-sm text-muted-foreground mt-1 italic">
|
|
No description. Click edit to add one.
|
|
</p>
|
|
)}
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => handleEditDescription(selectedFile)}
|
|
className="flex-shrink-0"
|
|
data-testid="edit-description-button"
|
|
>
|
|
<Pencil className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Content area */}
|
|
<div className="flex-1 overflow-hidden px-4 pb-2 sm:pb-4">
|
|
{selectedFile.type === 'image' ? (
|
|
<div
|
|
className="h-full flex items-center justify-center bg-card rounded-lg"
|
|
data-testid="image-preview"
|
|
>
|
|
<img
|
|
src={editedContent}
|
|
alt={selectedFile.name}
|
|
className="max-w-full max-h-full object-contain"
|
|
/>
|
|
</div>
|
|
) : isPreviewMode ? (
|
|
<Card className="h-full overflow-auto p-4" data-testid="markdown-preview">
|
|
<Markdown>{editedContent}</Markdown>
|
|
</Card>
|
|
) : (
|
|
<Card className="h-full overflow-hidden">
|
|
<textarea
|
|
className="w-full h-full p-4 font-mono text-sm bg-transparent resize-none focus:outline-none"
|
|
value={editedContent}
|
|
onChange={(e) => handleContentChange(e.target.value)}
|
|
placeholder="Enter context content here..."
|
|
spellCheck={false}
|
|
data-testid="context-editor"
|
|
/>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="flex-1 flex items-center justify-center">
|
|
<div className="text-center">
|
|
<File className="w-12 h-12 text-muted-foreground mx-auto mb-3" />
|
|
<p className="text-foreground-secondary">Select a file to view or edit</p>
|
|
<p className="text-muted-foreground text-sm mt-1">Or drop files here to add them</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Create Markdown Dialog */}
|
|
<Dialog open={isCreateMarkdownOpen} onOpenChange={setIsCreateMarkdownOpen}>
|
|
<DialogContent
|
|
data-testid="create-markdown-dialog"
|
|
className="w-[60vw] max-w-[60vw] max-h-[80vh] flex flex-col"
|
|
>
|
|
<DialogHeader>
|
|
<DialogTitle>Create Markdown Context</DialogTitle>
|
|
<DialogDescription>
|
|
Create a new markdown file to add context for AI prompts.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-4 py-4 flex-1 overflow-auto">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="markdown-filename">File Name</Label>
|
|
<Input
|
|
id="markdown-filename"
|
|
value={newMarkdownName}
|
|
onChange={(e) => setNewMarkdownName(e.target.value)}
|
|
placeholder="context-file.md"
|
|
data-testid="new-markdown-name"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="markdown-description">
|
|
Description (for AI to understand the context)
|
|
</Label>
|
|
<Input
|
|
id="markdown-description"
|
|
value={newMarkdownDescription}
|
|
onChange={(e) => setNewMarkdownDescription(e.target.value)}
|
|
placeholder="e.g., Coding style guidelines for this project"
|
|
data-testid="new-markdown-description"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="markdown-content">Content</Label>
|
|
<textarea
|
|
id="markdown-content"
|
|
value={newMarkdownContent}
|
|
onChange={(e) => setNewMarkdownContent(e.target.value)}
|
|
onDrop={async (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
// Try files first, then items for better compatibility
|
|
let files = Array.from(e.dataTransfer.files);
|
|
if (files.length === 0 && e.dataTransfer.items) {
|
|
const items = Array.from(e.dataTransfer.items);
|
|
files = items
|
|
.filter((item) => item.kind === 'file')
|
|
.map((item) => item.getAsFile())
|
|
.filter((f): f is globalThis.File => f !== null);
|
|
}
|
|
|
|
const mdFile = files.find((f) => isMarkdownFilename(f.name));
|
|
if (mdFile) {
|
|
const content = await mdFile.text();
|
|
setNewMarkdownContent(content);
|
|
if (!newMarkdownName.trim()) {
|
|
setNewMarkdownName(mdFile.name);
|
|
}
|
|
}
|
|
}}
|
|
onDragOver={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}}
|
|
placeholder="Enter your markdown content here..."
|
|
className="w-full h-60 p-3 font-mono text-sm bg-background border border-border rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-ring focus:border-transparent"
|
|
spellCheck={false}
|
|
data-testid="new-markdown-content"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => {
|
|
setIsCreateMarkdownOpen(false);
|
|
setNewMarkdownName('');
|
|
setNewMarkdownDescription('');
|
|
setNewMarkdownContent('');
|
|
}}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<HotkeyButton
|
|
onClick={handleCreateMarkdown}
|
|
disabled={!newMarkdownName.trim()}
|
|
hotkey={{ key: 'Enter', cmdCtrl: true }}
|
|
hotkeyActive={isCreateMarkdownOpen}
|
|
data-testid="confirm-create-markdown"
|
|
>
|
|
Create
|
|
</HotkeyButton>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Delete Confirmation Dialog */}
|
|
<Dialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
|
<DialogContent data-testid="delete-context-dialog">
|
|
<DialogHeader>
|
|
<DialogTitle>Delete Context File</DialogTitle>
|
|
<DialogDescription>
|
|
Are you sure you want to delete "{selectedFile?.name}"? This action cannot be undone.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setIsDeleteDialogOpen(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
variant="destructive"
|
|
onClick={handleDeleteFile}
|
|
className="bg-red-600 hover:bg-red-700"
|
|
data-testid="confirm-delete-file"
|
|
>
|
|
Delete
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Rename Dialog */}
|
|
<Dialog open={isRenameDialogOpen} onOpenChange={setIsRenameDialogOpen}>
|
|
<DialogContent data-testid="rename-context-dialog">
|
|
<DialogHeader>
|
|
<DialogTitle>Rename Context File</DialogTitle>
|
|
<DialogDescription>Enter a new name for "{selectedFile?.name}".</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="py-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="rename-filename">File Name</Label>
|
|
<Input
|
|
id="rename-filename"
|
|
value={renameFileName}
|
|
onChange={(e) => setRenameFileName(e.target.value)}
|
|
placeholder="Enter new filename"
|
|
data-testid="rename-file-input"
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' && renameFileName.trim()) {
|
|
handleRenameFile();
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => {
|
|
setIsRenameDialogOpen(false);
|
|
setRenameFileName('');
|
|
}}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={handleRenameFile}
|
|
disabled={!renameFileName.trim() || renameFileName === selectedFile?.name}
|
|
data-testid="confirm-rename-file"
|
|
>
|
|
Rename
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Edit Description Dialog */}
|
|
<Dialog open={isEditDescriptionOpen} onOpenChange={setIsEditDescriptionOpen}>
|
|
<DialogContent data-testid="edit-description-dialog">
|
|
<DialogHeader>
|
|
<DialogTitle>Edit Description</DialogTitle>
|
|
<DialogDescription>
|
|
Update the description for "{editDescriptionFileName}". This helps AI understand the
|
|
context.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="py-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="edit-description">Description</Label>
|
|
<Textarea
|
|
id="edit-description"
|
|
value={editDescriptionValue}
|
|
onChange={(e) => setEditDescriptionValue(e.target.value)}
|
|
placeholder="e.g., API documentation for authentication endpoints..."
|
|
className="min-h-[100px]"
|
|
data-testid="edit-description-input"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => {
|
|
setIsEditDescriptionOpen(false);
|
|
setEditDescriptionValue('');
|
|
setEditDescriptionFileName('');
|
|
}}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={handleSaveDescription} data-testid="confirm-save-description">
|
|
Save
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|