feature: merge conflict detection and resolution (#6)

- pulls now correctly identify merge conflicts and enter a merge state
- user resolves each file individually
- commit resolve merge state
- allows users to keep custom changes and pull in updates
- improve commit message component
- seperated commit / add functionality
This commit is contained in:
Sam Chau
2024-11-18 08:30:42 +10:30
committed by Sam Chau
parent 6afb274e41
commit ca84a1c95b
45 changed files with 4102 additions and 1444 deletions

2
.gitignore vendored
View File

@@ -8,6 +8,8 @@ __pycache__/
# Environment variables # Environment variables
.env .env
.env.1
.env.2
# OS files # OS files
.DS_Store .DS_Store

View File

@@ -1,5 +1,3 @@
# app/__init__.py
import os import os
from flask import Flask, jsonify from flask import Flask, jsonify
from flask_cors import CORS from flask_cors import CORS
@@ -12,6 +10,8 @@ from .settings_utils import create_empty_settings_if_not_exists, load_settings
REGEX_DIR = os.path.join('data', 'db', 'regex_patterns') REGEX_DIR = os.path.join('data', 'db', 'regex_patterns')
FORMAT_DIR = os.path.join('data', 'db', 'custom_formats') FORMAT_DIR = os.path.join('data', 'db', 'custom_formats')
PROFILE_DIR = os.path.join('data', 'db', 'profiles') PROFILE_DIR = os.path.join('data', 'db', 'profiles')
DATA_DIR = '/app/data'
def create_app(): def create_app():
app = Flask(__name__) app = Flask(__name__)
@@ -35,7 +35,9 @@ def create_app():
return app return app
def initialize_directories(): def initialize_directories():
os.makedirs(REGEX_DIR, exist_ok=True) os.makedirs(REGEX_DIR, exist_ok=True)
os.makedirs(FORMAT_DIR, exist_ok=True) os.makedirs(FORMAT_DIR, exist_ok=True)
os.makedirs(PROFILE_DIR, exist_ok=True) os.makedirs(PROFILE_DIR, exist_ok=True)
os.makedirs(DATA_DIR, exist_ok=True)

View File

@@ -1,6 +1,5 @@
from flask import Blueprint, request, jsonify from flask import Blueprint, request, jsonify
from .status.status import get_git_status from .status.status import get_git_status
from .status.diff import get_diff
from .branches.manager import Branch_Manager from .branches.manager import Branch_Manager
from .operations.manager import GitOperations from .operations.manager import GitOperations
from .repo.unlink import unlink_repository from .repo.unlink import unlink_repository
@@ -74,6 +73,8 @@ def create_branch():
return jsonify({'success': True, **result}), 200 return jsonify({'success': True, **result}), 200
else: else:
logger.error(f"Failed to create branch: {result}") logger.error(f"Failed to create branch: {result}")
if 'merging' in result.get('error', '').lower():
return jsonify({'success': False, 'error': result}), 409
return jsonify({'success': False, 'error': result}), 400 return jsonify({'success': False, 'error': result}), 400
@@ -99,6 +100,8 @@ def checkout_branch():
return jsonify({'success': True, **result}), 200 return jsonify({'success': True, **result}), 200
else: else:
logger.error(f"Failed to checkout branch: {result}") logger.error(f"Failed to checkout branch: {result}")
if 'merging' in result.get('error', '').lower():
return jsonify({'success': False, 'error': result}), 409
return jsonify({'success': False, 'error': result}), 400 return jsonify({'success': False, 'error': result}), 400
@@ -111,6 +114,8 @@ def delete_branch(branch_name):
return jsonify({'success': True, **result}), 200 return jsonify({'success': True, **result}), 200
else: else:
logger.error(f"Failed to delete branch: {result}") logger.error(f"Failed to delete branch: {result}")
if 'merging' in result.get('error', '').lower():
return jsonify({'success': False, 'error': result}), 409
return jsonify({'success': False, 'error': result}), 400 return jsonify({'success': False, 'error': result}), 400
@@ -129,23 +134,43 @@ def push_branch():
if success: if success:
return jsonify({"success": True, "data": result}), 200 return jsonify({"success": True, "data": result}), 200
else: else:
return jsonify({"success": False, "error": result["error"]}), 500 if 'merging' in result.get('error', '').lower():
return jsonify({'success': False, 'error': result}), 409
return jsonify({'success': False, 'error': result["error"]}), 500
@bp.route('/commit', methods=['POST'])
def commit_files():
files = request.json.get('files', [])
user_commit_message = request.json.get('commit_message', "Commit changes")
logger.debug(f"Received request to commit files: {files}")
commit_message = generate_commit_message(user_commit_message, files)
success, message = git_operations.commit(files, commit_message)
if success:
logger.debug("Successfully committed files")
return jsonify({'success': True, 'message': message}), 200
else:
logger.error(f"Error committing files: {message}")
return jsonify({'success': False, 'error': message}), 400
@bp.route('/push', methods=['POST']) @bp.route('/push', methods=['POST'])
def push_files(): def push_files():
files = request.json.get('files', []) logger.debug("Received request to push changes")
user_commit_message = request.json.get('commit_message', success, message = git_operations.push()
"Commit and push staged files")
logger.debug(f"Received request to push files: {files}")
commit_message = generate_commit_message(user_commit_message, files)
success, message = git_operations.push(files, commit_message)
if success: if success:
logger.debug("Successfully committed and pushed files") logger.debug("Successfully pushed changes")
return jsonify({'success': True, 'message': message}), 200 return jsonify({'success': True, 'message': message}), 200
else: else:
logger.error(f"Error pushing files: {message}") logger.error(f"Error pushing changes: {message}")
# If message is a dict, it's a structured error
if isinstance(message, dict):
return jsonify({'success': False, 'error': message}), 400 return jsonify({'success': False, 'error': message}), 400
# Otherwise it's a string error
return jsonify({'success': False, 'error': str(message)}), 400
@bp.route('/revert', methods=['POST']) @bp.route('/revert', methods=['POST'])
@@ -193,27 +218,48 @@ def delete_file():
@bp.route('/pull', methods=['POST']) @bp.route('/pull', methods=['POST'])
def pull_branch(): def pull_branch():
branch_name = request.json.get('branch') branch_name = request.json.get('branch')
success, message = git_operations.pull(branch_name) success, response = git_operations.pull(branch_name)
if success:
return jsonify({'success': True, 'message': message}), 200
else:
logger.error(f"Error pulling branch: {message}")
return jsonify({'success': False, 'error': message}), 400
# Handle different response types
@bp.route('/diff', methods=['POST']) if isinstance(response, dict):
def diff_file(): if response.get('state') == 'resolve':
file_path = request.json.get('file_path') # Merge conflict is now a success case with state='resolve'
try: return jsonify({
diff = get_diff(REPO_PATH, file_path) 'success': True,
logger.debug(f"Diff for file {file_path}: {diff}") 'state': 'resolve',
return jsonify({'success': True, 'diff': diff if diff else ""}), 200 'message': response['message'],
except Exception as e: 'details': response['details']
logger.error(f"Error getting diff for file {file_path}: {str(e)}", }), 200
exc_info=True) elif response.get('state') == 'error':
# Handle error states
return jsonify({ return jsonify({
'success': False, 'success': False,
'error': f"Error getting diff for file: {str(e)}" 'state': 'error',
'message': response['message'],
'details': response.get('details', {})
}), 409 if response.get('type') in [
'merge_conflict', 'uncommitted_changes'
] else 400
elif response.get('state') == 'complete':
# Normal success case
return jsonify({
'success': True,
'state': 'complete',
'message': response['message'],
'details': response.get('details', {})
}), 200
# Fallback for string responses or unexpected formats
if success:
return jsonify({
'success': True,
'state': 'complete',
'message': response
}), 200
return jsonify({
'success': False,
'state': 'error',
'message': str(response)
}), 400 }), 400
@@ -227,6 +273,16 @@ def handle_stage_files():
return jsonify({'success': False, 'error': message}), 400 return jsonify({'success': False, 'error': message}), 400
@bp.route('/unstage', methods=['POST'])
def handle_unstage_files():
files = request.json.get('files', [])
success, message = git_operations.unstage(files)
if success:
return jsonify({'success': True, 'message': message}), 200
else:
return jsonify({'success': False, 'error': message}), 400
@bp.route('/unlink', methods=['POST']) @bp.route('/unlink', methods=['POST'])
def unlink(): def unlink():
data = request.get_json() data = request.get_json()
@@ -239,20 +295,67 @@ def unlink():
def generate_commit_message(user_message, files): def generate_commit_message(user_message, files):
file_changes = [] return user_message
for file in files:
if 'regex_patterns' in file:
file_changes.append(f"Update regex pattern: {file.split('/')[-1]}")
elif 'custom_formats' in file:
file_changes.append(f"Update custom format: {file.split('/')[-1]}")
else:
file_changes.append(f"Update: {file}")
commit_message = f"{user_message}\n\nChanges:\n" + "\n".join(file_changes)
return commit_message
@bp.route('/dev', methods=['GET']) @bp.route('/dev', methods=['GET'])
def dev_mode(): def dev_mode():
is_dev_mode = check_dev_mode() is_dev_mode = check_dev_mode()
return jsonify({'devMode': is_dev_mode}), 200 return jsonify({'devMode': is_dev_mode}), 200
@bp.route('/resolve', methods=['POST'])
def resolve_conflicts():
logger.debug("Received request to resolve conflicts")
resolutions = request.json.get('resolutions')
if not resolutions:
return jsonify({
'success': False,
'error': "Resolutions are required"
}), 400
result = git_operations.resolve(resolutions)
if result.get('success'):
logger.debug("Successfully resolved conflicts")
return jsonify(result), 200
else:
logger.error(f"Error resolving conflicts: {result.get('error')}")
return jsonify(result), 400
@bp.route('/merge/finalize', methods=['POST'])
def finalize_merge():
"""
Route to finalize a merge after all conflicts have been resolved.
Expected to be called only after all conflicts are resolved and changes are staged.
"""
logger.debug("Received request to finalize merge")
result = git_operations.finalize_merge()
if result.get('success'):
logger.debug(
f"Successfully finalized merge with files: {result.get('committed_files', [])}"
)
return jsonify({
'success': True,
'message': result.get('message'),
'committed_files': result.get('committed_files', [])
}), 200
else:
logger.error(f"Error finalizing merge: {result.get('error')}")
return jsonify({'success': False, 'error': result.get('error')}), 400
@bp.route('/merge/abort', methods=['POST'])
def abort_merge():
logger.debug("Received request to abort merge")
success, message = git_operations.abort_merge()
if success:
logger.debug("Successfully aborted merge")
return jsonify({'success': True, 'message': message}), 200
else:
logger.error(f"Error aborting merge: {message}")
return jsonify({'success': False, 'error': message}), 400

View File

@@ -1,23 +1,45 @@
# git/branches/branches.py # git/branches/branches.py
import git import git
import os
from .create import create_branch from .create import create_branch
from .checkout import checkout_branch from .checkout import checkout_branch
from .delete import delete_branch from .delete import delete_branch
from .get import get_branches, get_current_branch from .get import get_branches, get_current_branch
from .push import push_branch_to_remote from .push import push_branch_to_remote
class Branch_Manager: class Branch_Manager:
def __init__(self, repo_path): def __init__(self, repo_path):
self.repo_path = repo_path self.repo_path = repo_path
def is_merging(self):
repo = git.Repo(self.repo_path)
return os.path.exists(os.path.join(repo.git_dir, 'MERGE_HEAD'))
def create(self, branch_name, base_branch='main'): def create(self, branch_name, base_branch='main'):
if self.is_merging():
return False, {
'error':
'Cannot create branch while merging. Resolve conflicts first.'
}
return create_branch(self.repo_path, branch_name, base_branch) return create_branch(self.repo_path, branch_name, base_branch)
def checkout(self, branch_name): def checkout(self, branch_name):
if self.is_merging():
return False, {
'error':
'Cannot checkout while merging. Resolve conflicts first.'
}
return checkout_branch(self.repo_path, branch_name) return checkout_branch(self.repo_path, branch_name)
def delete(self, branch_name): def delete(self, branch_name):
if self.is_merging():
return False, {
'error':
'Cannot delete branch while merging. Resolve conflicts first.'
}
return delete_branch(self.repo_path, branch_name) return delete_branch(self.repo_path, branch_name)
def get_all(self): def get_all(self):
@@ -27,4 +49,8 @@ class Branch_Manager:
return get_current_branch(self.repo_path) return get_current_branch(self.repo_path)
def push(self, branch_name): def push(self, branch_name):
if self.is_merging():
return False, {
'error': 'Cannot push while merging. Resolve conflicts first.'
}
return push_branch_to_remote(self.repo_path, branch_name) return push_branch_to_remote(self.repo_path, branch_name)

View File

@@ -1,10 +1,10 @@
# git/operations/commit.py # git/operations/commit.py
import git import git
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def commit_changes(repo_path, files, message): def commit_changes(repo_path, files, message):
try: try:
repo = git.Repo(repo_path) repo = git.Repo(repo_path)

View File

@@ -1,5 +1,3 @@
# git/operations/operations.py
import git import git
from .stage import stage_files from .stage import stage_files
from .commit import commit_changes from .commit import commit_changes
@@ -7,19 +5,50 @@ from .push import push_changes
from .revert import revert_file, revert_all from .revert import revert_file, revert_all
from .delete import delete_file from .delete import delete_file
from .pull import pull_branch from .pull import pull_branch
from .unstage import unstage_files
from .merge import abort_merge, finalize_merge
from .resolve import resolve_conflicts
import os
import logging
logger = logging.getLogger(__name__)
class GitOperations: class GitOperations:
def __init__(self, repo_path): def __init__(self, repo_path):
self.repo_path = repo_path self.repo_path = repo_path
self.configure_git()
def configure_git(self):
try:
repo = git.Repo(self.repo_path)
# Get user info from env variables
git_name = os.environ.get('GITHUB_USER_NAME')
git_email = os.environ.get('GITHUB_USER_EMAIL')
logger.debug(f"Git config - Name: {git_name}, Email: {git_email}"
) # Add this
if git_name and git_email:
with repo.config_writer() as config:
config.set_value('user', 'name', git_name)
config.set_value('user', 'email', git_email)
logger.debug("Git identity configured successfully")
except Exception as e:
logger.error(f"Error configuring git user: {str(e)}")
def stage(self, files): def stage(self, files):
return stage_files(self.repo_path, files) return stage_files(self.repo_path, files)
def unstage(self, files):
return unstage_files(self.repo_path, files)
def commit(self, files, message): def commit(self, files, message):
return commit_changes(self.repo_path, files, message) return commit_changes(self.repo_path, files, message)
def push(self, files, message): def push(self):
return push_changes(self.repo_path, files, message) return push_changes(self.repo_path)
def revert(self, file_path): def revert(self, file_path):
return revert_file(self.repo_path, file_path) return revert_file(self.repo_path, file_path)
@@ -32,3 +61,14 @@ class GitOperations:
def pull(self, branch_name): def pull(self, branch_name):
return pull_branch(self.repo_path, branch_name) return pull_branch(self.repo_path, branch_name)
def finalize_merge(self):
repo = git.Repo(self.repo_path)
return finalize_merge(repo)
def abort_merge(self):
return abort_merge(self.repo_path)
def resolve(self, resolutions):
repo = git.Repo(self.repo_path)
return resolve_conflicts(repo, resolutions)

View File

@@ -0,0 +1,96 @@
# git/operations/merge.py
import git
import logging
import os
from typing import Dict, Any, Tuple
from .commit import commit_changes
logger = logging.getLogger(__name__)
def finalize_merge(repo) -> Dict[str, Any]:
"""
Finalize a merge by committing all staged files after conflict resolution.
"""
try:
if not os.path.exists(os.path.join(repo.git_dir, 'MERGE_HEAD')):
return {
'success': False,
'error': 'Not currently in a merge state'
}
# Get unmerged files
unmerged_files = []
status = repo.git.status('--porcelain', '-z').split('\0')
for item in status:
if item and len(item) >= 4:
x, y, file_path = item[0], item[1], item[3:]
if 'U' in (x, y):
unmerged_files.append(file_path)
# Force update the index for unmerged files
for file_path in unmerged_files:
# Remove from index first
try:
repo.git.execute(['git', 'reset', '--', file_path])
except git.GitCommandError:
pass
# Add back to index
try:
repo.git.execute(['git', 'add', '--', file_path])
except git.GitCommandError as e:
logger.error(f"Error adding file {file_path}: {str(e)}")
return {
'success': False,
'error': f"Failed to stage resolved file {file_path}"
}
# Create commit message
commit_message = "Merge complete: resolved conflicts"
# Commit
try:
repo.git.commit('-m', commit_message)
logger.info("Successfully finalized merge")
return {'success': True, 'message': 'Merge completed successfully'}
except git.GitCommandError as e:
logger.error(f"Git command error during commit: {str(e)}")
return {
'success': False,
'error': f"Failed to commit merge: {str(e)}"
}
except Exception as e:
logger.error(f"Failed to finalize merge: {str(e)}")
return {
'success': False,
'error': f"Failed to finalize merge: {str(e)}"
}
def abort_merge(repo_path):
try:
repo = git.Repo(repo_path)
# Try aborting the merge using git merge --abort
try:
repo.git.execute(['git', 'merge', '--abort'])
return True, "Merge aborted successfully"
except git.GitCommandError as e:
logger.warning(
"Error aborting merge with 'git merge --abort'. Trying 'git reset --hard'."
)
# If git merge --abort fails, try resetting to the previous commit using git reset --hard
try:
repo.git.execute(['git', 'reset', '--hard'])
return True, "Merge aborted and repository reset to the previous commit"
except git.GitCommandError as e:
logger.exception(
"Error resetting repository with 'git reset --hard'")
return False, str(e)
except Exception as e:
logger.exception("Unexpected error aborting merge")
return False, str(e)

View File

@@ -1,15 +1,49 @@
# git/operations/pull.py
import git import git
import logging import logging
from git import GitCommandError
from ..status.status import get_git_status
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def pull_branch(repo_path, branch_name): def pull_branch(repo_path, branch_name):
try: try:
repo = git.Repo(repo_path) repo = git.Repo(repo_path)
repo.git.pull('origin', branch_name)
return True, f"Successfully pulled changes for branch {branch_name}." # Check for uncommitted changes first
if repo.is_dirty(untracked_files=True):
return False, {
'type': 'uncommitted_changes',
'message':
'Cannot pull: You have uncommitted local changes that would be lost',
'details': 'Please commit or stash your changes before pulling'
}
try:
# Fetch first to get remote changes
repo.remotes.origin.fetch()
try:
# Try to pull with explicit merge strategy
repo.git.pull('origin', branch_name, '--no-rebase')
return True, "Successfully pulled changes for branch {branch_name}"
except GitCommandError as e:
if "CONFLICT" in str(e):
# Don't reset - let Git stay in merge conflict state
return True, {
'state': 'resolve',
'type': 'merge_conflict',
'message':
'Repository is now in conflict resolution state. Please resolve conflicts to continue merge.',
'details': 'Please resolve conflicts to continue merge'
}
raise e
except GitCommandError as e:
logger.error(f"Git command error pulling branch: {str(e)}",
exc_info=True)
return False, f"Error pulling branch: {str(e)}"
except Exception as e: except Exception as e:
logger.error(f"Error pulling branch: {str(e)}", exc_info=True) logger.error(f"Error pulling branch: {str(e)}", exc_info=True)
return False, f"Error pulling branch: {str(e)}" return False, f"Error pulling branch: {str(e)}"

View File

@@ -1,16 +1,14 @@
# git/operations/push.py # git/operations/push.py
import git import git
import logging import logging
from .commit import commit_changes
from ..auth.authenticate import check_dev_mode, get_github_token from ..auth.authenticate import check_dev_mode, get_github_token
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def push_changes(repo_path, files, message): def push_changes(repo_path):
try: try:
# Check if we're in dev mode # Check if we're in dev mode - keep this check for push operations
if not check_dev_mode(): if not check_dev_mode():
logger.warning("Not in dev mode. Push operation not allowed.") logger.warning("Not in dev mode. Push operation not allowed.")
return False, "Push operation not allowed in production mode." return False, "Push operation not allowed in production mode."
@@ -22,37 +20,36 @@ def push_changes(repo_path, files, message):
return False, "GitHub token not available" return False, "GitHub token not available"
repo = git.Repo(repo_path) repo = git.Repo(repo_path)
# Commit changes
commit_success, commit_message = commit_changes(
repo_path, files, message)
if not commit_success:
return False, commit_message
# Modify the remote URL to include the token
origin = repo.remote(name='origin') origin = repo.remote(name='origin')
auth_repo_url = origin.url.replace('https://', auth_repo_url = origin.url.replace('https://',
f'https://{github_token}@') f'https://{github_token}@')
origin.set_url(auth_repo_url) origin.set_url(auth_repo_url)
try:
# Push changes # Push changes
push_info = origin.push() push_info = origin.push()
# Restore the original remote URL (without the token)
origin.set_url(
origin.url.replace(f'https://{github_token}@', 'https://'))
# Check if the push was successful
if push_info and push_info[0].flags & push_info[0].ERROR: if push_info and push_info[0].flags & push_info[0].ERROR:
raise git.GitCommandError("git push", push_info[0].summary) raise git.GitCommandError("git push", push_info[0].summary)
return True, "Successfully pushed changes." return True, "Successfully pushed changes."
except git.GitCommandError as e:
error_msg = str(e)
if "non-fast-forward" in error_msg:
return False, {
"type":
"non_fast_forward",
"message":
"Push rejected: Remote contains work that you do not have locally. Please pull the latest changes first."
}
raise e
finally:
# Always restore the original URL (without token)
origin.set_url(
origin.url.replace(f'https://{github_token}@', 'https://'))
except git.GitCommandError as e: except git.GitCommandError as e:
logger.error(f"Git command error pushing changes: {str(e)}", logger.error(f"Git command error pushing changes: {str(e)}",
exc_info=True) exc_info=True)
return False, f"Error pushing changes: {str(e)}" return False, str(e)
except Exception as e: except Exception as e:
logger.error(f"Error pushing changes: {str(e)}", exc_info=True) logger.error(f"Error pushing changes: {str(e)}", exc_info=True)
return False, f"Error pushing changes: {str(e)}" return False, f"Error pushing changes: {str(e)}"

View File

@@ -0,0 +1,223 @@
# git/operations/resolve.py
import yaml
from git import GitCommandError
import logging
from typing import Dict, Any
import os
from copy import deepcopy
logger = logging.getLogger(__name__)
def get_version_data(repo, ref, file_path):
"""Get YAML data from a specific version of a file."""
try:
content = repo.git.show(f'{ref}:{file_path}')
return yaml.safe_load(content) if content else None
except GitCommandError:
return None
def resolve_conflicts(
repo, resolutions: Dict[str, Dict[str, str]]) -> Dict[str, Any]:
logger.debug(f"Received resolutions for files: {list(resolutions.keys())}")
"""
Resolve merge conflicts based on provided resolutions.
"""
# Get list of conflicting files
try:
status = repo.git.status('--porcelain', '-z').split('\0')
conflicts = []
for item in status:
if not item or len(item) < 4:
continue
x, y, file_path = item[0], item[1], item[3:]
if 'U' in (x, y) or (x == 'D' and y == 'D'):
conflicts.append(file_path)
# Validate resolutions are for actual conflicting files
for file_path in resolutions:
if file_path not in conflicts:
return {
'success': False,
'error': f"File not in conflict: {file_path}"
}
except Exception as e:
return {
'success': False,
'error': f"Failed to get conflicts: {str(e)}"
}
# Store initial states for rollback
initial_states = {}
for file_path in resolutions:
try:
# Join with repo path
full_path = os.path.join(repo.working_dir, file_path)
with open(full_path, 'r') as f:
initial_states[file_path] = f.read()
except Exception as e:
return {
'success': False,
'error': f"Couldn't read file {file_path}: {str(e)}"
}
try:
results = {}
for file_path, field_resolutions in resolutions.items():
# Get all three versions
base_data = get_version_data(repo, 'HEAD^', file_path)
ours_data = get_version_data(repo, 'HEAD', file_path)
theirs_data = get_version_data(repo, 'MERGE_HEAD', file_path)
if not base_data or not ours_data or not theirs_data:
raise Exception(f"Couldn't get all versions of {file_path}")
# Start with a deep copy of ours_data to preserve all fields
resolved_data = deepcopy(ours_data)
# Track changes
kept_values = {}
discarded_values = {}
# Handle each resolution field
for field, choice in field_resolutions.items():
if field.startswith('custom_format_'):
# Extract the custom_format ID
try:
cf_id = int(field.split('_')[-1])
except ValueError:
raise Exception(
f"Invalid custom_format ID in field: {field}")
# Find the custom_format in ours and theirs
ours_cf = next(
(item for item in ours_data.get('custom_formats', [])
if item['id'] == cf_id), None)
theirs_cf = next(
(item
for item in theirs_data.get('custom_formats', [])
if item['id'] == cf_id), None)
if choice == 'local' and ours_cf:
resolved_cf = ours_cf
kept_values[field] = ours_cf
discarded_values[field] = theirs_cf
elif choice == 'incoming' and theirs_cf:
resolved_cf = theirs_cf
kept_values[field] = theirs_cf
discarded_values[field] = ours_cf
else:
raise Exception(
f"Invalid choice or missing custom_format ID {cf_id} for field: {field}"
)
# Update the resolved_data's custom_formats
resolved_cf_list = resolved_data.get('custom_formats', [])
for idx, item in enumerate(resolved_cf_list):
if item['id'] == cf_id:
resolved_cf_list[idx] = resolved_cf
break
else:
# If not found, append it
resolved_cf_list.append(resolved_cf)
resolved_data['custom_formats'] = resolved_cf_list
elif field.startswith('tag_'):
# Extract the tag name
tag_name = field[len('tag_'):]
current_tags = set(resolved_data.get('tags', []))
if choice == 'local':
# Assume 'local' means keeping the tag from ours
if tag_name in ours_data.get('tags', []):
current_tags.add(tag_name)
kept_values[field] = 'local'
discarded_values[field] = 'incoming'
else:
current_tags.discard(tag_name)
kept_values[field] = 'none'
discarded_values[field] = 'incoming'
elif choice == 'incoming':
# Assume 'incoming' means keeping the tag from theirs
if tag_name in theirs_data.get('tags', []):
current_tags.add(tag_name)
kept_values[field] = 'incoming'
discarded_values[field] = 'local'
else:
current_tags.discard(tag_name)
kept_values[field] = 'none'
discarded_values[field] = 'local'
else:
raise Exception(
f"Invalid choice for tag field: {field}")
resolved_data['tags'] = sorted(current_tags)
else:
# Handle other fields
field_key = field
if choice == 'local':
resolved_data[field_key] = ours_data.get(field_key)
kept_values[field_key] = ours_data.get(field_key)
discarded_values[field_key] = theirs_data.get(
field_key)
elif choice == 'incoming':
resolved_data[field_key] = theirs_data.get(field_key)
kept_values[field_key] = theirs_data.get(field_key)
discarded_values[field_key] = ours_data.get(field_key)
else:
raise Exception(f"Invalid choice for field: {field}")
# Write resolved version using full path
full_path = os.path.join(repo.working_dir, file_path)
with open(full_path, 'w') as f:
yaml.safe_dump(resolved_data, f, default_flow_style=False)
# Stage the resolved file
repo.index.add([file_path])
results[file_path] = {
'kept_values': kept_values,
'discarded_values': discarded_values
}
# Log the base, ours, theirs, and resolved versions
logger.info(f"Successfully resolved {file_path}")
logger.info(
f"Base version:\n{yaml.safe_dump(base_data, default_flow_style=False)}"
)
logger.info(
f"Ours version:\n{yaml.safe_dump(ours_data, default_flow_style=False)}"
)
logger.info(
f"Theirs version:\n{yaml.safe_dump(theirs_data, default_flow_style=False)}"
)
logger.info(
f"Resolved version:\n{yaml.safe_dump(resolved_data, default_flow_style=False)}"
)
logger.debug("==== Status after resolve_conflicts ====")
status_output = repo.git.status('--porcelain', '-z').split('\0')
for item in status_output:
if item:
logger.debug(f"File status: {item}")
logger.debug("=======================================")
return {'success': True, 'results': results}
except Exception as e:
# Rollback on any error using full paths
for file_path, initial_state in initial_states.items():
try:
full_path = os.path.join(repo.working_dir, file_path)
with open(full_path, 'w') as f:
f.write(initial_state)
except Exception as rollback_error:
logger.error(
f"Failed to rollback {file_path}: {str(rollback_error)}")
logger.error(f"Failed to resolve conflicts: {str(e)}")
return {'success': False, 'error': str(e)}

View File

@@ -1,30 +1,14 @@
# git/operations/stage.py # git/operations/stage.py
import git import git
import logging import logging
from ..auth.authenticate import check_dev_mode, get_github_token
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def stage_files(repo_path, files): def stage_files(repo_path, files):
try: try:
# Check if we're in dev mode
if not check_dev_mode():
logger.warning("Not in dev mode. Staging operation not allowed.")
return False, "Staging operation not allowed in production mode."
# Get the GitHub token
github_token = get_github_token()
if not github_token:
logger.error("GitHub token not available")
return False, "GitHub token not available"
repo = git.Repo(repo_path) repo = git.Repo(repo_path)
# Authenticate with GitHub token
with repo.git.custom_environment(GIT_ASKPASS='echo',
GIT_USERNAME=github_token):
if not files: if not files:
repo.git.add(A=True) repo.git.add(A=True)
message = "All changes have been staged." message = "All changes have been staged."
@@ -38,7 +22,6 @@ def stage_files(repo_path, files):
logger.error(f"Git command error staging files: {str(e)}", logger.error(f"Git command error staging files: {str(e)}",
exc_info=True) exc_info=True)
return False, f"Error staging files: {str(e)}" return False, f"Error staging files: {str(e)}"
except Exception as e: except Exception as e:
logger.error(f"Error staging files: {str(e)}", exc_info=True) logger.error(f"Error staging files: {str(e)}", exc_info=True)
return False, f"Error staging files: {str(e)}" return False, f"Error staging files: {str(e)}"

View File

@@ -0,0 +1,52 @@
from dataclasses import dataclass
from typing import List, Dict, Optional, Literal
from enum import Enum
class FileType(str, Enum):
REGEX = "regex"
CUSTOM_FORMAT = "custom format"
QUALITY_PROFILE = "quality profile"
class ResolutionChoice(str, Enum):
LOCAL = "local"
INCOMING = "incoming"
@dataclass
class TagConflict:
tag: str
local_status: Literal["Present", "Absent"]
incoming_status: Literal["Present", "Absent"]
resolution: Optional[ResolutionChoice] = None
@dataclass
class FormatConflict:
format_id: str
local_score: Optional[int]
incoming_score: Optional[int]
resolution: Optional[ResolutionChoice] = None
@dataclass
class GeneralConflict:
key: str
local_value: any
incoming_value: any
resolution: Optional[ResolutionChoice] = None
@dataclass
class FileResolution:
file_type: FileType
filename: str
tags: List[TagConflict]
formats: List[FormatConflict]
general: List[GeneralConflict]
@dataclass
class ResolutionRequest:
resolutions: Dict[str, FileResolution]

View File

@@ -0,0 +1,15 @@
# git/operations/unstage.py
import git
import logging
logger = logging.getLogger(__name__)
def unstage_files(repo_path, files):
try:
repo = git.Repo(repo_path)
repo.index.reset(files=files)
return True, "Successfully unstaged files."
except Exception as e:
logger.error(f"Error unstaging files: {str(e)}", exc_info=True)
return False, f"Error unstaging files: {str(e)}"

View File

@@ -1,35 +0,0 @@
import os
import git
import logging
logger = logging.getLogger(__name__)
def get_diff(repo_path, file_path):
try:
repo = git.Repo(repo_path)
branch = repo.active_branch.name
remote_branch = f'origin/{branch}' # Assuming the remote is 'origin'
# Fetch the latest changes from the remote
repo.git.fetch()
# Check if the file is untracked
untracked_files = repo.untracked_files
if file_path in untracked_files:
with open(os.path.join(repo.working_dir, file_path), 'r') as file:
content = file.read()
diff = "\n".join([f"+{line}" for line in content.splitlines()])
else:
# Check if the file is deleted
if not os.path.exists(os.path.join(repo.working_dir, file_path)):
diff = "-Deleted File"
else:
# Get the diff between the local and the remote branch
diff = repo.git.diff(f'{remote_branch}', file_path)
return diff
except Exception as e:
logger.error(f"Error getting diff for file {file_path}: {str(e)}",
exc_info=True)
raise e

View File

@@ -1,51 +1,256 @@
# git/status/incoming_changes.py # git/status/incoming_changes.py
import os import os
import logging import logging
from .utils import extract_data_from_yaml, determine_type, parse_commit_message import yaml
from git import GitCommandError
from .utils import determine_type, parse_commit_message, extract_data_from_yaml
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def check_merge_conflict(repo, branch, file_path):
"""
Checks if an incoming change will conflict with local changes.
Returns True if there would be a merge conflict, False otherwise.
"""
try:
# Check for both uncommitted and committed changes
has_changes = False
# 1. Check uncommitted changes
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
# 2. Check committed changes not in remote
try:
# Get the merge-base (common ancestor) of local and remote
merge_base = repo.git.merge_base('HEAD',
f'origin/{branch}').strip()
# Check if there are any commits affecting this file between merge-base and HEAD
committed_changes = repo.git.log(f'{merge_base}..HEAD',
'--',
file_path,
ignore_missing=True).strip()
has_changes = has_changes or bool(committed_changes)
except GitCommandError as e:
logger.warning(f"Error checking committed changes: {str(e)}")
if has_changes:
try:
# Use correct merge-tree syntax
merge_test = repo.git.merge_tree('--write-tree', 'HEAD',
f'origin/{branch}')
# Check if this specific file has conflicts in the merge result
return any(
line.startswith('<<<<<<< ')
for line in merge_test.splitlines() if file_path in line)
except GitCommandError as e:
logger.warning(
f"Merge tree test failed, assuming conflict: {str(e)}")
return True # If merge-tree fails, assume there's a conflict
return False
except Exception as e:
logger.error(
f"Error checking merge conflict for {file_path}: {str(e)}")
return False # Default to no conflict if we can't determine
def get_file_data(repo, file_path, ref):
try:
content = repo.git.show(f'{ref}:{file_path}')
return yaml.safe_load(content)
except GitCommandError:
logger.warning(
f"Failed to retrieve content for file: {file_path} at {ref}")
return None
def get_incoming_changes(repo, branch): def get_incoming_changes(repo, branch):
incoming_changes = [] incoming_changes = []
diff = repo.git.diff(f'HEAD...origin/{branch}', name_only=True)
changed_files = diff.split('\n') if diff else [] try:
# Get changed files between local and remote
diff_index = repo.git.diff(f'HEAD...origin/{branch}',
'--name-only').split('\n')
untracked = repo.git.ls_files('--others',
'--exclude-standard').split('\n')
changed_files = list(filter(None, set(diff_index + untracked)))
except GitCommandError as e:
logger.error(f"Error getting changed files: {str(e)}")
return []
for file_path in changed_files: for file_path in changed_files:
if file_path: if not file_path:
full_path = os.path.join(repo.working_dir, file_path) continue
file_data = extract_data_from_yaml(full_path) if os.path.exists(
full_path) else None
# Correcting the git show command try:
# Get both versions of the file
local_data = get_file_data(repo, file_path, 'HEAD')
remote_data = get_file_data(repo, file_path, f'origin/{branch}')
if local_data == remote_data:
continue
# Check for potential merge conflicts
will_conflict = check_merge_conflict(repo, branch, file_path)
# Get commit message
try:
raw_commit_message = repo.git.show(f'HEAD...origin/{branch}', raw_commit_message = repo.git.show(f'HEAD...origin/{branch}',
'--format=%B', '-s', '--format=%B', '-s', '--',
file_path).strip() file_path).strip()
parsed_commit_message = parse_commit_message( commit_message = parse_commit_message(raw_commit_message)
raw_commit_message except GitCommandError:
) # Parse commit message using the util function commit_message = {
"body": "",
"footer": "",
"scope": "",
"subject": "Unable to retrieve commit message",
"type": ""
}
if not local_data and remote_data:
status = 'New'
local_name = remote_data.get('name')
incoming_name = None
changes = [{
'key': key,
'change': 'added',
'value': value
} for key, value in remote_data.items()]
else:
status = 'Modified'
local_name = local_data.get(
'name') if local_data else os.path.basename(file_path)
incoming_name = remote_data.get(
'name') if remote_data else None
changes = compare_data(local_data, remote_data)
if not changes:
continue
file_type = determine_type(file_path)
file_id = remote_data.get('id') if remote_data else None
incoming_changes.append({ incoming_changes.append({
'name': 'commit_message': commit_message,
file_data.get('name', os.path.basename(file_path)) 'deleted': False,
if file_data else os.path.basename(file_path), 'file_path': file_path,
'id': 'id': file_id,
file_data.get('id') if file_data else None, 'modified': True,
'type': 'local_name': local_name,
determine_type(file_path), 'incoming_name': incoming_name,
'status': 'staged': False,
'Incoming', 'status': status,
'file_path': 'type': file_type,
file_path, 'changes': changes,
'commit_message': 'will_conflict':
parsed_commit_message, # Use parsed commit message will_conflict # Added conflict status per file
'staged':
False,
'modified':
True,
'deleted':
False
}) })
except Exception as e:
logger.error(
f"Error processing incoming change for {file_path}: {str(e)}")
continue
logger.info(f"Found {len(incoming_changes)} incoming changes")
return incoming_changes return incoming_changes
def compare_data(local_data, remote_data):
if local_data is None and remote_data is not None:
# File is entirely new
return [{'key': 'file', 'change': 'added'}]
if local_data is not None and remote_data is None:
# File has been deleted
return [{'key': 'file', 'change': 'deleted'}]
changes = []
all_keys = set(local_data.keys()).union(set(remote_data.keys()))
for key in all_keys:
local_value = local_data.get(key)
remote_value = remote_data.get(key)
if local_value != remote_value:
if key == 'tags':
changes.extend(compare_tags(local_value, remote_value))
elif key == 'custom_formats':
changes.extend(
compare_custom_formats(local_value, remote_value))
else:
changes.append({
'key': key,
'change': 'modified',
'from': local_value,
'to': remote_value
})
return changes
def compare_tags(local_tags, remote_tags):
local_tags = set(local_tags or [])
remote_tags = set(remote_tags or [])
added = remote_tags - local_tags
removed = local_tags - remote_tags
changes = []
if added:
changes.append({
'key': 'tags',
'change': 'added',
'value': list(added)
})
if removed:
changes.append({
'key': 'tags',
'change': 'removed',
'value': list(removed)
})
return changes
def compare_custom_formats(local_cfs, remote_cfs):
local_cfs = {cf['id']: cf for cf in local_cfs or []}
remote_cfs = {cf['id']: cf for cf in remote_cfs or []}
all_ids = set(local_cfs.keys()).union(set(remote_cfs.keys()))
changes = []
for cf_id in all_ids:
local_cf = local_cfs.get(cf_id)
remote_cf = remote_cfs.get(cf_id)
if local_cf != remote_cf:
if local_cf and remote_cf:
if local_cf['score'] != remote_cf['score']:
changes.append({
'key': f'custom_format_{cf_id}',
'change': 'modified',
'from': local_cf['score'],
'to': remote_cf['score']
})
elif local_cf and not remote_cf:
changes.append({
'key': f'custom_format_{cf_id}',
'change': 'removed',
'value': local_cf['score']
})
elif not local_cf and remote_cf:
changes.append({
'key': f'custom_format_{cf_id}',
'change': 'added',
'value': remote_cf['score']
})
return changes

