Advertisement
Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- import os
- import sys
- import threading
- import traceback
- import time
- import re
- import platform
- import json
- import subprocess
- import tempfile
- import shutil # Added for recursive directory deletion
- from datetime import datetime
- # Conditional imports for AI APIs
- try:
- import google.generativeai as genai
- HAS_GEMINI = True
- except ImportError:
- print("Warning: google-generativeai not found. Gemini API support disabled.")
- HAS_GEMINI = False
- class MockGeminiModel: # Mock class to prevent errors if genai is missing
- def __init__(self, model_name): self.model_name = model_name
- def start_chat(self, history): return MockChatSession()
- class MockChatSession:
- def send_message(self, message, stream=True):
- class MockChunk: text = "Mock Gemini Response (API not available)"
- return [MockChunk()]
- genai = type('genai', (object,), {'GenerativeModel': MockGeminiModel, 'configure': lambda *args, **kwargs: None})()
- try:
- from mistralai.client import MistralClient
- from mistralai.models.chat_models import ChatMessage
- HAS_MISTRAL = True
- except ImportError:
- print("Warning: mistralai not found. Mistral API support disabled.")
- HAS_MISTRAL = False
- class MockMistralClient: # Mock class to prevent errors if mistralai is missing
- def __init__(self, api_key): pass
- def chat(self, model, messages, stream=True):
- class MockChunk:
- choices = [type('MockChoice', (object,), {'delta': type('MockDelta', (object,), {'content': "Mock Mistral Response (API not available)"})()})()]
- return [MockChunk()]
- ChatMessage = lambda role, content: {'role': role, 'content': content} # Mock ChatMessage
- MistralClient = MockMistralClient
- from PyQt6.QtWidgets import (
- QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
- QPushButton, QListWidget, QLineEdit, QLabel, QMessageBox,
- QTextEdit, QScrollArea, QSizePolicy,
- QDialog, QDialogButtonBox, QComboBox, QFileDialog,
- QTabWidget, QSplitter, QTreeView,
- QMenu, QStatusBar, QToolBar, QToolButton, QSystemTrayIcon,
- QSpinBox, QCheckBox, QInputDialog, QAbstractItemView
- )
- from PyQt6.QtGui import (
- QIcon, QFontMetrics, QFont, QTextOption, QColor,
- QGuiApplication, QClipboard, QPalette, QBrush,
- QTextCursor, QAction, QDesktopServices, QTextCharFormat,
- QSyntaxHighlighter, QTextDocument, QFileSystemModel, QPainter, QTextFormat
- )
- from PyQt6.QtCore import (
- Qt, QThread, pyqtSignal, QSize, QMutex, QTimer, QObject,
- QRect, QFileInfo, QDir, QStandardPaths, QUrl, QModelIndex
- )
- from PyQt6.QtPrintSupport import QPrintDialog, QPrinter
- # Importy Pygments do kolorowania składni
- from pygments import highlight
- from pygments.lexers import get_lexer_by_name, guess_lexer, ClassNotFound
- from pygments.formatters import HtmlFormatter
- from pygments.util import ClassNotFound as PygmentsClassNotFound
- # --- Constants ---
- SETTINGS_FILE = "./editor_settings.json"
- # List of models the application should attempt to use.
- # Structure: (API_TYPE, MODEL_IDENTIFIER, DISPLAY_NAME)
- # API_TYPE can be "gemini" or "mistral"
- # MODEL_IDENTIFIER is the string used by the respective API library
- # DISPLAY_NAME is what's shown to the user
- AVAILABLE_MODELS_CONFIG = [
- ("gemini", "gemini-1.5-flash-latest", "Gemini 1.5 Flash (Latest)"),
- ("gemini", "gemini-1.5-pro-latest", "Gemini 1.5 Pro (Latest)"),
- ("gemini", "gemini-2.0-flash-thinking-exp-1219", "Gemini 2.0 Flash (Experimental)"),
- ("gemini", "gemini-2.5-flash-preview-04-17", "Gemini 2.5 Flash (Preview)"),
- ("mistral", "codestral-latest", "Codestral (Latest)"), # Example Codestral model
- ("mistral", "mistral-large-latest", "Mistral Large (Latest)"),
- ("mistral", "mistral-medium", "Mistral Medium"),
- ("mistral", "mistral-small", "Mistral Small"),
- ("mistral", "mistral-tiny", "Mistral Tiny"),
- ]
- # Determine which models are actually available based on installed libraries
- ACTIVE_MODELS_CONFIG = []
- for api_type, identifier, name in AVAILABLE_MODELS_CONFIG:
- if api_type == "gemini" and HAS_GEMINI:
- ACTIVE_MODELS_CONFIG.append((api_type, identifier, name))
- elif api_type == "mistral" and HAS_MISTRAL:
- ACTIVE_MODELS_CONFIG.append((api_type, identifier, name))
- if not ACTIVE_MODELS_CONFIG:
- # QMessageBox.critical(None, "Błąd API", "Brak dostępnych API. Proszę zainstalować google-generativeai lub mistralai.")
- print("Warning: No AI APIs available. AI features will be disabled.")
- # Fallback to a dummy entry if no APIs are available, to prevent crashes
- ACTIVE_MODELS_CONFIG = [("none", "none", "Brak dostępnych modeli")]
- DEFAULT_MODEL_CONFIG = ACTIVE_MODELS_CONFIG[0] if ACTIVE_MODELS_CONFIG else ("none", "none", "Brak") # Use the first active model as default
- RECENT_FILES_MAX = 10
- DEFAULT_FONT_SIZE = 12
- DEFAULT_THEME = "dark"
- GEMINI_API_KEY_FILE = "./.api_key" # Keep original Google key file
- # --- Syntax Highlighter Classes ---
- # (PythonHighlighter, CSSHighlighter, HTMLHighlighter, JSHighlighter, GMLHighlighter - copied from your code)
- # ... (Paste your Syntax Highlighter classes here) ...
- class PythonHighlighter(QSyntaxHighlighter):
- def __init__(self, document):
- super().__init__(document)
- self.highlight_rules = []
- keywords = [
- 'and', 'as', 'assert', 'break', 'class', 'continue', 'def', 'del',
- 'elif', 'else', 'except', 'False', 'finally', 'for', 'from', 'global',
- 'if', 'import', 'in', 'is', 'lambda', 'None', 'nonlocal', 'not', 'or',
- 'pass', 'raise', 'return', 'True', 'try', 'while', 'with', 'yield'
- ]
- keyword_format = QTextCharFormat()
- keyword_format.setForeground(QColor("#569CD6")) # Blue
- keyword_format.setFontWeight(QFont.Weight.Bold)
- self.highlight_rules.extend([(r'\b%s\b' % kw, keyword_format) for kw in keywords])
- string_format = QTextCharFormat()
- string_format.setForeground(QColor("#CE9178")) # Orange
- self.highlight_rules.append((r'"[^"\\]*(\\.[^"\\]*)*"', string_format))
- self.highlight_rules.append((r"'[^'\\]*(\\.[^'\\]*)*'", string_format))
- function_format = QTextCharFormat()
- function_format.setForeground(QColor("#DCDCAA")) # Light yellow
- self.highlight_rules.append((r'\b[A-Za-z_][A-Za-z0-9_]*\s*(?=\()', function_format))
- number_format = QTextCharFormat()
- number_format.setForeground(QColor("#B5CEA8")) # Green
- self.highlight_rules.append((r'\b[0-9]+\b', number_format))
- comment_format = QTextCharFormat()
- comment_format.setForeground(QColor("#6A9955")) # Green
- comment_format.setFontItalic(True)
- self.highlight_rules.append((r'#[^\n]*', comment_format))
- def highlightBlock(self, text):
- for pattern, format in self.highlight_rules:
- expression = re.compile(pattern)
- matches = expression.finditer(text)
- for match in matches:
- start = match.start()
- length = match.end() - start
- self.setFormat(start, length, format)
- class CSSHighlighter(QSyntaxHighlighter):
- def __init__(self, document):
- super().__init__(document)
- self.highlight_rules = []
- keywords = ['color', 'font', 'margin', 'padding', 'display', 'position', 'transition']
- keyword_format = QTextCharFormat()
- keyword_format.setForeground(QColor("#ff6ac1")) # Pinkish
- keyword_format.setFontWeight(QFont.Weight.Bold)
- self.highlight_rules.extend([(r'\b%s\b' % kw, keyword_format) for kw in keywords])
- value_format = QTextCharFormat()
- value_format.setForeground(QColor("#ce9178")) # Orange
- self.highlight_rules.append((r'#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})', value_format))
- self.highlight_rules.append((r'rgb[a]?\([^)]+\)', value_format))
- selector_format = QTextCharFormat()
- selector_format.setForeground(QColor("#dcdcAA")) # Light yellow
- self.highlight_rules.append((r'^\s*[^{]+(?={)', selector_format))
- comment_format = QTextCharFormat()
- comment_format.setForeground(QColor("#6A9955")) # Green
- comment_format.setFontItalic(True)
- self.highlight_rules.append((r'/\*.*?\*/', comment_format), re.DOTALL)
- def highlightBlock(self, text):
- for pattern, format in self.highlight_rules:
- expression = re.compile(pattern)
- for match in expression.finditer(text):
- start = match.start()
- length = match.end() - start
- self.setFormat(start, length, format)
- class HTMLHighlighter(QSyntaxHighlighter):
- def __init__(self, document):
- super().__init__(document)
- self.highlight_rules = []
- tag_format = QTextCharFormat()
- tag_format.setForeground(QColor("#569CD6")) # Blue
- self.highlight_rules.append((r'</?[\w-]+>', tag_format))
- attr_format = QTextCharFormat()
- attr_format.setForeground(QColor("#9cdcfe")) # Light blue
- self.highlight_rules.append((r'[\w-]+(?=\s*=)', attr_format))
- value_format = QTextCharFormat()
- value_format.setForeground(QColor("#ce9178")) # Orange
- self.highlight_rules.append((r'="[^"]*"', value_format))
- comment_format = QTextCharFormat()
- comment_format.setForeground(QColor("#6A9955")) # Green
- comment_format.setFontItalic(True)
- self.highlight_rules.append((r'<!--[\s\S]*?-->', comment_format))
- def highlightBlock(self, text):
- for pattern, format in self.highlight_rules:
- expression = re.compile(pattern)
- for match in expression.finditer(text):
- start = match.start()
- length = match.end() - start
- self.setFormat(start, length, format)
- class JSHighlighter(QSyntaxHighlighter):
- def __init__(self, document):
- super().__init__(document)
- self.highlight_rules = []
- keywords = ['var', 'let', 'const', 'function', 'if', 'else', 'return', 'for', 'while']
- keyword_format = QTextCharFormat()
- keyword_format.setForeground(QColor("#c586c0")) # Purple
- keyword_format.setFontWeight(QFont.Weight.Bold)
- self.highlight_rules.extend([(r'\b%s\b' % kw, keyword_format) for kw in keywords])
- string_format = QTextCharFormat()
- string_format.setForeground(QColor("#ce9178")) # Orange
- self.highlight_rules.append((r'"[^"\\]*(\\.[^"\\]*)*"', string_format))
- self.highlight_rules.append((r"'[^'\\]*(\\.[^'\\]*)*'", string_format))
- function_format = QTextCharFormat()
- function_format.setForeground(QColor("#dcdcaa")) # Light yellow
- self.highlight_rules.append((r'\b[A-Za-z_][A-Za-z0-9_]*\s*(?=\()', function_format))
- comment_format = QTextCharFormat()
- comment_format.setForeground(QColor("#6A9955")) # Green
- comment_format.setFontItalic(True)
- self.highlight_rules.append((r'//[^\n]*', comment_format))
- self.highlight_rules.append((r'/\*[\s\S]*?\*/', comment_format), re.DOTALL)
- def highlightBlock(self, text):
- for pattern, format in self.highlight_rules:
- expression = re.compile(pattern)
- for match in expression.finditer(text):
- start = match.start()
- length = match.end() - start
- self.setFormat(start, length, format)
- class GMLHighlighter(QSyntaxHighlighter):
- def __init__(self, document):
- super().__init__(document)
- self.highlight_rules = []
- keywords = ['if', 'else', 'switch', 'case', 'break', 'return', 'var', 'with', 'while']
- keyword_format = QTextCharFormat()
- keyword_format.setForeground(QColor("#c586c0")) # Purple
- keyword_format.setFontWeight(QFont.Weight.Bold)
- self.highlight_rules.extend([(r'\b%s\b' % kw, keyword_format) for kw in keywords])
- var_format = QTextCharFormat()
- var_format.setForeground(QColor("#4ec9b0")) # Teal
- self.highlight_rules.append((r'_[a-zA-Z][a-zA-Z0-9]*', var_format))
- func_format = QTextCharFormat()
- func_format.setForeground(QColor("#dcdcaa")) # Light yellow
- gml_funcs = ['instance_create', 'ds_list_add', 'draw_text']
- self.highlight_rules.extend([(r'\b%s\b(?=\()', func_format) for func in gml_funcs])
- string_format = QTextCharFormat()
- string_format.setForeground(QColor("#ce9178")) # Orange
- self.highlight_rules.append((r'"[^"\\]*(\\.[^"\\]*)*"', string_format))
- comment_format = QTextCharFormat()
- comment_format.setForeground(QColor("#6A9955")) # Green
- comment_format.setFontItalic(True)
- self.highlight_rules.append((r'//[^\n]*', comment_format))
- self.highlight_rules.append((r'/\*[\s\S]*?\*/', comment_format), re.DOTALL)
- def highlightBlock(self, text):
- for pattern, format in self.highlight_rules:
- expression = re.compile(pattern)
- for match in expression.finditer(text):
- start = match.start()
- length = match.end() - start
- self.setFormat(start, length, format)
- # --- End of Syntax Highlighter Classes ---
- # --- API Key Loading ---
- def load_gemini_api_key(filepath=GEMINI_API_KEY_FILE):
- """Reads Gemini API key from a file."""
- if not os.path.exists(filepath):
- # Don't show critical error if file is just missing, allow user to configure in settings
- print(f"Gemini API key file not found: {filepath}. Please add key in settings.")
- return None
- try:
- with open(filepath, "r") as f:
- key = f.read().strip()
- if not key:
- print(f"Gemini API key file is empty: {filepath}. Please add key in settings.")
- return None
- return key
- except Exception as e:
- print(f"Error reading Gemini API key file: {filepath}\nError: {e}")
- # QMessageBox.warning(None, "Błąd odczytu klucza API", f"Nie można odczytać pliku klucza API Google Gemini: {filepath}\nBłąd: {e}")
- return None
- # Load Gemini key initially, but allow overriding/setting in settings
- GEMINI_API_KEY_GLOBAL = load_gemini_api_key()
- # --- Configure APIs (Initial) ---
- # This configuration should happen *after* loading settings in the main window,
- # where the Mistral key from settings is also available.
- # The current global configuration is okay for checking HAS_GEMINI but actual
- # worker instances need potentially updated keys from settings.
- # --- Settings Persistence ---
- def load_settings():
- """Loads settings from a JSON file."""
- # Determine default model based on active APIs
- default_model_config = ACTIVE_MODELS_CONFIG[0] if ACTIVE_MODELS_CONFIG else ("none", "none", "Brak")
- default_api_type = default_model_config[0]
- default_model_identifier = default_model_config[1]
- default_settings = {
- "api_type": default_api_type, # New field to store active API type
- "model_identifier": default_model_identifier, # New field to store model identifier
- "mistral_api_key": None, # New field for Mistral key
- "recent_files": [],
- "font_size": DEFAULT_FONT_SIZE,
- "theme": DEFAULT_THEME,
- "workspace": "",
- "show_sidebar": True,
- "show_statusbar": True,
- "show_toolbar": True
- }
- try:
- if os.path.exists(SETTINGS_FILE):
- with open(SETTINGS_FILE, 'r') as f:
- settings = json.load(f)
- # Handle potential old format or missing new fields
- if "api_type" not in settings or "model_identifier" not in settings:
- # Attempt to migrate from old "model_name" if it exists
- old_model_name = settings.get("model_name", "")
- found_match = False
- for api_type, identifier, name in ACTIVE_MODELS_CONFIG:
- if identifier == old_model_name or name == old_model_name: # Check both identifier and display name from old settings
- settings["api_type"] = api_type
- settings["model_identifier"] = identifier
- found_match = True
- break
- if not found_match:
- # Fallback to default if old name not found or no old name
- settings["api_type"] = default_api_type
- settings["model_identifier"] = default_model_identifier
- if "model_name" in settings:
- del settings["model_name"] # Remove old field
- # Add defaults for any other missing keys (including new mistral_api_key)
- for key in default_settings:
- if key not in settings:
- settings[key] = default_settings[key]
- # Validate loaded model against active configurations
- is_active = any(s[0] == settings.get("api_type") and s[1] == settings.get("model_identifier") for s in ACTIVE_MODELS_CONFIG)
- if not is_active:
- print(f"Warning: Loaded model config ({settings.get('api_type')}, {settings.get('model_identifier')}) is not active. Falling back to default.")
- settings["api_type"] = default_api_type
- settings["model_identifier"] = default_model_identifier
- return settings
- return default_settings
- except Exception as e:
- print(f"Błąd ładowania ustawień: {e}. Używam ustawień domyślnych.")
- return default_settings
- def save_settings(settings: dict):
- """Saves settings to a JSON file."""
- try:
- with open(SETTINGS_FILE, 'w') as f:
- json.dump(settings, f, indent=4)
- except Exception as e:
- print(f"Błąd zapisywania ustawień: {e}")
- # --- API Formatting Helper ---
- def format_chat_history(messages: list, api_type: str) -> list:
- """Formats chat history for different API types."""
- formatted_history = []
- for role, content, metadata in messages:
- # Skip assistant placeholder messages and internal error/empty messages
- if not (role == "assistant" and metadata is not None and metadata.get("type") in ["placeholder", "error", "empty_response"]):
- if api_type == "gemini":
- # Gemini uses "user" and "model" roles
- formatted_history.append({
- "role": "user" if role == "user" else "model",
- "parts": [content] # Gemini uses 'parts' with content
- })
- elif api_type == "mistral":
- # Mistral uses "user" and "assistant" roles
- formatted_history.append(ChatMessage(role='user' if role == 'user' else 'assistant', content=content))
- # Add other API types here if needed
- return formatted_history
- # --- API Worker Threads ---
- class GeminiWorker(QThread):
- response_chunk = pyqtSignal(str)
- response_complete = pyqtSignal()
- error = pyqtSignal(str)
- def __init__(self, api_key: str, user_message: str, chat_history: list, model_identifier: str, parent=None):
- super().__init__(parent)
- self.api_key = api_key
- self.user_message = user_message
- self.chat_history = chat_history # Raw history from main window
- self.model_identifier = model_identifier
- self._is_running = True
- self._mutex = QMutex()
- print(f"GeminiWorker created for model: {model_identifier}")
- def stop(self):
- self._mutex.lock()
- try:
- self._is_running = False
- finally:
- self._mutex.unlock()
- def run(self):
- if not self.api_key:
- self.error.emit("Klucz API Google Gemini nie został skonfigurowany.")
- return
- if not self.user_message.strip():
- self.error.emit("Proszę podać niepustą wiadomość tekstową.")
- return
- try:
- # Format history for Gemini API
- api_history = format_chat_history(self.chat_history, "gemini")
- try:
- # Attempt to get the model instance
- genai.configure(api_key=self.api_key) # Ensure API key is used in this thread
- model_instance = genai.GenerativeModel(self.model_identifier)
- # Start chat with history
- chat = model_instance.start_chat(history=api_history)
- # Send message and get stream
- response_stream = chat.send_message(self.user_message, stream=True)
- except Exception as api_err:
- error_str = str(api_err)
- if "BlockedPromptException" in error_str or ("FinishReason" in error_str and "SAFETY" in error_str):
- self.error.emit(f"Odpowiedź zablokowana przez filtry bezpieczeństwa.")
- elif "Candidate.content is empty" in error_str:
- self.error.emit(f"Otrzymano pustą treść z API (możliwe, że zablokowana lub niepowodzenie).")
- elif "returned an invalid response" in error_str or "Could not find model" in error_str or "Invalid model name" in error_str:
- self.error.emit(f"API Gemini zwróciło nieprawidłową odpowiedź lub model '{self.model_identifier}' nie znaleziono. Proszę sprawdzić ustawienia modelu i klucz API.\nSzczegóły: {api_err}")
- elif "AUTHENTICATION_ERROR" in error_str or "Invalid API key" in error_str:
- self.error.emit(f"Błąd autoryzacji API Gemini. Proszę sprawdzić klucz API w ustawieniach.")
- else:
- error_details = f"{type(api_err).__name__}: {api_err}"
- if hasattr(api_err, 'status_code'):
- error_details += f" (Status: {api_err.status_code})"
- self.error.emit(f"Wywołanie API Gemini nie powiodło się:\n{error_details}")
- return
- try:
- full_response_text = ""
- # Process the response stream chunk by chunk
- for chunk in response_stream:
- self._mutex.lock()
- is_running = self._is_running
- self._mutex.unlock()
- if not is_running:
- break
- if not chunk.candidates:
- continue
- try:
- # Concatenate text parts from the chunk
- # Safely access candidates and content
- text_parts = [part.text for candidate in chunk.candidates for part in candidate.content.parts if part.text]
- current_chunk = "".join(text_parts)
- except (AttributeError, IndexError) as e:
- print(f"Warning: Could not access chunk text: {e}")
- current_chunk = "" # Handle cases where structure isn't as expected
- if current_chunk:
- full_response_text += current_chunk
- self.response_chunk.emit(current_chunk)
- self._mutex.lock()
- stopped_manually = not self._is_running
- self._mutex.unlock()
- if not stopped_manually:
- self.response_complete.emit()
- except Exception as stream_err:
- self._mutex.lock()
- was_stopped = not self._is_running
- self._mutex.unlock()
- if not was_stopped:
- error_details = f"{type(stream_err).__name__}: {stream_err}"
- self.error.emit(f"Błąd podczas strumieniowania odpowiedzi z API Gemini:\n{error_details}")
- except Exception as e:
- error_details = f"{type(e).__name__}: {e}"
- self.error.emit(f"Wystąpił nieoczekiwany błąd w wątku roboczym Gemini:\n{error_details}\n{traceback.format_exc()}")
- class MistralWorker(QThread):
- response_chunk = pyqtSignal(str)
- response_complete = pyqtSignal()
- error = pyqtSignal(str)
- def __init__(self, api_key: str, user_message: str, chat_history: list, model_identifier: str, parent=None):
- super().__init__(parent)
- self.api_key = api_key
- self.user_message = user_message
- self.chat_history = chat_history # Raw history from main window
- self.model_identifier = model_identifier
- self._is_running = True
- self._mutex = QMutex()
- print(f"MistralWorker created for model: {model_identifier}")
- def stop(self):
- self._mutex.lock()
- try:
- self._is_running = False
- finally:
- self._mutex.unlock()
- def run(self):
- if not self.api_key:
- self.error.emit("Klucz API Mistral nie został skonfigurowany w ustawieniach.")
- return
- if not self.user_message.strip():
- self.error.emit("Proszę podać niepustą wiadomość tekstową.")
- return
- try:
- # Format history for Mistral API
- # Mistral API expects a list of ChatMessage objects or dicts {'role': '...', 'content': '...'}
- # The last message is the current user message, others are history
- api_messages = format_chat_history(self.chat_history, "mistral")
- api_messages.append(ChatMessage(role='user', content=self.user_message))
- try:
- client = MistralClient(api_key=self.api_key)
- response_stream = client.chat(
- model=self.model_identifier,
- messages=api_messages,
- stream=True
- )
- except Exception as api_err:
- error_str = str(api_err)
- # Add more specific error handling for Mistral API if needed
- if "authentication_error" in error_str.lower():
- self.error.emit(f"Błąd autoryzacji API Mistral. Proszę sprawdzić klucz API w ustawieniach.")
- elif "model_not_found" in error_str.lower():
- self.error.emit(f"Model Mistral '{self.model_identifier}' nie znaleziono lub jest niedostępny dla tego klucza API.")
- else:
- error_details = f"{type(api_err).__name__}: {api_err}"
- self.error.emit(f"Wywołanie API Mistral nie powiodło się:\n{error_details}")
- return
- try:
- full_response_text = ""
- # Process the response stream chunk by chunk
- for chunk in response_stream:
- self._mutex.lock()
- is_running = self._is_running
- self._mutex.unlock()
- if not is_running:
- break
- # Mistral stream chunk structure: chunk.choices[0].delta.content
- current_chunk = ""
- if chunk.choices and chunk.choices[0].delta and chunk.choices[0].delta.content:
- current_chunk = chunk.choices[0].delta.content
- if current_chunk:
- full_response_text += current_chunk
- self.response_chunk.emit(current_chunk)
- self._mutex.lock()
- stopped_manually = not self._is_running
- self._mutex.unlock()
- if not stopped_manually:
- self.response_complete.emit()
- except Exception as stream_err:
- self._mutex.lock()
- was_stopped = not self._is_running
- self._mutex.unlock()
- if not was_stopped:
- error_details = f"{type(stream_err).__name__}: {stream_err}"
- self.error.emit(f"Błąd podczas strumieniowania odpowiedzi z API Mistral:\n{error_details}")
- except Exception as e:
- error_details = f"{type(e).__name__}: {e}"
- self.error.emit(f"Wystąpił nieoczekiwany błąd w wątku roboczym Mistral:\n{error_details}\n{traceback.format_exc()}")
- # --- Pygments Helper for Syntax Highlighting ---
- # (highlight_code_html and related CSS - copied from your code)
- # ... (Paste your Pygments helper functions and CSS here) ...
- PYGMENTS_STYLE_NAME = 'dracula'
- try:
- PYGMENTS_CSS = HtmlFormatter(style=PYGMENTS_STYLE_NAME, full=False, cssclass='highlight').get_style_defs('.highlight')
- except ClassNotFound:
- print(f"Ostrzeżenie: Styl Pygments '{PYGMENTS_STYLE_NAME}' nie znaleziono. Używam 'default'.")
- PYGMENTS_STYLE_NAME = 'default'
- PYGMENTS_CSS = HtmlFormatter(style=PYGMENTS_STYLE_NAME, full=False, cssclass='highlight').get_style_defs('.highlight')
- CUSTOM_CODE_CSS = f"""
- .highlight {{
- padding: 0 !important;
- margin: 0 !important;
- }}
- .highlight pre {{
- margin: 0 !important;
- padding: 0 !important;
- border: none !important;
- white-space: pre-wrap;
- word-wrap: break-word;
- }}
- """
- FINAL_CODE_CSS = PYGMENTS_CSS + CUSTOM_CODE_CSS
- def highlight_code_html(code, language=''):
- try:
- if language:
- lexer = get_lexer_by_name(language, stripall=True)
- else:
- lexer = guess_lexer(code)
- if lexer.name == 'text':
- raise PygmentsClassNotFound # Don't use 'text' lexer
- except (PygmentsClassNotFound, ValueError):
- try:
- # Fallback to a generic lexer or plain text
- lexer = get_lexer_by_name('text', stripall=True)
- except PygmentsClassNotFound:
- # This fallback should theoretically always work, but as a safeguard:
- return f"<pre><code>{code}</code></pre>"
- formatter = HtmlFormatter(style=PYGMENTS_STYLE_NAME, full=False, cssclass='highlight')
- return highlight(code, lexer, formatter)
- # --- Custom Widgets for Chat Messages ---
- # (CodeDisplayTextEdit, MessageWidget - copied from your code)
- # ... (Paste your CodeDisplayTextEdit and MessageWidget classes here) ...
- class CodeDisplayTextEdit(QTextEdit):
- def __init__(self, parent=None):
- super().__init__(parent)
- self.setReadOnly(True)
- self.setAcceptRichText(True)
- self.setWordWrapMode(QTextOption.WrapMode.NoWrap) # Code blocks shouldn't wrap standardly
- self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
- self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
- self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
- self.setMinimumHeight(QFontMetrics(self.font()).lineSpacing() * 3 + 16)
- self.setFrameStyle(QTextEdit.Shape.Box | QTextEdit.Shadow.Plain)
- self.document().setDocumentMargin(0)
- self.setContentsMargins(0,0,0,0)
- self.setStyleSheet(f"""
- QTextEdit {{
- background-color: #2d2d2d; /* Dark background for code */
- color: #ffffff; /* White text */
- border: 1px solid #4a4a4a;
- border-radius: 5px;
- padding: 8px;
- font-family: "Consolas", "Courier New", monospace;
- font-size: 9pt; /* Smaller font for code blocks */
- }}
- {FINAL_CODE_CSS} /* Pygments CSS for syntax highlighting */
- """)
- def setHtml(self, html: str):
- super().setHtml(html)
- self.document().adjustSize()
- doc_height = self.document().size().height()
- buffer = 5
- self.setFixedHeight(int(doc_height) + buffer)
- class MessageWidget(QWidget):
- def __init__(self, role: str, content: str, metadata: dict = None, parent=None):
- super().__init__(parent)
- self.role = role
- self.content = content
- self.metadata = metadata
- self.is_placeholder = (role == "assistant" and metadata is not None and metadata.get("type") == "placeholder")
- self.segments = []
- self.layout = QVBoxLayout(self)
- self.layout.setContentsMargins(0, 5, 0, 5)
- self.layout.setSpacing(3)
- bubble_widget = QWidget()
- self.content_layout = QVBoxLayout(bubble_widget)
- self.content_layout.setContentsMargins(12, 8, 12, 8)
- self.content_layout.setSpacing(6)
- user_color = "#dcf8c6"
- assistant_color = "#e0e0e0"
- bubble_style = f"""
- QWidget {{
- background-color: {'{user_color}' if role == 'user' else '{assistant_color}'};
- border-radius: 15px;
- padding: 0px;
- border: 1px solid #e0e0e0;
- }}
- """
- if self.is_placeholder:
- bubble_style = """
- QWidget {
- background-color: #f0f0f0;
- border-radius: 15px;
- padding: 0px;
- border: 1px dashed #cccccc;
- }
- """
- bubble_widget.setStyleSheet(bubble_style)
- bubble_widget.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum)
- outer_layout = QHBoxLayout()
- outer_layout.setContentsMargins(0, 0, 0, 0)
- outer_layout.setSpacing(0)
- screen_geometry = QGuiApplication.primaryScreen().availableGeometry()
- max_bubble_width = int(screen_geometry.width() * 0.75)
- bubble_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
- bubble_widget.setMinimumWidth(1)
- spacer_left = QWidget()
- spacer_right = QWidget()
- spacer_left.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
- spacer_right.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
- if role == 'user':
- outer_layout.addWidget(spacer_left)
- outer_layout.addWidget(bubble_widget, 1)
- outer_layout.addWidget(spacer_right, 0)
- else:
- outer_layout.addWidget(spacer_left, 0)
- outer_layout.addWidget(bubble_widget, 1)
- outer_layout.addWidget(spacer_right)
- self.layout.addLayout(outer_layout)
- if self.is_placeholder:
- placeholder_label = QLabel(content)
- placeholder_label.setStyleSheet("QLabel { color: #505050; font-style: italic; padding: 10px; }")
- placeholder_label.setWordWrap(True)
- placeholder_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
- self.content_layout.addWidget(placeholder_label)
- self.placeholder_label = placeholder_label
- placeholder_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
- placeholder_label.setMinimumWidth(1)
- else:
- self.display_content(content, self.content_layout)
- self.content_layout.addStretch(1)
- def display_content(self, content, layout):
- block_pattern = re.compile(r'(^|\n)(`{3,})(\w*)\n(.*?)\n\2(?:\n|$)', re.DOTALL)
- last_end = 0
- for match in block_pattern.finditer(content):
- text_before = content[last_end:match.start()].strip()
- if text_before:
- self.add_text_segment(text_before, layout)
- code = match.group(4)
- language = match.group(3).strip()
- code_area = CodeDisplayTextEdit()
- highlighted_html = highlight_code_html(code, language)
- code_area.setHtml(highlighted_html)
- layout.addWidget(code_area)
- self.segments.append(code_area)
- copy_button = QPushButton("Kopiuj kod")
- copy_button.setIcon(QIcon.fromTheme("edit-copy", QIcon(":/icons/copy.png")))
- copy_button.setFixedSize(100, 25)
- copy_button.setStyleSheet("""
- QPushButton {
- background-color: #3c3c3c;
- color: #ffffff;
- border: 1px solid #5a5a5a;
- border-radius: 4px;
- padding: 2px 8px;
- font-size: 9pt;
- }
- QPushButton:hover {
- background-color: #4a4a4a;
- border-color: #6a6a6a;
- }
- QPushButton:pressed {
- background-color: #2a2a2a;
- border-color: #5a5a5a;
- }
- """)
- clipboard = QApplication.clipboard()
- if clipboard:
- copy_button.clicked.connect(lambda checked=False, code_widget=code_area: self.copy_code_to_clipboard(code_widget))
- else:
- copy_button.setEnabled(False)
- btn_layout = QHBoxLayout()
- btn_layout.addStretch()
- btn_layout.addWidget(copy_button)
- btn_layout.setContentsMargins(0, 0, 0, 0)
- btn_layout.setSpacing(0)
- layout.addLayout(btn_layout)
- last_end = match.end()
- remaining_text = content[last_end:].strip()
- if remaining_text:
- self.add_text_segment(remaining_text, layout)
- def add_text_segment(self, text: str, layout: QVBoxLayout):
- if not text:
- return
- text_edit = QTextEdit()
- text_edit.setReadOnly(True)
- text_edit.setFrameStyle(QTextEdit.Shape.NoFrame)
- text_edit.setContentsMargins(0, 0, 0, 0)
- text_edit.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
- text_edit.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
- text_edit.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
- text_edit.setWordWrapMode(QTextOption.WrapMode.WrapAtWordBoundaryOrAnywhere)
- text_edit.setAcceptRichText(True)
- text_edit.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
- text_edit.customContextMenuRequested.connect(lambda pos, te=text_edit: self.show_context_menu(pos, te))
- text_edit.setStyleSheet(f"""
- QTextEdit {{
- background-color: transparent;
- border: none;
- padding: 0;
- font-size: 10pt;
- color: {'#333333' if self.role == 'user' else '#ffffff'};
- }}
- """)
- html_text = text.replace('&', '&').replace('<', '<').replace('>', '>')
- html_text = re.sub(r'\*\*(.*?)\*\*', r'<b>\1</b>', html_text)
- inline_code_style = "font-family: Consolas, 'Courier New', monospace; background-color: #f0f0f0; padding: 1px 3px; border-radius: 3px; font-size: 9pt;"
- html_text = re.sub(r'`([^`]+)`', rf'<span style="{inline_code_style}">\1</span>', html_text)
- html_text = html_text.replace('\n', '<br>')
- text_edit.setHtml(html_text)
- self.segments.append(text_edit)
- text_edit.document().adjustSize()
- doc_size = text_edit.document().size()
- buffer = 5
- text_edit.setFixedHeight(int(doc_size.height()) + buffer)
- layout.addWidget(text_edit)
- def show_context_menu(self, position, text_edit):
- menu = QMenu(text_edit)
- copy_action = menu.addAction("Kopiuj")
- copy_action.setIcon(QIcon.fromTheme("edit-copy"))
- copy_action.triggered.connect(text_edit.copy)
- menu.addSeparator()
- select_all_action = menu.addAction("Zaznacz wszystko")
- select_all_action.setIcon(QIcon.fromTheme("edit-select-all"))
- select_all_action.setShortcut("Ctrl+A")
- select_all_action.triggered.connect(text_edit.selectAll)
- menu.exec(text_edit.viewport().mapToGlobal(position))
- def copy_code_to_clipboard(self, code_widget: CodeDisplayTextEdit):
- clipboard = QApplication.clipboard()
- if clipboard:
- code_text = code_widget.toPlainText()
- clipboard.setText(code_text)
- sender_button = self.sender()
- if sender_button:
- original_text = sender_button.text()
- sender_button.setText("Skopiowano!")
- QTimer.singleShot(1500, lambda: sender_button.setText(original_text))
- def update_placeholder_text(self, text):
- if self.is_placeholder and hasattr(self, 'placeholder_label'):
- display_text = text.strip()
- if len(display_text) > 200:
- display_text = "..." + display_text[-200:]
- display_text = "⚙️ Przetwarzam... " + display_text
- self.placeholder_label.setText(display_text)
- def apply_theme_colors(self, background: QColor, foreground: QColor, bubble_user: QColor, bubble_assistant: QColor):
- bubble_widget = self.findChild(QWidget)
- if bubble_widget and not self.is_placeholder:
- bubble_style = f"""
- QWidget {{
- background-color: {'{bubble_user.name()}' if self.role == 'user' else '{bubble_assistant.name()}'};
- border-radius: 15px;
- padding: 0px;
- border: 1px solid #e0e0e0;
- }}
- """
- bubble_widget.setStyleSheet(bubble_style)
- bubble_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
- for segment in self.segments:
- if isinstance(segment, QTextEdit) and not isinstance(segment, CodeDisplayTextEdit):
- segment.setStyleSheet(f"""
- QTextEdit {{
- background-color: transparent;
- border: none;
- padding: 0;
- font-size: 10pt;
- color: {'{foreground.name()}' if self.role == 'assistant' else '#333333'};
- }}
- """)
- # --- End of Custom Widgets ---
- # --- Code Editor Widget ---
- # (CodeEditor - copied and slightly modified for theme colors and line numbers)
- # ... (Paste your CodeEditor class here) ...
- class CodeEditor(QTextEdit):
- def __init__(self, parent=None):
- super().__init__(parent)
- self.setFont(QFont("Consolas", DEFAULT_FONT_SIZE))
- self.setLineWrapMode(QTextEdit.LineWrapMode.NoWrap)
- self.highlighter = PythonHighlighter(self.document()) # Default highlighter
- self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
- self.customContextMenuRequested.connect(self.show_context_menu)
- self.line_number_area = QWidget(self)
- self.line_number_area.setFixedWidth(40)
- self.line_number_area.setStyleSheet("background-color: #252526; color: #858585;")
- self.update_line_number_area_width()
- self.document().blockCountChanged.connect(self.update_line_number_area_width)
- self.verticalScrollBar().valueChanged.connect(lambda: self.line_number_area.update())
- self.textChanged.connect(lambda: self.line_number_area.update())
- self.cursorPositionChanged.connect(lambda: self.update_line_number_area(self.viewport().rect(), 0))
- self.cursorPositionChanged.connect(self.highlight_current_line)
- self.setTabStopDistance(QFontMetrics(self.font()).horizontalAdvance(' ') * 4)
- self.current_line_format = QTextCharFormat()
- self.current_line_format.setBackground(QColor("#2d2d2d"))
- def delete(self):
- cursor = self.textCursor()
- if cursor.hasSelection():
- cursor.removeSelectedText()
- else:
- cursor.deleteChar()
- self.setTextCursor(cursor)
- def show_context_menu(self, position):
- # This context menu is now set up by the main window's setup_editor_context_menu
- # which provides more actions. This local one is potentially redundant or simplified.
- # For now, let's keep the main window's setup, and this method could be empty or removed.
- # Or, it could call the main window's method. Let's update the main window setup.
- pass # The main window will handle this connection instead
- def update_line_number_area_width(self):
- digits = len(str(max(1, self.document().blockCount())))
- space = 10 + self.fontMetrics().horizontalAdvance('9') * digits
- self.line_number_area.setFixedWidth(space)
- self.setViewportMargins(self.line_number_area.width(), 0, 0, 0)
- def update_line_number_area(self, rect, dy):
- if dy != 0:
- self.line_number_area.scroll(0, dy)
- else:
- self.line_number_area.update(0, rect.y(), self.line_number_area.width(), rect.height())
- if rect.contains(self.viewport().rect()):
- self.update_line_number_area_width()
- def resizeEvent(self, event):
- super().resizeEvent(event)
- cr = self.contentsRect()
- self.line_number_area.setGeometry(QRect(cr.left(), cr.top(), self.line_number_area.width(), cr.height()))
- def line_number_area_paint_event(self, event):
- painter = QPainter(self.line_number_area)
- if not painter.isActive():
- painter.begin(self.line_number_area)
- bg_color = self.palette().color(QPalette.ColorRole.Base)
- painter.fillRect(event.rect(), bg_color)
- doc = self.document()
- block = doc.begin()
- block_number = 0
- scroll_offset = self.verticalScrollBar().value()
- top = block.layout().boundingRect().top() + scroll_offset
- bottom = top + block.layout().boundingRect().height()
- while block.isValid() and top <= event.rect().bottom():
- if block.isVisible() and bottom >= event.rect().top():
- number = str(block_number + 1)
- # Use line number area's stylesheet color for text
- # Accessing stylesheet color directly is tricky, fallback to palette or fixed color
- # Let's use the foreground color set in set_theme_colors
- painter.setPen(self.line_number_area.palette().color(QPalette.ColorRole.WindowText))
- painter.drawText(0, int(top), self.line_number_area.width() - 5, self.fontMetrics().height(),
- Qt.AlignmentFlag.AlignRight, number)
- block = block.next()
- if block.isValid():
- top = bottom
- # Recalculate block height each time
- bottom = top + self.blockBoundingRect(block).height() # Use blockBoundingRect for accurate height
- block_number += 1
- else:
- break
- def highlight_current_line(self):
- extra_selections = []
- if not self.isReadOnly():
- selection = QTextEdit.ExtraSelection()
- selection.format = self.current_line_format
- selection.format.setProperty(QTextFormat.Property.FullWidthSelection, True)
- selection.cursor = self.textCursor()
- selection.cursor.clearSelection()
- extra_selections.append(selection)
- self.setExtraSelections(extra_selections)
- def set_font_size(self, size: int):
- font = self.font()
- font.setPointSize(size)
- self.setFont(font)
- self.setTabStopDistance(QFontMetrics(self.font()).horizontalAdvance(' ') * 4)
- self.update_line_number_area_width()
- self.line_number_area.update()
- def set_theme_colors(self, background: QColor, foreground: QColor, line_number_bg: QColor, line_number_fg: QColor, current_line_bg: QColor):
- palette = self.palette()
- palette.setColor(QPalette.ColorRole.Base, background)
- palette.setColor(QPalette.ColorRole.Text, foreground)
- self.setPalette(palette)
- # Update line number area palette and stylesheet for immediate effect
- linenum_palette = self.line_number_area.palette()
- linenum_palette.setColor(QPalette.ColorRole.Window, line_number_bg) # Window role for background
- linenum_palette.setColor(QPalette.ColorRole.WindowText, line_number_fg) # WindowText for foreground
- self.line_number_area.setPalette(linenum_palette)
- self.line_number_area.setStyleSheet(f"QWidget {{ background-color: {line_number_bg.name()}; color: {line_number_fg.name()}; }}")
- self.current_line_format.setBackground(current_line_bg)
- self.highlight_current_line()
- def paintEvent(self, event):
- # Custom paint event to draw the line number area *before* the main editor content
- # Note: QWidget's paintEvent is not automatically called by QTextEdit's paintEvent
- # We need to manually trigger the line number area repaint or rely on its own update signals.
- # The current setup relies on signals connected in __init__.
- # We can skip the manual paintEvent call here and let the signals handle it.
- super().paintEvent(event)
- # --- End of Code Editor Widget ---
- # --- Settings Dialog ---
- class SettingsDialog(QDialog):
- def __init__(self, active_models_config: list, current_settings: dict, parent=None):
- super().__init__(parent)
- self.setWindowTitle("Ustawienia")
- self.setMinimumWidth(400)
- self.setModal(True)
- self.active_models_config = active_models_config
- self.current_settings = current_settings
- self.layout = QVBoxLayout(self)
- # Model selection
- model_label = QLabel("Model AI:")
- self.layout.addWidget(model_label)
- self.model_combo = QComboBox()
- # Populate with display names, but store API type and identifier as user data
- for api_type, identifier, display_name in self.active_models_config:
- self.model_combo.addItem(display_name, userData=(api_type, identifier))
- # Set the current model in the combobox
- try:
- current_api_type = self.current_settings.get("api_type")
- current_identifier = self.current_settings.get("model_identifier")
- # Find the index for the current model config
- for i in range(self.model_combo.count()):
- api_type, identifier = self.model_combo.itemData(i)
- if api_type == current_api_type and identifier == current_identifier:
- self.model_combo.setCurrentIndex(i)
- break
- else: # If current model not found, select the first available
- if self.model_combo.count() > 0:
- self.model_combo.setCurrentIndex(0)
- except Exception as e:
- print(f"Error setting initial model in settings dialog: {e}")
- if self.model_combo.count() > 0: self.model_combo.setCurrentIndex(0)
- self.layout.addWidget(self.model_combo)
- # API Key Inputs (conditional based on available APIs)
- if HAS_GEMINI:
- # Gemini key is usually from file, but could add an input here too if needed
- pass # Keep Gemini key from file for now
- if HAS_MISTRAL:
- mistral_key_label = QLabel("Klucz API Mistral:")
- self.layout.addWidget(mistral_key_label)
- self.mistral_key_input = QLineEdit()
- self.mistral_key_input.setPlaceholderText("Wprowadź klucz API Mistral")
- self.mistral_key_input.setText(self.current_settings.get("mistral_api_key", ""))
- self.layout.addWidget(self.mistral_key_input)
- # Theme selection
- theme_label = QLabel("Motyw:")
- self.layout.addWidget(theme_label)
- self.theme_combo = QComboBox()
- self.theme_combo.addItems(["ciemny", "jasny"])
- self.theme_combo.setCurrentText("ciemny" if self.current_settings.get("theme", DEFAULT_THEME) == "dark" else "jasny")
- self.layout.addWidget(self.theme_combo)
- # Font size
- font_label = QLabel("Rozmiar czcionki:")
- self.layout.addWidget(font_label)
- self.font_spin = QSpinBox()
- self.font_spin.setRange(8, 24)
- self.font_spin.setValue(self.current_settings.get("font_size", DEFAULT_FONT_SIZE))
- self.layout.addWidget(self.font_spin)
- # UI elements visibility
- self.sidebar_check = QCheckBox("Pokaż pasek boczny")
- self.sidebar_check.setChecked(self.current_settings.get("show_sidebar", True))
- self.layout.addWidget(self.sidebar_check)
- self.toolbar_check = QCheckBox("Pokaż pasek narzędzi")
- self.toolbar_check.setChecked(self.current_settings.get("show_toolbar", True))
- self.layout.addWidget(self.toolbar_check)
- self.statusbar_check = QCheckBox("Pokaż pasek stanu")
- self.statusbar_check.setChecked(self.current_settings.get("show_statusbar", True))
- self.layout.addWidget(self.statusbar_check)
- self.layout.addStretch(1)
- # Standard OK/Cancel buttons
- self.button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
- ok_button = self.button_box.button(QDialogButtonBox.StandardButton.Ok)
- if ok_button: ok_button.setText("Zapisz")
- cancel_button = self.button_box.button(QDialogButtonBox.StandardButton.Cancel)
- if cancel_button: cancel_button.setText("Anuluj")
- self.button_box.accepted.connect(self.accept)
- self.button_box.rejected.connect(self.reject)
- self.layout.addWidget(self.button_box)
- def get_selected_model_config(self) -> tuple:
- """Returns (api_type, model_identifier) of the selected model."""
- return self.model_combo.currentData()
- def get_mistral_api_key(self) -> str:
- """Returns the Mistral API key from the input field, or None if Mistral is not supported."""
- if HAS_MISTRAL:
- return self.mistral_key_input.text().strip() or None
- return None
- def get_selected_theme(self) -> str:
- return "dark" if self.theme_combo.currentText() == "ciemny" else "light"
- def get_font_size(self) -> int:
- return self.font_spin.value()
- def get_ui_visibility(self) -> dict:
- return {
- "show_sidebar": self.sidebar_check.isChecked(),
- "show_toolbar": self.toolbar_check.isChecked(),
- "show_statusbar": self.statusbar_check.isChecked()
- }
- # --- File Explorer ---
- class FileExplorer(QTreeView):
- # Signal emitted when one or more files are selected for opening (e.g., by double-click or context menu)
- # Emits a list of file paths (only files, not directories)
- openFilesRequested = pyqtSignal(list)
- # Signal emitted when one or more items are selected for deletion
- # Emits a list of file/directory paths
- deleteItemsRequested = pyqtSignal(list)
- def __init__(self, parent=None):
- super().__init__(parent)
- self.model = QFileSystemModel() # Store model as instance variable
- self.setModel(self.model)
- home_dir = QStandardPaths.standardLocations(QStandardPaths.StandardLocation.HomeLocation)[0]
- self.model.setRootPath(home_dir)
- self.setRootIndex(self.model.index(home_dir))
- self.setAnimated(False)
- self.setIndentation(15)
- self.setSortingEnabled(True)
- # Set default sorting: Folders first, then by name, case-insensitive is often preferred
- self.model.setFilter(QDir.Filter.AllEntries | QDir.Filter.NoDotAndDotDot | QDir.Filter.Hidden) # Hide . and .., also hidden files/folders
- # self.model.setSortingFlags(QDir.SortFlag.DirsFirst | QDir.SortFlag.Name | QDir.SortFlag.IgnoreCase | QDir.SortFlag.LocaleAware)
- # The above line caused the error. QFileSystemModel handles DirsFirst internally when sorting by name.
- # We can rely on the default sorting behavior combined with sortByColumn.
- self.sortByColumn(0, Qt.SortOrder.AscendingOrder) # Sort by Name column (0) ascending
- # Hide columns we don't need
- for i in range(1, self.model.columnCount()):
- self.hideColumn(i)
- # Selection mode: Allows multi-selection with Ctrl/Shift
- self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
- # Selection behavior: Select entire rows
- self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
- # Connect double-click
- # Disconnect the default activated behavior that changes root
- try:
- self.activated.disconnect(self.on_item_activated)
- except TypeError: # Handle case where it's not connected yet or connected elsewhere
- pass
- # Connect double-click to toggle expansion for directories and open for files
- self.doubleClicked.connect(self.on_item_double_clicked)
- self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
- self.customContextMenuRequested.connect(self.show_context_menu)
- # Internal clipboard for copy/cut operations
- self._clipboard_paths = []
- self._is_cut_operation = False
- def on_item_activated(self, index: QModelIndex):
- """Handles item activation (e.g., single-click or Enter key)."""
- # Keep single-click behavior minimal or just selection
- if not index.isValid():
- return
- # Default single-click is usually just selection, which is fine.
- # The original on_item_activated changed the root on double-click, which is now handled by on_item_double_clicked.
- # We can leave this method empty or connect it to something else if needed.
- pass
- def on_item_double_clicked(self, index: QModelIndex):
- """Handles item double-click."""
- if not index.isValid():
- return
- file_path = self.model.filePath(index)
- file_info = QFileInfo(file_path)
- if file_info.isDir():
- # Toggle directory expansion instead of changing root
- self.setExpanded(index, not self.isExpanded(index))
- else:
- # If it's a file, emit signal to main window to open it
- self.openFilesRequested.emit([file_path]) # Emit a list even for a single file
- def get_selected_paths(self) -> list:
- """Returns a list of unique file/directory paths for all selected items."""
- paths = set() # Use a set to ensure uniqueness
- # Iterate through selected indexes, but only take the first column's index for each row
- # to avoid duplicates if multiple columns were visible
- for index in self.selectedIndexes():
- if index.column() == 0: # Only process the name column index
- paths.add(self.model.filePath(index))
- return list(paths)
- def show_context_menu(self, position):
- menu = QMenu()
- index = self.indexAt(position) # Get index at click position
- clipboard = QApplication.clipboard() # Get global clipboard
- # --- Actions based on the clicked item ---
- if index.isValid():
- file_path = self.model.filePath(index)
- file_info = QFileInfo(file_path)
- selected_paths = self.get_selected_paths() # Get all selected items
- # --- Actions for the item at the click position ---
- # New File/Folder actions (only if clicked item is a directory)
- if file_info.isDir():
- new_file_action = menu.addAction(QIcon.fromTheme("document-new"), "Nowy plik w tym folderze")
- new_file_action.triggered.connect(lambda: self.create_new_file(file_path))
- new_folder_action = menu.addAction(QIcon.fromTheme("folder-new"), "Nowy folder w tym folderze")
- new_folder_action.triggered.connect(lambda: self.create_new_folder(file_path))
- menu.addSeparator()
- # Open action (for both files and directories)
- open_action = menu.addAction(QIcon.fromTheme("document-open"), "Otwórz")
- if file_info.isDir():
- # For directories, this could either expand/collapse or change root.
- # Let's make it change root via context menu for explicit navigation.
- open_action.triggered.connect(lambda: self.setRootIndex(index))
- # Alternative: open_action.triggered.connect(lambda: self.setExpanded(index, not self.isExpanded(index)))
- else:
- # For files, emit the open signal to the main window
- open_action.triggered.connect(lambda: self.openFilesRequested.emit([file_path]))
- # Copy/Cut actions for the clicked item
- copy_action = menu.addAction(QIcon.fromTheme("edit-copy"), "Kopiuj")
- copy_action.triggered.connect(lambda: self.copy_items([file_path]))
- cut_action = menu.addAction(QIcon.fromTheme("edit-cut"), "Wytnij")
- cut_action.triggered.connect(lambda: self.cut_items([file_path]))
- # Paste actions (conditional based on clipboard and clicked item type)
- if self._clipboard_paths: # Only show paste options if clipboard is not empty
- if file_info.isDir():
- # Paste into the clicked directory
- paste_into_action = menu.addAction(QIcon.fromTheme("edit-paste"), "Wklej do folderu")
- paste_into_action.triggered.connect(lambda: self.paste_items(file_path)) # Paste into this folder
- # Paste alongside the clicked directory (in its parent)
- parent_dir = self.model.filePath(index.parent())
- if parent_dir: # Cannot paste alongside the root of the model
- paste_alongside_action = menu.addAction(QIcon.fromTheme("edit-paste"), "Wklej obok")
- paste_alongside_action.triggered.connect(lambda: self.paste_items(parent_dir)) # Paste into parent folder
- else: # Clicked item is a file
- # Paste alongside the clicked file (in its parent)
- parent_dir = self.model.filePath(index.parent())
- if parent_dir: # Cannot paste alongside the root of the model
- paste_alongside_action = menu.addAction(QIcon.fromTheme("edit-paste"), "Wklej obok")
- paste_alongside_action.triggered.connect(lambda: self.paste_items(parent_dir)) # Paste into parent folder
- # Rename action for the clicked item
- rename_action = menu.addAction(QIcon.fromTheme("edit-rename"), "Zmień nazwę")
- rename_action.triggered.connect(lambda: self.edit(index)) # QTreeView.edit starts renaming
- # Delete action for the clicked item
- delete_action = menu.addAction(QIcon.fromTheme("edit-delete"), "Usuń")
- delete_action.triggered.connect(lambda: self.deleteItemsRequested.emit([file_path])) # Emit list for consistency
- menu.addSeparator()
- show_in_explorer_action = menu.addAction(QIcon.fromTheme("system-file-manager"), "Pokaż w menedżerze plików")
- show_in_explorer_action.triggered.connect(lambda: self.show_in_explorer(file_path))
- else:
- # --- Actions for empty space ---
- root_path = self.model.filePath(self.rootIndex()) # Target actions to the current root directory
- new_file_action = menu.addAction(QIcon.fromTheme("document-new"), "Nowy plik")
- new_file_action.triggered.connect(lambda: self.create_new_file(root_path))
- new_folder_action = menu.addAction(QIcon.fromTheme("folder-new"), "Nowy folder")
- new_folder_action.triggered.connect(lambda: self.create_new_folder(root_path))
- menu.addSeparator()
- # Paste action for empty space (paste into the current root directory)
- if self._clipboard_paths:
- paste_action = menu.addAction(QIcon.fromTheme("edit-paste"), f"Wklej elementy ({len(self._clipboard_paths)})")
- paste_action.triggered.connect(lambda: self.paste_items(root_path))
- select_all_action = menu.addAction(QIcon.fromTheme("edit-select-all"), "Zaznacz wszystko")
- select_all_action.triggered.connect(self.selectAll)
- # --- Actions for multiple selected items (if applicable, add them regardless of clicked item if multi-selected) ---
- # Check if *multiple* items are selected (excluding the single item already handled above)
- all_selected_paths = self.get_selected_paths()
- if len(all_selected_paths) > 1:
- # Avoid adding separator if one was just added
- if not menu.actions()[-1].isSeparator():
- menu.addSeparator()
- # Filter out directories for "Open Selected Files"
- selected_files = [p for p in all_selected_paths if QFileInfo(p).isFile()]
- if selected_files:
- open_selected_action = menu.addAction(QIcon.fromTheme("document-open-folder"), f"Otwórz zaznaczone pliki ({len(selected_files)})")
- open_selected_action.triggered.connect(lambda: self.openFilesRequested.emit(selected_files))
- # Copy/Cut for multiple selected items
- copy_selected_action = menu.addAction(QIcon.fromTheme("edit-copy"), f"Kopiuj zaznaczone elementy ({len(all_selected_paths)})")
- copy_selected_action.triggered.connect(lambda: self.copy_items(all_selected_paths))
- cut_selected_action = menu.addAction(QIcon.fromTheme("edit-cut"), f"Wytnij zaznaczone elementy ({len(all_selected_paths)})")
- cut_selected_action.triggered.connect(lambda: self.cut_items(all_selected_paths))
- # Delete action for all selected items (files and folders)
- delete_selected_action = menu.addAction(QIcon.fromTheme("edit-delete"), f"Usuń zaznaczone elementy ({len(all_selected_paths)})")
- delete_selected_action.triggered.connect(lambda: self.deleteItemsRequested.emit(all_selected_paths)) # Emit list for consistency
- menu.exec(self.viewport().mapToGlobal(position))
- def create_new_file(self, dir_path):
- name, ok = QInputDialog.getText(self, "Nowy plik", "Nazwa pliku:", QLineEdit.EchoMode.Normal, "nowy_plik.txt")
- if ok and name:
- file_path = os.path.join(dir_path, name)
- try:
- if os.path.exists(file_path):
- QMessageBox.warning(self, "Błąd", f"Plik już istnieje: {file_path}")
- return
- # Create the file manually
- with open(file_path, 'w', encoding='utf-8') as f:
- f.write('') # Create an empty file
- print(f"Utworzono plik: {file_path}")
- # Refresh the model to show the new file
- self.model.refresh(self.model.index(dir_path))
- # Optional: Select and start renaming the new file
- new_index = self.model.index(file_path)
- if new_index.isValid():
- self.setCurrentIndex(new_index)
- self.edit(new_index)
- self.parent().update_status_bar_message(f"Utworzono nowy plik: {os.path.basename(file_path)}")
- except Exception as e:
- QMessageBox.warning(self, "Błąd", f"Nie można utworzyć pliku '{name}':\n{e}")
- self.parent().update_status_bar_message(f"Błąd tworzenia pliku: {e}")
- def create_new_folder(self, dir_path):
- name, ok = QInputDialog.getText(self, "Nowy folder", "Nazwa folderu:", QLineEdit.EchoMode.Normal, "Nowy folder")
- if ok and name:
- folder_path = os.path.join(dir_path, name)
- try:
- if os.path.exists(folder_path):
- QMessageBox.warning(self, "Błąd", f"Folder już istnieje: {folder_path}")
- return
- # Use the model's method which handles refreshing and selection/editing
- index = self.model.index(dir_path)
- if index.isValid():
- new_index = self.model.mkdir(index, name)
- if new_index.isValid():
- # Optional: Expand parent and select/rename new folder
- self.setExpanded(index, True)
- self.setCurrentIndex(new_index)
- self.edit(new_index)
- if self.parent() and hasattr(self.parent(), 'update_status_bar_message'):
- self.parent().update_status_bar_message(f"Utworzono nowy folder: {os.path.basename(folder_path)}")
- else:
- print(f"Folder {folder_path} utworzony, ale brak metody 'update_status_bar_message'!")
- else:
- QMessageBox.warning(self, "Błąd", f"Nie można utworzyć folderu '{name}'. Sprawdź uprawnienia.")
- if self.parent() and hasattr(self.parent(), 'update_status_bar_message'):
- self.parent().update_status_bar_message(f"Błąd tworzenia folderu: {name}")
- else:
- # Fallback if dir_path cannot be found in the model (less likely for valid paths)
- os.mkdir(folder_path)
- self.model.refresh(self.model.index(dir_path)) # Manual refresh
- new_index = self.model.index(folder_path)
- if new_index.isValid():
- self.setExpanded(self.model.index(dir_path), True)
- self.setCurrentIndex(new_index)
- self.edit(new_index)
- if self.parent() and hasattr(self.parent(), 'update_status_bar_message'):
- self.parent().update_status_bar_message(f"Utworzono nowy folder: {os.path.basename(folder_path)}")
- else:
- QMessageBox.warning(self, "Błąd", f"Nie można utworzyć folderu '{name}'. Sprawdź uprawnienia.")
- if self.parent() and hasattr(self.parent(), 'update_status_bar_message'):
- self.parent().update_status_bar_message(f"Błąd tworzenia folderu: {name}")
- except Exception as e:
- QMessageBox.warning(self, "Błąd", f"Nie można utworzyć folderu '{name}':\n{e}")
- if self.parent() and hasattr(self.parent(), 'update_status_bar_message'):
- self.parent().update_status_bar_message(f"Błąd tworzenia folderu: {e}")
- def delete_items(self, file_paths: list):
- """Initiates deletion of a list of files/directories."""
- if not file_paths:
- return
- # Get confirmation for multiple items
- if len(file_paths) > 1:
- items_list = "\n".join([os.path.basename(p) for p in file_paths])
- reply = QMessageBox.question(self, "Usuń zaznaczone",
- f"Czy na pewno chcesz usunąć następujące elementy?\n\n{items_list}\n\nTa operacja jest nieodwracalna.",
- QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
- else:
- # Confirmation for single item (reusing logic from show_context_menu)
- item_name = os.path.basename(file_paths[0])
- reply = QMessageBox.question(self, "Usuń", f"Czy na pewno chcesz usunąć '{item_name}'?",
- QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
- if reply == QMessageBox.StandardButton.Yes:
- deleted_count = 0
- error_messages = []
- parent_dirs_to_refresh = set()
- for file_path in file_paths:
- parent_dirs_to_refresh.add(os.path.dirname(file_path))
- try:
- index = self.model.index(file_path)
- if index.isValid():
- # QFileSystemModel.remove handles both files and non-empty directories recursively
- # on supported platforms (like Windows, macOS). On Linux, it might be just rmdir for empty dirs.
- # Let's prioritize the model's method first as it might be more integrated.
- # For robustness, we can keep the shutil fallback.
- # NOTE: model.remove returns True on success, False on failure, doesn't raise exceptions.
- if self.model.remove(index.row(), 1, index.parent()):
- print(f"Usunięto element (model): {file_path}")
- deleted_count += 1
- else:
- # model.remove failed, try recursive deletion with shutil/os
- print(f"Model nie usunął '{file_path}', próbuję shutil/os...")
- if os.path.isdir(file_path):
- shutil.rmtree(file_path)
- print(f"Usunięto katalog (shutil): {file_path}")
- deleted_count += 1
- elif os.path.exists(file_path):
- os.remove(file_path)
- print(f"Usunięto plik (os.remove): {file_path}")
- deleted_count += 1
- else:
- # Should not happen if index was valid initially
- error_messages.append(f"Nie znaleziono: {file_path}")
- else:
- error_messages.append(f"Nieprawidłowa ścieżka lub element niedostępny: {file_path}")
- except Exception as e:
- error_messages.append(f"Nie można usunąć '{os.path.basename(file_path)}': {e}")
- print(f"Błąd usuwania '{file_path}': {traceback.format_exc()}") # Log error
- # Refresh parent directories that were affected
- for parent_dir in parent_dirs_to_refresh:
- if os.path.exists(parent_dir): # Ensure parent still exists
- self.model.refresh(self.model.index(parent_dir))
- if error_messages:
- QMessageBox.warning(self, "Błąd usuwania", "Wystąpiły błędy podczas usuwania niektórych elementów:\n\n" + "\n".join(error_messages))
- self.parent().update_status_bar_message(f"Wystąpiły błędy podczas usuwania ({len(error_messages)} błędów)")
- elif deleted_count > 0:
- self.parent().update_status_bar_message(f"Pomyślnie usunięto {deleted_count} elementów.")
- def copy_items(self, paths_to_copy: list):
- """Stores paths for copy operation in the internal clipboard."""
- if not paths_to_copy:
- return
- self._clipboard_paths = paths_to_copy
- self._is_cut_operation = False
- self.parent().update_status_bar_message(f"Skopiowano {len(paths_to_copy)} elementów.")
- print(f"Skopiowano: {self._clipboard_paths}") # Debug print
- def cut_items(self, paths_to_cut: list):
- """Stores paths for cut operation in the internal clipboard."""
- if not paths_to_cut:
- return
- self._clipboard_paths = paths_to_cut
- self._is_cut_operation = True
- self.parent().update_status_bar_message(f"Wycięto {len(paths_to_cut)} elementów.")
- print(f"Wycięto: {self._clipboard_paths}") # Debug print
- def paste_items(self, destination_dir: str):
- """Pastes items from the internal clipboard into the destination directory."""
- if not self._clipboard_paths:
- self.parent().update_status_bar_message("Schowek jest pusty.")
- return
- if not os.path.isdir(destination_dir):
- QMessageBox.warning(self, "Błąd wklejania", f"Docelowa ścieżka nie jest katalogiem: {destination_dir}")
- self.parent().update_status_bar_message(f"Błąd wklejania: {destination_dir} nie jest katalogiem.")
- return
- if not os.access(destination_dir, os.W_OK):
- QMessageBox.warning(self, "Błąd wklejania", f"Brak uprawnień zapisu w katalogu docelowym: {destination_dir}")
- self.parent().update_status_bar_message(f"Błąd wklejania: Brak uprawnień w {destination_dir}.")
- return
- operation = "Przenoszenie" if self._is_cut_operation else "Kopiowanie"
- self.parent().update_status_bar_message(f"{operation} {len(self._clipboard_paths)} elementów do '{os.path.basename(destination_dir)}'...")
- success_count = 0
- error_messages = []
- parent_dirs_to_refresh = {destination_dir} # Always refresh destination
- for src_path in self._clipboard_paths:
- if not os.path.exists(src_path):
- error_messages.append(f"Źródło nie istnieje: {os.path.basename(src_path)}")
- continue
- item_name = os.path.basename(src_path)
- dest_path = os.path.join(destination_dir, item_name)
- # Prevent pasting an item into itself or its sub-directory during a move
- if self._is_cut_operation and src_path == dest_path:
- error_messages.append(f"Nie można przenieść '{item_name}' w to samo miejsce.")
- continue
- if self._is_cut_operation and os.path.commonpath([src_path, dest_path]) == src_path and os.path.isdir(src_path):
- error_messages.append(f"Nie można przenieść '{item_name}' do jego podkatalogu.")
- continue
- # Handle potential overwrite (simple overwrite for now)
- if os.path.exists(dest_path):
- # Ask for confirmation? For simplicity, let's overwrite or skip for now.
- # A more complex dialog could be added here.
- # For this example, let's just overwrite.
- if os.path.isdir(dest_path):
- try: shutil.rmtree(dest_path)
- except Exception as e: error_messages.append(f"Nie można nadpisać katalogu '{item_name}': {e}"); continue
- else:
- try: os.remove(dest_path)
- except Exception as e: error_messages.append(f"Nie można nadpisać pliku '{item_name}': {e}"); continue
- try:
- if self._is_cut_operation:
- # Move the item
- shutil.move(src_path, dest_path)
- success_count += 1
- parent_dirs_to_refresh.add(os.path.dirname(src_path)) # Also refresh source's parent on move
- else:
- # Copy the item (recursive for directories)
- if os.path.isdir(src_path):
- shutil.copytree(src_path, dest_path)
- else:
- shutil.copy2(src_path, dest_path) # copy2 preserves metadata
- success_count += 1
- except Exception as e:
- error_messages.append(f"Błąd {operation.lower()} '{item_name}': {e}")
- print(f"Błąd {operation.lower()} '{src_path}' do '{dest_path}': {traceback.format_exc()}") # Log error
- # Refresh affected directories
- for refresh_dir in parent_dirs_to_refresh:
- if os.path.exists(refresh_dir):
- self.model.refresh(self.model.index(refresh_dir))
- if self._is_cut_operation and success_count > 0:
- # Clear clipboard only if it was a cut operation and at least one item was successfully moved
- self._clipboard_paths = []
- self._is_cut_operation = False
- if error_messages:
- QMessageBox.warning(self, f"Błąd {operation.lower()}enia", f"Wystąpiły błędy podczas {operation.lower()}enia:\n\n" + "\n".join(error_messages))
- self.parent().update_status_bar_message(f"Wystąpiły błędy podczas {operation.lower()}enia ({len(error_messages)} błędów)")
- elif success_count > 0:
- self.parent().update_status_bar_message(f"Pomyślnie {operation.lower()}ono {success_count} elementów.")
- else:
- # This case happens if clipboard was empty or all items failed
- if not self._clipboard_paths: # If clipboard was empty initially
- pass # Message already handled at the start
- else: # All items failed
- self.parent().update_status_bar_message(f"Nie udało się {operation.lower()}ić żadnych elementów.")
- def show_in_explorer(self, file_path):
- """Opens the file or folder in the native file explorer."""
- if sys.platform == "win32":
- try:
- # Use explorer.exe /select, to select the file/folder
- subprocess.Popen(['explorer.exe', '/select,', os.path.normpath(file_path)])
- self.parent().update_status_bar_message(f"Otworzono w eksploratorze: {os.path.basename(file_path)}")
- except FileNotFoundError:
- QMessageBox.warning(self, "Błąd", "Nie znaleziono 'explorer.exe'.")
- self.parent().update_status_bar_message("Błąd: Nie znaleziono explorer.exe.")
- except Exception as e:
- QMessageBox.warning(self, "Błąd", f"Nie można otworzyć menedżera plików:\n{e}")
- self.parent().update_status_bar_message(f"Błąd otwarcia w menedżerze: {e}")
- elif sys.platform == "darwin": # macOS
- try:
- # Use 'open -R' to reveal file in Finder, or 'open' for folder
- subprocess.Popen(['open', '-R', file_path])
- self.parent().update_status_bar_message(f"Otworzono w Finderze: {os.path.basename(file_path)}")
- except FileNotFoundError:
- QMessageBox.warning(self, "Błąd", "Nie znaleziono 'open'.")
- self.parent().update_status_bar_message("Błąd: Nie znaleziono open.")
- except Exception as e:
- QMessageBox.warning(self, "Błąd", f"Nie można otworzyć Findera:\n{e}")
- self.parent().update_status_bar_message(f"Błąd otwarcia w Finderze: {e}")
- else: # Linux
- try:
- # Use xdg-open which should open the containing folder
- # For a file, xdg-open opens the file. To open the folder containing the file:
- target_path = os.path.dirname(file_path) if os.path.isfile(file_path) else file_path
- subprocess.Popen(['xdg-open', target_path])
- self.parent().update_status_bar_message(f"Otworzono w menedżerze plików: {os.path.basename(target_path)}")
- except FileNotFoundError:
- QMessageBox.warning(self, "Błąd", "Nie znaleziono 'xdg-open'. Nie można otworzyć lokalizacji pliku.")
- self.parent().update_status_bar_message("Błąd: Nie znaleziono xdg-open.")
- except Exception as e:
- QMessageBox.warning(self, "Błąd", f"Nie można otworzyć lokalizacji pliku:\n{e}")
- self.parent().update_status_bar_message(f"Błąd otwarcia w menedżerze: {e}")
- # Open file logic is now handled by the main window via signal
- # The file explorer's open_file method is effectively replaced by on_item_activated
- # which emits openFilesRequested.
- # --- Main Application Window ---
- class CodeEditorWindow(QMainWindow):
- def __init__(self, parent=None):
- super().__init__(parent)
- self.setWindowTitle("Edytor Kodu AI")
- self.setGeometry(100, 100, 1200, 800)
- # Load settings
- self.settings = load_settings()
- self.current_api_type = self.settings.get("api_type", DEFAULT_MODEL_CONFIG[0])
- self.current_model_identifier = self.settings.get("model_identifier", DEFAULT_MODEL_CONFIG[1])
- self.mistral_api_key = self.settings.get("mistral_api_key") # Load Mistral key
- # Gemini key is loaded globally GEMINI_API_KEY_GLOBAL
- self.recent_files = self.settings["recent_files"]
- self.font_size = self.settings["font_size"]
- self.theme = self.settings["theme"]
- self.workspace = self.settings["workspace"]
- self.show_sidebar = self.settings["show_sidebar"]
- self.show_toolbar = self.settings["show_toolbar"]
- self.show_statusbar = self.settings["show_statusbar"]
- # Initialize UI
- self.init_ui()
- # Store references to menu actions for toggling visibility checks (Fix AttributeError)
- self.action_toggle_sidebar = self.findChild(QAction, "Przełącz Pasek Boczny")
- self.action_toggle_toolbar = self.findChild(QAction, "Przełącz Pasek Narzędzi")
- self.action_toggle_statusbar = self.findChild(QAction, "Przełącz Pasek Stanu")
- # Chat History State
- # Stored as list of (role, content, metadata) tuples
- self.chat_history = []
- # Threading Setup for API calls
- self.worker = None
- self.worker_thread = None
- self._is_processing = False
- # State for streaming response
- self.current_placeholder_widget = None
- self.current_response_content = ""
- # Setup status bar message timer
- self._status_timer = QTimer(self)
- self._status_timer.setSingleShot(True)
- self._status_timer.timeout.connect(self.clear_status_bar_message)
- # Open workspace if set and exists
- if self.workspace and os.path.exists(self.workspace) and os.path.isdir(self.workspace):
- self.file_explorer.setRootIndex(self.file_explorer.model.index(self.workspace))
- self.update_status_bar_message(f"Obszar roboczy: {self.workspace}")
- else:
- # If workspace is not set or invalid, set root to home directory
- home_dir = QStandardPaths.standardLocations(QStandardPaths.StandardLocation.HomeLocation)[0]
- self.file_explorer.model.setRootPath(home_dir)
- self.file_explorer.setRootIndex(self.file_explorer.model.index(home_dir))
- self.workspace = home_dir # Update settings to reflect actual root
- self.settings["workspace"] = self.workspace
- save_settings(self.settings) # Save updated workspace
- self.update_status_bar_message(f"Ustawiono domyślny obszar roboczy: {self.workspace}")
- # Add welcome message
- # Find the display name for the initial model
- initial_model_name = next((name for api_type, identifier, name in ACTIVE_MODELS_CONFIG if api_type == self.current_api_type and identifier == self.current_model_identifier), self.current_model_identifier)
- self.add_message("assistant", f"Witaj w edytorze kodu AI! Aktualnie działam na modelu '{initial_model_name}'. Jak mogę Ci dziś pomóc?")
- def init_ui(self):
- central_widget = QWidget()
- self.setCentralWidget(central_widget)
- self.main_splitter = QSplitter(Qt.Orientation.Horizontal)
- self.sidebar = QWidget()
- sidebar_layout = QVBoxLayout(self.sidebar)
- sidebar_layout.setContentsMargins(0, 0, 0, 0)
- sidebar_layout.setSpacing(0)
- # FileExplorer initialization (this is where the error occurred)
- # The fix is inside the FileExplorer class itself
- self.file_explorer = FileExplorer(self)
- sidebar_layout.addWidget(self.file_explorer)
- # Connect signals from FileExplorer
- self.file_explorer.openFilesRequested.connect(self.open_files)
- self.file_explorer.deleteItemsRequested.connect(self.file_explorer.delete_items) # Connect to file_explorer's delete method
- self.right_panel = QSplitter(Qt.Orientation.Vertical)
- self.tabs = QTabWidget()
- self.tabs.setTabsClosable(True)
- self.tabs.tabCloseRequested.connect(self.close_tab)
- self.tabs.currentChanged.connect(self.update_status_bar)
- self.chat_container = QWidget()
- chat_layout = QVBoxLayout(self.chat_container)
- chat_layout.setContentsMargins(0, 0, 0, 0)
- chat_layout.setSpacing(0)
- self.chat_scroll = QScrollArea()
- self.chat_scroll.setWidgetResizable(True)
- self.chat_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
- self.chat_widget = QWidget()
- self.chat_widget.setObjectName("chat_widget")
- self.chat_layout = QVBoxLayout(self.chat_widget)
- self.chat_layout.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignHCenter)
- self.chat_layout.setSpacing(10)
- self.chat_layout.addStretch(1)
- self.chat_scroll.setWidget(self.chat_widget)
- chat_layout.addWidget(self.chat_scroll)
- self.chat_input = QLineEdit()
- self.chat_input.setPlaceholderText("Wpisz wiadomość tutaj...")
- self.chat_input.returnPressed.connect(self.send_message)
- self.send_button = QPushButton("Wyślij")
- self.send_button.clicked.connect(self.send_message)
- input_layout = QHBoxLayout()
- input_layout.addWidget(self.chat_input, 1)
- input_layout.addWidget(self.send_button)
- chat_layout.addLayout(input_layout)
- self.main_splitter.addWidget(self.sidebar)
- self.right_panel.addWidget(self.tabs)
- self.right_panel.addWidget(self.chat_container)
- self.right_panel.setStretchFactor(0, 3)
- self.right_panel.setStretchFactor(1, 1)
- self.main_splitter.addWidget(self.right_panel)
- main_layout = QVBoxLayout(central_widget)
- main_layout.addWidget(self.main_splitter)
- self.create_menu_bar()
- self.create_tool_bar()
- self.status_bar = QStatusBar()
- self.setStatusBar(self.status_bar)
- self.update_status_bar()
- self.apply_font_size(self.font_size)
- self.apply_theme(self.theme)
- self.sidebar.setVisible(self.show_sidebar)
- self.toolbar.setVisible(self.show_toolbar)
- self.status_bar.setVisible(self.show_statusbar)
- self.main_splitter.setSizes([200, 800])
- self.right_panel.setSizes([600, 200])
- def create_menu_bar(self):
- menubar = self.menuBar()
- # File menu
- file_menu = menubar.addMenu("📄 Plik")
- new_action = QAction(QIcon.fromTheme("document-new"), "Nowy", self)
- new_action.setShortcut("Ctrl+N")
- new_action.setShortcutContext(Qt.ShortcutContext.WindowShortcut)
- new_action.triggered.connect(self.new_file)
- file_menu.addAction(new_action)
- open_action = QAction(QIcon.fromTheme("document-open"), "Otwórz...", self)
- open_action.setShortcut("Ctrl+O")
- open_action.setShortcutContext(Qt.ShortcutContext.WindowShortcut)
- open_action.triggered.connect(self.open_file_dialog)
- file_menu.addAction(open_action)
- save_action = QAction(QIcon.fromTheme("document-save"), "Zapisz", self)
- save_action.setObjectName("action_save") # Add object name for potential lookup
- save_action.setShortcut("Ctrl+S")
- save_action.setShortcutContext(Qt.ShortcutContext.WindowShortcut) # Ensure Ctrl+S works even when editor has focus
- save_action.triggered.connect(self.save_file)
- file_menu.addAction(save_action)
- save_as_action = QAction("Zapisz jako...", self)
- save_as_action.setShortcut("Ctrl+Shift+S")
- save_as_action.setShortcutContext(Qt.ShortcutContext.WindowShortcut)
- save_as_action.triggered.connect(self.save_file_as)
- file_menu.addAction(save_as_action)
- file_menu.addSeparator()
- open_workspace_action = QAction(QIcon.fromTheme("folder-open"), "Otwórz Obszar Roboczy...", self)
- open_workspace_action.triggered.connect(self.open_workspace)
- file_menu.addAction(open_workspace_action)
- self.recent_files_menu = file_menu.addMenu("Ostatnie pliki")
- self.update_recent_files_menu()
- file_menu.addSeparator()
- exit_action = QAction("Wyjście", self)
- exit_action.setShortcut("Ctrl+Q")
- exit_action.setShortcutContext(Qt.ShortcutContext.WindowShortcut)
- exit_action.triggered.connect(self.close)
- file_menu.addAction(exit_action)
- # Edit menu
- edit_menu = menubar.addMenu("✏️ Edycja")
- undo_action = QAction(QIcon.fromTheme("edit-undo"), "Cofnij", self)
- undo_action.setShortcut("Ctrl+Z")
- undo_action.setShortcutContext(Qt.ShortcutContext.WidgetWithChildrenShortcut) # Standard editor shortcut
- undo_action.triggered.connect(self.undo)
- edit_menu.addAction(undo_action)
- redo_action = QAction(QIcon.fromTheme("edit-redo"), "Ponów", self)
- redo_action.setShortcut("Ctrl+Y")
- redo_action.setShortcutContext(Qt.ShortcutContext.WidgetWithChildrenShortcut) # Standard editor shortcut
- redo_action.triggered.connect(self.redo)
- edit_menu.addAction(redo_action)
- edit_menu.addSeparator()
- cut_action = QAction(QIcon.fromTheme("edit-cut"), "Wytnij", self)
- cut_action.setShortcut("Ctrl+X")
- cut_action.setShortcutContext(Qt.ShortcutContext.WidgetWithChildrenShortcut) # Standard editor shortcut
- cut_action.triggered.connect(self.cut)
- edit_menu.addAction(cut_action)
- copy_action = QAction(QIcon.fromTheme("edit-copy"), "Kopiuj", self)
- copy_action.setShortcut("Ctrl+C")
- copy_action.setShortcutContext(Qt.ShortcutContext.WidgetWithChildrenShortcut) # Standard editor shortcut
- copy_action.triggered.connect(self.copy)
- edit_menu.addAction(copy_action)
- paste_action = QAction(QIcon.fromTheme("edit-paste"), "Wklej", self)
- paste_action.setShortcut("Ctrl+V")
- paste_action.setShortcutContext(Qt.ShortcutContext.WidgetWithChildrenShortcut) # Standard editor shortcut
- paste_action.triggered.connect(self.paste)
- edit_menu.addAction(paste_action)
- edit_menu.addSeparator()
- find_action = QAction(QIcon.fromTheme("edit-find"), "Znajdź...", self)
- find_action.setShortcut("Ctrl+F")
- find_action.setShortcutContext(Qt.ShortcutContext.WindowShortcut) # Find can often be window-wide
- find_action.triggered.connect(self.find)
- edit_menu.addAction(find_action)
- replace_action = QAction(QIcon.fromTheme("edit-find-replace"), "Zamień...", self)
- replace_action.setShortcut("Ctrl+H")
- replace_action.setShortcutContext(Qt.ShortcutContext.WindowShortcut)
- replace_action.triggered.connect(self.replace)
- edit_menu.addAction(replace_action)
- # View menu
- view_menu = menubar.addMenu("🖼️ Widok")
- # Store references to toggle actions (Fix AttributeError)
- self.action_toggle_sidebar = QAction("Przełącz Pasek Boczny", self)
- self.action_toggle_sidebar.setObjectName("Przełącz Pasek Boczny") # Set object name for findChild if needed elsewhere
- self.action_toggle_sidebar.setShortcut("Ctrl+B")
- self.action_toggle_sidebar.setCheckable(True)
- self.action_toggle_sidebar.setChecked(self.show_sidebar)
- self.action_toggle_sidebar.triggered.connect(self.toggle_sidebar)
- view_menu.addAction(self.action_toggle_sidebar)
- self.action_toggle_toolbar = QAction("Przełącz Pasek Narzędzi", self)
- self.action_toggle_toolbar.setObjectName("Przełącz Pasek Narzędzi")
- self.action_toggle_toolbar.setCheckable(True)
- self.action_toggle_toolbar.setChecked(self.show_toolbar)
- self.action_toggle_toolbar.triggered.connect(self.toggle_toolbar)
- view_menu.addAction(self.action_toggle_toolbar)
- self.action_toggle_statusbar = QAction("Przełącz Pasek Stanu", self)
- self.action_toggle_statusbar.setObjectName("Przełącz Pasek Stanu")
- self.action_toggle_statusbar.setCheckable(True)
- self.action_toggle_statusbar.setChecked(self.show_statusbar)
- self.action_toggle_statusbar.triggered.connect(self.toggle_statusbar)
- view_menu.addAction(self.action_toggle_statusbar)
- view_menu.addSeparator()
- dark_theme_action = QAction("Ciemny Motyw", self)
- dark_theme_action.triggered.connect(lambda: self.apply_theme("dark"))
- view_menu.addAction(dark_theme_action)
- light_theme_action = QAction("Jasny Motyw", self)
- light_theme_action.triggered.connect(lambda: self.apply_theme("light"))
- view_menu.addAction(light_theme_action)
- # Tools menu
- tools_menu = menubar.addMenu("🛠️ Narzędzia")
- run_code_action = QAction(QIcon.fromTheme("system-run"), "Uruchom kod", self)
- run_code_action.setShortcut("Ctrl+R")
- run_code_action.setShortcutContext(Qt.ShortcutContext.WindowShortcut) # Run code is window-wide
- run_code_action.triggered.connect(self.run_code)
- tools_menu.addAction(run_code_action)
- settings_action = QAction(QIcon.fromTheme("preferences-system"), "Ustawienia...", self)
- settings_action.triggered.connect(self.show_settings_dialog)
- tools_menu.addAction(settings_action)
- # Help menu
- help_menu = menubar.addMenu("❓ Pomoc")
- about_action = QAction("O programie", self)
- about_action.triggered.connect(self.show_about)
- help_menu.addAction(about_action)
- def create_tool_bar(self):
- self.toolbar = QToolBar("Główny Pasek Narzędzi")
- self.addToolBar(Qt.ToolBarArea.TopToolBarArea, self.toolbar)
- self.toolbar.setObjectName("main_toolbar") # Add object name for styling
- # Add actions (use the same actions created in the menu bar if possible, or recreate)
- # Recreating ensures they have icons regardless of theme availability
- self.toolbar.addAction(QAction(QIcon.fromTheme("document-new"), "Nowy", self, triggered=self.new_file))
- self.toolbar.addAction(QAction(QIcon.fromTheme("document-open"), "Otwórz", self, triggered=self.open_file_dialog))
- # Connect the toolbar save action to the same slot and set shortcut context
- save_toolbar_action = QAction(QIcon.fromTheme("document-save"), "Zapisz", self, triggered=self.save_file)
- save_toolbar_action.setShortcut("Ctrl+S") # Redundant but good practice
- save_toolbar_action.setShortcutContext(Qt.ShortcutContext.WindowShortcut)
- self.toolbar.addAction(save_toolbar_action)
- self.toolbar.addSeparator()
- undo_action = QAction(QIcon.fromTheme("edit-undo"), "Cofnij", self, triggered=self.undo)
- undo_action.setShortcut("Ctrl+Z")
- undo_action.setShortcutContext(Qt.ShortcutContext.WidgetWithChildrenShortcut)
- self.toolbar.addAction(undo_action)
- redo_action = QAction(QIcon.fromTheme("edit-redo"), "Ponów", self, triggered=self.redo)
- redo_action.setShortcut("Ctrl+Y")
- redo_action.setShortcutContext(Qt.ShortcutContext.WidgetWithChildrenShortcut)
- self.toolbar.addAction(redo_action)
- self.toolbar.addSeparator()
- cut_action = QAction(QIcon.fromTheme("edit-cut"), "Wytnij", self, triggered=self.cut)
- cut_action.setShortcut("Ctrl+X")
- cut_action.setShortcutContext(Qt.ShortcutContext.WidgetWithChildrenShortcut)
- self.toolbar.addAction(cut_action)
- copy_action = QAction(QIcon.fromTheme("edit-copy"), "Kopiuj", self, triggered=self.copy)
- copy_action.setShortcut("Ctrl+C")
- copy_action.setShortcutContext(Qt.ShortcutContext.WidgetWithChildrenShortcut)
- self.toolbar.addAction(copy_action)
- paste_action = QAction(QIcon.fromTheme("edit-paste"), "Wklej", self, triggered=self.paste)
- paste_action.setShortcut("Ctrl+V")
- paste_action.setShortcutContext(Qt.ShortcutContext.WidgetWithChildrenShortcut)
- self.toolbar.addAction(paste_action)
- self.toolbar.addSeparator()
- find_action = QAction(QIcon.fromTheme("edit-find"), "Znajdź", self, triggered=self.find)
- find_action.setShortcut("Ctrl+F")
- find_action.setShortcutContext(Qt.ShortcutContext.WindowShortcut)
- self.toolbar.addAction(find_action)
- self.toolbar.addSeparator()
- run_code_action = QAction(QIcon.fromTheme("system-run"), "➡️ Uruchom kod", self, triggered=self.run_code)
- run_code_action.setShortcut("Ctrl+R")
- run_code_action.setShortcutContext(Qt.ShortcutContext.WindowShortcut)
- self.toolbar.addAction(run_code_action)
- def apply_theme(self, theme_name):
- self.theme = theme_name
- self.settings["theme"] = theme_name
- save_settings(self.settings)
- if theme_name == "dark":
- main_bg = QColor("#252526")
- main_fg = QColor("#ffffff")
- menu_bg = QColor("#252526")
- menu_fg = QColor("#ffffff")
- menu_selected_bg = QColor("#2d2d30")
- menu_border = QColor("#454545")
- tab_pane_border = QColor("#454545")
- tab_pane_bg = QColor("#1e1e1e")
- tab_bg = QColor("#2d2d2d")
- tab_fg = QColor("#ffffff")
- tab_selected_bg = QColor("#1e1e1e")
- statusbar_bg = QColor("#252526")
- statusbar_fg = QColor("#ffffff")
- toolbar_bg = QColor("#252526")
- toolbar_fg = QColor("#ffffff")
- splitter_handle_bg = QColor("#252526")
- lineedit_bg = QColor("#333333")
- lineedit_fg = QColor("#ffffff")
- lineedit_border = QColor("#454545")
- button_bg = QColor("#3c3c3c")
- button_fg = QColor("#ffffff")
- button_border = QColor("#5a5a5a")
- button_hover_bg = QColor("#4a4a4a")
- button_pressed_bg = QColor("#2a2a2a")
- editor_bg = QColor("#1e1e1e")
- editor_fg = QColor("#d4d4d4")
- linenum_area_bg = QColor("#252526")
- linenum_fg = QColor("#858585")
- current_line_bg = QColor("#2d2d2d")
- chat_bg = QColor("#1e1e1e")
- chat_input_bg = QColor("#333333")
- chat_input_fg = QColor("#ffffff")
- bubble_user = QColor("#3a3a3a")
- bubble_assistant = QColor("#2d2d2d")
- bubble_border = QColor("#454545")
- else: # light theme
- main_bg = QColor("#f5f5f5")
- main_fg = QColor("#333333")
- menu_bg = QColor("#f5f5f5")
- menu_fg = QColor("#333333")
- menu_selected_bg = QColor("#e5e5e5")
- menu_border = QColor("#cccccc")
- tab_pane_border = QColor("#cccccc")
- tab_pane_bg = QColor("#ffffff")
- tab_bg = QColor("#e5e5e5")
- tab_fg = QColor("#333333")
- tab_selected_bg = QColor("#ffffff")
- statusbar_bg = QColor("#f5f5f5")
- statusbar_fg = QColor("#333333")
- toolbar_bg = QColor("#f5f5f5")
- toolbar_fg = QColor("#333333")
- splitter_handle_bg = QColor("#f5f5f5")
- lineedit_bg = QColor("#ffffff")
- lineedit_fg = QColor("#000000")
- lineedit_border = QColor("#cccccc")
- button_bg = QColor("#e1e1e1")
- button_fg = QColor("#000000")
- button_border = QColor("#cccccc")
- button_hover_bg = QColor("#d1d1d1")
- button_pressed_bg = QColor("#c1c1c1")
- editor_bg = QColor("#ffffff")
- editor_fg = QColor("#000000")
- linenum_area_bg = QColor("#eeeeee")
- linenum_fg = QColor("#666666")
- current_line_bg = QColor("#f0f0f0")
- chat_bg = QColor("#ffffff")
- chat_input_bg = QColor("#ffffff")
- chat_input_fg = QColor("#000000")
- bubble_user = QColor("#dcf8c6")
- bubble_assistant = QColor("#ffffff")
- bubble_border = QColor("#e0e0e0")
- palette = QPalette()
- palette.setColor(QPalette.ColorRole.Window, main_bg)
- palette.setColor(QPalette.ColorRole.WindowText, main_fg)
- palette.setColor(QPalette.ColorRole.Base, editor_bg) # Used by QTextEdit background
- palette.setColor(QPalette.ColorRole.Text, editor_fg) # Used by QTextEdit text color
- palette.setColor(QPalette.ColorRole.Button, button_bg)
- palette.setColor(QPalette.ColorRole.ButtonText, button_fg)
- palette.setColor(QPalette.ColorRole.Highlight, QColor("#0078d4"))
- palette.setColor(QPalette.ColorRole.HighlightedText, QColor("#ffffff"))
- palette.setColor(QPalette.ColorRole.ToolTipBase, QColor("#ffffe1")) # Tooltip background
- palette.setColor(QPalette.ColorRole.ToolTipText, QColor("#000000")) # Tooltip text
- # Set palette for the application
- QApplication.setPalette(palette)
- # Apply specific stylesheets
- self.setStyleSheet(f"""
- QMainWindow {{
- background-color: {main_bg.name()};
- color: {main_fg.name()};
- }}
- QMenuBar {{
- background-color: {menu_bg.name()};
- color: {menu_fg.name()};
- }}
- QMenuBar::item {{
- background-color: transparent;
- padding: 5px 10px;
- color: {menu_fg.name()};
- }}
- QMenuBar::item:selected {{
- background-color: {menu_selected_bg.name()};
- }}
- QMenu {{
- background-color: {menu_bg.name()};
- border: 1px solid {menu_border.name()};
- color: {menu_fg.name()};
- }}
- QMenu::item:selected {{
- background-color: {menu_selected_bg.name()};
- }}
- QTabWidget::pane {{
- border: 1px solid {tab_pane_border.name()};
- background: {tab_pane_bg.name()};
- }}
- QTabBar::tab {{
- background: {tab_bg.name()};
- color: {tab_fg.name()};
- padding: 5px;
- border: 1px solid {tab_pane_border.name()};
- border-bottom: none;
- min-width: 80px;
- }}
- QTabBar::tab:top, QTabBar::tab:bottom {{
- border-top-left-radius: 4px;
- border-top-right-radius: 4px;
- }}
- QTabBar::tab:left, QTabBar::tab:right {{
- border-top-left-radius: 4px;
- border-bottom-left-radius: 4px;
- }}
- QTabBar::tab:hover {{
- background: {tab_selected_bg.name()};
- }}
- QTabBar::tab:selected {{
- background: {tab_selected_bg.name()};
- border-bottom: 1px solid {tab_selected_bg.name()};
- }}
- QStatusBar {{
- background: {statusbar_bg.name()};
- color: {statusbar_fg.name()};
- border-top: 1px solid {menu_border.name()};
- }}
- QToolBar {{
- background: {toolbar_bg.name()};
- border: none;
- padding: 2px;
- spacing: 5px;
- }}
- QToolButton {{
- padding: 4px;
- border: 1px solid transparent; /* subtle border for hover */
- border-radius: 3px;
- }}
- QToolButton:hover {{
- background-color: {button_hover_bg.name()};
- border-color: {button_border.name()};
- }}
- QToolButton:pressed {{
- background-color: {button_pressed_bg.name()};
- border-color: {button_border.darker(150).name()};
- }}
- QSplitter::handle {{
- background: {splitter_handle_bg.name()};
- }}
- QSplitter::handle:hover {{
- background: {button_hover_bg.name()};
- }}
- QLineEdit {{
- background-color: {lineedit_bg.name()};
- color: {lineedit_fg.name()};
- border: 1px solid {lineedit_border.name()};
- padding: 4px;
- border-radius: 4px;
- }}
- QPushButton {{
- background-color: {button_bg.name()};
- color: {button_fg.name()};
- border: 1px solid {button_border.name()};
- border-radius: 4px;
- padding: 5px 10px;
- }}
- QPushButton:hover {{
- background-color: {button_hover_bg.name()};
- border-color: {button_border.darker(120).name()};
- }}
- QPushButton:pressed {{
- background-color: {button_pressed_bg.name()};
- border-color: {button_border.darker(150).name()};
- }}
- QScrollArea {{
- border: none;
- }}
- #chat_widget {{
- background-color: {chat_bg.name()};
- }}
- QTreeView {{
- background-color: {main_bg.name()};
- color: {main_fg.name()};
- border: 1px solid {tab_pane_border.name()}; /* Add border for separation */
- selection-background-color: {palette.color(QPalette.ColorRole.Highlight).name()};
- selection-color: {palette.color(QPalette.ColorRole.HighlightedText).name()};
- }}
- QTreeView::item:hover {{
- background-color: {menu_selected_bg.name()}; /* Subtle hover effect */
- }}
- """)
- # Apply theme colors to CodeEditor instances
- for i in range(self.tabs.count()):
- editor = self.tabs.widget(i)
- if isinstance(editor, CodeEditor):
- editor.set_theme_colors(editor_bg, editor_fg, linenum_area_bg, linenum_fg, current_line_bg)
- # Apply theme colors to MessageWidget instances
- for i in range(self.chat_layout.count()):
- item = self.chat_layout.itemAt(i)
- if item and item.widget() and isinstance(item.widget(), MessageWidget):
- message_widget = item.widget()
- message_widget.apply_theme_colors(chat_bg, main_fg, bubble_user, bubble_assistant)
- self.apply_font_size(self.font_size)
- def update_status_bar_message(self, message: str, timeout_ms: int = 3000):
- """Displays a temporary message in the status bar."""
- if self.statusBar() and self.show_statusbar:
- self.statusBar().showMessage(message, timeout_ms)
- # The timeout handling by showMessage is often sufficient, but a dedicated timer
- # can be used for more complex clearing logic if needed.
- # self._status_timer.stop()
- # self._status_timer.start(timeout_ms)
- def clear_status_bar_message(self):
- """Clears the temporary status bar message."""
- if self.statusBar() and self.show_statusbar:
- self.statusBar().clearMessage()
- self.update_status_bar() # Restore default status message (line/col)
- def apply_font_size(self, size: int):
- self.font_size = size
- self.settings["font_size"] = size
- save_settings(self.settings)
- for i in range(self.tabs.count()):
- editor = self.tabs.widget(i)
- if isinstance(editor, CodeEditor):
- editor.set_font_size(size)
- font = self.chat_input.font()
- font.setPointSize(size)
- self.chat_input.setFont(font)
- # Note: MessageWidget text font size is largely controlled by internal stylesheets (10pt, 9pt).
- def update_status_bar(self):
- # Ensure status bar object exists before trying to use it
- if self.statusBar() and self.show_statusbar:
- # If there's a temporary message, don't overwrite it immediately
- if not self.statusBar().currentMessage():
- editor = self.get_current_editor()
- if editor:
- cursor = editor.textCursor()
- line = cursor.blockNumber() + 1
- col = cursor.columnNumber() + 1
- modified_status = "*" if editor.document().isModified() else ""
- file_name = os.path.basename(getattr(editor, 'file_path', 'Bez tytułu'))
- self.statusBar().showMessage(f"Plik: {file_name}{modified_status} | Linia: {line}, Kolumna: {col}")
- else:
- current_tab_index = self.tabs.currentIndex()
- if current_tab_index != -1:
- tab_title = self.tabs.tabText(current_tab_index)
- self.statusBar().showMessage(f"Gotowy - {tab_title}")
- else:
- self.statusBar().showMessage("Gotowy")
- elif self.statusBar(): # Status bar exists but is hidden
- self.statusBar().clearMessage() # Clear any lingering message
- def get_current_editor(self):
- current_widget = self.tabs.currentWidget()
- if current_widget and isinstance(current_widget, CodeEditor):
- return current_widget
- return None
- def setup_editor_context_menu(self, editor):
- """Sets up a custom context menu for the CodeEditor instance."""
- # Disconnect any default context menu connection first if it existed
- try:
- editor.customContextMenuRequested.disconnect()
- except:
- pass # Ignore if not connected
- editor.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
- editor.customContextMenuRequested.connect(lambda pos: self.show_editor_context_menu(pos, editor)) # Pass editor explicitly
- def show_editor_context_menu(self, position, editor):
- """Shows a custom context menu for the CodeEditor."""
- menu = QMenu(editor)
- undo_action = menu.addAction("Cofnij")
- undo_action.setIcon(QIcon.fromTheme("edit-undo"))
- undo_action.setShortcut("Ctrl+Z")
- undo_action.triggered.connect(editor.undo)
- undo_action.setEnabled(editor.document().isUndoAvailable())
- redo_action = menu.addAction("Ponów")
- redo_action.setIcon(QIcon.fromTheme("edit-redo"))
- redo_action.setShortcut("Ctrl+Y")
- redo_action.triggered.connect(editor.redo)
- redo_action.setEnabled(editor.document().isRedoAvailable())
- menu.addSeparator()
- cut_action = menu.addAction("Wytnij")
- cut_action.setIcon(QIcon.fromTheme("edit-cut"))
- cut_action.setShortcut("Ctrl+X")
- cut_action.triggered.connect(editor.cut)
- cut_action.setEnabled(editor.textCursor().hasSelection())
- copy_action = menu.addAction("Kopiuj")
- copy_action.setIcon(QIcon.fromTheme("edit-copy"))
- copy_action.setShortcut("Ctrl+C")
- copy_action.triggered.connect(editor.copy)
- copy_action.setEnabled(editor.textCursor().hasSelection())
- paste_action = menu.addAction("Wklej")
- paste_action.setIcon(QIcon.fromTheme("edit-paste"))
- paste_action.setShortcut("Ctrl+V")
- paste_action.triggered.connect(editor.paste)
- clipboard = QApplication.clipboard()
- paste_action.setEnabled(bool(clipboard.text()))
- delete_action = menu.addAction("Usuń")
- delete_action.setIcon(QIcon.fromTheme("edit-delete"))
- delete_action.triggered.connect(lambda: editor.textCursor().removeSelectedText())
- delete_action.setEnabled(editor.textCursor().hasSelection())
- menu.addSeparator()
- select_all_action = menu.addAction("Zaznacz wszystko")
- select_all_action.setIcon(QIcon.fromTheme("edit-select-all"))
- select_all_action.setShortcut("Ctrl+A")
- select_all_action.triggered.connect(editor.selectAll)
- menu.exec(editor.viewport().mapToGlobal(position))
- def new_file(self):
- editor = CodeEditor()
- editor.document().contentsChanged.connect(self.update_status_bar)
- editor.cursorPositionChanged.connect(self.update_status_bar)
- editor.document().setModified(False) # New file starts as unmodified
- self.setup_editor_context_menu(editor) # Setup the context menu
- tab_title = "Bez tytułu"
- # Store file_path as None initially for unsaved files
- editor.file_path = None
- editor.setObjectName("editor_tab") # Add object name for styling
- self.tabs.addTab(editor, tab_title)
- self.tabs.setCurrentWidget(editor)
- self.apply_font_size(self.font_size)
- # Re-apply theme to ensure new editor gets correct colors
- self.apply_theme(self.theme)
- self.update_recent_files(None) # Add placeholder for untitled file (or just update menu)
- self.update_status_bar()
- self.update_status_bar_message("Utworzono nowy plik 'Bez tytułu'.")
- def open_file_dialog(self):
- start_dir = self.workspace if self.workspace and os.path.exists(self.workspace) else QStandardPaths.standardLocations(QStandardPaths.StandardLocation.HomeLocation)[0]
- file_path, _ = QFileDialog.getOpenFileName(self, "Otwórz plik", start_dir,
- "Wszystkie pliki (*);;"
- "Pliki Pythona (*.py);;"
- "Pliki tekstowe (*.txt);;"
- "Pliki CSS (*.css);;"
- "Pliki HTML (*.html *.htm);;"
- "Pliki JavaScript (*.js);;"
- "Pliki GML (*.gml);;"
- "Pliki JSON (*.json);;"
- "Pliki Markdown (*.md);;"
- "Pliki konfiguracyjne (*.ini *.cfg)")
- if file_path:
- self.open_file(file_path)
- def open_file(self, file_path):
- """Opens a single file in a new tab."""
- if not file_path or not os.path.exists(file_path):
- QMessageBox.warning(self, "Błąd", f"Plik nie znaleziono:\n{file_path}")
- self.update_status_bar_message(f"Błąd: Plik nie znaleziono ({os.path.basename(file_path)})")
- return False
- # Check if the file is already open in a tab
- for i in range(self.tabs.count()):
- editor = self.tabs.widget(i)
- if isinstance(editor, CodeEditor) and hasattr(editor, 'file_path') and editor.file_path == file_path:
- self.tabs.setCurrentIndex(i)
- self.update_status_bar_message(f"Przełączono na plik: {os.path.basename(file_path)}")
- return True
- # If not already open, open the file
- try:
- with open(file_path, 'r', encoding='utf-8') as f:
- content = f.read()
- editor = CodeEditor()
- editor.setPlainText(content)
- editor.document().contentsChanged.connect(self.update_status_bar)
- editor.cursorPositionChanged.connect(self.update_status_bar)
- editor.document().setModified(False) # Newly opened file is not modified
- self.setup_editor_context_menu(editor)
- file_name = os.path.basename(file_path)
- tab_title = file_name
- # Set syntax highlighting based on file extension
- # Get the actual CodeEditor highlighter object
- highlighter = None
- file_extension = os.path.splitext(file_path)[1].lower()
- if file_extension == '.py':
- highlighter = PythonHighlighter(editor.document())
- elif file_extension == '.css':
- highlighter = CSSHighlighter(editor.document())
- elif file_extension in ['.html', '.htm']:
- highlighter = HTMLHighlighter(editor.document())
- elif file_extension == '.js':
- highlighter = JSHighlighter(editor.document())
- elif file_extension == '.gml':
- highlighter = GMLHighlighter(editor.document())
- # Add more extensions/highlighters as needed
- editor.highlighter = highlighter # Assign the created highlighter (can be None)
- if highlighter:
- highlighter.rehighlight()
- editor.file_path = file_path
- editor.setObjectName("editor_tab") # Add object name for styling
- self.tabs.addTab(editor, tab_title)
- self.tabs.setCurrentWidget(editor)
- self.update_recent_files(file_path) # Update recent files list
- self.apply_font_size(self.font_size)
- self.apply_theme(self.theme) # Re-apply theme to ensure new editor has correct colors
- self.update_status_bar()
- self.update_status_bar_message(f"Otworzono plik: {os.path.basename(file_path)}")
- return True
- except Exception as e:
- QMessageBox.warning(self, "Błąd", f"Nie można otworzyć pliku '{file_path}':\n{e}")
- self.update_status_bar_message(f"Błąd otwierania pliku: {e}")
- return False
- def open_files(self, file_paths: list):
- """Opens a list of files."""
- if not file_paths:
- return
- for file_path in file_paths:
- # Call the single open_file method for each file in the list
- self.open_file(file_path)
- def save_file(self):
- editor = self.get_current_editor()
- if not editor:
- self.update_status_bar_message("Brak aktywnego edytora do zapisania.")
- return False # No active editor
- # If editor has a file_path, save to it
- if hasattr(editor, 'file_path') and editor.file_path and os.path.exists(os.path.dirname(editor.file_path) if os.path.dirname(editor.file_path) else '.'):
- # Check if the directory exists, or if it's a new file in the current directory
- file_path = editor.file_path
- else:
- # If no file_path (new file) or path is invalid, use Save As
- return self.save_file_as()
- # Perform the save operation
- try:
- with open(file_path, 'w', encoding='utf-8') as f:
- f.write(editor.toPlainText())
- editor.document().setModified(False)
- self.update_status_bar()
- self.update_recent_files(file_path)
- self.update_status_bar_message(f"Zapisano plik: {os.path.basename(file_path)}")
- return True
- except Exception as e:
- QMessageBox.warning(self, "Błąd", f"Nie można zapisać pliku '{file_path}':\n{e}")
- self.update_status_bar_message(f"Błąd zapisywania pliku: {e}")
- return False
- def save_file_as(self):
- editor = self.get_current_editor()
- if not editor:
- self.update_status_bar_message("Brak aktywnego edytora do zapisania.")
- return False
- initial_dir = self.workspace if self.workspace and os.path.exists(self.workspace) else QStandardPaths.standardLocations(QStandardPaths.StandardLocation.HomeLocation)[0]
- if hasattr(editor, 'file_path') and editor.file_path and os.path.dirname(editor.file_path):
- initial_dir = os.path.dirname(editor.file_path)
- file_path, _ = QFileDialog.getSaveFileName(self, "Zapisz plik jako", initial_dir, "All Files (*);;Python Files (*.py);;Text Files (*.txt)")
- if not file_path:
- self.update_status_bar_message("Zapisywanie anulowane.")
- return False
- try:
- with open(file_path, 'w', encoding='utf-8') as f:
- f.write(editor.toPlainText())
- file_name = os.path.basename(file_path)
- index = self.tabs.indexOf(editor)
- self.tabs.setTabText(index, file_name)
- editor.file_path = file_path
- editor.document().setModified(False)
- self.update_status_bar()
- self.update_recent_files(file_path)
- # Update syntax highlighting if extension changed
- highlighter = None
- file_extension = os.path.splitext(file_path)[1].lower()
- if file_extension == '.py':
- highlighter = PythonHighlighter(editor.document())
- elif file_extension == '.css':
- highlighter = CSSHighlighter(editor.document())
- elif file_extension in ['.html', '.htm']:
- highlighter = HTMLHighlighter(editor.document())
- elif file_extension == '.js':
- highlighter = JSHighlighter(editor.document())
- elif file_extension == '.gml':
- highlighter = GMLHighlighter(editor.document())
- # Add more extensions/highlighters as needed
- editor.highlighter = highlighter # Assign the new highlighter
- editor.document().clearFormats() # Clear old highlighting before applying new one
- if highlighter:
- highlighter.rehighlight()
- self.update_status_bar_message(f"Zapisano plik jako: {os.path.basename(file_path)}")
- return True
- except Exception as e:
- QMessageBox.warning(self, "Błąd", f"Nie można zapisać pliku '{file_path}':\n{e}")
- self.update_status_bar_message(f"Błąd zapisywania pliku jako: {e}")
- return False
- def close_tab(self, index):
- editor = self.tabs.widget(index)
- if editor:
- if isinstance(editor, CodeEditor) and editor.document().isModified():
- file_name = os.path.basename(getattr(editor, 'file_path', 'Bez tytułu'))
- reply = QMessageBox.question(self, "Zapisz zmiany", f"Czy chcesz zapisać zmiany w '{file_name}' przed zamknięciem?",
- QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No | QMessageBox.StandardButton.Cancel)
- if reply == QMessageBox.StandardButton.Yes:
- # Need to save the tab before closing. If save fails/cancelled, stop closing.
- # Temporarily set this tab as current to make save_file work on it.
- original_index = self.tabs.currentIndex()
- self.tabs.setCurrentIndex(index)
- save_success = self.save_file()
- self.tabs.setCurrentIndex(original_index) # Restore original index
- if not save_success:
- return # Stop closing if save failed/cancelled
- elif reply == QMessageBox.StandardButton.Cancel:
- return # User cancelled closing
- # If reply is No or Yes (and save succeeded), continue closing
- tab_name = self.tabs.tabText(index) # Get name *before* removing tab
- self.tabs.removeTab(index)
- editor.deleteLater()
- self.update_status_bar() # Update status bar as current tab might change
- self.update_status_bar_message(f"Zamknięto zakładkę: {tab_name}")
- def update_recent_files(self, file_path):
- """Updates the list of recent files and the menu."""
- # Note: None is passed for untitled files, which shouldn't be added to recent.
- if file_path and isinstance(file_path, str) and os.path.exists(file_path):
- # Normalize path for consistency
- file_path = os.path.normpath(file_path)
- # Remove if already exists to move it to the top
- if file_path in self.recent_files:
- self.recent_files.remove(file_path)
- # Add to the beginning
- self.recent_files.insert(0, file_path)
- # Trim the list if it exceeds max size
- if len(self.recent_files) > RECENT_FILES_MAX:
- self.recent_files = self.recent_files[:RECENT_FILES_MAX]
- # Save the updated list
- self.settings["recent_files"] = self.recent_files
- save_settings(self.settings)
- # Always update the menu after potentially changing the list
- self.update_recent_files_menu()
- def update_recent_files_menu(self, menu: QMenu = None):
- """Updates the 'Ostatnie pliki' menu."""
- # Find the menu if not passed
- if menu is None:
- # Iterate through the menu bar actions to find the "Plik" menu
- for file_action in self.menuBar().actions():
- if file_action.text() == "📄 Plik" and file_action.menu():
- # Iterate through the "Plik" menu actions to find the "Ostatnie pliki" submenu
- for sub_action in file_action.menu().actions():
- # Use object name or text for lookup
- # Ensure the action has a menu before accessing it
- if sub_action.text() == "Ostatnie pliki" and sub_action.menu():
- menu = sub_action.menu()
- break
- if menu: break # Stop searching once found
- if not menu: # Menu not found, cannot update
- print("Warning: Recent files menu not found.")
- return
- menu.clear()
- # Filter out non-existent files from the stored list before updating the menu
- valid_recent_files = [f for f in self.recent_files if os.path.exists(f)]
- if not valid_recent_files:
- menu.setEnabled(False)
- # Add a dummy action
- disabled_action = QAction("Brak ostatnio otwieranych plików", self)
- disabled_action.setEnabled(False)
- menu.addAction(disabled_action)
- else:
- menu.setEnabled(True)
- # Use the filtered list to populate the menu
- for file_path in valid_recent_files:
- action = QAction(os.path.basename(file_path), self)
- # Store the file path in the action's data
- action.setData(file_path)
- action.triggered.connect(self.open_recent_file)
- menu.addAction(action)
- # Update settings with the cleaned list
- if valid_recent_files != self.recent_files:
- self.recent_files = valid_recent_files
- self.settings["recent_files"] = self.recent_files
- save_settings(self.settings)
- def open_recent_file(self):
- action = self.sender()
- if action:
- file_path = action.data()
- if file_path and isinstance(file_path, str) and os.path.exists(file_path):
- self.open_file(file_path)
- else:
- QMessageBox.warning(self, "Błąd", f"Ostatni plik nie znaleziono:\n{file_path}")
- self.update_status_bar_message(f"Błąd: Ostatni plik nie znaleziono ({os.path.basename(file_path)})")
- # Remove invalid file from recent list
- if file_path in self.recent_files:
- self.recent_files.remove(file_path)
- self.settings["recent_files"] = self.recent_files
- save_settings(self.settings)
- self.update_recent_files_menu()
- def open_workspace(self):
- start_dir = self.workspace if self.workspace and os.path.exists(self.workspace) else QStandardPaths.standardLocations(QStandardPaths.StandardLocation.HomeLocation)[0]
- dir_path = QFileDialog.getExistingDirectory(self, "Otwórz Obszar Roboczy", start_dir)
- if dir_path:
- self.workspace = dir_path
- self.file_explorer.model.setRootPath(dir_path)
- self.file_explorer.setRootIndex(self.file_explorer.model.index(dir_path))
- self.settings["workspace"] = dir_path
- save_settings(self.settings)
- self.update_status_bar_message(f"Zmieniono obszar roboczy na: {dir_path}")
- # Standard editor actions (delegated to current editor)
- def undo(self):
- editor = self.get_current_editor()
- if editor:
- editor.undo()
- self.update_status_bar_message("Cofnięto ostatnią operację.")
- def redo(self):
- editor = self.get_current_editor()
- if editor:
- editor.redo()
- self.update_status_bar_message("Ponowiono ostatnią operację.")
- def cut(self):
- editor = self.get_current_editor()
- if editor:
- editor.cut()
- self.update_status_bar_message("Wycięto zaznaczenie.")
- def copy(self):
- editor = self.get_current_editor()
- if editor:
- editor.copy()
- self.update_status_bar_message("Skopiowano zaznaczenie.")
- def paste(self):
- editor = self.get_current_editor()
- if editor:
- editor.paste()
- self.update_status_bar_message("Wklejono zawartość schowka.")
- # Basic find/replace (delegated)
- def find(self):
- editor = self.get_current_editor()
- if editor:
- text, ok = QInputDialog.getText(self, "Znajdź", "Szukaj:")
- if ok and text:
- cursor = editor.textCursor()
- # Find from current position first
- if not editor.find(text):
- # If not found from current position, try from the beginning
- cursor.movePosition(QTextCursor.MoveOperation.Start)
- editor.setTextCursor(cursor)
- if not editor.find(text):
- QMessageBox.information(self, "Znajdź", f"'{text}' nie znaleziono.")
- self.update_status_bar_message(f"Nie znaleziono '{text}'.")
- else:
- self.update_status_bar_message(f"Znaleziono pierwsze wystąpienie '{text}'.")
- else:
- self.update_status_bar_message(f"Znaleziono następne wystąpienie '{text}'.")
- def replace(self):
- editor = self.get_current_editor()
- if editor:
- find_text, ok1 = QInputDialog.getText(self, "Zamień", "Szukaj:")
- if ok1 and find_text:
- replace_text, ok2 = QInputDialog.getText(self, "Zamień", "Zamień na:")
- if ok2:
- # Simple text replacement (replaces all occurrences)
- text = editor.toPlainText()
- # Count occurrences before replacing
- occurrences = text.count(find_text)
- if occurrences > 0:
- new_text = text.replace(find_text, replace_text)
- editor.setPlainText(new_text)
- editor.document().setModified(True)
- self.update_status_bar()
- self.update_status_bar_message(f"Zamieniono {occurrences} wystąpień '{find_text}'.")
- else:
- QMessageBox.information(self, "Zamień", f"'{find_text}' nie znaleziono.")
- self.update_status_bar_message(f"Nie znaleziono '{find_text}' do zamiany.")
- # Code execution
- def run_code(self):
- editor = self.get_current_editor()
- if editor:
- code = editor.toPlainText()
- if not code.strip():
- self.add_message("assistant", "Brak kodu do uruchomienia.")
- self.update_status_bar_message("Brak kodu do uruchomienia.")
- return
- # Add user message to chat history
- self.add_message("user", f"Proszę uruchomić ten kod:\n```\n{code}\n```")
- try:
- # Use a temporary file with a .py extension to allow python interpreter to identify it
- # Ensure the temp directory exists and has write permissions
- temp_dir = tempfile.gettempdir()
- if not os.access(temp_dir, os.W_OK):
- QMessageBox.warning(self, "Błąd", f"Brak uprawnień zapisu w katalogu tymczasowym: {temp_dir}")
- self.add_message("assistant", f"Błąd: Brak uprawnień zapisu w katalogu tymczasowym.")
- self.update_status_bar_message("Błąd: Brak uprawnień zapisu w katalogu tymczasowym.")
- return
- # Ensure the temp file has a .py extension for the interpreter
- temp_file = tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False, encoding='utf-8', dir=temp_dir)
- temp_file_path = temp_file.name
- temp_file.write(code)
- temp_file.close()
- self.add_message("assistant", "⚙️ Uruchamiam kod...", {"type": "placeholder"})
- self.update_status_bar_message(f"Uruchamiam kod ({os.path.basename(getattr(editor, 'file_path', 'Bez tytułu'))})")
- # Run the code in a separate process
- # Using sys.executable ensures we use the same Python interpreter running the app
- process = subprocess.Popen([sys.executable, temp_file_path],
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
- text=True, # Decode output as text
- encoding='utf-8',
- cwd=os.path.dirname(temp_file_path)) # Set working directory
- stdout = ""
- stderr = ""
- try:
- # Use a slightly longer timeout, maybe 30 seconds?
- # Or make it configurable. Let's stick to 10 for now.
- timeout_seconds = 10
- stdout, stderr = process.communicate(timeout=timeout_seconds)
- process.wait() # Ensure process is truly finished
- except subprocess.TimeoutExpired:
- process.kill() # Kill the process if it times out
- process.wait() # Wait for it to be killed
- stderr = f"Czas wykonania kodu minął po {timeout_seconds} sekundach. Proces został przerwany.\n{stderr}"
- self.update_status_bar_message(f"Wykonanie kodu przekroczyło limit czasu ({timeout_seconds}s).")
- except Exception as proc_err:
- stderr = f"Błąd wewnętrzny podczas uruchamiania procesu: {proc_err}\n{stderr}"
- self.update_status_bar_message(f"Błąd wewnętrzny uruchamiania kodu: {proc_err}")
- # Clean up the temporary file
- try:
- os.unlink(temp_file_path)
- except OSError as e:
- print(f"Błąd usuwania pliku tymczasowego {temp_file_path}: {e}")
- # Decide if this should be a user-visible error, probably not critical
- # Remove the placeholder message
- self.remove_last_message_widget()
- # Display the output and errors in the chat
- output_message = ""
- if stdout:
- output_message += f"Wyjście:\n```text\n{stdout.strip()}\n```\n" # Use 'text' for plain output highlighting
- if stderr:
- output_message += f"Błędy:\n```text\n{stderr.strip()}\n```\n"
- if output_message:
- self.add_message("assistant", f"Wykonanie kodu zakończone:\n{output_message}")
- self.update_status_bar_message("Wykonanie kodu zakończone.")
- else:
- self.add_message("assistant", "Kod wykonano bez wyjścia i błędów.")
- self.update_status_bar_message("Kod wykonano bez wyjścia/błędów.")
- except FileNotFoundError:
- self.remove_last_message_widget()
- self.add_message("assistant", f"Błąd: Interpreter Pythona '{sys.executable}' nie znaleziono.")
- self.update_status_bar_message(f"Błąd: Interpreter Pythona nie znaleziono.")
- except Exception as e:
- self.remove_last_message_widget()
- self.add_message("assistant", f"Błąd wykonania kodu: {str(e)}")
- print(f"Błąd uruchamiania kodu: {traceback.format_exc()}")
- self.update_status_bar_message(f"Błąd wykonania kodu: {e}")
- # Visibility toggles (Fixed AttributeErrors by using stored action references)
- def toggle_sidebar(self):
- self.show_sidebar = not self.show_sidebar
- self.sidebar.setVisible(self.show_sidebar)
- self.settings["show_sidebar"] = self.show_sidebar
- save_settings(self.settings)
- if self.action_toggle_sidebar: # Check if reference exists
- self.action_toggle_sidebar.setChecked(self.show_sidebar)
- self.update_status_bar_message(f"Pasek boczny: {'widoczny' if self.show_sidebar else 'ukryty'}")
- def toggle_toolbar(self):
- self.show_toolbar = not self.show_toolbar
- self.toolbar.setVisible(self.show_toolbar)
- self.settings["show_toolbar"] = self.show_toolbar
- save_settings(self.settings)
- if self.action_toggle_toolbar: # Check if reference exists
- self.action_toggle_toolbar.setChecked(self.show_toolbar)
- self.update_status_bar_message(f"Pasek narzędzi: {'widoczny' if self.show_toolbar else 'ukryty'}")
- def toggle_statusbar(self):
- self.show_statusbar = not self.show_statusbar
- if self.statusBar():
- self.statusBar().setVisible(self.show_statusbar)
- self.settings["show_statusbar"] = self.show_statusbar
- save_settings(self.settings)
- if self.action_toggle_statusbar: # Check if reference exists
- self.action_toggle_statusbar.setChecked(self.show_statusbar)
- # Status bar message won't appear if status bar is now hidden
- if self.show_statusbar:
- self.update_status_bar_message(f"Pasek stanu: {'widoczny' if self.show_statusbar else 'ukryty'}")
- def show_settings_dialog(self):
- # Pass active model configurations and current settings
- dialog = SettingsDialog(ACTIVE_MODELS_CONFIG, self.settings, self)
- if dialog.exec() == QDialog.DialogCode.Accepted:
- # Update model and API type
- selected_api_type, selected_identifier = dialog.get_selected_model_config()
- model_changed = (selected_api_type != self.current_api_type or selected_identifier != self.current_model_identifier)
- self.current_api_type = selected_api_type
- self.current_model_identifier = selected_identifier
- self.settings["api_type"] = self.current_api_type
- self.settings["model_identifier"] = self.current_model_identifier
- # Update Mistral key
- if HAS_MISTRAL:
- new_mistral_key = dialog.get_mistral_api_key()
- key_changed = (new_mistral_key != self.mistral_api_key)
- self.mistral_api_key = new_mistral_key
- self.settings["mistral_api_key"] = self.mistral_api_key
- else:
- key_changed = False # Key couldn't change if Mistral isn't supported
- save_settings(self.settings)
- # Inform user about settings changes
- status_messages = []
- if model_changed:
- display_name = next((name for api_type, identifier, name in ACTIVE_MODELS_CONFIG if api_type == self.current_api_type and identifier == self.current_model_identifier), self.current_model_identifier)
- status_messages.append(f"Model AI zmieniono na '{display_name}'.")
- if key_changed:
- status_messages.append(f"Ustawienia klucza API Mistral zaktualizowane.")
- new_theme = dialog.get_selected_theme()
- if new_theme != self.theme:
- self.apply_theme(new_theme)
- status_messages.append(f"Motyw zmieniono na '{new_theme}'.")
- new_font_size = dialog.get_font_size()
- if new_font_size != self.font_size:
- self.apply_font_size(new_font_size)
- status_messages.append(f"Rozmiar czcionki zmieniono na {new_font_size}.")
- ui_visibility = dialog.get_ui_visibility()
- if ui_visibility["show_sidebar"] != self.show_sidebar:
- self.toggle_sidebar() # This call updates settings and status bar message internally
- if ui_visibility["show_toolbar"] != self.show_toolbar:
- self.toggle_toolbar() # This call updates settings and status bar message internally
- if ui_visibility["show_statusbar"] != self.show_statusbar:
- self.toggle_statusbar() # This call updates settings and status bar message internally
- if status_messages:
- self.update_status_bar_message("Ustawienia zaktualizowane: " + "; ".join(status_messages), 5000)
- else:
- self.update_status_bar_message("Ustawienia zapisano.")
- def show_about(self):
- QMessageBox.about(self, "Informacje o Edytorze Kodu AI",
- "<h2>Edytor Kodu AI</h2>"
- "<p>Prosty edytor kodu z integracją czatu AI.</p>"
- "<p>Wykorzystuje API Google Gemini i Mistral do pomocy AI.</p>"
- "<p>Wersja 1.1</p>"
- "<p>Stworzony przy użyciu PyQt6, google-generativeai i mistralai</p>")
- self.update_status_bar_message("Wyświetlono informacje o programie.")
- # --- Chat Message Handling ---
- def add_message(self, role: str, content: str, metadata: dict = None):
- # Add message to internal history (excluding placeholders, errors, empty)
- if metadata is None or metadata.get("type") not in ["placeholder", "error", "empty_response"]:
- # Store clean history for API calls
- self.chat_history.append((role, content, metadata))
- # Limit history size
- HISTORY_LIMIT = 20 # Keep a reasonable history size
- if len(self.chat_history) > HISTORY_LIMIT:
- self.chat_history = self.chat_history[len(self.chat_history) - HISTORY_LIMIT:]
- message_widget = MessageWidget(role, content, metadata=metadata, parent=self.chat_widget)
- # Apply current theme colors
- if self.theme == "dark":
- bubble_user_color = QColor("#3a3a3a")
- bubble_assistant_color = QColor("#2d2d2d")
- main_fg_color = QColor("#ffffff")
- else: # light theme
- bubble_user_color = QColor("#dcf8c6")
- bubble_assistant_color = QColor("#ffffff")
- main_fg_color = QColor("#333333")
- message_widget.apply_theme_colors(self.chat_widget.palette().color(QPalette.ColorRole.Window), main_fg_color, bubble_user_color, bubble_assistant_color)
- # Add the widget to the chat layout, keeping the stretch item at the end
- # Find the stretch item
- stretch_item = None
- if self.chat_layout.count() > 0:
- last_item = self.chat_layout.itemAt(self.chat_layout.count() - 1)
- if last_item and last_item.spacerItem():
- stretch_item = self.chat_layout.takeAt(self.chat_layout.count() - 1)
- self.chat_layout.addWidget(message_widget)
- # Re-add the stretch item if found
- if stretch_item:
- self.chat_layout.addItem(stretch_item)
- elif self.chat_layout.count() == 1: # If this is the very first message and no stretch was added yet
- self.chat_layout.addStretch(1)
- if message_widget.is_placeholder:
- self.current_placeholder_widget = message_widget
- QTimer.singleShot(50, self.scroll_chat_to_bottom)
- def remove_last_message_widget(self):
- if self.chat_layout.count() > 1: # Need at least 1 widget + 1 stretch
- widget_to_remove = None
- # Find the last widget item before the stretch
- for i in reversed(range(self.chat_layout.count())):
- item = self.chat_layout.itemAt(i)
- if item and item.widget():
- widget_to_remove = item.widget()
- break
- if widget_to_remove:
- self.chat_layout.removeWidget(widget_to_remove)
- widget_to_remove.deleteLater()
- self.current_placeholder_widget = None
- def scroll_chat_to_bottom(self):
- self.chat_scroll.verticalScrollBar().setValue(self.chat_scroll.verticalScrollBar().maximum())
- def send_message(self):
- if self._is_processing:
- return
- msg = self.chat_input.text().strip()
- if not msg:
- return
- self._is_processing = True
- self.add_message("user", msg, None)
- self.chat_input.clear()
- self.chat_input.setPlaceholderText("Czekam na odpowiedź...")
- self.send_button.setEnabled(False)
- self.chat_input.setEnabled(False)
- self.update_status_bar_message("Wysyłam zapytanie do modelu AI...")
- # Stop any running worker thread
- if self.worker_thread and self.worker_thread.isRunning():
- print("Stopping existing worker thread...")
- self.worker.stop()
- if not self.worker_thread.wait(1000): # Wait up to 1 second
- print("Worker thread did not stop cleanly, terminating.")
- self.worker_thread.terminate()
- self.worker_thread.wait()
- print("Worker thread stopped.")
- # Determine which worker to use based on selected API type
- api_type = self.current_api_type
- model_identifier = self.current_model_identifier
- worker = None
- if api_type == "gemini" and HAS_GEMINI:
- api_key = GEMINI_API_KEY_GLOBAL # Use the globally loaded Gemini key
- if not api_key:
- self.handle_error("Klucz API Google Gemini nie znaleziono. Ustaw go w pliku .api_key lub w ustawieniach.")
- self._is_processing = False # Reset state
- self.send_button.setEnabled(True)
- self.chat_input.setEnabled(True)
- self.chat_input.setPlaceholderText("Wpisz wiadomość tutaj...")
- return
- worker = GeminiWorker(api_key, msg, list(self.chat_history), model_identifier)
- elif api_type == "mistral" and HAS_MISTRAL:
- api_key = self.mistral_api_key # Use the key from settings
- if not api_key:
- self.handle_error("Klucz API Mistral nie ustawiono w ustawieniach.")
- self._is_processing = False # Reset state
- self.send_button.setEnabled(True)
- self.chat_input.setEnabled(True)
- self.chat_input.setPlaceholderText("Wpisz wiadomość tutaj...")
- return
- worker = MistralWorker(api_key, msg, list(self.chat_history), model_identifier)
- else:
- # Check if the selected model type is "none" (fallback when no APIs are installed)
- if api_type == "none":
- self.handle_error("Brak dostępnych modeli AI. Proszę zainstalować obsługiwane biblioteki API.")
- else:
- self.handle_error(f"Wybrany model '{model_identifier}' (API '{api_type}') nie jest obsługiwany lub brakuje zainstalowanych bibliotek.")
- self._is_processing = False # Reset state
- self.send_button.setEnabled(True)
- self.chat_input.setEnabled(True)
- self.chat_input.setPlaceholderText("Wpisz wiadomość tutaj...")
- return
- self.worker = worker # Store the current worker
- self.worker_thread = QThread()
- self.worker.moveToThread(self.worker_thread)
- self.worker.response_chunk.connect(self.handle_response_chunk)
- self.worker.response_complete.connect(self.handle_response_complete)
- self.worker.error.connect(self.handle_error)
- self.worker_thread.started.connect(self.worker.run)
- self.worker.finished.connect(self.worker_thread.quit)
- self.worker.finished.connect(self.worker.deleteLater)
- self.worker_thread.finished.connect(self.worker_thread.deleteLater)
- self.current_response_content = ""
- display_name = next((name for t, i, name in ACTIVE_MODELS_CONFIG if t == api_type and i == model_identifier), model_identifier)
- self.add_message("assistant", f"⚙️ Przetwarzam z użyciem '{display_name}'...", {"type": "placeholder"})
- self.worker_thread.start()
- def handle_response_chunk(self, chunk: str):
- self.current_response_content += chunk
- if self.current_placeholder_widget:
- self.current_placeholder_widget.update_placeholder_text(self.current_response_content)
- self.scroll_chat_to_bottom()
- def handle_response_complete(self):
- if self.current_placeholder_widget:
- self.remove_last_message_widget()
- final_content = self.current_response_content.strip()
- if final_content:
- self.add_message("assistant", self.current_response_content, None)
- else:
- self.add_message("assistant", "Otrzymano pustą odpowiedź od modelu.", {"type": "empty_response"})
- self.current_response_content = ""
- self.send_button.setEnabled(True)
- self.chat_input.setEnabled(True)
- self.chat_input.setPlaceholderText("Wpisz wiadomość tutaj...")
- self.chat_input.setFocus()
- self._is_processing = False
- self.scroll_chat_to_bottom()
- self.update_status_bar_message("Odpowiedź AI zakończona.")
- def handle_error(self, error_message: str):
- if self.current_placeholder_widget:
- self.remove_last_message_widget()
- error_styled_message = f"<span style='color: #cc0000; font-weight: bold;'>Błąd API:</span> {error_message}"
- self.add_message("assistant", error_styled_message, {"type": "error"})
- self.send_button.setEnabled(True)
- self.chat_input.setEnabled(True)
- self.chat_input.setPlaceholderText("Wpisz wiadomość tutaj...")
- self.chat_input.setFocus()
- self._is_processing = False
- self.current_response_content = ""
- self.scroll_chat_to_bottom()
- self.update_status_bar_message(f"Błąd API: {error_message[:50]}...") # Truncate message for status bar
- def closeEvent(self, event):
- # Stop the worker thread
- if self.worker_thread and self.worker_thread.isRunning():
- self.worker.stop()
- if not self.worker_thread.wait(3000): # Wait up to 3 seconds
- print("Worker thread did not finish after stop signal, terminating.")
- self.worker_thread.terminate()
- self.worker_thread.wait()
- # Check for unsaved files
- unsaved_tabs = []
- for i in range(self.tabs.count()):
- editor = self.tabs.widget(i)
- if isinstance(editor, CodeEditor) and editor.document().isModified():
- unsaved_tabs.append(i)
- if unsaved_tabs:
- # Ask about saving all unsaved tabs
- reply = QMessageBox.question(self, "Zapisz zmiany", f"Masz niezapisane zmiany w {len(unsaved_tabs)} plikach.\nCzy chcesz zapisać zmiany przed wyjściem?",
- QMessageBox.StandardButton.SaveAll | QMessageBox.StandardButton.Discard | QMessageBox.StandardButton.Cancel)
- if reply == QMessageBox.StandardButton.Cancel:
- event.ignore() # Stop closing
- self.update_status_bar_message("Zamykanie anulowane.")
- return
- elif reply == QMessageBox.StandardButton.SaveAll:
- save_success = True
- # Iterate over unsaved tabs and try to save each one
- for index in unsaved_tabs:
- editor = self.tabs.widget(index) # Get the editor again, index might change if tabs are closed during save
- if editor and isinstance(editor, CodeEditor) and editor.document().isModified():
- # Temporarily switch to the tab to make save_file work correctly
- original_index = self.tabs.currentIndex()
- self.tabs.setCurrentIndex(index)
- current_save_success = self.save_file() # This updates status bar
- self.tabs.setCurrentIndex(original_index) # Restore original index
- if not current_save_success:
- save_success = False
- # If any save fails/cancelled, stop the whole process
- event.ignore()
- self.update_status_bar_message("Zamykanie przerwane z powodu błędu zapisu.")
- return # Stop the loop and closing process
- if save_success:
- event.accept() # Continue closing if all saves succeeded
- else:
- # This path should ideally not be reached due to the 'return' above,
- # but as a safeguard:
- event.ignore()
- self.update_status_bar_message("Zamykanie przerwane z powodu błędu zapisu.") # Redundant but safe
- return
- elif reply == QMessageBox.StandardButton.Discard:
- # Discard changes and close all tabs
- # We need to close tabs in reverse order to avoid index issues
- for i in reversed(unsaved_tabs):
- self.tabs.removeTab(i) # Remove tab without saving check
- event.accept() # Continue closing
- self.update_status_bar_message("Zamknięto pliki bez zapisywania zmian.")
- else:
- # No unsaved tabs, just accept the close event
- event.accept()
- # --- Main Application Entry Point ---
- if __name__ == "__main__":
- # Enable High DPI scaling
- QGuiApplication.setHighDpiScaleFactorRoundingPolicy(Qt.HighDpiScaleFactorRoundingPolicy.PassThrough)
- app = QApplication(sys.argv)
- app.setApplicationName("Edytor Kodu AI")
- app.setOrganizationName("YourOrganization")
- # Initialize icon theme if available
- # QIcon.setThemeName("breeze-dark") # Example theme, requires icon theme installed
- try:
- main_window = CodeEditorWindow()
- main_window.show()
- sys.exit(app.exec())
- except Exception as app_error:
- print(f"Wystąpił nieoczekiwany błąd podczas uruchamiania aplikacji:\n{app_error}")
- traceback.print_exc()
- # Ensure message box is shown even if app failed to initialize fully
- try:
- QMessageBox.critical(None, "Błąd uruchomienia aplikacji", f"Wystąpił nieoczekiwany błąd podczas uruchomienia aplikacji:\n{app_error}\n\nSprawdź konsolę, aby uzyskać szczegóły.")
- except Exception as mb_error:
- print(f"Could not show error message box: {mb_error}")
- sys.exit(1)
Advertisement
Comments
-
- Leon West Accidental Goblin King Audiobooks 1-4
- magnet:?xt=urn:btih:49d386821d7a4093ac6209084242fbdf979b0ac1
- magnet:?xt=urn:btih:9f49b631081256fdab2d7b13927ce27bd44cf683
- magnet:?xt=urn:btih:6ef04c5cd32428d63afbca8a5b754688082059d3
- magnet:?xt=urn:btih:feae4390a335f0743bc8852d70183ace64240e1a
Add Comment
Please, Sign In to add comment
Advertisement