· 4 months ago · May 18, 2025, 01:30 PM
1import os
2import sys
3import threading
4import traceback
5import time
6import re
7import platform
8import json
9import subprocess
10import tempfile
11import shutil # Added for recursive directory deletion
12from datetime import datetime
13
14# Conditional imports for AI APIs
15try:
16 import google.generativeai as genai
17 HAS_GEMINI = True
18except ImportError:
19 print("Warning: google-generativeai not found. Gemini API support disabled.")
20 HAS_GEMINI = False
21 class MockGeminiModel: # Mock class to prevent errors if genai is missing
22 def __init__(self, model_name): self.model_name = model_name
23 def start_chat(self, history): return MockChatSession()
24 class MockChatSession:
25 def send_message(self, message, stream=True):
26 class MockChunk: text = "Mock Gemini Response (API not available)"
27 return [MockChunk()]
28 genai = type('genai', (object,), {'GenerativeModel': MockGeminiModel, 'configure': lambda *args, **kwargs: None})()
29
30try:
31 from mistralai.client import MistralClient
32 from mistralai.models.chat_models import ChatMessage
33 HAS_MISTRAL = True
34except ImportError:
35 print("Warning: mistralai not found. Mistral API support disabled.")
36 HAS_MISTRAL = False
37 class MockMistralClient: # Mock class to prevent errors if mistralai is missing
38 def __init__(self, api_key): pass
39 def chat(self, model, messages, stream=True):
40 class MockChunk:
41 choices = [type('MockChoice', (object,), {'delta': type('MockDelta', (object,), {'content': "Mock Mistral Response (API not available)"})()})()]
42 return [MockChunk()]
43 ChatMessage = lambda role, content: {'role': role, 'content': content} # Mock ChatMessage
44 MistralClient = MockMistralClient
45
46from PyQt6.QtWidgets import (
47 QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
48 QPushButton, QListWidget, QLineEdit, QLabel, QMessageBox,
49 QTextEdit, QScrollArea, QSizePolicy,
50 QDialog, QDialogButtonBox, QComboBox, QFileDialog,
51 QTabWidget, QSplitter, QTreeView,
52 QMenu, QStatusBar, QToolBar, QToolButton, QSystemTrayIcon,
53 QSpinBox, QCheckBox, QInputDialog, QAbstractItemView
54)
55
56from PyQt6.QtGui import (
57 QIcon, QFontMetrics, QFont, QTextOption, QColor,
58 QGuiApplication, QClipboard, QPalette, QBrush,
59 QTextCursor, QAction, QDesktopServices, QTextCharFormat,
60 QSyntaxHighlighter, QTextDocument, QFileSystemModel, QPainter, QTextFormat
61)
62
63from PyQt6.QtCore import (
64 Qt, QThread, pyqtSignal, QSize, QMutex, QTimer, QObject,
65 QRect, QFileInfo, QDir, QStandardPaths, QUrl, QModelIndex
66)
67
68from PyQt6.QtPrintSupport import QPrintDialog, QPrinter
69# Importy Pygments do kolorowania składni
70
71from pygments import highlight
72from pygments.lexers import get_lexer_by_name, guess_lexer, ClassNotFound
73from pygments.formatters import HtmlFormatter
74from pygments.util import ClassNotFound as PygmentsClassNotFound
75# --- Constants ---
76
77SETTINGS_FILE = "./editor_settings.json"
78# List of models the application should attempt to use.
79# Structure: (API_TYPE, MODEL_IDENTIFIER, DISPLAY_NAME)
80# API_TYPE can be "gemini" or "mistral"
81# MODEL_IDENTIFIER is the string used by the respective API library
82# DISPLAY_NAME is what's shown to the user
83AVAILABLE_MODELS_CONFIG = [
84 ("gemini", "gemini-1.5-flash-latest", "Gemini 1.5 Flash (Latest)"),
85 ("gemini", "gemini-1.5-pro-latest", "Gemini 1.5 Pro (Latest)"),
86 ("gemini", "gemini-2.0-flash-thinking-exp-1219", "Gemini 2.0 Flash (Experimental)"),
87 ("gemini", "gemini-2.5-flash-preview-04-17", "Gemini 2.5 Flash (Preview)"),
88 ("mistral", "codestral-latest", "Codestral (Latest)"), # Example Codestral model
89 ("mistral", "mistral-large-latest", "Mistral Large (Latest)"),
90 ("mistral", "mistral-medium", "Mistral Medium"),
91 ("mistral", "mistral-small", "Mistral Small"),
92 ("mistral", "mistral-tiny", "Mistral Tiny"),
93]
94
95
96# Determine which models are actually available based on installed libraries
97ACTIVE_MODELS_CONFIG = []
98for api_type, identifier, name in AVAILABLE_MODELS_CONFIG:
99 if api_type == "gemini" and HAS_GEMINI:
100 ACTIVE_MODELS_CONFIG.append((api_type, identifier, name))
101 elif api_type == "mistral" and HAS_MISTRAL:
102 ACTIVE_MODELS_CONFIG.append((api_type, identifier, name))
103
104if not ACTIVE_MODELS_CONFIG:
105 # QMessageBox.critical(None, "Błąd API", "Brak dostępnych API. Proszę zainstalować google-generativeai lub mistralai.")
106 print("Warning: No AI APIs available. AI features will be disabled.")
107 # Fallback to a dummy entry if no APIs are available, to prevent crashes
108 ACTIVE_MODELS_CONFIG = [("none", "none", "Brak dostępnych modeli")]
109
110
111DEFAULT_MODEL_CONFIG = ACTIVE_MODELS_CONFIG[0] if ACTIVE_MODELS_CONFIG else ("none", "none", "Brak") # Use the first active model as default
112
113RECENT_FILES_MAX = 10
114DEFAULT_FONT_SIZE = 12
115DEFAULT_THEME = "dark"
116GEMINI_API_KEY_FILE = "./.api_key" # Keep original Google key file
117
118# --- Syntax Highlighter Classes ---
119# (PythonHighlighter, CSSHighlighter, HTMLHighlighter, JSHighlighter, GMLHighlighter - copied from your code)
120# ... (Paste your Syntax Highlighter classes here) ...
121class PythonHighlighter(QSyntaxHighlighter):
122 def __init__(self, document):
123 super().__init__(document)
124 self.highlight_rules = []
125
126 keywords = [
127 'and', 'as', 'assert', 'break', 'class', 'continue', 'def', 'del',
128 'elif', 'else', 'except', 'False', 'finally', 'for', 'from', 'global',
129 'if', 'import', 'in', 'is', 'lambda', 'None', 'nonlocal', 'not', 'or',
130 'pass', 'raise', 'return', 'True', 'try', 'while', 'with', 'yield'
131 ]
132 keyword_format = QTextCharFormat()
133 keyword_format.setForeground(QColor("#569CD6")) # Blue
134 keyword_format.setFontWeight(QFont.Weight.Bold)
135 self.highlight_rules.extend([(r'\b%s\b' % kw, keyword_format) for kw in keywords])
136
137 string_format = QTextCharFormat()
138 string_format.setForeground(QColor("#CE9178")) # Orange
139 self.highlight_rules.append((r'"[^"\\]*(\\.[^"\\]*)*"', string_format))
140 self.highlight_rules.append((r"'[^'\\]*(\\.[^'\\]*)*'", string_format))
141
142 function_format = QTextCharFormat()
143 function_format.setForeground(QColor("#DCDCAA")) # Light yellow
144 self.highlight_rules.append((r'\b[A-Za-z_][A-Za-z0-9_]*\s*(?=\()', function_format))
145
146 number_format = QTextCharFormat()
147 number_format.setForeground(QColor("#B5CEA8")) # Green
148 self.highlight_rules.append((r'\b[0-9]+\b', number_format))
149
150 comment_format = QTextCharFormat()
151 comment_format.setForeground(QColor("#6A9955")) # Green
152 comment_format.setFontItalic(True)
153 self.highlight_rules.append((r'#[^\n]*', comment_format))
154
155 def highlightBlock(self, text):
156 for pattern, format in self.highlight_rules:
157 expression = re.compile(pattern)
158 matches = expression.finditer(text)
159 for match in matches:
160 start = match.start()
161 length = match.end() - start
162 self.setFormat(start, length, format)
163
164class CSSHighlighter(QSyntaxHighlighter):
165 def __init__(self, document):
166 super().__init__(document)
167 self.highlight_rules = []
168
169 keywords = ['color', 'font', 'margin', 'padding', 'display', 'position', 'transition']
170 keyword_format = QTextCharFormat()
171 keyword_format.setForeground(QColor("#ff6ac1")) # Pinkish
172 keyword_format.setFontWeight(QFont.Weight.Bold)
173 self.highlight_rules.extend([(r'\b%s\b' % kw, keyword_format) for kw in keywords])
174
175 value_format = QTextCharFormat()
176 value_format.setForeground(QColor("#ce9178")) # Orange
177 self.highlight_rules.append((r'#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})', value_format))
178 self.highlight_rules.append((r'rgb[a]?\([^)]+\)', value_format))
179
180 selector_format = QTextCharFormat()
181 selector_format.setForeground(QColor("#dcdcAA")) # Light yellow
182 self.highlight_rules.append((r'^\s*[^{]+(?={)', selector_format))
183
184 comment_format = QTextCharFormat()
185 comment_format.setForeground(QColor("#6A9955")) # Green
186 comment_format.setFontItalic(True)
187 self.highlight_rules.append((r'/\*.*?\*/', comment_format), re.DOTALL)
188
189 def highlightBlock(self, text):
190 for pattern, format in self.highlight_rules:
191 expression = re.compile(pattern)
192 for match in expression.finditer(text):
193 start = match.start()
194 length = match.end() - start
195 self.setFormat(start, length, format)
196
197class HTMLHighlighter(QSyntaxHighlighter):
198 def __init__(self, document):
199 super().__init__(document)
200 self.highlight_rules = []
201
202 tag_format = QTextCharFormat()
203 tag_format.setForeground(QColor("#569CD6")) # Blue
204 self.highlight_rules.append((r'</?[\w-]+>', tag_format))
205
206 attr_format = QTextCharFormat()
207 attr_format.setForeground(QColor("#9cdcfe")) # Light blue
208 self.highlight_rules.append((r'[\w-]+(?=\s*=)', attr_format))
209
210 value_format = QTextCharFormat()
211 value_format.setForeground(QColor("#ce9178")) # Orange
212 self.highlight_rules.append((r'="[^"]*"', value_format))
213
214 comment_format = QTextCharFormat()
215 comment_format.setForeground(QColor("#6A9955")) # Green
216 comment_format.setFontItalic(True)
217 self.highlight_rules.append((r'<!--[\s\S]*?-->', comment_format))
218
219 def highlightBlock(self, text):
220 for pattern, format in self.highlight_rules:
221 expression = re.compile(pattern)
222 for match in expression.finditer(text):
223 start = match.start()
224 length = match.end() - start
225 self.setFormat(start, length, format)
226
227class JSHighlighter(QSyntaxHighlighter):
228 def __init__(self, document):
229 super().__init__(document)
230 self.highlight_rules = []
231
232 keywords = ['var', 'let', 'const', 'function', 'if', 'else', 'return', 'for', 'while']
233 keyword_format = QTextCharFormat()
234 keyword_format.setForeground(QColor("#c586c0")) # Purple
235 keyword_format.setFontWeight(QFont.Weight.Bold)
236 self.highlight_rules.extend([(r'\b%s\b' % kw, keyword_format) for kw in keywords])
237
238 string_format = QTextCharFormat()
239 string_format.setForeground(QColor("#ce9178")) # Orange
240 self.highlight_rules.append((r'"[^"\\]*(\\.[^"\\]*)*"', string_format))
241 self.highlight_rules.append((r"'[^'\\]*(\\.[^'\\]*)*'", string_format))
242
243 function_format = QTextCharFormat()
244 function_format.setForeground(QColor("#dcdcaa")) # Light yellow
245 self.highlight_rules.append((r'\b[A-Za-z_][A-Za-z0-9_]*\s*(?=\()', function_format))
246
247 comment_format = QTextCharFormat()
248 comment_format.setForeground(QColor("#6A9955")) # Green
249 comment_format.setFontItalic(True)
250 self.highlight_rules.append((r'//[^\n]*', comment_format))
251 self.highlight_rules.append((r'/\*[\s\S]*?\*/', comment_format), re.DOTALL)
252
253 def highlightBlock(self, text):
254 for pattern, format in self.highlight_rules:
255 expression = re.compile(pattern)
256 for match in expression.finditer(text):
257 start = match.start()
258 length = match.end() - start
259 self.setFormat(start, length, format)
260
261class GMLHighlighter(QSyntaxHighlighter):
262 def __init__(self, document):
263 super().__init__(document)
264 self.highlight_rules = []
265
266 keywords = ['if', 'else', 'switch', 'case', 'break', 'return', 'var', 'with', 'while']
267 keyword_format = QTextCharFormat()
268 keyword_format.setForeground(QColor("#c586c0")) # Purple
269 keyword_format.setFontWeight(QFont.Weight.Bold)
270 self.highlight_rules.extend([(r'\b%s\b' % kw, keyword_format) for kw in keywords])
271
272 var_format = QTextCharFormat()
273 var_format.setForeground(QColor("#4ec9b0")) # Teal
274 self.highlight_rules.append((r'_[a-zA-Z][a-zA-Z0-9]*', var_format))
275
276 func_format = QTextCharFormat()
277 func_format.setForeground(QColor("#dcdcaa")) # Light yellow
278 gml_funcs = ['instance_create', 'ds_list_add', 'draw_text']
279 self.highlight_rules.extend([(r'\b%s\b(?=\()', func_format) for func in gml_funcs])
280
281 string_format = QTextCharFormat()
282 string_format.setForeground(QColor("#ce9178")) # Orange
283 self.highlight_rules.append((r'"[^"\\]*(\\.[^"\\]*)*"', string_format))
284
285 comment_format = QTextCharFormat()
286 comment_format.setForeground(QColor("#6A9955")) # Green
287 comment_format.setFontItalic(True)
288 self.highlight_rules.append((r'//[^\n]*', comment_format))
289 self.highlight_rules.append((r'/\*[\s\S]*?\*/', comment_format), re.DOTALL)
290
291
292 def highlightBlock(self, text):
293 for pattern, format in self.highlight_rules:
294 expression = re.compile(pattern)
295 for match in expression.finditer(text):
296 start = match.start()
297 length = match.end() - start
298 self.setFormat(start, length, format)
299# --- End of Syntax Highlighter Classes ---
300
301# --- API Key Loading ---
302
303def load_gemini_api_key(filepath=GEMINI_API_KEY_FILE):
304 """Reads Gemini API key from a file."""
305 if not os.path.exists(filepath):
306 # Don't show critical error if file is just missing, allow user to configure in settings
307 print(f"Gemini API key file not found: {filepath}. Please add key in settings.")
308 return None
309 try:
310 with open(filepath, "r") as f:
311 key = f.read().strip()
312 if not key:
313 print(f"Gemini API key file is empty: {filepath}. Please add key in settings.")
314 return None
315 return key
316 except Exception as e:
317 print(f"Error reading Gemini API key file: {filepath}\nError: {e}")
318 # QMessageBox.warning(None, "Błąd odczytu klucza API", f"Nie można odczytać pliku klucza API Google Gemini: {filepath}\nBłąd: {e}")
319 return None
320
321# Load Gemini key initially, but allow overriding/setting in settings
322GEMINI_API_KEY_GLOBAL = load_gemini_api_key()
323
324# --- Configure APIs (Initial) ---
325# This configuration should happen *after* loading settings in the main window,
326# where the Mistral key from settings is also available.
327# The current global configuration is okay for checking HAS_GEMINI but actual
328# worker instances need potentially updated keys from settings.
329
330# --- Settings Persistence ---
331
332def load_settings():
333 """Loads settings from a JSON file."""
334 # Determine default model based on active APIs
335 default_model_config = ACTIVE_MODELS_CONFIG[0] if ACTIVE_MODELS_CONFIG else ("none", "none", "Brak")
336 default_api_type = default_model_config[0]
337 default_model_identifier = default_model_config[1]
338
339 default_settings = {
340 "api_type": default_api_type, # New field to store active API type
341 "model_identifier": default_model_identifier, # New field to store model identifier
342 "mistral_api_key": None, # New field for Mistral key
343 "recent_files": [],
344 "font_size": DEFAULT_FONT_SIZE,
345 "theme": DEFAULT_THEME,
346 "workspace": "",
347 "show_sidebar": True,
348 "show_statusbar": True,
349 "show_toolbar": True
350 }
351
352 try:
353 if os.path.exists(SETTINGS_FILE):
354 with open(SETTINGS_FILE, 'r') as f:
355 settings = json.load(f)
356 # Handle potential old format or missing new fields
357 if "api_type" not in settings or "model_identifier" not in settings:
358 # Attempt to migrate from old "model_name" if it exists
359 old_model_name = settings.get("model_name", "")
360 found_match = False
361 for api_type, identifier, name in ACTIVE_MODELS_CONFIG:
362 if identifier == old_model_name or name == old_model_name: # Check both identifier and display name from old settings
363 settings["api_type"] = api_type
364 settings["model_identifier"] = identifier
365 found_match = True
366 break
367 if not found_match:
368 # Fallback to default if old name not found or no old name
369 settings["api_type"] = default_api_type
370 settings["model_identifier"] = default_model_identifier
371 if "model_name" in settings:
372 del settings["model_name"] # Remove old field
373
374 # Add defaults for any other missing keys (including new mistral_api_key)
375 for key in default_settings:
376 if key not in settings:
377 settings[key] = default_settings[key]
378
379 # Validate loaded model against active configurations
380 is_active = any(s[0] == settings.get("api_type") and s[1] == settings.get("model_identifier") for s in ACTIVE_MODELS_CONFIG)
381 if not is_active:
382 print(f"Warning: Loaded model config ({settings.get('api_type')}, {settings.get('model_identifier')}) is not active. Falling back to default.")
383 settings["api_type"] = default_api_type
384 settings["model_identifier"] = default_model_identifier
385
386
387 return settings
388 return default_settings
389 except Exception as e:
390 print(f"Błąd ładowania ustawień: {e}. Używam ustawień domyślnych.")
391 return default_settings
392
393def save_settings(settings: dict):
394 """Saves settings to a JSON file."""
395 try:
396 with open(SETTINGS_FILE, 'w') as f:
397 json.dump(settings, f, indent=4)
398 except Exception as e:
399 print(f"Błąd zapisywania ustawień: {e}")
400
401# --- API Formatting Helper ---
402def format_chat_history(messages: list, api_type: str) -> list:
403 """Formats chat history for different API types."""
404 formatted_history = []
405 for role, content, metadata in messages:
406 # Skip assistant placeholder messages and internal error/empty messages
407 if not (role == "assistant" and metadata is not None and metadata.get("type") in ["placeholder", "error", "empty_response"]):
408 if api_type == "gemini":
409 # Gemini uses "user" and "model" roles
410 formatted_history.append({
411 "role": "user" if role == "user" else "model",
412 "parts": [content] # Gemini uses 'parts' with content
413 })
414 elif api_type == "mistral":
415 # Mistral uses "user" and "assistant" roles
416 formatted_history.append(ChatMessage(role='user' if role == 'user' else 'assistant', content=content))
417 # Add other API types here if needed
418 return formatted_history
419
420# --- API Worker Threads ---
421
422class GeminiWorker(QThread):
423 response_chunk = pyqtSignal(str)
424 response_complete = pyqtSignal()
425 error = pyqtSignal(str)
426
427 def __init__(self, api_key: str, user_message: str, chat_history: list, model_identifier: str, parent=None):
428 super().__init__(parent)
429 self.api_key = api_key
430 self.user_message = user_message
431 self.chat_history = chat_history # Raw history from main window
432 self.model_identifier = model_identifier
433 self._is_running = True
434 self._mutex = QMutex()
435 print(f"GeminiWorker created for model: {model_identifier}")
436
437
438 def stop(self):
439 self._mutex.lock()
440 try:
441 self._is_running = False
442 finally:
443 self._mutex.unlock()
444
445 def run(self):
446 if not self.api_key:
447 self.error.emit("Klucz API Google Gemini nie został skonfigurowany.")
448 return
449 if not self.user_message.strip():
450 self.error.emit("Proszę podać niepustą wiadomość tekstową.")
451 return
452
453 try:
454 # Format history for Gemini API
455 api_history = format_chat_history(self.chat_history, "gemini")
456
457 try:
458 # Attempt to get the model instance
459 genai.configure(api_key=self.api_key) # Ensure API key is used in this thread
460 model_instance = genai.GenerativeModel(self.model_identifier)
461
462 # Start chat with history
463 chat = model_instance.start_chat(history=api_history)
464
465 # Send message and get stream
466 response_stream = chat.send_message(self.user_message, stream=True)
467
468 except Exception as api_err:
469 error_str = str(api_err)
470 if "BlockedPromptException" in error_str or ("FinishReason" in error_str and "SAFETY" in error_str):
471 self.error.emit(f"Odpowiedź zablokowana przez filtry bezpieczeństwa.")
472 elif "Candidate.content is empty" in error_str:
473 self.error.emit(f"Otrzymano pustą treść z API (możliwe, że zablokowana lub niepowodzenie).")
474 elif "returned an invalid response" in error_str or "Could not find model" in error_str or "Invalid model name" in error_str:
475 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}")
476 elif "AUTHENTICATION_ERROR" in error_str or "Invalid API key" in error_str:
477 self.error.emit(f"Błąd autoryzacji API Gemini. Proszę sprawdzić klucz API w ustawieniach.")
478 else:
479 error_details = f"{type(api_err).__name__}: {api_err}"
480 if hasattr(api_err, 'status_code'):
481 error_details += f" (Status: {api_err.status_code})"
482 self.error.emit(f"Wywołanie API Gemini nie powiodło się:\n{error_details}")
483 return
484
485 try:
486 full_response_text = ""
487 # Process the response stream chunk by chunk
488 for chunk in response_stream:
489 self._mutex.lock()
490 is_running = self._is_running
491 self._mutex.unlock()
492
493 if not is_running:
494 break
495
496 if not chunk.candidates:
497 continue
498
499 try:
500 # Concatenate text parts from the chunk
501 # Safely access candidates and content
502 text_parts = [part.text for candidate in chunk.candidates for part in candidate.content.parts if part.text]
503 current_chunk = "".join(text_parts)
504 except (AttributeError, IndexError) as e:
505 print(f"Warning: Could not access chunk text: {e}")
506 current_chunk = "" # Handle cases where structure isn't as expected
507
508 if current_chunk:
509 full_response_text += current_chunk
510 self.response_chunk.emit(current_chunk)
511
512 self._mutex.lock()
513 stopped_manually = not self._is_running
514 self._mutex.unlock()
515
516 if not stopped_manually:
517 self.response_complete.emit()
518
519 except Exception as stream_err:
520 self._mutex.lock()
521 was_stopped = not self._is_running
522 self._mutex.unlock()
523
524 if not was_stopped:
525 error_details = f"{type(stream_err).__name__}: {stream_err}"
526 self.error.emit(f"Błąd podczas strumieniowania odpowiedzi z API Gemini:\n{error_details}")
527
528 except Exception as e:
529 error_details = f"{type(e).__name__}: {e}"
530 self.error.emit(f"Wystąpił nieoczekiwany błąd w wątku roboczym Gemini:\n{error_details}\n{traceback.format_exc()}")
531
532
533class MistralWorker(QThread):
534 response_chunk = pyqtSignal(str)
535 response_complete = pyqtSignal()
536 error = pyqtSignal(str)
537
538 def __init__(self, api_key: str, user_message: str, chat_history: list, model_identifier: str, parent=None):
539 super().__init__(parent)
540 self.api_key = api_key
541 self.user_message = user_message
542 self.chat_history = chat_history # Raw history from main window
543 self.model_identifier = model_identifier
544 self._is_running = True
545 self._mutex = QMutex()
546 print(f"MistralWorker created for model: {model_identifier}")
547
548 def stop(self):
549 self._mutex.lock()
550 try:
551 self._is_running = False
552 finally:
553 self._mutex.unlock()
554
555 def run(self):
556 if not self.api_key:
557 self.error.emit("Klucz API Mistral nie został skonfigurowany w ustawieniach.")
558 return
559 if not self.user_message.strip():
560 self.error.emit("Proszę podać niepustą wiadomość tekstową.")
561 return
562
563 try:
564 # Format history for Mistral API
565 # Mistral API expects a list of ChatMessage objects or dicts {'role': '...', 'content': '...'}
566 # The last message is the current user message, others are history
567 api_messages = format_chat_history(self.chat_history, "mistral")
568 api_messages.append(ChatMessage(role='user', content=self.user_message))
569
570
571 try:
572 client = MistralClient(api_key=self.api_key)
573
574 response_stream = client.chat(
575 model=self.model_identifier,
576 messages=api_messages,
577 stream=True
578 )
579
580 except Exception as api_err:
581 error_str = str(api_err)
582 # Add more specific error handling for Mistral API if needed
583 if "authentication_error" in error_str.lower():
584 self.error.emit(f"Błąd autoryzacji API Mistral. Proszę sprawdzić klucz API w ustawieniach.")
585 elif "model_not_found" in error_str.lower():
586 self.error.emit(f"Model Mistral '{self.model_identifier}' nie znaleziono lub jest niedostępny dla tego klucza API.")
587 else:
588 error_details = f"{type(api_err).__name__}: {api_err}"
589 self.error.emit(f"Wywołanie API Mistral nie powiodło się:\n{error_details}")
590 return
591
592
593 try:
594 full_response_text = ""
595 # Process the response stream chunk by chunk
596 for chunk in response_stream:
597 self._mutex.lock()
598 is_running = self._is_running
599 self._mutex.unlock()
600
601 if not is_running:
602 break
603
604 # Mistral stream chunk structure: chunk.choices[0].delta.content
605 current_chunk = ""
606 if chunk.choices and chunk.choices[0].delta and chunk.choices[0].delta.content:
607 current_chunk = chunk.choices[0].delta.content
608
609 if current_chunk:
610 full_response_text += current_chunk
611 self.response_chunk.emit(current_chunk)
612
613 self._mutex.lock()
614 stopped_manually = not self._is_running
615 self._mutex.unlock()
616
617 if not stopped_manually:
618 self.response_complete.emit()
619
620 except Exception as stream_err:
621 self._mutex.lock()
622 was_stopped = not self._is_running
623 self._mutex.unlock()
624
625 if not was_stopped:
626 error_details = f"{type(stream_err).__name__}: {stream_err}"
627 self.error.emit(f"Błąd podczas strumieniowania odpowiedzi z API Mistral:\n{error_details}")
628
629 except Exception as e:
630 error_details = f"{type(e).__name__}: {e}"
631 self.error.emit(f"Wystąpił nieoczekiwany błąd w wątku roboczym Mistral:\n{error_details}\n{traceback.format_exc()}")
632
633
634# --- Pygments Helper for Syntax Highlighting ---
635# (highlight_code_html and related CSS - copied from your code)
636# ... (Paste your Pygments helper functions and CSS here) ...
637PYGMENTS_STYLE_NAME = 'dracula'
638try:
639 PYGMENTS_CSS = HtmlFormatter(style=PYGMENTS_STYLE_NAME, full=False, cssclass='highlight').get_style_defs('.highlight')
640except ClassNotFound:
641 print(f"Ostrzeżenie: Styl Pygments '{PYGMENTS_STYLE_NAME}' nie znaleziono. Używam 'default'.")
642 PYGMENTS_STYLE_NAME = 'default'
643 PYGMENTS_CSS = HtmlFormatter(style=PYGMENTS_STYLE_NAME, full=False, cssclass='highlight').get_style_defs('.highlight')
644
645CUSTOM_CODE_CSS = f"""
646.highlight {{
647 padding: 0 !important;
648 margin: 0 !important;
649}}
650.highlight pre {{
651 margin: 0 !important;
652 padding: 0 !important;
653 border: none !important;
654 white-space: pre-wrap;
655 word-wrap: break-word;
656}}
657"""
658FINAL_CODE_CSS = PYGMENTS_CSS + CUSTOM_CODE_CSS
659
660def highlight_code_html(code, language=''):
661 try:
662 if language:
663 lexer = get_lexer_by_name(language, stripall=True)
664 else:
665 lexer = guess_lexer(code)
666 if lexer.name == 'text':
667 raise PygmentsClassNotFound # Don't use 'text' lexer
668 except (PygmentsClassNotFound, ValueError):
669 try:
670 # Fallback to a generic lexer or plain text
671 lexer = get_lexer_by_name('text', stripall=True)
672 except PygmentsClassNotFound:
673 # This fallback should theoretically always work, but as a safeguard:
674 return f"<pre><code>{code}</code></pre>"
675
676
677 formatter = HtmlFormatter(style=PYGMENTS_STYLE_NAME, full=False, cssclass='highlight')
678 return highlight(code, lexer, formatter)
679
680# --- Custom Widgets for Chat Messages ---
681# (CodeDisplayTextEdit, MessageWidget - copied from your code)
682# ... (Paste your CodeDisplayTextEdit and MessageWidget classes here) ...
683class CodeDisplayTextEdit(QTextEdit):
684 def __init__(self, parent=None):
685 super().__init__(parent)
686 self.setReadOnly(True)
687 self.setAcceptRichText(True)
688 self.setWordWrapMode(QTextOption.WrapMode.NoWrap) # Code blocks shouldn't wrap standardly
689 self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
690 self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
691 self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
692 self.setMinimumHeight(QFontMetrics(self.font()).lineSpacing() * 3 + 16)
693 self.setFrameStyle(QTextEdit.Shape.Box | QTextEdit.Shadow.Plain)
694 self.document().setDocumentMargin(0)
695 self.setContentsMargins(0,0,0,0)
696
697 self.setStyleSheet(f"""
698 QTextEdit {{
699 background-color: #2d2d2d; /* Dark background for code */
700 color: #ffffff; /* White text */
701 border: 1px solid #4a4a4a;
702 border-radius: 5px;
703 padding: 8px;
704 font-family: "Consolas", "Courier New", monospace;
705 font-size: 9pt; /* Smaller font for code blocks */
706 }}
707 {FINAL_CODE_CSS} /* Pygments CSS for syntax highlighting */
708 """)
709
710 def setHtml(self, html: str):
711 super().setHtml(html)
712 self.document().adjustSize()
713 doc_height = self.document().size().height()
714 buffer = 5
715 self.setFixedHeight(int(doc_height) + buffer)
716
717class MessageWidget(QWidget):
718 def __init__(self, role: str, content: str, metadata: dict = None, parent=None):
719 super().__init__(parent)
720 self.role = role
721 self.content = content
722 self.metadata = metadata
723 self.is_placeholder = (role == "assistant" and metadata is not None and metadata.get("type") == "placeholder")
724 self.segments = []
725
726 self.layout = QVBoxLayout(self)
727 self.layout.setContentsMargins(0, 5, 0, 5)
728 self.layout.setSpacing(3)
729
730 bubble_widget = QWidget()
731 self.content_layout = QVBoxLayout(bubble_widget)
732 self.content_layout.setContentsMargins(12, 8, 12, 8)
733 self.content_layout.setSpacing(6)
734
735 user_color = "#dcf8c6"
736 assistant_color = "#e0e0e0"
737
738 bubble_style = f"""
739 QWidget {{
740 background-color: {'{user_color}' if role == 'user' else '{assistant_color}'};
741 border-radius: 15px;
742 padding: 0px;
743 border: 1px solid #e0e0e0;
744 }}
745 """
746 if self.is_placeholder:
747 bubble_style = """
748 QWidget {
749 background-color: #f0f0f0;
750 border-radius: 15px;
751 padding: 0px;
752 border: 1px dashed #cccccc;
753 }
754 """
755 bubble_widget.setStyleSheet(bubble_style)
756 bubble_widget.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum)
757
758 outer_layout = QHBoxLayout()
759 outer_layout.setContentsMargins(0, 0, 0, 0)
760 outer_layout.setSpacing(0)
761
762 screen_geometry = QGuiApplication.primaryScreen().availableGeometry()
763 max_bubble_width = int(screen_geometry.width() * 0.75)
764 bubble_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
765 bubble_widget.setMinimumWidth(1)
766
767 spacer_left = QWidget()
768 spacer_right = QWidget()
769 spacer_left.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
770 spacer_right.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
771
772 if role == 'user':
773 outer_layout.addWidget(spacer_left)
774 outer_layout.addWidget(bubble_widget, 1)
775 outer_layout.addWidget(spacer_right, 0)
776 else:
777 outer_layout.addWidget(spacer_left, 0)
778 outer_layout.addWidget(bubble_widget, 1)
779 outer_layout.addWidget(spacer_right)
780
781 self.layout.addLayout(outer_layout)
782
783 if self.is_placeholder:
784 placeholder_label = QLabel(content)
785 placeholder_label.setStyleSheet("QLabel { color: #505050; font-style: italic; padding: 10px; }")
786 placeholder_label.setWordWrap(True)
787 placeholder_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
788 self.content_layout.addWidget(placeholder_label)
789 self.placeholder_label = placeholder_label
790 placeholder_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
791 placeholder_label.setMinimumWidth(1)
792 else:
793 self.display_content(content, self.content_layout)
794
795 self.content_layout.addStretch(1)
796
797 def display_content(self, content, layout):
798 block_pattern = re.compile(r'(^|\n)(`{3,})(\w*)\n(.*?)\n\2(?:\n|$)', re.DOTALL)
799 last_end = 0
800
801 for match in block_pattern.finditer(content):
802 text_before = content[last_end:match.start()].strip()
803 if text_before:
804 self.add_text_segment(text_before, layout)
805
806 code = match.group(4)
807 language = match.group(3).strip()
808
809 code_area = CodeDisplayTextEdit()
810 highlighted_html = highlight_code_html(code, language)
811 code_area.setHtml(highlighted_html)
812 layout.addWidget(code_area)
813 self.segments.append(code_area)
814
815 copy_button = QPushButton("Kopiuj kod")
816 copy_button.setIcon(QIcon.fromTheme("edit-copy", QIcon(":/icons/copy.png")))
817 copy_button.setFixedSize(100, 25)
818 copy_button.setStyleSheet("""
819 QPushButton {
820 background-color: #3c3c3c;
821 color: #ffffff;
822 border: 1px solid #5a5a5a;
823 border-radius: 4px;
824 padding: 2px 8px;
825 font-size: 9pt;
826 }
827 QPushButton:hover {
828 background-color: #4a4a4a;
829 border-color: #6a6a6a;
830 }
831 QPushButton:pressed {
832 background-color: #2a2a2a;
833 border-color: #5a5a5a;
834 }
835 """)
836
837 clipboard = QApplication.clipboard()
838 if clipboard:
839 copy_button.clicked.connect(lambda checked=False, code_widget=code_area: self.copy_code_to_clipboard(code_widget))
840 else:
841 copy_button.setEnabled(False)
842
843 btn_layout = QHBoxLayout()
844 btn_layout.addStretch()
845 btn_layout.addWidget(copy_button)
846 btn_layout.setContentsMargins(0, 0, 0, 0)
847 btn_layout.setSpacing(0)
848 layout.addLayout(btn_layout)
849
850 last_end = match.end()
851
852 remaining_text = content[last_end:].strip()
853 if remaining_text:
854 self.add_text_segment(remaining_text, layout)
855
856 def add_text_segment(self, text: str, layout: QVBoxLayout):
857 if not text:
858 return
859
860 text_edit = QTextEdit()
861 text_edit.setReadOnly(True)
862 text_edit.setFrameStyle(QTextEdit.Shape.NoFrame)
863 text_edit.setContentsMargins(0, 0, 0, 0)
864 text_edit.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
865 text_edit.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
866 text_edit.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
867 text_edit.setWordWrapMode(QTextOption.WrapMode.WrapAtWordBoundaryOrAnywhere)
868 text_edit.setAcceptRichText(True)
869
870 text_edit.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
871 text_edit.customContextMenuRequested.connect(lambda pos, te=text_edit: self.show_context_menu(pos, te))
872
873 text_edit.setStyleSheet(f"""
874 QTextEdit {{
875 background-color: transparent;
876 border: none;
877 padding: 0;
878 font-size: 10pt;
879 color: {'#333333' if self.role == 'user' else '#ffffff'};
880 }}
881 """)
882
883 html_text = text.replace('&', '&').replace('<', '<').replace('>', '>')
884 html_text = re.sub(r'\*\*(.*?)\*\*', r'<b>\1</b>', html_text)
885 inline_code_style = "font-family: Consolas, 'Courier New', monospace; background-color: #f0f0f0; padding: 1px 3px; border-radius: 3px; font-size: 9pt;"
886 html_text = re.sub(r'`([^`]+)`', rf'<span style="{inline_code_style}">\1</span>', html_text)
887 html_text = html_text.replace('\n', '<br>')
888
889 text_edit.setHtml(html_text)
890 self.segments.append(text_edit)
891
892 text_edit.document().adjustSize()
893 doc_size = text_edit.document().size()
894 buffer = 5
895 text_edit.setFixedHeight(int(doc_size.height()) + buffer)
896
897 layout.addWidget(text_edit)
898
899 def show_context_menu(self, position, text_edit):
900 menu = QMenu(text_edit)
901
902 copy_action = menu.addAction("Kopiuj")
903 copy_action.setIcon(QIcon.fromTheme("edit-copy"))
904 copy_action.triggered.connect(text_edit.copy)
905
906 menu.addSeparator()
907
908 select_all_action = menu.addAction("Zaznacz wszystko")
909 select_all_action.setIcon(QIcon.fromTheme("edit-select-all"))
910 select_all_action.setShortcut("Ctrl+A")
911 select_all_action.triggered.connect(text_edit.selectAll)
912
913 menu.exec(text_edit.viewport().mapToGlobal(position))
914
915 def copy_code_to_clipboard(self, code_widget: CodeDisplayTextEdit):
916 clipboard = QApplication.clipboard()
917 if clipboard:
918 code_text = code_widget.toPlainText()
919 clipboard.setText(code_text)
920
921 sender_button = self.sender()
922 if sender_button:
923 original_text = sender_button.text()
924 sender_button.setText("Skopiowano!")
925 QTimer.singleShot(1500, lambda: sender_button.setText(original_text))
926
927 def update_placeholder_text(self, text):
928 if self.is_placeholder and hasattr(self, 'placeholder_label'):
929 display_text = text.strip()
930 if len(display_text) > 200:
931 display_text = "..." + display_text[-200:]
932 display_text = "⚙️ Przetwarzam... " + display_text
933 self.placeholder_label.setText(display_text)
934
935 def apply_theme_colors(self, background: QColor, foreground: QColor, bubble_user: QColor, bubble_assistant: QColor):
936 bubble_widget = self.findChild(QWidget)
937 if bubble_widget and not self.is_placeholder:
938 bubble_style = f"""
939 QWidget {{
940 background-color: {'{bubble_user.name()}' if self.role == 'user' else '{bubble_assistant.name()}'};
941 border-radius: 15px;
942 padding: 0px;
943 border: 1px solid #e0e0e0;
944 }}
945 """
946 bubble_widget.setStyleSheet(bubble_style)
947 bubble_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
948
949 for segment in self.segments:
950 if isinstance(segment, QTextEdit) and not isinstance(segment, CodeDisplayTextEdit):
951 segment.setStyleSheet(f"""
952 QTextEdit {{
953 background-color: transparent;
954 border: none;
955 padding: 0;
956 font-size: 10pt;
957 color: {'{foreground.name()}' if self.role == 'assistant' else '#333333'};
958 }}
959 """)
960# --- End of Custom Widgets ---
961
962
963# --- Code Editor Widget ---
964# (CodeEditor - copied and slightly modified for theme colors and line numbers)
965# ... (Paste your CodeEditor class here) ...
966class CodeEditor(QTextEdit):
967 def __init__(self, parent=None):
968 super().__init__(parent)
969 self.setFont(QFont("Consolas", DEFAULT_FONT_SIZE))
970 self.setLineWrapMode(QTextEdit.LineWrapMode.NoWrap)
971 self.highlighter = PythonHighlighter(self.document()) # Default highlighter
972
973 self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
974 self.customContextMenuRequested.connect(self.show_context_menu)
975
976 self.line_number_area = QWidget(self)
977 self.line_number_area.setFixedWidth(40)
978 self.line_number_area.setStyleSheet("background-color: #252526; color: #858585;")
979 self.update_line_number_area_width()
980
981 self.document().blockCountChanged.connect(self.update_line_number_area_width)
982 self.verticalScrollBar().valueChanged.connect(lambda: self.line_number_area.update())
983 self.textChanged.connect(lambda: self.line_number_area.update())
984 self.cursorPositionChanged.connect(lambda: self.update_line_number_area(self.viewport().rect(), 0))
985 self.cursorPositionChanged.connect(self.highlight_current_line)
986
987 self.setTabStopDistance(QFontMetrics(self.font()).horizontalAdvance(' ') * 4)
988
989 self.current_line_format = QTextCharFormat()
990 self.current_line_format.setBackground(QColor("#2d2d2d"))
991
992 def delete(self):
993 cursor = self.textCursor()
994 if cursor.hasSelection():
995 cursor.removeSelectedText()
996 else:
997 cursor.deleteChar()
998 self.setTextCursor(cursor)
999
1000 def show_context_menu(self, position):
1001 # This context menu is now set up by the main window's setup_editor_context_menu
1002 # which provides more actions. This local one is potentially redundant or simplified.
1003 # For now, let's keep the main window's setup, and this method could be empty or removed.
1004 # Or, it could call the main window's method. Let's update the main window setup.
1005 pass # The main window will handle this connection instead
1006
1007
1008 def update_line_number_area_width(self):
1009 digits = len(str(max(1, self.document().blockCount())))
1010 space = 10 + self.fontMetrics().horizontalAdvance('9') * digits
1011 self.line_number_area.setFixedWidth(space)
1012 self.setViewportMargins(self.line_number_area.width(), 0, 0, 0)
1013
1014 def update_line_number_area(self, rect, dy):
1015 if dy != 0:
1016 self.line_number_area.scroll(0, dy)
1017 else:
1018 self.line_number_area.update(0, rect.y(), self.line_number_area.width(), rect.height())
1019
1020 if rect.contains(self.viewport().rect()):
1021 self.update_line_number_area_width()
1022
1023 def resizeEvent(self, event):
1024 super().resizeEvent(event)
1025 cr = self.contentsRect()
1026 self.line_number_area.setGeometry(QRect(cr.left(), cr.top(), self.line_number_area.width(), cr.height()))
1027
1028 def line_number_area_paint_event(self, event):
1029 painter = QPainter(self.line_number_area)
1030 if not painter.isActive():
1031 painter.begin(self.line_number_area)
1032
1033 bg_color = self.palette().color(QPalette.ColorRole.Base)
1034 painter.fillRect(event.rect(), bg_color)
1035
1036 doc = self.document()
1037 block = doc.begin()
1038 block_number = 0
1039 scroll_offset = self.verticalScrollBar().value()
1040
1041 top = block.layout().boundingRect().top() + scroll_offset
1042 bottom = top + block.layout().boundingRect().height()
1043
1044 while block.isValid() and top <= event.rect().bottom():
1045 if block.isVisible() and bottom >= event.rect().top():
1046 number = str(block_number + 1)
1047 # Use line number area's stylesheet color for text
1048 # Accessing stylesheet color directly is tricky, fallback to palette or fixed color
1049 # Let's use the foreground color set in set_theme_colors
1050 painter.setPen(self.line_number_area.palette().color(QPalette.ColorRole.WindowText))
1051
1052
1053 painter.drawText(0, int(top), self.line_number_area.width() - 5, self.fontMetrics().height(),
1054 Qt.AlignmentFlag.AlignRight, number)
1055
1056 block = block.next()
1057 if block.isValid():
1058 top = bottom
1059 # Recalculate block height each time
1060 bottom = top + self.blockBoundingRect(block).height() # Use blockBoundingRect for accurate height
1061 block_number += 1
1062 else:
1063 break
1064
1065 def highlight_current_line(self):
1066 extra_selections = []
1067
1068 if not self.isReadOnly():
1069 selection = QTextEdit.ExtraSelection()
1070 selection.format = self.current_line_format
1071 selection.format.setProperty(QTextFormat.Property.FullWidthSelection, True)
1072 selection.cursor = self.textCursor()
1073 selection.cursor.clearSelection()
1074 extra_selections.append(selection)
1075
1076 self.setExtraSelections(extra_selections)
1077
1078 def set_font_size(self, size: int):
1079 font = self.font()
1080 font.setPointSize(size)
1081 self.setFont(font)
1082 self.setTabStopDistance(QFontMetrics(self.font()).horizontalAdvance(' ') * 4)
1083 self.update_line_number_area_width()
1084 self.line_number_area.update()
1085
1086 def set_theme_colors(self, background: QColor, foreground: QColor, line_number_bg: QColor, line_number_fg: QColor, current_line_bg: QColor):
1087 palette = self.palette()
1088 palette.setColor(QPalette.ColorRole.Base, background)
1089 palette.setColor(QPalette.ColorRole.Text, foreground)
1090 self.setPalette(palette)
1091
1092 # Update line number area palette and stylesheet for immediate effect
1093 linenum_palette = self.line_number_area.palette()
1094 linenum_palette.setColor(QPalette.ColorRole.Window, line_number_bg) # Window role for background
1095 linenum_palette.setColor(QPalette.ColorRole.WindowText, line_number_fg) # WindowText for foreground
1096 self.line_number_area.setPalette(linenum_palette)
1097 self.line_number_area.setStyleSheet(f"QWidget {{ background-color: {line_number_bg.name()}; color: {line_number_fg.name()}; }}")
1098
1099
1100 self.current_line_format.setBackground(current_line_bg)
1101 self.highlight_current_line()
1102
1103 def paintEvent(self, event):
1104 # Custom paint event to draw the line number area *before* the main editor content
1105 # Note: QWidget's paintEvent is not automatically called by QTextEdit's paintEvent
1106 # We need to manually trigger the line number area repaint or rely on its own update signals.
1107 # The current setup relies on signals connected in __init__.
1108 # We can skip the manual paintEvent call here and let the signals handle it.
1109 super().paintEvent(event)
1110# --- End of Code Editor Widget ---
1111
1112
1113# --- Settings Dialog ---
1114
1115class SettingsDialog(QDialog):
1116 def __init__(self, active_models_config: list, current_settings: dict, parent=None):
1117 super().__init__(parent)
1118 self.setWindowTitle("Ustawienia")
1119 self.setMinimumWidth(400)
1120 self.setModal(True)
1121
1122 self.active_models_config = active_models_config
1123 self.current_settings = current_settings
1124
1125 self.layout = QVBoxLayout(self)
1126
1127 # Model selection
1128 model_label = QLabel("Model AI:")
1129 self.layout.addWidget(model_label)
1130
1131 self.model_combo = QComboBox()
1132 # Populate with display names, but store API type and identifier as user data
1133 for api_type, identifier, display_name in self.active_models_config:
1134 self.model_combo.addItem(display_name, userData=(api_type, identifier))
1135
1136 # Set the current model in the combobox
1137 try:
1138 current_api_type = self.current_settings.get("api_type")
1139 current_identifier = self.current_settings.get("model_identifier")
1140 # Find the index for the current model config
1141 for i in range(self.model_combo.count()):
1142 api_type, identifier = self.model_combo.itemData(i)
1143 if api_type == current_api_type and identifier == current_identifier:
1144 self.model_combo.setCurrentIndex(i)
1145 break
1146 else: # If current model not found, select the first available
1147 if self.model_combo.count() > 0:
1148 self.model_combo.setCurrentIndex(0)
1149 except Exception as e:
1150 print(f"Error setting initial model in settings dialog: {e}")
1151 if self.model_combo.count() > 0: self.model_combo.setCurrentIndex(0)
1152
1153
1154 self.layout.addWidget(self.model_combo)
1155
1156 # API Key Inputs (conditional based on available APIs)
1157 if HAS_GEMINI:
1158 # Gemini key is usually from file, but could add an input here too if needed
1159 pass # Keep Gemini key from file for now
1160
1161 if HAS_MISTRAL:
1162 mistral_key_label = QLabel("Klucz API Mistral:")
1163 self.layout.addWidget(mistral_key_label)
1164 self.mistral_key_input = QLineEdit()
1165 self.mistral_key_input.setPlaceholderText("Wprowadź klucz API Mistral")
1166 self.mistral_key_input.setText(self.current_settings.get("mistral_api_key", ""))
1167 self.layout.addWidget(self.mistral_key_input)
1168
1169
1170 # Theme selection
1171 theme_label = QLabel("Motyw:")
1172 self.layout.addWidget(theme_label)
1173
1174 self.theme_combo = QComboBox()
1175 self.theme_combo.addItems(["ciemny", "jasny"])
1176 self.theme_combo.setCurrentText("ciemny" if self.current_settings.get("theme", DEFAULT_THEME) == "dark" else "jasny")
1177 self.layout.addWidget(self.theme_combo)
1178
1179 # Font size
1180 font_label = QLabel("Rozmiar czcionki:")
1181 self.layout.addWidget(font_label)
1182
1183 self.font_spin = QSpinBox()
1184 self.font_spin.setRange(8, 24)
1185 self.font_spin.setValue(self.current_settings.get("font_size", DEFAULT_FONT_SIZE))
1186 self.layout.addWidget(self.font_spin)
1187
1188 # UI elements visibility
1189 self.sidebar_check = QCheckBox("Pokaż pasek boczny")
1190 self.sidebar_check.setChecked(self.current_settings.get("show_sidebar", True))
1191 self.layout.addWidget(self.sidebar_check)
1192
1193 self.toolbar_check = QCheckBox("Pokaż pasek narzędzi")
1194 self.toolbar_check.setChecked(self.current_settings.get("show_toolbar", True))
1195 self.layout.addWidget(self.toolbar_check)
1196
1197 self.statusbar_check = QCheckBox("Pokaż pasek stanu")
1198 self.statusbar_check.setChecked(self.current_settings.get("show_statusbar", True))
1199 self.layout.addWidget(self.statusbar_check)
1200
1201 self.layout.addStretch(1)
1202
1203 # Standard OK/Cancel buttons
1204 self.button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
1205 ok_button = self.button_box.button(QDialogButtonBox.StandardButton.Ok)
1206 if ok_button: ok_button.setText("Zapisz")
1207 cancel_button = self.button_box.button(QDialogButtonBox.StandardButton.Cancel)
1208 if cancel_button: cancel_button.setText("Anuluj")
1209 self.button_box.accepted.connect(self.accept)
1210 self.button_box.rejected.connect(self.reject)
1211 self.layout.addWidget(self.button_box)
1212
1213 def get_selected_model_config(self) -> tuple:
1214 """Returns (api_type, model_identifier) of the selected model."""
1215 return self.model_combo.currentData()
1216
1217 def get_mistral_api_key(self) -> str:
1218 """Returns the Mistral API key from the input field, or None if Mistral is not supported."""
1219 if HAS_MISTRAL:
1220 return self.mistral_key_input.text().strip() or None
1221 return None
1222
1223
1224 def get_selected_theme(self) -> str:
1225 return "dark" if self.theme_combo.currentText() == "ciemny" else "light"
1226
1227 def get_font_size(self) -> int:
1228 return self.font_spin.value()
1229
1230 def get_ui_visibility(self) -> dict:
1231 return {
1232 "show_sidebar": self.sidebar_check.isChecked(),
1233 "show_toolbar": self.toolbar_check.isChecked(),
1234 "show_statusbar": self.statusbar_check.isChecked()
1235 }
1236
1237# --- File Explorer ---
1238
1239class FileExplorer(QTreeView):
1240 # Signal emitted when one or more files are selected for opening (e.g., by double-click or context menu)
1241 # Emits a list of file paths (only files, not directories)
1242 openFilesRequested = pyqtSignal(list)
1243 # Signal emitted when one or more items are selected for deletion
1244 # Emits a list of file/directory paths
1245 deleteItemsRequested = pyqtSignal(list)
1246
1247 def __init__(self, parent=None):
1248 super().__init__(parent)
1249 self.model = QFileSystemModel() # Store model as instance variable
1250 self.setModel(self.model)
1251
1252 home_dir = QStandardPaths.standardLocations(QStandardPaths.StandardLocation.HomeLocation)[0]
1253 self.model.setRootPath(home_dir)
1254 self.setRootIndex(self.model.index(home_dir))
1255
1256 self.setAnimated(False)
1257 self.setIndentation(15)
1258 self.setSortingEnabled(True)
1259
1260 # Set default sorting: Folders first, then by name, case-insensitive is often preferred
1261 self.model.setFilter(QDir.Filter.AllEntries | QDir.Filter.NoDotAndDotDot | QDir.Filter.Hidden) # Hide . and .., also hidden files/folders
1262 # self.model.setSortingFlags(QDir.SortFlag.DirsFirst | QDir.SortFlag.Name | QDir.SortFlag.IgnoreCase | QDir.SortFlag.LocaleAware)
1263 # The above line caused the error. QFileSystemModel handles DirsFirst internally when sorting by name.
1264 # We can rely on the default sorting behavior combined with sortByColumn.
1265 self.sortByColumn(0, Qt.SortOrder.AscendingOrder) # Sort by Name column (0) ascending
1266
1267 # Hide columns we don't need
1268 for i in range(1, self.model.columnCount()):
1269 self.hideColumn(i)
1270
1271 # Selection mode: Allows multi-selection with Ctrl/Shift
1272 self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
1273 # Selection behavior: Select entire rows
1274 self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
1275
1276 # Connect double-click
1277 # Disconnect the default activated behavior that changes root
1278 try:
1279 self.activated.disconnect(self.on_item_activated)
1280 except TypeError: # Handle case where it's not connected yet or connected elsewhere
1281 pass
1282 # Connect double-click to toggle expansion for directories and open for files
1283 self.doubleClicked.connect(self.on_item_double_clicked)
1284
1285
1286 self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
1287 self.customContextMenuRequested.connect(self.show_context_menu)
1288
1289 # Internal clipboard for copy/cut operations
1290 self._clipboard_paths = []
1291 self._is_cut_operation = False
1292
1293 def on_item_activated(self, index: QModelIndex):
1294 """Handles item activation (e.g., single-click or Enter key)."""
1295 # Keep single-click behavior minimal or just selection
1296 if not index.isValid():
1297 return
1298 # Default single-click is usually just selection, which is fine.
1299 # The original on_item_activated changed the root on double-click, which is now handled by on_item_double_clicked.
1300 # We can leave this method empty or connect it to something else if needed.
1301 pass
1302
1303 def on_item_double_clicked(self, index: QModelIndex):
1304 """Handles item double-click."""
1305 if not index.isValid():
1306 return
1307
1308 file_path = self.model.filePath(index)
1309 file_info = QFileInfo(file_path)
1310
1311 if file_info.isDir():
1312 # Toggle directory expansion instead of changing root
1313 self.setExpanded(index, not self.isExpanded(index))
1314 else:
1315 # If it's a file, emit signal to main window to open it
1316 self.openFilesRequested.emit([file_path]) # Emit a list even for a single file
1317
1318
1319 def get_selected_paths(self) -> list:
1320 """Returns a list of unique file/directory paths for all selected items."""
1321 paths = set() # Use a set to ensure uniqueness
1322 # Iterate through selected indexes, but only take the first column's index for each row
1323 # to avoid duplicates if multiple columns were visible
1324 for index in self.selectedIndexes():
1325 if index.column() == 0: # Only process the name column index
1326 paths.add(self.model.filePath(index))
1327 return list(paths)
1328
1329 def show_context_menu(self, position):
1330 menu = QMenu()
1331 index = self.indexAt(position) # Get index at click position
1332 clipboard = QApplication.clipboard() # Get global clipboard
1333
1334 # --- Actions based on the clicked item ---
1335 if index.isValid():
1336 file_path = self.model.filePath(index)
1337 file_info = QFileInfo(file_path)
1338 selected_paths = self.get_selected_paths() # Get all selected items
1339
1340 # --- Actions for the item at the click position ---
1341
1342 # New File/Folder actions (only if clicked item is a directory)
1343 if file_info.isDir():
1344 new_file_action = menu.addAction(QIcon.fromTheme("document-new"), "Nowy plik w tym folderze")
1345 new_file_action.triggered.connect(lambda: self.create_new_file(file_path))
1346
1347 new_folder_action = menu.addAction(QIcon.fromTheme("folder-new"), "Nowy folder w tym folderze")
1348 new_folder_action.triggered.connect(lambda: self.create_new_folder(file_path))
1349
1350 menu.addSeparator()
1351
1352 # Open action (for both files and directories)
1353 open_action = menu.addAction(QIcon.fromTheme("document-open"), "Otwórz")
1354 if file_info.isDir():
1355 # For directories, this could either expand/collapse or change root.
1356 # Let's make it change root via context menu for explicit navigation.
1357 open_action.triggered.connect(lambda: self.setRootIndex(index))
1358 # Alternative: open_action.triggered.connect(lambda: self.setExpanded(index, not self.isExpanded(index)))
1359 else:
1360 # For files, emit the open signal to the main window
1361 open_action.triggered.connect(lambda: self.openFilesRequested.emit([file_path]))
1362
1363 # Copy/Cut actions for the clicked item
1364 copy_action = menu.addAction(QIcon.fromTheme("edit-copy"), "Kopiuj")
1365 copy_action.triggered.connect(lambda: self.copy_items([file_path]))
1366
1367 cut_action = menu.addAction(QIcon.fromTheme("edit-cut"), "Wytnij")
1368 cut_action.triggered.connect(lambda: self.cut_items([file_path]))
1369
1370
1371 # Paste actions (conditional based on clipboard and clicked item type)
1372 if self._clipboard_paths: # Only show paste options if clipboard is not empty
1373 if file_info.isDir():
1374 # Paste into the clicked directory
1375 paste_into_action = menu.addAction(QIcon.fromTheme("edit-paste"), "Wklej do folderu")
1376 paste_into_action.triggered.connect(lambda: self.paste_items(file_path)) # Paste into this folder
1377 # Paste alongside the clicked directory (in its parent)
1378 parent_dir = self.model.filePath(index.parent())
1379 if parent_dir: # Cannot paste alongside the root of the model
1380 paste_alongside_action = menu.addAction(QIcon.fromTheme("edit-paste"), "Wklej obok")
1381 paste_alongside_action.triggered.connect(lambda: self.paste_items(parent_dir)) # Paste into parent folder
1382 else: # Clicked item is a file
1383 # Paste alongside the clicked file (in its parent)
1384 parent_dir = self.model.filePath(index.parent())
1385 if parent_dir: # Cannot paste alongside the root of the model
1386 paste_alongside_action = menu.addAction(QIcon.fromTheme("edit-paste"), "Wklej obok")
1387 paste_alongside_action.triggered.connect(lambda: self.paste_items(parent_dir)) # Paste into parent folder
1388
1389
1390 # Rename action for the clicked item
1391 rename_action = menu.addAction(QIcon.fromTheme("edit-rename"), "Zmień nazwę")
1392 rename_action.triggered.connect(lambda: self.edit(index)) # QTreeView.edit starts renaming
1393
1394 # Delete action for the clicked item
1395 delete_action = menu.addAction(QIcon.fromTheme("edit-delete"), "Usuń")
1396 delete_action.triggered.connect(lambda: self.deleteItemsRequested.emit([file_path])) # Emit list for consistency
1397
1398 menu.addSeparator()
1399
1400 show_in_explorer_action = menu.addAction(QIcon.fromTheme("system-file-manager"), "Pokaż w menedżerze plików")
1401 show_in_explorer_action.triggered.connect(lambda: self.show_in_explorer(file_path))
1402
1403 else:
1404 # --- Actions for empty space ---
1405 root_path = self.model.filePath(self.rootIndex()) # Target actions to the current root directory
1406
1407 new_file_action = menu.addAction(QIcon.fromTheme("document-new"), "Nowy plik")
1408 new_file_action.triggered.connect(lambda: self.create_new_file(root_path))
1409
1410 new_folder_action = menu.addAction(QIcon.fromTheme("folder-new"), "Nowy folder")
1411 new_folder_action.triggered.connect(lambda: self.create_new_folder(root_path))
1412
1413 menu.addSeparator()
1414
1415 # Paste action for empty space (paste into the current root directory)
1416 if self._clipboard_paths:
1417 paste_action = menu.addAction(QIcon.fromTheme("edit-paste"), f"Wklej elementy ({len(self._clipboard_paths)})")
1418 paste_action.triggered.connect(lambda: self.paste_items(root_path))
1419
1420
1421 select_all_action = menu.addAction(QIcon.fromTheme("edit-select-all"), "Zaznacz wszystko")
1422 select_all_action.triggered.connect(self.selectAll)
1423
1424
1425 # --- Actions for multiple selected items (if applicable, add them regardless of clicked item if multi-selected) ---
1426 # Check if *multiple* items are selected (excluding the single item already handled above)
1427 all_selected_paths = self.get_selected_paths()
1428 if len(all_selected_paths) > 1:
1429 # Avoid adding separator if one was just added
1430 if not menu.actions()[-1].isSeparator():
1431 menu.addSeparator()
1432
1433 # Filter out directories for "Open Selected Files"
1434 selected_files = [p for p in all_selected_paths if QFileInfo(p).isFile()]
1435 if selected_files:
1436 open_selected_action = menu.addAction(QIcon.fromTheme("document-open-folder"), f"Otwórz zaznaczone pliki ({len(selected_files)})")
1437 open_selected_action.triggered.connect(lambda: self.openFilesRequested.emit(selected_files))
1438
1439 # Copy/Cut for multiple selected items
1440 copy_selected_action = menu.addAction(QIcon.fromTheme("edit-copy"), f"Kopiuj zaznaczone elementy ({len(all_selected_paths)})")
1441 copy_selected_action.triggered.connect(lambda: self.copy_items(all_selected_paths))
1442
1443 cut_selected_action = menu.addAction(QIcon.fromTheme("edit-cut"), f"Wytnij zaznaczone elementy ({len(all_selected_paths)})")
1444 cut_selected_action.triggered.connect(lambda: self.cut_items(all_selected_paths))
1445
1446 # Delete action for all selected items (files and folders)
1447 delete_selected_action = menu.addAction(QIcon.fromTheme("edit-delete"), f"Usuń zaznaczone elementy ({len(all_selected_paths)})")
1448 delete_selected_action.triggered.connect(lambda: self.deleteItemsRequested.emit(all_selected_paths)) # Emit list for consistency
1449
1450
1451 menu.exec(self.viewport().mapToGlobal(position))
1452
1453 def create_new_file(self, dir_path):
1454 name, ok = QInputDialog.getText(self, "Nowy plik", "Nazwa pliku:", QLineEdit.EchoMode.Normal, "nowy_plik.txt")
1455 if ok and name:
1456 file_path = os.path.join(dir_path, name)
1457 try:
1458 if os.path.exists(file_path):
1459 QMessageBox.warning(self, "Błąd", f"Plik już istnieje: {file_path}")
1460 return
1461 # Create the file manually
1462 with open(file_path, 'w', encoding='utf-8') as f:
1463 f.write('') # Create an empty file
1464 print(f"Utworzono plik: {file_path}")
1465 # Refresh the model to show the new file
1466 self.model.refresh(self.model.index(dir_path))
1467 # Optional: Select and start renaming the new file
1468 new_index = self.model.index(file_path)
1469 if new_index.isValid():
1470 self.setCurrentIndex(new_index)
1471 self.edit(new_index)
1472 self.parent().update_status_bar_message(f"Utworzono nowy plik: {os.path.basename(file_path)}")
1473
1474 except Exception as e:
1475 QMessageBox.warning(self, "Błąd", f"Nie można utworzyć pliku '{name}':\n{e}")
1476 self.parent().update_status_bar_message(f"Błąd tworzenia pliku: {e}")
1477
1478
1479 def create_new_folder(self, dir_path):
1480 name, ok = QInputDialog.getText(self, "Nowy folder", "Nazwa folderu:", QLineEdit.EchoMode.Normal, "Nowy folder")
1481 if ok and name:
1482 folder_path = os.path.join(dir_path, name)
1483 try:
1484 if os.path.exists(folder_path):
1485 QMessageBox.warning(self, "Błąd", f"Folder już istnieje: {folder_path}")
1486 return
1487 # Use the model's method which handles refreshing and selection/editing
1488 index = self.model.index(dir_path)
1489 if index.isValid():
1490 new_index = self.model.mkdir(index, name)
1491 if new_index.isValid():
1492 # Optional: Expand parent and select/rename new folder
1493 self.setExpanded(index, True)
1494 self.setCurrentIndex(new_index)
1495 self.edit(new_index)
1496 if self.parent() and hasattr(self.parent(), 'update_status_bar_message'):
1497 self.parent().update_status_bar_message(f"Utworzono nowy folder: {os.path.basename(folder_path)}")
1498 else:
1499 print(f"Folder {folder_path} utworzony, ale brak metody 'update_status_bar_message'!")
1500 else:
1501 QMessageBox.warning(self, "Błąd", f"Nie można utworzyć folderu '{name}'. Sprawdź uprawnienia.")
1502 if self.parent() and hasattr(self.parent(), 'update_status_bar_message'):
1503 self.parent().update_status_bar_message(f"Błąd tworzenia folderu: {name}")
1504 else:
1505 # Fallback if dir_path cannot be found in the model (less likely for valid paths)
1506 os.mkdir(folder_path)
1507 self.model.refresh(self.model.index(dir_path)) # Manual refresh
1508 new_index = self.model.index(folder_path)
1509 if new_index.isValid():
1510 self.setExpanded(self.model.index(dir_path), True)
1511 self.setCurrentIndex(new_index)
1512 self.edit(new_index)
1513 if self.parent() and hasattr(self.parent(), 'update_status_bar_message'):
1514 self.parent().update_status_bar_message(f"Utworzono nowy folder: {os.path.basename(folder_path)}")
1515 else:
1516 QMessageBox.warning(self, "Błąd", f"Nie można utworzyć folderu '{name}'. Sprawdź uprawnienia.")
1517 if self.parent() and hasattr(self.parent(), 'update_status_bar_message'):
1518 self.parent().update_status_bar_message(f"Błąd tworzenia folderu: {name}")
1519 except Exception as e:
1520 QMessageBox.warning(self, "Błąd", f"Nie można utworzyć folderu '{name}':\n{e}")
1521 if self.parent() and hasattr(self.parent(), 'update_status_bar_message'):
1522 self.parent().update_status_bar_message(f"Błąd tworzenia folderu: {e}")
1523
1524
1525
1526 def delete_items(self, file_paths: list):
1527 """Initiates deletion of a list of files/directories."""
1528 if not file_paths:
1529 return
1530
1531 # Get confirmation for multiple items
1532 if len(file_paths) > 1:
1533 items_list = "\n".join([os.path.basename(p) for p in file_paths])
1534 reply = QMessageBox.question(self, "Usuń zaznaczone",
1535 f"Czy na pewno chcesz usunąć następujące elementy?\n\n{items_list}\n\nTa operacja jest nieodwracalna.",
1536 QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
1537 else:
1538 # Confirmation for single item (reusing logic from show_context_menu)
1539 item_name = os.path.basename(file_paths[0])
1540 reply = QMessageBox.question(self, "Usuń", f"Czy na pewno chcesz usunąć '{item_name}'?",
1541 QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
1542
1543 if reply == QMessageBox.StandardButton.Yes:
1544 deleted_count = 0
1545 error_messages = []
1546 parent_dirs_to_refresh = set()
1547
1548 for file_path in file_paths:
1549 parent_dirs_to_refresh.add(os.path.dirname(file_path))
1550 try:
1551 index = self.model.index(file_path)
1552 if index.isValid():
1553 # QFileSystemModel.remove handles both files and non-empty directories recursively
1554 # on supported platforms (like Windows, macOS). On Linux, it might be just rmdir for empty dirs.
1555 # Let's prioritize the model's method first as it might be more integrated.
1556 # For robustness, we can keep the shutil fallback.
1557 # NOTE: model.remove returns True on success, False on failure, doesn't raise exceptions.
1558 if self.model.remove(index.row(), 1, index.parent()):
1559 print(f"Usunięto element (model): {file_path}")
1560 deleted_count += 1
1561 else:
1562 # model.remove failed, try recursive deletion with shutil/os
1563 print(f"Model nie usunął '{file_path}', próbuję shutil/os...")
1564 if os.path.isdir(file_path):
1565 shutil.rmtree(file_path)
1566 print(f"Usunięto katalog (shutil): {file_path}")
1567 deleted_count += 1
1568 elif os.path.exists(file_path):
1569 os.remove(file_path)
1570 print(f"Usunięto plik (os.remove): {file_path}")
1571 deleted_count += 1
1572 else:
1573 # Should not happen if index was valid initially
1574 error_messages.append(f"Nie znaleziono: {file_path}")
1575 else:
1576 error_messages.append(f"Nieprawidłowa ścieżka lub element niedostępny: {file_path}")
1577 except Exception as e:
1578 error_messages.append(f"Nie można usunąć '{os.path.basename(file_path)}': {e}")
1579 print(f"Błąd usuwania '{file_path}': {traceback.format_exc()}") # Log error
1580
1581 # Refresh parent directories that were affected
1582 for parent_dir in parent_dirs_to_refresh:
1583 if os.path.exists(parent_dir): # Ensure parent still exists
1584 self.model.refresh(self.model.index(parent_dir))
1585
1586 if error_messages:
1587 QMessageBox.warning(self, "Błąd usuwania", "Wystąpiły błędy podczas usuwania niektórych elementów:\n\n" + "\n".join(error_messages))
1588 self.parent().update_status_bar_message(f"Wystąpiły błędy podczas usuwania ({len(error_messages)} błędów)")
1589 elif deleted_count > 0:
1590 self.parent().update_status_bar_message(f"Pomyślnie usunięto {deleted_count} elementów.")
1591
1592
1593 def copy_items(self, paths_to_copy: list):
1594 """Stores paths for copy operation in the internal clipboard."""
1595 if not paths_to_copy:
1596 return
1597 self._clipboard_paths = paths_to_copy
1598 self._is_cut_operation = False
1599 self.parent().update_status_bar_message(f"Skopiowano {len(paths_to_copy)} elementów.")
1600 print(f"Skopiowano: {self._clipboard_paths}") # Debug print
1601
1602
1603 def cut_items(self, paths_to_cut: list):
1604 """Stores paths for cut operation in the internal clipboard."""
1605 if not paths_to_cut:
1606 return
1607 self._clipboard_paths = paths_to_cut
1608 self._is_cut_operation = True
1609 self.parent().update_status_bar_message(f"Wycięto {len(paths_to_cut)} elementów.")
1610 print(f"Wycięto: {self._clipboard_paths}") # Debug print
1611
1612
1613 def paste_items(self, destination_dir: str):
1614 """Pastes items from the internal clipboard into the destination directory."""
1615 if not self._clipboard_paths:
1616 self.parent().update_status_bar_message("Schowek jest pusty.")
1617 return
1618
1619 if not os.path.isdir(destination_dir):
1620 QMessageBox.warning(self, "Błąd wklejania", f"Docelowa ścieżka nie jest katalogiem: {destination_dir}")
1621 self.parent().update_status_bar_message(f"Błąd wklejania: {destination_dir} nie jest katalogiem.")
1622 return
1623
1624 if not os.access(destination_dir, os.W_OK):
1625 QMessageBox.warning(self, "Błąd wklejania", f"Brak uprawnień zapisu w katalogu docelowym: {destination_dir}")
1626 self.parent().update_status_bar_message(f"Błąd wklejania: Brak uprawnień w {destination_dir}.")
1627 return
1628
1629 operation = "Przenoszenie" if self._is_cut_operation else "Kopiowanie"
1630 self.parent().update_status_bar_message(f"{operation} {len(self._clipboard_paths)} elementów do '{os.path.basename(destination_dir)}'...")
1631
1632 success_count = 0
1633 error_messages = []
1634 parent_dirs_to_refresh = {destination_dir} # Always refresh destination
1635
1636 for src_path in self._clipboard_paths:
1637 if not os.path.exists(src_path):
1638 error_messages.append(f"Źródło nie istnieje: {os.path.basename(src_path)}")
1639 continue
1640
1641 item_name = os.path.basename(src_path)
1642 dest_path = os.path.join(destination_dir, item_name)
1643
1644 # Prevent pasting an item into itself or its sub-directory during a move
1645 if self._is_cut_operation and src_path == dest_path:
1646 error_messages.append(f"Nie można przenieść '{item_name}' w to samo miejsce.")
1647 continue
1648 if self._is_cut_operation and os.path.commonpath([src_path, dest_path]) == src_path and os.path.isdir(src_path):
1649 error_messages.append(f"Nie można przenieść '{item_name}' do jego podkatalogu.")
1650 continue
1651
1652 # Handle potential overwrite (simple overwrite for now)
1653 if os.path.exists(dest_path):
1654 # Ask for confirmation? For simplicity, let's overwrite or skip for now.
1655 # A more complex dialog could be added here.
1656 # For this example, let's just overwrite.
1657 if os.path.isdir(dest_path):
1658 try: shutil.rmtree(dest_path)
1659 except Exception as e: error_messages.append(f"Nie można nadpisać katalogu '{item_name}': {e}"); continue
1660 else:
1661 try: os.remove(dest_path)
1662 except Exception as e: error_messages.append(f"Nie można nadpisać pliku '{item_name}': {e}"); continue
1663
1664
1665 try:
1666 if self._is_cut_operation:
1667 # Move the item
1668 shutil.move(src_path, dest_path)
1669 success_count += 1
1670 parent_dirs_to_refresh.add(os.path.dirname(src_path)) # Also refresh source's parent on move
1671 else:
1672 # Copy the item (recursive for directories)
1673 if os.path.isdir(src_path):
1674 shutil.copytree(src_path, dest_path)
1675 else:
1676 shutil.copy2(src_path, dest_path) # copy2 preserves metadata
1677 success_count += 1
1678
1679 except Exception as e:
1680 error_messages.append(f"Błąd {operation.lower()} '{item_name}': {e}")
1681 print(f"Błąd {operation.lower()} '{src_path}' do '{dest_path}': {traceback.format_exc()}") # Log error
1682
1683
1684 # Refresh affected directories
1685 for refresh_dir in parent_dirs_to_refresh:
1686 if os.path.exists(refresh_dir):
1687 self.model.refresh(self.model.index(refresh_dir))
1688
1689 if self._is_cut_operation and success_count > 0:
1690 # Clear clipboard only if it was a cut operation and at least one item was successfully moved
1691 self._clipboard_paths = []
1692 self._is_cut_operation = False
1693
1694 if error_messages:
1695 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))
1696 self.parent().update_status_bar_message(f"Wystąpiły błędy podczas {operation.lower()}enia ({len(error_messages)} błędów)")
1697 elif success_count > 0:
1698 self.parent().update_status_bar_message(f"Pomyślnie {operation.lower()}ono {success_count} elementów.")
1699 else:
1700 # This case happens if clipboard was empty or all items failed
1701 if not self._clipboard_paths: # If clipboard was empty initially
1702 pass # Message already handled at the start
1703 else: # All items failed
1704 self.parent().update_status_bar_message(f"Nie udało się {operation.lower()}ić żadnych elementów.")
1705
1706 def show_in_explorer(self, file_path):
1707 """Opens the file or folder in the native file explorer."""
1708 if sys.platform == "win32":
1709 try:
1710 # Use explorer.exe /select, to select the file/folder
1711 subprocess.Popen(['explorer.exe', '/select,', os.path.normpath(file_path)])
1712 self.parent().update_status_bar_message(f"Otworzono w eksploratorze: {os.path.basename(file_path)}")
1713 except FileNotFoundError:
1714 QMessageBox.warning(self, "Błąd", "Nie znaleziono 'explorer.exe'.")
1715 self.parent().update_status_bar_message("Błąd: Nie znaleziono explorer.exe.")
1716 except Exception as e:
1717 QMessageBox.warning(self, "Błąd", f"Nie można otworzyć menedżera plików:\n{e}")
1718 self.parent().update_status_bar_message(f"Błąd otwarcia w menedżerze: {e}")
1719 elif sys.platform == "darwin": # macOS
1720 try:
1721 # Use 'open -R' to reveal file in Finder, or 'open' for folder
1722 subprocess.Popen(['open', '-R', file_path])
1723 self.parent().update_status_bar_message(f"Otworzono w Finderze: {os.path.basename(file_path)}")
1724 except FileNotFoundError:
1725 QMessageBox.warning(self, "Błąd", "Nie znaleziono 'open'.")
1726 self.parent().update_status_bar_message("Błąd: Nie znaleziono open.")
1727 except Exception as e:
1728 QMessageBox.warning(self, "Błąd", f"Nie można otworzyć Findera:\n{e}")
1729 self.parent().update_status_bar_message(f"Błąd otwarcia w Finderze: {e}")
1730 else: # Linux
1731 try:
1732 # Use xdg-open which should open the containing folder
1733 # For a file, xdg-open opens the file. To open the folder containing the file:
1734 target_path = os.path.dirname(file_path) if os.path.isfile(file_path) else file_path
1735 subprocess.Popen(['xdg-open', target_path])
1736 self.parent().update_status_bar_message(f"Otworzono w menedżerze plików: {os.path.basename(target_path)}")
1737 except FileNotFoundError:
1738 QMessageBox.warning(self, "Błąd", "Nie znaleziono 'xdg-open'. Nie można otworzyć lokalizacji pliku.")
1739 self.parent().update_status_bar_message("Błąd: Nie znaleziono xdg-open.")
1740 except Exception as e:
1741 QMessageBox.warning(self, "Błąd", f"Nie można otworzyć lokalizacji pliku:\n{e}")
1742 self.parent().update_status_bar_message(f"Błąd otwarcia w menedżerze: {e}")
1743
1744 # Open file logic is now handled by the main window via signal
1745 # The file explorer's open_file method is effectively replaced by on_item_activated
1746 # which emits openFilesRequested.
1747
1748
1749# --- Main Application Window ---
1750
1751class CodeEditorWindow(QMainWindow):
1752 def __init__(self, parent=None):
1753 super().__init__(parent)
1754 self.setWindowTitle("Edytor Kodu AI")
1755 self.setGeometry(100, 100, 1200, 800)
1756
1757 # Load settings
1758 self.settings = load_settings()
1759 self.current_api_type = self.settings.get("api_type", DEFAULT_MODEL_CONFIG[0])
1760 self.current_model_identifier = self.settings.get("model_identifier", DEFAULT_MODEL_CONFIG[1])
1761 self.mistral_api_key = self.settings.get("mistral_api_key") # Load Mistral key
1762 # Gemini key is loaded globally GEMINI_API_KEY_GLOBAL
1763
1764 self.recent_files = self.settings["recent_files"]
1765 self.font_size = self.settings["font_size"]
1766 self.theme = self.settings["theme"]
1767 self.workspace = self.settings["workspace"]
1768 self.show_sidebar = self.settings["show_sidebar"]
1769 self.show_toolbar = self.settings["show_toolbar"]
1770 self.show_statusbar = self.settings["show_statusbar"]
1771
1772 # Initialize UI
1773 self.init_ui()
1774
1775 # Store references to menu actions for toggling visibility checks (Fix AttributeError)
1776 self.action_toggle_sidebar = self.findChild(QAction, "Przełącz Pasek Boczny")
1777 self.action_toggle_toolbar = self.findChild(QAction, "Przełącz Pasek Narzędzi")
1778 self.action_toggle_statusbar = self.findChild(QAction, "Przełącz Pasek Stanu")
1779
1780
1781 # Chat History State
1782 # Stored as list of (role, content, metadata) tuples
1783 self.chat_history = []
1784
1785 # Threading Setup for API calls
1786 self.worker = None
1787 self.worker_thread = None
1788 self._is_processing = False
1789
1790 # State for streaming response
1791 self.current_placeholder_widget = None
1792 self.current_response_content = ""
1793
1794 # Setup status bar message timer
1795 self._status_timer = QTimer(self)
1796 self._status_timer.setSingleShot(True)
1797 self._status_timer.timeout.connect(self.clear_status_bar_message)
1798
1799 # Open workspace if set and exists
1800 if self.workspace and os.path.exists(self.workspace) and os.path.isdir(self.workspace):
1801 self.file_explorer.setRootIndex(self.file_explorer.model.index(self.workspace))
1802 self.update_status_bar_message(f"Obszar roboczy: {self.workspace}")
1803 else:
1804 # If workspace is not set or invalid, set root to home directory
1805 home_dir = QStandardPaths.standardLocations(QStandardPaths.StandardLocation.HomeLocation)[0]
1806 self.file_explorer.model.setRootPath(home_dir)
1807 self.file_explorer.setRootIndex(self.file_explorer.model.index(home_dir))
1808 self.workspace = home_dir # Update settings to reflect actual root
1809 self.settings["workspace"] = self.workspace
1810 save_settings(self.settings) # Save updated workspace
1811 self.update_status_bar_message(f"Ustawiono domyślny obszar roboczy: {self.workspace}")
1812
1813
1814 # Add welcome message
1815 # Find the display name for the initial model
1816 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)
1817
1818 self.add_message("assistant", f"Witaj w edytorze kodu AI! Aktualnie działam na modelu '{initial_model_name}'. Jak mogę Ci dziś pomóc?")
1819
1820 def init_ui(self):
1821 central_widget = QWidget()
1822 self.setCentralWidget(central_widget)
1823
1824 self.main_splitter = QSplitter(Qt.Orientation.Horizontal)
1825
1826 self.sidebar = QWidget()
1827 sidebar_layout = QVBoxLayout(self.sidebar)
1828 sidebar_layout.setContentsMargins(0, 0, 0, 0)
1829 sidebar_layout.setSpacing(0)
1830
1831 # FileExplorer initialization (this is where the error occurred)
1832 # The fix is inside the FileExplorer class itself
1833 self.file_explorer = FileExplorer(self)
1834 sidebar_layout.addWidget(self.file_explorer)
1835 # Connect signals from FileExplorer
1836 self.file_explorer.openFilesRequested.connect(self.open_files)
1837 self.file_explorer.deleteItemsRequested.connect(self.file_explorer.delete_items) # Connect to file_explorer's delete method
1838
1839
1840 self.right_panel = QSplitter(Qt.Orientation.Vertical)
1841
1842 self.tabs = QTabWidget()
1843 self.tabs.setTabsClosable(True)
1844 self.tabs.tabCloseRequested.connect(self.close_tab)
1845 self.tabs.currentChanged.connect(self.update_status_bar)
1846
1847 self.chat_container = QWidget()
1848 chat_layout = QVBoxLayout(self.chat_container)
1849 chat_layout.setContentsMargins(0, 0, 0, 0)
1850 chat_layout.setSpacing(0)
1851
1852 self.chat_scroll = QScrollArea()
1853 self.chat_scroll.setWidgetResizable(True)
1854 self.chat_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
1855
1856 self.chat_widget = QWidget()
1857 self.chat_widget.setObjectName("chat_widget")
1858 self.chat_layout = QVBoxLayout(self.chat_widget)
1859 self.chat_layout.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignHCenter)
1860 self.chat_layout.setSpacing(10)
1861 self.chat_layout.addStretch(1)
1862
1863 self.chat_scroll.setWidget(self.chat_widget)
1864 chat_layout.addWidget(self.chat_scroll)
1865
1866 self.chat_input = QLineEdit()
1867 self.chat_input.setPlaceholderText("Wpisz wiadomość tutaj...")
1868 self.chat_input.returnPressed.connect(self.send_message)
1869
1870 self.send_button = QPushButton("Wyślij")
1871 self.send_button.clicked.connect(self.send_message)
1872
1873 input_layout = QHBoxLayout()
1874 input_layout.addWidget(self.chat_input, 1)
1875 input_layout.addWidget(self.send_button)
1876 chat_layout.addLayout(input_layout)
1877
1878 self.main_splitter.addWidget(self.sidebar)
1879 self.right_panel.addWidget(self.tabs)
1880 self.right_panel.addWidget(self.chat_container)
1881 self.right_panel.setStretchFactor(0, 3)
1882 self.right_panel.setStretchFactor(1, 1)
1883 self.main_splitter.addWidget(self.right_panel)
1884
1885 main_layout = QVBoxLayout(central_widget)
1886 main_layout.addWidget(self.main_splitter)
1887
1888 self.create_menu_bar()
1889 self.create_tool_bar()
1890 self.status_bar = QStatusBar()
1891 self.setStatusBar(self.status_bar)
1892 self.update_status_bar()
1893
1894 self.apply_font_size(self.font_size)
1895 self.apply_theme(self.theme)
1896
1897 self.sidebar.setVisible(self.show_sidebar)
1898 self.toolbar.setVisible(self.show_toolbar)
1899 self.status_bar.setVisible(self.show_statusbar)
1900
1901 self.main_splitter.setSizes([200, 800])
1902 self.right_panel.setSizes([600, 200])
1903
1904 def create_menu_bar(self):
1905 menubar = self.menuBar()
1906
1907 # File menu
1908 file_menu = menubar.addMenu("📄 Plik")
1909
1910 new_action = QAction(QIcon.fromTheme("document-new"), "Nowy", self)
1911 new_action.setShortcut("Ctrl+N")
1912 new_action.setShortcutContext(Qt.ShortcutContext.WindowShortcut)
1913 new_action.triggered.connect(self.new_file)
1914 file_menu.addAction(new_action)
1915
1916 open_action = QAction(QIcon.fromTheme("document-open"), "Otwórz...", self)
1917 open_action.setShortcut("Ctrl+O")
1918 open_action.setShortcutContext(Qt.ShortcutContext.WindowShortcut)
1919 open_action.triggered.connect(self.open_file_dialog)
1920 file_menu.addAction(open_action)
1921
1922 save_action = QAction(QIcon.fromTheme("document-save"), "Zapisz", self)
1923 save_action.setObjectName("action_save") # Add object name for potential lookup
1924 save_action.setShortcut("Ctrl+S")
1925 save_action.setShortcutContext(Qt.ShortcutContext.WindowShortcut) # Ensure Ctrl+S works even when editor has focus
1926 save_action.triggered.connect(self.save_file)
1927 file_menu.addAction(save_action)
1928
1929 save_as_action = QAction("Zapisz jako...", self)
1930 save_as_action.setShortcut("Ctrl+Shift+S")
1931 save_as_action.setShortcutContext(Qt.ShortcutContext.WindowShortcut)
1932 save_as_action.triggered.connect(self.save_file_as)
1933 file_menu.addAction(save_as_action)
1934
1935 file_menu.addSeparator()
1936
1937 open_workspace_action = QAction(QIcon.fromTheme("folder-open"), "Otwórz Obszar Roboczy...", self)
1938 open_workspace_action.triggered.connect(self.open_workspace)
1939 file_menu.addAction(open_workspace_action)
1940
1941 self.recent_files_menu = file_menu.addMenu("Ostatnie pliki")
1942 self.update_recent_files_menu()
1943
1944 file_menu.addSeparator()
1945
1946 exit_action = QAction("Wyjście", self)
1947 exit_action.setShortcut("Ctrl+Q")
1948 exit_action.setShortcutContext(Qt.ShortcutContext.WindowShortcut)
1949 exit_action.triggered.connect(self.close)
1950 file_menu.addAction(exit_action)
1951
1952 # Edit menu
1953 edit_menu = menubar.addMenu("✏️ Edycja")
1954
1955 undo_action = QAction(QIcon.fromTheme("edit-undo"), "Cofnij", self)
1956 undo_action.setShortcut("Ctrl+Z")
1957 undo_action.setShortcutContext(Qt.ShortcutContext.WidgetWithChildrenShortcut) # Standard editor shortcut
1958 undo_action.triggered.connect(self.undo)
1959 edit_menu.addAction(undo_action)
1960
1961 redo_action = QAction(QIcon.fromTheme("edit-redo"), "Ponów", self)
1962 redo_action.setShortcut("Ctrl+Y")
1963 redo_action.setShortcutContext(Qt.ShortcutContext.WidgetWithChildrenShortcut) # Standard editor shortcut
1964 redo_action.triggered.connect(self.redo)
1965 edit_menu.addAction(redo_action)
1966
1967 edit_menu.addSeparator()
1968
1969 cut_action = QAction(QIcon.fromTheme("edit-cut"), "Wytnij", self)
1970 cut_action.setShortcut("Ctrl+X")
1971 cut_action.setShortcutContext(Qt.ShortcutContext.WidgetWithChildrenShortcut) # Standard editor shortcut
1972 cut_action.triggered.connect(self.cut)
1973 edit_menu.addAction(cut_action)
1974
1975 copy_action = QAction(QIcon.fromTheme("edit-copy"), "Kopiuj", self)
1976 copy_action.setShortcut("Ctrl+C")
1977 copy_action.setShortcutContext(Qt.ShortcutContext.WidgetWithChildrenShortcut) # Standard editor shortcut
1978 copy_action.triggered.connect(self.copy)
1979 edit_menu.addAction(copy_action)
1980
1981 paste_action = QAction(QIcon.fromTheme("edit-paste"), "Wklej", self)
1982 paste_action.setShortcut("Ctrl+V")
1983 paste_action.setShortcutContext(Qt.ShortcutContext.WidgetWithChildrenShortcut) # Standard editor shortcut
1984 paste_action.triggered.connect(self.paste)
1985 edit_menu.addAction(paste_action)
1986
1987 edit_menu.addSeparator()
1988
1989 find_action = QAction(QIcon.fromTheme("edit-find"), "Znajdź...", self)
1990 find_action.setShortcut("Ctrl+F")
1991 find_action.setShortcutContext(Qt.ShortcutContext.WindowShortcut) # Find can often be window-wide
1992 find_action.triggered.connect(self.find)
1993 edit_menu.addAction(find_action)
1994
1995 replace_action = QAction(QIcon.fromTheme("edit-find-replace"), "Zamień...", self)
1996 replace_action.setShortcut("Ctrl+H")
1997 replace_action.setShortcutContext(Qt.ShortcutContext.WindowShortcut)
1998 replace_action.triggered.connect(self.replace)
1999 edit_menu.addAction(replace_action)
2000
2001 # View menu
2002 view_menu = menubar.addMenu("🖼️ Widok")
2003
2004 # Store references to toggle actions (Fix AttributeError)
2005 self.action_toggle_sidebar = QAction("Przełącz Pasek Boczny", self)
2006 self.action_toggle_sidebar.setObjectName("Przełącz Pasek Boczny") # Set object name for findChild if needed elsewhere
2007 self.action_toggle_sidebar.setShortcut("Ctrl+B")
2008 self.action_toggle_sidebar.setCheckable(True)
2009 self.action_toggle_sidebar.setChecked(self.show_sidebar)
2010 self.action_toggle_sidebar.triggered.connect(self.toggle_sidebar)
2011 view_menu.addAction(self.action_toggle_sidebar)
2012
2013 self.action_toggle_toolbar = QAction("Przełącz Pasek Narzędzi", self)
2014 self.action_toggle_toolbar.setObjectName("Przełącz Pasek Narzędzi")
2015 self.action_toggle_toolbar.setCheckable(True)
2016 self.action_toggle_toolbar.setChecked(self.show_toolbar)
2017 self.action_toggle_toolbar.triggered.connect(self.toggle_toolbar)
2018 view_menu.addAction(self.action_toggle_toolbar)
2019
2020 self.action_toggle_statusbar = QAction("Przełącz Pasek Stanu", self)
2021 self.action_toggle_statusbar.setObjectName("Przełącz Pasek Stanu")
2022 self.action_toggle_statusbar.setCheckable(True)
2023 self.action_toggle_statusbar.setChecked(self.show_statusbar)
2024 self.action_toggle_statusbar.triggered.connect(self.toggle_statusbar)
2025 view_menu.addAction(self.action_toggle_statusbar)
2026
2027 view_menu.addSeparator()
2028
2029 dark_theme_action = QAction("Ciemny Motyw", self)
2030 dark_theme_action.triggered.connect(lambda: self.apply_theme("dark"))
2031 view_menu.addAction(dark_theme_action)
2032
2033 light_theme_action = QAction("Jasny Motyw", self)
2034 light_theme_action.triggered.connect(lambda: self.apply_theme("light"))
2035 view_menu.addAction(light_theme_action)
2036
2037 # Tools menu
2038 tools_menu = menubar.addMenu("🛠️ Narzędzia")
2039
2040 run_code_action = QAction(QIcon.fromTheme("system-run"), "Uruchom kod", self)
2041 run_code_action.setShortcut("Ctrl+R")
2042 run_code_action.setShortcutContext(Qt.ShortcutContext.WindowShortcut) # Run code is window-wide
2043 run_code_action.triggered.connect(self.run_code)
2044 tools_menu.addAction(run_code_action)
2045
2046 settings_action = QAction(QIcon.fromTheme("preferences-system"), "Ustawienia...", self)
2047 settings_action.triggered.connect(self.show_settings_dialog)
2048 tools_menu.addAction(settings_action)
2049
2050 # Help menu
2051 help_menu = menubar.addMenu("❓ Pomoc")
2052
2053 about_action = QAction("O programie", self)
2054 about_action.triggered.connect(self.show_about)
2055 help_menu.addAction(about_action)
2056
2057
2058 def create_tool_bar(self):
2059 self.toolbar = QToolBar("Główny Pasek Narzędzi")
2060 self.addToolBar(Qt.ToolBarArea.TopToolBarArea, self.toolbar)
2061 self.toolbar.setObjectName("main_toolbar") # Add object name for styling
2062
2063 # Add actions (use the same actions created in the menu bar if possible, or recreate)
2064 # Recreating ensures they have icons regardless of theme availability
2065 self.toolbar.addAction(QAction(QIcon.fromTheme("document-new"), "Nowy", self, triggered=self.new_file))
2066 self.toolbar.addAction(QAction(QIcon.fromTheme("document-open"), "Otwórz", self, triggered=self.open_file_dialog))
2067 # Connect the toolbar save action to the same slot and set shortcut context
2068 save_toolbar_action = QAction(QIcon.fromTheme("document-save"), "Zapisz", self, triggered=self.save_file)
2069 save_toolbar_action.setShortcut("Ctrl+S") # Redundant but good practice
2070 save_toolbar_action.setShortcutContext(Qt.ShortcutContext.WindowShortcut)
2071 self.toolbar.addAction(save_toolbar_action)
2072
2073 self.toolbar.addSeparator()
2074
2075 undo_action = QAction(QIcon.fromTheme("edit-undo"), "Cofnij", self, triggered=self.undo)
2076 undo_action.setShortcut("Ctrl+Z")
2077 undo_action.setShortcutContext(Qt.ShortcutContext.WidgetWithChildrenShortcut)
2078 self.toolbar.addAction(undo_action)
2079
2080 redo_action = QAction(QIcon.fromTheme("edit-redo"), "Ponów", self, triggered=self.redo)
2081 redo_action.setShortcut("Ctrl+Y")
2082 redo_action.setShortcutContext(Qt.ShortcutContext.WidgetWithChildrenShortcut)
2083 self.toolbar.addAction(redo_action)
2084
2085 self.toolbar.addSeparator()
2086
2087 cut_action = QAction(QIcon.fromTheme("edit-cut"), "Wytnij", self, triggered=self.cut)
2088 cut_action.setShortcut("Ctrl+X")
2089 cut_action.setShortcutContext(Qt.ShortcutContext.WidgetWithChildrenShortcut)
2090 self.toolbar.addAction(cut_action)
2091
2092 copy_action = QAction(QIcon.fromTheme("edit-copy"), "Kopiuj", self, triggered=self.copy)
2093 copy_action.setShortcut("Ctrl+C")
2094 copy_action.setShortcutContext(Qt.ShortcutContext.WidgetWithChildrenShortcut)
2095 self.toolbar.addAction(copy_action)
2096
2097 paste_action = QAction(QIcon.fromTheme("edit-paste"), "Wklej", self, triggered=self.paste)
2098 paste_action.setShortcut("Ctrl+V")
2099 paste_action.setShortcutContext(Qt.ShortcutContext.WidgetWithChildrenShortcut)
2100 self.toolbar.addAction(paste_action)
2101
2102 self.toolbar.addSeparator()
2103
2104 find_action = QAction(QIcon.fromTheme("edit-find"), "Znajdź", self, triggered=self.find)
2105 find_action.setShortcut("Ctrl+F")
2106 find_action.setShortcutContext(Qt.ShortcutContext.WindowShortcut)
2107 self.toolbar.addAction(find_action)
2108
2109 self.toolbar.addSeparator()
2110
2111 run_code_action = QAction(QIcon.fromTheme("system-run"), "➡️ Uruchom kod", self, triggered=self.run_code)
2112 run_code_action.setShortcut("Ctrl+R")
2113 run_code_action.setShortcutContext(Qt.ShortcutContext.WindowShortcut)
2114 self.toolbar.addAction(run_code_action)
2115
2116 def apply_theme(self, theme_name):
2117 self.theme = theme_name
2118 self.settings["theme"] = theme_name
2119 save_settings(self.settings)
2120
2121 if theme_name == "dark":
2122 main_bg = QColor("#252526")
2123 main_fg = QColor("#ffffff")
2124 menu_bg = QColor("#252526")
2125 menu_fg = QColor("#ffffff")
2126 menu_selected_bg = QColor("#2d2d30")
2127 menu_border = QColor("#454545")
2128 tab_pane_border = QColor("#454545")
2129 tab_pane_bg = QColor("#1e1e1e")
2130 tab_bg = QColor("#2d2d2d")
2131 tab_fg = QColor("#ffffff")
2132 tab_selected_bg = QColor("#1e1e1e")
2133 statusbar_bg = QColor("#252526")
2134 statusbar_fg = QColor("#ffffff")
2135 toolbar_bg = QColor("#252526")
2136 toolbar_fg = QColor("#ffffff")
2137 splitter_handle_bg = QColor("#252526")
2138 lineedit_bg = QColor("#333333")
2139 lineedit_fg = QColor("#ffffff")
2140 lineedit_border = QColor("#454545")
2141 button_bg = QColor("#3c3c3c")
2142 button_fg = QColor("#ffffff")
2143 button_border = QColor("#5a5a5a")
2144 button_hover_bg = QColor("#4a4a4a")
2145 button_pressed_bg = QColor("#2a2a2a")
2146 editor_bg = QColor("#1e1e1e")
2147 editor_fg = QColor("#d4d4d4")
2148 linenum_area_bg = QColor("#252526")
2149 linenum_fg = QColor("#858585")
2150 current_line_bg = QColor("#2d2d2d")
2151 chat_bg = QColor("#1e1e1e")
2152 chat_input_bg = QColor("#333333")
2153 chat_input_fg = QColor("#ffffff")
2154 bubble_user = QColor("#3a3a3a")
2155 bubble_assistant = QColor("#2d2d2d")
2156 bubble_border = QColor("#454545")
2157
2158 else: # light theme
2159 main_bg = QColor("#f5f5f5")
2160 main_fg = QColor("#333333")
2161 menu_bg = QColor("#f5f5f5")
2162 menu_fg = QColor("#333333")
2163 menu_selected_bg = QColor("#e5e5e5")
2164 menu_border = QColor("#cccccc")
2165 tab_pane_border = QColor("#cccccc")
2166 tab_pane_bg = QColor("#ffffff")
2167 tab_bg = QColor("#e5e5e5")
2168 tab_fg = QColor("#333333")
2169 tab_selected_bg = QColor("#ffffff")
2170 statusbar_bg = QColor("#f5f5f5")
2171 statusbar_fg = QColor("#333333")
2172 toolbar_bg = QColor("#f5f5f5")
2173 toolbar_fg = QColor("#333333")
2174 splitter_handle_bg = QColor("#f5f5f5")
2175 lineedit_bg = QColor("#ffffff")
2176 lineedit_fg = QColor("#000000")
2177 lineedit_border = QColor("#cccccc")
2178 button_bg = QColor("#e1e1e1")
2179 button_fg = QColor("#000000")
2180 button_border = QColor("#cccccc")
2181 button_hover_bg = QColor("#d1d1d1")
2182 button_pressed_bg = QColor("#c1c1c1")
2183 editor_bg = QColor("#ffffff")
2184 editor_fg = QColor("#000000")
2185 linenum_area_bg = QColor("#eeeeee")
2186 linenum_fg = QColor("#666666")
2187 current_line_bg = QColor("#f0f0f0")
2188 chat_bg = QColor("#ffffff")
2189 chat_input_bg = QColor("#ffffff")
2190 chat_input_fg = QColor("#000000")
2191 bubble_user = QColor("#dcf8c6")
2192 bubble_assistant = QColor("#ffffff")
2193 bubble_border = QColor("#e0e0e0")
2194
2195 palette = QPalette()
2196 palette.setColor(QPalette.ColorRole.Window, main_bg)
2197 palette.setColor(QPalette.ColorRole.WindowText, main_fg)
2198 palette.setColor(QPalette.ColorRole.Base, editor_bg) # Used by QTextEdit background
2199 palette.setColor(QPalette.ColorRole.Text, editor_fg) # Used by QTextEdit text color
2200 palette.setColor(QPalette.ColorRole.Button, button_bg)
2201 palette.setColor(QPalette.ColorRole.ButtonText, button_fg)
2202 palette.setColor(QPalette.ColorRole.Highlight, QColor("#0078d4"))
2203 palette.setColor(QPalette.ColorRole.HighlightedText, QColor("#ffffff"))
2204 palette.setColor(QPalette.ColorRole.ToolTipBase, QColor("#ffffe1")) # Tooltip background
2205 palette.setColor(QPalette.ColorRole.ToolTipText, QColor("#000000")) # Tooltip text
2206
2207
2208 # Set palette for the application
2209 QApplication.setPalette(palette)
2210
2211 # Apply specific stylesheets
2212 self.setStyleSheet(f"""
2213 QMainWindow {{
2214 background-color: {main_bg.name()};
2215 color: {main_fg.name()};
2216 }}
2217 QMenuBar {{
2218 background-color: {menu_bg.name()};
2219 color: {menu_fg.name()};
2220 }}
2221 QMenuBar::item {{
2222 background-color: transparent;
2223 padding: 5px 10px;
2224 color: {menu_fg.name()};
2225 }}
2226 QMenuBar::item:selected {{
2227 background-color: {menu_selected_bg.name()};
2228 }}
2229 QMenu {{
2230 background-color: {menu_bg.name()};
2231 border: 1px solid {menu_border.name()};
2232 color: {menu_fg.name()};
2233 }}
2234 QMenu::item:selected {{
2235 background-color: {menu_selected_bg.name()};
2236 }}
2237 QTabWidget::pane {{
2238 border: 1px solid {tab_pane_border.name()};
2239 background: {tab_pane_bg.name()};
2240 }}
2241 QTabBar::tab {{
2242 background: {tab_bg.name()};
2243 color: {tab_fg.name()};
2244 padding: 5px;
2245 border: 1px solid {tab_pane_border.name()};
2246 border-bottom: none;
2247 min-width: 80px;
2248 }}
2249 QTabBar::tab:top, QTabBar::tab:bottom {{
2250 border-top-left-radius: 4px;
2251 border-top-right-radius: 4px;
2252 }}
2253 QTabBar::tab:left, QTabBar::tab:right {{
2254 border-top-left-radius: 4px;
2255 border-bottom-left-radius: 4px;
2256 }}
2257 QTabBar::tab:hover {{
2258 background: {tab_selected_bg.name()};
2259 }}
2260 QTabBar::tab:selected {{
2261 background: {tab_selected_bg.name()};
2262 border-bottom: 1px solid {tab_selected_bg.name()};
2263 }}
2264 QStatusBar {{
2265 background: {statusbar_bg.name()};
2266 color: {statusbar_fg.name()};
2267 border-top: 1px solid {menu_border.name()};
2268 }}
2269 QToolBar {{
2270 background: {toolbar_bg.name()};
2271 border: none;
2272 padding: 2px;
2273 spacing: 5px;
2274 }}
2275 QToolButton {{
2276 padding: 4px;
2277 border: 1px solid transparent; /* subtle border for hover */
2278 border-radius: 3px;
2279 }}
2280 QToolButton:hover {{
2281 background-color: {button_hover_bg.name()};
2282 border-color: {button_border.name()};
2283 }}
2284 QToolButton:pressed {{
2285 background-color: {button_pressed_bg.name()};
2286 border-color: {button_border.darker(150).name()};
2287 }}
2288 QSplitter::handle {{
2289 background: {splitter_handle_bg.name()};
2290 }}
2291 QSplitter::handle:hover {{
2292 background: {button_hover_bg.name()};
2293 }}
2294 QLineEdit {{
2295 background-color: {lineedit_bg.name()};
2296 color: {lineedit_fg.name()};
2297 border: 1px solid {lineedit_border.name()};
2298 padding: 4px;
2299 border-radius: 4px;
2300 }}
2301 QPushButton {{
2302 background-color: {button_bg.name()};
2303 color: {button_fg.name()};
2304 border: 1px solid {button_border.name()};
2305 border-radius: 4px;
2306 padding: 5px 10px;
2307 }}
2308 QPushButton:hover {{
2309 background-color: {button_hover_bg.name()};
2310 border-color: {button_border.darker(120).name()};
2311 }}
2312 QPushButton:pressed {{
2313 background-color: {button_pressed_bg.name()};
2314 border-color: {button_border.darker(150).name()};
2315 }}
2316 QScrollArea {{
2317 border: none;
2318 }}
2319 #chat_widget {{
2320 background-color: {chat_bg.name()};
2321 }}
2322 QTreeView {{
2323 background-color: {main_bg.name()};
2324 color: {main_fg.name()};
2325 border: 1px solid {tab_pane_border.name()}; /* Add border for separation */
2326 selection-background-color: {palette.color(QPalette.ColorRole.Highlight).name()};
2327 selection-color: {palette.color(QPalette.ColorRole.HighlightedText).name()};
2328 }}
2329 QTreeView::item:hover {{
2330 background-color: {menu_selected_bg.name()}; /* Subtle hover effect */
2331 }}
2332
2333 """)
2334
2335 # Apply theme colors to CodeEditor instances
2336 for i in range(self.tabs.count()):
2337 editor = self.tabs.widget(i)
2338 if isinstance(editor, CodeEditor):
2339 editor.set_theme_colors(editor_bg, editor_fg, linenum_area_bg, linenum_fg, current_line_bg)
2340
2341 # Apply theme colors to MessageWidget instances
2342 for i in range(self.chat_layout.count()):
2343 item = self.chat_layout.itemAt(i)
2344 if item and item.widget() and isinstance(item.widget(), MessageWidget):
2345 message_widget = item.widget()
2346 message_widget.apply_theme_colors(chat_bg, main_fg, bubble_user, bubble_assistant)
2347
2348 self.apply_font_size(self.font_size)
2349
2350 def update_status_bar_message(self, message: str, timeout_ms: int = 3000):
2351 """Displays a temporary message in the status bar."""
2352 if self.statusBar() and self.show_statusbar:
2353 self.statusBar().showMessage(message, timeout_ms)
2354 # The timeout handling by showMessage is often sufficient, but a dedicated timer
2355 # can be used for more complex clearing logic if needed.
2356 # self._status_timer.stop()
2357 # self._status_timer.start(timeout_ms)
2358
2359 def clear_status_bar_message(self):
2360 """Clears the temporary status bar message."""
2361 if self.statusBar() and self.show_statusbar:
2362 self.statusBar().clearMessage()
2363 self.update_status_bar() # Restore default status message (line/col)
2364
2365
2366 def apply_font_size(self, size: int):
2367 self.font_size = size
2368 self.settings["font_size"] = size
2369 save_settings(self.settings)
2370
2371 for i in range(self.tabs.count()):
2372 editor = self.tabs.widget(i)
2373 if isinstance(editor, CodeEditor):
2374 editor.set_font_size(size)
2375
2376 font = self.chat_input.font()
2377 font.setPointSize(size)
2378 self.chat_input.setFont(font)
2379 # Note: MessageWidget text font size is largely controlled by internal stylesheets (10pt, 9pt).
2380
2381 def update_status_bar(self):
2382 # Ensure status bar object exists before trying to use it
2383 if self.statusBar() and self.show_statusbar:
2384 # If there's a temporary message, don't overwrite it immediately
2385 if not self.statusBar().currentMessage():
2386 editor = self.get_current_editor()
2387 if editor:
2388 cursor = editor.textCursor()
2389 line = cursor.blockNumber() + 1
2390 col = cursor.columnNumber() + 1
2391 modified_status = "*" if editor.document().isModified() else ""
2392 file_name = os.path.basename(getattr(editor, 'file_path', 'Bez tytułu'))
2393 self.statusBar().showMessage(f"Plik: {file_name}{modified_status} | Linia: {line}, Kolumna: {col}")
2394 else:
2395 current_tab_index = self.tabs.currentIndex()
2396 if current_tab_index != -1:
2397 tab_title = self.tabs.tabText(current_tab_index)
2398 self.statusBar().showMessage(f"Gotowy - {tab_title}")
2399 else:
2400 self.statusBar().showMessage("Gotowy")
2401 elif self.statusBar(): # Status bar exists but is hidden
2402 self.statusBar().clearMessage() # Clear any lingering message
2403
2404
2405 def get_current_editor(self):
2406 current_widget = self.tabs.currentWidget()
2407 if current_widget and isinstance(current_widget, CodeEditor):
2408 return current_widget
2409 return None
2410
2411 def setup_editor_context_menu(self, editor):
2412 """Sets up a custom context menu for the CodeEditor instance."""
2413 # Disconnect any default context menu connection first if it existed
2414 try:
2415 editor.customContextMenuRequested.disconnect()
2416 except:
2417 pass # Ignore if not connected
2418
2419 editor.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
2420 editor.customContextMenuRequested.connect(lambda pos: self.show_editor_context_menu(pos, editor)) # Pass editor explicitly
2421
2422
2423 def show_editor_context_menu(self, position, editor):
2424 """Shows a custom context menu for the CodeEditor."""
2425 menu = QMenu(editor)
2426
2427 undo_action = menu.addAction("Cofnij")
2428 undo_action.setIcon(QIcon.fromTheme("edit-undo"))
2429 undo_action.setShortcut("Ctrl+Z")
2430 undo_action.triggered.connect(editor.undo)
2431 undo_action.setEnabled(editor.document().isUndoAvailable())
2432
2433 redo_action = menu.addAction("Ponów")
2434 redo_action.setIcon(QIcon.fromTheme("edit-redo"))
2435 redo_action.setShortcut("Ctrl+Y")
2436 redo_action.triggered.connect(editor.redo)
2437 redo_action.setEnabled(editor.document().isRedoAvailable())
2438
2439 menu.addSeparator()
2440
2441 cut_action = menu.addAction("Wytnij")
2442 cut_action.setIcon(QIcon.fromTheme("edit-cut"))
2443 cut_action.setShortcut("Ctrl+X")
2444 cut_action.triggered.connect(editor.cut)
2445 cut_action.setEnabled(editor.textCursor().hasSelection())
2446
2447 copy_action = menu.addAction("Kopiuj")
2448 copy_action.setIcon(QIcon.fromTheme("edit-copy"))
2449 copy_action.setShortcut("Ctrl+C")
2450 copy_action.triggered.connect(editor.copy)
2451 copy_action.setEnabled(editor.textCursor().hasSelection())
2452
2453 paste_action = menu.addAction("Wklej")
2454 paste_action.setIcon(QIcon.fromTheme("edit-paste"))
2455 paste_action.setShortcut("Ctrl+V")
2456 paste_action.triggered.connect(editor.paste)
2457 clipboard = QApplication.clipboard()
2458 paste_action.setEnabled(bool(clipboard.text()))
2459
2460 delete_action = menu.addAction("Usuń")
2461 delete_action.setIcon(QIcon.fromTheme("edit-delete"))
2462 delete_action.triggered.connect(lambda: editor.textCursor().removeSelectedText())
2463 delete_action.setEnabled(editor.textCursor().hasSelection())
2464
2465 menu.addSeparator()
2466
2467 select_all_action = menu.addAction("Zaznacz wszystko")
2468 select_all_action.setIcon(QIcon.fromTheme("edit-select-all"))
2469 select_all_action.setShortcut("Ctrl+A")
2470 select_all_action.triggered.connect(editor.selectAll)
2471
2472 menu.exec(editor.viewport().mapToGlobal(position))
2473
2474 def new_file(self):
2475 editor = CodeEditor()
2476 editor.document().contentsChanged.connect(self.update_status_bar)
2477 editor.cursorPositionChanged.connect(self.update_status_bar)
2478 editor.document().setModified(False) # New file starts as unmodified
2479
2480 self.setup_editor_context_menu(editor) # Setup the context menu
2481
2482 tab_title = "Bez tytułu"
2483 # Store file_path as None initially for unsaved files
2484 editor.file_path = None
2485 editor.setObjectName("editor_tab") # Add object name for styling
2486
2487 self.tabs.addTab(editor, tab_title)
2488 self.tabs.setCurrentWidget(editor)
2489
2490 self.apply_font_size(self.font_size)
2491 # Re-apply theme to ensure new editor gets correct colors
2492 self.apply_theme(self.theme)
2493
2494 self.update_recent_files(None) # Add placeholder for untitled file (or just update menu)
2495 self.update_status_bar()
2496 self.update_status_bar_message("Utworzono nowy plik 'Bez tytułu'.")
2497
2498
2499 def open_file_dialog(self):
2500 start_dir = self.workspace if self.workspace and os.path.exists(self.workspace) else QStandardPaths.standardLocations(QStandardPaths.StandardLocation.HomeLocation)[0]
2501
2502 file_path, _ = QFileDialog.getOpenFileName(self, "Otwórz plik", start_dir,
2503 "Wszystkie pliki (*);;"
2504 "Pliki Pythona (*.py);;"
2505 "Pliki tekstowe (*.txt);;"
2506 "Pliki CSS (*.css);;"
2507 "Pliki HTML (*.html *.htm);;"
2508 "Pliki JavaScript (*.js);;"
2509 "Pliki GML (*.gml);;"
2510 "Pliki JSON (*.json);;"
2511 "Pliki Markdown (*.md);;"
2512 "Pliki konfiguracyjne (*.ini *.cfg)")
2513 if file_path:
2514 self.open_file(file_path)
2515
2516 def open_file(self, file_path):
2517 """Opens a single file in a new tab."""
2518 if not file_path or not os.path.exists(file_path):
2519 QMessageBox.warning(self, "Błąd", f"Plik nie znaleziono:\n{file_path}")
2520 self.update_status_bar_message(f"Błąd: Plik nie znaleziono ({os.path.basename(file_path)})")
2521 return False
2522
2523 # Check if the file is already open in a tab
2524 for i in range(self.tabs.count()):
2525 editor = self.tabs.widget(i)
2526 if isinstance(editor, CodeEditor) and hasattr(editor, 'file_path') and editor.file_path == file_path:
2527 self.tabs.setCurrentIndex(i)
2528 self.update_status_bar_message(f"Przełączono na plik: {os.path.basename(file_path)}")
2529 return True
2530
2531 # If not already open, open the file
2532 try:
2533 with open(file_path, 'r', encoding='utf-8') as f:
2534 content = f.read()
2535
2536 editor = CodeEditor()
2537 editor.setPlainText(content)
2538
2539 editor.document().contentsChanged.connect(self.update_status_bar)
2540 editor.cursorPositionChanged.connect(self.update_status_bar)
2541 editor.document().setModified(False) # Newly opened file is not modified
2542
2543 self.setup_editor_context_menu(editor)
2544
2545 file_name = os.path.basename(file_path)
2546 tab_title = file_name
2547
2548 # Set syntax highlighting based on file extension
2549 # Get the actual CodeEditor highlighter object
2550 highlighter = None
2551 file_extension = os.path.splitext(file_path)[1].lower()
2552 if file_extension == '.py':
2553 highlighter = PythonHighlighter(editor.document())
2554 elif file_extension == '.css':
2555 highlighter = CSSHighlighter(editor.document())
2556 elif file_extension in ['.html', '.htm']:
2557 highlighter = HTMLHighlighter(editor.document())
2558 elif file_extension == '.js':
2559 highlighter = JSHighlighter(editor.document())
2560 elif file_extension == '.gml':
2561 highlighter = GMLHighlighter(editor.document())
2562 # Add more extensions/highlighters as needed
2563
2564 editor.highlighter = highlighter # Assign the created highlighter (can be None)
2565 if highlighter:
2566 highlighter.rehighlight()
2567
2568
2569 editor.file_path = file_path
2570 editor.setObjectName("editor_tab") # Add object name for styling
2571
2572 self.tabs.addTab(editor, tab_title)
2573 self.tabs.setCurrentWidget(editor)
2574
2575 self.update_recent_files(file_path) # Update recent files list
2576
2577 self.apply_font_size(self.font_size)
2578 self.apply_theme(self.theme) # Re-apply theme to ensure new editor has correct colors
2579
2580 self.update_status_bar()
2581 self.update_status_bar_message(f"Otworzono plik: {os.path.basename(file_path)}")
2582 return True
2583 except Exception as e:
2584 QMessageBox.warning(self, "Błąd", f"Nie można otworzyć pliku '{file_path}':\n{e}")
2585 self.update_status_bar_message(f"Błąd otwierania pliku: {e}")
2586 return False
2587
2588 def open_files(self, file_paths: list):
2589 """Opens a list of files."""
2590 if not file_paths:
2591 return
2592
2593 for file_path in file_paths:
2594 # Call the single open_file method for each file in the list
2595 self.open_file(file_path)
2596
2597
2598 def save_file(self):
2599 editor = self.get_current_editor()
2600 if not editor:
2601 self.update_status_bar_message("Brak aktywnego edytora do zapisania.")
2602 return False # No active editor
2603
2604 # If editor has a file_path, save to it
2605 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 '.'):
2606 # Check if the directory exists, or if it's a new file in the current directory
2607 file_path = editor.file_path
2608 else:
2609 # If no file_path (new file) or path is invalid, use Save As
2610 return self.save_file_as()
2611
2612 # Perform the save operation
2613 try:
2614 with open(file_path, 'w', encoding='utf-8') as f:
2615 f.write(editor.toPlainText())
2616
2617 editor.document().setModified(False)
2618 self.update_status_bar()
2619 self.update_recent_files(file_path)
2620 self.update_status_bar_message(f"Zapisano plik: {os.path.basename(file_path)}")
2621 return True
2622 except Exception as e:
2623 QMessageBox.warning(self, "Błąd", f"Nie można zapisać pliku '{file_path}':\n{e}")
2624 self.update_status_bar_message(f"Błąd zapisywania pliku: {e}")
2625 return False
2626
2627 def save_file_as(self):
2628 editor = self.get_current_editor()
2629 if not editor:
2630 self.update_status_bar_message("Brak aktywnego edytora do zapisania.")
2631 return False
2632
2633 initial_dir = self.workspace if self.workspace and os.path.exists(self.workspace) else QStandardPaths.standardLocations(QStandardPaths.StandardLocation.HomeLocation)[0]
2634 if hasattr(editor, 'file_path') and editor.file_path and os.path.dirname(editor.file_path):
2635 initial_dir = os.path.dirname(editor.file_path)
2636
2637 file_path, _ = QFileDialog.getSaveFileName(self, "Zapisz plik jako", initial_dir, "All Files (*);;Python Files (*.py);;Text Files (*.txt)")
2638
2639 if not file_path:
2640 self.update_status_bar_message("Zapisywanie anulowane.")
2641 return False
2642
2643 try:
2644 with open(file_path, 'w', encoding='utf-8') as f:
2645 f.write(editor.toPlainText())
2646
2647 file_name = os.path.basename(file_path)
2648 index = self.tabs.indexOf(editor)
2649 self.tabs.setTabText(index, file_name)
2650
2651 editor.file_path = file_path
2652 editor.document().setModified(False)
2653 self.update_status_bar()
2654 self.update_recent_files(file_path)
2655
2656 # Update syntax highlighting if extension changed
2657 highlighter = None
2658 file_extension = os.path.splitext(file_path)[1].lower()
2659 if file_extension == '.py':
2660 highlighter = PythonHighlighter(editor.document())
2661 elif file_extension == '.css':
2662 highlighter = CSSHighlighter(editor.document())
2663 elif file_extension in ['.html', '.htm']:
2664 highlighter = HTMLHighlighter(editor.document())
2665 elif file_extension == '.js':
2666 highlighter = JSHighlighter(editor.document())
2667 elif file_extension == '.gml':
2668 highlighter = GMLHighlighter(editor.document())
2669 # Add more extensions/highlighters as needed
2670 editor.highlighter = highlighter # Assign the new highlighter
2671 editor.document().clearFormats() # Clear old highlighting before applying new one
2672 if highlighter:
2673 highlighter.rehighlight()
2674
2675
2676 self.update_status_bar_message(f"Zapisano plik jako: {os.path.basename(file_path)}")
2677 return True
2678 except Exception as e:
2679 QMessageBox.warning(self, "Błąd", f"Nie można zapisać pliku '{file_path}':\n{e}")
2680 self.update_status_bar_message(f"Błąd zapisywania pliku jako: {e}")
2681 return False
2682
2683 def close_tab(self, index):
2684 editor = self.tabs.widget(index)
2685 if editor:
2686 if isinstance(editor, CodeEditor) and editor.document().isModified():
2687 file_name = os.path.basename(getattr(editor, 'file_path', 'Bez tytułu'))
2688 reply = QMessageBox.question(self, "Zapisz zmiany", f"Czy chcesz zapisać zmiany w '{file_name}' przed zamknięciem?",
2689 QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No | QMessageBox.StandardButton.Cancel)
2690 if reply == QMessageBox.StandardButton.Yes:
2691 # Need to save the tab before closing. If save fails/cancelled, stop closing.
2692 # Temporarily set this tab as current to make save_file work on it.
2693 original_index = self.tabs.currentIndex()
2694 self.tabs.setCurrentIndex(index)
2695 save_success = self.save_file()
2696 self.tabs.setCurrentIndex(original_index) # Restore original index
2697 if not save_success:
2698 return # Stop closing if save failed/cancelled
2699 elif reply == QMessageBox.StandardButton.Cancel:
2700 return # User cancelled closing
2701 # If reply is No or Yes (and save succeeded), continue closing
2702
2703 tab_name = self.tabs.tabText(index) # Get name *before* removing tab
2704 self.tabs.removeTab(index)
2705 editor.deleteLater()
2706 self.update_status_bar() # Update status bar as current tab might change
2707 self.update_status_bar_message(f"Zamknięto zakładkę: {tab_name}")
2708
2709 def update_recent_files(self, file_path):
2710 """Updates the list of recent files and the menu."""
2711 # Note: None is passed for untitled files, which shouldn't be added to recent.
2712 if file_path and isinstance(file_path, str) and os.path.exists(file_path):
2713 # Normalize path for consistency
2714 file_path = os.path.normpath(file_path)
2715 # Remove if already exists to move it to the top
2716 if file_path in self.recent_files:
2717 self.recent_files.remove(file_path)
2718
2719 # Add to the beginning
2720 self.recent_files.insert(0, file_path)
2721
2722 # Trim the list if it exceeds max size
2723 if len(self.recent_files) > RECENT_FILES_MAX:
2724 self.recent_files = self.recent_files[:RECENT_FILES_MAX]
2725
2726 # Save the updated list
2727 self.settings["recent_files"] = self.recent_files
2728 save_settings(self.settings)
2729
2730 # Always update the menu after potentially changing the list
2731 self.update_recent_files_menu()
2732
2733
2734 def update_recent_files_menu(self, menu: QMenu = None):
2735 """Updates the 'Ostatnie pliki' menu."""
2736 # Find the menu if not passed
2737 if menu is None:
2738 # Iterate through the menu bar actions to find the "Plik" menu
2739 for file_action in self.menuBar().actions():
2740 if file_action.text() == "📄 Plik" and file_action.menu():
2741 # Iterate through the "Plik" menu actions to find the "Ostatnie pliki" submenu
2742 for sub_action in file_action.menu().actions():
2743 # Use object name or text for lookup
2744 # Ensure the action has a menu before accessing it
2745 if sub_action.text() == "Ostatnie pliki" and sub_action.menu():
2746 menu = sub_action.menu()
2747 break
2748 if menu: break # Stop searching once found
2749
2750 if not menu: # Menu not found, cannot update
2751 print("Warning: Recent files menu not found.")
2752 return
2753
2754 menu.clear()
2755 # Filter out non-existent files from the stored list before updating the menu
2756 valid_recent_files = [f for f in self.recent_files if os.path.exists(f)]
2757
2758 if not valid_recent_files:
2759 menu.setEnabled(False)
2760 # Add a dummy action
2761 disabled_action = QAction("Brak ostatnio otwieranych plików", self)
2762 disabled_action.setEnabled(False)
2763 menu.addAction(disabled_action)
2764 else:
2765 menu.setEnabled(True)
2766 # Use the filtered list to populate the menu
2767 for file_path in valid_recent_files:
2768 action = QAction(os.path.basename(file_path), self)
2769 # Store the file path in the action's data
2770 action.setData(file_path)
2771 action.triggered.connect(self.open_recent_file)
2772 menu.addAction(action)
2773
2774 # Update settings with the cleaned list
2775 if valid_recent_files != self.recent_files:
2776 self.recent_files = valid_recent_files
2777 self.settings["recent_files"] = self.recent_files
2778 save_settings(self.settings)
2779
2780
2781 def open_recent_file(self):
2782 action = self.sender()
2783 if action:
2784 file_path = action.data()
2785 if file_path and isinstance(file_path, str) and os.path.exists(file_path):
2786 self.open_file(file_path)
2787 else:
2788 QMessageBox.warning(self, "Błąd", f"Ostatni plik nie znaleziono:\n{file_path}")
2789 self.update_status_bar_message(f"Błąd: Ostatni plik nie znaleziono ({os.path.basename(file_path)})")
2790 # Remove invalid file from recent list
2791 if file_path in self.recent_files:
2792 self.recent_files.remove(file_path)
2793 self.settings["recent_files"] = self.recent_files
2794 save_settings(self.settings)
2795 self.update_recent_files_menu()
2796
2797
2798 def open_workspace(self):
2799 start_dir = self.workspace if self.workspace and os.path.exists(self.workspace) else QStandardPaths.standardLocations(QStandardPaths.StandardLocation.HomeLocation)[0]
2800
2801 dir_path = QFileDialog.getExistingDirectory(self, "Otwórz Obszar Roboczy", start_dir)
2802 if dir_path:
2803 self.workspace = dir_path
2804 self.file_explorer.model.setRootPath(dir_path)
2805 self.file_explorer.setRootIndex(self.file_explorer.model.index(dir_path))
2806 self.settings["workspace"] = dir_path
2807 save_settings(self.settings)
2808 self.update_status_bar_message(f"Zmieniono obszar roboczy na: {dir_path}")
2809
2810 # Standard editor actions (delegated to current editor)
2811 def undo(self):
2812 editor = self.get_current_editor()
2813 if editor:
2814 editor.undo()
2815 self.update_status_bar_message("Cofnięto ostatnią operację.")
2816
2817
2818 def redo(self):
2819 editor = self.get_current_editor()
2820 if editor:
2821 editor.redo()
2822 self.update_status_bar_message("Ponowiono ostatnią operację.")
2823
2824
2825 def cut(self):
2826 editor = self.get_current_editor()
2827 if editor:
2828 editor.cut()
2829 self.update_status_bar_message("Wycięto zaznaczenie.")
2830
2831
2832 def copy(self):
2833 editor = self.get_current_editor()
2834 if editor:
2835 editor.copy()
2836 self.update_status_bar_message("Skopiowano zaznaczenie.")
2837
2838
2839 def paste(self):
2840 editor = self.get_current_editor()
2841 if editor:
2842 editor.paste()
2843 self.update_status_bar_message("Wklejono zawartość schowka.")
2844
2845
2846 # Basic find/replace (delegated)
2847 def find(self):
2848 editor = self.get_current_editor()
2849 if editor:
2850 text, ok = QInputDialog.getText(self, "Znajdź", "Szukaj:")
2851 if ok and text:
2852 cursor = editor.textCursor()
2853 # Find from current position first
2854 if not editor.find(text):
2855 # If not found from current position, try from the beginning
2856 cursor.movePosition(QTextCursor.MoveOperation.Start)
2857 editor.setTextCursor(cursor)
2858 if not editor.find(text):
2859 QMessageBox.information(self, "Znajdź", f"'{text}' nie znaleziono.")
2860 self.update_status_bar_message(f"Nie znaleziono '{text}'.")
2861 else:
2862 self.update_status_bar_message(f"Znaleziono pierwsze wystąpienie '{text}'.")
2863 else:
2864 self.update_status_bar_message(f"Znaleziono następne wystąpienie '{text}'.")
2865
2866
2867 def replace(self):
2868 editor = self.get_current_editor()
2869 if editor:
2870 find_text, ok1 = QInputDialog.getText(self, "Zamień", "Szukaj:")
2871 if ok1 and find_text:
2872 replace_text, ok2 = QInputDialog.getText(self, "Zamień", "Zamień na:")
2873 if ok2:
2874 # Simple text replacement (replaces all occurrences)
2875 text = editor.toPlainText()
2876 # Count occurrences before replacing
2877 occurrences = text.count(find_text)
2878 if occurrences > 0:
2879 new_text = text.replace(find_text, replace_text)
2880 editor.setPlainText(new_text)
2881 editor.document().setModified(True)
2882 self.update_status_bar()
2883 self.update_status_bar_message(f"Zamieniono {occurrences} wystąpień '{find_text}'.")
2884 else:
2885 QMessageBox.information(self, "Zamień", f"'{find_text}' nie znaleziono.")
2886 self.update_status_bar_message(f"Nie znaleziono '{find_text}' do zamiany.")
2887
2888
2889 # Code execution
2890 def run_code(self):
2891 editor = self.get_current_editor()
2892 if editor:
2893 code = editor.toPlainText()
2894 if not code.strip():
2895 self.add_message("assistant", "Brak kodu do uruchomienia.")
2896 self.update_status_bar_message("Brak kodu do uruchomienia.")
2897 return
2898
2899 # Add user message to chat history
2900 self.add_message("user", f"Proszę uruchomić ten kod:\n```\n{code}\n```")
2901
2902 try:
2903 # Use a temporary file with a .py extension to allow python interpreter to identify it
2904 # Ensure the temp directory exists and has write permissions
2905 temp_dir = tempfile.gettempdir()
2906 if not os.access(temp_dir, os.W_OK):
2907 QMessageBox.warning(self, "Błąd", f"Brak uprawnień zapisu w katalogu tymczasowym: {temp_dir}")
2908 self.add_message("assistant", f"Błąd: Brak uprawnień zapisu w katalogu tymczasowym.")
2909 self.update_status_bar_message("Błąd: Brak uprawnień zapisu w katalogu tymczasowym.")
2910 return
2911
2912 # Ensure the temp file has a .py extension for the interpreter
2913 temp_file = tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False, encoding='utf-8', dir=temp_dir)
2914 temp_file_path = temp_file.name
2915 temp_file.write(code)
2916 temp_file.close()
2917
2918
2919 self.add_message("assistant", "⚙️ Uruchamiam kod...", {"type": "placeholder"})
2920 self.update_status_bar_message(f"Uruchamiam kod ({os.path.basename(getattr(editor, 'file_path', 'Bez tytułu'))})")
2921
2922
2923 # Run the code in a separate process
2924 # Using sys.executable ensures we use the same Python interpreter running the app
2925 process = subprocess.Popen([sys.executable, temp_file_path],
2926 stdout=subprocess.PIPE,
2927 stderr=subprocess.PIPE,
2928 text=True, # Decode output as text
2929 encoding='utf-8',
2930 cwd=os.path.dirname(temp_file_path)) # Set working directory
2931
2932
2933 stdout = ""
2934 stderr = ""
2935 try:
2936 # Use a slightly longer timeout, maybe 30 seconds?
2937 # Or make it configurable. Let's stick to 10 for now.
2938 timeout_seconds = 10
2939 stdout, stderr = process.communicate(timeout=timeout_seconds)
2940 process.wait() # Ensure process is truly finished
2941 except subprocess.TimeoutExpired:
2942 process.kill() # Kill the process if it times out
2943 process.wait() # Wait for it to be killed
2944 stderr = f"Czas wykonania kodu minął po {timeout_seconds} sekundach. Proces został przerwany.\n{stderr}"
2945 self.update_status_bar_message(f"Wykonanie kodu przekroczyło limit czasu ({timeout_seconds}s).")
2946
2947 except Exception as proc_err:
2948 stderr = f"Błąd wewnętrzny podczas uruchamiania procesu: {proc_err}\n{stderr}"
2949 self.update_status_bar_message(f"Błąd wewnętrzny uruchamiania kodu: {proc_err}")
2950
2951
2952 # Clean up the temporary file
2953 try:
2954 os.unlink(temp_file_path)
2955 except OSError as e:
2956 print(f"Błąd usuwania pliku tymczasowego {temp_file_path}: {e}")
2957 # Decide if this should be a user-visible error, probably not critical
2958
2959 # Remove the placeholder message
2960 self.remove_last_message_widget()
2961
2962 # Display the output and errors in the chat
2963 output_message = ""
2964 if stdout:
2965 output_message += f"Wyjście:\n```text\n{stdout.strip()}\n```\n" # Use 'text' for plain output highlighting
2966 if stderr:
2967 output_message += f"Błędy:\n```text\n{stderr.strip()}\n```\n"
2968
2969 if output_message:
2970 self.add_message("assistant", f"Wykonanie kodu zakończone:\n{output_message}")
2971 self.update_status_bar_message("Wykonanie kodu zakończone.")
2972 else:
2973 self.add_message("assistant", "Kod wykonano bez wyjścia i błędów.")
2974 self.update_status_bar_message("Kod wykonano bez wyjścia/błędów.")
2975
2976 except FileNotFoundError:
2977 self.remove_last_message_widget()
2978 self.add_message("assistant", f"Błąd: Interpreter Pythona '{sys.executable}' nie znaleziono.")
2979 self.update_status_bar_message(f"Błąd: Interpreter Pythona nie znaleziono.")
2980 except Exception as e:
2981 self.remove_last_message_widget()
2982 self.add_message("assistant", f"Błąd wykonania kodu: {str(e)}")
2983 print(f"Błąd uruchamiania kodu: {traceback.format_exc()}")
2984 self.update_status_bar_message(f"Błąd wykonania kodu: {e}")
2985
2986 # Visibility toggles (Fixed AttributeErrors by using stored action references)
2987 def toggle_sidebar(self):
2988 self.show_sidebar = not self.show_sidebar
2989 self.sidebar.setVisible(self.show_sidebar)
2990 self.settings["show_sidebar"] = self.show_sidebar
2991 save_settings(self.settings)
2992 if self.action_toggle_sidebar: # Check if reference exists
2993 self.action_toggle_sidebar.setChecked(self.show_sidebar)
2994 self.update_status_bar_message(f"Pasek boczny: {'widoczny' if self.show_sidebar else 'ukryty'}")
2995
2996 def toggle_toolbar(self):
2997 self.show_toolbar = not self.show_toolbar
2998 self.toolbar.setVisible(self.show_toolbar)
2999 self.settings["show_toolbar"] = self.show_toolbar
3000 save_settings(self.settings)
3001 if self.action_toggle_toolbar: # Check if reference exists
3002 self.action_toggle_toolbar.setChecked(self.show_toolbar)
3003 self.update_status_bar_message(f"Pasek narzędzi: {'widoczny' if self.show_toolbar else 'ukryty'}")
3004
3005 def toggle_statusbar(self):
3006 self.show_statusbar = not self.show_statusbar
3007 if self.statusBar():
3008 self.statusBar().setVisible(self.show_statusbar)
3009 self.settings["show_statusbar"] = self.show_statusbar
3010 save_settings(self.settings)
3011 if self.action_toggle_statusbar: # Check if reference exists
3012 self.action_toggle_statusbar.setChecked(self.show_statusbar)
3013 # Status bar message won't appear if status bar is now hidden
3014 if self.show_statusbar:
3015 self.update_status_bar_message(f"Pasek stanu: {'widoczny' if self.show_statusbar else 'ukryty'}")
3016
3017
3018 def show_settings_dialog(self):
3019 # Pass active model configurations and current settings
3020 dialog = SettingsDialog(ACTIVE_MODELS_CONFIG, self.settings, self)
3021 if dialog.exec() == QDialog.DialogCode.Accepted:
3022 # Update model and API type
3023 selected_api_type, selected_identifier = dialog.get_selected_model_config()
3024
3025 model_changed = (selected_api_type != self.current_api_type or selected_identifier != self.current_model_identifier)
3026
3027 self.current_api_type = selected_api_type
3028 self.current_model_identifier = selected_identifier
3029 self.settings["api_type"] = self.current_api_type
3030 self.settings["model_identifier"] = self.current_model_identifier
3031
3032 # Update Mistral key
3033 if HAS_MISTRAL:
3034 new_mistral_key = dialog.get_mistral_api_key()
3035 key_changed = (new_mistral_key != self.mistral_api_key)
3036 self.mistral_api_key = new_mistral_key
3037 self.settings["mistral_api_key"] = self.mistral_api_key
3038 else:
3039 key_changed = False # Key couldn't change if Mistral isn't supported
3040
3041 save_settings(self.settings)
3042
3043 # Inform user about settings changes
3044 status_messages = []
3045 if model_changed:
3046 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)
3047 status_messages.append(f"Model AI zmieniono na '{display_name}'.")
3048 if key_changed:
3049 status_messages.append(f"Ustawienia klucza API Mistral zaktualizowane.")
3050
3051 new_theme = dialog.get_selected_theme()
3052 if new_theme != self.theme:
3053 self.apply_theme(new_theme)
3054 status_messages.append(f"Motyw zmieniono na '{new_theme}'.")
3055
3056
3057 new_font_size = dialog.get_font_size()
3058 if new_font_size != self.font_size:
3059 self.apply_font_size(new_font_size)
3060 status_messages.append(f"Rozmiar czcionki zmieniono na {new_font_size}.")
3061
3062
3063 ui_visibility = dialog.get_ui_visibility()
3064 if ui_visibility["show_sidebar"] != self.show_sidebar:
3065 self.toggle_sidebar() # This call updates settings and status bar message internally
3066 if ui_visibility["show_toolbar"] != self.show_toolbar:
3067 self.toggle_toolbar() # This call updates settings and status bar message internally
3068 if ui_visibility["show_statusbar"] != self.show_statusbar:
3069 self.toggle_statusbar() # This call updates settings and status bar message internally
3070
3071
3072 if status_messages:
3073 self.update_status_bar_message("Ustawienia zaktualizowane: " + "; ".join(status_messages), 5000)
3074 else:
3075 self.update_status_bar_message("Ustawienia zapisano.")
3076
3077
3078 def show_about(self):
3079 QMessageBox.about(self, "Informacje o Edytorze Kodu AI",
3080 "<h2>Edytor Kodu AI</h2>"
3081 "<p>Prosty edytor kodu z integracją czatu AI.</p>"
3082 "<p>Wykorzystuje API Google Gemini i Mistral do pomocy AI.</p>"
3083 "<p>Wersja 1.1</p>"
3084 "<p>Stworzony przy użyciu PyQt6, google-generativeai i mistralai</p>")
3085 self.update_status_bar_message("Wyświetlono informacje o programie.")
3086
3087
3088 # --- Chat Message Handling ---
3089 def add_message(self, role: str, content: str, metadata: dict = None):
3090 # Add message to internal history (excluding placeholders, errors, empty)
3091 if metadata is None or metadata.get("type") not in ["placeholder", "error", "empty_response"]:
3092 # Store clean history for API calls
3093 self.chat_history.append((role, content, metadata))
3094 # Limit history size
3095 HISTORY_LIMIT = 20 # Keep a reasonable history size
3096 if len(self.chat_history) > HISTORY_LIMIT:
3097 self.chat_history = self.chat_history[len(self.chat_history) - HISTORY_LIMIT:]
3098
3099 message_widget = MessageWidget(role, content, metadata=metadata, parent=self.chat_widget)
3100
3101 # Apply current theme colors
3102 if self.theme == "dark":
3103 bubble_user_color = QColor("#3a3a3a")
3104 bubble_assistant_color = QColor("#2d2d2d")
3105 main_fg_color = QColor("#ffffff")
3106 else: # light theme
3107 bubble_user_color = QColor("#dcf8c6")
3108 bubble_assistant_color = QColor("#ffffff")
3109 main_fg_color = QColor("#333333")
3110
3111 message_widget.apply_theme_colors(self.chat_widget.palette().color(QPalette.ColorRole.Window), main_fg_color, bubble_user_color, bubble_assistant_color)
3112
3113 # Add the widget to the chat layout, keeping the stretch item at the end
3114 # Find the stretch item
3115 stretch_item = None
3116 if self.chat_layout.count() > 0:
3117 last_item = self.chat_layout.itemAt(self.chat_layout.count() - 1)
3118 if last_item and last_item.spacerItem():
3119 stretch_item = self.chat_layout.takeAt(self.chat_layout.count() - 1)
3120
3121 self.chat_layout.addWidget(message_widget)
3122
3123 # Re-add the stretch item if found
3124 if stretch_item:
3125 self.chat_layout.addItem(stretch_item)
3126 elif self.chat_layout.count() == 1: # If this is the very first message and no stretch was added yet
3127 self.chat_layout.addStretch(1)
3128
3129
3130 if message_widget.is_placeholder:
3131 self.current_placeholder_widget = message_widget
3132
3133 QTimer.singleShot(50, self.scroll_chat_to_bottom)
3134
3135 def remove_last_message_widget(self):
3136 if self.chat_layout.count() > 1: # Need at least 1 widget + 1 stretch
3137 widget_to_remove = None
3138 # Find the last widget item before the stretch
3139 for i in reversed(range(self.chat_layout.count())):
3140 item = self.chat_layout.itemAt(i)
3141 if item and item.widget():
3142 widget_to_remove = item.widget()
3143 break
3144
3145 if widget_to_remove:
3146 self.chat_layout.removeWidget(widget_to_remove)
3147 widget_to_remove.deleteLater()
3148
3149 self.current_placeholder_widget = None
3150
3151 def scroll_chat_to_bottom(self):
3152 self.chat_scroll.verticalScrollBar().setValue(self.chat_scroll.verticalScrollBar().maximum())
3153
3154 def send_message(self):
3155 if self._is_processing:
3156 return
3157
3158 msg = self.chat_input.text().strip()
3159 if not msg:
3160 return
3161
3162 self._is_processing = True
3163 self.add_message("user", msg, None)
3164
3165 self.chat_input.clear()
3166 self.chat_input.setPlaceholderText("Czekam na odpowiedź...")
3167 self.send_button.setEnabled(False)
3168 self.chat_input.setEnabled(False)
3169 self.update_status_bar_message("Wysyłam zapytanie do modelu AI...")
3170
3171
3172 # Stop any running worker thread
3173 if self.worker_thread and self.worker_thread.isRunning():
3174 print("Stopping existing worker thread...")
3175 self.worker.stop()
3176 if not self.worker_thread.wait(1000): # Wait up to 1 second
3177 print("Worker thread did not stop cleanly, terminating.")
3178 self.worker_thread.terminate()
3179 self.worker_thread.wait()
3180 print("Worker thread stopped.")
3181
3182 # Determine which worker to use based on selected API type
3183 api_type = self.current_api_type
3184 model_identifier = self.current_model_identifier
3185 worker = None
3186
3187 if api_type == "gemini" and HAS_GEMINI:
3188 api_key = GEMINI_API_KEY_GLOBAL # Use the globally loaded Gemini key
3189 if not api_key:
3190 self.handle_error("Klucz API Google Gemini nie znaleziono. Ustaw go w pliku .api_key lub w ustawieniach.")
3191 self._is_processing = False # Reset state
3192 self.send_button.setEnabled(True)
3193 self.chat_input.setEnabled(True)
3194 self.chat_input.setPlaceholderText("Wpisz wiadomość tutaj...")
3195 return
3196 worker = GeminiWorker(api_key, msg, list(self.chat_history), model_identifier)
3197
3198 elif api_type == "mistral" and HAS_MISTRAL:
3199 api_key = self.mistral_api_key # Use the key from settings
3200 if not api_key:
3201 self.handle_error("Klucz API Mistral nie ustawiono w ustawieniach.")
3202 self._is_processing = False # Reset state
3203 self.send_button.setEnabled(True)
3204 self.chat_input.setEnabled(True)
3205 self.chat_input.setPlaceholderText("Wpisz wiadomość tutaj...")
3206 return
3207 worker = MistralWorker(api_key, msg, list(self.chat_history), model_identifier)
3208 else:
3209 # Check if the selected model type is "none" (fallback when no APIs are installed)
3210 if api_type == "none":
3211 self.handle_error("Brak dostępnych modeli AI. Proszę zainstalować obsługiwane biblioteki API.")
3212 else:
3213 self.handle_error(f"Wybrany model '{model_identifier}' (API '{api_type}') nie jest obsługiwany lub brakuje zainstalowanych bibliotek.")
3214
3215 self._is_processing = False # Reset state
3216 self.send_button.setEnabled(True)
3217 self.chat_input.setEnabled(True)
3218 self.chat_input.setPlaceholderText("Wpisz wiadomość tutaj...")
3219 return
3220
3221
3222 self.worker = worker # Store the current worker
3223 self.worker_thread = QThread()
3224 self.worker.moveToThread(self.worker_thread)
3225
3226 self.worker.response_chunk.connect(self.handle_response_chunk)
3227 self.worker.response_complete.connect(self.handle_response_complete)
3228 self.worker.error.connect(self.handle_error)
3229
3230 self.worker_thread.started.connect(self.worker.run)
3231 self.worker.finished.connect(self.worker_thread.quit)
3232 self.worker.finished.connect(self.worker.deleteLater)
3233 self.worker_thread.finished.connect(self.worker_thread.deleteLater)
3234
3235 self.current_response_content = ""
3236 display_name = next((name for t, i, name in ACTIVE_MODELS_CONFIG if t == api_type and i == model_identifier), model_identifier)
3237 self.add_message("assistant", f"⚙️ Przetwarzam z użyciem '{display_name}'...", {"type": "placeholder"})
3238
3239 self.worker_thread.start()
3240
3241 def handle_response_chunk(self, chunk: str):
3242 self.current_response_content += chunk
3243 if self.current_placeholder_widget:
3244 self.current_placeholder_widget.update_placeholder_text(self.current_response_content)
3245 self.scroll_chat_to_bottom()
3246
3247 def handle_response_complete(self):
3248 if self.current_placeholder_widget:
3249 self.remove_last_message_widget()
3250
3251 final_content = self.current_response_content.strip()
3252 if final_content:
3253 self.add_message("assistant", self.current_response_content, None)
3254 else:
3255 self.add_message("assistant", "Otrzymano pustą odpowiedź od modelu.", {"type": "empty_response"})
3256
3257 self.current_response_content = ""
3258 self.send_button.setEnabled(True)
3259 self.chat_input.setEnabled(True)
3260 self.chat_input.setPlaceholderText("Wpisz wiadomość tutaj...")
3261 self.chat_input.setFocus()
3262 self._is_processing = False
3263 self.scroll_chat_to_bottom()
3264 self.update_status_bar_message("Odpowiedź AI zakończona.")
3265
3266
3267 def handle_error(self, error_message: str):
3268 if self.current_placeholder_widget:
3269 self.remove_last_message_widget()
3270
3271 error_styled_message = f"<span style='color: #cc0000; font-weight: bold;'>Błąd API:</span> {error_message}"
3272 self.add_message("assistant", error_styled_message, {"type": "error"})
3273
3274 self.send_button.setEnabled(True)
3275 self.chat_input.setEnabled(True)
3276 self.chat_input.setPlaceholderText("Wpisz wiadomość tutaj...")
3277 self.chat_input.setFocus()
3278 self._is_processing = False
3279 self.current_response_content = ""
3280 self.scroll_chat_to_bottom()
3281 self.update_status_bar_message(f"Błąd API: {error_message[:50]}...") # Truncate message for status bar
3282
3283 def closeEvent(self, event):
3284 # Stop the worker thread
3285 if self.worker_thread and self.worker_thread.isRunning():
3286 self.worker.stop()
3287 if not self.worker_thread.wait(3000): # Wait up to 3 seconds
3288 print("Worker thread did not finish after stop signal, terminating.")
3289 self.worker_thread.terminate()
3290 self.worker_thread.wait()
3291
3292 # Check for unsaved files
3293 unsaved_tabs = []
3294 for i in range(self.tabs.count()):
3295 editor = self.tabs.widget(i)
3296 if isinstance(editor, CodeEditor) and editor.document().isModified():
3297 unsaved_tabs.append(i)
3298
3299 if unsaved_tabs:
3300 # Ask about saving all unsaved tabs
3301 reply = QMessageBox.question(self, "Zapisz zmiany", f"Masz niezapisane zmiany w {len(unsaved_tabs)} plikach.\nCzy chcesz zapisać zmiany przed wyjściem?",
3302 QMessageBox.StandardButton.SaveAll | QMessageBox.StandardButton.Discard | QMessageBox.StandardButton.Cancel)
3303
3304 if reply == QMessageBox.StandardButton.Cancel:
3305 event.ignore() # Stop closing
3306 self.update_status_bar_message("Zamykanie anulowane.")
3307 return
3308 elif reply == QMessageBox.StandardButton.SaveAll:
3309 save_success = True
3310 # Iterate over unsaved tabs and try to save each one
3311 for index in unsaved_tabs:
3312 editor = self.tabs.widget(index) # Get the editor again, index might change if tabs are closed during save
3313 if editor and isinstance(editor, CodeEditor) and editor.document().isModified():
3314 # Temporarily switch to the tab to make save_file work correctly
3315 original_index = self.tabs.currentIndex()
3316 self.tabs.setCurrentIndex(index)
3317 current_save_success = self.save_file() # This updates status bar
3318 self.tabs.setCurrentIndex(original_index) # Restore original index
3319
3320 if not current_save_success:
3321 save_success = False
3322 # If any save fails/cancelled, stop the whole process
3323 event.ignore()
3324 self.update_status_bar_message("Zamykanie przerwane z powodu błędu zapisu.")
3325 return # Stop the loop and closing process
3326
3327 if save_success:
3328 event.accept() # Continue closing if all saves succeeded
3329 else:
3330 # This path should ideally not be reached due to the 'return' above,
3331 # but as a safeguard:
3332 event.ignore()
3333 self.update_status_bar_message("Zamykanie przerwane z powodu błędu zapisu.") # Redundant but safe
3334 return
3335
3336 elif reply == QMessageBox.StandardButton.Discard:
3337 # Discard changes and close all tabs
3338 # We need to close tabs in reverse order to avoid index issues
3339 for i in reversed(unsaved_tabs):
3340 self.tabs.removeTab(i) # Remove tab without saving check
3341
3342 event.accept() # Continue closing
3343 self.update_status_bar_message("Zamknięto pliki bez zapisywania zmian.")
3344
3345
3346 else:
3347 # No unsaved tabs, just accept the close event
3348 event.accept()
3349
3350
3351# --- Main Application Entry Point ---
3352
3353if __name__ == "__main__":
3354 # Enable High DPI scaling
3355 QGuiApplication.setHighDpiScaleFactorRoundingPolicy(Qt.HighDpiScaleFactorRoundingPolicy.PassThrough)
3356
3357 app = QApplication(sys.argv)
3358 app.setApplicationName("Edytor Kodu AI")
3359 app.setOrganizationName("YourOrganization")
3360
3361 # Initialize icon theme if available
3362 # QIcon.setThemeName("breeze-dark") # Example theme, requires icon theme installed
3363
3364 try:
3365 main_window = CodeEditorWindow()
3366 main_window.show()
3367 sys.exit(app.exec())
3368 except Exception as app_error:
3369 print(f"Wystąpił nieoczekiwany błąd podczas uruchamiania aplikacji:\n{app_error}")
3370 traceback.print_exc()
3371 # Ensure message box is shown even if app failed to initialize fully
3372 try:
3373 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.")
3374 except Exception as mb_error:
3375 print(f"Could not show error message box: {mb_error}")
3376 sys.exit(1)