diff --git a/README.md b/README.md index b092e1e..e849bac 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ A cross-platform, AI-powered terminal assistant with custom commands, local API - Terminal-like UI with command history and autocompletion - AI-powered responses for natural language queries - Custom shell command execution (with sudo/password support) +- **Built-in text editors: nano and vim with full modal editing support** - Secure local storage for API keys - Cross-platform: Linux, Windows, macOS (experimental) - Colorful output and user-friendly design diff --git a/TODO b/TODO index ae61230..5088810 100644 --- a/TODO +++ b/TODO @@ -11,13 +11,18 @@ SCRAPED: change command to change the API key # Scraped as we have new settings DONE: Add support to various AI models like gemini, groq, etc DONE: add markdown support DONE: clickable links +DONE: Add support for nano/vim +DONE: Persistent command history (saved to ~/.term_history) + +TODO: add proper updates to the command like sync/update (for progress) -handle nano/vim write tests for each of above -Persistent history API key in build change model to change the model A faster sudo command focus on infutfield in password field each time and after closing on terminal minimal ls command output -Implement context +Implement context (history knowledge) +Update Welcome screen when there isn't any API key set +Implement landing page with onboarding +handle failing commands diff --git a/src-tauri/src/commands/history.rs b/src-tauri/src/commands/history.rs new file mode 100644 index 0000000..82e1d06 --- /dev/null +++ b/src-tauri/src/commands/history.rs @@ -0,0 +1,67 @@ +use std::fs; +use std::path::PathBuf; + +fn get_history_file_path() -> Result { + let home_dir = dirs::home_dir() + .ok_or_else(|| "Failed to get home directory".to_string())?; + + Ok(home_dir.join(".term_history")) +} + +#[tauri::command] +pub fn save_command_to_history(command: String) -> Result<(), String> { + let history_file = get_history_file_path()?; + + // Read existing history + let mut history = if history_file.exists() { + fs::read_to_string(&history_file) + .map_err(|e| format!("Failed to read history file: {}", e))? + } else { + String::new() + }; + + // Append new command with newline + if !history.is_empty() && !history.ends_with('\n') { + history.push('\n'); + } + history.push_str(&command); + history.push('\n'); + + // Write back to file + fs::write(&history_file, history) + .map_err(|e| format!("Failed to write history file: {}", e))?; + + Ok(()) +} + +#[tauri::command] +pub fn load_command_history() -> Result, String> { + let history_file = get_history_file_path()?; + + if !history_file.exists() { + return Ok(Vec::new()); + } + + let content = fs::read_to_string(&history_file) + .map_err(|e| format!("Failed to read history file: {}", e))?; + + let history: Vec = content + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| line.to_string()) + .collect(); + + Ok(history) +} + +#[tauri::command] +pub fn clear_command_history() -> Result<(), String> { + let history_file = get_history_file_path()?; + + if history_file.exists() { + fs::remove_file(&history_file) + .map_err(|e| format!("Failed to clear history file: {}", e))?; + } + + Ok(()) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 82676f6..345a69b 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,11 +1,13 @@ pub mod ai; pub mod api_key; -mod files; +pub mod files; +pub mod history; pub mod settings; pub mod shell; pub use ai::*; pub use api_key::*; pub use files::*; +pub use history::*; pub use settings::*; pub use shell::*; 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..4c7a175 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -14,6 +14,12 @@ 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::history::save_command_to_history, + commands::history::load_command_history, + commands::history::clear_command_history, + commands::files::read_file, 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 */} +
+