# workspace.py - Workspace management for HenAi import os import json import re from datetime import datetime from flask import jsonify, request, g from werkzeug.utils import secure_filename # Constants WORKSPACE_FILE = 'workspace.json' USER_DATA_ROOT = '/data/users' def get_user_workspace_path(user_id): """Get the path to user's workspace file""" if user_id: user_dir = os.path.join(USER_DATA_ROOT, str(user_id)) os.makedirs(user_dir, exist_ok=True) return os.path.join(user_dir, 'workspace.json') return WORKSPACE_FILE def get_current_user_id(): """Get current user ID from Flask g object or headers""" # First try from g (set by auth middleware) if hasattr(g, 'user_id') and g.user_id: return str(g.user_id) # Then try from headers (set by proxy) user_id = request.headers.get('X-User-ID') if user_id: return user_id return None def load_workspace(user_id=None): """Load workspace structure from file (user-specific if user_id provided)""" if user_id is None: user_id = get_current_user_id() workspace_path = get_user_workspace_path(user_id) try: if os.path.exists(workspace_path): with open(workspace_path, 'r', encoding='utf-8') as f: return json.load(f) except Exception as e: print(f"Error loading workspace for user {user_id}: {e}") return {"folders": []} # Folders will be stored in order (newest first) def save_workspace(workspace, user_id=None): """Save workspace structure to file (user-specific if user_id provided)""" if user_id is None: user_id = get_current_user_id() workspace_path = get_user_workspace_path(user_id) # Ensure directory exists os.makedirs(os.path.dirname(workspace_path), exist_ok=True) try: with open(workspace_path, 'w', encoding='utf-8') as f: json.dump(workspace, f, indent=2, ensure_ascii=False) except Exception as e: print(f"Error saving workspace for user {user_id}: {e}") def build_folder_tree(folder, base_path=''): """Recursively build folder tree with file contents""" tree = { 'name': base_path.split('/')[-1] if base_path else 'Root', 'path': base_path, 'type': 'folder', 'children': [] } # Add subfolders for subfolder in folder.get('folders', []): subfolder_path = base_path + '/' + subfolder['name'] if base_path else subfolder['name'] tree['children'].append(build_folder_tree(subfolder, subfolder_path)) # Add files for file_item in folder.get('files', []): file_path = base_path + '/' + file_item['name'] if base_path else file_item['name'] tree['children'].append({ 'name': file_item['name'], 'path': file_path, 'type': 'file', 'content': file_item.get('content', ''), 'size': len(file_item.get('content', '')), 'updated_at': file_item.get('updated_at', '') }) return tree def build_folder_context(folder, base_path=''): """Build a text representation of folder structure and contents""" context = f"\n=== FOLDER: {base_path if base_path else 'Root'} ===\n" # Add subfolders for subfolder in folder.get('folders', []): subfolder_path = base_path + '/' + subfolder['name'] if base_path else subfolder['name'] context += f"\nšŸ“ Folder: {subfolder['name']}/ (path: {subfolder_path})\n" context += build_folder_context(subfolder, subfolder_path) # Add files with content for file_item in folder.get('files', []): file_path = base_path + '/' + file_item['name'] if base_path else file_item['name'] file_ext = file_item['name'].split('.')[-1].lower() context += f"\n--- FILE: {file_path} ({file_ext}) ---\n" context += file_item.get('content', '') context += f"\n--- END FILE: {file_path} ---\n" return context def get_file_content_from_workspace(file_path, user_id=None): """Helper to get file content from workspace (user-specific)""" if user_id is None: user_id = get_current_user_id() workspace = load_workspace(user_id) parts = file_path.split('/') file_name = parts[-1] folder_parts = parts[:-1] if len(parts) > 1 else [] current = workspace for folder_name in folder_parts: found = False for folder in current.get('folders', []): if folder.get('name') == folder_name: current = folder found = True break if not found: return None files = current.get('files', []) for file_item in files: if file_item.get('name') == file_name: return file_item.get('content', '') return None def navigate_to_folder(workspace, folder_path): """Navigate to a folder given its path""" if not folder_path: return workspace parts = folder_path.split('/') current = workspace for part in parts: found = False for folder in current.get('folders', []): if folder.get('name') == part: current = folder found = True break if not found: return None return current def find_file_in_folder(folder, file_name): """Find a file in a folder by name""" for file_item in folder.get('files', []): if file_item.get('name') == file_name: return file_item return None # ============= WORKSPACE ROUTE HANDLERS ============= def register_workspace_routes(app): """Register all workspace routes with the Flask app""" @app.route('/api/workspace', methods=['GET']) def get_workspace(): """Get the entire workspace structure (user-specific)""" user_id = get_current_user_id() workspace = load_workspace(user_id) return jsonify(workspace) @app.route('/api/workspace/folder', methods=['POST']) def create_folder(): """Create a new folder (user-specific)""" data = request.json folder_name = data.get('name', '').strip() parent_path = data.get('parent_path', '') if not folder_name: return jsonify({'error': 'Folder name is required'}), 400 user_id = get_current_user_id() workspace = load_workspace(user_id) # Navigate to parent folder if specified if parent_path: parts = parent_path.split('/') if parent_path else [] current = workspace for part in parts: found = False for folder in current.get('folders', []): if folder.get('name') == part: current = folder found = True break if not found: return jsonify({'error': f'Parent folder "{parent_path}" not found'}), 404 parent = current else: parent = workspace # Ensure folders list exists if 'folders' not in parent: parent['folders'] = [] # Check if folder already exists for folder in parent['folders']: if folder.get('name') == folder_name: return jsonify({'error': 'Folder already exists'}), 400 # Create new folder (newest at the beginning of the list) new_folder = { 'name': folder_name, 'created_at': datetime.now().isoformat(), 'folders': [], 'files': [] } parent['folders'].insert(0, new_folder) # Insert at beginning for newest first save_workspace(workspace, user_id) return jsonify({'success': True, 'folder': new_folder}) @app.route('/api/workspace/file', methods=['POST']) def create_file(): """Create a new file in a folder (user-specific)""" data = request.json file_name = data.get('name', '').strip() folder_path = data.get('folder_path', '') content = data.get('content', '') if not file_name: return jsonify({'error': 'File name is required'}), 400 user_id = get_current_user_id() workspace = load_workspace(user_id) # Navigate to the target folder if folder_path: parts = folder_path.split('/') if folder_path else [] current = workspace for part in parts: found = False for folder in current.get('folders', []): if folder.get('name') == part: current = folder found = True break if not found: return jsonify({'error': f'Folder "{folder_path}" not found'}), 404 target_folder = current else: target_folder = workspace # Ensure files list exists if 'files' not in target_folder: target_folder['files'] = [] # Check if file already exists for file_item in target_folder['files']: if file_item.get('name') == file_name: return jsonify({'error': 'File already exists'}), 400 # Create new file (newest at the beginning of the list) new_file = { 'name': file_name, 'content': content, 'created_at': datetime.now().isoformat(), 'updated_at': datetime.now().isoformat() } target_folder['files'].insert(0, new_file) # Insert at beginning for newest first save_workspace(workspace, user_id) return jsonify({'success': True, 'file': new_file}) @app.route('/api/workspace/file/', methods=['PUT']) def update_file(file_path): """Update file content (user-specific)""" data = request.json content = data.get('content', '') user_id = get_current_user_id() workspace = load_workspace(user_id) # Parse file path (format: "folder1/subfolder/file.txt" or "file.txt") parts = file_path.split('/') file_name = parts[-1] folder_parts = parts[:-1] if len(parts) > 1 else [] # Navigate to the folder containing the file current = workspace for folder_name in folder_parts: found = False for folder in current.get('folders', []): if folder.get('name') == folder_name: current = folder found = True break if not found: return jsonify({'error': f'Folder "{folder_name}" not found'}), 404 # Find and update the file files = current.get('files', []) for file_item in files: if file_item.get('name') == file_name: file_item['content'] = content file_item['updated_at'] = datetime.now().isoformat() save_workspace(workspace, user_id) return jsonify({'success': True, 'file': file_item}) return jsonify({'error': 'File not found'}), 404 @app.route('/api/workspace/file/', methods=['DELETE']) def delete_file(file_path): """Delete a file (user-specific)""" user_id = get_current_user_id() workspace = load_workspace(user_id) # Parse file path parts = file_path.split('/') file_name = parts[-1] folder_parts = parts[:-1] if len(parts) > 1 else [] # Navigate to the folder containing the file current = workspace for folder_name in folder_parts: found = False for folder in current.get('folders', []): if folder.get('name') == folder_name: current = folder found = True break if not found: return jsonify({'error': f'Folder "{folder_name}" not found'}), 404 # Find and delete the file files = current.get('files', []) for i, file_item in enumerate(files): if file_item.get('name') == file_name: del files[i] save_workspace(workspace, user_id) return jsonify({'success': True}) return jsonify({'error': 'File not found'}), 404 @app.route('/api/workspace/folder/', methods=['DELETE']) def delete_folder(folder_path): """Delete a folder and all its contents (user-specific)""" user_id = get_current_user_id() workspace = load_workspace(user_id) # Parse folder path parts = folder_path.split('/') if folder_path else [] if not parts: return jsonify({'error': 'Cannot delete root'}), 400 folder_name = parts[-1] parent_parts = parts[:-1] # Navigate to parent current = workspace for parent_name in parent_parts: found = False for folder in current.get('folders', []): if folder.get('name') == parent_name: current = folder found = True break if not found: return jsonify({'error': f'Parent folder not found'}), 404 # Find and delete the folder folders = current.get('folders', []) for i, folder in enumerate(folders): if folder.get('name') == folder_name: del folders[i] save_workspace(workspace, user_id) return jsonify({'success': True}) return jsonify({'error': 'Folder not found'}), 404 @app.route('/api/workspace/file//content', methods=['GET']) def get_file_content(file_path): """Get the full content of a file for the IDE (user-specific)""" user_id = get_current_user_id() workspace = load_workspace(user_id) # Parse file path parts = file_path.split('/') file_name = parts[-1] folder_parts = parts[:-1] if len(parts) > 1 else [] # Navigate to the folder containing the file current = workspace for folder_name in folder_parts: found = False for folder in current.get('folders', []): if folder.get('name') == folder_name: current = folder found = True break if not found: return jsonify({'error': f'Folder not found'}), 404 # Find the file files = current.get('files', []) for file_item in files: if file_item.get('name') == file_name: return jsonify({ 'success': True, 'content': file_item.get('content', ''), 'name': file_name, 'path': file_path }) return jsonify({'error': 'File not found'}), 404 @app.route('/api/workspace/folder_tree', methods=['POST']) def get_folder_tree(): """Get the full tree structure of a root folder for AI context (user-specific)""" data = request.json folder_path = data.get('folder_path', '') user_id = get_current_user_id() workspace = load_workspace(user_id) # Navigate to the folder if folder_path: parts = folder_path.split('/') if folder_path else [] current = workspace for part in parts: found = False for folder in current.get('folders', []): if folder.get('name') == part: current = folder found = True break if not found: return jsonify({'error': f'Folder "{folder_path}" not found'}), 404 else: current = workspace # Build folder tree with file contents tree = build_folder_tree(current, folder_path) return jsonify({ 'success': True, 'tree': tree, 'user_id': user_id }) # ============= TERMINAL SESSION MANAGEMENT ============= # Store active terminal sessions per workspace folder active_terminal_sessions = {} @app.route('/api/workspace/terminal/start', methods=['POST']) def start_terminal_session(): """Start a terminal session for a specific workspace folder""" data = request.json folder_path = data.get('folder_path', '') session_id = data.get('session_id') if not session_id: return jsonify({'error': 'Session ID required'}), 400 # Get the actual workspace root (where workspace.json is) workspace_base = os.path.dirname(os.path.abspath(__file__)) # If folder_path is provided, navigate to that subfolder within workspace if folder_path: # Clean the folder path - remove any Windows drive letters clean_path = re.sub(r'^[A-Za-z]:[/\\]', '', folder_path) clean_path = clean_path.replace('HenAi:\\Workspace\\', '').replace('HenAi:\\', '') workspace_dir = os.path.join(workspace_base, clean_path) else: workspace_dir = workspace_base # Ensure directory exists if not os.path.exists(workspace_dir): os.makedirs(workspace_dir, exist_ok=True) # Import terminal emulator from terminal import TerminalEmulator # Create terminal session with workspace root set terminal = TerminalEmulator() terminal.workspace_root = workspace_base terminal.current_directory = workspace_dir os.chdir(workspace_dir) # Store session if folder_path not in active_terminal_sessions: active_terminal_sessions[folder_path] = {} active_terminal_sessions[folder_path][session_id] = { 'terminal': terminal, 'started_at': datetime.now().isoformat(), 'folder_path': folder_path, 'is_active': True } # Return friendly path for display rel_path = os.path.relpath(workspace_dir, workspace_base) if rel_path == '.': friendly_path = "HenAi:\\Workspace" else: # CORRECT - move backslash replacement outside f-string escaped_path = rel_path.replace(os.sep, '\\') friendly_path = f"HenAi:\\Workspace\\{escaped_path}" return jsonify({ 'success': True, 'session_id': session_id, 'cwd': friendly_path, 'raw_cwd': workspace_dir, 'message': f'Terminal started in {friendly_path}' }) @app.route('/api/workspace/terminal/execute', methods=['POST']) def execute_terminal_command(): """Execute a command in the terminal session""" data = request.json folder_path = data.get('folder_path', '') session_id = data.get('session_id') command = data.get('command', '') if not session_id: return jsonify({'error': 'Session ID required'}), 400 # Check if session exists if folder_path not in active_terminal_sessions: return jsonify({'error': 'No active terminal session for this folder'}), 404 if session_id not in active_terminal_sessions[folder_path]: return jsonify({'error': 'Invalid session ID'}), 404 session = active_terminal_sessions[folder_path][session_id] terminal = session['terminal'] if not session['is_active']: return jsonify({'error': 'Terminal session is closed'}), 400 # Execute command result = terminal.execute_and_get_output(command) return jsonify({ 'success': result['success'], 'output': result['output'], 'error': result['error'], 'exit_code': result['exit_code'], 'cwd': terminal.current_directory }) @app.route('/api/workspace/terminal/stop', methods=['POST']) def stop_terminal_session(): """Stop and close a terminal session""" data = request.json folder_path = data.get('folder_path', '') session_id = data.get('session_id') if not session_id: return jsonify({'error': 'Session ID required'}), 400 if folder_path in active_terminal_sessions: if session_id in active_terminal_sessions[folder_path]: # Mark as inactive and remove active_terminal_sessions[folder_path][session_id]['is_active'] = False del active_terminal_sessions[folder_path][session_id] # Clean up empty folder entries if not active_terminal_sessions[folder_path]: del active_terminal_sessions[folder_path] return jsonify({'success': True, 'message': 'Terminal session closed'}) return jsonify({'error': 'Terminal session not found'}), 404 @app.route('/api/workspace/terminal/status', methods=['GET']) def get_terminal_status(): """Check if a terminal session is active for a folder""" folder_path = request.args.get('folder_path', '') is_active = folder_path in active_terminal_sessions and len(active_terminal_sessions[folder_path]) > 0 # Get active sessions info sessions = [] if is_active: for sess_id, sess in active_terminal_sessions[folder_path].items(): sessions.append({ 'session_id': sess_id, 'started_at': sess['started_at'], 'cwd': sess['terminal'].current_directory }) return jsonify({ 'is_active': is_active, 'sessions': sessions, 'folder_path': folder_path })