View File

@@ -0,0 +1,115 @@
import os
import yaml
import logging
from git import GitCommandError
from .utils import determine_type
logger = logging.getLogger(__name__)
# Define the possible states
UNRESOLVED = "UNRESOLVED" # File is still in conflict, hasn't been resolved and not added
RESOLVED = "RESOLVED" # File is no longer in conflict, been resolved and has been added
def get_merge_conflicts(repo):
"""Get all merge conflicts in the repository."""
try:
if not os.path.exists(os.path.join(repo.git_dir, 'MERGE_HEAD')):
logger.debug("No MERGE_HEAD found - not in merge state")
return []
conflicts = []
status = repo.git.status('--porcelain', '-z').split('\0')
logger.debug(f"Raw status output: {[s for s in status if s]}")
for item in status:
if not item or len(item) < 4:
continue
x, y, file_path = item[0], item[1], item[3:]
logger.debug(
f"Processing status item - X: {x}, Y: {y}, Path: {file_path}")
if 'U' in (x, y) or (x == 'D' and y == 'D'):
conflict = process_conflict_file(repo, file_path)
if conflict:
conflicts.append(conflict)
logger.debug(f"Found {len(conflicts)} conflicts")
return conflicts
except Exception as e:
logger.error(f"Error getting merge conflicts: {str(e)}", exc_info=True)
return []
def process_conflict_file(repo, file_path):
"""Process a single conflict file and return its conflict information."""
try:
logger.debug(f"Processing conflict file: {file_path}")
# Get current and incoming versions
ours_data = get_version_data(repo, 'HEAD', file_path)
theirs_data = get_version_data(repo, 'MERGE_HEAD', file_path)
if not ours_data or not theirs_data:
logger.warning(
f"Missing data for {file_path} - Ours: {bool(ours_data)}, Theirs: {bool(theirs_data)}"
)
return None
conflict_details = {'conflicting_parameters': []}
# Find conflicting fields
for key in set(ours_data.keys()) | set(theirs_data.keys()):
if key == 'date_modified':
continue
ours_value = ours_data.get(key)
theirs_value = theirs_data.get(key)
if ours_value != theirs_value:
logger.debug(
f"Found conflict in {key} - Local: {ours_value}, Incoming: {theirs_value}"
)
conflict_details['conflicting_parameters'].append({
'parameter':
key,
'local_value':
ours_value,
'incoming_value':
theirs_value
})
# Check if file still has unmerged (UU) status
status_output = repo.git.status('--porcelain', file_path)
logger.debug(f"Status output for {file_path}: {status_output}")
status = UNRESOLVED if status_output.startswith('UU') else RESOLVED
result = {
'file_path': file_path,
'type': determine_type(file_path),
'name': ours_data.get('name'),
'status': status,
'conflict_details': conflict_details
}
logger.debug(f"Processed conflict result: {result}")
return result
except Exception as e:
logger.error(f"Error processing conflict file {file_path}: {str(e)}",
exc_info=True)
return None
def get_version_data(repo, ref, file_path):
"""Get YAML data from a specific version of a file."""
try:
content = repo.git.show(f'{ref}:{file_path}')
return yaml.safe_load(content) if content else None
except GitCommandError as e:
logger.error(
f"Error getting version data for {ref}:{file_path}: {str(e)}")
return None

View File

