From b21afb143e5361a127e9065150fd8790900f843e Mon Sep 17 00:00:00 2001 From: Vaibhav Sapate Date: Wed, 4 Mar 2026 18:03:35 +0530 Subject: [PATCH 1/2] feat: Added nano/vim editor support to the term closes: #33 Signed-off-by: Vaibhav Sapate --- TODO | 1 + src-tauri/src/commands/shell.rs | 63 ++++ src-tauri/src/main.rs | 2 + src/__tests__/editor.test.tsx | 291 +++++++++++++++++ src/components/Editor/NanoEditor.tsx | 198 ++++++++++++ src/components/Editor/TextEditor.tsx | 118 +++++++ src/components/Editor/VimEditor.tsx | 323 +++++++++++++++++++ src/components/Editor/index.tsx | 4 + src/components/Terminal/CommandProcessor.tsx | 32 +- src/components/Terminal/index.tsx | 114 ++++--- src/index.css | 2 - 11 files changed, 1095 insertions(+), 53 deletions(-) create mode 100644 src/__tests__/editor.test.tsx create mode 100644 src/components/Editor/NanoEditor.tsx create mode 100644 src/components/Editor/TextEditor.tsx create mode 100644 src/components/Editor/VimEditor.tsx create mode 100644 src/components/Editor/index.tsx diff --git a/TODO b/TODO index ae61230..7633e6c 100644 --- a/TODO +++ b/TODO @@ -21,3 +21,4 @@ A faster sudo command focus on infutfield in password field each time and after closing on terminal minimal ls command output Implement context +handle failing commands diff --git a/src-tauri/src/commands/shell.rs b/src-tauri/src/commands/shell.rs index 265828c..e1eb331 100644 --- a/src-tauri/src/commands/shell.rs +++ b/src-tauri/src/commands/shell.rs @@ -1,4 +1,6 @@ use std::process::Command; +use std::fs; +use std::path::Path; #[tauri::command] pub async fn run_shell(command: String) -> Result { @@ -281,3 +283,64 @@ pub fn change_directory(path: String) -> Result { Err(e) => Err(format!("Failed to change directory: {}", e)) } } + + +#[tauri::command] +pub fn read_file_for_editor(path: String) -> Result { + let expanded_path = if path.starts_with("~") { + if let Ok(home) = std::env::var("HOME") { + path.replacen("~", &home, 1) + } else { + path + } + } else { + path + }; + + // Make path absolute if it's relative + let absolute_path = if Path::new(&expanded_path).is_absolute() { + expanded_path + } else { + let current_dir = std::env::current_dir() + .map_err(|e| format!("Failed to get current directory: {}", e))?; + current_dir.join(&expanded_path) + .to_string_lossy() + .into_owned() + }; + + fs::read_to_string(&absolute_path) + .map_err(|e| format!("Failed to read file '{}': {}", absolute_path, e)) +} + +#[tauri::command] +pub fn write_file_from_editor(path: String, content: String) -> Result<(), String> { + let expanded_path = if path.starts_with("~") { + if let Ok(home) = std::env::var("HOME") { + path.replacen("~", &home, 1) + } else { + path + } + } else { + path + }; + + // Make path absolute if it's relative + let absolute_path = if Path::new(&expanded_path).is_absolute() { + expanded_path + } else { + let current_dir = std::env::current_dir() + .map_err(|e| format!("Failed to get current directory: {}", e))?; + current_dir.join(&expanded_path) + .to_string_lossy() + .into_owned() + }; + + // Create parent directories if they don't exist + if let Some(parent) = Path::new(&absolute_path).parent() { + fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create parent directories: {}", e))?; + } + + fs::write(&absolute_path, content) + .map_err(|e| format!("Failed to write file '{}': {}", absolute_path, e)) +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index fda5f2b..edc24f6 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -14,6 +14,8 @@ fn main() { commands::shell::get_current_dir, commands::shell::list_directory_contents, commands::shell::change_directory, + commands::shell::read_file_for_editor, + commands::shell::write_file_from_editor, commands::ai::ask_llm, commands::api_key::save_api_key, commands::api_key::get_api_key, diff --git a/src/__tests__/editor.test.tsx b/src/__tests__/editor.test.tsx new file mode 100644 index 0000000..5b26eca --- /dev/null +++ b/src/__tests__/editor.test.tsx @@ -0,0 +1,291 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import TextEditor from '../components/Editor/TextEditor'; +import VimEditor from '../components/Editor/VimEditor'; +import NanoEditor from '../components/Editor/NanoEditor'; + +// Mock Tauri API +vi.mock('@tauri-apps/api/core', () => ({ + invoke: vi.fn() +})); + +describe('TextEditor', () => { + const mockOnClose = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render loading state initially', () => { + const { invoke } = require('@tauri-apps/api/core'); + invoke.mockResolvedValue('test content'); + + render( + + ); + + expect(screen.getByText(/Loading/i)).toBeInTheDocument(); + }); + + it('should load file content', async () => { + const { invoke } = require('@tauri-apps/api/core'); + invoke.mockResolvedValue('test content'); + + render( + + ); + + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith('read_file_for_editor', { + path: '/test/file.txt' + }); + }); + }); + + it('should handle file not found error', async () => { + const { invoke } = require('@tauri-apps/api/core'); + invoke.mockRejectedValue(new Error('No such file')); + + render( + + ); + + // Should still render editor with empty content + await waitFor(() => { + expect(invoke).toHaveBeenCalled(); + }); + }); +}); + +describe('NanoEditor', () => { + const mockOnSave = vi.fn(); + const mockOnClose = vi.fn(); + const mockOnContentChange = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render with initial content', () => { + render( + + ); + + expect(screen.getByText('GNU nano')).toBeInTheDocument(); + expect(screen.getByText('/test/file.txt')).toBeInTheDocument(); + }); + + it('should show modified indicator when content changes', () => { + render( + + ); + + expect(screen.getByText('[Modified]')).toBeInTheDocument(); + }); + + it('should display keyboard shortcuts', () => { + render( + + ); + + expect(screen.getByText(/Help/i)).toBeInTheDocument(); + expect(screen.getByText(/Save/i)).toBeInTheDocument(); + expect(screen.getByText(/Exit/i)).toBeInTheDocument(); + }); + + it('should call onContentChange when text changes', () => { + render( + + ); + + const textarea = screen.getByRole('textbox'); + fireEvent.change(textarea, { target: { value: 'Hello World' } }); + + expect(mockOnContentChange).toHaveBeenCalledWith('Hello World'); + }); +}); + +describe('VimEditor', () => { + const mockOnSave = vi.fn(); + const mockOnClose = vi.fn(); + const mockOnContentChange = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render with initial content', () => { + render( + + ); + + expect(screen.getByText('/test/file.txt')).toBeInTheDocument(); + }); + + it('should start in normal mode', () => { + render( + + ); + + // Normal mode should not show mode indicator + expect(screen.queryByText('-- INSERT --')).not.toBeInTheDocument(); + }); + + it('should show modified indicator', () => { + render( + + ); + + expect(screen.getByText('[Modified]')).toBeInTheDocument(); + }); + + it('should display line count', () => { + render( + + ); + + expect(screen.getByText(/3 lines/i)).toBeInTheDocument(); + }); + + it('should call onContentChange when content changes', () => { + render( + + ); + + const textarea = screen.getByRole('textbox'); + fireEvent.change(textarea, { target: { value: 'Hello World' } }); + + expect(mockOnContentChange).toHaveBeenCalled(); + }); +}); + +describe('Editor Integration', () => { + it('should handle save operation in nano', async () => { + const mockOnSave = vi.fn().mockResolvedValue(true); + const mockOnClose = vi.fn(); + const mockOnContentChange = vi.fn(); + + render( + + ); + + const textarea = screen.getByRole('textbox'); + + // Simulate Ctrl+O (save) + fireEvent.keyDown(textarea, { key: 'o', ctrlKey: true }); + + await waitFor(() => { + expect(mockOnSave).toHaveBeenCalledWith('Hello'); + }); + }); + + it('should handle exit with unsaved changes', () => { + const mockOnSave = vi.fn(); + const mockOnClose = vi.fn(); + const mockOnContentChange = vi.fn(); + + // Mock window.confirm + global.confirm = vi.fn(() => false); + + render( + + ); + + const textarea = screen.getByRole('textbox'); + + // Simulate Ctrl+X (exit) + fireEvent.keyDown(textarea, { key: 'x', ctrlKey: true }); + + expect(global.confirm).toHaveBeenCalled(); + }); +}); diff --git a/src/components/Editor/NanoEditor.tsx b/src/components/Editor/NanoEditor.tsx new file mode 100644 index 0000000..0d19833 --- /dev/null +++ b/src/components/Editor/NanoEditor.tsx @@ -0,0 +1,198 @@ +import React, { useState, useEffect, useRef } from 'react'; + +interface NanoEditorProps { + filePath: string; + initialContent: string; + onSave: (content: string) => Promise; + onClose: () => void; + onContentChange: (content: string) => void; + hasUnsavedChanges: boolean; +} + +const NanoEditor: React.FC = ({ + filePath, + initialContent, + onSave, + onClose, + onContentChange, + hasUnsavedChanges +}) => { + const [content, setContent] = useState(initialContent); + const [statusMessage, setStatusMessage] = useState(''); + const [showHelp, setShowHelp] = useState(false); + const [cursorPosition, setCursorPosition] = useState({ line: 1, col: 1 }); + const textareaRef = useRef(null); + + useEffect(() => { + if (textareaRef.current) { + textareaRef.current.focus(); + } + }, []); + + useEffect(() => { + if (content !== initialContent) { + onContentChange(content); + } + }, [content]); + + const handleKeyDown = async (e: React.KeyboardEvent) => { + // Ctrl+S - Save + if (e.ctrlKey && e.key === 's') { + e.preventDefault(); + const success = await onSave(content); + if (success) { + setStatusMessage(`[ Wrote ${content.split('\n').length} lines ]`); + setTimeout(() => setStatusMessage(''), 2000); + } + } + // Ctrl+X - Exit + else if (e.ctrlKey && e.key === 'x') { + e.preventDefault(); + if (hasUnsavedChanges) { + const confirmExit = window.confirm('Save modified buffer?'); + if (confirmExit) { + const success = await onSave(content); + if (success) { + onClose(); + } + } else { + onClose(); + } + } else { + onClose(); + } + } + // Ctrl+G - Help + else if (e.ctrlKey && e.key === 'g') { + e.preventDefault(); + setShowHelp(!showHelp); + } + // Ctrl+K - Cut line + else if (e.ctrlKey && e.key === 'k') { + e.preventDefault(); + const textarea = textareaRef.current; + if (!textarea) return; + + const start = textarea.selectionStart; + const lines = content.split('\n'); + let currentPos = 0; + let lineIndex = 0; + + for (let i = 0; i < lines.length; i++) { + if (currentPos + lines[i].length >= start) { + lineIndex = i; + break; + } + currentPos += lines[i].length + 1; + } + + const newLines = [...lines]; + newLines.splice(lineIndex, 1); + setContent(newLines.join('\n')); + setStatusMessage('[ Cut line ]'); + setTimeout(() => setStatusMessage(''), 2000); + } + // Ctrl+W - Search + else if (e.ctrlKey && e.key === 'w') { + e.preventDefault(); + const searchTerm = prompt('Search:'); + if (searchTerm) { + const index = content.indexOf(searchTerm); + if (index !== -1 && textareaRef.current) { + textareaRef.current.setSelectionRange(index, index + searchTerm.length); + textareaRef.current.focus(); + } else { + setStatusMessage(`[ "${searchTerm}" not found ]`); + setTimeout(() => setStatusMessage(''), 2000); + } + } + } + }; + + const handleChange = (e: React.ChangeEvent) => { + setContent(e.target.value); + updateCursorPosition(e.target); + }; + + const handleClick = (e: React.MouseEvent) => { + updateCursorPosition(e.currentTarget); + }; + + const handleKeyUp = (e: React.KeyboardEvent) => { + updateCursorPosition(e.currentTarget); + }; + + const updateCursorPosition = (textarea: HTMLTextAreaElement) => { + const text = textarea.value.substring(0, textarea.selectionStart); + const lines = text.split('\n'); + const line = lines.length; + const col = lines[lines.length - 1].length + 1; + setCursorPosition({ line, col }); + }; + + return ( +
+ {/* Header */} +
+
+ GNU nano + {filePath} + {hasUnsavedChanges && [Modified]} +
+
+ + {/* Editor area */} +
+