diff --git a/backend/ai/prompts.py b/backend/ai/prompts.py index 6838ad8..8ae5916 100644 --- a/backend/ai/prompts.py +++ b/backend/ai/prompts.py @@ -27,7 +27,7 @@ ## First Step: Read the Index Before exploring the wiki, read `agents/index.md` to understand the wiki structure. -This index contains page descriptions, tags, and navigation tips. +For creating TSX views or CSV data files, read `agents/data-views.md` for examples. ## Your Capabilities - **Search**: Use grep_pages for content search (regex), glob_pages for path patterns @@ -59,6 +59,7 @@ ## First Step: Read the Index Start by reading `agents/index.md` to understand wiki structure and page locations. +For TSX views or CSV data, read `agents/data-views.md` for patterns and examples. Always use the file path (e.g., 'home.md', 'agents/index.md') when referencing pages. Use list_pages() first to see exact file paths. Never use display titles like "Home". diff --git a/backend/main.py b/backend/main.py index 78a31d9..35c210d 100644 --- a/backend/main.py +++ b/backend/main.py @@ -73,6 +73,11 @@ async def startup_event(): print(f"โœ… Wiki repository loaded successfully ({WIKI_REPO_PATH})") print(f"๐Ÿ“š Found {len(wiki.list_pages())} pages") + # Initialize template files (examples for agents) + created = wiki.ensure_templates() + if created: + print(f"๐Ÿ“ Initialized {len(created)} template files") + # Initialize thread support (sets up .gitignore, cleans orphaned worktrees) git_ops.init_thread_support(wiki) print("๐Ÿงต Thread support initialized") diff --git a/backend/storage/git_wiki.py b/backend/storage/git_wiki.py index ebebce5..079c498 100644 --- a/backend/storage/git_wiki.py +++ b/backend/storage/git_wiki.py @@ -2,45 +2,30 @@ Git-based wiki storage backend. Replaces SQLite database with git repository for version control. -Pages are plain markdown files - no frontmatter. +Supports multiple file types: +- Markdown (.md) - wiki pages +- CSV (.csv) - data files for interactive views +- TSX (.tsx) - React view components + Metadata (author, dates) comes from git history. Navigation and tags are in agents/index.md. """ import re +import csv +import io +import shutil from pathlib import Path from datetime import datetime from typing import List, Dict, Optional, Any from git import Repo, GitCommandError, Actor -# Default content for agents/index.md when initializing a new wiki -DEFAULT_AGENTS_INDEX = """# Wiki Index - -Quick reference for navigating this wiki. **Agents should read this first** when starting a task. - -## Pages - -(Add page entries here as the wiki grows) +# Templates directory (for auto-instantiating agent examples) +TEMPLATES_DIR = Path(__file__).parent.parent / "templates" -## Folders -- **agents/** - Agent configuration and wiki metadata (this folder) - - `index.md` - Navigation hub (you are here) - -## Navigation Tips - -1. Use `list_pages` to see all pages (sorted alphabetically) -2. Use `grep_pages` to search content across all pages -3. Use `glob_pages` to find pages by path pattern -4. Read this index first to understand wiki structure before making changes - -## Adding New Pages - -When creating a new page: -1. Use a descriptive filename (e.g., `api-reference.md`, `getting-started.md`) -2. Update this index.md to add a navigation entry -3. Pages are sorted alphabetically - add number prefixes if specific order is needed -""" +# Supported file extensions for wiki content +SUPPORTED_EXTENSIONS = {'.md', '.csv', '.tsx'} @@ -87,26 +72,64 @@ def __init__(self, repo_path: str): def _ensure_agents_folder(self): """ - Ensure agents/ folder exists with default index.md. + Ensure agents/ folder exists. - Creates index.md if missing, providing a starting point for - wiki navigation. + The actual content (index.md, examples, etc.) is handled by + ensure_templates() which copies from backend/templates/. """ agents_dir = self.pages_dir / "agents" agents_dir.mkdir(exist_ok=True) - index_path = agents_dir / "index.md" + def ensure_templates(self) -> List[str]: + """ + Copy template files to wiki if they don't exist. + + Templates are stored in backend/templates/ and get auto-instantiated + into the wiki pages/ folder on startup. This provides example files + for agents to learn from. + + Returns: + List of created page paths + """ + if not TEMPLATES_DIR.exists(): + return [] + + created = [] + + for template_path in TEMPLATES_DIR.rglob("*"): + if not template_path.is_file(): + continue + + # Skip hidden files + if template_path.name.startswith('.'): + continue + + # Get relative path from templates dir + rel_path = template_path.relative_to(TEMPLATES_DIR) + wiki_path = self.pages_dir / rel_path - if not index_path.exists(): - index_path.write_text(DEFAULT_AGENTS_INDEX, encoding='utf-8') + # Only copy if doesn't exist + if not wiki_path.exists(): + # Create parent directories + wiki_path.parent.mkdir(parents=True, exist_ok=True) + # Copy file + shutil.copy(template_path, wiki_path) + created.append(str(rel_path)) + + # Commit all created files + if created: try: - self.repo.index.add(["pages/agents/index.md"]) + for rel_path in created: + self.repo.index.add([f"pages/{rel_path}"]) self.repo.index.commit( - "Initialize agents/ folder", + f"Initialize templates: {', '.join(created[:3])}{'...' if len(created) > 3 else ''}", author=self._create_author("System") ) - except Exception: - pass # Ignore commit errors on init + print(f"Initialized {len(created)} template files: {', '.join(created)}") + except Exception as e: + print(f"Warning: Failed to commit templates: {e}") + + return created @staticmethod def _create_author(author_name: str) -> Actor: @@ -177,18 +200,39 @@ def _parse_order_from_filename(filename: str) -> tuple: # No order prefix - return 0 and full name return 0, name + @staticmethod + def _get_file_type(filepath: Path) -> str: + """ + Determine file type from extension. + + Args: + filepath: Path to file + + Returns: + 'markdown', 'csv', or 'tsx' + """ + ext = filepath.suffix.lower() + if ext == '.csv': + return 'csv' + elif ext == '.tsx': + return 'tsx' + else: + return 'markdown' + def _get_page_path(self, title: str) -> Path: """Get full filesystem path for a page by title or path. Expects full path for files in subdirectories (e.g., "agents/index.md"). + Supports .md, .csv, and .tsx files. """ # Direct path lookup - this is the expected case - if title.endswith('.md') or '/' in title: + has_extension = any(title.endswith(ext) for ext in SUPPORTED_EXTENSIONS) + if has_extension or '/' in title: direct_path = self.pages_dir / title if direct_path.exists(): return direct_path - # Try exact filename match + # Try exact filename match (for .md files) filename = self.title_to_filename(title) exact_path = self.pages_dir / filename if exact_path.exists(): @@ -199,29 +243,50 @@ def _get_page_path(self, title: str) -> Path: def _parse_page(self, filepath: Path) -> Dict[str, Any]: """ - Parse a markdown file into a page dict. + Parse a file into a page dict. - Reads raw markdown content. Strips frontmatter if present (for migration). - Title is the filename. + Handles different file types: + - Markdown (.md): Strips frontmatter if present + - CSV (.csv): Parses into headers and rows + - TSX (.tsx): Returns raw content Args: - filepath: Path to the markdown file + filepath: Path to the file Returns: - Dictionary with page data (path, content only) + Dictionary with page data (path, content, file_type, and type-specific fields) """ raw_content = filepath.read_text(encoding='utf-8') + file_type = self._get_file_type(filepath) - # Strip frontmatter if present (for backwards compatibility during migration) - content = self._strip_frontmatter(raw_content) - - return { + base_result = { "path": str(filepath.relative_to(self.pages_dir)), "title": filepath.name, - "content": content, + "file_type": file_type, "has_conflicts": "<<<<<<" in raw_content or "=======" in raw_content } + if file_type == 'csv': + # Parse CSV into headers and rows + base_result["content"] = raw_content + try: + reader = csv.DictReader(io.StringIO(raw_content)) + rows = list(reader) + base_result["headers"] = reader.fieldnames or [] + base_result["rows"] = rows + except Exception: + # Fallback if CSV parsing fails + base_result["headers"] = [] + base_result["rows"] = [] + elif file_type == 'tsx': + # TSX: Return raw content as-is + base_result["content"] = raw_content + else: + # Markdown: Strip frontmatter if present + base_result["content"] = self._strip_frontmatter(raw_content) + + return base_result + def _strip_frontmatter(self, content: str) -> str: """ Strip YAML frontmatter from content if present. @@ -471,15 +536,16 @@ def rename_page(self, old_path: str, new_name: str, author: str = "User") -> Dic return self._parse_page(new_filepath) - def list_pages(self, limit: Optional[int] = None) -> List[Dict[str, Any]]: + def list_pages(self, limit: Optional[int] = None, file_type: Optional[str] = None) -> List[Dict[str, Any]]: """ List all pages in the wiki. Args: limit: Maximum number of pages to return (optional) + file_type: Filter by file type ('markdown', 'csv', 'tsx') or None for all Returns: - List of page dictionaries (path and title only) + List of page dictionaries (path, title, file_type) """ pages = [] @@ -487,11 +553,23 @@ def list_pages(self, limit: Optional[int] = None) -> List[Dict[str, Any]]: # Skip directories and hidden files if not filepath.is_file() or filepath.name.startswith('.'): continue + + # Only include supported file types + if filepath.suffix.lower() not in SUPPORTED_EXTENSIONS: + continue + try: rel_path = str(filepath.relative_to(self.pages_dir)) + ft = self._get_file_type(filepath) + + # Filter by file type if specified + if file_type and ft != file_type: + continue + pages.append({ "path": rel_path, - "title": filepath.name + "title": filepath.name, + "file_type": ft }) except Exception as e: print(f"Warning: Failed to parse {filepath}: {e}") @@ -554,8 +632,8 @@ def _manual_search(self, query: str, limit: int) -> List[Dict[str, Any]]: pages = [] query_lower = query.lower() - for filepath in self.pages_dir.rglob("*.md"): - if filepath.is_file(): + for filepath in self.pages_dir.rglob("*"): + if filepath.is_file() and filepath.suffix.lower() in SUPPORTED_EXTENSIONS: try: content = filepath.read_text(encoding='utf-8').lower() if query_lower in content: @@ -649,7 +727,7 @@ def get_page_tree(self) -> List[Dict[str, Any]]: Get hierarchical tree of all pages and folders. Returns: - List of tree items, each with id, title, path, type, children + List of tree items, each with id, title, path, type, file_type, children Sorted alphabetically. """ def build_tree(directory: Path, parent_path: Optional[str] = None) -> List[Dict]: @@ -673,16 +751,18 @@ def build_tree(directory: Path, parent_path: Optional[str] = None) -> List[Dict] "children": build_tree(entry, rel_path) } items.append(item) - elif entry.is_file(): + elif entry.is_file() and entry.suffix.lower() in SUPPORTED_EXTENSIONS: try: rel_path = str(entry.relative_to(self.pages_dir)) - # Remove extension for id (if .md) - item_id = rel_path.replace('.md', '') if rel_path.endswith('.md') else rel_path + file_type = GitWiki._get_file_type(entry) + # Remove extension for id + item_id = rel_path.rsplit('.', 1)[0] if '.' in rel_path else rel_path item = { "id": item_id, "title": entry.name, "path": rel_path, "type": "page", + "file_type": file_type, "parent_path": parent_path, "children": None } @@ -1227,7 +1307,7 @@ def get_diff_stats_by_page(self, base: str, target: str) -> Dict[str, Dict[str, target: Target ref (e.g., "thread/feature-x") Returns: - Dictionary mapping page paths to {additions, deletions} + Dictionary mapping page paths to {additions, deletions, file_type} """ try: result = self.repo.git.diff('--numstat', base, target) @@ -1239,14 +1319,17 @@ def get_diff_stats_by_page(self, base: str, target: str) -> Dict[str, Dict[str, parts = line.split('\t') if len(parts) == 3: adds, dels, path = parts - # Only include pages (files in pages/ directory) - if path.startswith('pages/') and path.endswith('.md'): - # Convert path to page identifier - page_path = path[6:] # Remove 'pages/' prefix - stats[page_path] = { - "additions": int(adds) if adds != '-' else 0, - "deletions": int(dels) if dels != '-' else 0 - } + # Only include pages (files in pages/ directory with supported extensions) + if path.startswith('pages/'): + ext = '.' + path.rsplit('.', 1)[-1] if '.' in path else '' + if ext.lower() in SUPPORTED_EXTENSIONS: + # Convert path to page identifier + page_path = path[6:] # Remove 'pages/' prefix + stats[page_path] = { + "additions": int(adds) if adds != '-' else 0, + "deletions": int(dels) if dels != '-' else 0, + "file_type": self._get_file_type(Path(path)) + } return stats except GitCommandError: @@ -1286,14 +1369,18 @@ def search_pages_regex( except regex_module.error as e: return [{"error": f"Invalid regex pattern: {e}"}] - for filepath in self.pages_dir.rglob("*.md"): - if not filepath.is_file(): + for filepath in self.pages_dir.rglob("*"): + if not filepath.is_file() or filepath.suffix.lower() not in SUPPORTED_EXTENSIONS: continue try: raw_content = filepath.read_text(encoding='utf-8') - # Strip frontmatter if present - page_content = self._strip_frontmatter(raw_content) + file_type = self._get_file_type(filepath) + # Strip frontmatter if present (for markdown files) + if file_type == 'markdown': + page_content = self._strip_frontmatter(raw_content) + else: + page_content = raw_content page_path = str(filepath.relative_to(self.pages_dir)) lines = page_content.split('\n') @@ -1333,16 +1420,17 @@ def glob_pages(self, pattern: str, limit: int = 50) -> List[Dict[str, Any]]: - ? matches single character Args: - pattern: Glob pattern (e.g., "docs/*", "**/*api*") + pattern: Glob pattern (e.g., "docs/*", "**/*api*", "*.csv") limit: Maximum results to return Returns: - List of matching pages with title, path, updated_at + List of matching pages with title, path, file_type """ import fnmatch - # Normalize pattern - ensure it ends with .md for file matching - if not pattern.endswith('.md') and not pattern.endswith('*'): + # Normalize pattern - add wildcard if no extension specified + has_extension = any(pattern.endswith(ext) for ext in SUPPORTED_EXTENSIONS) + if not has_extension and not pattern.endswith('*'): search_pattern = pattern + '*' else: search_pattern = pattern @@ -1355,13 +1443,13 @@ def glob_pages(self, pattern: str, limit: int = 50) -> List[Dict[str, Any]]: results = [] - for filepath in self.pages_dir.rglob("*.md"): - if not filepath.is_file(): + for filepath in self.pages_dir.rglob("*"): + if not filepath.is_file() or filepath.suffix.lower() not in SUPPORTED_EXTENSIONS: continue rel_path = str(filepath.relative_to(self.pages_dir)) - # Remove .md for matching against pattern - rel_path_no_ext = rel_path.replace('.md', '') + # Remove extension for matching against pattern + rel_path_no_ext = rel_path.rsplit('.', 1)[0] if '.' in rel_path else rel_path # Match against both with and without extension if fnmatch.fnmatch(rel_path, search_pattern) or \ @@ -1372,6 +1460,7 @@ def glob_pages(self, pattern: str, limit: int = 50) -> List[Dict[str, Any]]: results.append({ "title": filepath.name, "path": rel_path, + "file_type": self._get_file_type(filepath), "updated_at": None # Legacy field }) diff --git a/backend/templates/agents/components/DataTable.tsx b/backend/templates/agents/components/DataTable.tsx new file mode 100644 index 0000000..df7741c --- /dev/null +++ b/backend/templates/agents/components/DataTable.tsx @@ -0,0 +1,74 @@ +// Reusable DataTable component - can be imported via useComponent +// Usage: const DataTable = useComponent('agents/components/DataTable.tsx'); + +export default function DataTable({ + title, + headers, + rows, + onIncrement, + onDecrement, + valueColumn +}) { + return ( + + + {title} + + + + + + {headers.map((header, i) => ( + {header} + ))} + {(onIncrement || onDecrement) && ( + Actions + )} + + + + {rows.map((row, rowIndex) => ( + + {headers.map((header, colIndex) => { + const key = header.toLowerCase(); + return ( + + {row[key] !== undefined ? String(row[key]) : ''} + + ); + })} + {(onIncrement || onDecrement) && ( + +
+ {onDecrement && ( + + )} + {onIncrement && ( + + )} +
+
+ )} +
+ ))} +
+
+
+ {rows.length} items +
+
+
+ ); +} diff --git a/backend/templates/agents/data-views.md b/backend/templates/agents/data-views.md new file mode 100644 index 0000000..32d3911 --- /dev/null +++ b/backend/templates/agents/data-views.md @@ -0,0 +1,63 @@ +# Data Views Reference + +The wiki supports interactive TSX views and CSV data files. + +## File Types + +| Extension | Purpose | +|-----------|---------| +| `.md` | Markdown pages | +| `.csv` | Tabular data | +| `.tsx` | Interactive views | + +## Creating CSV Files + +CSV files store tabular data. Example structure: + +```csv +name,email,role +Alice,alice@example.com,admin +Bob,bob@example.com,user +``` + +## Creating TSX Views + +TSX views are React components compiled at runtime. Export a default function component. + +### Available Hooks + +- `useCSV('file.csv')` - Load and edit CSV data + - Returns: `{ rows, headers, updateCell, addRow, deleteRow, isLoaded }` +- `usePage('file.md')` - Load any text file content + - Returns: `{ content, setContent, isLoaded }` +- `useComponent('path.tsx')` - Reuse other TSX components + - Returns: The component or null if loading + +### Available Components + +UI components available in scope: +- `Button` - Clickable button with variants +- `Card`, `CardContent`, `CardHeader`, `CardTitle` - Card layout +- `Badge` - Status/count badges +- `Table`, `TableHead`, `TableBody`, `TableRow`, `TableCell`, `TableHeader` - Table layout + +### React Hooks + +Standard React hooks available: +- `useState`, `useEffect`, `useMemo`, `useCallback`, `memo`, `useRef` + +## Examples + +See these pages for working examples: + +- [agents/examples/simple-view.tsx](page:agents/examples/simple-view.tsx) - Minimal view with state +- [agents/examples/data-view.tsx](page:agents/examples/data-view.tsx) - CSV data manipulation +- [agents/examples/kanban.tsx](page:agents/examples/kanban.tsx) - Kanban board with drag-and-drop +- [agents/components/DataTable.tsx](page:agents/components/DataTable.tsx) - Reusable component + +## Tips + +1. Always check `isLoaded` before rendering data +2. CSV cell values are strings - parse numbers with `parseInt()`/`parseFloat()` +3. Use `useComponent` to share UI between views +4. Tailwind CSS classes work for styling diff --git a/backend/templates/agents/examples/data-view.tsx b/backend/templates/agents/examples/data-view.tsx new file mode 100644 index 0000000..93c037c --- /dev/null +++ b/backend/templates/agents/examples/data-view.tsx @@ -0,0 +1,54 @@ +// CSV data manipulation example - demonstrates useCSV hook +export default function DataView() { + const tasks = useCSV('agents/examples/tasks.csv'); + + if (!tasks.isLoaded) { + return Loading...; + } + + const toggleComplete = (index) => { + const current = tasks.rows[index].completed === 'true'; + tasks.updateCell(index, 'completed', String(!current)); + }; + + const addTask = () => { + tasks.addRow({ task: 'New task', completed: 'false' }); + }; + + return ( + + + Task List + + + + + + Task + Done + + + + {tasks.rows.map((row, i) => ( + + {row.task} + + + + + ))} + +
+ +
+
+ ); +} diff --git a/backend/templates/agents/examples/kanban-tasks.csv b/backend/templates/agents/examples/kanban-tasks.csv new file mode 100644 index 0000000..4cbab76 --- /dev/null +++ b/backend/templates/agents/examples/kanban-tasks.csv @@ -0,0 +1,9 @@ +title,description,status,priority +Design database schema,Define tables and relationships for user data,done,high +Implement authentication,Add login and signup with JWT tokens,done,high +Create API endpoints,REST endpoints for CRUD operations,in-progress,high +Write unit tests,Test coverage for core modules,in-progress,medium +Setup CI/CD pipeline,Automated testing and deployment,todo,medium +Add dark mode,Theme toggle for better UX,todo,low +Performance optimization,Profile and optimize slow queries,review,medium +Update documentation,API docs and README updates,review,low diff --git a/backend/templates/agents/examples/kanban.tsx b/backend/templates/agents/examples/kanban.tsx new file mode 100644 index 0000000..c384e5f --- /dev/null +++ b/backend/templates/agents/examples/kanban.tsx @@ -0,0 +1,167 @@ +// Kanban board with drag-and-drop task management +// Demonstrates useCSV for data, useState for drag state, and dynamic columns + +export default function Kanban() { + const tasks = useCSV('agents/examples/kanban-tasks.csv'); + const [draggedTask, setDraggedTask] = useState(null); + const [dragOverColumn, setDragOverColumn] = useState(null); + + const columns = ['todo', 'in-progress', 'review', 'done']; + const columnLabels = { + 'todo': 'To Do', + 'in-progress': 'In Progress', + 'review': 'Review', + 'done': 'Done' + }; + const columnColors = { + 'todo': 'bg-slate-100', + 'in-progress': 'bg-blue-50', + 'review': 'bg-amber-50', + 'done': 'bg-green-50' + }; + + if (!tasks.isLoaded) { + return Loading tasks...; + } + + const getTasksByStatus = (status) => { + return tasks.rows + .map((row, index) => ({ ...row, _index: index })) + .filter(row => row.status === status); + }; + + const handleDragStart = (e, task) => { + setDraggedTask(task); + e.dataTransfer.effectAllowed = 'move'; + }; + + const handleDragOver = (e, column) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + setDragOverColumn(column); + }; + + const handleDragLeave = () => { + setDragOverColumn(null); + }; + + const handleDrop = (e, newStatus) => { + e.preventDefault(); + if (draggedTask && draggedTask.status !== newStatus) { + tasks.updateCell(draggedTask._index, 'status', newStatus); + } + setDraggedTask(null); + setDragOverColumn(null); + }; + + const handleDragEnd = () => { + setDraggedTask(null); + setDragOverColumn(null); + }; + + const addTask = () => { + const title = prompt('Task title:'); + if (title) { + tasks.addRow({ + title, + description: '', + status: 'todo', + priority: 'medium' + }); + } + }; + + const deleteTask = (index) => { + if (confirm('Delete this task?')) { + tasks.deleteRow(index); + } + }; + + const getPriorityColor = (priority) => { + switch (priority) { + case 'high': return 'border-l-red-500'; + case 'medium': return 'border-l-amber-500'; + case 'low': return 'border-l-green-500'; + default: return 'border-l-slate-300'; + } + }; + + return ( +
+
+