@@ -1,13 +1,14 @@
# git/status/outgoing_changes.py # git/status/outgoing_changes.py
import os import os
import json
import yaml import yaml
import logging import logging
from .utils import extract_data_from_yaml, determine_type, interpret_git_status from git import GitCommandError
from .utils import determine_type, parse_commit_message
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def get_outgoing_changes(repo): def get_outgoing_changes(repo):
status = repo.git.status('--porcelain', '-z').split('\0') status = repo.git.status('--porcelain', '-z').split('\0')
logger.debug(f"Raw porcelain status: {status}") logger.debug(f"Raw porcelain status: {status}")
@@ -26,81 +27,213 @@ def get_outgoing_changes(repo):
x, y, file_path = item[0], item[1], item[3:] x, y, file_path = item[0], item[1], item[3:]
logger.debug(f"Parsed status: x={x}, y={y}, file_path={file_path}") logger.debug(f"Parsed status: x={x}, y={y}, file_path={file_path}")
# Skip files in conflict state
if x == 'U' or y == 'U':
continue
is_staged = x != ' ' and x != '?' is_staged = x != ' ' and x != '?'
is_deleted = x == 'D' or y == 'D' is_deleted = x == 'D' or y == 'D'
full_path = os.path.join(repo.working_dir, file_path)
if is_deleted: if is_deleted:
changes.append(process_deleted_file(repo, file_path, is_staged))
else:
changes.append(
process_modified_file(repo, file_path, x, y, is_staged))
logger.debug(f"Final changes: {changes}")
return changes
def process_deleted_file(repo, file_path, is_staged):
try: try:
# Get the content of the file from the last commit
file_content = repo.git.show(f'HEAD:{file_path}') file_content = repo.git.show(f'HEAD:{file_path}')
yaml_content = yaml.safe_load(file_content) yaml_content = yaml.safe_load(file_content)
original_name = yaml_content.get('name', 'Unknown') original_name = yaml_content.get('name', 'Unknown')
original_id = yaml_content.get('id', '') original_id = yaml_content.get('id', '')
except Exception as e: except Exception as e:
logger.warning(f"Could not retrieve original name for deleted file {file_path}: {str(e)}") logger.warning(
f"Could not retrieve original content for deleted file {file_path}: {str(e)}"
)
original_name = "Unknown" original_name = "Unknown"
original_id = "" original_id = ""
changes.append({ return {
'name': original_name, 'name': original_name,
'prior_name': original_name,
'outgoing_name': None,
'id': original_id, 'id': original_id,
'type': determine_type(file_path), 'type': determine_type(file_path),
'status': 'Deleted', 'status': 'Deleted',
'file_path': file_path, 'file_path': file_path,
'staged': is_staged, 'staged': is_staged,
'modified': False, 'modified': False,
'deleted': True 'deleted': True,
}) 'changes': [{
elif os.path.isdir(full_path): 'key': 'file',
logger.debug(f"Found directory: {file_path}, going through folder.") 'change': 'deleted'
for root, dirs, files in os.walk(full_path): }]
for file in files: }
if file.endswith('.yml') or file.endswith('.yaml'):
file_full_path = os.path.join(root, file)
logger.debug(f"Found file: {file_full_path}, going through file.") def process_modified_file(repo, file_path, x, y, is_staged):
file_data = extract_data_from_yaml(file_full_path) try:
if file_data: # Get the content of the file from the last commit
logger.debug(f"File contents: {file_data}") old_content = repo.git.show(f'HEAD:{file_path}')
logger.debug(f"Found ID: {file_data.get('id')}") old_data = yaml.safe_load(old_content)
logger.debug(f"Found Name: {file_data.get('name')}") except GitCommandError:
changes.append({ old_data = None
'name': file_data.get('name', ''),
'id': file_data.get('id', ''), # Get the current content of the file
with open(os.path.join(repo.working_dir, file_path), 'r') as f:
new_content = f.read()
new_data = yaml.safe_load(new_content)
detailed_changes = compare_data(old_data, new_data)
# Determine prior_name and outgoing_name
prior_name = old_data.get('name') if old_data else None
outgoing_name = new_data.get('name') if new_data else None
# If there's no name change, set outgoing_name to None
if prior_name == outgoing_name:
outgoing_name = None
return {
'name': new_data.get('name', os.path.basename(file_path)),
'prior_name': prior_name,
'outgoing_name': outgoing_name,
'id': new_data.get('id', ''),
'type': determine_type(file_path), 'type': determine_type(file_path),
'status': interpret_git_status(x, y), 'status': 'Modified' if old_data else 'New',
'file_path': os.path.relpath(file_full_path, repo.working_dir),
'staged': x != '?' and x != ' ',
'modified': y == 'M',
'deleted': False
})
else:
logger.debug(f"No data extracted from file: {file_full_path}")
else:
file_data = extract_data_from_yaml(full_path) if os.path.exists(full_path) else None
if file_data:
changes.append({
'name': file_data.get('name', ''),
'id': file_data.get('id', ''),
'type': determine_type(file_path),
'status': interpret_git_status(x, y),
'file_path': file_path, 'file_path': file_path,
'staged': is_staged, 'staged': is_staged,
'modified': y != ' ', 'modified': y != ' ',
'deleted': False 'deleted': False,
}) 'changes': detailed_changes
}
def compare_data(old_data, new_data):
if old_data is None and new_data is not None:
return [{'key': 'file', 'change': 'added'}]
if old_data is not None and new_data is None:
return [{'key': 'file', 'change': 'deleted'}]
changes = []
all_keys = set(old_data.keys()).union(set(new_data.keys()))
for key in all_keys:
old_value = old_data.get(key)
new_value = new_data.get(key)
if old_value != new_value:
if key == 'tags':
changes.extend(compare_tags(old_value, new_value))
elif key == 'custom_formats':
changes.extend(compare_custom_formats(old_value, new_value))
elif key == 'conditions':
changes.extend(compare_conditions(old_value, new_value))
else: else:
changes.append({ changes.append({
'name': os.path.basename(file_path).replace('.yml', ''), 'key': key,
'id': '', 'change': 'modified',
'type': determine_type(file_path), 'from': old_value,
'status': interpret_git_status(x, y), 'to': new_value
'file_path': file_path, })
'staged': is_staged,
'modified': y != ' ', return changes
'deleted': False
def compare_tags(old_tags, new_tags):
old_tags = set(old_tags or [])
new_tags = set(new_tags or [])
added = new_tags - old_tags
removed = old_tags - new_tags
changes = []
if added:
changes.append({
'key': 'tags',
'change': 'added',
'value': list(added)
})
if removed:
changes.append({
'key': 'tags',
'change': 'removed',
'value': list(removed)
})
return changes
def compare_custom_formats(old_cfs, new_cfs):
old_cfs = {cf['id']: cf for cf in old_cfs or []}
new_cfs = {cf['id']: cf for cf in new_cfs or []}
all_ids = set(old_cfs.keys()).union(set(new_cfs.keys()))
changes = []
for cf_id in all_ids:
old_cf = old_cfs.get(cf_id)
new_cf = new_cfs.get(cf_id)
if old_cf != new_cf:
if old_cf and new_cf:
if old_cf['score'] != new_cf['score']:
changes.append({
'key': f'custom_format_{cf_id}',
'change': 'modified',
'from': old_cf['score'],
'to': new_cf['score']
})
elif old_cf and not new_cf:
changes.append({
'key': f'custom_format_{cf_id}',
'change': 'removed',
'value': old_cf['score']
})
elif not old_cf and new_cf:
changes.append({
'key': f'custom_format_{cf_id}',
'change': 'added',
'value': new_cf['score']
})
return changes
def compare_conditions(old_conditions, new_conditions):
changes = []
old_conditions = old_conditions or []
new_conditions = new_conditions or []
# Check for removed or modified conditions
for i, old_cond in enumerate(old_conditions):
if i >= len(new_conditions):
changes.append({
'key': f'conditions[{i}]',
'change': 'removed',
'value': old_cond
})
elif old_cond != new_conditions[i]:
for key in old_cond.keys():
if old_cond.get(key) != new_conditions[i].get(key):
changes.append({
'key': f'conditions[{i}].{key}',
'change': 'modified',
'from': old_cond.get(key),
'to': new_conditions[i].get(key)
})
# Check for added conditions
for i in range(len(old_conditions), len(new_conditions)):
changes.append({
'key': f'conditions[{i}]',
'change': 'added',
'value': new_conditions[i]
}) })
logger.debug(f"Final changes: {json.dumps(changes, indent=2)}")
return changes return changes

View File

@@ -1,44 +1,95 @@
# git/status/status.py # git/status/status.py
import git import git
from git.exc import GitCommandError, InvalidGitRepositoryError from git.exc import GitCommandError, InvalidGitRepositoryError
import logging import logging
import json
from .incoming_changes import get_incoming_changes from .incoming_changes import get_incoming_changes
from .outgoing_changes import get_outgoing_changes from .outgoing_changes import get_outgoing_changes
from .merge_conflicts import get_merge_conflicts
from .utils import determine_type
import os
import yaml
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def get_commits_ahead(repo, branch): def get_commits_ahead(repo, branch):
return list(repo.iter_commits(f'origin/{branch}..{branch}')) return list(repo.iter_commits(f'origin/{branch}..{branch}'))
def get_commits_behind(repo, branch): def get_commits_behind(repo, branch):
return list(repo.iter_commits(f'{branch}..origin/{branch}')) return list(repo.iter_commits(f'{branch}..origin/{branch}'))
def get_unpushed_changes(repo, branch):
"""Get detailed info about files modified in unpushed commits"""
try:
# Get the file paths
unpushed_files = repo.git.diff(f'origin/{branch}..{branch}',
'--name-only').split('\n')
unpushed_files = [f for f in unpushed_files if f]
detailed_changes = []
for file_path in unpushed_files:
try:
# Get the current content of the file to extract name
with open(os.path.join(repo.working_dir, file_path), 'r') as f:
content = yaml.safe_load(f.read())
detailed_changes.append({
'type':
determine_type(file_path),
'name':
content.get('name', os.path.basename(file_path)),
'file_path':
file_path
})
except Exception as e:
logger.warning(
f"Could not get details for {file_path}: {str(e)}")
# Fallback to basic info if we can't read the file
detailed_changes.append({
'type': determine_type(file_path),
'name': os.path.basename(file_path),
'file_path': file_path
})
return detailed_changes
except Exception as e:
logger.error(f"Error getting unpushed changes: {str(e)}")
return []
def get_git_status(repo_path): def get_git_status(repo_path):
try: try:
logger.debug(f"Attempting to get status for repo at {repo_path}") logger.debug(f"Attempting to get status for repo at {repo_path}")
repo = git.Repo(repo_path) repo = git.Repo(repo_path)
logger.debug(f"Successfully created Repo object") branch = repo.active_branch.name
remote_branch_exists = f"origin/{branch}" in [
ref.name for ref in repo.remotes.origin.refs
]
# Check for merge state
is_merging = os.path.exists(os.path.join(repo.git_dir, 'MERGE_HEAD'))
# Get merge conflicts if we're in a merge state
merge_conflicts = get_merge_conflicts(repo) if is_merging else []
# Get all changes first
outgoing_changes = get_outgoing_changes(repo) outgoing_changes = get_outgoing_changes(repo)
logger.debug(f"Outgoing changes detected: {outgoing_changes}") logger.debug(f"Outgoing changes detected: {outgoing_changes}")
branch = repo.active_branch.name
remote_branch_exists = f"origin/{branch}" in [ref.name for ref in repo.remotes.origin.refs]
if remote_branch_exists: if remote_branch_exists:
repo.remotes.origin.fetch() repo.remotes.origin.fetch()
commits_behind = get_commits_behind(repo, branch) commits_behind = get_commits_behind(repo, branch)
commits_ahead = get_commits_ahead(repo, branch) commits_ahead = get_commits_ahead(repo, branch)
logger.debug(f"Commits behind: {len(commits_behind)}, Commits ahead: {len(commits_ahead)}")
incoming_changes = get_incoming_changes(repo, branch) incoming_changes = get_incoming_changes(repo, branch)
unpushed_files = get_unpushed_changes(
repo, branch) if commits_ahead else []
else: else:
commits_behind = [] commits_behind = []
commits_ahead = [] commits_ahead = []
incoming_changes = [] incoming_changes = []
logger.debug("Remote branch does not exist, skipping commits ahead/behind and incoming changes calculation.") unpushed_files = []
status = { status = {
"branch": branch, "branch": branch,
@@ -47,15 +98,13 @@ def get_git_status(repo_path):
"commits_behind": len(commits_behind), "commits_behind": len(commits_behind),
"commits_ahead": len(commits_ahead), "commits_ahead": len(commits_ahead),
"incoming_changes": incoming_changes, "incoming_changes": incoming_changes,
"has_unpushed_commits": len(commits_ahead) > 0,
"unpushed_files": unpushed_files,
"is_merging": is_merging,
"merge_conflicts": merge_conflicts,
"has_conflicts": bool(merge_conflicts)
} }
logger.debug(f"Final status object: {json.dumps(status, indent=2)}")
return True, status return True, status
except GitCommandError as e:
logger.error(f"GitCommandError: {str(e)}")
return False, f"Git error: {str(e)}"
except InvalidGitRepositoryError:
logger.error(f"InvalidGitRepositoryError for path: {repo_path}")
return False, "Invalid Git repository"
except Exception as e: except Exception as e:
logger.error(f"Unexpected error in get_git_status: {str(e)}", exc_info=True) logger.error(f"Error in get_git_status: {str(e)}", exc_info=True)
return False, f"Unexpected error: {str(e)}" return False, str(e)

View File

@@ -8,7 +8,7 @@ services:
- ./frontend:/app - ./frontend:/app
- /app/node_modules - /app/node_modules
environment: environment:
- VITE_API_URL=http://192.168.1.111:5000 # Replace with your host machine's IP - VITE_API_URL=http://localhost:5000
- CHOKIDAR_USEPOLLING=true - CHOKIDAR_USEPOLLING=true
backend: backend:
@@ -21,6 +21,6 @@ services:
environment: environment:
- FLASK_ENV=development - FLASK_ENV=development
env_file: env_file:
- .env - .env.1
volumes: volumes:
backend_data: backend_data:

View File

@@ -0,0 +1,41 @@
stateDiagram-v2
[*] --> CheckingForUpdates: User Initiates Pull
CheckingForUpdates --> NormalPull: No Conflicts Detected
CheckingForUpdates --> ConflictDetected: Conflicts Found
NormalPull --> [*]: Pull Complete
ConflictDetected --> ResolutionState: Enter Resolution Mode
note right of ResolutionState
System returns conflict object
containing all conflicted files
end note
state ResolutionState {
[*] --> FileSelection
FileSelection --> FileResolution: Select Unresolved File
FileResolution --> ConflictChoice
state ConflictChoice {
[*] --> DecisionMaking
DecisionMaking --> KeepLocal: User Keeps Local
DecisionMaking --> AcceptIncoming: User Accepts Incoming
DecisionMaking --> CustomMerge: User Combines/Modifies
KeepLocal --> MarkResolved
AcceptIncoming --> MarkResolved
CustomMerge --> MarkResolved
}
ConflictChoice --> AddFile: File Resolved
AddFile --> FileSelection: More Files\nto Resolve
AddFile --> AllFilesResolved: No More\nConflicts
}
ResolutionState --> CommitChanges: All Files Resolved
CommitChanges --> [*]: Resolution Complete

View File

@@ -0,0 +1,24 @@
Profilarr Sync Flow
```mermaid
flowchart TD
A[User Opens App] --> B[Check Git Status]
B --> C{Changes Detected?}
C -->|No Changes| D[Up to Date]
C -->|Changes Exist| E{Type of Change}
E -->|Incoming Only| F[Fast Forward Available]
E -->|Outgoing Only| G[Push Available*]
E -->|Both| H{Conflicts?}
H -->|Yes| I[Show Conflict UI]
H -->|No| J[Auto-merge]
I --> K[User Resolves]
K --> L[Apply Resolution]
L --> M[Update Git State]
J --> M
F --> M
G --> M
%% Add note about push restrictions
N[*Push only available for developers<br/>on specific branches]
N -.- G
```

View File

@@ -1,16 +1,14 @@
<!doctype html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head>
<head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/regex.svg" /> <link rel="icon" type="image/svg+xml" href="/regex.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Regexerr</title> <title>Profilarr</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.jsx"></script> <script type="module" src="/src/main.jsx"></script>
</body> </body>
</html> </html>

View File

