From f6ad7485b18940eedbfafc9cacd545be913a5fe6 Mon Sep 17 00:00:00 2001 From: santiagosayshey Date: Mon, 17 Feb 2025 07:06:11 +1030 Subject: [PATCH] feat: Rename / Status Improvements (#142) - Renames within profilarr now affect all files that reference the renamed file - Outgoing / Incoming status now properly shows renamed files as renamed, rather than new - Overhauled view changes header + commit section. Now shows commit scope + file type as badges + improved message parsing --- backend/app/data/utils.py | 122 ++++- backend/app/git/status/incoming_changes.py | 126 +++++- backend/app/git/status/outgoing_changes.py | 61 ++- backend/app/git/status/status.py | 69 ++- .../src/components/profile/ProfileCard.jsx | 6 +- .../components/profile/ProfileGeneralTab.jsx | 66 +-- .../src/components/profile/ProfileModal.jsx | 7 +- .../components/profile/ProfileScoringTab.jsx | 425 ------------------ .../profile/scoring/AdvancedView.jsx | 200 +++++++++ .../components/profile/scoring/BasicView.jsx | 81 ++++ .../profile/scoring/FormatSettings.jsx | 147 ++++++ .../profile/scoring/ProfileScoringTab.jsx | 107 +++++ .../profile/scoring/UpgradeSettings.jsx | 90 ++++ .../settings/git/status/DiffCommit.jsx | 86 ++-- .../settings/git/status/ViewChanges.jsx | 53 ++- frontend/src/components/ui/NumberInput.jsx | 151 +++++++ frontend/src/components/ui/Sort.jsx | 80 ++++ frontend/src/components/ui/SortDropdown.jsx | 75 ++++ frontend/src/components/ui/Tooltip.jsx | 62 ++- frontend/src/constants/commits.js | 54 ++- frontend/src/hooks/useSorting.js | 46 ++ 21 files changed, 1520 insertions(+), 594 deletions(-) delete mode 100644 frontend/src/components/profile/ProfileScoringTab.jsx create mode 100644 frontend/src/components/profile/scoring/AdvancedView.jsx create mode 100644 frontend/src/components/profile/scoring/BasicView.jsx create mode 100644 frontend/src/components/profile/scoring/FormatSettings.jsx create mode 100644 frontend/src/components/profile/scoring/ProfileScoringTab.jsx create mode 100644 frontend/src/components/profile/scoring/UpgradeSettings.jsx create mode 100644 frontend/src/components/ui/NumberInput.jsx create mode 100644 frontend/src/components/ui/Sort.jsx create mode 100644 frontend/src/components/ui/SortDropdown.jsx create mode 100644 frontend/src/hooks/useSorting.js diff --git a/backend/app/data/utils.py b/backend/app/data/utils.py index dfa0bec..b468536 100644 --- a/backend/app/data/utils.py +++ b/backend/app/data/utils.py @@ -158,26 +158,39 @@ def update_yaml_file(file_path: str, data: Dict[str, Any], # Check if this is a rename operation if 'rename' in data: new_name = data['rename'] + old_name = filename_to_display(os.path.basename(file_path)[:-4]) + directory = os.path.dirname(file_path) new_file_path = os.path.join(directory, display_to_filename(new_name)) - # Remove rename field before saving + # Update references before performing the rename + try: + updated_files = update_references(category, old_name, new_name) + logger.info(f"Updated references in: {updated_files}") + except Exception as e: + logger.error(f"Failed to update references: {e}") + raise Exception(f"Failed to update references: {str(e)}") + + # Remove rename field and update the name field in the data data_to_save = {k: v for k, v in data.items() if k != 'rename'} + data_to_save['name'] = new_name - # First save the updated content to the CURRENT file location - save_yaml_file(file_path, - data_to_save, - category, - use_data_name=False) - - # Check if file is being tracked by git repo = git.Repo(REPO_PATH) rel_old_path = os.path.relpath(file_path, REPO_PATH) rel_new_path = os.path.relpath(new_file_path, REPO_PATH) try: - # Check if file is tracked by git + # First, save the content changes to the current file + save_yaml_file(file_path, + data_to_save, + category, + use_data_name=False) + + # Stage the content changes first + repo.index.add([rel_old_path]) + + # Then perform the rename tracked_files = repo.git.ls_files().splitlines() is_tracked = rel_old_path in tracked_files @@ -187,6 +200,8 @@ def update_yaml_file(file_path: str, data: Dict[str, Any], else: # For untracked files, manually move os.rename(file_path, new_file_path) + # Stage the new file + repo.index.add([rel_new_path]) except git.GitCommandError as e: logger.error(f"Git operation failed: {e}") @@ -299,6 +314,95 @@ def check_delete_constraints(category: str, name: str) -> Tuple[bool, str]: return False, f"Error checking references: {str(e)}" +def update_references(category: str, old_name: str, + new_name: str) -> List[str]: + """ + Update references to a renamed item across all relevant files. + Returns a list of files that were updated. + """ + updated_files = [] + + try: + # Convert names to use parentheses for comparison + old_check_name = old_name.replace('[', '(').replace(']', ')') + new_check_name = new_name.replace('[', '(').replace(']', ')') + + if category == 'regex_pattern': + # Update references in custom formats + format_dir = get_category_directory('custom_format') + for format_file in os.listdir(format_dir): + if not format_file.endswith('.yml'): + continue + + format_path = os.path.join(format_dir, format_file) + try: + format_data = load_yaml_file(format_path) + updated = False + + # Check and update each condition in the format + for condition in format_data.get('conditions', []): + if (condition['type'] in [ + 'release_title', 'release_group', 'edition' + ] and condition.get('pattern') == old_check_name): + condition['pattern'] = new_check_name + updated = True + + if updated: + save_yaml_file(format_path, + format_data, + 'custom_format', + use_data_name=False) + updated_files.append( + f"custom format: {format_data['name']}") + + except Exception as e: + logger.error( + f"Error updating format file {format_file}: {e}") + continue + + elif category == 'custom_format': + # Update references in quality profiles + profile_dir = get_category_directory('profile') + for profile_file in os.listdir(profile_dir): + if not profile_file.endswith('.yml'): + continue + + profile_path = os.path.join(profile_dir, profile_file) + try: + profile_data = load_yaml_file(profile_path) + updated = False + + # Update custom_formats array in profile + for format_ref in profile_data.get('custom_formats', []): + format_name = format_ref.get('name', '') + # Convert format name to use parentheses for comparison + format_name = format_name.replace('[', '(').replace( + ']', ')') + + if format_name == old_check_name: + format_ref['name'] = new_name + updated = True + + if updated: + save_yaml_file(profile_path, + profile_data, + 'profile', + use_data_name=False) + updated_files.append( + f"quality profile: {profile_data['name']}") + + except Exception as e: + logger.error( + f"Error updating profile file {profile_file}: {e}") + continue + + return updated_files + + except Exception as e: + logger.error(f"Error updating references: {e}") + raise + + def test_regex_pattern( pattern: str, tests: List[Dict[str, Any]]) -> Tuple[bool, str, List[Dict[str, Any]]]: diff --git a/backend/app/git/status/incoming_changes.py b/backend/app/git/status/incoming_changes.py index 6d10313..c94ea38 100644 --- a/backend/app/git/status/incoming_changes.py +++ b/backend/app/git/status/incoming_changes.py @@ -8,6 +8,16 @@ from .utils import determine_type, parse_commit_message logger = logging.getLogger(__name__) +def extract_name(file_path): + """Extract name from file path by removing type prefix and extension""" + # Remove the file extension + name = os.path.splitext(file_path)[0] + # Remove the type prefix (everything before the first '/') + if '/' in name: + name = name.split('/', 1)[1] + return name + + def check_merge_conflict(repo, branch, file_path): """Check if pulling a file would cause merge conflicts""" try: @@ -15,7 +25,7 @@ def check_merge_conflict(repo, branch, file_path): status = repo.git.status('--porcelain', file_path).strip() if status: status_code = status[:2] if len(status) >= 2 else '' - has_changes = 'M' in status_code or 'A' in status_code or 'D' in status_code + has_changes = 'M' in status_code or 'A' in status_code or 'D' in status_code or 'R' in status_code else: # Check for unpushed commits merge_base = repo.git.merge_base('HEAD', @@ -49,32 +59,122 @@ def get_commit_message(repo, branch, file_path): raw_message = repo.git.show(f'HEAD...origin/{branch}', '--format=%B', '-s', '--', file_path).strip() return parse_commit_message(raw_message) - except GitCommandError: + except GitCommandError as e: + logger.error( + f"Git command error getting commit message for {file_path}: {str(e)}" + ) return { "body": "", "footer": "", "scope": "", - "subject": "Unable to retrieve commit message", + "subject": f"Error retrieving commit message: {str(e)}", "type": "" } +def parse_commit_message(message): + """Parse a commit message into its components""" + try: + # Default structure + parsed = { + "type": "Unknown Type", + "scope": "Unknown Scope", + "subject": "", + "body": "", + "footer": "" + } + + if not message: + return parsed + + # Split message into lines + lines = message.strip().split('\n') + + # Parse first line (header) + if lines: + header = lines[0] + + # Try to parse conventional commit format: type(scope): subject + import re + conventional_format = re.match(r'^(\w+)(?:\(([^)]+)\))?: (.+)$', + header) + + if conventional_format: + groups = conventional_format.groups() + parsed.update({ + "type": groups[0] or "Unknown Type", + "scope": groups[1] or "Unknown Scope", + "subject": groups[2] + }) + else: + parsed["subject"] = header + + # Parse body and footer + if len(lines) > 1: + # Find the divider between body and footer (if any) + footer_start = -1 + for i, line in enumerate(lines[1:], 1): + if re.match(r'^[A-Z_-]+:', line): + footer_start = i + break + + # Extract body and footer + if footer_start != -1: + parsed["body"] = '\n'.join(lines[1:footer_start]).strip() + parsed["footer"] = '\n'.join(lines[footer_start:]).strip() + else: + parsed["body"] = '\n'.join(lines[1:]).strip() + + return parsed + + except Exception as e: + logger.error(f"Error parsing commit message: {str(e)}") + return { + "type": "Unknown Type", + "scope": "Unknown Scope", + "subject": "Error parsing commit message", + "body": "", + "footer": "" + } + + def get_incoming_changes(repo, branch): """Get list of changes that would come in from origin""" try: - # Get changed files - diff_index = repo.git.diff(f'HEAD...origin/{branch}', - '--name-only').split('\n') - changed_files = list(filter(None, set(diff_index))) + # Get status including renames + diff_output = repo.git.diff(f'HEAD...origin/{branch}', '--name-status', + '-M').split('\n') + changed_files = [] + rename_mapping = {} + + # Process status to identify renames + for line in diff_output: + if not line: + continue + parts = line.split('\t') + if len(parts) < 2: + continue + + status = parts[0] + if status.startswith('R'): + old_path, new_path = parts[1], parts[2] + rename_mapping[new_path] = old_path + changed_files.append(new_path) + else: + changed_files.append(parts[1]) logger.info(f"Processing {len(changed_files)} incoming changes") incoming_changes = [] for file_path in changed_files: try: + # Handle renamed files + old_path = rename_mapping.get(file_path, file_path) + is_rename = file_path in rename_mapping + # Get local and remote versions try: - local_content = repo.git.show(f'HEAD:{file_path}') + local_content = repo.git.show(f'HEAD:{old_path}') local_data = yaml.safe_load(local_content) except (GitCommandError, yaml.YAMLError): local_data = None @@ -87,7 +187,7 @@ def get_incoming_changes(repo, branch): remote_data = None # Skip if no actual changes - if local_data == remote_data: + if local_data == remote_data and not is_rename: continue # Check for conflicts and get commit info @@ -109,13 +209,17 @@ def get_incoming_changes(repo, branch): 'id': remote_data.get('id') if remote_data else None, 'local_name': - local_data.get('name') if local_data else None, + extract_name(old_path) + if is_rename else extract_name(file_path), 'incoming_name': - remote_data.get('name') if remote_data else None, + extract_name(file_path), 'staged': False }) + if is_rename: + change['status'] = 'Renamed' + incoming_changes.append(change) except Exception as e: diff --git a/backend/app/git/status/outgoing_changes.py b/backend/app/git/status/outgoing_changes.py index 0860805..f8c15f1 100644 --- a/backend/app/git/status/outgoing_changes.py +++ b/backend/app/git/status/outgoing_changes.py @@ -8,10 +8,19 @@ from .utils import determine_type logger = logging.getLogger(__name__) +def extract_name(file_path): + """Extract name from file path by removing type prefix and extension""" + # Remove the file extension + name = os.path.splitext(file_path)[0] + # Remove the type prefix (everything before the first '/') + if '/' in name: + name = name.split('/', 1)[1] + return name + + def get_outgoing_changes(repo): """Get list of changes in working directory""" try: - # Get status of working directory status = repo.git.status('--porcelain', '-z').split('\0') logger.info(f"Processing {len(status)} changes from git status") @@ -37,50 +46,68 @@ def get_outgoing_changes(repo): i += 1 continue - is_staged = x != ' ' and x != '?' + # Handle renamed files + if x == 'R' or y == 'R': + if i + 1 < len(status) and status[i + 1]: + outgoing_name = extract_name(file_path) + prior_name = extract_name(status[i + 1]) + original_path = status[i + 1] # Path for old content + new_path = file_path # Path for new content + is_staged = x == 'R' + status_value = 'Renamed' + i += 2 + else: + i += 1 + else: + name = extract_name(file_path) + prior_name = name + outgoing_name = name + original_path = file_path + new_path = file_path + is_staged = x != ' ' and x != '?' + status_value = None + i += 1 try: # Get old content (from HEAD) try: - old_content = repo.git.show(f'HEAD:{file_path}') + old_content = repo.git.show(f'HEAD:{original_path}') old_data = yaml.safe_load(old_content) except GitCommandError: old_data = None except yaml.YAMLError as e: logger.warning( - f"Failed to parse old YAML for {file_path}: {str(e)}") + f"Failed to parse old YAML for {original_path}: {str(e)}" + ) old_data = None # Get new content (from working directory) try: - full_path = os.path.join(repo.working_dir, file_path) + full_path = os.path.join(repo.working_dir, new_path) with open(full_path, 'r') as f: new_data = yaml.safe_load(f.read()) except (IOError, yaml.YAMLError) as e: logger.warning( - f"Failed to read/parse current file {file_path}: {str(e)}" + f"Failed to read/parse current file {new_path}: {str(e)}" ) new_data = None # Generate change summary - change = create_change_summary(old_data, new_data, file_path) - change['type'] = determine_type(file_path) + change = create_change_summary(old_data, new_data, new_path) + change['type'] = determine_type(new_path) change['staged'] = is_staged + change['prior_name'] = prior_name + change['outgoing_name'] = outgoing_name + + if status_value: + change['status'] = status_value + changes.append(change) except Exception as e: logger.error(f"Failed to process {file_path}: {str(e)}", exc_info=True) - i += 1 - - # Handle renamed files - if x == 'R' or y == 'R': - if i + 1 < len(status) and status[i + 1]: - i += 2 # Skip the old filename entry - else: - i += 1 - return changes except Exception as e: diff --git a/backend/app/git/status/status.py b/backend/app/git/status/status.py index f0a6f35..71e32be 100644 --- a/backend/app/git/status/status.py +++ b/backend/app/git/status/status.py @@ -10,6 +10,7 @@ import os import yaml import threading from datetime import datetime +import json from ...db import get_settings logger = logging.getLogger(__name__) @@ -217,37 +218,85 @@ class GitStatusManager: return self.status.copy() +def format_git_status(status): + """Format git status for logging with truncation and pretty printing. + + Args: + status (dict): The git status dictionary to format + + Returns: + str: Formatted status string + """ + + def truncate_list(lst, max_items=3): + """Truncate a list and add count of remaining items.""" + if len(lst) <= max_items: + return lst + return lst[:max_items] + [f"... and {len(lst) - max_items} more items"] + + def truncate_string(s, max_length=50): + """Truncate a string if it's too long.""" + if not s or len(s) <= max_length: + return s + return s[:max_length] + "..." + + # Create a copy to modify + formatted_status = status.copy() + + # Truncate lists + for key in [ + 'outgoing_changes', 'merge_conflicts', 'incoming_changes', + 'unpushed_files' + ]: + if key in formatted_status and isinstance(formatted_status[key], list): + formatted_status[key] = truncate_list(formatted_status[key]) + + # Format any nested dictionaries in the lists + for key in formatted_status: + if isinstance(formatted_status[key], list): + formatted_status[key] = [{ + k: truncate_string(str(v)) + for k, v in item.items() + } if isinstance(item, dict) else item + for item in formatted_status[key]] + + # Convert to JSON with nice formatting + formatted_json = json.dumps(formatted_status, indent=2, default=str) + + # Add a timestamp header + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + return f"=== Git Status at {timestamp} ===\n{formatted_json}" + + def get_git_status(repo_path): try: status_manager = GitStatusManager.get_instance(repo_path) status_manager.update_local_status() - return True, status_manager.get_status() + success, status = True, status_manager.get_status() + + # Log the formatted status + logger.info("\n" + format_git_status(status)) + + return success, status except git.exc.InvalidGitRepositoryError: logger.info(f"No git repository found at {repo_path}") - # Return a valid status object indicating no repo - return True, { - # Local status - empty/false everything + empty_status = { "branch": "", "outgoing_changes": [], "is_merging": False, "merge_conflicts": [], "has_conflicts": False, - - # Remote status - explicitly show no repo/remote "remote_branch_exists": False, "commits_behind": 0, "commits_ahead": 0, "incoming_changes": [], "has_unpushed_commits": False, "unpushed_files": [], - - # Metadata "last_local_update": None, "last_remote_update": None, - - # New flag to explicitly indicate no repo "has_repo": False } + return True, empty_status except Exception as e: logger.error(f"Error in get_git_status: {str(e)}", exc_info=True) return False, str(e) diff --git a/frontend/src/components/profile/ProfileCard.jsx b/frontend/src/components/profile/ProfileCard.jsx index 81d2175..56c59e4 100644 --- a/frontend/src/components/profile/ProfileCard.jsx +++ b/frontend/src/components/profile/ProfileCard.jsx @@ -176,11 +176,7 @@ const ProfileCard = ({ {/* Description - Fixed Height with Scroll */}
{content.description && ( -
+
{unsanitize(content.description)} diff --git a/frontend/src/components/profile/ProfileGeneralTab.jsx b/frontend/src/components/profile/ProfileGeneralTab.jsx index 4e360fe..7b58545 100644 --- a/frontend/src/components/profile/ProfileGeneralTab.jsx +++ b/frontend/src/components/profile/ProfileGeneralTab.jsx @@ -7,10 +7,8 @@ const ProfileGeneralTab = ({ name, description, tags, - upgradesAllowed, onNameChange, onDescriptionChange, - onUpgradesAllowedChange, onAddTag, onRemoveTag, error @@ -42,37 +40,13 @@ const ProfileGeneralTab = ({ )}
-
-
- -

- Name of this profile. Import will use the same - name -

-
-
- -

- Allow automatic upgrades for this profile -

-
+
+ +

+ Name of this profile. Import will use the same name +

+ text-xs font-semibold + bg-blue-600/20 text-blue-400 + group'> {tag} + {isDropdownOpen && ( + <> +
setIsDropdownOpen(false)} + /> +
+
+ + +
+
+ + )} +
+
+ + {isAdvancedView ? ( + + ) : ( + + )} +
+ ); +}; + +FormatSettings.propTypes = { + formats: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + score: PropTypes.number.isRequired, + tags: PropTypes.arrayOf(PropTypes.string) + }) + ).isRequired, + onScoreChange: PropTypes.func.isRequired +}; + +export default FormatSettings; diff --git a/frontend/src/components/profile/scoring/ProfileScoringTab.jsx b/frontend/src/components/profile/scoring/ProfileScoringTab.jsx new file mode 100644 index 0000000..e32d462 --- /dev/null +++ b/frontend/src/components/profile/scoring/ProfileScoringTab.jsx @@ -0,0 +1,107 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import FormatSettings from './FormatSettings'; +import UpgradeSettings from './UpgradeSettings'; + +const ProfileScoringTab = ({ + formats, + onScoreChange, + minCustomFormatScore, + upgradeUntilScore, + minScoreIncrement, + onMinScoreChange, + onUpgradeUntilScoreChange, + onMinIncrementChange, + upgradesAllowed, + onUpgradesAllowedChange +}) => { + return ( +
+ {/* Upgrade Settings Section */} +
+
+
+

+ Upgrade Settings +

+

+ Assign scores to different formats to control + download preferences +

+
+
+ +

+ Allow automatic upgrades for this profile +

+
+
+ +
+ +
+
+ + {/* Format Settings Section */} +
+
+

+ Format Settings +

+

+ Configure when upgrades should be downloaded and what + scores are required +

+
+ + +
+
+ ); +}; + +ProfileScoringTab.propTypes = { + formats: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + score: PropTypes.number.isRequired, + tags: PropTypes.arrayOf(PropTypes.string) + }) + ).isRequired, + onScoreChange: PropTypes.func.isRequired, + minCustomFormatScore: PropTypes.number.isRequired, + upgradeUntilScore: PropTypes.number.isRequired, + minScoreIncrement: PropTypes.number.isRequired, + onMinScoreChange: PropTypes.func.isRequired, + onUpgradeUntilScoreChange: PropTypes.func.isRequired, + onMinIncrementChange: PropTypes.func.isRequired, + upgradesAllowed: PropTypes.bool.isRequired, + onUpgradesAllowedChange: PropTypes.func.isRequired +}; + +export default ProfileScoringTab; diff --git a/frontend/src/components/profile/scoring/UpgradeSettings.jsx b/frontend/src/components/profile/scoring/UpgradeSettings.jsx new file mode 100644 index 0000000..93c2f6a --- /dev/null +++ b/frontend/src/components/profile/scoring/UpgradeSettings.jsx @@ -0,0 +1,90 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import NumberInput from '@ui/NumberInput'; + +const UpgradeSettings = ({ + minCustomFormatScore, + upgradeUntilScore, + minScoreIncrement, + onMinScoreChange, + onUpgradeUntilScoreChange, + onMinIncrementChange, + upgradesAllowed +}) => { + return ( +
+
+ {/* Minimum Custom Format Score - Always visible */} +
+
+ +

+ Minimum custom format score allowed to download +

+
+ +
+ + {/* Conditional settings that only show when upgrades are allowed */} + {upgradesAllowed && ( + <> + {/* Upgrade Until Score */} +
+
+ +

+ Once the quality cutoff is met or exceeded + and this custom format score is reached, no + more upgrades will be grabbed +

+
+ +
+ + {/* Minimum Score Increment */} +
+
+ +

+ Minimum required improvement of the custom + format score between existing and new + releases before considering an upgrade +

+
+ +
+ + )} +
+
+ ); +}; + +UpgradeSettings.propTypes = { + minCustomFormatScore: PropTypes.number.isRequired, + upgradeUntilScore: PropTypes.number.isRequired, + minScoreIncrement: PropTypes.number.isRequired, + onMinScoreChange: PropTypes.func.isRequired, + onUpgradeUntilScoreChange: PropTypes.func.isRequired, + onMinIncrementChange: PropTypes.func.isRequired, + upgradesAllowed: PropTypes.bool.isRequired +}; + +export default UpgradeSettings; diff --git a/frontend/src/components/settings/git/status/DiffCommit.jsx b/frontend/src/components/settings/git/status/DiffCommit.jsx index 2b78242..b0bb006 100644 --- a/frontend/src/components/settings/git/status/DiffCommit.jsx +++ b/frontend/src/components/settings/git/status/DiffCommit.jsx @@ -1,62 +1,62 @@ import React from 'react'; import PropTypes from 'prop-types'; +import {GitCommit, Info} from 'lucide-react'; +import Tooltip from '@ui/Tooltip'; const DiffCommit = ({commitMessage}) => { - if (!commitMessage) return null; - - const {subject, body, footer} = commitMessage; + const {subject, body} = commitMessage; return ( -
-
- - Details - -
-
-
-
- - {subject} - -
+
+ + + {/* Subject row */} + + + - {/* Render the body without double hyphens */} + {/* Body row - only rendered if body exists */} {body && ( -
    - {body - .split('\n') - .filter(line => line.trim().startsWith('-')) // Ensure we only take lines starting with "-" - .map((line, index) => ( -
  • - {line.trim().replace(/^-\s*/, '')}{' '} - {/* Remove leading hyphen */} -
  • - ))} -
+ + + )} - - {/* Render the footer if it exists */} - {footer && ( -
- {footer} -
- )} - - + +
+
+
+ + + {subject} + +
+ + + +
+
+
+ {body.split('\n').map((line, index) => ( +
+ {line} +
+ ))} +
+
); }; DiffCommit.propTypes = { commitMessage: PropTypes.shape({ - type: PropTypes.string, - scope: PropTypes.string, subject: PropTypes.string.isRequired, - body: PropTypes.string, - footer: PropTypes.string + body: PropTypes.string }) }; diff --git a/frontend/src/components/settings/git/status/ViewChanges.jsx b/frontend/src/components/settings/git/status/ViewChanges.jsx index c99158f..0fef1d6 100644 --- a/frontend/src/components/settings/git/status/ViewChanges.jsx +++ b/frontend/src/components/settings/git/status/ViewChanges.jsx @@ -4,19 +4,55 @@ import Modal from '@ui/Modal'; import DiffCommit from './DiffCommit'; import {FileText} from 'lucide-react'; import useChangeParser from '@hooks/useChangeParser'; +import {COMMIT_TYPES, FILE_TYPES, COMMIT_SCOPES} from '@constants/commits'; +import Tooltip from '@ui/Tooltip'; + +const Badge = ({icon: Icon, label, className, tooltipContent}) => ( + +
+ + {label} +
+
+); const ViewChanges = ({isOpen, onClose, change, isIncoming}) => { - // Parse the array of changes const parsedChanges = useChangeParser(change.changes || []); + const typeInfo = FILE_TYPES[change.type] || { + bg: 'bg-gray-500/10', + text: 'text-gray-400', + icon: FileText + }; + + const commitType = COMMIT_TYPES.find( + t => t.value === change.commit_message?.type + ); + const titleContent = ( -
-
- - {change.name} -
- - {change.type} +
+ + {change.name} +
+ {commitType && ( + + )} + s.label === change.type) + ?.description + } + /> +
); @@ -27,7 +63,6 @@ const ViewChanges = ({isOpen, onClose, change, isIncoming}) => { title={titleContent} width='10xl'>
- {/* If there's a commit message, show it */} {change.commit_message && ( )} diff --git a/frontend/src/components/ui/NumberInput.jsx b/frontend/src/components/ui/NumberInput.jsx new file mode 100644 index 0000000..4b77ba5 --- /dev/null +++ b/frontend/src/components/ui/NumberInput.jsx @@ -0,0 +1,151 @@ +import React, {useState} from 'react'; +import PropTypes from 'prop-types'; +import {ChevronUp, ChevronDown} from 'lucide-react'; + +const NumberInput = ({ + value, + onChange, + className = '', + step = 1, + disabled = false, + min, + max, + ...props +}) => { + const [localValue, setLocalValue] = useState(''); + const [isFocused, setIsFocused] = useState(false); + + const displayValue = isFocused ? localValue : value.toString(); + + const handleChange = e => { + const input = e.target.value; + if (input === '' || input === '-' || /^-?\d*$/.test(input)) { + setLocalValue(input); + } + }; + + const handleBlur = () => { + setIsFocused(false); + const numValue = + localValue === '' || localValue === '-' ? 0 : parseInt(localValue); + + if (min !== undefined && numValue < min) { + onChange(min); + return; + } + if (max !== undefined && numValue > max) { + onChange(max); + return; + } + + onChange(numValue); + }; + + const handleFocus = () => { + setIsFocused(true); + setLocalValue(value.toString()); + }; + + const increment = () => { + const newValue = value + step; + if (max === undefined || newValue <= max) { + onChange(newValue); + } + }; + + const decrement = () => { + const newValue = value - step; + if (min === undefined || newValue >= min) { + onChange(newValue); + } + }; + + const handleKeyDown = e => { + if (e.key === 'ArrowUp') { + e.preventDefault(); + increment(); + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + decrement(); + } + }; + + const inputClasses = [ + 'w-16 h-8 px-2 py-1 text-sm border border-gray-700', + 'rounded-l focus:outline-none', + 'bg-gray-800', + isFocused ? 'text-blue-400' : 'text-gray-300', + '[appearance:textfield]', + disabled && 'opacity-50 cursor-not-allowed', + className + ] + .filter(Boolean) + .join(' '); + + const buttonContainerClasses = [ + 'inline-flex flex-col border border-l-0 border-gray-700 rounded-r overflow-hidden h-8', + 'bg-gray-800', + disabled && 'opacity-50' + ] + .filter(Boolean) + .join(' '); + + const buttonClasses = [ + 'flex items-center justify-center h-1/2 px-1', + 'hover:bg-gray-700', + isFocused ? 'text-blue-400' : 'text-gray-400', + 'hover:text-gray-200 transition-colors', + 'disabled:opacity-50 disabled:cursor-not-allowed' + ].join(' '); + + return ( +
+ +
+ + +
+
+ ); +}; + +NumberInput.propTypes = { + /** Current number value */ + value: PropTypes.number.isRequired, + /** Handler called when value changes */ + onChange: PropTypes.func.isRequired, + /** Additional classes to apply to container */ + className: PropTypes.string, + /** Amount to increment/decrement by */ + step: PropTypes.number, + /** Whether the input is disabled */ + disabled: PropTypes.bool, + /** Minimum allowed value */ + min: PropTypes.number, + /** Maximum allowed value */ + max: PropTypes.number +}; + +export default NumberInput; diff --git a/frontend/src/components/ui/Sort.jsx b/frontend/src/components/ui/Sort.jsx new file mode 100644 index 0000000..5e3c8ff --- /dev/null +++ b/frontend/src/components/ui/Sort.jsx @@ -0,0 +1,80 @@ +import React, {useState, useRef, useEffect} from 'react'; +import PropTypes from 'prop-types'; +import {ChevronUp, ChevronDown} from 'lucide-react'; + +const Sort = ({options, value, onChange}) => { + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + const [field, direction] = value.split('-'); + + useEffect(() => { + function handleClickOutside(event) { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target) + ) { + setIsOpen(false); + } + } + document.addEventListener('mousedown', handleClickOutside); + return () => + document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const handleChange = newField => { + if (newField === field) { + onChange(`${field}-${direction === 'asc' ? 'desc' : 'asc'}`); + } else { + onChange(`${newField}-desc`); + } + setIsOpen(false); + }; + + return ( +
+
+ {direction === 'asc' ? ( + + ) : ( + + )} + +
+ + {isOpen && ( +
+
+ {options.map(option => ( + + ))} +
+
+ )} +
+ ); +}; + +Sort.propTypes = { + options: PropTypes.arrayOf( + PropTypes.shape({ + label: PropTypes.string.isRequired, + value: PropTypes.string.isRequired + }) + ).isRequired, + value: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired +}; + +export default Sort; diff --git a/frontend/src/components/ui/SortDropdown.jsx b/frontend/src/components/ui/SortDropdown.jsx new file mode 100644 index 0000000..8e52fb1 --- /dev/null +++ b/frontend/src/components/ui/SortDropdown.jsx @@ -0,0 +1,75 @@ +import React, {useState} from 'react'; +import {ChevronDown, ChevronUp, ArrowDown, ArrowUp} from 'lucide-react'; + +const SortDropdown = ({ + sortOptions, + currentSort, + onSortChange, + className = '' +}) => { + const [isOpen, setIsOpen] = useState(false); + + const toggleDropdown = () => setIsOpen(prev => !prev); + + const handleSortClick = field => { + onSortChange(field); + setIsOpen(false); + }; + + const getCurrentSortLabel = () => { + const option = sortOptions.find(opt => opt.value === currentSort.field); + return option ? option.label : 'Sort by'; + }; + + return ( +
+ + + {isOpen && ( +
+
+ {sortOptions.map(option => ( + + ))} +
+
+ )} +
+ ); +}; + +export default SortDropdown; diff --git a/frontend/src/components/ui/Tooltip.jsx b/frontend/src/components/ui/Tooltip.jsx index 5884969..11ba3b5 100644 --- a/frontend/src/components/ui/Tooltip.jsx +++ b/frontend/src/components/ui/Tooltip.jsx @@ -1,16 +1,60 @@ -import React from 'react'; +import React, {useState, useEffect} from 'react'; +import ReactDOM from 'react-dom'; const Tooltip = ({content, children}) => { + const [showTooltip, setShowTooltip] = useState(false); + const [position, setPosition] = useState({x: 0, y: 0}); + const triggerRef = React.useRef(null); + + const updatePosition = () => { + if (triggerRef.current) { + const rect = triggerRef.current.getBoundingClientRect(); + setPosition({ + x: rect.left + rect.width / 2, + y: rect.top - 10 + }); + } + }; + + useEffect(() => { + window.addEventListener('scroll', updatePosition); + window.addEventListener('resize', updatePosition); + return () => { + window.removeEventListener('scroll', updatePosition); + window.removeEventListener('resize', updatePosition); + }; + }, []); + return ( -
- {children} -
-
- {content} -
-
+ <> +
{ + updatePosition(); + setShowTooltip(true); + }} + onMouseLeave={() => setShowTooltip(false)}> + {children}
-
+ {showTooltip && + ReactDOM.createPortal( +
+
+ {content} +
+
+
, + document.body + )} + ); }; diff --git a/frontend/src/constants/commits.js b/frontend/src/constants/commits.js index bac72df..91734dc 100644 --- a/frontend/src/constants/commits.js +++ b/frontend/src/constants/commits.js @@ -1,31 +1,75 @@ +import { + Plus, // for add + Sparkles, // for create + Wrench, // for tweak + Trash, // for remove + Bug, // for fix + Code, // for regex + FileJson, // for format + Settings // for profile +} from 'lucide-react'; + export const COMMIT_TYPES = [ { value: 'create', label: 'Create', - description: 'Building entirely new components or systems' + description: 'Building entirely new components or systems', + icon: Sparkles, + bg: 'bg-green-500/10', + text: 'text-green-400' }, { value: 'add', label: 'Add', - description: 'Adding entries to existing systems' + description: 'Adding entries to existing systems', + icon: Plus, + bg: 'bg-blue-500/10', + text: 'text-blue-400' }, { value: 'tweak', label: 'Tweak', - description: 'Fine-tuning and adjustments to existing components' + description: 'Fine-tuning and adjustments to existing components', + icon: Wrench, + bg: 'bg-amber-500/10', + text: 'text-amber-400' }, { value: 'remove', label: 'Remove', - description: 'Removing components or features from the system' + description: 'Removing components or features from the system', + icon: Trash, + bg: 'bg-red-500/10', + text: 'text-red-400' }, { value: 'fix', label: 'Fix', - description: 'Corrections and bug fixes' + description: 'Corrections and bug fixes', + icon: Bug, + bg: 'bg-purple-500/10', + text: 'text-purple-400' } ]; +export const FILE_TYPES = { + 'Regex Pattern': { + bg: 'bg-green-500/10', + text: 'text-green-400', + icon: Code + }, + 'Custom Format': { + bg: 'bg-blue-500/10', + text: 'text-blue-400', + icon: FileJson + }, + 'Quality Profile': { + bg: 'bg-amber-500/10', + text: 'text-amber-400', + icon: Settings + } +}; + export const COMMIT_SCOPES = [ { value: 'regex', diff --git a/frontend/src/hooks/useSorting.js b/frontend/src/hooks/useSorting.js new file mode 100644 index 0000000..4959b8d --- /dev/null +++ b/frontend/src/hooks/useSorting.js @@ -0,0 +1,46 @@ +// hooks/useSorting.js +import {useState, useCallback} from 'react'; + +export const useSorting = initialSortConfig => { + const [sortConfig, setSortConfig] = useState(initialSortConfig); + + const sortData = useCallback( + data => { + if (!sortConfig.field) return data; + + return [...data].sort((a, b) => { + const aValue = a[sortConfig.field]; + const bValue = b[sortConfig.field]; + + if (aValue === null || aValue === undefined) return 1; + if (bValue === null || bValue === undefined) return -1; + + // If we're sorting numbers and they're equal, sort by name + if (typeof aValue === 'number' && aValue === bValue) { + return a.name.localeCompare(b.name); + } + + const comparison = + aValue < bValue ? -1 : aValue > bValue ? 1 : 0; + return sortConfig.direction === 'asc' + ? comparison + : -comparison; + }); + }, + [sortConfig] + ); + + const updateSort = useCallback(field => { + setSortConfig(prevConfig => ({ + field, + direction: + prevConfig.field === field + ? prevConfig.direction === 'asc' + ? 'desc' + : 'asc' + : prevConfig.direction + })); + }, []); + + return {sortConfig, updateSort, sortData}; +};