Files
automaker/apps/ui/src/components/views/context-view.tsx
gsxdsm cce0a341b5 Bug fixes and stability improvements (#815)
* 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>
2026-03-02 20:23:44 -08:00

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>
);
}