@@ -1,6 +1,6 @@
import axios from 'axios'; import axios from 'axios';
const API_BASE_URL = 'http://localhost:5000'; const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:5000';
export const getRegexes = async () => { export const getRegexes = async () => {
try { try {
@@ -133,7 +133,15 @@ export const getSettings = async () => {
export const getGitStatus = async () => { export const getGitStatus = async () => {
try { try {
const response = await axios.get(`${API_BASE_URL}/git/status`); const response = await axios.get(`${API_BASE_URL}/git/status`);
return response.data; // Ensure has_unpushed_commits is included in the response
return {
...response.data,
data: {
...response.data.data,
has_unpushed_commits:
response.data.data.has_unpushed_commits || false
}
};
} catch (error) { } catch (error) {
console.error('Error fetching Git status:', error); console.error('Error fetching Git status:', error);
throw error; throw error;
@@ -152,9 +160,21 @@ export const getBranches = async () => {
export const checkoutBranch = async branchName => { export const checkoutBranch = async branchName => {
try { try {
const response = await axios.post(`${API_BASE_URL}/git/checkout`, { const response = await axios.post(
`${API_BASE_URL}/git/checkout`,
{
branch: branchName branch: branchName
}); },
{
validateStatus: status => {
return (
(status >= 200 && status < 300) ||
status === 400 ||
status === 409
);
}
}
);
return response.data; return response.data;
} catch (error) { } catch (error) {
console.error('Error checking out branch:', error); console.error('Error checking out branch:', error);
@@ -164,10 +184,22 @@ export const checkoutBranch = async branchName => {
export const createBranch = async (branchName, baseBranch) => { export const createBranch = async (branchName, baseBranch) => {
try { try {
const response = await axios.post(`${API_BASE_URL}/git/branch`, { const response = await axios.post(
`${API_BASE_URL}/git/branch`,
{
name: branchName, name: branchName,
base: baseBranch base: baseBranch
}); },
{
validateStatus: status => {
return (
(status >= 200 && status < 300) ||
status === 400 ||
status === 409
);
}
}
);
return response.data; return response.data;
} catch (error) { } catch (error) {
console.error('Error creating branch:', error); console.error('Error creating branch:', error);
@@ -178,7 +210,16 @@ export const createBranch = async (branchName, baseBranch) => {
export const deleteBranch = async branchName => { export const deleteBranch = async branchName => {
try { try {
const response = await axios.delete( const response = await axios.delete(
`${API_BASE_URL}/git/branch/${branchName}` `${API_BASE_URL}/git/branch/${branchName}`,
{
validateStatus: status => {
return (
(status >= 200 && status < 300) ||
status === 400 ||
status === 409
);
}
}
); );
return response.data; return response.data;
} catch (error) { } catch (error) {
@@ -187,6 +228,30 @@ export const deleteBranch = async branchName => {
} }
}; };
export const pushBranchToRemote = async branchName => {
try {
const response = await axios.post(
`${API_BASE_URL}/git/branch/push`,
{
branch: branchName
},
{
validateStatus: status => {
return (
(status >= 200 && status < 300) ||
status === 400 ||
status === 409
);
}
}
);
return response.data;
} catch (error) {
console.error('Error pushing branch to remote:', error);
throw error;
}
};
export const addFiles = async files => { export const addFiles = async files => {
try { try {
const response = await axios.post(`${API_BASE_URL}/git/stage`, {files}); const response = await axios.post(`${API_BASE_URL}/git/stage`, {files});
@@ -197,19 +262,49 @@ export const addFiles = async files => {
} }
}; };
export const pushFiles = async (files, commitMessage) => { export const unstageFiles = async files => {
try { try {
const response = await axios.post(`${API_BASE_URL}/git/push`, { const response = await axios.post(`${API_BASE_URL}/git/unstage`, {
files
});
return response.data;
} catch (error) {
console.error('Error unstaging files:', error);
throw error;
}
};
export const commitFiles = async (files, commitMessage) => {
try {
const response = await axios.post(`${API_BASE_URL}/git/commit`, {
files, files,
commit_message: commitMessage commit_message: commitMessage
}); });
return response.data; return response.data;
} catch (error) { } catch (error) {
console.error('Error pushing files:', error); console.error('Error committing files:', error);
throw error; throw error;
} }
}; };
export const pushFiles = async () => {
try {
const response = await axios.post(`${API_BASE_URL}/git/push`);
return response.data;
} catch (error) {
// Pass through the structured error from the backend
if (error.response?.data) {
return {
success: false,
error: error.response.data.error
};
}
return {
success: false,
error: 'Failed to push changes'
};
}
};
export const revertFile = async filePath => { export const revertFile = async filePath => {
try { try {
const response = await axios.post(`${API_BASE_URL}/git/revert`, { const response = await axios.post(`${API_BASE_URL}/git/revert`, {
@@ -251,20 +346,19 @@ export const pullBranch = async branchName => {
}); });
return response.data; return response.data;
} catch (error) { } catch (error) {
console.error('Error pulling branch:', error); if (error.response?.data) {
throw error; return {
success: false,
state: error.response.data.state || 'error',
message: error.response.data.message,
details: error.response.data.details
};
} }
}; return {
success: false,
export const getDiff = async filePath => { state: 'error',
try { message: 'Failed to pull changes'
const response = await axios.post(`${API_BASE_URL}/git/diff`, { };
file_path: filePath
});
return response.data;
} catch (error) {
console.error('Error fetching diff:', error);
throw error;
} }
}; };
@@ -335,18 +429,6 @@ export const unlinkRepo = async (removeFiles = false) => {
} }
}; };
export const pushBranchToRemote = async branchName => {
try {
const response = await axios.post(`${API_BASE_URL}/git/branch/push`, {
branch: branchName
});
return response.data;
} catch (error) {
console.error('Error pushing branch to remote:', error);
throw error;
}
};
export const checkDevMode = async () => { export const checkDevMode = async () => {
try { try {
const response = await axios.get(`${API_BASE_URL}/git/dev`); const response = await axios.get(`${API_BASE_URL}/git/dev`);
@@ -356,3 +438,44 @@ export const checkDevMode = async () => {
throw error; throw error;
} }
}; };
export const resolveConflict = async resolutions => {
try {
const response = await axios.post(`${API_BASE_URL}/git/resolve`, {
resolutions
});
return response.data;
} catch (error) {
console.error('Error resolving conflicts:', error);
throw error;
}
};
export const finalizeMerge = async () => {
try {
const response = await axios.post(`${API_BASE_URL}/git/merge/finalize`);
return response.data;
} catch (error) {
console.error('Error finalizing merge:', error);
if (error.response?.data) {
return {
success: false,
error: error.response.data.error
};
}
return {
success: false,
error: 'Failed to finalize merge'
};
}
};
export const abortMerge = async () => {
try {
const response = await axios.post(`${API_BASE_URL}/git/merge/abort`);
return response.data;
} catch (error) {
console.error('Error aborting merge:', error);
throw error;
}
};

View File

@@ -1,33 +1,37 @@
import React, { useState, useEffect } from "react"; import React, {useState, useEffect} from 'react';
import FormatCard from "./FormatCard"; import {useNavigate} from 'react-router-dom';
import FormatModal from "./FormatModal"; import FormatCard from './FormatCard';
import AddNewCard from "../ui/AddNewCard"; import FormatModal from './FormatModal';
import { getFormats } from "../../api/api"; import AddNewCard from '../ui/AddNewCard';
import FilterMenu from "../ui/FilterMenu"; import {getFormats, getGitStatus} from '../../api/api';
import SortMenu from "../ui/SortMenu"; import FilterMenu from '../ui/FilterMenu';
import { Loader } from "lucide-react"; import SortMenu from '../ui/SortMenu';
import {Loader} from 'lucide-react';
function FormatPage() { function FormatPage() {
const [formats, setFormats] = useState([]); const [formats, setFormats] = useState([]);
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedFormat, setSelectedFormat] = useState(null); const [selectedFormat, setSelectedFormat] = useState(null);
const [sortBy, setSortBy] = useState("title"); const [sortBy, setSortBy] = useState('title');
const [filterType, setFilterType] = useState("none"); const [filterType, setFilterType] = useState('none');
const [filterValue, setFilterValue] = useState(""); const [filterValue, setFilterValue] = useState('');
const [allTags, setAllTags] = useState([]); const [allTags, setAllTags] = useState([]);
const [isCloning, setIsCloning] = useState(false); const [isCloning, setIsCloning] = useState(false);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [mergeConflicts, setMergeConflicts] = useState([]);
const navigate = useNavigate();
const loadingMessages = [ const loadingMessages = [
"Decoding the custom format matrix...", 'Decoding the custom format matrix...',
"Parsing the digital alphabet soup...", 'Parsing the digital alphabet soup...',
"Untangling the format spaghetti...", 'Untangling the format spaghetti...',
"Calibrating the format-o-meter...", 'Calibrating the format-o-meter...',
"Indexing your media DNA...", 'Indexing your media DNA...'
]; ];
useEffect(() => { useEffect(() => {
fetchFormats(); fetchGitStatus();
}, []); }, []);
const fetchFormats = async () => { const fetchFormats = async () => {
@@ -35,16 +39,33 @@ function FormatPage() {
const fetchedFormats = await getFormats(); const fetchedFormats = await getFormats();
setFormats(fetchedFormats); setFormats(fetchedFormats);
const tags = [ const tags = [
...new Set(fetchedFormats.flatMap((format) => format.tags || [])), ...new Set(fetchedFormats.flatMap(format => format.tags || []))
]; ];
setAllTags(tags); setAllTags(tags);
} catch (error) { } catch (error) {
console.error("Error fetching formats:", error); console.error('Error fetching formats:', error);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}; };
const fetchGitStatus = async () => {
try {
const result = await getGitStatus();
if (result.success) {
setMergeConflicts(result.data.merge_conflicts || []);
if (result.data.merge_conflicts.length === 0) {
fetchFormats();
} else {
setIsLoading(false);
}
}
} catch (error) {
console.error('Error fetching Git status:', error);
setIsLoading(false);
}
};
const handleOpenModal = (format = null) => { const handleOpenModal = (format = null) => {
setSelectedFormat(format); setSelectedFormat(format);
setIsModalOpen(true); setIsModalOpen(true);
@@ -57,11 +78,11 @@ function FormatPage() {
setIsCloning(false); setIsCloning(false);
}; };
const handleCloneFormat = (format) => { const handleCloneFormat = format => {
const clonedFormat = { const clonedFormat = {
...format, ...format,
id: 0, id: 0,
name: `${format.name} [COPY]`, name: `${format.name} [COPY]`
}; };
setSelectedFormat(clonedFormat); setSelectedFormat(clonedFormat);
setIsModalOpen(true); setIsModalOpen(true);
@@ -73,16 +94,16 @@ function FormatPage() {
handleCloseModal(); handleCloseModal();
}; };
const formatDate = (dateString) => { const formatDate = dateString => {
return new Date(dateString).toLocaleString(); return new Date(dateString).toLocaleString();
}; };
const sortedAndFilteredFormats = formats const sortedAndFilteredFormats = formats
.filter((format) => { .filter(format => {
if (filterType === "tag") { if (filterType === 'tag') {
return format.tags && format.tags.includes(filterValue); return format.tags && format.tags.includes(filterValue);
} }
if (filterType === "date") { if (filterType === 'date') {
const formatDate = new Date(format.date_modified); const formatDate = new Date(format.date_modified);
const filterDate = new Date(filterValue); const filterDate = new Date(filterValue);
return formatDate.toDateString() === filterDate.toDateString(); return formatDate.toDateString() === filterDate.toDateString();
@@ -90,29 +111,61 @@ function FormatPage() {
return true; return true;
}) })
.sort((a, b) => { .sort((a, b) => {
if (sortBy === "title") return a.name.localeCompare(b.name); if (sortBy === 'title') return a.name.localeCompare(b.name);
if (sortBy === "dateCreated") if (sortBy === 'dateCreated')
return new Date(b.date_created) - new Date(a.date_created); return new Date(b.date_created) - new Date(a.date_created);
if (sortBy === "dateModified") if (sortBy === 'dateModified')
return new Date(b.date_modified) - new Date(a.date_modified); return new Date(b.date_modified) - new Date(a.date_modified);
return 0; return 0;
}); });
const hasConflicts = mergeConflicts.length > 0;
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex flex-col items-center justify-center h-screen"> <div className='flex flex-col items-center justify-center h-screen'>
<Loader size={48} className="animate-spin text-blue-500 mb-4" /> <Loader size={48} className='animate-spin text-blue-500 mb-4' />
<p className="text-lg font-medium text-gray-700 dark:text-gray-300"> <p className='text-lg font-medium text-gray-700 dark:text-gray-300'>
{loadingMessages[Math.floor(Math.random() * loadingMessages.length)]} {
loadingMessages[
Math.floor(Math.random() * loadingMessages.length)
]
}
</p> </p>
</div> </div>
); );
} }
if (hasConflicts) {
return (
<div className='bg-gray-900 text-white'>
<div className='mt-8 flex justify-between items-center'>
<h4 className='text-xl font-extrabold'>
Merge Conflicts Detected
</h4>
<button
onClick={() => navigate('/settings')}
className='bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded transition'>
Resolve Conflicts
</button>
</div>
<div className='mt-6 p-4 bg-gray-800 rounded-lg shadow-md'>
<h3 className='text-xl font-semibold'>What Happened?</h3>
<p className='mt-2 text-gray-300'>
This page is locked because there are unresolved merge
conflicts. You need to address these conflicts in the
settings page before continuing.
</p>
</div>
</div>
);
}
return ( return (
<div> <div>
<h2 className="text-2xl font-bold mb-4">Manage Custom Formats</h2> <h2 className='text-2xl font-bold mb-4'>Manage Custom Formats</h2>
<div className="mb-4 flex items-center space-x-4"> <div className='mb-4 flex items-center space-x-4'>
<SortMenu sortBy={sortBy} setSortBy={setSortBy} /> <SortMenu sortBy={sortBy} setSortBy={setSortBy} />
<FilterMenu <FilterMenu
filterType={filterType} filterType={filterType}
@@ -122,14 +175,14 @@ function FormatPage() {
allTags={allTags} allTags={allTags}
/> />
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 mb-4"> <div className='grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 mb-4'>
{sortedAndFilteredFormats.map((format) => ( {sortedAndFilteredFormats.map(format => (
<FormatCard <FormatCard
key={format.id} key={format.id}
format={format} format={format}
onEdit={() => handleOpenModal(format)} onEdit={() => handleOpenModal(format)}
onClone={handleCloneFormat} // Pass the clone handler onClone={handleCloneFormat}
showDate={sortBy !== "title"} showDate={sortBy !== 'title'}
formatDate={formatDate} formatDate={formatDate}
/> />
))} ))}

View File

@@ -265,12 +265,7 @@ function ProfileModal({
}; };
return ( return (
<Modal <Modal isOpen={isOpen} onClose={onClose} title={modalTitle}>
isOpen={isOpen}
onClose={onClose}
title={modalTitle}
width='screen-2xl'
height='6xl'>
{loading ? ( {loading ? (
<div className='flex justify-center items-center'> <div className='flex justify-center items-center'>
<Loader size={24} className='animate-spin text-gray-300' /> <Loader size={24} className='animate-spin text-gray-300' />

View File

@@ -1,35 +1,38 @@
import React, { useState, useEffect } from "react"; import React, {useState, useEffect} from 'react';
import ProfileCard from "./ProfileCard"; import {useNavigate} from 'react-router-dom';
import ProfileModal from "./ProfileModal"; import ProfileCard from './ProfileCard';
import AddNewCard from "../ui/AddNewCard"; import ProfileModal from './ProfileModal';
import { getProfiles, getFormats } from "../../api/api"; import AddNewCard from '../ui/AddNewCard';
import FilterMenu from "../ui/FilterMenu"; import {getProfiles, getFormats, getGitStatus} from '../../api/api';
import SortMenu from "../ui/SortMenu"; import FilterMenu from '../ui/FilterMenu';
import { Loader } from "lucide-react"; import SortMenu from '../ui/SortMenu';
import {Loader} from 'lucide-react';
function ProfilePage() { function ProfilePage() {
const [profiles, setProfiles] = useState([]); const [profiles, setProfiles] = useState([]);
const [formats, setFormats] = useState([]); const [formats, setFormats] = useState([]);
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedProfile, setSelectedProfile] = useState(null); const [selectedProfile, setSelectedProfile] = useState(null);
const [sortBy, setSortBy] = useState("title"); const [sortBy, setSortBy] = useState('title');
const [filterType, setFilterType] = useState("none"); const [filterType, setFilterType] = useState('none');
const [filterValue, setFilterValue] = useState(""); const [filterValue, setFilterValue] = useState('');
const [allTags, setAllTags] = useState([]); const [allTags, setAllTags] = useState([]);
const [isCloning, setIsCloning] = useState(false); const [isCloning, setIsCloning] = useState(false);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [mergeConflicts, setMergeConflicts] = useState([]);
const navigate = useNavigate();
const loadingMessages = [ const loadingMessages = [
"Profiling your media collection...", 'Profiling your media collection...',
"Organizing your digital hoard...", 'Organizing your digital hoard...',
"Calibrating the flux capacitor...", 'Calibrating the flux capacitor...',
"Synchronizing with the movie matrix...", 'Synchronizing with the movie matrix...',
"Optimizing your binge-watching potential...", 'Optimizing your binge-watching potential...'
]; ];
useEffect(() => { useEffect(() => {
fetchProfiles(); fetchGitStatus();
fetchFormats();
}, []); }, []);
const fetchProfiles = async () => { const fetchProfiles = async () => {
@@ -37,11 +40,13 @@ function ProfilePage() {
const fetchedProfiles = await getProfiles(); const fetchedProfiles = await getProfiles();
setProfiles(fetchedProfiles); setProfiles(fetchedProfiles);
const tags = [ const tags = [
...new Set(fetchedProfiles.flatMap((profile) => profile.tags || [])), ...new Set(
fetchedProfiles.flatMap(profile => profile.tags || [])
)
]; ];
setAllTags(tags); setAllTags(tags);
} catch (error) { } catch (error) {
console.error("Error fetching profiles:", error); console.error('Error fetching profiles:', error);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@@ -52,7 +57,25 @@ function ProfilePage() {
const fetchedFormats = await getFormats(); const fetchedFormats = await getFormats();
setFormats(fetchedFormats); setFormats(fetchedFormats);
} catch (error) { } catch (error) {
console.error("Error fetching formats:", error); console.error('Error fetching formats:', error);
}
};
const fetchGitStatus = async () => {
try {
const result = await getGitStatus();
if (result.success) {
setMergeConflicts(result.data.merge_conflicts || []);
if (result.data.merge_conflicts.length === 0) {
fetchProfiles();
fetchFormats();
} else {
setIsLoading(false);
}
}
} catch (error) {
console.error('Error fetching Git status:', error);
setIsLoading(false);
} }
}; };
@@ -60,7 +83,7 @@ function ProfilePage() {
const safeProfile = profile const safeProfile = profile
? { ? {
...profile, ...profile,
custom_formats: profile.custom_formats || [], custom_formats: profile.custom_formats || []
} }
: null; : null;
setSelectedProfile(safeProfile); setSelectedProfile(safeProfile);
@@ -74,12 +97,12 @@ function ProfilePage() {
setIsCloning(false); setIsCloning(false);
}; };
const handleCloneProfile = (profile) => { const handleCloneProfile = profile => {
const clonedProfile = { const clonedProfile = {
...profile, ...profile,
id: 0, id: 0,
name: `${profile.name} [COPY]`, name: `${profile.name} [COPY]`,
custom_formats: profile.custom_formats || [], custom_formats: profile.custom_formats || []
}; };
setSelectedProfile(clonedProfile); setSelectedProfile(clonedProfile);
setIsModalOpen(true); setIsModalOpen(true);
@@ -91,17 +114,16 @@ function ProfilePage() {
handleCloseModal(); handleCloseModal();
}; };
// Define the missing formatDate function const formatDate = dateString => {
const formatDate = (dateString) => {
return new Date(dateString).toLocaleString(); return new Date(dateString).toLocaleString();
}; };
const sortedAndFilteredProfiles = profiles const sortedAndFilteredProfiles = profiles
.filter((profile) => { .filter(profile => {
if (filterType === "tag") { if (filterType === 'tag') {
return profile.tags && profile.tags.includes(filterValue); return profile.tags && profile.tags.includes(filterValue);
} }
if (filterType === "date") { if (filterType === 'date') {
const profileDate = new Date(profile.date_modified); const profileDate = new Date(profile.date_modified);
const filterDate = new Date(filterValue); const filterDate = new Date(filterValue);
return profileDate.toDateString() === filterDate.toDateString(); return profileDate.toDateString() === filterDate.toDateString();
@@ -109,29 +131,61 @@ function ProfilePage() {
return true; return true;
}) })
.sort((a, b) => { .sort((a, b) => {
if (sortBy === "name") return a.name.localeCompare(b.name); if (sortBy === 'name') return a.name.localeCompare(b.name);
if (sortBy === "dateCreated") if (sortBy === 'dateCreated')
return new Date(b.date_created) - new Date(a.date_created); return new Date(b.date_created) - new Date(a.date_created);
if (sortBy === "dateModified") if (sortBy === 'dateModified')
return new Date(b.date_modified) - new Date(a.date_modified); return new Date(b.date_modified) - new Date(a.date_modified);
return 0; return 0;
}); });
const hasConflicts = mergeConflicts.length > 0;
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex flex-col items-center justify-center h-screen"> <div className='flex flex-col items-center justify-center h-screen'>
<Loader size={48} className="animate-spin text-blue-500 mb-4" /> <Loader size={48} className='animate-spin text-blue-500 mb-4' />
<p className="text-lg font-medium text-gray-700 dark:text-gray-300"> <p className='text-lg font-medium text-gray-700 dark:text-gray-300'>
{loadingMessages[Math.floor(Math.random() * loadingMessages.length)]} {
loadingMessages[
Math.floor(Math.random() * loadingMessages.length)
]
}
</p> </p>
</div> </div>
); );
} }
if (hasConflicts) {
return (
<div className='bg-gray-900 text-white'>
<div className='mt-8 flex justify-between items-center'>
<h4 className='text-xl font-extrabold'>
Merge Conflicts Detected
</h4>
<button
onClick={() => navigate('/settings')}
className='bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded transition'>
Resolve Conflicts
</button>
</div>
<div className='mt-6 p-4 bg-gray-800 rounded-lg shadow-md'>
<h3 className='text-xl font-semibold'>What Happened?</h3>
<p className='mt-2 text-gray-300'>
This page is locked because there are unresolved merge
conflicts. You need to address these conflicts in the
settings page before continuing.
</p>
</div>
</div>
);
}
return ( return (
<div> <div>
<h2 className="text-2xl font-bold mb-4">Manage Profiles</h2> <h2 className='text-2xl font-bold mb-4'>Manage Profiles</h2>
<div className="mb-4 flex items-center space-x-4"> <div className='mb-4 flex items-center space-x-4'>
<SortMenu sortBy={sortBy} setSortBy={setSortBy} /> <SortMenu sortBy={sortBy} setSortBy={setSortBy} />
<FilterMenu <FilterMenu
filterType={filterType} filterType={filterType}
@@ -141,15 +195,15 @@ function ProfilePage() {
allTags={allTags} allTags={allTags}
/> />
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 mb-4"> <div className='grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 mb-4'>
{sortedAndFilteredProfiles.map((profile) => ( {sortedAndFilteredProfiles.map(profile => (
<ProfileCard <ProfileCard
key={profile.id} key={profile.id}
profile={profile} profile={profile}
onEdit={() => handleOpenModal(profile)} onEdit={() => handleOpenModal(profile)}
onClone={handleCloneProfile} onClone={handleCloneProfile}
showDate={sortBy !== "name"} showDate={sortBy !== 'name'}
formatDate={formatDate} // Pass the formatDate function to the ProfileCard formatDate={formatDate}
/> />
))} ))}
<AddNewCard onAdd={() => handleOpenModal()} /> <AddNewCard onAdd={() => handleOpenModal()} />

View File

@@ -1,33 +1,41 @@
import React, { useState, useEffect } from "react"; import React, {useState, useEffect} from 'react';
import RegexCard from "./RegexCard"; import {useNavigate} from 'react-router-dom';
import RegexModal from "./RegexModal"; import RegexCard from './RegexCard';
import AddNewCard from "../ui/AddNewCard"; import RegexModal from './RegexModal';
import { getRegexes } from "../../api/api"; import AddNewCard from '../ui/AddNewCard';
import FilterMenu from "../ui/FilterMenu"; import {getRegexes} from '../../api/api';
import SortMenu from "../ui/SortMenu"; import FilterMenu from '../ui/FilterMenu';
import { Loader } from "lucide-react"; import SortMenu from '../ui/SortMenu';
import {Loader} from 'lucide-react';
import {getGitStatus} from '../../api/api';
function RegexPage() { function RegexPage() {
const [regexes, setRegexes] = useState([]); const [regexes, setRegexes] = useState([]);
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedRegex, setSelectedRegex] = useState(null); const [selectedRegex, setSelectedRegex] = useState(null);
const [sortBy, setSortBy] = useState("title"); const [sortBy, setSortBy] = useState('title');
const [filterType, setFilterType] = useState("none"); const [filterType, setFilterType] = useState('none');
const [filterValue, setFilterValue] = useState(""); const [filterValue, setFilterValue] = useState('');
const [allTags, setAllTags] = useState([]); const [allTags, setAllTags] = useState([]);
const [isCloning, setIsCloning] = useState(false); const [isCloning, setIsCloning] = useState(false);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [mergeConflicts, setMergeConflicts] = useState([]);
const navigate = useNavigate();
const loadingMessages = [ const loadingMessages = [
"Matching patterns in the digital universe...", 'Compiling complex patterns...',
"Capturing groups of binary brilliance...", 'Analyzing regex efficiency...',
"Escaping special characters in the wild...", 'Optimizing search algorithms...',
"Quantifying the unquantifiable...", 'Testing pattern boundaries...',
"Regex-ing the un-regex-able...", 'Loading regex libraries...',
'Parsing intricate expressions...',
'Detecting pattern conflicts...',
'Refactoring nested groups...'
]; ];
useEffect(() => { useEffect(() => {
fetchRegexes(); fetchGitStatus();
}, []); }, []);
const fetchRegexes = async () => { const fetchRegexes = async () => {
@@ -35,16 +43,33 @@ function RegexPage() {
const fetchedRegexes = await getRegexes(); const fetchedRegexes = await getRegexes();
setRegexes(fetchedRegexes); setRegexes(fetchedRegexes);
const tags = [ const tags = [
...new Set(fetchedRegexes.flatMap((regex) => regex.tags || [])), ...new Set(fetchedRegexes.flatMap(regex => regex.tags || []))
]; ];
setAllTags(tags); setAllTags(tags);
} catch (error) { } catch (error) {
console.error("Error fetching regexes:", error); console.error('Error fetching regexes:', error);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}; };
const fetchGitStatus = async () => {
try {
const result = await getGitStatus();
if (result.success) {
setMergeConflicts(result.data.merge_conflicts || []);
if (result.data.merge_conflicts.length === 0) {
fetchRegexes();
} else {
setIsLoading(false);
}
}
} catch (error) {
console.error('Error fetching Git status:', error);
setIsLoading(false);
}
};
const handleOpenModal = (regex = null) => { const handleOpenModal = (regex = null) => {
setSelectedRegex(regex); setSelectedRegex(regex);
setIsModalOpen(true); setIsModalOpen(true);
@@ -57,12 +82,12 @@ function RegexPage() {
setIsCloning(false); setIsCloning(false);
}; };
const handleCloneRegex = (regex) => { const handleCloneRegex = regex => {
const clonedRegex = { const clonedRegex = {
...regex, ...regex,
id: 0, id: 0,
name: `${regex.name} [COPY]`, name: `${regex.name} [COPY]`,
regex101Link: "", regex101Link: ''
}; };
setSelectedRegex(clonedRegex); setSelectedRegex(clonedRegex);
setIsModalOpen(true); setIsModalOpen(true);
@@ -74,16 +99,16 @@ function RegexPage() {
handleCloseModal(); handleCloseModal();
}; };
const formatDate = (dateString) => { const formatDate = dateString => {
return new Date(dateString).toLocaleString(); return new Date(dateString).toLocaleString();
}; };
const sortedAndFilteredRegexes = regexes const sortedAndFilteredRegexes = regexes
.filter((regex) => { .filter(regex => {
if (filterType === "tag") { if (filterType === 'tag') {
return regex.tags && regex.tags.includes(filterValue); return regex.tags && regex.tags.includes(filterValue);
} }
if (filterType === "date") { if (filterType === 'date') {
const regexDate = new Date(regex.date_modified); const regexDate = new Date(regex.date_modified);
const filterDate = new Date(filterValue); const filterDate = new Date(filterValue);
return regexDate.toDateString() === filterDate.toDateString(); return regexDate.toDateString() === filterDate.toDateString();
@@ -91,29 +116,61 @@ function RegexPage() {
return true; return true;
}) })
.sort((a, b) => { .sort((a, b) => {
if (sortBy === "title") return a.name.localeCompare(b.name); if (sortBy === 'title') return a.name.localeCompare(b.name);
if (sortBy === "dateCreated") if (sortBy === 'dateCreated')
return new Date(b.date_created) - new Date(a.date_created); return new Date(b.date_created) - new Date(a.date_created);
if (sortBy === "dateModified") if (sortBy === 'dateModified')
return new Date(b.date_modified) - new Date(a.date_modified); return new Date(b.date_modified) - new Date(a.date_modified);
return 0; return 0;
}); });
const hasConflicts = mergeConflicts.length > 0;
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex flex-col items-center justify-center h-screen"> <div className='flex flex-col items-center justify-center h-screen'>
<Loader size={48} className="animate-spin text-blue-500 mb-4" /> <Loader size={48} className='animate-spin text-blue-500 mb-4' />
<p className="text-lg font-medium text-gray-700 dark:text-gray-300"> <p className='text-lg font-medium text-gray-700 dark:text-gray-300'>
{loadingMessages[Math.floor(Math.random() * loadingMessages.length)]} {
loadingMessages[
Math.floor(Math.random() * loadingMessages.length)
]
}
</p> </p>
</div> </div>
); );
} }
if (hasConflicts) {
return (
<div className='bg-gray-900 text-white'>
<div className='mt-8 flex justify-between items-center'>
<h4 className='text-xl font-extrabold'>
Merge Conflicts Detected
</h4>
<button
onClick={() => navigate('/settings')}
className='bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded transition'>
Resolve Conflicts
</button>
</div>
<div className='mt-6 p-4 bg-gray-800 rounded-lg shadow-md'>
<h3 className='text-xl font-semibold'>What Happened?</h3>
<p className='mt-2 text-gray-300'>
This page is locked because there are unresolved merge
conflicts. You need to address these conflicts in the
settings page before continuing.
</p>
</div>
</div>
);
}
return ( return (
<div> <div>
<h2 className="text-2xl font-bold mb-4">Manage Regex Patterns</h2> <h2 className='text-2xl font-bold mb-4'>Manage Regex Patterns</h2>
<div className="mb-4 flex items-center space-x-4"> <div className='mb-4 flex items-center space-x-4'>
<SortMenu sortBy={sortBy} setSortBy={setSortBy} /> <SortMenu sortBy={sortBy} setSortBy={setSortBy} />
<FilterMenu <FilterMenu
filterType={filterType} filterType={filterType}
@@ -123,14 +180,14 @@ function RegexPage() {
allTags={allTags} allTags={allTags}
/> />
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 mb-4"> <div className='grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 mb-4'>
{sortedAndFilteredRegexes.map((regex) => ( {sortedAndFilteredRegexes.map(regex => (
<RegexCard <RegexCard
key={regex.id} key={regex.id}
regex={regex} regex={regex}
onEdit={() => handleOpenModal(regex)} onEdit={() => handleOpenModal(regex)}
onClone={handleCloneRegex} // Pass the clone handler onClone={handleCloneRegex} // Pass the clone handler
showDate={sortBy !== "title"} showDate={sortBy !== 'title'}
formatDate={formatDate} formatDate={formatDate}
/> />
))} ))}

View File

@@ -3,6 +3,8 @@ import {
getSettings, getSettings,
getGitStatus, getGitStatus,
addFiles, addFiles,
unstageFiles,
commitFiles,
pushFiles, pushFiles,
revertFile, revertFile,
pullBranch, pullBranch,
@@ -32,6 +34,7 @@ const SettingsPage = () => {
const [noChangesMessage, setNoChangesMessage] = useState(''); const [noChangesMessage, setNoChangesMessage] = useState('');
const [activeTab, setActiveTab] = useState('git'); const [activeTab, setActiveTab] = useState('git');
const tabsRef = useRef({}); const tabsRef = useRef({});
const [mergeConflicts, setMergeConflicts] = useState([]);
useEffect(() => { useEffect(() => {
fetchSettings(); fetchSettings();
@@ -67,10 +70,11 @@ const SettingsPage = () => {
setStatusLoading(true); setStatusLoading(true);
setStatusLoadingMessage(getRandomMessage(statusLoadingMessages)); setStatusLoadingMessage(getRandomMessage(statusLoadingMessages));
setNoChangesMessage(getRandomMessage(noChangesMessages)); setNoChangesMessage(getRandomMessage(noChangesMessages));
try { try {
const result = await getGitStatus(); const result = await getGitStatus();
if (result.success) { if (result.success) {
setChanges({ const gitStatus = {
...result.data, ...result.data,
outgoing_changes: Array.isArray( outgoing_changes: Array.isArray(
result.data.outgoing_changes result.data.outgoing_changes
@@ -81,8 +85,16 @@ const SettingsPage = () => {
result.data.incoming_changes result.data.incoming_changes
) )
? result.data.incoming_changes ? result.data.incoming_changes
: [],
merge_conflicts: Array.isArray(result.data.merge_conflicts)
? result.data.merge_conflicts
: [] : []
}); };
setChanges(gitStatus);
setMergeConflicts(gitStatus.merge_conflicts);
console.log('Git Status:', JSON.stringify(gitStatus, null, 2));
} }
} catch (error) { } catch (error) {
console.error('Error fetching Git status:', error); console.error('Error fetching Git status:', error);
@@ -114,13 +126,33 @@ const SettingsPage = () => {
} }
}; };
const handleUnstageSelectedChanges = async selectedChanges => {
setLoadingAction('unstage_selected');
try {
const response = await unstageFiles(selectedChanges);
if (response.success) {
await fetchGitStatus();
Alert.success(response.message);
} else {
Alert.error(response.error);
}
} catch (error) {
Alert.error(
'An unexpected error occurred while unstaging changes.'
);
console.error('Error unstaging changes:', error);
} finally {
setLoadingAction('');
}
};
const handleCommitSelectedChanges = async ( const handleCommitSelectedChanges = async (
selectedChanges, selectedChanges,
commitMessage commitMessage
) => { ) => {
setLoadingAction('commit_selected'); setLoadingAction('commit_selected');
try { try {
const response = await pushFiles(selectedChanges, commitMessage); const response = await commitFiles(selectedChanges, commitMessage);
if (response.success) { if (response.success) {
await fetchGitStatus(); await fetchGitStatus();
Alert.success(response.message); Alert.success(response.message);
@@ -137,6 +169,64 @@ const SettingsPage = () => {
} }
}; };
const handlePushChanges = async () => {
setLoadingAction('push_changes');
try {
const response = await pushFiles();
if (response.success) {
await fetchGitStatus();
Alert.success(response.message);
} else {
if (typeof response.error === 'object' && response.error.type) {
// Handle structured errors
Alert.error(response.error.message);
} else {
// Handle string errors
Alert.error(response.error);
}
}
} catch (error) {
console.error('Error in handlePushChanges:', error);
Alert.error('An unexpected error occurred while pushing changes.');
} finally {
setLoadingAction('');
}
};
const handlePullSelectedChanges = async () => {
setLoadingAction('pull_changes');
try {
const response = await pullBranch(changes.branch);
// First update status regardless of what happened
await fetchGitStatus();
if (response.success) {
if (response.state === 'resolve') {
Alert.info(
response.message ||
'Repository is now in conflict resolution state. Please resolve conflicts to continue. ',
{
autoClose: true,
closeOnClick: true
}
);
} else {
Alert.success(
response.message || 'Successfully pulled changes'
);
}
} else {
Alert.error(response.message || 'Failed to pull changes');
}
} catch (error) {
console.error('Error in pullBranch:', error);
Alert.error('Failed to pull changes');
} finally {
setLoadingAction('');
}
};
const handleRevertSelectedChanges = async selectedChanges => { const handleRevertSelectedChanges = async selectedChanges => {
setLoadingAction('revert_selected'); setLoadingAction('revert_selected');
try { try {
@@ -164,24 +254,6 @@ const SettingsPage = () => {
} }
}; };
const handlePullSelectedChanges = async selectedChanges => {
setLoadingAction('pull_changes');
try {
const response = await pullBranch(changes.branch, selectedChanges);
if (response.success) {
await fetchGitStatus();
Alert.success(response.message);
} else {
Alert.error(response.error);
}
} catch (error) {
Alert.error('An unexpected error occurred while pulling changes.');
console.error('Error pulling changes:', error);
} finally {
setLoadingAction('');
}
};
return ( return (
<div> <div>
<nav className='flex space-x-4'> <nav className='flex space-x-4'>
@@ -236,13 +308,18 @@ const SettingsPage = () => {
status={changes} status={changes}
isDevMode={isDevMode} isDevMode={isDevMode}
onStageSelected={handleStageSelectedChanges} onStageSelected={handleStageSelectedChanges}
onUnstageSelected={
handleUnstageSelectedChanges
}
onCommitSelected={ onCommitSelected={
handleCommitSelectedChanges handleCommitSelectedChanges
} }
onPushSelected={handlePushChanges}
onRevertSelected={ onRevertSelected={
handleRevertSelectedChanges handleRevertSelectedChanges
} }
onPullSelected={handlePullSelectedChanges} onPullSelected={handlePullSelectedChanges}
fetchGitStatus={fetchGitStatus}
loadingAction={loadingAction} loadingAction={loadingAction}
/> />
)} )}

View File

@@ -1,64 +1,42 @@
import React from 'react'; import React from 'react';
import {Loader, RotateCcw, Download, CheckCircle, Plus} from 'lucide-react'; import {
Loader,
RotateCcw,
Download,
CheckCircle,
Plus,
Upload
} from 'lucide-react';
import Tooltip from '../../ui/Tooltip'; import Tooltip from '../../ui/Tooltip';
const ActionButton = ({
onClick,
disabled,
loading,
icon,
text,
className,
disabledTooltip
}) => {
const baseClassName =
'flex items-center px-4 py-2 text-white rounded-md transition-all duration-200 ease-in-out text-xs';
const enabledClassName = `${baseClassName} ${className} hover:opacity-80`;
const disabledClassName = `${baseClassName} ${className} opacity-50 cursor-not-allowed`;
return (
<Tooltip content={disabled ? disabledTooltip : text}>
<button
onClick={onClick}
className={disabled ? disabledClassName : enabledClassName}
disabled={disabled || loading}>
{loading ? (
<Loader size={12} className='animate-spin mr-1' />
) : (
React.cloneElement(icon, {className: 'mr-1', size: 12})
)}
{text}
</button>
</Tooltip>
);
};
const ActionButtons = ({ const ActionButtons = ({
isDevMode, isDevMode,
selectedOutgoingChanges, selectedOutgoingChanges,
selectedIncomingChanges,
selectionType, selectionType,
commitMessage, commitMessage,
loadingAction, loadingAction,
onStageSelected, onStageSelected,
onCommitSelected, onCommitSelected,
onPushSelected,
onRevertSelected, onRevertSelected,
onPullSelected hasUnpushedCommits
}) => { }) => {
const canStage = const canStage =
isDevMode && isDevMode &&
selectedOutgoingChanges.length > 0 && selectedOutgoingChanges.length > 0 &&
selectionType !== 'staged'; selectionType !== 'staged';
const canCommit = const canCommit =
isDevMode && isDevMode &&
selectedOutgoingChanges.length > 0 && selectedOutgoingChanges.length > 0 &&
commitMessage.trim() && commitMessage.trim() &&
selectionType !== 'unstaged'; selectionType !== 'unstaged';
const canPush = isDevMode && hasUnpushedCommits;
const canRevert = selectedOutgoingChanges.length > 0; const canRevert = selectedOutgoingChanges.length > 0;
const canPull = selectedIncomingChanges.length > 0;
return ( return (
<div className='mt-4 flex justify-start space-x-2'> <div className='space-x-2 flex flex-wrap gap-2'>
{isDevMode && ( {isDevMode && (
<> <>
<ActionButton <ActionButton
@@ -84,6 +62,17 @@ const ActionButtons = ({
className='bg-blue-600' className='bg-blue-600'
disabledTooltip='Select staged files and enter a commit message to enable committing' disabledTooltip='Select staged files and enter a commit message to enable committing'
/> />
<ActionButton
onClick={onPushSelected}
disabled={!canPush}
loading={loadingAction === 'push_changes'}
icon={<Upload />}
text={`Push${hasUnpushedCommits ? ' Changes' : ''}`}
className='bg-purple-600'
disabledTooltip={
hasUnpushedCommits ? '' : 'No changes to push'
}
/>
</> </>
)} )}
<ActionButton <ActionButton
@@ -95,17 +84,39 @@ const ActionButtons = ({
className='bg-red-600' className='bg-red-600'
disabledTooltip='Select files to revert' disabledTooltip='Select files to revert'
/> />
<ActionButton
onClick={() => onPullSelected(selectedIncomingChanges)}
disabled={!canPull}
loading={loadingAction === 'pull_changes'}
icon={<Download />}
text='Pull'
className='bg-yellow-600'
disabledTooltip='Select incoming changes to pull'
/>
</div> </div>
); );
}; };
const ActionButton = ({
onClick,
disabled,
loading,
icon,
tooltip,
className,
disabledTooltip
}) => {
const baseClassName =
'flex items-center justify-center w-8 h-8 text-white rounded-md transition-all duration-200 ease-in-out hover:opacity-80';
const buttonClassName = `${baseClassName} ${className} ${
disabled ? 'opacity-50 cursor-not-allowed' : ''
}`;
return (
<Tooltip content={disabled ? disabledTooltip : tooltip}>
<button
onClick={onClick}
className={buttonClassName}
disabled={disabled || loading}>
{loading ? (
<Loader size={14} className='animate-spin' />
) : (
React.cloneElement(icon, {size: 14})
)}
</button>
</Tooltip>
);
};
export default ActionButtons; export default ActionButtons;

View File

@@ -5,24 +5,17 @@ import {
MinusCircle, MinusCircle,
Edit, Edit,
GitBranch, GitBranch,
AlertCircle, AlertTriangle,
Code, Code,
FileText, FileText,
Settings, Settings,
File File
} from 'lucide-react'; } from 'lucide-react';
import Tooltip from '../../ui/Tooltip'; import Tooltip from '../../ui/Tooltip';
import ViewDiff from './modal/ViewDiff'; import ViewChanges from './modal/ViewChanges';
const ChangeRow = ({ const ChangeRow = ({change, isSelected, onSelect, isIncoming, isDevMode}) => {
change, const [showChanges, setShowChanges] = useState(false);
isSelected,
onSelect,
isIncoming,
isDevMode,
diffContent
}) => {
const [showDiff, setShowDiff] = useState(false);
const getStatusIcon = status => { const getStatusIcon = status => {
switch (status) { switch (status) {
@@ -40,7 +33,7 @@ const ChangeRow = ({
case 'Renamed': case 'Renamed':
return <GitBranch className='text-purple-400' size={16} />; return <GitBranch className='text-purple-400' size={16} />;
default: default:
return <AlertCircle className='text-gray-400' size={16} />; return <AlertTriangle className='text-gray-400' size={16} />;
} }
}; };
@@ -57,27 +50,49 @@ const ChangeRow = ({
} }
}; };
const handleViewDiff = e => { const handleViewChanges = e => {
e.stopPropagation(); e.stopPropagation();
console.log('Change Object: ', JSON.stringify(change, null, 2)); setShowChanges(true);
setShowDiff(true);
}; };
const handleRowClick = () => {
if (!isIncoming && onSelect) {
onSelect(change.file_path);
}
};
// Determine row classes based on whether it's incoming or selected
const rowClasses = `border-t border-gray-600 ${
isIncoming
? 'cursor-default'
: `cursor-pointer ${
isSelected ? 'bg-blue-700 bg-opacity-30' : 'hover:bg-gray-700'
}`
}`;
return ( return (
<> <>
<tr <tr className={rowClasses} onClick={handleRowClick}>
className={`border-t border-gray-600 cursor-pointer hover:bg-gray-700 ${
isSelected ? 'bg-gray-700' : ''
}`}
onClick={() => onSelect(change.file_path)}>
<td className='px-4 py-2 text-gray-300'> <td className='px-4 py-2 text-gray-300'>
<div className='flex items-center'> <div className='flex items-center relative'>
{getStatusIcon(change.status)} {getStatusIcon(change.status)}
<span className='ml-2'> <span className='ml-2'>
{change.staged {change.staged
? `${change.status} (Staged)` ? `${change.status} (Staged)`
: change.status} : change.status}
</span> </span>
{isIncoming && change.will_conflict && (
<span
className='inline-block relative'
style={{zIndex: 1}}>
<Tooltip content='Potential Merge Conflict Detected'>
<AlertTriangle
className='text-yellow-400 ml-2'
size={16}
/>
</Tooltip>
</span>
)}
</div> </div>
</td> </td>
<td className='px-4 py-2 text-gray-300'> <td className='px-4 py-2 text-gray-300'>
@@ -87,38 +102,59 @@ const ChangeRow = ({
</div> </div>
</td> </td>
<td className='px-4 py-2 text-gray-300'> <td className='px-4 py-2 text-gray-300'>
{change.name || 'Unnamed'} {isIncoming ? (
change.incoming_name &&
change.incoming_name !== change.local_name ? (
<>
<span className='mr-3'>
<strong>Local:</strong>{' '}
{change.local_name || 'Unnamed'}
</span>
<span>
<strong>Incoming:</strong>{' '}
{change.incoming_name || 'Unnamed'}
</span>
</>
) : (
change.local_name ||
change.incoming_name ||
'Unnamed'
)
) : change.outgoing_name &&
change.outgoing_name !== change.prior_name ? (
<>
<span className='mr-3'>
<strong>Prior:</strong>{' '}
{change.prior_name || 'Unnamed'}
</span>
<span>
<strong>Outgoing:</strong>{' '}
{change.outgoing_name || 'Unnamed'}
</span>
</>
) : (
change.outgoing_name || change.prior_name || 'Unnamed'
)}
</td> </td>
<td className='px-4 py-2 text-left align-middle'> <td className='px-4 py-2 text-left align-middle'>
<Tooltip content='View differences'> <Tooltip content='View changes'>
<button <button
onClick={handleViewDiff} onClick={handleViewChanges}
className='flex items-center justify-center px-2 py-1 bg-gray-600 text-white rounded hover:bg-gray-700 transition-colors text-xs' className='flex items-center justify-center px-2 py-1 bg-gray-600 text-white rounded hover:bg-gray-700 transition-colors text-xs'
style={{width: '100%'}}> style={{width: '100%'}}>
<Eye size={12} className='mr-1' /> <Eye size={12} className='mr-1' />
View Diff Changes
</button> </button>
</Tooltip> </Tooltip>
</td> </td>
<td className='px-4 py-2 text-right text-gray-300 align-middle'>
<input
type='checkbox'
checked={isSelected}
onChange={e => e.stopPropagation()}
disabled={!isIncoming && change.staged}
/>
</td>
</tr> </tr>
<ViewDiff <ViewChanges
key={`${change.file_path}-diff`} key={`${change.file_path}-changes`}
isOpen={showDiff} isOpen={showChanges}
onClose={() => setShowDiff(false)} onClose={() => setShowChanges(false)}
diffContent={diffContent} change={change}
type={change.type}
name={change.name}
commitMessage={change.commit_message}
isDevMode={isDevMode}
isIncoming={isIncoming} isIncoming={isIncoming}
isDevMode={isDevMode}
/> />
</> </>
); );

View File

@@ -1,21 +1,23 @@
import React from 'react'; import React from 'react';
import {ArrowDown, ArrowUp} from 'lucide-react'; import {ArrowDown, ArrowUp} from 'lucide-react';
import ChangeRow from './ChangeRow'; import ChangeRow from './ChangeRow';
import ConflictRow from './ConflictRow';
const ChangeTable = ({ const ChangeTable = ({
changes, changes,
title,
icon,
isIncoming, isIncoming,
isMergeConflict,
selectedChanges, selectedChanges,
onSelectChange, onSelectChange,
sortConfig, sortConfig,
onRequestSort, onRequestSort,
isDevMode, isDevMode,
diffContents fetchGitStatus
}) => { }) => {
const sortedChanges = changesArray => { const sortedChanges = changesArray => {
if (!sortConfig.key) return changesArray; // Don't sort if we're showing merge conflicts or if no sort config
if (isMergeConflict || !sortConfig?.key) return changesArray;
return [...changesArray].sort((a, b) => { return [...changesArray].sort((a, b) => {
if (a[sortConfig.key] < b[sortConfig.key]) { if (a[sortConfig.key] < b[sortConfig.key]) {
return sortConfig.direction === 'ascending' ? -1 : 1; return sortConfig.direction === 'ascending' ? -1 : 1;
@@ -47,61 +49,49 @@ const ChangeTable = ({
}; };
return ( return (
<div className='mb-4'>
<h4 className='text-sm font-medium text-gray-200 mb-4 flex items-center mt-3'>
{icon}
<span>
{isIncoming
? title
: isDevMode
? 'Outgoing Changes'
: 'Local Changes'}{' '}
({changes.length})
</span>
</h4>
<div className='border border-gray-600 rounded-md overflow-hidden'>
<table className='w-full text-sm'> <table className='w-full text-sm'>
<thead className='bg-gray-600'> <thead className='bg-gray-600'>
<tr> <tr>
<SortableHeader sortKey='status' className='w-1/6'> <SortableHeader sortKey='status' className='w-1/5'>
Status Status
</SortableHeader> </SortableHeader>
<SortableHeader sortKey='type' className='w-1/6'> <SortableHeader sortKey='type' className='w-1/5'>
Type Type
</SortableHeader> </SortableHeader>
<SortableHeader sortKey='name' className='w-2/6'> <SortableHeader sortKey='name' className='w-1/2'>
Name Name
</SortableHeader> </SortableHeader>
<th className='px-4 py-2 text-left text-gray-300 w-1/12'> <th className='px-4 py-2 text-left text-gray-300 w-1/5'>
Actions Actions
</th> </th>
<th className='px-4 py-2 text-right text-gray-300 w-1/12'>
Select
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{sortedChanges(changes).map((change, index) => ( {sortedChanges(changes).map((change, index) =>
isMergeConflict ? (
<ConflictRow
key={`merge-conflict-${index}`}
change={change}
isDevMode={isDevMode}
fetchGitStatus={fetchGitStatus}
/>
) : (
<ChangeRow <ChangeRow
key={`${ key={`${
isIncoming ? 'incoming' : 'outgoing' isIncoming ? 'incoming' : 'outgoing'
}-${index}`} }-${index}`}
change={change} change={change}
isSelected={selectedChanges.includes( isSelected={selectedChanges?.includes(
change.file_path change.file_path
)} )}
onSelect={filePath => onSelect={!isIncoming ? onSelectChange : null}
onSelectChange(filePath, isIncoming)
}
isIncoming={isIncoming} isIncoming={isIncoming}
isDevMode={isDevMode} isDevMode={isDevMode}
diffContent={diffContents[change.file_path]}
/> />
))} )
)}
</tbody> </tbody>
</table> </table>
</div>
</div>
); );
}; };

View File

@@ -1,57 +1,215 @@
import React from 'react'; import React, {useState, useEffect} from 'react';
import Textarea from '../../ui/TextArea';
const CommitSection = ({ const COMMIT_TYPES = [
status, {value: 'feat', label: 'Feature', description: 'A new feature'},
commitMessage, {value: 'fix', label: 'Bug Fix', description: 'A bug fix'},
setCommitMessage, {
hasIncomingChanges, value: 'docs',
funMessage, label: 'Documentation',
isDevMode description: 'Documentation only changes'
}) => { },
const hasUnstagedChanges = status.outgoing_changes.some( {
change => !change.staged || (change.staged && change.modified) value: 'style',
); label: 'Style',
const hasStagedChanges = status.outgoing_changes.some( description: 'Changes that do not affect code meaning'
change => change.staged },
); {
const hasAnyChanges = status.outgoing_changes.length > 0; value: 'refactor',
label: 'Refactor',
description: 'Code change that neither fixes a bug nor adds a feature'
},
{
value: 'perf',
label: 'Performance',
description: 'A code change that improves performance'
},
{value: 'test', label: 'Test', description: 'Adding or correcting tests'},
{
value: 'chore',
label: 'Chore',
description: "Other changes that don't modify src or test files"
},
{value: 'custom', label: 'Custom', description: 'Custom type'}
];
const SCOPES = [
{
value: 'regex',
label: 'Regex',
description: 'Changes related to regex patterns'
},
{
value: 'format',
label: 'Format',
description: 'Changes related to custom formats'
},
{
value: 'profile',
label: 'Profile',
description: 'Changes related to quality profiles'
},
{value: 'custom', label: 'Custom', description: 'Custom scope'}
];
const formatBodyLines = text => {
if (!text) return '';
return text
.split('\n')
.map(line => {
const trimmedLine = line.trim();
if (!trimmedLine) return '';
const cleanLine = trimmedLine.startsWith('- ')
? trimmedLine.substring(2).trim()
: trimmedLine;
return cleanLine ? `- ${cleanLine}` : '';
})
.filter(Boolean)
.join('\n');
};
const CommitSection = ({commitMessage, setCommitMessage}) => {
const [type, setType] = useState('');
const [customType, setCustomType] = useState('');
const [scope, setScope] = useState('');
const [customScope, setCustomScope] = useState('');
const [subject, setSubject] = useState('');
const [body, setBody] = useState('');
const [footer, setFooter] = useState('');
useEffect(() => {
const effectiveType = type === 'custom' ? customType : type;
const effectiveScope = scope === 'custom' ? customScope : scope;
if (effectiveType && subject) {
let message = `${effectiveType}${
effectiveScope ? `(${effectiveScope})` : ''
}: ${subject}`;
if (body) {
message += `\n\n${formatBodyLines(body)}`;
}
if (footer) {
message += `\n\n${footer}`;
}
setCommitMessage(message);
} else {
setCommitMessage('');
}
}, [
type,
customType,
scope,
customScope,
subject,
body,
footer,
setCommitMessage
]);
const selectStyles =
'bg-gray-700 text-sm text-gray-200 focus:outline-none focus:bg-gray-600 hover:bg-gray-600 transition-colors duration-150';
const inputStyles = 'bg-gray-700 text-sm text-gray-200 focus:outline-none';
return ( return (
<div className='mt-4'> <div className='mt-4'>
{isDevMode ? ( <div className='bg-gray-800 rounded-md overflow-hidden border border-gray-700 shadow-sm'>
<> <div className='flex items-center bg-gray-700 border-b border-gray-600'>
{hasAnyChanges || hasIncomingChanges ? ( <div className='flex-none w-64 border-r border-gray-600'>
<> <div className='relative'>
{hasStagedChanges && ( <select
<> value={type}
<h3 className='text-sm font-semibold text-gray-100 mb-4'> onChange={e => setType(e.target.value)}
Commit Message: className={`w-full px-3 py-2.5 appearance-none cursor-pointer ${selectStyles}`}>
</h3> <option value='' disabled>
<Textarea Select Type
value={commitMessage} </option>
onChange={e => {COMMIT_TYPES.map(({value, label}) => (
setCommitMessage(e.target.value) <option key={value} value={value}>
} {label}
placeholder='Enter your commit message here...' </option>
className='w-full p-2 text-sm text-gray-200 bg-gray-600 border border-gray-500 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 resize-y h-[75px] mb-2' ))}
</select>
<div className='absolute inset-y-0 right-0 flex items-center px-2 pointer-events-none'>
<svg
className='h-4 w-4 fill-current text-gray-400'
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 20 20'>
<path d='M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z' />
</svg>
</div>
</div>
{type === 'custom' && (
<input
type='text'
value={customType}
onChange={e => setCustomType(e.target.value)}
placeholder='Enter custom type'
className={`w-full px-3 py-2 border-t border-gray-600 bg-gray-800 ${inputStyles}`}
/> />
</>
)} )}
</>
) : (
<div className='text-gray-300 text-sm italic'>
{funMessage}
</div> </div>
)}
</> <div className='flex-none w-64 border-r border-gray-600'>
) : ( <div className='relative'>
<div className='text-gray-300 text-sm italic'> <select
Developer mode is disabled. Commit functionality is not value={scope}
available. onChange={e => setScope(e.target.value)}
className={`w-full px-3 py-2.5 appearance-none cursor-pointer ${selectStyles}`}>
<option value=''>No Scope</option>
{SCOPES.map(({value, label}) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
<div className='absolute inset-y-0 right-0 flex items-center px-2 pointer-events-none'>
<svg
className='h-4 w-4 fill-current text-gray-400'
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 20 20'>
<path d='M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z' />
</svg>
</div> </div>
</div>
{scope === 'custom' && (
<input
type='text'
value={customScope}
onChange={e => setCustomScope(e.target.value)}
placeholder='Enter custom scope'
className={`w-full px-3 py-2 border-t border-gray-600 bg-gray-800 ${inputStyles}`}
/>
)} )}
</div> </div>
<input
type='text'
value={subject}
onChange={e => setSubject(e.target.value)}
placeholder='Brief description of the changes'
maxLength={50}
className={`flex-1 px-3 py-2.5 ${inputStyles}`}
/>
</div>
<textarea
value={body}
onChange={e => setBody(e.target.value)}
placeholder='Detailed description of changes'
className={`w-full px-3 py-3 resize-none h-32 border-b border-gray-600 bg-gray-800 ${inputStyles}`}
/>
<input
type='text'
value={footer}
onChange={e => setFooter(e.target.value)}
placeholder='References to issues, PRs (optional)'
className={`w-full px-3 py-2.5 bg-gray-800 ${inputStyles}`}
/>
</div>
</div>
); );
}; };

View File

@@ -0,0 +1,99 @@
import React, {useState} from 'react';
import {AlertTriangle, GitMerge, Check, Edit2} from 'lucide-react';
import Tooltip from '../../ui/Tooltip';
import ResolveConflicts from './modal/ResolveConflicts';
const ConflictRow = ({change, isDevMode, fetchGitStatus}) => {
const [showChanges, setShowChanges] = useState(false);
const handleResolveConflicts = e => {
e.stopPropagation();
setShowChanges(true);
};
// Get name values from the correct path in the data structure
const nameConflict = change.conflict_details?.conflicting_parameters?.find(
param => param.parameter === 'name'
);
const displayLocalName =
nameConflict?.local_value || change.name || 'Unnamed';
const displayIncomingName = nameConflict?.incoming_value || 'Unnamed';
const isResolved = change.status === 'RESOLVED';
return (
<>
<tr className='border-t border-gray-600'>
<td className='px-4 py-2 text-gray-300'>
<div className='flex items-center'>
{isResolved ? (
<Check className='text-green-400' size={16} />
) : (
<AlertTriangle
className='text-yellow-400'
size={16}
/>
)}
<span className='ml-2'>
{isResolved ? 'Resolved' : 'Unresolved'}
</span>
</div>
</td>
<td className='px-4 py-2 text-gray-300'>{change.type}</td>
<td className='px-4 py-2 text-gray-300'>
{displayLocalName !== displayIncomingName ? (
<>
<span className='mr-3'>
<strong>Local:</strong> {displayLocalName}
</span>
<span>
<strong>Incoming:</strong> {displayIncomingName}
</span>
</>
) : (
displayLocalName
)}
</td>
<td className='px-4 py-2 text-left align-middle'>
<Tooltip
content={
isResolved ? 'Edit resolution' : 'Resolve conflicts'
}>
<button
onClick={handleResolveConflicts}
className={`flex items-center justify-center px-2 py-1 rounded hover:bg-gray-700 transition-colors text-xs w-full ${
isResolved
? 'bg-green-600 hover:bg-green-700'
: 'bg-gray-600 hover:bg-gray-700'
}`}>
{isResolved ? (
<>
<Edit2 size={12} className='mr-1' />
Edit
</>
) : (
<>
<GitMerge size={12} className='mr-1' />
Resolve
</>
)}
</button>
</Tooltip>
</td>
</tr>
<ResolveConflicts
key={`${change.file_path}-changes`}
isOpen={showChanges}
onClose={() => setShowChanges(false)}
change={change}
isIncoming={false}
isMergeConflict={true}
fetchGitStatus={fetchGitStatus}
isDevMode={isDevMode}
/>
</>
);
};
export default ConflictRow;

View File

@@ -0,0 +1,39 @@
// ConflictTable.jsx
import ConflictRow from './ConflictRow';
const ConflictTable = ({conflicts, isDevMode, fetchGitStatus}) => {
return (
<div className='border border-gray-600 rounded-md overflow-hidden mt-3'>
<table className='w-full text-sm'>
<thead className='bg-gray-600'>
<tr>
<th className='px-4 py-2 text-left text-gray-300 w-1/5'>
Status
</th>
<th className='px-4 py-2 text-left text-gray-300 w-1/5'>
Type
</th>
<th className='px-4 py-2 text-left text-gray-300 w-1/2'>
Name
</th>
<th className='px-4 py-2 text-left text-gray-300 w-1/5'>
Actions
</th>
</tr>
</thead>
<tbody>
{conflicts.map((conflict, index) => (
<ConflictRow
key={`conflict-${index}`}
change={conflict}
isDevMode={isDevMode}
fetchGitStatus={fetchGitStatus}
/>
))}
</tbody>
</table>
</div>
);
};
export default ConflictTable;

View File

@@ -1,30 +1,60 @@
import React, {useState, useEffect} from 'react'; import React, {useState, useEffect} from 'react';
import {GitMerge, ArrowUpFromLine, ArrowDownToLine} from 'lucide-react'; import {
GitMerge,
ArrowUpFromLine,
ArrowDownToLine,
AlertTriangle,
Download,
Plus,
CheckCircle,
RotateCcw,
Upload,
MinusCircle,
XCircle
} from 'lucide-react';
import ChangeTable from './ChangeTable'; import ChangeTable from './ChangeTable';
import ConflictTable from './ConflictTable';
import CommitSection from './CommitMessage'; import CommitSection from './CommitMessage';
import Modal from '../../ui/Modal';
import Tooltip from '../../ui/Tooltip';
import {getRandomMessage, noChangesMessages} from '../../../utils/messages'; import {getRandomMessage, noChangesMessages} from '../../../utils/messages';
import ActionButtons from './ActionButtons'; import IconButton from '../../ui/IconButton';
import {getDiff} from '../../../api/api'; import {abortMerge, finalizeMerge} from '../../../api/api';
import Alert from '../../ui/Alert';
const StatusContainer = ({ const StatusContainer = ({
status, status,
isDevMode, isDevMode,
onStageSelected, onStageSelected,
onUnstageSelected,
onCommitSelected, onCommitSelected,
onRevertSelected, onRevertSelected,
onPullSelected, onPullSelected,
loadingAction onPushSelected,
loadingAction,
fetchGitStatus
}) => { }) => {
const [sortConfig, setSortConfig] = useState({ const [sortConfig, setSortConfig] = useState({
key: 'type', key: 'type',
direction: 'ascending' direction: 'ascending'
}); });
const [selectedIncomingChanges, setSelectedIncomingChanges] = useState([]);
const [selectedOutgoingChanges, setSelectedOutgoingChanges] = useState([]); const [selectedOutgoingChanges, setSelectedOutgoingChanges] = useState([]);
const [selectedMergeConflicts, setSelectedMergeConflicts] = useState([]);
const [commitMessage, setCommitMessage] = useState(''); const [commitMessage, setCommitMessage] = useState('');
const [selectionType, setSelectionType] = useState(null); const [selectionType, setSelectionType] = useState(null);
const [noChangesMessage, setNoChangesMessage] = useState(''); const [noChangesMessage, setNoChangesMessage] = useState('');
const [diffContents, setDiffContents] = useState({}); const [isAbortModalOpen, setIsAbortModalOpen] = useState(false);
const canStage =
selectedOutgoingChanges.length > 0 && selectionType !== 'staged';
const canCommit =
selectedOutgoingChanges.length > 0 &&
selectionType === 'staged' &&
commitMessage.trim().length > 0;
const canRevert = selectedOutgoingChanges.length > 0;
const canPush = isDevMode && status.has_unpushed_commits;
const requestSort = key => { const requestSort = key => {
let direction = 'ascending'; let direction = 'ascending';
@@ -34,48 +64,61 @@ const StatusContainer = ({
setSortConfig({key, direction}); setSortConfig({key, direction});
}; };
const handleSelectChange = (filePath, isIncoming) => { const handleSelectChange = filePath => {
if (isIncoming) {
if (selectedOutgoingChanges.length > 0) {
setSelectedOutgoingChanges([]);
}
setSelectedIncomingChanges(prevSelected => {
if (prevSelected.includes(filePath)) {
return prevSelected.filter(path => path !== filePath);
} else {
return [...prevSelected, filePath];
}
});
} else {
if (selectedIncomingChanges.length > 0) {
setSelectedIncomingChanges([]);
}
const change = status.outgoing_changes.find( const change = status.outgoing_changes.find(
c => c.file_path === filePath c => c.file_path === filePath
); );
if (!change) {
console.error('Could not find change for file path:', filePath);
return;
}
const isStaged = change.staged; const isStaged = change.staged;
console.log('Selection change:', {
filePath,
isStaged,
currentSelectionType: selectionType,
currentSelected: selectedOutgoingChanges
});
setSelectedOutgoingChanges(prevSelected => { setSelectedOutgoingChanges(prevSelected => {
if (prevSelected.includes(filePath)) { if (prevSelected.includes(filePath)) {
// Deselecting a file
const newSelection = prevSelected.filter( const newSelection = prevSelected.filter(
path => path !== filePath path => path !== filePath
); );
if (newSelection.length === 0) setSelectionType(null); // If no more files are selected, reset selection type
if (newSelection.length === 0) {
setSelectionType(null);
}
return newSelection; return newSelection;
} else { } else {
if ( // Selecting a file
prevSelected.length === 0 || if (prevSelected.length === 0) {
// First selection sets the type
setSelectionType(isStaged ? 'staged' : 'unstaged');
return [filePath];
} else if (
(isStaged && selectionType === 'staged') || (isStaged && selectionType === 'staged') ||
(!isStaged && selectionType === 'unstaged') (!isStaged && selectionType === 'unstaged')
) { ) {
setSelectionType(isStaged ? 'staged' : 'unstaged'); // Only allow selection if it matches current type
return [...prevSelected, filePath]; return [...prevSelected, filePath];
} else { }
// Don't add if it doesn't match the type
return prevSelected; return prevSelected;
} }
}
}); });
};
const handleCommitSelected = (files, message) => {
if (!message.trim()) {
console.error('Commit message cannot be empty');
return;
} }
onCommitSelected(files, message);
}; };
const getStageButtonTooltip = () => { const getStageButtonTooltip = () => {
@@ -85,11 +128,21 @@ const StatusContainer = ({
if (selectedOutgoingChanges.length === 0) { if (selectedOutgoingChanges.length === 0) {
return 'Select files to stage'; return 'Select files to stage';
} }
return 'Stage selected files'; return 'Stage Changes';
};
const getUnstageButtonTooltip = () => {
if (selectionType === 'unstaged') {
return 'These files are not staged';
}
if (selectedOutgoingChanges.length === 0) {
return 'Select files to unstage';
}
return 'Unstage Changes';
}; };
const getCommitButtonTooltip = () => { const getCommitButtonTooltip = () => {
if (selectionType === 'unstaged') { if (selectionType !== 'staged') {
return 'You can only commit staged files'; return 'You can only commit staged files';
} }
if (selectedOutgoingChanges.length === 0) { if (selectedOutgoingChanges.length === 0) {
@@ -98,50 +151,85 @@ const StatusContainer = ({
if (!commitMessage.trim()) { if (!commitMessage.trim()) {
return 'Enter a commit message'; return 'Enter a commit message';
} }
return 'Commit selected files'; return 'Commit Changes';
}; };
const getRevertButtonTooltip = () => { const getRevertButtonTooltip = () => {
if (selectedOutgoingChanges.length === 0) { if (selectedOutgoingChanges.length === 0) {
return 'Select files to revert'; return 'Select files to revert';
} }
return 'Revert selected files'; return 'Revert Changes';
};
const handleAbortMergeClick = () => {
setIsAbortModalOpen(true);
};
const handleConfirmAbortMerge = async () => {
try {
const response = await abortMerge();
if (response.success) {
await fetchGitStatus();
Alert.success(response.message);
} else {
Alert.error(response.error);
}
} catch (error) {
console.error('Error aborting merge:', error);
Alert.error(
'An unexpected error occurred while aborting the merge.'
);
} finally {
setIsAbortModalOpen(false);
}
}; };
useEffect(() => { useEffect(() => {
const fetchDiffs = async () => {
const allChanges = [
...status.incoming_changes,
...status.outgoing_changes
];
const diffPromises = allChanges.map(change =>
getDiff(change.file_path)
);
const diffs = await Promise.all(diffPromises);
const newDiffContents = {};
allChanges.forEach((change, index) => {
if (diffs[index].success) {
newDiffContents[change.file_path] = diffs[index].diff;
}
});
setDiffContents(newDiffContents);
};
fetchDiffs();
if ( if (
status.incoming_changes.length === 0 && status.incoming_changes.length === 0 &&
status.outgoing_changes.length === 0 status.outgoing_changes.length === 0 &&
status.merge_conflicts.length === 0 &&
(!isDevMode || !status.has_unpushed_commits)
) { ) {
setNoChangesMessage(getRandomMessage(noChangesMessages)); setNoChangesMessage(getRandomMessage(noChangesMessages));
} }
}, [status]); }, [status, isDevMode]);
// Reset commit message when selection changes
useEffect(() => {
if (selectionType !== 'staged') {
setCommitMessage('');
}
}, [selectionType]);
const hasChanges = const hasChanges =
status.incoming_changes.length > 0 || status.incoming_changes.length > 0 ||
status.outgoing_changes.length > 0; status.outgoing_changes.length > 0 ||
status.merge_conflicts.length > 0 ||
(isDevMode && status.has_unpushed_commits);
const areAllConflictsResolved = () => {
return status.merge_conflicts.every(
conflict => conflict.status === 'RESOLVED'
);
};
const handleMergeCommit = async () => {
try {
const response = await finalizeMerge();
if (response.success) {
await fetchGitStatus();
Alert.success(response.message);
} else {
Alert.error(response.error);
}
} catch (error) {
console.error('Error finalizing merge:', error);
Alert.error(
'An unexpected error occurred while finalizing the merge.'
);
}
};
return ( return (
<div className='dark:bg-gray-800 border border-gray-200 dark:border-gray-700 p-4 rounded-md'> <div className='dark:bg-gray-800 border border-gray-200 dark:border-gray-700 p-4 rounded-md'>
@@ -156,102 +244,283 @@ const StatusContainer = ({
{noChangesMessage} {noChangesMessage}
</span> </span>
) : ( ) : (
<span className='text-gray-400 text-m flex items-center'> <span className='text-gray-400 text-m flex items-center space-x-2'>
Out of Date! <span>Out of Date!</span>
</span> </span>
)} )}
</div> </div>
{!hasChanges && (
<div className='flex-shrink-0'>
<ActionButtons
isDevMode={isDevMode}
selectedOutgoingChanges={selectedOutgoingChanges}
selectedIncomingChanges={selectedIncomingChanges}
selectionType={selectionType}
commitMessage={commitMessage}
loadingAction={loadingAction}
onStageSelected={onStageSelected}
onCommitSelected={onCommitSelected}
onRevertSelected={onRevertSelected}
onPullSelected={onPullSelected}
getStageButtonTooltip={getStageButtonTooltip}
getCommitButtonTooltip={getCommitButtonTooltip}
getRevertButtonTooltip={getRevertButtonTooltip}
/>
</div>
)}
</div> </div>
{status.is_merging ? (
<div className='mb-4'>
<div className='flex items-center justify-between'>
<h4 className='text-sm font-medium text-gray-200 flex items-center'>
<AlertTriangle
className='text-yellow-400 mr-2'
size={16}
/>
<span>Merge Conflicts</span>
</h4>
<div className='flex space-x-2'>
<Tooltip
content={
areAllConflictsResolved()
? 'Commit merge changes'
: 'Resolve all conflicts first'
}>
<button
onClick={handleMergeCommit}
disabled={!areAllConflictsResolved()}
className={`p-1.5 rounded-md text-white focus:outline-none focus:ring-2 focus:ring-offset-2
${
areAllConflictsResolved()
? 'bg-green-500 hover:bg-green-600 focus:ring-green-500'
: 'bg-gray-400 cursor-not-allowed'
}`}>
<CheckCircle size={16} />
</button>
</Tooltip>
<Tooltip content='Abort Merge'>
<button
onClick={handleAbortMergeClick}
className='p-1.5 text-white bg-red-600 rounded-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500'>
<XCircle size={16} />
</button>
</Tooltip>
</div>
</div>
<ConflictTable
conflicts={status.merge_conflicts}
isDevMode={isDevMode}
fetchGitStatus={fetchGitStatus}
/>
</div>
) : (
<>
{status.incoming_changes.length > 0 && ( {status.incoming_changes.length > 0 && (
<ChangeTable <div className='mb-4'>
changes={status.incoming_changes} <div className='flex items-center justify-between mb-2'>
title='Incoming Changes' <h4 className='text-sm font-medium text-gray-200 flex items-center'>
icon={
<ArrowDownToLine <ArrowDownToLine
className='text-blue-400 mr-2' className='text-blue-400 mr-2'
size={16} size={16}
/> />
} <span>
Incoming Changes (
{status.incoming_changes.length})
</span>
</h4>
<IconButton
onClick={onPullSelected}
disabled={false}
loading={loadingAction === 'pull_changes'}
icon={<Download />}
tooltip='Pull Changes'
className='bg-yellow-600'
/>
</div>
<div className='border border-gray-600 rounded-md overflow-hidden'>
<ChangeTable
changes={status.incoming_changes}
isIncoming={true} isIncoming={true}
selectedChanges={selectedIncomingChanges} selectable={false}
onSelectChange={handleSelectChange} selectedChanges={[]}
sortConfig={sortConfig} sortConfig={sortConfig}
onRequestSort={requestSort} onRequestSort={requestSort}
isDevMode={isDevMode} isDevMode={isDevMode}
diffContents={diffContents}
/> />
</div>
</div>
)} )}
{status.outgoing_changes.length > 0 && ( {(status.outgoing_changes.length > 0 ||
<ChangeTable (isDevMode && status.has_unpushed_commits)) && (
changes={status.outgoing_changes} <div className='mb-4'>
title='Outgoing Changes' <div className='flex items-center justify-between mb-2'>
icon={ <h4 className='text-sm font-medium text-gray-200 flex items-center'>
<ArrowUpFromLine <ArrowUpFromLine
className='text-blue-400 mr-2' className='text-blue-400 mr-2'
size={16} size={16}
/> />
<span>
Outgoing Changes (
{status.outgoing_changes.length +
(isDevMode && status.unpushed_files
? status.unpushed_files.length
: 0)}
)
</span>
</h4>
<div className='space-x-2 flex'>
<IconButton
onClick={() =>
onStageSelected(
selectedOutgoingChanges
)
} }
disabled={
selectionType !== 'unstaged' ||
selectedOutgoingChanges.length === 0
}
loading={
loadingAction === 'stage_selected'
}
icon={<Plus />}
tooltip='Stage Changes'
className='bg-green-600'
disabledTooltip={getStageButtonTooltip()}
/>
<IconButton
onClick={() =>
onUnstageSelected(
selectedOutgoingChanges
)
}
disabled={
selectionType !== 'staged' ||
selectedOutgoingChanges.length === 0
}
loading={
loadingAction === 'unstage_selected'
}
icon={<MinusCircle />}
tooltip='Unstage Changes'
className='bg-yellow-600'
disabledTooltip={getUnstageButtonTooltip()}
/>
<IconButton
onClick={() =>
handleCommitSelected(
selectedOutgoingChanges,
commitMessage
)
}
disabled={!canCommit}
loading={
loadingAction === 'commit_selected'
}
icon={<CheckCircle />}
tooltip='Commit Changes'
className='bg-blue-600'
disabledTooltip={getCommitButtonTooltip()}
/>
{isDevMode && (
<IconButton
onClick={onPushSelected}
disabled={!canPush}
loading={
loadingAction === 'push_changes'
}
icon={<Upload />}
tooltip={
<div>
<div>Push Changes</div>
{status.unpushed_files
?.length > 0 && (
<div className='mt-1 text-xs'>
{status.unpushed_files.map(
(
file,
index
) => (
<div
key={
index
}>
{' '}
{
file.type
}
:{' '}
{
file.name
}
</div>
)
)}
</div>
)}
</div>
}
className='bg-purple-600'
disabledTooltip='No changes to push'
/>
)}
<IconButton
onClick={() =>
onRevertSelected(
selectedOutgoingChanges
)
}
disabled={!canRevert}
loading={
loadingAction === 'revert_selected'
}
icon={<RotateCcw />}
tooltip='Revert Changes'
className='bg-red-600'
disabledTooltip={getRevertButtonTooltip()}
/>
</div>
</div>
{status.outgoing_changes.length > 0 && (
<div className='border border-gray-600 rounded-md overflow-hidden'>
<ChangeTable
changes={status.outgoing_changes}
isIncoming={false} isIncoming={false}
selectedChanges={selectedOutgoingChanges} selectedChanges={
onSelectChange={handleSelectChange} selectedOutgoingChanges
}
onSelectChange={filePath =>
handleSelectChange(filePath)
}
sortConfig={sortConfig} sortConfig={sortConfig}
onRequestSort={requestSort} onRequestSort={requestSort}
isDevMode={isDevMode} isDevMode={isDevMode}
diffContents={diffContents}
/> />
</div>
)}
</div>
)}
</>
)} )}
{hasChanges && ( {selectionType === 'staged' &&
<> selectedOutgoingChanges.length > 0 && (
<CommitSection <CommitSection
status={status} status={status}
commitMessage={commitMessage} commitMessage={commitMessage}
setCommitMessage={setCommitMessage} setCommitMessage={setCommitMessage}
selectedOutgoingChanges={selectedOutgoingChanges}
loadingAction={loadingAction} loadingAction={loadingAction}
hasIncomingChanges={status.incoming_changes.length > 0} hasIncomingChanges={status.incoming_changes.length > 0}
hasMergeConflicts={status.merge_conflicts.length > 0}
isDevMode={isDevMode} isDevMode={isDevMode}
/> />
<div className='mt-4 flex justify-end'>
<ActionButtons
isDevMode={isDevMode}
selectedOutgoingChanges={selectedOutgoingChanges}
selectedIncomingChanges={selectedIncomingChanges}
selectionType={selectionType}
commitMessage={commitMessage}
loadingAction={loadingAction}
onStageSelected={onStageSelected}
onCommitSelected={onCommitSelected}
onRevertSelected={onRevertSelected}
onPullSelected={onPullSelected}
getStageButtonTooltip={getStageButtonTooltip}
getCommitButtonTooltip={getCommitButtonTooltip}
getRevertButtonTooltip={getRevertButtonTooltip}
/>
</div>
</>
)} )}
<Modal
isOpen={isAbortModalOpen}
onClose={() => setIsAbortModalOpen(false)}
title='Confirm Abort Merge'
width='md'>
<div className='space-y-4'>
<div className='text-gray-700 dark:text-gray-300'>
<p>Are you sure you want to abort the current merge?</p>
<p className='mt-2 text-yellow-600 dark:text-yellow-400'>
This will discard all merge progress and restore
your repository to its state before the merge began.
</p>
</div>
<div className='flex justify-end space-x-3'>
<button
onClick={handleConfirmAbortMerge}
className='px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500'>
Abort Merge
</button>
</div>
</div>
</Modal>
</div> </div>
); );
}; };

View File

@@ -8,6 +8,11 @@ const DiffCommit = ({commitMessage}) => {
return ( return (
<div className='bg-gray-100 dark:bg-gray-800 p-4 rounded-lg'> <div className='bg-gray-100 dark:bg-gray-800 p-4 rounded-lg'>
<div className='mb-2'>
<span className='text-xl font-semibold text-gray-700 dark:text-gray-300 mr-3'>
Details
</span>
</div>
<div className='flex items-start space-x-3'> <div className='flex items-start space-x-3'>
<div className='flex-1'> <div className='flex-1'>
<div className='mb-2'> <div className='mb-2'>

View File

@@ -0,0 +1,427 @@
import React, {useState, useEffect} from 'react';
import PropTypes from 'prop-types';
import Modal from '../../../ui/Modal';
import DiffCommit from './DiffCommit';
import Tooltip from '../../../ui/Tooltip';
import Alert from '../../../ui/Alert';
import {getFormats, resolveConflict} from '../../../../api/api';
const ResolveConflicts = ({
isOpen,
onClose,
change,
isIncoming,
isMergeConflict,
fetchGitStatus
}) => {
const [formatNames, setFormatNames] = useState({});
const [conflictResolutions, setConflictResolutions] = useState({});
useEffect(() => {
const fetchFormatNames = async () => {
try {
const formats = await getFormats();
const namesMap = formats.reduce((acc, format) => {
acc[format.id] = format.name;
return acc;
}, {});
setFormatNames(namesMap);
} catch (error) {
console.error('Error fetching format names:', error);
}
};
fetchFormatNames();
}, []);
useEffect(() => {
if (!isMergeConflict) {
setConflictResolutions({});
}
}, [isMergeConflict, change]);
const handleResolutionChange = (key, value) => {
setConflictResolutions(prev => ({
...prev,
[key]: value
}));
};
const parseKey = param => {
return param
.split('_')
.map(
word =>
word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
)
.join(' ');
};
const formatDate = dateString => {
const date = new Date(dateString);
return date.toLocaleString();
};
const renderTable = (title, headers, data, renderRow) => {
if (!data || data.length === 0) return null;
return (
<div className='mb-6'>
<h4 className='text-md font-semibold text-gray-200 mb-2'>
{title}
</h4>
<div className='border border-gray-600 rounded-md overflow-hidden'>
<table className='w-full table-fixed'>
<thead className='bg-gray-600'>
<tr>
{headers.map((header, index) => (
<th
key={index}
className={`px-4 py-2 text-left text-gray-300 ${header.width}`}>
{header.label}
</th>
))}
</tr>
</thead>
<tbody>
{data.map((item, index) => renderRow(item, index))}
</tbody>
</table>
</div>
</div>
);
};
const renderBasicFields = () => {
const basicFields = ['name', 'description'];
const conflicts = change.conflict_details.conflicting_parameters.filter(
param => basicFields.includes(param.parameter)
);
if (conflicts.length === 0) return null;
return renderTable(
'Basic Fields',
[
{label: 'Field', width: 'w-1/4'},
{label: 'Local Value', width: 'w-1/4'},
{label: 'Incoming Value', width: 'w-1/4'},
{label: 'Resolution', width: 'w-1/4'}
],
conflicts,
({parameter, local_value, incoming_value}) => (
<tr key={parameter} className='border-t border-gray-600'>
<td className='px-4 py-2.5 text-gray-300'>
{parseKey(parameter)}
</td>
<td className='px-4 py-2.5 text-gray-300'>{local_value}</td>
<td className='px-4 py-2.5 text-gray-300'>
{incoming_value}
</td>
<td className='px-4 py-2.5'>
<select
value={conflictResolutions[parameter] || ''}
onChange={e =>
handleResolutionChange(
parameter,
e.target.value
)
}
className='w-full p-2 bg-gray-700 text-gray-200 rounded'>
<option value='' disabled>
Select
</option>
<option value='local'>Keep Local</option>
<option value='incoming'>Accept Incoming</option>
</select>
</td>
</tr>
)
);
};
const renderCustomFormatConflicts = () => {
if (change.type !== 'Quality Profile') return null;
const formatConflict =
change.conflict_details.conflicting_parameters.find(
param => param.parameter === 'custom_formats'
);
if (!formatConflict) return null;
const changedFormats = [];
const localFormats = formatConflict.local_value;
const incomingFormats = formatConflict.incoming_value;
// Compare and find changed scores
localFormats.forEach(localFormat => {
const incomingFormat = incomingFormats.find(
f => f.id === localFormat.id
);
if (incomingFormat && incomingFormat.score !== localFormat.score) {
changedFormats.push({
id: localFormat.id,
name:
formatNames[localFormat.id] ||
`Format ${localFormat.id}`,
local_score: localFormat.score,
incoming_score: incomingFormat.score
});
}
});
if (changedFormats.length === 0) return null;
return renderTable(
'Custom Format Conflicts',
[
{label: 'Format', width: 'w-1/4'},
{label: 'Local Score', width: 'w-1/4'},
{label: 'Incoming Score', width: 'w-1/4'},
{label: 'Resolution', width: 'w-1/4'}
],
changedFormats,
({id, name, local_score, incoming_score}) => (
<tr key={id} className='border-t border-gray-600'>
<td className='px-4 py-2.5 text-gray-300'>{name}</td>
<td className='px-4 py-2.5 text-gray-300'>{local_score}</td>
<td className='px-4 py-2.5 text-gray-300'>
{incoming_score}
</td>
<td className='px-4 py-2.5'>
<select
value={
conflictResolutions[`custom_format_${id}`] || ''
}
onChange={e =>
handleResolutionChange(
`custom_format_${id}`,
e.target.value
)
}
className='w-full p-2 bg-gray-700 text-gray-200 rounded'>
<option value='' disabled>
Select
</option>
<option value='local'>Keep Local Score</option>
<option value='incoming'>
Accept Incoming Score
</option>
</select>
</td>
</tr>
)
);
};
const renderTagConflicts = () => {
const tagConflict = change.conflict_details.conflicting_parameters.find(
param => param.parameter === 'tags'
);
if (!tagConflict) return null;
const localTags = new Set(tagConflict.local_value);
const incomingTags = new Set(tagConflict.incoming_value);
const allTags = [...new Set([...localTags, ...incomingTags])];
const tagDiffs = allTags
.filter(tag => localTags.has(tag) !== incomingTags.has(tag))
.map(tag => ({
tag,
local_status: localTags.has(tag) ? 'present' : 'absent',
incoming_status: incomingTags.has(tag) ? 'present' : 'absent'
}));
if (tagDiffs.length === 0) return null;
return renderTable(
'Tag Conflicts',
[
{label: 'Tag', width: 'w-1/4'},
{label: 'Local Status', width: 'w-1/4'},
{label: 'Incoming Status', width: 'w-1/4'},
{label: 'Resolution', width: 'w-1/4'}
],
tagDiffs,
({tag, local_status, incoming_status}) => (
<tr key={tag} className='border-t border-gray-600'>
<td className='px-4 py-2.5 text-gray-300'>{tag}</td>
<td className='px-4 py-2.5 text-gray-300'>
{local_status}
</td>
<td className='px-4 py-2.5 text-gray-300'>
{incoming_status}
</td>
<td className='px-4 py-2.5'>
<select
value={conflictResolutions[`tag_${tag}`] || ''}
onChange={e =>
handleResolutionChange(
`tag_${tag}`,
e.target.value
)
}
className='w-full p-2 bg-gray-700 text-gray-200 rounded'>
<option value='' disabled>
Select
</option>
<option value='local'>Keep Local Status</option>
<option value='incoming'>
Accept Incoming Status
</option>
</select>
</td>
</tr>
)
);
};
const areAllConflictsResolved = () => {
if (!isMergeConflict) return true;
const requiredResolutions = [];
// Basic fields
change.conflict_details.conflicting_parameters
.filter(param => ['name', 'description'].includes(param.parameter))
.forEach(param => requiredResolutions.push(param.parameter));
// Custom formats (only for Quality Profiles)
if (change.type === 'Quality Profile') {
const formatConflict =
change.conflict_details.conflicting_parameters.find(
param => param.parameter === 'custom_formats'
);
if (formatConflict) {
const localFormats = formatConflict.local_value;
const incomingFormats = formatConflict.incoming_value;
localFormats.forEach(localFormat => {
const incomingFormat = incomingFormats.find(
f => f.id === localFormat.id
);
if (
incomingFormat &&
incomingFormat.score !== localFormat.score
) {
requiredResolutions.push(
`custom_format_${localFormat.id}`
);
}
});
}
}
// Tags
const tagConflict = change.conflict_details.conflicting_parameters.find(
param => param.parameter === 'tags'
);
if (tagConflict) {
const localTags = new Set(tagConflict.local_value);
const incomingTags = new Set(tagConflict.incoming_value);
const allTags = [...new Set([...localTags, ...incomingTags])];
allTags.forEach(tag => {
if (localTags.has(tag) !== incomingTags.has(tag)) {
requiredResolutions.push(`tag_${tag}`);
}
});
}
return requiredResolutions.every(key => conflictResolutions[key]);
};
const handleResolveConflicts = async () => {
console.log('File path:', change.file_path);
const resolutions = {
[change.file_path]: conflictResolutions
};
console.log('Sending resolutions:', resolutions);
try {
const result = await resolveConflict(resolutions);
Alert.success('Successfully resolved conflicts');
if (result.error) {
Alert.warning(result.error);
}
} catch (error) {
Alert.error(error.message || 'Failed to resolve conflicts');
}
};
// Title with status indicator
const titleContent = (
<div className='flex items-center space-x-2'>
<span className='text-lg font-bold'>
{change.type} - {change.name}
</span>
<span
className={`px-2 py-1 rounded text-xs ${
isMergeConflict
? 'bg-yellow-500'
: isIncoming
? 'bg-blue-500'
: 'bg-green-500'
}`}>
{isMergeConflict
? 'Merge Conflict'
: isIncoming
? 'Incoming Change'
: 'Local Change'}
</span>
</div>
);
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={titleContent}
width='5xl'>
<div className='space-y-4'>
{renderBasicFields()}
{renderCustomFormatConflicts()}
{renderTagConflicts()}
{isMergeConflict && (
<div className='flex justify-end'>
<Tooltip
content={
!areAllConflictsResolved()
? 'Resolve all conflicts first!'
: ''
}>
<button
onClick={handleResolveConflicts}
disabled={!areAllConflictsResolved()}
className={`px-4 py-2 rounded ${
areAllConflictsResolved()
? 'bg-purple-500 hover:bg-purple-600 text-white'
: 'bg-gray-500 text-gray-300 cursor-not-allowed'
}`}>
Resolve Conflicts
</button>
</Tooltip>
</div>
)}
</div>
</Modal>
);
};
ResolveConflicts.propTypes = {
isOpen: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
change: PropTypes.object.isRequired,
isIncoming: PropTypes.bool.isRequired,
isMergeConflict: PropTypes.bool,
fetchGitStatus: PropTypes.func.isRequired
};
export default ResolveConflicts;

View File

@@ -88,14 +88,14 @@ const SettingsBranchModal = ({
Alert.success('Branch created successfully'); Alert.success('Branch created successfully');
resetForm(); resetForm();
} else { } else {
Alert.error(response.error); Alert.error(response.error.error || response.error);
} }
} catch (error) { } catch (error) {
if ( if (error.response?.status === 409) {
error.response && Alert.error(
error.response.status === 400 && 'Cannot perform operation - merge in progress. Please resolve conflicts first.'
error.response.data.error );
) { } else if (error.response?.status === 400) {
Alert.error(error.response.data.error); Alert.error(error.response.data.error);
} else { } else {
console.error('Error branching off:', error); console.error('Error branching off:', error);
@@ -139,14 +139,15 @@ const SettingsBranchModal = ({
Alert.success('Branch checked out successfully'); Alert.success('Branch checked out successfully');
onClose(); onClose();
} else { } else {
Alert.error(response.error); // The error is nested inside result.error from the backend
Alert.error(response.error.error || response.error);
} }
} catch (error) { } catch (error) {
if ( if (error.response?.status === 409) {
error.response && Alert.error(
error.response.status === 400 && 'Cannot perform operation - merge in progress. Please resolve conflicts first.'
error.response.data.error );
) { } else if (error.response?.status === 400) {
Alert.error(error.response.data.error); Alert.error(error.response.data.error);
} else { } else {
Alert.error( Alert.error(
@@ -179,13 +180,19 @@ const SettingsBranchModal = ({
`Branch '${branchName}' deleted successfully` `Branch '${branchName}' deleted successfully`
); );
} else { } else {
Alert.error(response.error); Alert.error(response.error.error || response.error);
} }
} catch (error) { } catch (error) {
if (error.response?.status === 409) {
Alert.error(
'Cannot perform operation - merge in progress. Please resolve conflicts first.'
);
} else {
Alert.error( Alert.error(
'An unexpected error occurred while deleting the branch.' 'An unexpected error occurred while deleting the branch.'
); );
console.error('Error deleting branch:', error); console.error('Error deleting branch:', error);
}
} finally { } finally {
setLoadingAction(''); setLoadingAction('');
setConfirmAction(null); setConfirmAction(null);
@@ -194,7 +201,6 @@ const SettingsBranchModal = ({
setConfirmAction(`delete-${branchName}`); setConfirmAction(`delete-${branchName}`);
} }
}; };
const handlePushToRemote = async branchName => { const handlePushToRemote = async branchName => {
if (confirmAction === `push-${branchName}`) { if (confirmAction === `push-${branchName}`) {
setLoadingAction(`push-${branchName}`); setLoadingAction(`push-${branchName}`);
@@ -206,13 +212,19 @@ const SettingsBranchModal = ({
); );
await fetchBranches(); await fetchBranches();
} else { } else {
Alert.error(response.error); Alert.error(response.error.error || response.error);
} }
} catch (error) { } catch (error) {
if (error.response?.status === 409) {
Alert.error(
'Cannot perform operation - merge in progress. Please resolve conflicts first.'
);
} else {
Alert.error( Alert.error(
'An unexpected error occurred while pushing the branch to remote.' 'An unexpected error occurred while pushing the branch to remote.'
); );
console.error('Error pushing branch to remote:', error); console.error('Error pushing branch to remote:', error);
}
} finally { } finally {
setLoadingAction(''); setLoadingAction('');
setConfirmAction(null); setConfirmAction(null);

View File

@@ -0,0 +1,207 @@
import React, {useState, useEffect} from 'react';
import PropTypes from 'prop-types';
import Modal from '../../../ui/Modal';
import DiffCommit from './DiffCommit';
import {getFormats} from '../../../../api/api';
const ViewChanges = ({isOpen, onClose, change, isIncoming}) => {
const [formatNames, setFormatNames] = useState({});
useEffect(() => {
const fetchFormatNames = async () => {
try {
const formats = await getFormats();
const namesMap = formats.reduce((acc, format) => {
acc[format.id] = format.name;
return acc;
}, {});
setFormatNames(namesMap);
} catch (error) {
console.error('Error fetching format names:', error);
}
};
fetchFormatNames();
}, []);
const parseKey = param => {
return param
.split('_')
.map(
word =>
word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
)
.join(' ');
};
const parseChange = changeType => {
return (
changeType.charAt(0).toUpperCase() +
changeType.slice(1).toLowerCase()
);
};
const renderTable = (title, headers, data, renderRow) => {
if (!data || !Array.isArray(data) || data.length === 0) {
return (
<div className='mb-6'>
<h4 className='text-md font-semibold text-gray-200 mb-2'>
{title}
</h4>
<div className='border border-gray-600 rounded-md p-4 text-gray-400'>
No data available.
</div>
</div>
);
}
return (
<div className='mb-6'>
<h4 className='text-md font-semibold text-gray-200 mb-2'>
{title}
</h4>
<div className='border border-gray-600 rounded-md overflow-hidden'>
<table className='w-full table-fixed'>
<colgroup>
{headers.map((header, index) => (
<col key={index} className={header.width} />
))}
</colgroup>
<thead className='bg-gray-600'>
<tr>
{headers.map((header, index) => (
<th
key={index}
className={`px-4 py-2 text-left text-gray-300 ${
header.align || ''
} ${
index === 0 ? 'rounded-tl-md' : ''
} ${
index === headers.length - 1
? 'rounded-tr-md'
: ''
}`}>
{header.label}
</th>
))}
</tr>
</thead>
<tbody>{data.map(renderRow)}</tbody>
</table>
</div>
</div>
);
};
const renderChanges = () => {
const isNewFile = change.status === 'New';
const headers = isNewFile
? [
{key: 'change', label: 'Change', width: 'w-1/5'},
{key: 'key', label: 'Key', width: 'w-1/5'},
{key: 'value', label: 'Value', width: 'w-3/5'}
]
: [
{key: 'change', label: 'Change', width: 'w-1/5'},
{key: 'key', label: 'Key', width: 'w-1/5'},
{key: 'from', label: 'From', width: 'w-1/5'},
{key: 'to', label: 'Value', width: 'w-3/5'}
];
return renderTable(
'Changes',
headers,
change.changes,
({change: changeType, key, from, to, value}, index) => {
if (key.startsWith('custom_format_')) {
const formatId = key.split('_')[2];
return (
<tr
key={`custom_format_${formatId}`}
className='border-t border-gray-600'>
<td className='px-4 py-2.5 text-gray-300'>
{parseChange(changeType)}
</td>
<td className='px-4 py-2.5 text-gray-300'>
{`Custom Format: ${
formatNames[formatId] ||
`Format ${formatId}`
}`}
</td>
{isNewFile ? (
<td className='px-4 py-2.5 text-gray-300'>
{to ?? value ?? '-'}
</td>
) : (
<>
<td className='px-4 py-2.5 text-gray-300'>
{from ?? '-'}
</td>
<td className='px-4 py-2.5 text-gray-300'>
{to ?? value ?? '-'}
</td>
</>
)}
</tr>
);
}
return (
<tr key={index} className='border-t border-gray-600'>
<td className='px-4 py-2.5 text-gray-300'>
{parseChange(changeType)}
</td>
<td className='px-4 py-2.5 text-gray-300'>
{parseKey(key)}
</td>
{isNewFile ? (
<td className='px-4 py-2.5 text-gray-300'>
{to ?? value ?? '-'}
</td>
) : (
<>
<td className='px-4 py-2.5 text-gray-300'>
{from ?? '-'}
</td>
<td className='px-4 py-2.5 text-gray-300'>
{to ?? value ?? '-'}
</td>
</>
)}
</tr>
);
}
);
};
const titleContent = (
<div className='flex items-center space-x-2 flex-wrap'>
<span className='text-lg font-bold'>View Changes</span>
</div>
);
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={titleContent}
width='5xl'>
<div className='space-y-4'>
{change.commit_message && (
<DiffCommit commitMessage={change.commit_message} />
)}
{renderChanges()}
</div>
</Modal>
);
};
ViewChanges.propTypes = {
isOpen: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
change: PropTypes.object.isRequired,
isIncoming: PropTypes.bool.isRequired
};
export default ViewChanges;

View File

@@ -1,166 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import Modal from '../../../ui/Modal';
import DiffCommit from './DiffCommit';
const ViewDiff = ({
isOpen,
onClose,
diffContent,
type,
name,
commitMessage,
isIncoming
}) => {
const formatDiffContent = content => {
if (!content) return [];
const lines = content.split('\n');
// Remove the first 5 lines (git diff header)
const contentWithoutHeader = lines.slice(5);
return contentWithoutHeader.map((line, index) => {
let lineClass = 'py-1 pl-4 border-l-2 ';
let displayLine = line;
if (line.startsWith('+')) {
if (isIncoming) {
lineClass += 'bg-red-900/30 text-red-400 border-red-500';
displayLine = '-' + line.slice(1);
} else {
lineClass +=
'bg-green-900/30 text-green-400 border-green-500';
}
} else if (line.startsWith('-')) {
if (isIncoming) {
lineClass +=
'bg-green-900/30 text-green-400 border-green-500';
displayLine = '+' + line.slice(1);
} else {
lineClass += 'bg-red-900/30 text-red-400 border-red-500';
}
} else {
lineClass += 'border-transparent';
}
return (
<div key={index} className={`flex ${lineClass}`}>
<span className='w-12 text-gray-500 select-none text-right pr-4 border-r border-gray-700'>
{index + 1}
</span>
<code className='flex-1 pl-4 font-mono text-sm'>
{displayLine}
</code>
</div>
);
});
};
// Tag background colors based on the type and scope
const typeColors = {
'New Feature':
'bg-green-100 text-white-800 dark:bg-green-900 dark:text-white-200',
'Bug Fix':
'bg-red-100 text-white-800 dark:bg-red-900 dark:text-white-200',
Documentation:
'bg-yellow-100 text-white-800 dark:bg-yellow-900 dark:text-white-200',
'Style Change':
'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
Refactoring:
'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200',
'Performance Improvement':
'bg-teal-100 text-teal-800 dark:bg-teal-900 dark:text-teal-200',
'Test Addition/Modification':
'bg-pink-100 text-pink-800 dark:bg-pink-900 dark:text-pink-200',
'Chore/Maintenance':
'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200'
};
const scopeColors = {
'Regex Pattern':
'bg-blue-100 text-white-800 dark:bg-blue-900 dark:text-white-200',
'Custom Format':
'bg-green-100 text-white-800 dark:bg-green-900 dark:text-white-200',
'Quality Profile':
'bg-yellow-100 text-white-800 dark:bg-yellow-900 dark:text-white-200'
};
const renderTag = (label, colorClass) => (
<span
className={`inline-block px-2 py-1 rounded text-xs font-medium mr-2 ${colorClass}`}>
{label}
</span>
);
const titleContent = (
<div className='flex items-center space-x-2 flex-wrap'>
<span>{name}</span>
<span
className={`px-2 py-1 rounded text-xs font-medium ${
isIncoming
? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
}`}>
{isIncoming ? 'Incoming' : 'Outgoing'}
</span>
{commitMessage &&
commitMessage.type &&
renderTag(
commitMessage.type,
typeColors[commitMessage.type] ||
'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200'
)}
{commitMessage &&
commitMessage.scope &&
renderTag(
commitMessage.scope,
scopeColors[commitMessage.scope] ||
'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200'
)}
</div>
);
const formattedContent = formatDiffContent(diffContent);
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={titleContent}
width='3xl'>
<div className='space-y-4'>
<DiffCommit commitMessage={commitMessage} />
<div className='border border-gray-300 dark:border-gray-600 rounded-lg overflow-hidden'>
<div className='bg-gray-50 dark:bg-gray-800 p-2 text-sm font-medium text-gray-600 dark:text-gray-300 border-b border-gray-300 dark:border-gray-600'>
Diff Content
</div>
<div className='bg-white dark:bg-gray-900 p-4 max-h-[60vh] overflow-y-auto'>
{formattedContent.length > 0 ? (
formattedContent
) : (
<div className='text-gray-500 dark:text-gray-400 italic'>
No differences found or file is empty.
</div>
)}
</div>
</div>
</div>
</Modal>
);
};
ViewDiff.propTypes = {
isOpen: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
diffContent: PropTypes.string,
type: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
commitMessage: PropTypes.shape({
type: PropTypes.string,
scope: PropTypes.string,
subject: PropTypes.string,
body: PropTypes.string,
footer: PropTypes.string
}),
isIncoming: PropTypes.bool.isRequired
};
export default ViewDiff;

View File

@@ -0,0 +1,32 @@
import React from 'react';
import {Loader} from 'lucide-react';
import Tooltip from './Tooltip';
const IconButton = ({
onClick,
disabled,
loading,
icon,
tooltip,
className,
disabledTooltip
}) => {
return (
<Tooltip content={disabled ? disabledTooltip : tooltip}>
<button
onClick={onClick}
disabled={disabled || loading}
className={`flex items-center justify-center w-8 h-8 text-white rounded-md transition-all duration-200 ease-in-out hover:opacity-80 ${className} ${
disabled ? 'opacity-50 cursor-not-allowed' : ''
}`}>
{loading ? (
<Loader size={14} className='animate-spin' />
) : (
React.cloneElement(icon, {size: 14})
)}
</button>
</Tooltip>
);
};
export default IconButton;

View File

@@ -9,8 +9,9 @@ function Modal({
level = 0, level = 0,
disableCloseOnOutsideClick = false, disableCloseOnOutsideClick = false,
disableCloseOnEscape = false, disableCloseOnEscape = false,
width = 'lg', width = 'auto',
height = 'auto' height = 'auto',
maxHeight = '80vh'
}) { }) {
const modalRef = useRef(); const modalRef = useRef();
@@ -39,6 +40,7 @@ function Modal({
}; };
const widthClasses = { const widthClasses = {
auto: 'w-auto max-w-[60%]',
sm: 'max-w-sm', sm: 'max-w-sm',
md: 'max-w-md', md: 'max-w-md',
lg: 'max-w-lg', lg: 'max-w-lg',
@@ -74,7 +76,7 @@ function Modal({
return ( return (
<div <div
className={`fixed inset-0 overflow-y-auto h-full w-full flex items-center justify-center transition-opacity duration-300 ease-out ${ className={`fixed inset-0 overflow-y-auto h-full w-full flex items-center justify-center transition-opacity duration-300 ease-out scrollable ${
isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none' isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'
}`} }`}
style={{zIndex: 1000 + level * 10}} style={{zIndex: 1000 + level * 10}}
@@ -86,7 +88,7 @@ function Modal({
style={{zIndex: 1000 + level * 10}}></div> style={{zIndex: 1000 + level * 10}}></div>
<div <div
ref={modalRef} ref={modalRef}
className={`relative bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full shadow-md ${ className={`relative bg-white dark:bg-gray-800 rounded-lg shadow-xl ${
widthClasses[width] widthClasses[width]
} ${ } ${
heightClasses[height] heightClasses[height]
@@ -95,7 +97,8 @@ function Modal({
}`} }`}
style={{ style={{
zIndex: 1001 + level * 10, zIndex: 1001 + level * 10,
overflowY: 'auto' overflowY: 'auto',
maxHeight: maxHeight
}} }}
onClick={e => e.stopPropagation()}> onClick={e => e.stopPropagation()}>
<div className='flex justify-between items-center px-6 py-4 pb-3 border-b border-gray-300 dark:border-gray-700'> <div className='flex justify-between items-center px-6 py-4 pb-3 border-b border-gray-300 dark:border-gray-700'>
@@ -134,6 +137,7 @@ Modal.propTypes = {
disableCloseOnOutsideClick: PropTypes.bool, disableCloseOnOutsideClick: PropTypes.bool,
disableCloseOnEscape: PropTypes.bool, disableCloseOnEscape: PropTypes.bool,
width: PropTypes.oneOf([ width: PropTypes.oneOf([
'auto',
'sm', 'sm',
'md', 'md',
'lg', 'lg',
@@ -164,7 +168,8 @@ Modal.propTypes = {
'5xl', '5xl',
'6xl', '6xl',
'full' 'full'
]) ]),
maxHeight: PropTypes.string
}; };
export default Modal; export default Modal;

View File

@@ -1,30 +1,28 @@
import PropTypes from "prop-types"; import PropTypes from 'prop-types';
import { useState, useEffect, useRef } from "react"; import {useState, useEffect, useRef, useLayoutEffect} from 'react';
import { Link, useLocation } from "react-router-dom"; import {Link, useLocation} from 'react-router-dom';
function ToggleSwitch({ checked, onChange }) { function ToggleSwitch({checked, onChange}) {
return ( return (
<label className="flex items-center cursor-pointer"> <label className='flex items-center cursor-pointer'>
<div className="relative"> <div className='relative'>
<input <input
type="checkbox" type='checkbox'
className="sr-only" className='sr-only'
checked={checked} checked={checked}
onChange={onChange} onChange={onChange}
/> />
<div <div
className={`block w-14 h-8 rounded-full ${ className={`block w-14 h-8 rounded-full ${
checked ? "bg-blue-600" : "bg-gray-600" checked ? 'bg-blue-600' : 'bg-gray-600'
} transition-colors duration-300`} } transition-colors duration-300`}></div>
></div>
<div <div
className={`dot absolute left-1 top-1 bg-white w-6 h-6 rounded-full transition-transform duration-300 ${ className={`dot absolute left-1 top-1 bg-white w-6 h-6 rounded-full transition-transform duration-300 ${
checked ? "transform translate-x-6" : "" checked ? 'transform translate-x-6' : ''
}`} }`}></div>
></div>
</div> </div>
<div className="ml-3 text-gray-300 font-medium"> <div className='ml-3 text-gray-300 font-medium'>
{checked ? "Dark" : "Light"} {checked ? 'Dark' : 'Light'}
</div> </div>
</label> </label>
); );
@@ -32,86 +30,107 @@ function ToggleSwitch({ checked, onChange }) {
ToggleSwitch.propTypes = { ToggleSwitch.propTypes = {
checked: PropTypes.bool.isRequired, checked: PropTypes.bool.isRequired,
onChange: PropTypes.func.isRequired, onChange: PropTypes.func.isRequired
}; };
function Navbar({ darkMode, setDarkMode }) { function Navbar({darkMode, setDarkMode}) {
const [tabOffset, setTabOffset] = useState(0); const [tabOffset, setTabOffset] = useState(0);
const [tabWidth, setTabWidth] = useState(0); const [tabWidth, setTabWidth] = useState(0);
const tabsRef = useRef({}); const tabsRef = useRef({});
const location = useLocation(); const location = useLocation();
const [isInitialized, setIsInitialized] = useState(false);
const getActiveTab = (pathname) => { const getActiveTab = pathname => {
if (pathname.startsWith("/regex")) return "regex"; if (pathname === '/' || pathname === '') return 'settings';
if (pathname.startsWith("/format")) return "format"; if (pathname.startsWith('/regex')) return 'regex';
if (pathname.startsWith("/profile")) return "profile"; if (pathname.startsWith('/format')) return 'format';
if (pathname.startsWith("/settings")) return "settings"; if (pathname.startsWith('/profile')) return 'profile';
return "settings"; // default to settings if no match if (pathname.startsWith('/settings')) return 'settings';
return 'settings';
}; };
const activeTab = getActiveTab(location.pathname); const activeTab = getActiveTab(location.pathname);
useEffect(() => { const updateTabPosition = () => {
if (tabsRef.current[activeTab]) { if (tabsRef.current[activeTab]) {
const tab = tabsRef.current[activeTab]; const tab = tabsRef.current[activeTab];
setTabOffset(tab.offsetLeft); setTabOffset(tab.offsetLeft);
setTabWidth(tab.offsetWidth); setTabWidth(tab.offsetWidth);
if (!isInitialized) {
setIsInitialized(true);
} }
}
};
// Use useLayoutEffect for initial position
useLayoutEffect(() => {
updateTabPosition();
}, [activeTab]);
// Use ResizeObserver to handle window resizing
useEffect(() => {
const resizeObserver = new ResizeObserver(updateTabPosition);
if (tabsRef.current[activeTab]) {
resizeObserver.observe(tabsRef.current[activeTab]);
}
return () => resizeObserver.disconnect();
}, [activeTab]); }, [activeTab]);
return ( return (
<nav className="bg-gray-800 shadow-md"> <nav className='bg-gray-800 shadow-md'>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 relative"> <div className='max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 relative'>
<div className="flex items-center justify-between h-16"> <div className='flex items-center justify-between h-16'>
<div className="flex items-center space-x-8"> <div className='flex items-center space-x-8'>
<h1 className="text-2xl font-bold text-white">Profilarr</h1> <h1 className='text-2xl font-bold text-white'>
<div className="relative flex space-x-2"> Profilarr
</h1>
<div className='relative flex space-x-2'>
{isInitialized && (
<div <div
className="absolute top-0 bottom-0 bg-gray-900 rounded-md transition-all duration-300" className='absolute top-0 bottom-0 bg-gray-900 rounded-md transition-all duration-300'
style={{ left: tabOffset, width: tabWidth }} style={{
></div> left: `${tabOffset}px`,
width: `${tabWidth}px`
}}></div>
)}
<Link <Link
to="/regex" to='/regex'
ref={(el) => (tabsRef.current["regex"] = el)} ref={el => (tabsRef.current['regex'] = el)}
className={`px-3 py-2 rounded-md text-sm font-medium relative z-10 ${ className={`px-3 py-2 rounded-md text-sm font-medium relative z-10 ${
activeTab === "regex" activeTab === 'regex'
? "text-white" ? 'text-white'
: "text-gray-300 hover:bg-gray-700 hover:text-white" : 'text-gray-300 hover:bg-gray-700 hover:text-white'
}`} }`}>
>
Regex Patterns Regex Patterns
</Link> </Link>
<Link <Link
to="/format" to='/format'
ref={(el) => (tabsRef.current["format"] = el)} ref={el => (tabsRef.current['format'] = el)}
className={`px-3 py-2 rounded-md text-sm font-medium relative z-10 ${ className={`px-3 py-2 rounded-md text-sm font-medium relative z-10 ${
activeTab === "format" activeTab === 'format'
? "text-white" ? 'text-white'
: "text-gray-300 hover:bg-gray-700 hover:text-white" : 'text-gray-300 hover:bg-gray-700 hover:text-white'
}`} }`}>
>
Custom Formats Custom Formats
</Link> </Link>
<Link <Link
to="/profile" to='/profile'
ref={(el) => (tabsRef.current["profile"] = el)} ref={el => (tabsRef.current['profile'] = el)}
className={`px-3 py-2 rounded-md text-sm font-medium relative z-10 ${ className={`px-3 py-2 rounded-md text-sm font-medium relative z-10 ${
activeTab === "profile" activeTab === 'profile'
? "text-white" ? 'text-white'
: "text-gray-300 hover:bg-gray-700 hover:text-white" : 'text-gray-300 hover:bg-gray-700 hover:text-white'
}`} }`}>
>
Quality Profiles Quality Profiles
</Link> </Link>
<Link <Link
to="/settings" to='/settings'
ref={(el) => (tabsRef.current["settings"] = el)} ref={el => (tabsRef.current['settings'] = el)}
className={`px-3 py-2 rounded-md text-sm font-medium relative z-10 ${ className={`px-3 py-2 rounded-md text-sm font-medium relative z-10 ${
activeTab === "settings" activeTab === 'settings'
? "text-white" ? 'text-white'
: "text-gray-300 hover:bg-gray-700 hover:text-white" : 'text-gray-300 hover:bg-gray-700 hover:text-white'
}`} }`}>
>
Settings Settings
</Link> </Link>
</div> </div>
@@ -128,7 +147,7 @@ function Navbar({ darkMode, setDarkMode }) {
Navbar.propTypes = { Navbar.propTypes = {
darkMode: PropTypes.bool.isRequired, darkMode: PropTypes.bool.isRequired,
setDarkMode: PropTypes.func.isRequired, setDarkMode: PropTypes.func.isRequired
}; };
export default Navbar; export default Navbar;

View File

@@ -1,14 +1,14 @@
import React from 'react'; import React from 'react';
const Tooltip = ({ content, children }) => { const Tooltip = ({content, children}) => {
return ( return (
<div className="relative flex items-center group"> <div className='relative group'>
{children} {children}
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-3 opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none"> <div className='absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none z-[9999]'>
<div className="bg-gray-900 text-white text-xs rounded py-1 px-2 shadow-lg whitespace-nowrap z-50"> <div className='bg-gray-900 text-white text-xs rounded py-1 px-2 shadow-lg whitespace-nowrap'>
{content} {content}
</div> </div>
<div className="absolute w-2.5 h-2.5 bg-gray-900 transform rotate-45 -bottom-1 left-1/2 -translate-x-1/2"></div> <div className='absolute w-2.5 h-2.5 bg-gray-900 transform rotate-45 -bottom-1 left-1/2 -translate-x-1/2'></div>
</div> </div>
</div> </div>
); );

View File

@@ -1,17 +1,70 @@
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Fira+Code:wght@400;500&display=swap"); @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Fira+Code:wght@400;500&display=swap');
@import url("https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;700&display=swap"); @import url('https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;700&display=swap');
@import url("https://fonts.googleapis.com/css2?family=Schibsted+Grotesk:wght@400;700&display=swap"); @import url('https://fonts.googleapis.com/css2?family=Schibsted+Grotesk:wght@400;700&display=swap');
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
html { html {
font-family: "Schibsted Grotesk", sans-serif; font-family: 'Schibsted Grotesk', sans-serif;
} }
code, code,
pre, pre,
.font-mono { .font-mono {
font-family: "Fira Code", monospace; font-family: 'Fira Code', monospace;
}
/* Custom Scrollbar for Light Mode */
.scrollable::-webkit-scrollbar {
width: 8px;
}
.scrollable::-webkit-scrollbar-track {
background: #f1f1f1;
}
.scrollable::-webkit-scrollbar-thumb {
background-color: #c1c1c1;
border-radius: 4px;
border: 2px solid #f1f1f1;
transition: background-color 0.3s ease;
}
.scrollable::-webkit-scrollbar-thumb:hover {
background-color: #a1a1a1;
}
/* Custom Scrollbar for Dark Mode */
.dark .scrollable::-webkit-scrollbar-track {
background: #1e293b; /* dark-100 */
}
.dark .scrollable::-webkit-scrollbar-thumb {
background-color: #334155; /* dark-200 */
border: 2px solid #1e293b; /* dark-100 */
transition: background-color 0.3s ease;
}
.dark .scrollable::-webkit-scrollbar-thumb:hover {
background-color: #475569; /* dark-300 */
}
/* For Firefox */
.scrollable {
scrollbar-width: thin;
scrollbar-color: #c1c1c1 #f1f1f1;
}
.scrollable:hover {
scrollbar-color: #a1a1a1 #f1f1f1;
}
.dark .scrollable {
scrollbar-color: #334155 #1e293b; /* dark-200 and dark-100 */
}
.dark .scrollable:hover {
scrollbar-color: #475569 #1e293b; /* dark-300 and dark-100 */
} }