From 5aff824a5d30e0b58f87b9a9982c338868778d07 Mon Sep 17 00:00:00 2001 From: banagale Date: Tue, 20 Aug 2024 15:03:54 -0700 Subject: [PATCH 1/3] Allow selection of individual symbols in python on a single file --- filekitty/app.py | 254 +++++++++++++++++++++++++++-------------------- 1 file changed, 148 insertions(+), 106 deletions(-) diff --git a/filekitty/app.py b/filekitty/app.py index d6fb804..e3589ff 100644 --- a/filekitty/app.py +++ b/filekitty/app.py @@ -1,11 +1,14 @@ +import ast import os -from PyQt5.QtCore import QSettings, QTimer, Qt -from PyQt5.QtGui import QIcon, QGuiApplication, QKeySequence, QDragEnterEvent, QDropEvent +from PyQt5.QtCore import Qt +from PyQt5.QtGui import QIcon, QGuiApplication, QKeySequence from PyQt5.QtWidgets import ( QApplication, QWidget, QFileDialog, QVBoxLayout, QPushButton, QTextEdit, - QLabel, QListWidget, QDialog, QLineEdit, QHBoxLayout, QAction, QMenuBar, - QGraphicsColorizeEffect + QLabel, QListWidget, QDialog, QAction, QMenuBar +) +from PyQt5.QtWidgets import ( + QListWidgetItem ) ICON_PATH = 'assets/icon/FileKitty-icon.png' @@ -18,38 +21,50 @@ def __init__(self, parent=None): self.initUI() def initUI(self): - layout = QVBoxLayout() + layout = QVBoxLayout(self) + self.setLayout(layout) - self.pathEdit = QLineEdit(self) - self.pathEdit.setPlaceholderText("Enter or select default file path...") - layout.addWidget(self.pathEdit) - btnBrowse = QPushButton("Browse...") - btnBrowse.clicked.connect(self.browsePath) - layout.addWidget(btnBrowse) +class SelectClassesFunctionsDialog(QDialog): + def __init__(self, classes, functions, selected_items=None, parent=None): + super(SelectClassesFunctionsDialog, self).__init__(parent) + self.setWindowTitle('Select Classes/Functions') + self.classes = classes + self.functions = functions + self.selected_items = selected_items if selected_items is not None else [] + self.initUI() - btnLayout = QHBoxLayout() - btnSave = QPushButton('Save') - btnCancel = QPushButton('Cancel') - btnLayout.addWidget(btnSave) - btnLayout.addWidget(btnCancel) + def initUI(self): + layout = QVBoxLayout(self) - btnSave.clicked.connect(self.accept) - btnCancel.clicked.connect(self.reject) + self.fileList = QListWidget(self) + for cls in self.classes: + item = QListWidgetItem(f"Class: {cls}") + item.setCheckState(Qt.Checked if cls in self.selected_items else Qt.Unchecked) + self.fileList.addItem(item) - layout.addLayout(btnLayout) - self.setLayout(layout) + for func in self.functions: + item = QListWidgetItem(f"Function: {func}") + item.setCheckState(Qt.Checked if func in self.selected_items else Qt.Unchecked) + self.fileList.addItem(item) + + layout.addWidget(self.fileList) + + self.btnOk = QPushButton('OK', self) + self.btnOk.clicked.connect(self.accept) + layout.addWidget(self.btnOk) - def browsePath(self): - dir_path = QFileDialog.getExistingDirectory(self, "Select Default Directory") - if dir_path: - self.pathEdit.setText(dir_path) + self.setLayout(layout) - def get_path(self): - return self.pathEdit.text() + def accept(self): + self.selected_items = [ + item.text().split(": ")[1] for item in self.fileList.findItems("*", Qt.MatchWildcard) + if item.checkState() == Qt.Checked + ] + super().accept() - def set_path(self, path): - self.pathEdit.setText(path) + def get_selected_items(self): + return self.selected_items class FilePicker(QWidget): @@ -58,7 +73,8 @@ def __init__(self): self.setWindowTitle('FileKitty') self.setWindowIcon(QIcon(ICON_PATH)) self.setGeometry(100, 100, 800, 600) - self.setAcceptDrops(True) + self.selected_items = [] # Track selected items + self.currentFiles = [] # Track current files self.initUI() self.createActions() self.createMenu() @@ -76,15 +92,15 @@ def initUI(self): self.lineCountLabel = QLabel('Lines ready to copy: 0', self) layout.addWidget(self.lineCountLabel) - self.btnRefresh = QPushButton('🔄 Refresh Text from Files', self) - self.btnRefresh.clicked.connect(self.refreshFiles) - self.btnRefresh.setEnabled(False) - layout.addWidget(self.btnRefresh) - btnOpen = QPushButton('📂 Select Files', self) btnOpen.clicked.connect(self.openFiles) layout.addWidget(btnOpen) + self.btnSelectClassesFunctions = QPushButton('🔍 Select Classes/Functions', self) + self.btnSelectClassesFunctions.clicked.connect(self.selectClassesFunctions) + self.btnSelectClassesFunctions.setEnabled(False) + layout.addWidget(self.btnSelectClassesFunctions) + self.btnCopy = QPushButton('📋 Copy to Clipboard', self) self.btnCopy.clicked.connect(self.copyToClipboard) self.btnCopy.setEnabled(False) @@ -92,6 +108,8 @@ def initUI(self): self.textEdit.textChanged.connect(self.updateCopyButtonState) + self.setLayout(layout) + def createActions(self): self.prefAction = QAction("Preferences", self) self.prefAction.setShortcut(QKeySequence("Ctrl+,")) @@ -105,47 +123,37 @@ def createMenu(self): def showPreferences(self): dialog = PreferencesDialog(self) - dialog.set_path(self.get_default_path()) - if dialog.exec_(): - new_path = dialog.get_path() - self.set_default_path(new_path) - - def get_default_path(self): - settings = QSettings('YourCompany', 'FileKitty') - return settings.value('defaultPath', '') - - def set_default_path(self, path): - settings = QSettings('YourCompany', 'FileKitty') - settings.setValue('defaultPath', path) + dialog.exec_() def openFiles(self): - default_path = self.get_default_path() or "" options = QFileDialog.Options() files, _ = QFileDialog.getOpenFileNames( - self, "Select files to concatenate", default_path, - "All Files (*);;Text Files (*.txt)", options=options + self, "Select files to analyze", "", + "All Files (*);;Python Files (*.py);;JavaScript Files (*.js);;TypeScript Files (*.ts *.tsx)", + options=options ) if files: - self.fileList.clear() self.currentFiles = files - self.refreshFiles() - self.btnRefresh.setEnabled(True) - concatenated_content = self.concatenate_files(files) - self.textEdit.setText(concatenated_content) - - def concatenate_files(self, files): - common_prefix = os.path.commonpath(files) - common_prefix = os.path.dirname(os.path.dirname(os.path.dirname(common_prefix))) - concatenated_content = "" - for file in files: - relative_path = os.path.relpath(file, start=common_prefix) - self.fileList.addItem(relative_path) - concatenated_content += f"### `{relative_path}`\n\n```\n" - with open(file, 'r', encoding='utf-8') as f: - content = f.read() - concatenated_content += content - concatenated_content += "\n```\n\n" - return concatenated_content.rstrip() + self.fileList.clear() + for file in files: + self.fileList.addItem(file) + + # Check if all selected files are Python files to enable the class/function selection + if all(file.endswith(('.py',)) for file in files): + self.btnSelectClassesFunctions.setEnabled(True) + else: + self.btnSelectClassesFunctions.setEnabled(False) + + self.updateTextEdit() + + def selectClassesFunctions(self): + if self.currentFiles: + file_path = self.currentFiles[0] + classes, functions, _, file_content = parse_python_file(file_path) + dialog = SelectClassesFunctionsDialog(classes, functions, self.selected_items, self) + if dialog.exec_(): + self.selected_items = dialog.get_selected_items() + self.updateTextEdit() def copyToClipboard(self): clipboard = QGuiApplication.clipboard() @@ -157,45 +165,79 @@ def updateCopyButtonState(self): self.lineCountLabel.setText(f'Lines ready to copy: {line_count}') self.btnCopy.setEnabled(bool(text)) - def refreshFiles(self): - if hasattr(self, 'currentFiles') and self.currentFiles: - concatenated_content = self.concatenate_files(self.currentFiles) - self.textEdit.setText(concatenated_content) - - def dragEnterEvent(self, event: QDragEnterEvent): - if event.mimeData().hasUrls(): - event.acceptProposedAction() - else: - event.ignore() - - def dropEvent(self, event: QDropEvent): - if event.mimeData().hasUrls(): - files = [] - for url in event.mimeData().urls(): - if url.isLocalFile(): - files.append(url.toLocalFile()) - if files: - self.fileList.clear() - self.currentFiles = files - self.refreshFiles() - self.btnRefresh.setEnabled(True) - self.animateDropSuccess() - event.acceptProposedAction() + def updateTextEdit(self): + if self.currentFiles: + file_path = self.currentFiles[0] + if file_path.endswith('.py'): + _, _, imports, file_content = parse_python_file(file_path) + if not self.selected_items: + code = f"# {os.path.basename(file_path)}\n\n```python\n{file_content}\n```" + else: + code = extract_code_and_imports(file_content, self.selected_items, file_path) + else: + # For non-Python files, simply show the entire file content + with open(file_path, 'r', encoding='utf-8') as file: + file_content = file.read() + code = f"# {os.path.basename(file_path)}\n\n```{self.detect_language(file_path)}\n{file_content}\n```" + + self.textEdit.setText(code) + + def detect_language(self, file_path): + """Detect the language based on the file extension for syntax highlighting in markdown.""" + if file_path.endswith('.js'): + return 'javascript' + elif file_path.endswith(('.ts', '.tsx')): + return 'typescript' else: - event.ignore() - - def applyBrightnessEffect(self): - self.effect = QGraphicsColorizeEffect(self) - self.effect.setColor(Qt.darkBlue) - self.effect.setStrength(0.25) - self.setGraphicsEffect(self.effect) - - def removeBrightnessEffect(self): - self.setGraphicsEffect(None) - - def animateDropSuccess(self): - self.applyBrightnessEffect() - QTimer.singleShot(100, self.removeBrightnessEffect) + return 'plaintext' + + +def parse_python_file(file_path): + try: + with open(file_path, 'r', encoding='utf-8') as file: + file_content = file.read() + tree = ast.parse(file_content, filename=file_path) + except SyntaxError as e: + print(f"Syntax error in file {file_path}: {e}") + return [], [], [], "" + + classes = [] + functions = [] + imports = [] + + for node in ast.walk(tree): + if isinstance(node, ast.ClassDef): + classes.append(node.name) + elif isinstance(node, ast.FunctionDef): + functions.append(node.name) + elif isinstance(node, (ast.Import, ast.ImportFrom)): + imports.append(ast.get_source_segment(file_content, node)) + + return classes, functions, imports, file_content + + +def extract_code_and_imports(file_content, selected_items, file_path): + tree = ast.parse(file_content) + selected_code = [] + imports = set() + + for node in ast.walk(tree): + if isinstance(node, (ast.Import, ast.ImportFrom)): + imports.add(ast.get_source_segment(file_content, node)) + + imports_str = "\n".join(sorted(imports)) + file_name = os.path.basename(file_path) + header = f"# {file_name}\n\n## Selected Classes/Functions: {', '.join(selected_items)}\n" + + for node in ast.walk(tree): + if isinstance(node, (ast.ClassDef, ast.FunctionDef)) and node.name in selected_items: + start_line = node.lineno - 1 + end_line = node.end_lineno + code_block = "\n".join(file_content.splitlines()[start_line:end_line]) + reference_path = f"{file_path.replace('/', '.')}.{node.name}" + selected_code.append(f"### `{reference_path}`\n\n```python\n{code_block}\n```\n") + + return f"{header}\n```python\n{imports_str}\n```\n\n" + "\n".join(selected_code) if __name__ == '__main__': From d60392eb2a46839a95200e5b52a87624b440e601 Mon Sep 17 00:00:00 2001 From: banagale Date: Tue, 20 Aug 2024 15:33:38 -0700 Subject: [PATCH 2/3] Sanitize paths, increase symbol selection modal size --- filekitty/app.py | 93 +++++++++++++++++++++++++++++++++--------------- 1 file changed, 65 insertions(+), 28 deletions(-) diff --git a/filekitty/app.py b/filekitty/app.py index e3589ff..0fe9084 100644 --- a/filekitty/app.py +++ b/filekitty/app.py @@ -26,27 +26,36 @@ def initUI(self): class SelectClassesFunctionsDialog(QDialog): - def __init__(self, classes, functions, selected_items=None, parent=None): + def __init__(self, all_classes, all_functions, selected_items=None, parent=None): super(SelectClassesFunctionsDialog, self).__init__(parent) self.setWindowTitle('Select Classes/Functions') - self.classes = classes - self.functions = functions + self.all_classes = all_classes + self.all_functions = all_functions self.selected_items = selected_items if selected_items is not None else [] + self.resize(600, 400) # Set width to 600px and height to 400px self.initUI() def initUI(self): layout = QVBoxLayout(self) self.fileList = QListWidget(self) - for cls in self.classes: - item = QListWidgetItem(f"Class: {cls}") - item.setCheckState(Qt.Checked if cls in self.selected_items else Qt.Unchecked) - self.fileList.addItem(item) - - for func in self.functions: - item = QListWidgetItem(f"Function: {func}") - item.setCheckState(Qt.Checked if func in self.selected_items else Qt.Unchecked) - self.fileList.addItem(item) + for file_path, classes in self.all_classes.items(): + file_header = QListWidgetItem(f"File: {os.path.basename(file_path)} (Classes)") + file_header.setFlags(file_header.flags() & ~Qt.ItemIsSelectable) + self.fileList.addItem(file_header) + for cls in classes: + item = QListWidgetItem(f"Class: {cls}") + item.setCheckState(Qt.Checked if cls in self.selected_items else Qt.Unchecked) + self.fileList.addItem(item) + + for file_path, functions in self.all_functions.items(): + file_header = QListWidgetItem(f"File: {os.path.basename(file_path)} (Functions)") + file_header.setFlags(file_header.flags() & ~Qt.ItemIsSelectable) + self.fileList.addItem(file_header) + for func in functions: + item = QListWidgetItem(f"Function: {func}") + item.setCheckState(Qt.Checked if func in self.selected_items else Qt.Unchecked) + self.fileList.addItem(item) layout.addWidget(self.fileList) @@ -136,10 +145,11 @@ def openFiles(self): self.currentFiles = files self.fileList.clear() for file in files: - self.fileList.addItem(file) + sanitized_path = self.sanitize_path(file) + self.fileList.addItem(sanitized_path) - # Check if all selected files are Python files to enable the class/function selection - if all(file.endswith(('.py',)) for file in files): + # Enable or disable the "Select Classes/Functions" button based on whether all selected files are Python files + if all(file.endswith('.py') for file in files): self.btnSelectClassesFunctions.setEnabled(True) else: self.btnSelectClassesFunctions.setEnabled(False) @@ -147,10 +157,17 @@ def openFiles(self): self.updateTextEdit() def selectClassesFunctions(self): - if self.currentFiles: - file_path = self.currentFiles[0] - classes, functions, _, file_content = parse_python_file(file_path) - dialog = SelectClassesFunctionsDialog(classes, functions, self.selected_items, self) + """Allow selection of classes/functions from all selected Python files.""" + all_classes = {} + all_functions = {} + for file_path in self.currentFiles: + if file_path.endswith('.py'): + classes, functions, _, _ = parse_python_file(file_path) + all_classes[file_path] = classes + all_functions[file_path] = functions + + if all_classes or all_functions: + dialog = SelectClassesFunctionsDialog(all_classes, all_functions, self.selected_items, self) if dialog.exec_(): self.selected_items = dialog.get_selected_items() self.updateTextEdit() @@ -165,22 +182,38 @@ def updateCopyButtonState(self): self.lineCountLabel.setText(f'Lines ready to copy: {line_count}') self.btnCopy.setEnabled(bool(text)) + def sanitize_path(self, file_path): + """Remove sensitive directory information from file paths.""" + parts = file_path.split(os.sep) + if "Users" in parts: + user_index = parts.index("Users") + # Remove the "Users" directory and the one immediately following it (likely the username) + sanitized_parts = parts[:user_index] + parts[user_index + 2:] + return os.sep.join(sanitized_parts) + return file_path + def updateTextEdit(self): - if self.currentFiles: - file_path = self.currentFiles[0] + """Update the main text area with the content of all selected files.""" + combined_code = "" + for file_path in self.currentFiles: + sanitized_path = self.sanitize_path(file_path) if file_path.endswith('.py'): - _, _, imports, file_content = parse_python_file(file_path) + classes, functions, imports, file_content = parse_python_file(file_path) if not self.selected_items: - code = f"# {os.path.basename(file_path)}\n\n```python\n{file_content}\n```" + # If no specific classes/functions are selected, show the entire file content + combined_code += f"# {os.path.basename(sanitized_path)}\n\n```python\n{file_content}\n```\n" else: - code = extract_code_and_imports(file_content, self.selected_items, file_path) + # Show only the selected classes/functions + filtered_code = extract_code_and_imports(file_content, self.selected_items, file_path) + if filtered_code.strip(): # Only add if there is content to show + combined_code += filtered_code.replace(file_path, sanitized_path) else: - # For non-Python files, simply show the entire file content + # For non-Python files, simply append the entire file content with open(file_path, 'r', encoding='utf-8') as file: file_content = file.read() - code = f"# {os.path.basename(file_path)}\n\n```{self.detect_language(file_path)}\n{file_content}\n```" + combined_code += f"# {os.path.basename(sanitized_path)}\n\n```{self.detect_language(file_path)}\n{file_content}\n```\n" - self.textEdit.setText(code) + self.textEdit.setText(combined_code) def detect_language(self, file_path): """Detect the language based on the file extension for syntax highlighting in markdown.""" @@ -237,7 +270,11 @@ def extract_code_and_imports(file_content, selected_items, file_path): reference_path = f"{file_path.replace('/', '.')}.{node.name}" selected_code.append(f"### `{reference_path}`\n\n```python\n{code_block}\n```\n") - return f"{header}\n```python\n{imports_str}\n```\n\n" + "\n".join(selected_code) + if selected_code: + return f"{header}\n```python\n{imports_str}\n```\n\n" + "\n".join(selected_code) + else: + # If no classes/functions are selected in this file, return an empty string + return "" if __name__ == '__main__': From fcde4ff4f921edfbf040b3acecbc91eade3aa59c Mon Sep 17 00:00:00 2001 From: banagale Date: Tue, 20 Aug 2024 15:41:42 -0700 Subject: [PATCH 3/3] Include sanitized path with filename in copy-able code --- filekitty/app.py | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/filekitty/app.py b/filekitty/app.py index 0fe9084..c08b6fa 100644 --- a/filekitty/app.py +++ b/filekitty/app.py @@ -201,17 +201,17 @@ def updateTextEdit(self): classes, functions, imports, file_content = parse_python_file(file_path) if not self.selected_items: # If no specific classes/functions are selected, show the entire file content - combined_code += f"# {os.path.basename(sanitized_path)}\n\n```python\n{file_content}\n```\n" + combined_code += f"# {sanitized_path}\n\n```python\n{file_content}\n```\n" else: # Show only the selected classes/functions - filtered_code = extract_code_and_imports(file_content, self.selected_items, file_path) + filtered_code = extract_code_and_imports(file_content, self.selected_items, sanitized_path) if filtered_code.strip(): # Only add if there is content to show - combined_code += filtered_code.replace(file_path, sanitized_path) + combined_code += filtered_code else: # For non-Python files, simply append the entire file content with open(file_path, 'r', encoding='utf-8') as file: file_content = file.read() - combined_code += f"# {os.path.basename(sanitized_path)}\n\n```{self.detect_language(file_path)}\n{file_content}\n```\n" + combined_code += f"# {sanitized_path}\n\n```{self.detect_language(file_path)}\n{file_content}\n```\n" self.textEdit.setText(combined_code) @@ -248,8 +248,17 @@ def parse_python_file(file_path): return classes, functions, imports, file_content - -def extract_code_and_imports(file_content, selected_items, file_path): +def sanitize_path(file_path): + """Remove sensitive directory information from file paths.""" + parts = file_path.split(os.sep) + if "Users" in parts: + user_index = parts.index("Users") + # Remove the "Users" directory and the one immediately following it (likely the username) + sanitized_parts = parts[:user_index] + parts[user_index + 2:] + return os.sep.join(sanitized_parts) + return file_path + +def extract_code_and_imports(file_content, selected_items, sanitized_path): tree = ast.parse(file_content) selected_code = [] imports = set() @@ -259,15 +268,14 @@ def extract_code_and_imports(file_content, selected_items, file_path): imports.add(ast.get_source_segment(file_content, node)) imports_str = "\n".join(sorted(imports)) - file_name = os.path.basename(file_path) - header = f"# {file_name}\n\n## Selected Classes/Functions: {', '.join(selected_items)}\n" + header = f"# {sanitized_path}\n\n## Selected Classes/Functions: {', '.join(selected_items)}\n" for node in ast.walk(tree): if isinstance(node, (ast.ClassDef, ast.FunctionDef)) and node.name in selected_items: start_line = node.lineno - 1 end_line = node.end_lineno code_block = "\n".join(file_content.splitlines()[start_line:end_line]) - reference_path = f"{file_path.replace('/', '.')}.{node.name}" + reference_path = f"{sanitized_path.replace('/', '.')}.{node.name}" selected_code.append(f"### `{reference_path}`\n\n```python\n{code_block}\n```\n") if selected_code: