mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-18 10:23:07 +00:00
Make memory and context views mobile-friendly (#813)
* 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 --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
@@ -24,8 +24,10 @@ import {
|
||||
FilePlus,
|
||||
FileUp,
|
||||
MoreVertical,
|
||||
ArrowLeft,
|
||||
} from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { useIsMobile } from '@/hooks/use-media-query';
|
||||
import {
|
||||
useKeyboardShortcuts,
|
||||
useKeyboardShortcutsConfig,
|
||||
@@ -42,7 +44,7 @@ import {
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { sanitizeFilename } from '@/lib/image-utils';
|
||||
import { sanitizeFilename, isMarkdownFilename, isImageFilename } from '@/lib/image-utils';
|
||||
import { Markdown } from '../ui/markdown';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -54,6 +56,16 @@ 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';
|
||||
@@ -103,6 +115,9 @@ export function ContextView() {
|
||||
// File input ref for import
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Mobile detection
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
// Keyboard shortcuts for this view
|
||||
const contextShortcuts: KeyboardShortcut[] = useMemo(
|
||||
() => [
|
||||
@@ -122,18 +137,6 @@ export function ContextView() {
|
||||
return `${currentProject.path}/.automaker/context`;
|
||||
}, [currentProject]);
|
||||
|
||||
const isMarkdownFile = (filename: string): boolean => {
|
||||
const ext = filename.toLowerCase().substring(filename.lastIndexOf('.'));
|
||||
return ext === '.md' || ext === '.markdown';
|
||||
};
|
||||
|
||||
// Determine if a file is an image based on extension
|
||||
const isImageFile = (filename: string): boolean => {
|
||||
const imageExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp'];
|
||||
const ext = filename.toLowerCase().substring(filename.lastIndexOf('.'));
|
||||
return imageExtensions.includes(ext);
|
||||
};
|
||||
|
||||
// Load context metadata
|
||||
const loadMetadata = useCallback(async (): Promise<ContextMetadata> => {
|
||||
const contextPath = getContextPath();
|
||||
@@ -195,10 +198,15 @@ export function ContextView() {
|
||||
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')
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.isFile &&
|
||||
entry.name !== 'context-metadata.json' &&
|
||||
(isMarkdownFilename(entry.name) || isImageFilename(entry.name))
|
||||
)
|
||||
.map((entry) => ({
|
||||
name: entry.name,
|
||||
type: isImageFile(entry.name) ? 'image' : 'text',
|
||||
type: isImageFilename(entry.name) ? 'image' : 'text',
|
||||
path: `${contextPath}/${entry.name}`,
|
||||
description: metadata.files[entry.name]?.description,
|
||||
}));
|
||||
@@ -232,11 +240,10 @@ export function ContextView() {
|
||||
|
||||
// Select a file
|
||||
const handleSelectFile = (file: ContextFile) => {
|
||||
if (hasChanges) {
|
||||
// Could add a confirmation dialog here
|
||||
}
|
||||
// Note: Unsaved changes warning could be added here in the future
|
||||
// For now, silently proceed to avoid disrupting mobile UX flow
|
||||
loadFileContent(file);
|
||||
setIsPreviewMode(isMarkdownFile(file.name));
|
||||
setIsPreviewMode(isMarkdownFilename(file.name));
|
||||
};
|
||||
|
||||
// Save current file
|
||||
@@ -341,7 +348,7 @@ export function ContextView() {
|
||||
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
const isImage = isImageFile(file.name);
|
||||
const isImage = isImageFilename(file.name);
|
||||
|
||||
let filePath: string;
|
||||
let fileName: string;
|
||||
@@ -582,7 +589,7 @@ export function ContextView() {
|
||||
// Update selected file with new name and path
|
||||
const renamedFile: ContextFile = {
|
||||
name: newName,
|
||||
type: isImageFile(newName) ? 'image' : 'text',
|
||||
type: isImageFilename(newName) ? 'image' : 'text',
|
||||
path: newPath,
|
||||
content: result.content,
|
||||
description: metadata.files[newName]?.description,
|
||||
@@ -790,7 +797,17 @@ export function ContextView() {
|
||||
)}
|
||||
|
||||
{/* Left Panel - File List */}
|
||||
<div className="w-64 border-r border-border flex flex-col overflow-hidden">
|
||||
{/* 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})
|
||||
@@ -881,12 +898,31 @@ export function ContextView() {
|
||||
</div>
|
||||
|
||||
{/* Right Panel - Editor/Preview */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* 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" />
|
||||
) : (
|
||||
@@ -894,23 +930,26 @@ export function ContextView() {
|
||||
)}
|
||||
<span className="text-sm font-medium truncate">{selectedFile.name}</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{selectedFile.type === 'text' && isMarkdownFile(selectedFile.name) && (
|
||||
<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 mr-2" />
|
||||
Edit
|
||||
<Pencil className="w-4 h-4" />
|
||||
{!isMobile && <span className="ml-2">Edit</span>}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Eye className="w-4 h-4 mr-2" />
|
||||
Preview
|
||||
<Eye className="w-4 h-4" />
|
||||
{!isMobile && <span className="ml-2">Preview</span>}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
@@ -921,20 +960,31 @@ export function ContextView() {
|
||||
onClick={saveFile}
|
||||
disabled={!hasChanges || isSaving}
|
||||
data-testid="save-context-file"
|
||||
aria-label="Save"
|
||||
title="Save"
|
||||
>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
{isSaving ? 'Saving...' : hasChanges ? 'Save' : 'Saved'}
|
||||
<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>
|
||||
)}
|
||||
<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"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1072,7 +1122,7 @@ export function ContextView() {
|
||||
.filter((f): f is globalThis.File => f !== null);
|
||||
}
|
||||
|
||||
const mdFile = files.find((f) => isMarkdownFile(f.name));
|
||||
const mdFile = files.find((f) => isMarkdownFilename(f.name));
|
||||
if (mdFile) {
|
||||
const content = await mdFile.text();
|
||||
setNewMarkdownContent(content);
|
||||
|
||||
@@ -18,7 +18,9 @@ import {
|
||||
Pencil,
|
||||
FilePlus,
|
||||
MoreVertical,
|
||||
ArrowLeft,
|
||||
} from 'lucide-react';
|
||||
import { useIsMobile } from '@/hooks/use-media-query';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -31,6 +33,7 @@ import {
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { isMarkdownFilename } from '@/lib/image-utils';
|
||||
import { Markdown } from '../ui/markdown';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -41,6 +44,16 @@ import {
|
||||
|
||||
const logger = createLogger('MemoryView');
|
||||
|
||||
// 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 MemoryFile {
|
||||
name: string;
|
||||
content?: string;
|
||||
@@ -68,17 +81,15 @@ export function MemoryView() {
|
||||
// Actions panel state (for tablet/mobile)
|
||||
const [showActionsPanel, setShowActionsPanel] = useState(false);
|
||||
|
||||
// Mobile detection
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
// Get memory directory path
|
||||
const getMemoryPath = useCallback(() => {
|
||||
if (!currentProject) return null;
|
||||
return `${currentProject.path}/.automaker/memory`;
|
||||
}, [currentProject]);
|
||||
|
||||
const isMarkdownFile = (filename: string): boolean => {
|
||||
const ext = filename.toLowerCase().substring(filename.lastIndexOf('.'));
|
||||
return ext === '.md' || ext === '.markdown';
|
||||
};
|
||||
|
||||
// Load memory files
|
||||
const loadMemoryFiles = useCallback(async () => {
|
||||
const memoryPath = getMemoryPath();
|
||||
@@ -95,7 +106,7 @@ export function MemoryView() {
|
||||
const result = await api.readdir(memoryPath);
|
||||
if (result.success && result.entries) {
|
||||
const files: MemoryFile[] = result.entries
|
||||
.filter((entry) => entry.isFile && isMarkdownFile(entry.name))
|
||||
.filter((entry) => entry.isFile && isMarkdownFilename(entry.name))
|
||||
.map((entry) => ({
|
||||
name: entry.name,
|
||||
path: `${memoryPath}/${entry.name}`,
|
||||
@@ -130,9 +141,8 @@ export function MemoryView() {
|
||||
|
||||
// Select a file
|
||||
const handleSelectFile = (file: MemoryFile) => {
|
||||
if (hasChanges) {
|
||||
// Could add a confirmation dialog here
|
||||
}
|
||||
// Note: Unsaved changes warning could be added here in the future
|
||||
// For now, silently proceed to avoid disrupting mobile UX flow
|
||||
loadFileContent(file);
|
||||
setIsPreviewMode(true);
|
||||
};
|
||||
@@ -381,7 +391,17 @@ export function MemoryView() {
|
||||
{/* Main content area with file list and editor */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Left Panel - File List */}
|
||||
<div className="w-64 border-r border-border flex flex-col overflow-hidden">
|
||||
{/* 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">
|
||||
Memory Files ({memoryFiles.length})
|
||||
@@ -455,31 +475,53 @@ export function MemoryView() {
|
||||
</div>
|
||||
|
||||
{/* Right Panel - Editor/Preview */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* 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>
|
||||
)}
|
||||
<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="flex gap-2">
|
||||
<div className={cn('flex gap-2', isMobile && 'gap-1')}>
|
||||
{/* Mobile: Icon-only buttons with aria-labels for accessibility */}
|
||||
<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 mr-2" />
|
||||
Edit
|
||||
<Pencil className="w-4 h-4" />
|
||||
{!isMobile && <span className="ml-2">Edit</span>}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Eye className="w-4 h-4 mr-2" />
|
||||
Preview
|
||||
<Eye className="w-4 h-4" />
|
||||
{!isMobile && <span className="ml-2">Preview</span>}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
@@ -488,19 +530,30 @@ export function MemoryView() {
|
||||
onClick={saveFile}
|
||||
disabled={!hasChanges || isSaving}
|
||||
data-testid="save-memory-file"
|
||||
aria-label="Save"
|
||||
title="Save"
|
||||
>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
{isSaving ? 'Saving...' : hasChanges ? 'Save' : 'Saved'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsDeleteDialogOpen(true)}
|
||||
className="text-red-500 hover:text-red-400 hover:border-red-500/50"
|
||||
data-testid="delete-memory-file"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
<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-memory-file"
|
||||
aria-label="Delete"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user