Kanban Board

+ +
+ +
+ {columns.map(column => ( +
handleDragOver(e, column)} + onDragLeave={handleDragLeave} + onDrop={(e) => handleDrop(e, column)} + > +
+

+ {columnLabels[column]} +

+ {getTasksByStatus(column).length} +
+ +
+ {getTasksByStatus(column).map(task => ( + handleDragStart(e, task)} + onDragEnd={handleDragEnd} + className={`cursor-grab active:cursor-grabbing border-l-4 ${getPriorityColor(task.priority)} ${ + draggedTask?._index === task._index ? 'opacity-50' : '' + }`} + > + +
+
+

{task.title}

+ {task.description && ( +

+ {task.description} +

+ )} +
+ +
+
+ + {task.priority} + +
+
+
+ ))} +
+
+ ))} +
+ + + + Drag tasks between columns to change status. Tasks are saved automatically. + + +
+ ); +} diff --git a/backend/templates/agents/examples/simple-view.tsx b/backend/templates/agents/examples/simple-view.tsx new file mode 100644 index 0000000..ff74e9a --- /dev/null +++ b/backend/templates/agents/examples/simple-view.tsx @@ -0,0 +1,19 @@ +// Minimal TSX view example - demonstrates useState and UI components +export default function SimpleView() { + const [count, setCount] = useState(0); + + return ( + + + Simple Counter + + +
+ + {count} + +
+
+
+ ); +} diff --git a/backend/templates/agents/examples/tasks.csv b/backend/templates/agents/examples/tasks.csv new file mode 100644 index 0000000..688d7f8 --- /dev/null +++ b/backend/templates/agents/examples/tasks.csv @@ -0,0 +1,4 @@ +task,completed +Read the documentation,true +Create a view,false +Test the data hook,false diff --git a/backend/templates/agents/index.md b/backend/templates/agents/index.md new file mode 100644 index 0000000..73afbdf --- /dev/null +++ b/backend/templates/agents/index.md @@ -0,0 +1,38 @@ +# Wiki Index + +Quick reference for navigating this wiki. **Agents should read this first** when starting a task. + +You can edit it but keep this page structure unchanged. + +## Pages + +(Add page entries here as the wiki grows) + +## Folders + +- **agents/** - Agent configuration and wiki metadata (this folder) + - `index.md` - Navigation hub (you are here) + - `data-views.md` - Reference for TSX views and CSV data + - `examples/` - Working code examples + - `components/` - Reusable TSX components + +## Navigation Tips + +1. Use `list_pages` to see all pages (sorted alphabetically) +2. Use `grep_pages` to search content across all pages +3. Use `glob_pages` to find pages by path pattern +4. Read this index first to understand wiki structure before making changes + +## Adding New Pages + +When creating a new page: +1. Use a descriptive filename (e.g., `api-reference.md`, `getting-started.md`) +2. Update this index.md to add a navigation entry +3. Pages are sorted alphabetically - add number prefixes if specific order is needed + +## Creating Interactive Views + +For TSX views or CSV data files, see [agents/data-views.md](page:agents/data-views.md) for: +- Available hooks (`useCSV`, `usePage`, `useComponent`) +- UI components (Button, Card, Table, etc.) +- Working examples in `agents/examples/` diff --git a/etoneto-wiki b/etoneto-wiki index d11bd08..53654ee 160000 --- a/etoneto-wiki +++ b/etoneto-wiki @@ -1 +1 @@ -Subproject commit d11bd0842266c61b1f63af344f57c352ad40f71f +Subproject commit 53654ee4a1b1a15825764168aa0a4ba23fcbbc21 diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..2cee974 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,5 @@ +# Backend API host address (without protocol) +# Examples: +# localhost:8000 (local development) +# api.example.com (production) +VITE_API_HOST=localhost:8000 diff --git a/frontend/.gitignore b/frontend/.gitignore index a547bf3..1603971 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -12,6 +12,11 @@ dist dist-ssr *.local +# Environment files (keep .env.example as template) +.env +.env.local +.env.*.local + # Editor directories and files .vscode/* !.vscode/extensions.json diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 622a29c..aa8d9c9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -50,6 +50,7 @@ "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", "shiki": "^3.14.0", + "sucrase": "^3.35.0", "tailwind-merge": "^3.3.1", "tailwindcss-animate": "^1.0.7", "use-stick-to-bottom": "^1.1.1", diff --git a/frontend/package.json b/frontend/package.json index a7ceca9..8e0a417 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -56,6 +56,7 @@ "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", "shiki": "^3.14.0", + "sucrase": "^3.35.0", "tailwind-merge": "^3.3.1", "tailwindcss-animate": "^1.0.7", "use-stick-to-bottom": "^1.1.1", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 44db772..109e298 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import MainLayout from './components/layout/MainLayout'; import { AppProvider } from './contexts/AppContext'; import { AuthProvider } from './contexts/AuthContext'; diff --git a/frontend/src/components/agents/AgentSelector.tsx b/frontend/src/components/agents/AgentSelector.tsx index ed7efe0..7088342 100644 --- a/frontend/src/components/agents/AgentSelector.tsx +++ b/frontend/src/components/agents/AgentSelector.tsx @@ -1,7 +1,7 @@ /** * AgentSelector - dropdown to select between ChatAgent (default) and autonomous agents */ -import React, { useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import { Select, SelectContent, diff --git a/frontend/src/components/agents/BranchDiffPanel.tsx b/frontend/src/components/agents/BranchDiffPanel.tsx index d5d6ae1..10e49b3 100644 --- a/frontend/src/components/agents/BranchDiffPanel.tsx +++ b/frontend/src/components/agents/BranchDiffPanel.tsx @@ -1,7 +1,7 @@ /** * Branch Diff Panel - displays branch diff and merge options in center panel */ -import React, { useState, useEffect } from 'react'; +import { useState, useEffect } from 'react'; import { GitAPI } from '../../services/git-api'; import { useAppContext } from '../../contexts/AppContext'; import { DiffViewer } from './DiffViewer'; diff --git a/frontend/src/components/agents/DiffViewer.tsx b/frontend/src/components/agents/DiffViewer.tsx index a933e5e..6d4f680 100644 --- a/frontend/src/components/agents/DiffViewer.tsx +++ b/frontend/src/components/agents/DiffViewer.tsx @@ -1,7 +1,6 @@ /** * DiffViewer - displays unified diff with basic syntax highlighting */ -import React from 'react'; interface DiffViewerProps { diff: string; diff --git a/frontend/src/components/chat/MessageList.tsx b/frontend/src/components/chat/MessageList.tsx index dc7beda..93d97a4 100644 --- a/frontend/src/components/chat/MessageList.tsx +++ b/frontend/src/components/chat/MessageList.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useRef } from 'react'; -import { ChatMessage } from '../../types/chat'; +import type { ChatMessage } from '../../types/chat'; interface MessageListProps { messages: ChatMessage[]; diff --git a/frontend/src/components/common/ErrorBoundary.tsx b/frontend/src/components/common/ErrorBoundary.tsx index fcbbc2e..397daa6 100644 --- a/frontend/src/components/common/ErrorBoundary.tsx +++ b/frontend/src/components/common/ErrorBoundary.tsx @@ -1,4 +1,5 @@ -import React, { Component, ErrorInfo, ReactNode } from 'react'; +import { Component } from 'react'; +import type { ErrorInfo, ReactNode } from 'react'; interface Props { children: ReactNode; diff --git a/frontend/src/components/editor/CSVEditor.tsx b/frontend/src/components/editor/CSVEditor.tsx new file mode 100644 index 0000000..7a73551 --- /dev/null +++ b/frontend/src/components/editor/CSVEditor.tsx @@ -0,0 +1,200 @@ +/** + * Collaborative CSV table editor with Yjs backing. + * Provides inline cell editing with real-time sync. + */ + +import { useCallback, useState } from 'react'; +import { useCSV } from '../../hooks/useCSV'; +import type { DataRow } from '../../hooks/useCSV'; +import { Button } from '../ui/button'; +import { Plus, Trash2, Loader2 } from 'lucide-react'; + +interface CSVEditorProps { + pagePath: string; + initialHeaders?: string[]; + editable?: boolean; + className?: string; + onSaveStatusChange?: (status: 'saved' | 'saving' | 'dirty') => void; +} + +export const CSVEditor: React.FC = ({ + pagePath, + initialHeaders, + editable = true, + className = '', + onSaveStatusChange: _onSaveStatusChange, +}) => { + const { + rows, + headers, + updateCell, + addRow, + deleteRow, + isLoaded, + connectionStatus, + } = useCSV(pagePath, initialHeaders); + + const [editingCell, setEditingCell] = useState<{ row: number; col: string } | null>(null); + const [editValue, setEditValue] = useState(''); + + const handleCellClick = useCallback((rowIndex: number, column: string, value: string) => { + if (!editable) return; + setEditingCell({ row: rowIndex, col: column }); + setEditValue(String(value ?? '')); + }, [editable]); + + const handleCellBlur = useCallback(() => { + if (editingCell) { + updateCell(editingCell.row, editingCell.col, editValue); + setEditingCell(null); + } + }, [editingCell, editValue, updateCell]); + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleCellBlur(); + } else if (e.key === 'Escape') { + setEditingCell(null); + } else if (e.key === 'Tab' && editingCell) { + e.preventDefault(); + handleCellBlur(); + // Move to next cell + const currentColIndex = headers.indexOf(editingCell.col); + if (currentColIndex < headers.length - 1) { + const nextCol = headers[currentColIndex + 1]; + const currentValue = rows[editingCell.row]?.[nextCol]; + setEditingCell({ row: editingCell.row, col: nextCol }); + setEditValue(String(currentValue ?? '')); + } else if (editingCell.row < rows.length - 1) { + // Move to first column of next row + const nextCol = headers[0]; + const currentValue = rows[editingCell.row + 1]?.[nextCol]; + setEditingCell({ row: editingCell.row + 1, col: nextCol }); + setEditValue(String(currentValue ?? '')); + } + } + }, [editingCell, editValue, handleCellBlur, headers, rows]); + + const handleAddRow = useCallback(() => { + const newRow: DataRow = {}; + headers.forEach(h => { newRow[h] = ''; }); + addRow(newRow); + }, [addRow, headers]); + + const handleDeleteRow = useCallback((rowIndex: number) => { + deleteRow(rowIndex); + }, [deleteRow]); + + if (!isLoaded) { + return ( +
+ + Loading data... +
+ ); + } + + if (headers.length === 0) { + return ( +
+

No data yet. Add some columns to get started.

+ {editable && ( + + )} +
+ ); + } + + return ( +
+ {/* Connection status indicator */} + {connectionStatus !== 'connected' && ( +
+ {connectionStatus === 'connecting' ? 'Connecting...' : 'Disconnected'} +
+ )} + + + + + {headers.map(header => ( + + ))} + {editable && } + + + + {rows.map((row, rowIndex) => ( + + {headers.map(header => { + const isEditing = editingCell?.row === rowIndex && editingCell?.col === header; + const cellValue = row[header] ?? ''; + + return ( + + ); + })} + {editable && ( + + )} + + ))} + +
+ {header} +
handleCellClick(rowIndex, header, String(cellValue))} + > + {isEditing ? ( + setEditValue(e.target.value)} + onBlur={handleCellBlur} + onKeyDown={handleKeyDown} + autoFocus + className="w-full h-full px-3 py-2 bg-background border-none outline-none focus:ring-2 focus:ring-primary" + /> + ) : ( +
+ {String(cellValue)} +
+ )} +
+ +
+ + {editable && ( + + )} +
+ ); +}; + +export default CSVEditor; diff --git a/frontend/src/components/editor/CodeMirrorDiffView.tsx b/frontend/src/components/editor/CodeMirrorDiffView.tsx index 5866aea..5b10fc1 100644 --- a/frontend/src/components/editor/CodeMirrorDiffView.tsx +++ b/frontend/src/components/editor/CodeMirrorDiffView.tsx @@ -6,9 +6,11 @@ import { EditorView, basicSetup } from 'codemirror'; import { EditorState, RangeSetBuilder } from '@codemirror/state'; import { markdown } from '@codemirror/lang-markdown'; import { oneDark } from '@codemirror/theme-one-dark'; -import { Decoration, DecorationSet, ViewPlugin, ViewUpdate } from '@codemirror/view'; +import { Decoration, ViewPlugin } from '@codemirror/view'; +import type { DecorationSet, ViewUpdate } from '@codemirror/view'; import { diffLines } from 'diff'; import { cn } from '@/lib/utils'; +import { getApiUrl } from '../../utils/url'; interface CodeMirrorDiffViewProps { pagePath: string; @@ -43,10 +45,10 @@ export function CodeMirrorDiffView({ // Fetch both main and branch content via API (no checkout needed) useEffect(() => { Promise.all([ - fetch(`http://localhost:8000/api/pages/${encodeURIComponent(pagePath)}/at-ref?ref=main`) + fetch(`${getApiUrl()}/api/pages/${encodeURIComponent(pagePath)}/at-ref?ref=main`) .then(r => r.ok ? r.json() : { content: '' }) .then(data => data.content || ''), - fetch(`http://localhost:8000/api/pages/${encodeURIComponent(pagePath)}/at-ref?ref=${encodeURIComponent(branchName)}`) + fetch(`${getApiUrl()}/api/pages/${encodeURIComponent(pagePath)}/at-ref?ref=${encodeURIComponent(branchName)}`) .then(r => r.ok ? r.json() : { content: '' }) .then(data => data.content || '') ]) @@ -63,7 +65,7 @@ export function CodeMirrorDiffView({ }, [pagePath, branchName, refreshTrigger]); // Compute unified diff content with markers - const { diffContent, lineTypes, hasChanges } = useMemo(() => { + const { diffContent, lineTypes } = useMemo(() => { if (mainContent === null || branchContent === null) { return { diffContent: '', lineTypes: new Map(), hasChanges: false }; } diff --git a/frontend/src/components/editor/EditorToolbar.tsx b/frontend/src/components/editor/EditorToolbar.tsx index 35f8321..d8ab328 100644 --- a/frontend/src/components/editor/EditorToolbar.tsx +++ b/frontend/src/components/editor/EditorToolbar.tsx @@ -1,7 +1,7 @@ import { useEffect, useReducer } from 'react'; import { EditorView } from 'prosemirror-view'; import type { Command } from 'prosemirror-state'; -import { toggleMark, setBlockType, lift } from 'prosemirror-commands'; +import { toggleMark, setBlockType } from 'prosemirror-commands'; import { wrapInList, liftListItem } from 'prosemirror-schema-list'; import type { MarkType, NodeType } from 'prosemirror-model'; import { Button } from '@/components/ui/button'; diff --git a/frontend/src/components/layout/CenterPanel.tsx b/frontend/src/components/layout/CenterPanel.tsx index fcfeca5..4743350 100644 --- a/frontend/src/components/layout/CenterPanel.tsx +++ b/frontend/src/components/layout/CenterPanel.tsx @@ -1,21 +1,24 @@ -import React, { useState, useEffect, useCallback, useRef } from 'react'; +import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import { useAppContext } from '../../contexts/AppContext'; import { useAuth } from '../../contexts/AuthContext'; import LoadingSpinner from '../common/LoadingSpinner'; import { Button } from '@/components/ui/button'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; import { Card, CardContent } from '@/components/ui/card'; -import { AlertCircle, FileText, GitBranch, Code, Eye, Cloud, CloudOff, CloudUpload, AlertTriangle, Loader2, Pencil, Check, X } from 'lucide-react'; +import { AlertCircle, FileText, GitBranch, Code, Eye, Cloud, CloudOff, CloudUpload, AlertTriangle, Loader2, Pencil } from 'lucide-react'; import { RevisionHistory } from '../revisions/RevisionHistory'; -import type { PageRevision } from '../../types/page'; +import type { PageRevision, FileType } from '../../types/page'; import { ProseMirrorEditor } from '../editor/ProseMirrorEditor'; import { CollaborativeEditor, type CollabStatus } from '../editor/CollaborativeEditor'; import { CollaborativeCodeMirrorEditor } from '../editor/CollaborativeCodeMirrorEditor'; import { CodeMirrorEditor } from '../editor/CodeMirrorEditor'; import { CodeMirrorDiffView } from '../editor/CodeMirrorDiffView'; +import { CSVEditor } from '../editor/CSVEditor'; +import { ViewRuntime } from '../views/ViewRuntime'; import { stringToColor, getInitials } from '../../utils/colors'; import { setEditingState } from '../../services/collab'; import { ThreadsAPI, type ThreadFile } from '../../services/threads-api'; +import { getApiUrl } from '../../utils/url'; // Render filename with dimmed extension const FileName: React.FC<{ name: string; className?: string }> = ({ name, className }) => { @@ -68,6 +71,17 @@ const CenterPanel: React.FC = () => { const currentThread = threads.find(t => t.branch === currentBranch); const isResolving = currentThread?.status === 'resolving'; + // Determine file type from current page + const fileType: FileType = useMemo(() => { + if (!currentPage) return 'markdown'; + if (currentPage.file_type) return currentPage.file_type; + // Fallback to extension-based detection + const path = currentPage.path; + if (path.endsWith('.csv')) return 'csv'; + if (path.endsWith('.tsx')) return 'tsx'; + return 'markdown'; + }, [currentPage?.path, currentPage?.file_type]); + // Handle collab status changes from editors const handleCollabStatusChange = useCallback((status: CollabStatus) => { setCollabStatus(status); @@ -218,7 +232,7 @@ const CenterPanel: React.FC = () => { if (currentPage) { try { const response = await fetch( - `http://localhost:8000/api/pages/${encodeURIComponent(currentPage.path)}/at-ref?ref=${revision.sha}` + `${getApiUrl()}/api/pages/${encodeURIComponent(currentPage.path)}/at-ref?ref=${revision.sha}` ); if (response.ok) { const data = await response.json(); @@ -282,7 +296,7 @@ const CenterPanel: React.FC = () => { setTitleSaving(true); titleSavingRef.current = true; const response = await fetch( - `http://localhost:8000/api/pages/${encodeURIComponent(currentPage.path)}/rename`, + `${getApiUrl()}/api/pages/${encodeURIComponent(currentPage.path)}/rename`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -449,30 +463,63 @@ const CenterPanel: React.FC = () => { )} - {/* Single collaborative editor for both View and Edit modes - prevents remounting */} + {/* File-type aware editor rendering for View and Edit modes */} {(mainTab === 'view' || mainTab === 'edit') && !viewingRevision && (
- {formatMode === 'raw' ? ( - + ) : fileType === 'tsx' ? ( + /* TSX: View renders component, Edit shows code */ + mainTab === 'view' ? ( +
+ +
+ ) : ( + + ) ) : ( - + /* Markdown: Formatted or raw mode */ + formatMode === 'raw' ? ( + + ) : ( + + ) )}
diff --git a/frontend/src/components/pages/FolderItem.tsx b/frontend/src/components/pages/FolderItem.tsx index f85c7fe..3da9f15 100644 --- a/frontend/src/components/pages/FolderItem.tsx +++ b/frontend/src/components/pages/FolderItem.tsx @@ -2,7 +2,7 @@ import React, { useState } from 'react'; import { ChevronRight, ChevronDown, Folder, FolderOpen, Trash2, Check, X } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; -import { TreeItem } from '../../types/page'; +import type { TreeItem } from '../../types/page'; interface FolderItemProps { folder: TreeItem; diff --git a/frontend/src/components/pages/PageTree.tsx b/frontend/src/components/pages/PageTree.tsx index 2609993..e8051e0 100644 --- a/frontend/src/components/pages/PageTree.tsx +++ b/frontend/src/components/pages/PageTree.tsx @@ -1,17 +1,15 @@ -import React, { useMemo, useState, useRef, useEffect } from 'react'; +import React, { useMemo, useState, useEffect } from 'react'; import { DndContext, DragOverlay, PointerSensor, useSensor, useSensors, - DragStartEvent, - DragEndEvent, - DragOverEvent, useDraggable, useDroppable } from '@dnd-kit/core'; -import { TreeItem, Page } from '../../types/page'; +import type { DragStartEvent, DragEndEvent, DragOverEvent } from '@dnd-kit/core'; +import type { TreeItem, Page } from '../../types/page'; import { Button } from '@/components/ui/button'; import { ScrollArea } from '@/components/ui/scroll-area'; import { @@ -22,9 +20,10 @@ import { DropdownMenuLabel, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; -import { Plus, FileText, FolderPlus, GripVertical, ChevronRight, ChevronDown, Folder, FolderOpen, Trash2, LogOut, LogIn } from 'lucide-react'; +import { Plus, FileText, FileSpreadsheet, FileCode, FolderPlus, GripVertical, ChevronRight, ChevronDown, Folder, FolderOpen, Trash2, LogOut, LogIn } from 'lucide-react'; import { cn } from '@/lib/utils'; import { useAuth } from '../../contexts/AuthContext'; +import { getApiUrl } from '../../utils/url'; // Render filename with dimmed extension const FileName: React.FC<{ name: string }> = ({ name }) => { @@ -42,6 +41,13 @@ const FileName: React.FC<{ name: string }> = ({ name }) => { ); }; +// Get file icon based on path extension +const getFileIcon = (path: string) => { + if (path.endsWith('.csv')) return FileSpreadsheet; + if (path.endsWith('.tsx')) return FileCode; + return FileText; +}; + // Per-page diff stats (matches backend format) interface PageDiffStats { [pagePath: string]: { @@ -165,7 +171,7 @@ const PageTree: React.FC = ({ } // Fetch per-page diff stats with explicit head branch - fetch(`http://localhost:8000/api/git/diff-stats-by-page?base=main&head=${encodeURIComponent(currentBranch)}`) + fetch(`${getApiUrl()}/api/git/diff-stats-by-page?base=main&head=${encodeURIComponent(currentBranch)}`) .then(r => r.ok ? r.json() : { stats: {} }) .then(data => setDiffStats(data.stats || {})) .catch(() => setDiffStats({})); @@ -362,6 +368,7 @@ const PageTree: React.FC = ({ // Page item const pageStats = diffStats[item.path]; + const FileIcon = getFileIcon(item.path); return (
= ({ onClick={() => page && onPageSelect(page)} > - + {pageStats && (pageStats.additions > 0 || pageStats.deletions > 0) && ( @@ -455,7 +462,7 @@ const PageTree: React.FC = ({ onDragCancel={handleDragCancel} >
- {items.map((entry, index) => { + {items.map((entry) => { if (entry.type === 'dropzone') { // Only show drop zones when dragging if (!draggedId) return null; @@ -485,7 +492,10 @@ const PageTree: React.FC = ({ {draggedItem.type === 'folder' ? ( ) : ( - + (() => { + const DragIcon = getFileIcon(draggedItem.path); + return ; + })() )} {draggedItem.type === 'folder' ? draggedItem.title : } @@ -525,14 +535,14 @@ const PageTree: React.FC = ({ {isGuest ? ( <> window.location.href = 'http://localhost:8000/auth/google'} + onClick={() => window.location.href = `${getApiUrl()}/auth/google`} className="cursor-pointer" > Login with Google window.location.href = 'http://localhost:8000/auth/github'} + onClick={() => window.location.href = `${getApiUrl()}/auth/github`} className="cursor-pointer" > diff --git a/frontend/src/components/threads/ThreadChangesView.tsx b/frontend/src/components/threads/ThreadChangesView.tsx index 990c3ac..1f5faae 100644 --- a/frontend/src/components/threads/ThreadChangesView.tsx +++ b/frontend/src/components/threads/ThreadChangesView.tsx @@ -7,7 +7,7 @@ * - baseBranch changes * - refreshTrigger changes (for when main branch is updated) */ -import React, { useState, useEffect } from 'react'; +import { useState, useEffect } from 'react'; import { ChevronDown, ChevronRight, FilePlus, FileEdit, Trash2 } from 'lucide-react'; import { GitAPI } from '../../services/git-api'; import { DiffViewer } from '../agents/DiffViewer'; diff --git a/frontend/src/components/threads/ThreadSelector.tsx b/frontend/src/components/threads/ThreadSelector.tsx index 3c80675..d5c1d6f 100644 --- a/frontend/src/components/threads/ThreadSelector.tsx +++ b/frontend/src/components/threads/ThreadSelector.tsx @@ -29,12 +29,18 @@ interface ThreadSelectorProps { const StatusIcon: React.FC<{ status: ThreadStatus }> = ({ status }) => { switch (status) { + case "active": + return ; + case "archived": + return ; case "working": return ; case "need_help": return ; case "review": return ; + case "resolving": + return ; case "accepted": return ; case "rejected": @@ -43,9 +49,12 @@ const StatusIcon: React.FC<{ status: ThreadStatus }> = ({ status }) => { }; const statusLabel: Record = { + active: "Active", + archived: "Archived", working: "Working", need_help: "Needs help", review: "Ready for review", + resolving: "Resolving conflicts", accepted: "Accepted", rejected: "Rejected", }; diff --git a/frontend/src/components/ui/message.tsx b/frontend/src/components/ui/message.tsx index 069fcec..6ef731a 100644 --- a/frontend/src/components/ui/message.tsx +++ b/frontend/src/components/ui/message.tsx @@ -6,7 +6,8 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; -import { Markdown, MarkdownLinkHandler } from "./markdown"; +import { Markdown } from "./markdown"; +import type { MarkdownLinkHandler } from "./markdown"; export type MessageProps = { children: React.ReactNode; diff --git a/frontend/src/components/views/ViewRuntime.tsx b/frontend/src/components/views/ViewRuntime.tsx new file mode 100644 index 0000000..8e9a82d --- /dev/null +++ b/frontend/src/components/views/ViewRuntime.tsx @@ -0,0 +1,490 @@ +/** + * Runtime TSX compilation and execution using Sucrase. + * Provides a sandboxed environment for user-defined views. + * Supports component reuse via useComponent hook. + */ + +import React, { useState, useEffect, useMemo, useCallback, memo, createContext, useContext, useRef } from 'react'; +import type { ErrorInfo } from 'react'; +import { transform } from 'sucrase'; +import { useCSV } from '../../hooks/useCSV'; +import type { DataRow, SaveStatus } from '../../hooks/useCSV'; +import { usePage } from '../../hooks/usePage'; +import { useAuth } from '../../contexts/AuthContext'; +import type { CollabStatus } from '../editor/CollaborativeCodeMirrorEditor'; +import { getApiUrl } from '../../utils/url'; + +// UI components available in view scope +import { Button } from '../ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '../ui/card'; +import { Badge } from '../ui/badge'; + +// Cache for compiled components (shared across all ViewRuntime instances) +const componentCache = new Map | null; + error: Error | null; + loading: boolean; + promise: Promise | null; +}>(); + +// Context for providing component loader to user views +interface ComponentRegistryContextValue { + getComponent: (path: string) => React.ComponentType | null; + isLoading: (path: string) => boolean; + getError: (path: string) => Error | null; + loadComponent: (path: string) => void; +} + +interface ViewRuntimeProps { + /** TSX source code to compile and render */ + tsxCode: string; + /** Path to the view file (for error context) */ + pagePath: string; + /** Callback when compilation/runtime error occurs */ + onError?: (error: Error) => void; + /** Additional props to pass to the view component */ + viewProps?: Record; + /** Callback for collab status changes (save status) */ + onCollabStatusChange?: (status: CollabStatus) => void; +} + +interface ErrorBoundaryState { + hasError: boolean; + error: Error | null; +} + +// Context for tracking save status across multiple useCSV calls +interface SaveStatusContextValue { + registerStatus: (id: string, status: SaveStatus) => void; + unregisterStatus: (id: string) => void; +} + +const SaveStatusContext = createContext(null); +const ComponentRegistryContext = createContext(null); + +/** + * Compile TSX source code to a React component. + * Uses the same compilation logic as the main ViewRuntime. + */ +function compileTSXToComponent( + tsxCode: string, + scope: Record +): React.ComponentType { + const transformed = transform(tsxCode, { + transforms: ['typescript', 'jsx', 'imports'], + jsxRuntime: 'classic', + jsxPragma: 'React.createElement', + jsxFragmentPragma: 'React.Fragment', + }); + + const scopeKeys = Object.keys(scope); + const scopeValues = Object.values(scope); + + const wrappedCode = ` + "use strict"; + const exports = {}; + const module = { exports: {} }; + ${transformed.code} + if (exports.default) return exports.default; + if (module.exports.default) return module.exports.default; + if (Object.keys(module.exports).length > 0) return module.exports; + return exports; + `; + + const fn = new Function(...scopeKeys, wrappedCode); + const result = fn(...scopeValues); + + if (typeof result === 'function') { + return result; + } else if (result && typeof result.default === 'function') { + return result.default; + } + throw new Error('Component must export a React component as default export'); +} + +/** + * Wrapper hook that tracks save status and reports to context. + */ +function useCSVWithStatus( + pageId: string, + initialHeaders?: string[] +) { + const result = useCSV(pageId, initialHeaders); + const context = useContext(SaveStatusContext); + const idRef = useRef(`${pageId}-${Math.random()}`); + + useEffect(() => { + if (context) { + context.registerStatus(idRef.current, result.saveStatus); + } + return () => { + if (context) { + context.unregisterStatus(idRef.current); + } + }; + }, [context, result.saveStatus]); + + return result; +} + +/** + * Hook for loading and using other TSX components. + * Returns the component if loaded, null if loading, or throws if error. + * + * Usage in views: + * const DataTable = useComponent('components/DataTable.tsx'); + * if (!DataTable) return
Loading...
; + * return ; + */ +function createUseComponentHook(_forceUpdate: () => void) { + return function useComponent(componentPath: string): React.ComponentType | null { + const registry = useContext(ComponentRegistryContext); + + useEffect(() => { + if (registry) { + registry.loadComponent(componentPath); + } + }, [componentPath, registry]); + + if (!registry) { + console.warn('useComponent: ComponentRegistryContext not available'); + return null; + } + + const error = registry.getError(componentPath); + if (error) { + throw error; + } + + return registry.getComponent(componentPath); + }; +} + +/** + * Error boundary for catching runtime errors in user views. + */ +class ViewErrorBoundary extends React.Component< + { children: React.ReactNode; onError?: (error: Error) => void }, + ErrorBoundaryState +> { + constructor(props: { children: React.ReactNode; onError?: (error: Error) => void }) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.error('View runtime error:', error, errorInfo); + this.props.onError?.(error); + } + + render() { + if (this.state.hasError) { + return ( +
+

Runtime Error

+
+            {this.state.error?.message}
+          
+
+ ); + } + + return this.props.children; + } +} + +/** + * Create the scope object available to user views. + * Includes React, hooks, UI components, useCSV, usePage, and useComponent. + */ +const createScope = ( + useCSVHook: typeof useCSV, + usePageHook: typeof usePage, + useComponentHook: (path: string) => React.ComponentType | null +) => ({ + // React + React, + useState, + useEffect, + useMemo, + useCallback, + memo, + useRef, + + // Auth hook (needed by useCSV) + useAuth, + + // CSV data hook (wrapped to track save status) + useCSV: useCSVHook, + + // Generic text page hook + usePage: usePageHook, + + // Legacy alias for backwards compatibility + useDataPage: useCSVHook, + + // Component loader hook (for reusing other TSX components) + useComponent: useComponentHook, + + // UI components + Button, + Card, + CardContent, + CardHeader, + CardTitle, + Badge, + + // Basic HTML table components (for data display) + Table: ({ children, className = '' }: { children: React.ReactNode; className?: string }) => ( + {children}
+ ), + TableHead: ({ children, className = '' }: { children: React.ReactNode; className?: string }) => ( + {children} + ), + TableBody: ({ children, className = '' }: { children: React.ReactNode; className?: string }) => ( + {children} + ), + TableRow: ({ children, className = '' }: { children: React.ReactNode; className?: string }) => ( + {children} + ), + TableCell: ({ children, className = '' }: { children: React.ReactNode; className?: string }) => ( + {children} + ), + TableHeader: ({ children, className = '' }: { children: React.ReactNode; className?: string }) => ( + {children} + ), +}); + +/** + * ViewRuntime component - compiles and renders TSX code at runtime. + */ +export const ViewRuntime: React.FC = ({ + tsxCode, + pagePath, + onError, + viewProps = {}, + onCollabStatusChange, +}) => { + const [Component, setComponent] = useState | null>(null); + const [error, setError] = useState(null); + const [saveStatuses, setSaveStatuses] = useState>(new Map()); + const [, forceUpdate] = useState({}); + // Track loaded components to trigger re-renders when they're loaded + const [_loadedComponents, setLoadedComponents] = useState | null>>(new Map()); + void _loadedComponents; // Used to trigger re-renders + + // Create useComponent hook with forceUpdate + const useComponentHook = useMemo(() => createUseComponentHook(() => forceUpdate({})), []); + + // Aggregate save status from all useCSV hooks + const aggregatedSaveStatus = useMemo((): SaveStatus => { + if (saveStatuses.size === 0) return 'saved'; + const statuses = Array.from(saveStatuses.values()); + if (statuses.includes('saving')) return 'saving'; + if (statuses.includes('dirty')) return 'dirty'; + return 'saved'; + }, [saveStatuses]); + + // Notify parent of save status changes + useEffect(() => { + if (onCollabStatusChange) { + onCollabStatusChange({ + connectionStatus: 'connected', + remoteUsers: [], + saveStatus: aggregatedSaveStatus, + }); + } + }, [aggregatedSaveStatus, onCollabStatusChange]); + + // Context value for tracking save statuses + const saveStatusContextValue = useMemo((): SaveStatusContextValue => ({ + registerStatus: (id: string, status: SaveStatus) => { + setSaveStatuses(prev => { + const next = new Map(prev); + next.set(id, status); + return next; + }); + }, + unregisterStatus: (id: string) => { + setSaveStatuses(prev => { + const next = new Map(prev); + next.delete(id); + return next; + }); + }, + }), []); + + // Component registry context value for loading other TSX components + const componentRegistryValue = useMemo((): ComponentRegistryContextValue => ({ + getComponent: (path: string) => { + const cached = componentCache.get(path); + return cached?.component || null; + }, + isLoading: (path: string) => { + const cached = componentCache.get(path); + return cached?.loading || false; + }, + getError: (path: string) => { + const cached = componentCache.get(path); + return cached?.error || null; + }, + loadComponent: async (path: string) => { + // Check if already cached or loading + const existing = componentCache.get(path); + if (existing) { + return; + } + + // Mark as loading + componentCache.set(path, { + component: null, + error: null, + loading: true, + promise: null, + }); + + try { + // Fetch the TSX source from API + const response = await fetch(`${getApiUrl()}/api/pages/${encodeURIComponent(path)}`); + if (!response.ok) { + throw new Error(`Failed to load component: ${path}`); + } + const page = await response.json(); + const tsxSource = page.content || ''; + + // Compile the component using the same scope + // Note: This creates a simplified scope for reusable components + const componentScope = createScope(useCSVWithStatus, usePage, useComponentHook); + const compiled = compileTSXToComponent(tsxSource, componentScope); + + componentCache.set(path, { + component: compiled, + error: null, + loading: false, + promise: null, + }); + + // Update local state to trigger re-render + setLoadedComponents(prev => { + const next = new Map(prev); + next.set(path, compiled); + return next; + }); + + console.log(`Loaded component: ${path}`); + } catch (e) { + const err = e instanceof Error ? e : new Error(String(e)); + console.error(`Failed to compile component ${path}:`, err); + componentCache.set(path, { + component: null, + error: err, + loading: false, + promise: null, + }); + + // Update local state to trigger re-render + setLoadedComponents(prev => { + const next = new Map(prev); + next.set(path, null); + return next; + }); + } + }, + }), [useComponentHook]); + + useEffect(() => { + if (!tsxCode.trim()) { + setComponent(null); + setError(null); + return; + } + + try { + // Transform TSX to JS using Sucrase + // Include 'imports' to convert ES modules to CommonJS + const transformed = transform(tsxCode, { + transforms: ['typescript', 'jsx', 'imports'], + jsxRuntime: 'classic', + jsxPragma: 'React.createElement', + jsxFragmentPragma: 'React.Fragment', + }); + + // Create function from transformed code + // Use the wrapped useCSV that tracks save status + const scope = createScope(useCSVWithStatus, usePage, useComponentHook); + const scopeKeys = Object.keys(scope); + const scopeValues = Object.values(scope); + + // Wrap in function that returns the default export + // Sucrase with 'imports' transform sets exports.default for default exports + const wrappedCode = ` + "use strict"; + const exports = {}; + const module = { exports: {} }; + ${transformed.code} + // Check exports.default first (Sucrase uses this for default exports) + if (exports.default) return exports.default; + if (module.exports.default) return module.exports.default; + // Fall back to module.exports if it has properties + if (Object.keys(module.exports).length > 0) return module.exports; + return exports; + `; + + // Create and execute function with scope + const fn = new Function(...scopeKeys, wrappedCode); + const result = fn(...scopeValues); + + if (typeof result === 'function') { + setComponent(() => result); + setError(null); + } else if (result && typeof result.default === 'function') { + setComponent(() => result.default); + setError(null); + } else { + throw new Error('View must export a React component as default export'); + } + } catch (e) { + const err = e instanceof Error ? e : new Error(String(e)); + console.error('View compilation error:', err); + setError(err); + setComponent(null); + onError?.(err); + } + }, [tsxCode, onError, useComponentHook]); + + if (error) { + return ( +
+

Compilation Error

+

File: {pagePath}

+
+          {error.message}
+        
+
+ ); + } + + if (!Component) { + return ( +
+ Loading view... +
+ ); + } + + return ( + + + + + + + + ); +}; + +export default ViewRuntime; diff --git a/frontend/src/contexts/AppContext.tsx b/frontend/src/contexts/AppContext.tsx index 54b1eee..16f757d 100644 --- a/frontend/src/contexts/AppContext.tsx +++ b/frontend/src/contexts/AppContext.tsx @@ -6,6 +6,7 @@ import { treeApi } from '../services/tree-api'; import type { Thread, ThreadMessage, ThreadStatus } from '../types/thread'; import { useAuth } from './AuthContext'; import { invalidateSessions } from '../services/collab'; +import { getApiUrl } from '../utils/url'; interface AppState { pages: Page[]; @@ -286,6 +287,9 @@ interface AppContextType { moveItem: (sourcePath: string, targetParentPath: string | null, newOrder: number) => Promise; createFolder: (name: string, parentPath?: string) => Promise; deleteFolder: (path: string) => Promise; + // Branch actions + refreshBranches: () => Promise; + checkoutBranch: (branch: string) => Promise; }; websocket: { sendMessage: (message: unknown) => void; @@ -564,7 +568,7 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children } try { - const response = await fetch('http://localhost:8000/api/pages'); + const response = await fetch(`${getApiUrl()}/api/pages`); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } @@ -583,7 +587,7 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children // The page content will be updated when the user navigates or refreshes if (!currentPageRef.current && backendPages.length > 0) { try { - const pageResponse = await fetch(`http://localhost:8000/api/pages/${encodeURIComponent(backendPages[0].path)}`); + const pageResponse = await fetch(`${getApiUrl()}/api/pages/${encodeURIComponent(backendPages[0].path)}`); if (pageResponse.ok) { const fullPage = await pageResponse.json(); currentPageRef.current = fullPage; // Update ref immediately to prevent duplicate dispatches @@ -614,7 +618,7 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children } try { - const pageResponse = await fetch(`http://localhost:8000/api/pages/${encodeURIComponent(pageTitle)}`); + const pageResponse = await fetch(`${getApiUrl()}/api/pages/${encodeURIComponent(pageTitle)}`); if (pageResponse.ok) { const fullPage = await pageResponse.json(); // Only update if content actually changed (compare by content length and first/last chars as quick check) @@ -637,7 +641,7 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children } try { - const pageResponse = await fetch(`http://localhost:8000/api/pages/${encodeURIComponent(currentPageRef.current.path)}`); + const pageResponse = await fetch(`${getApiUrl()}/api/pages/${encodeURIComponent(currentPageRef.current.path)}`); if (pageResponse.ok) { const fullPage = await pageResponse.json(); currentPageRef.current = fullPage; @@ -717,7 +721,7 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children dispatch({ type: 'SET_ERROR', payload: null }); try { - const response = await fetch(`http://localhost:8000/api/pages/${encodeURIComponent(title)}`, { + const response = await fetch(`${getApiUrl()}/api/pages/${encodeURIComponent(title)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updates), @@ -741,7 +745,7 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children dispatch({ type: 'SET_ERROR', payload: null }); try { - const response = await fetch(`http://localhost:8000/api/pages/${encodeURIComponent(path)}?author=user`, { + const response = await fetch(`${getApiUrl()}/api/pages/${encodeURIComponent(path)}?author=user`, { method: 'DELETE', }); @@ -769,7 +773,7 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children dispatch({ type: 'SET_ERROR', payload: null }); try { - const response = await fetch(`http://localhost:8000/api/pages/${encodeURIComponent(page.path)}`); + const response = await fetch(`${getApiUrl()}/api/pages/${encodeURIComponent(page.path)}`); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } @@ -796,7 +800,7 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children dispatch({ type: 'SET_ERROR', payload: null }); try { - const response = await fetch('http://localhost:8000/api/pages', { + const response = await fetch(`${getApiUrl()}/api/pages`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -906,7 +910,7 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children await treeApi.moveItem({ sourcePath, targetParentPath, newOrder }); const tree = await treeApi.getTree(); dispatch({ type: 'SET_PAGE_TREE', payload: tree }); - const response = await fetch('http://localhost:8000/api/pages'); + const response = await fetch(`${getApiUrl()}/api/pages`); if (response.ok) { const data = await response.json(); dispatch({ type: 'SET_PAGES', payload: data.pages }); @@ -943,6 +947,41 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children console.error('Failed to delete folder:', err); dispatch({ type: 'SET_ERROR', payload: 'Failed to delete folder' }); } + }, + + // Branch actions + refreshBranches: async () => { + // Just fetch to verify endpoint is accessible - branch list is managed elsewhere + try { + await fetch(`${getApiUrl()}/api/git/branches`); + } catch (err) { + console.error('Failed to refresh branches:', err); + } + }, + + checkoutBranch: async (branch: string) => { + try { + const response = await fetch(`${getApiUrl()}/api/git/checkout`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ branch }), + }); + if (!response.ok) { + throw new Error(`Failed to checkout branch: ${response.statusText}`); + } + dispatch({ type: 'SET_CURRENT_BRANCH', payload: branch }); + // Reload pages and tree after checkout + const pagesResponse = await fetch(`${getApiUrl()}/api/pages`); + if (pagesResponse.ok) { + const data = await pagesResponse.json(); + dispatch({ type: 'SET_PAGES', payload: data.pages }); + } + const tree = await treeApi.getTree(); + dispatch({ type: 'SET_PAGE_TREE', payload: tree }); + } catch (err) { + console.error('Failed to checkout branch:', err); + dispatch({ type: 'SET_ERROR', payload: 'Failed to checkout branch' }); + } } }; diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index 12315bf..dba8341 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -1,5 +1,6 @@ import React, { createContext, useContext, useState, useEffect, useCallback } from 'react'; -import { User, initAuth, clearGuestId, getStoredGuestId } from '../services/auth-api'; +import type { User } from '../services/auth-api'; +import { initAuth, clearGuestId, getStoredGuestId } from '../services/auth-api'; interface AuthContextType { user: User | null; diff --git a/frontend/src/hooks/useCSV.ts b/frontend/src/hooks/useCSV.ts new file mode 100644 index 0000000..18232a1 --- /dev/null +++ b/frontend/src/hooks/useCSV.ts @@ -0,0 +1,463 @@ +/** + * Hook for collaborative CSV data editing using Yjs. + * Provides access to CSV data via Y.Array with real-time sync. + */ + +import { useState, useEffect, useCallback } from 'react'; +import * as Y from 'yjs'; +import { WebsocketProvider } from 'y-websocket'; +import { useAuth } from '../contexts/AuthContext'; +import { stringToColor, getInitials, getDisplayName } from '../utils/colors'; +import { getWsUrl, getApiUrl } from '../utils/url'; +import { updatePage } from '../services/api'; + +// Debounce delay for auto-save (milliseconds) +const SAVE_DEBOUNCE_MS = 2000; + +export interface DataRow { + [key: string]: string | number | boolean; +} + +export type SaveStatus = 'saved' | 'saving' | 'dirty'; + +export interface UseCSVResult { + /** Current rows from the data file */ + rows: T[]; + /** Column headers (from first row or explicitly set) */ + headers: string[]; + /** Update a specific cell value */ + updateCell: (rowIndex: number, column: keyof T, value: string) => void; + /** Add a new row at the end */ + addRow: (row: T) => void; + /** Delete a row by index */ + deleteRow: (rowIndex: number) => void; + /** Insert a row at a specific index */ + insertRow: (index: number, row: T) => void; + /** Whether the data has been loaded */ + isLoaded: boolean; + /** Whether the data is currently syncing */ + isSyncing: boolean; + /** Connection status */ + connectionStatus: 'connecting' | 'connected' | 'disconnected'; + /** Save status for the data */ + saveStatus: SaveStatus; +} + +// Save status listeners per page +type SaveStatusListener = (status: SaveStatus) => void; +const saveStatusListeners = new Map>(); + +function notifySaveStatus(pageId: string, status: SaveStatus): void { + const listeners = saveStatusListeners.get(pageId); + if (listeners) { + listeners.forEach(listener => listener(status)); + } +} + +// Active data sessions by page path +const activeDataSessions = new Map>; + headers: string[]; + saveTimer: ReturnType | null; + lastSavedContent: string; + isInitialLoad: boolean; + saveStatus: SaveStatus; +}>(); + +/** + * Serialize Y.Array to CSV and save to backend. + */ +async function saveCSVData(pageId: string): Promise { + const session = activeDataSessions.get(pageId); + if (!session) return; + + const content = serializeDataToCSV(session.yArray); + + // Skip if content hasn't changed + if (content === session.lastSavedContent) { + session.saveStatus = 'saved'; + notifySaveStatus(pageId, 'saved'); + return; + } + + session.saveStatus = 'saving'; + notifySaveStatus(pageId, 'saving'); + + try { + await updatePage(pageId, { + content, + author: 'collaborative', + }); + session.lastSavedContent = content; + session.saveStatus = 'saved'; + notifySaveStatus(pageId, 'saved'); + console.log(`Saved CSV data: ${pageId}`); + } catch (error) { + console.error(`Failed to save CSV ${pageId}:`, error); + // Revert to dirty so it will retry + session.saveStatus = 'dirty'; + notifySaveStatus(pageId, 'dirty'); + } +} + +/** + * Schedule a debounced save for CSV data. + */ +function scheduleSaveCSV(pageId: string): void { + const session = activeDataSessions.get(pageId); + if (!session || session.isInitialLoad) return; + + // Mark as dirty + session.saveStatus = 'dirty'; + notifySaveStatus(pageId, 'dirty'); + + // Clear existing timer + if (session.saveTimer) { + clearTimeout(session.saveTimer); + } + + // Schedule new save + session.saveTimer = setTimeout(() => { + saveCSVData(pageId); + session.saveTimer = null; + }, SAVE_DEBOUNCE_MS); +} + +/** + * Hook for collaborative CSV data editing. + * Syncs CSV data via Yjs Y.Array. + * + * @param pageId - The path to the CSV file (e.g., "data/tasks.csv") + * @param initialHeaders - Optional initial headers if creating new file + */ +export function useCSV( + pageId: string, + initialHeaders?: string[] +): UseCSVResult { + const { userId } = useAuth(); + const [rows, setRows] = useState([]); + const [headers, setHeaders] = useState(initialHeaders || []); + const [isLoaded, setIsLoaded] = useState(false); + const [isSyncing, setIsSyncing] = useState(false); + const [connectionStatus, setConnectionStatus] = useState<'connecting' | 'connected' | 'disconnected'>('connecting'); + const [saveStatus, setSaveStatus] = useState('saved'); + const [session, setSession] = useState ? V : never>(); + + useEffect(() => { + if (!userId || !pageId || !pageId.endsWith('.csv')) { + return; + } + + // Check for existing session + let existingSession = activeDataSessions.get(pageId); + let isNewSession = false; + + if (!existingSession) { + isNewSession = true; + // Create new Yjs document and provider + const doc = new Y.Doc(); + const wsUrl = getWsUrl('/ws/collab'); + + const provider = new WebsocketProvider(wsUrl, pageId, doc, { + connect: true, + params: { userId, clientId: userId }, + }); + + // Set up awareness + provider.awareness.setLocalStateField('user', { + id: userId, + name: getDisplayName(userId), + color: stringToColor(userId), + initials: getInitials(userId), + }); + + // Get Y.Array for data + const yArray = doc.getArray>('data'); + + existingSession = { + doc, + provider, + yArray, + headers: initialHeaders || [], + saveTimer: null, + lastSavedContent: '', + isInitialLoad: true, + saveStatus: 'saved' as SaveStatus, + }; + activeDataSessions.set(pageId, existingSession); + + // Set up auto-save observer + yArray.observeDeep(() => { + scheduleSaveCSV(pageId); + }); + + // Mark initial load complete after sync settles + provider.on('sync', (synced: boolean) => { + if (synced) { + setTimeout(() => { + const session = activeDataSessions.get(pageId); + if (session) { + session.isInitialLoad = false; + session.lastSavedContent = serializeDataToCSV(session.yArray); + console.log(`CSV initial load complete: ${pageId}`); + } + }, 500); + } + }); + } + + const { provider, yArray, doc } = existingSession; + + // Handle connection status - always set up for this hook instance + const handleStatus = (event: { status: string }) => { + setConnectionStatus( + event.status === 'connected' ? 'connected' : + event.status === 'connecting' ? 'connecting' : + 'disconnected' + ); + }; + provider.on('status', handleStatus); + + // Handle sync - fetch and initialize CSV content if empty + const handleSync = async (synced: boolean) => { + if (synced) { + setIsSyncing(false); + + // If Y.Array is empty, fetch CSV content from server and initialize + if (yArray.length === 0) { + try { + const response = await fetch(`${getApiUrl()}/api/pages/${encodeURIComponent(pageId)}`); + if (response.ok) { + const page = await response.json(); + if (page.content) { + initializeDataFromCSV(pageId, page.content, doc); + } + } + } catch (err) { + console.error('Failed to fetch CSV content:', err); + } + } + + setIsLoaded(true); + } + }; + provider.on('sync', handleSync); + + // If reusing an existing session that's already synced, mark as loaded immediately + if (!isNewSession && provider.synced) { + setIsLoaded(true); + setConnectionStatus('connected'); + } + + setSession(existingSession); + + // Convert Y.Array to plain array + const updateRows = () => { + const yArray = existingSession!.yArray; + const newRows: T[] = yArray.toArray().map(yMap => { + const obj: any = {}; + yMap.forEach((value, key) => { + obj[key] = value; + }); + return obj as T; + }); + setRows(newRows); + + // Extract headers from first row if not set + if (newRows.length > 0 && headers.length === 0) { + const firstRowHeaders = Object.keys(newRows[0]); + setHeaders(firstRowHeaders); + existingSession!.headers = firstRowHeaders; + } + }; + + // Initial load + updateRows(); + + // Observe changes - use observeDeep to catch Y.Map updates inside the array + existingSession.yArray.observeDeep(updateRows); + + // Subscribe to save status changes + if (!saveStatusListeners.has(pageId)) { + saveStatusListeners.set(pageId, new Set()); + } + const handleSaveStatus = (status: SaveStatus) => { + setSaveStatus(status); + }; + saveStatusListeners.get(pageId)!.add(handleSaveStatus); + + // Set initial save status from session + setSaveStatus(existingSession.saveStatus); + + return () => { + existingSession?.yArray.unobserveDeep(updateRows); + // Remove event handlers to avoid duplicates + provider.off('status', handleStatus); + provider.off('sync', handleSync); + // Remove save status listener + saveStatusListeners.get(pageId)?.delete(handleSaveStatus); + // Don't destroy session - let it persist for view switching + }; + }, [pageId, userId, initialHeaders]); + + const updateCell = useCallback((rowIndex: number, column: keyof T, value: string) => { + if (!session?.yArray) return; + const yMap = session.yArray.get(rowIndex); + if (yMap) { + yMap.set(column as string, value); + } + }, [session]); + + const addRow = useCallback((row: T) => { + if (!session?.yArray) return; + const yMap = new Y.Map(); + Object.entries(row).forEach(([key, value]) => { + yMap.set(key, value); + }); + session.yArray.push([yMap]); + }, [session]); + + const deleteRow = useCallback((rowIndex: number) => { + if (!session?.yArray) return; + session.yArray.delete(rowIndex, 1); + }, [session]); + + const insertRow = useCallback((index: number, row: T) => { + if (!session?.yArray) return; + const yMap = new Y.Map(); + Object.entries(row).forEach(([key, value]) => { + yMap.set(key, value); + }); + session.yArray.insert(index, [yMap]); + }, [session]); + + return { + rows, + headers, + updateCell, + addRow, + deleteRow, + insertRow, + isLoaded, + isSyncing, + connectionStatus, + saveStatus, + }; +} + +/** + * Initialize a data page from CSV content. + * Call this when first loading a CSV file to populate the Y.Array. + */ +export function initializeDataFromCSV( + _pageId: string, + csvContent: string, + doc: Y.Doc +): void { + const yArray = doc.getArray>('data'); + + // Don't reinitialize if already has data + if (yArray.length > 0) return; + + const lines = csvContent.split('\n').filter(l => l.trim()); + if (lines.length === 0) return; + + // Parse headers + const headers = parseCSVLine(lines[0]); + + // Parse rows in a transaction + doc.transact(() => { + lines.slice(1).forEach(line => { + const values = parseCSVLine(line); + const yMap = new Y.Map(); + headers.forEach((h, i) => { + yMap.set(h, values[i] ?? ''); + }); + yArray.push([yMap]); + }); + }); +} + +/** + * Serialize Y.Array data to CSV string. + */ +export function serializeDataToCSV(yArray: Y.Array>): string { + const rows = yArray.toArray(); + if (rows.length === 0) return ''; + + // Get headers from first row + const headers: string[] = []; + rows[0].forEach((_, key) => headers.push(key)); + + // Build CSV + const lines = [headers.join(',')]; + rows.forEach(yMap => { + const values = headers.map(h => { + const v = yMap.get(h); + // Escape and quote if needed + const str = String(v ?? ''); + if (str.includes(',') || str.includes('"') || str.includes('\n')) { + return `"${str.replace(/"/g, '""')}"`; + } + return str; + }); + lines.push(values.join(',')); + }); + + return lines.join('\n'); +} + +/** + * Parse a CSV line into values. + */ +function parseCSVLine(line: string): string[] { + const result: string[] = []; + let current = ''; + let inQuotes = false; + + for (let i = 0; i < line.length; i++) { + const char = line[i]; + if (char === '"') { + if (inQuotes && line[i + 1] === '"') { + current += '"'; + i++; // Skip next quote + } else { + inQuotes = !inQuotes; + } + } else if (char === ',' && !inQuotes) { + result.push(current); + current = ''; + } else { + current += char; + } + } + result.push(current); + return result; +} + +/** + * Clean up a data session when navigating away. + */ +export function destroyDataSession(pageId: string): void { + const session = activeDataSessions.get(pageId); + if (session) { + // Clear pending save timer + if (session.saveTimer) { + clearTimeout(session.saveTimer); + } + session.provider.awareness.setLocalState(null); + session.provider.disconnect(); + session.provider.destroy(); + session.doc.destroy(); + activeDataSessions.delete(pageId); + } +} + +/** + * Check if a data session exists. + */ +export function hasDataSession(pageId: string): boolean { + return activeDataSessions.has(pageId); +} diff --git a/frontend/src/hooks/usePage.ts b/frontend/src/hooks/usePage.ts new file mode 100644 index 0000000..65d5f9f --- /dev/null +++ b/frontend/src/hooks/usePage.ts @@ -0,0 +1,313 @@ +/** + * Hook for collaborative text page editing using Yjs. + * Provides access to any text file content with real-time sync. + * Works with .md, .txt, .json, .tsx, and any other text files. + */ + +import { useState, useEffect, useCallback } from 'react'; +import * as Y from 'yjs'; +import { WebsocketProvider } from 'y-websocket'; +import { useAuth } from '../contexts/AuthContext'; +import { stringToColor, getInitials, getDisplayName } from '../utils/colors'; +import { getWsUrl, getApiUrl } from '../utils/url'; +import { updatePage } from '../services/api'; + +// Debounce delay for auto-save (milliseconds) +const SAVE_DEBOUNCE_MS = 2000; + +export type SaveStatus = 'saved' | 'saving' | 'dirty'; + +export interface UsePageResult { + /** Current text content of the file */ + content: string; + /** Update the content */ + setContent: (content: string) => void; + /** Whether the content has been loaded */ + isLoaded: boolean; + /** Whether the content is currently syncing */ + isSyncing: boolean; + /** Connection status */ + connectionStatus: 'connecting' | 'connected' | 'disconnected'; + /** Save status for the content */ + saveStatus: SaveStatus; +} + +// Save status listeners per page +type SaveStatusListener = (status: SaveStatus) => void; +const saveStatusListeners = new Map>(); + +function notifySaveStatus(pageId: string, status: SaveStatus): void { + const listeners = saveStatusListeners.get(pageId); + if (listeners) { + listeners.forEach(listener => listener(status)); + } +} + +// Active text sessions by page path +const activeTextSessions = new Map | null; + lastSavedContent: string; + isInitialLoad: boolean; + saveStatus: SaveStatus; +}>(); + +/** + * Save text content to backend. + */ +async function saveTextContent(pageId: string): Promise { + const session = activeTextSessions.get(pageId); + if (!session) return; + + const content = session.yText.toString(); + + // Skip if content hasn't changed + if (content === session.lastSavedContent) { + session.saveStatus = 'saved'; + notifySaveStatus(pageId, 'saved'); + return; + } + + session.saveStatus = 'saving'; + notifySaveStatus(pageId, 'saving'); + + try { + await updatePage(pageId, { + content, + author: 'collaborative', + }); + session.lastSavedContent = content; + session.saveStatus = 'saved'; + notifySaveStatus(pageId, 'saved'); + console.log(`Saved text content: ${pageId}`); + } catch (error) { + console.error(`Failed to save text ${pageId}:`, error); + // Revert to dirty so it will retry + session.saveStatus = 'dirty'; + notifySaveStatus(pageId, 'dirty'); + } +} + +/** + * Schedule a debounced save for text content. + */ +function scheduleSaveText(pageId: string): void { + const session = activeTextSessions.get(pageId); + if (!session || session.isInitialLoad) return; + + // Mark as dirty + session.saveStatus = 'dirty'; + notifySaveStatus(pageId, 'dirty'); + + // Clear existing timer + if (session.saveTimer) { + clearTimeout(session.saveTimer); + } + + // Schedule new save + session.saveTimer = setTimeout(() => { + saveTextContent(pageId); + session.saveTimer = null; + }, SAVE_DEBOUNCE_MS); +} + +/** + * Hook for collaborative text page editing. + * Syncs text content via Yjs Y.Text. + * + * @param pageId - The path to the text file (e.g., "notes.md", "config.json") + */ +export function usePage(pageId: string): UsePageResult { + const { userId } = useAuth(); + const [content, setContentState] = useState(''); + const [isLoaded, setIsLoaded] = useState(false); + const [isSyncing, setIsSyncing] = useState(false); + const [connectionStatus, setConnectionStatus] = useState<'connecting' | 'connected' | 'disconnected'>('connecting'); + const [saveStatus, setSaveStatus] = useState('saved'); + const [session, setSession] = useState ? V : never>(); + + useEffect(() => { + if (!userId || !pageId) { + return; + } + + // Check for existing session + let existingSession = activeTextSessions.get(pageId); + let isNewSession = false; + + if (!existingSession) { + isNewSession = true; + // Create new Yjs document and provider + const doc = new Y.Doc(); + const wsUrl = getWsUrl('/ws/collab'); + + const provider = new WebsocketProvider(wsUrl, pageId, doc, { + connect: true, + params: { userId, clientId: userId }, + }); + + // Set up awareness + provider.awareness.setLocalStateField('user', { + id: userId, + name: getDisplayName(userId), + color: stringToColor(userId), + initials: getInitials(userId), + }); + + // Get Y.Text for content + const yText = doc.getText('content'); + + existingSession = { + doc, + provider, + yText, + saveTimer: null, + lastSavedContent: '', + isInitialLoad: true, + saveStatus: 'saved' as SaveStatus, + }; + activeTextSessions.set(pageId, existingSession); + + // Set up auto-save observer + yText.observe(() => { + scheduleSaveText(pageId); + }); + + // Mark initial load complete after sync settles + provider.on('sync', (synced: boolean) => { + if (synced) { + setTimeout(() => { + const session = activeTextSessions.get(pageId); + if (session) { + session.isInitialLoad = false; + session.lastSavedContent = session.yText.toString(); + console.log(`Text initial load complete: ${pageId}`); + } + }, 500); + } + }); + } + + const { provider, yText, doc } = existingSession; + + // Handle connection status + const handleStatus = (event: { status: string }) => { + setConnectionStatus( + event.status === 'connected' ? 'connected' : + event.status === 'connecting' ? 'connecting' : + 'disconnected' + ); + }; + provider.on('status', handleStatus); + + // Handle sync - fetch and initialize content if empty + const handleSync = async (synced: boolean) => { + if (synced) { + setIsSyncing(false); + + // If Y.Text is empty, fetch content from server and initialize + if (yText.length === 0) { + try { + const response = await fetch(`${getApiUrl()}/api/pages/${encodeURIComponent(pageId)}`); + if (response.ok) { + const page = await response.json(); + if (page.content) { + doc.transact(() => { + yText.insert(0, page.content); + }); + } + } + } catch (err) { + console.error('Failed to fetch text content:', err); + } + } + + setIsLoaded(true); + } + }; + provider.on('sync', handleSync); + + // If reusing an existing session that's already synced, mark as loaded immediately + if (!isNewSession && provider.synced) { + setIsLoaded(true); + setConnectionStatus('connected'); + } + + setSession(existingSession); + + // Update content state when Y.Text changes + const updateContent = () => { + setContentState(existingSession!.yText.toString()); + }; + + // Initial load + updateContent(); + + // Observe changes + existingSession.yText.observe(updateContent); + + // Subscribe to save status changes + if (!saveStatusListeners.has(pageId)) { + saveStatusListeners.set(pageId, new Set()); + } + const handleSaveStatus = (status: SaveStatus) => { + setSaveStatus(status); + }; + saveStatusListeners.get(pageId)!.add(handleSaveStatus); + + // Set initial save status from session + setSaveStatus(existingSession.saveStatus); + + return () => { + existingSession?.yText.unobserve(updateContent); + provider.off('status', handleStatus); + provider.off('sync', handleSync); + saveStatusListeners.get(pageId)?.delete(handleSaveStatus); + }; + }, [pageId, userId]); + + const setContent = useCallback((newContent: string) => { + if (!session?.yText) return; + + session.doc.transact(() => { + // Clear existing content and insert new + session.yText.delete(0, session.yText.length); + session.yText.insert(0, newContent); + }); + }, [session]); + + return { + content, + setContent, + isLoaded, + isSyncing, + connectionStatus, + saveStatus, + }; +} + +/** + * Clean up a text session when navigating away. + */ +export function destroyTextSession(pageId: string): void { + const session = activeTextSessions.get(pageId); + if (session) { + if (session.saveTimer) { + clearTimeout(session.saveTimer); + } + session.provider.awareness.setLocalState(null); + session.provider.disconnect(); + session.provider.destroy(); + session.doc.destroy(); + activeTextSessions.delete(pageId); + } +} + +/** + * Check if a text session exists. + */ +export function hasTextSession(pageId: string): boolean { + return activeTextSessions.has(pageId); +} diff --git a/frontend/src/hooks/usePages.ts b/frontend/src/hooks/usePages.ts index 7f2a29e..602563e 100644 --- a/frontend/src/hooks/usePages.ts +++ b/frontend/src/hooks/usePages.ts @@ -1,6 +1,7 @@ import { useState, useCallback, useEffect } from 'react'; -import { Page, PageTreeItem, ViewMode } from '../types/page'; +import type { Page, PageTreeItem, ViewMode } from '../types/page'; import { useAppContext } from '../contexts/AppContext'; +import { getApiUrl } from '../utils/url'; export interface UsePagesReturn { pages: Page[]; @@ -12,8 +13,8 @@ export interface UsePagesReturn { setCurrentPage: (page: Page | null) => void; setViewMode: (mode: ViewMode) => void; createPage: (title: string, content?: string) => Promise; - updatePage: (id: number, updates: Partial) => Promise; - deletePage: (id: number) => Promise; + updatePage: (path: string, updates: Partial) => Promise; + deletePage: (path: string) => Promise; loadPages: () => Promise; } @@ -27,10 +28,37 @@ export const usePages = (): UsePagesReturn => { // Generate page tree structure const pageTree: PageTreeItem[] = pages.map(page => ({ - id: page.id, - title: page.title + title: page.title, + path: page.path })); + const loadPages = useCallback(async () => { + setIsLoading(true); + setError(null); + + try { + const response = await fetch(`${getApiUrl()}/api/pages`); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + const backendPages: Page[] = data.pages; + + setPages(backendPages); + + // Set current page to first page if none selected and pages exist + if (!currentPage && backendPages.length > 0) { + setCurrentPage(backendPages[0]); + } + } catch (err) { + setError('Failed to load pages'); + console.error('Error loading pages:', err); + } finally { + setIsLoading(false); + } + }, [currentPage]); + // Load pages from backend on initialization useEffect(() => { loadPages(); @@ -53,9 +81,10 @@ export const usePages = (): UsePagesReturn => { try { const newPage: Page = { - id: Date.now(), // In real app, this would come from backend + path: title, title, content, + file_type: 'markdown', author: 'user', created_at: new Date().toISOString(), updated_at: new Date().toISOString(), @@ -73,7 +102,7 @@ export const usePages = (): UsePagesReturn => { } }, []); - const updatePage = useCallback(async (id: number, updates: Partial) => { + const updatePage = useCallback(async (path: string, updates: Partial) => { setIsLoading(true); setError(null); @@ -84,10 +113,10 @@ export const usePages = (): UsePagesReturn => { }; setPages(prev => prev.map(page => - page.id === id ? { ...page, ...updatedPage } : page + page.path === path ? { ...page, ...updatedPage } : page )); - if (currentPage?.id === id) { + if (currentPage?.path === path) { setCurrentPage(prev => prev ? { ...prev, ...updatedPage } : null); } } catch (err) { @@ -98,15 +127,15 @@ export const usePages = (): UsePagesReturn => { } }, [currentPage]); - const deletePage = useCallback(async (id: number) => { + const deletePage = useCallback(async (path: string) => { setIsLoading(true); setError(null); try { - setPages(prev => prev.filter(page => page.id !== id)); + setPages(prev => prev.filter(page => page.path !== path)); - if (currentPage?.id === id) { - const remainingPages = pages.filter(page => page.id !== id); + if (currentPage?.path === path) { + const remainingPages = pages.filter(page => page.path !== path); setCurrentPage(remainingPages.length > 0 ? remainingPages[0] : null); } } catch (err) { @@ -117,33 +146,6 @@ export const usePages = (): UsePagesReturn => { } }, [currentPage, pages]); - const loadPages = useCallback(async () => { - setIsLoading(true); - setError(null); - - try { - const response = await fetch('http://localhost:8000/api/pages'); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - const data = await response.json(); - const backendPages: Page[] = data.pages; - - setPages(backendPages); - - // Set current page to first page if none selected and pages exist - if (!currentPage && backendPages.length > 0) { - setCurrentPage(backendPages[0]); - } - } catch (err) { - setError('Failed to load pages'); - console.error('Error loading pages:', err); - } finally { - setIsLoading(false); - } - }, [currentPage]); - return { pages, currentPage, @@ -158,4 +160,4 @@ export const usePages = (): UsePagesReturn => { deletePage, loadPages }; -}; \ No newline at end of file +}; diff --git a/frontend/src/hooks/useWebSocket.ts b/frontend/src/hooks/useWebSocket.ts index d648e07..316d654 100644 --- a/frontend/src/hooks/useWebSocket.ts +++ b/frontend/src/hooks/useWebSocket.ts @@ -1,6 +1,6 @@ import { useEffect, useRef, useState, useCallback } from 'react'; -import { WebSocketService, createWebSocketService, WebSocketEventHandler, ConnectionStatusHandler } from '../services/websocket'; -import { WebSocketMessage } from '../types/chat'; +import { WebSocketService, createWebSocketService } from '../services/websocket'; +import type { WebSocketMessage } from '../types/chat'; export interface UseWebSocketReturn { connectionStatus: 'connecting' | 'connected' | 'disconnected' | 'error'; diff --git a/frontend/src/services/agents-api.ts b/frontend/src/services/agents-api.ts index 49d9288..a444f57 100644 --- a/frontend/src/services/agents-api.ts +++ b/frontend/src/services/agents-api.ts @@ -2,8 +2,9 @@ * API client for agent operations */ import type { Agent, AgentExecutionResult } from '../types/agent'; +import { getApiUrl } from '../utils/url'; -const API_BASE_URL = 'http://localhost:8000'; +const API_BASE_URL = getApiUrl(); export class AgentsAPI { /** diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 197d667..16da5e5 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -1,6 +1,7 @@ -import { Page, PageRevision } from '../types/page'; +import type { Page, PageRevision } from '../types/page'; +import { getApiUrl } from '../utils/url'; -const API_BASE_URL = 'http://localhost:8000/api'; +const API_BASE_URL = `${getApiUrl()}/api`; /** * API service for interacting with the backend REST API diff --git a/frontend/src/services/auth-api.ts b/frontend/src/services/auth-api.ts index 22cb749..2488044 100644 --- a/frontend/src/services/auth-api.ts +++ b/frontend/src/services/auth-api.ts @@ -1,8 +1,9 @@ /** * Authentication API service */ +import { getApiUrl } from '../utils/url'; -const API_BASE = 'http://localhost:8000'; +const API_BASE = getApiUrl(); const GUEST_ID_KEY = 'etoneto_guest_id'; export interface User { diff --git a/frontend/src/services/collab.ts b/frontend/src/services/collab.ts index 2091769..b9ee7d4 100644 --- a/frontend/src/services/collab.ts +++ b/frontend/src/services/collab.ts @@ -6,6 +6,7 @@ import * as Y from 'yjs'; import { WebsocketProvider } from 'y-websocket'; import { stringToColor, getInitials, getDisplayName } from '../utils/colors'; +import { getWsUrl, getApiUrl } from '../utils/url'; import { updatePage } from './api'; export type ConnectionStatus = 'connecting' | 'connected' | 'disconnected' | 'error'; @@ -63,16 +64,57 @@ function notifySaveStatus(pagePath: string, status: SaveStatus): void { } } +/** + * Serialize Y.Array data to CSV string. + */ +function serializeYArrayToCSV(yArray: Y.Array>): string { + const rows = yArray.toArray(); + if (rows.length === 0) return ''; + + // Get headers from first row + const headers: string[] = []; + rows[0].forEach((_, key) => headers.push(key)); + + // Build CSV + const lines = [headers.join(',')]; + rows.forEach(yMap => { + const values = headers.map(h => { + const v = yMap.get(h); + const str = String(v ?? ''); + // Escape and quote if needed + if (str.includes(',') || str.includes('"') || str.includes('\n')) { + return `"${str.replace(/"/g, '""')}"`; + } + return str; + }); + lines.push(values.join(',')); + }); + + return lines.join('\n'); +} + /** * Save document content to backend. + * Handles both markdown/tsx (Y.Text) and CSV (Y.Array) files. */ async function saveDocument(pagePath: string, pageTitle: string): Promise { const session = activeSessions.get(pagePath); const state = saveStates.get(pagePath); if (!session || !state) return; - const yText = session.doc.getText('content'); - const content = yText.toString(); + // Determine content based on file type + let content: string; + const isCSV = pagePath.endsWith('.csv'); + + if (isCSV) { + // CSV: Serialize Y.Array to CSV format + const yArray = session.doc.getArray>('data'); + content = serializeYArrayToCSV(yArray); + } else { + // Markdown/TSX: Use Y.Text + const yText = session.doc.getText('content'); + content = yText.toString(); + } // Skip if content hasn't changed if (content === state.lastSavedContent) { @@ -185,8 +227,7 @@ export function createCollabSession( const doc = new Y.Doc(); // Connect to backend collaboration WebSocket - // URL format: ws://localhost:8000/ws/collab/{clientId}/{pagePath} - const wsUrl = `ws://localhost:8000/ws/collab`; + const wsUrl = getWsUrl('/ws/collab'); const roomName = pagePath; // Use page path as room name const provider = new WebsocketProvider(wsUrl, roomName, doc, { @@ -243,20 +284,40 @@ export function createCollabSession( }; saveStates.set(pagePath, saveState); - // Set up Y.Text observer for auto-save - const yText = doc.getText('content'); - yText.observe(() => { - // Skip during initial load (sync from server) - if (saveState.isInitialLoad) return; - scheduleSave(pagePath, title); - }); + // Set up observer for auto-save (different for CSV vs text files) + const isCSV = pagePath.endsWith('.csv'); - // Mark initial load as complete after sync settles - setTimeout(() => { - saveState.isInitialLoad = false; - saveState.lastSavedContent = yText.toString(); - console.log(`Initial load complete for: ${pagePath}`); - }, 500); + if (isCSV) { + // CSV: Set up Y.Array observer + const yArray = doc.getArray>('data'); + yArray.observeDeep(() => { + // Skip during initial load (sync from server) + if (saveState.isInitialLoad) return; + scheduleSave(pagePath, title); + }); + + // Mark initial load as complete after sync settles + setTimeout(() => { + saveState.isInitialLoad = false; + saveState.lastSavedContent = serializeYArrayToCSV(yArray); + console.log(`Initial load complete for CSV: ${pagePath}`); + }, 500); + } else { + // Markdown/TSX: Set up Y.Text observer + const yText = doc.getText('content'); + yText.observe(() => { + // Skip during initial load (sync from server) + if (saveState.isInitialLoad) return; + scheduleSave(pagePath, title); + }); + + // Mark initial load as complete after sync settles + setTimeout(() => { + saveState.isInitialLoad = false; + saveState.lastSavedContent = yText.toString(); + console.log(`Initial load complete for: ${pagePath}`); + }, 500); + } // Create session object const session: CollabSession = { @@ -355,6 +416,71 @@ export function getSharedText(doc: Y.Doc): Y.Text { return doc.getText('content'); } +/** + * Get the shared Yjs Array for data (CSV files). + * Returns Y.Array where each Y.Map is a row with column keys. + */ +export function getSharedData(doc: Y.Doc): Y.Array> { + return doc.getArray>('data'); +} + +/** + * Initialize CSV data from content string. + * Parses CSV content and populates the Y.Array. + */ +export function initializeCSVData(doc: Y.Doc, csvContent: string): void { + const yArray = getSharedData(doc); + + // Don't reinitialize if already has data + if (yArray.length > 0) return; + + const lines = csvContent.split('\n').filter(l => l.trim()); + if (lines.length === 0) return; + + // Parse headers + const headers = parseCSVLine(lines[0]); + + // Parse rows in a transaction + doc.transact(() => { + lines.slice(1).forEach(line => { + const values = parseCSVLine(line); + const yMap = new Y.Map(); + headers.forEach((h, i) => { + yMap.set(h, values[i] ?? ''); + }); + yArray.push([yMap]); + }); + }); +} + +/** + * Parse a CSV line into values. + */ +function parseCSVLine(line: string): string[] { + const result: string[] = []; + let current = ''; + let inQuotes = false; + + for (let i = 0; i < line.length; i++) { + const char = line[i]; + if (char === '"') { + if (inQuotes && line[i + 1] === '"') { + current += '"'; + i++; // Skip next quote + } else { + inQuotes = !inQuotes; + } + } else if (char === ',' && !inQuotes) { + result.push(current); + current = ''; + } else { + current += char; + } + } + result.push(current); + return result; +} + /** * Release a collaborative session. * Sessions persist to allow seamless switching between formatted/raw views. @@ -447,7 +573,7 @@ export async function setEditingState( client_id: userId, editing: String(editing), }); - await fetch(`http://localhost:8000/api/collab/editing-state?${params}`, { + await fetch(`${getApiUrl()}/api/collab/editing-state?${params}`, { method: 'POST', }); console.log(`Editing state: ${pagePath} = ${editing}`); diff --git a/frontend/src/services/git-api.ts b/frontend/src/services/git-api.ts index a9836a7..69f52f6 100644 --- a/frontend/src/services/git-api.ts +++ b/frontend/src/services/git-api.ts @@ -1,9 +1,10 @@ /** * API client for git operations */ -import type { DiffStats, BranchDiff } from '../types/agent'; +import type { DiffStats } from '../types/agent'; +import { getApiUrl } from '../utils/url'; -const API_BASE_URL = 'http://localhost:8000'; +const API_BASE_URL = getApiUrl(); export class GitAPI { /** @@ -75,4 +76,35 @@ export class GitAPI { const data = await response.json(); return data.branch; } + + /** + * Delete a branch + */ + static async deleteBranch(branch: string, force: boolean = false): Promise { + const url = `${API_BASE_URL}/api/git/branches/${encodeURIComponent(branch)}${force ? '?force=true' : ''}`; + const response = await fetch(url, { + method: 'DELETE', + }); + + if (!response.ok) { + throw new Error(`Failed to delete branch: ${response.statusText}`); + } + } + + /** + * Checkout a branch + */ + static async checkoutBranch(branch: string): Promise { + const response = await fetch(`${API_BASE_URL}/api/git/checkout`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ branch }), + }); + + if (!response.ok) { + throw new Error(`Failed to checkout branch: ${response.statusText}`); + } + } } diff --git a/frontend/src/services/threads-api.ts b/frontend/src/services/threads-api.ts index 679431d..ea78fe8 100644 --- a/frontend/src/services/threads-api.ts +++ b/frontend/src/services/threads-api.ts @@ -1,8 +1,9 @@ /** * API client for thread operations */ +import { getApiUrl } from '../utils/url'; -const API_BASE_URL = 'http://localhost:8000'; +const API_BASE_URL = getApiUrl(); export interface ThreadFile { path: string; diff --git a/frontend/src/services/tree-api.ts b/frontend/src/services/tree-api.ts index 5b0caa2..f0568bc 100644 --- a/frontend/src/services/tree-api.ts +++ b/frontend/src/services/tree-api.ts @@ -1,6 +1,7 @@ -import { TreeItem, MoveOperation, FolderCreate } from '../types/page'; +import type { TreeItem, MoveOperation, FolderCreate } from '../types/page'; +import { getApiUrl } from '../utils/url'; -const API_BASE = 'http://localhost:8000'; +const API_BASE = getApiUrl(); export const treeApi = { async getTree(): Promise { diff --git a/frontend/src/services/websocket.ts b/frontend/src/services/websocket.ts index 7a51aab..0159269 100644 --- a/frontend/src/services/websocket.ts +++ b/frontend/src/services/websocket.ts @@ -1,4 +1,5 @@ -import { WebSocketMessage, ChatMessage } from '../types/chat'; +import type { WebSocketMessage } from '../types/chat'; +import { getWsUrl } from '../utils/url'; export type WebSocketEventHandler = (message: WebSocketMessage) => void; export type ConnectionStatusHandler = (status: 'connecting' | 'connected' | 'disconnected' | 'error') => void; @@ -177,7 +178,7 @@ export const createWebSocketService = (clientId?: string) => { // Create new instance console.log(`Creating new WebSocket service for: ${id}`); - const wsUrl = `ws://localhost:8000/ws`; + const wsUrl = getWsUrl('/ws'); const service = new WebSocketService(wsUrl, id); serviceInstances.set(id, service); diff --git a/frontend/src/types/chat.ts b/frontend/src/types/chat.ts index 0d57f6e..6b4ee26 100644 --- a/frontend/src/types/chat.ts +++ b/frontend/src/types/chat.ts @@ -25,13 +25,15 @@ export interface WebSocketMessage { type: | 'chat' | 'chat_response' | 'tool_call' | 'system' | 'system_prompt' | 'page_update' | 'error' | 'status' | 'success' // Agent messages - | 'agent_complete' | 'agent_selected' + | 'agent_complete' | 'agent_selected' | 'agent_start' // Thread messages | 'thread_created' | 'thread_status' | 'thread_deleted' | 'thread_list' | 'thread_selected' | 'thread_message' // Branch messages | 'branch_created' | 'branch_switched' | 'branch_deleted' // Page messages - | 'page_updated' | 'pages_changed'; + | 'page_updated' | 'pages_changed' | 'pages_content_changed' + // Collab messages + | 'collab_room_change'; data?: any; message?: string; tool_name?: string; diff --git a/frontend/src/types/page.ts b/frontend/src/types/page.ts index bdb7aca..43a5ec4 100644 --- a/frontend/src/types/page.ts +++ b/frontend/src/types/page.ts @@ -1,13 +1,20 @@ +// Supported file types +export type FileType = 'markdown' | 'csv' | 'tsx'; + export interface Page { - path: string; // Relative file path in git repo (e.g., "home.md", "agents/index.md") - content: string; // Plain markdown content (no frontmatter) - title: string; // Filename (e.g., "home.md") + path: string; // Relative file path in git repo (e.g., "home.md", "data.csv", "view.tsx") + content: string; // File content (markdown, CSV, or TSX source) + title: string; // Filename (e.g., "home.md", "tasks.csv") + file_type: FileType; // Type of file + // CSV-specific fields + headers?: string[]; // CSV column headers + rows?: Record[]; // CSV rows as objects // Legacy fields for compatibility - metadata comes from git author?: string; created_at?: string; updated_at?: string; tags?: string[]; - content_json?: any; // ProseMirror document JSON + content_json?: any; // ProseMirror document JSON (markdown only) } export interface PageRevision { @@ -28,9 +35,10 @@ export interface PageTreeItem { // Enhanced tree item with folder support export interface TreeItem { id: string; // Unique identifier (full path without extension) - title: string; // Filename (e.g., "home.md", "agents") - path: string; // Full relative path (e.g., "home.md", "agents/index.md") + title: string; // Filename (e.g., "home.md", "tasks.csv", "agents") + path: string; // Full relative path (e.g., "home.md", "agents/index.md", "data.csv") type: 'page' | 'folder'; + file_type?: FileType; // Type of file (only for pages, not folders) children?: TreeItem[] | null; parent_path: string | null; // Parent folder path (null = root) order?: number; // Legacy - items are now sorted alphabetically diff --git a/frontend/src/utils/url.ts b/frontend/src/utils/url.ts new file mode 100644 index 0000000..8cc90d3 --- /dev/null +++ b/frontend/src/utils/url.ts @@ -0,0 +1,37 @@ +/** + * URL utilities for API and WebSocket connections. + * Uses VITE_API_HOST environment variable, falls back to page location. + */ + +/** + * Get the API host from environment or derive from page location. + */ +function getHost(): string { + // Use environment variable if set + if (import.meta.env.VITE_API_HOST) { + return import.meta.env.VITE_API_HOST; + } + // Fallback to current page host + return window.location.host; +} + +/** + * Get the base API URL. + * Uses VITE_API_HOST env var in dev, or current page host in prod. + */ +export function getApiUrl(): string { + const host = getHost(); + // Use https if page is https, otherwise http + const protocol = window.location.protocol === 'https:' ? 'https:' : 'http:'; + return `${protocol}//${host}`; +} + +/** + * Get WebSocket URL for a given path. + * Automatically uses ws:// or wss:// based on page protocol. + */ +export function getWsUrl(path: string): string { + const host = getHost(); + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + return `${protocol}//${host}${path}`; +} diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts index 11f02fe..f91e68f 100644 --- a/frontend/src/vite-env.d.ts +++ b/frontend/src/vite-env.d.ts @@ -1 +1,9 @@ /// + +interface ImportMetaEnv { + readonly VITE_API_HOST?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index bdd682b..4f9319a 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,4 +1,3 @@ -/// import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import path from 'path' @@ -16,10 +15,4 @@ export default defineConfig({ usePolling: true, }, }, - test: { - globals: true, - environment: 'jsdom', - setupFiles: './src/test/setup.ts', - exclude: ['**/node_modules/**', '**/e2e/**'], - }, -}) +} as any)