mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2025-12-23 23:07:42 -05:00
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:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -8,6 +8,8 @@ __pycache__/
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.1
|
||||
.env.2
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
@@ -1,5 +1,3 @@
|
||||
# app/__init__.py
|
||||
|
||||
import os
|
||||
from flask import Flask, jsonify
|
||||
from flask_cors import CORS
|
||||
@@ -12,15 +10,17 @@ from .settings_utils import create_empty_settings_if_not_exists, load_settings
|
||||
REGEX_DIR = os.path.join('data', 'db', 'regex_patterns')
|
||||
FORMAT_DIR = os.path.join('data', 'db', 'custom_formats')
|
||||
PROFILE_DIR = os.path.join('data', 'db', 'profiles')
|
||||
DATA_DIR = '/app/data'
|
||||
|
||||
|
||||
def create_app():
|
||||
app = Flask(__name__)
|
||||
CORS(app, resources={r"/*": {"origins": "*"}})
|
||||
|
||||
|
||||
# Initialize directories and create empty settings file if it doesn't exist
|
||||
initialize_directories()
|
||||
create_empty_settings_if_not_exists()
|
||||
|
||||
|
||||
# Register Blueprints
|
||||
app.register_blueprint(regex_bp)
|
||||
app.register_blueprint(format_bp)
|
||||
@@ -35,7 +35,9 @@ def create_app():
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def initialize_directories():
|
||||
os.makedirs(REGEX_DIR, exist_ok=True)
|
||||
os.makedirs(FORMAT_DIR, exist_ok=True)
|
||||
os.makedirs(PROFILE_DIR, exist_ok=True)
|
||||
os.makedirs(DATA_DIR, exist_ok=True)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
from .status.status import get_git_status
|
||||
from .status.diff import get_diff
|
||||
from .branches.manager import Branch_Manager
|
||||
from .operations.manager import GitOperations
|
||||
from .repo.unlink import unlink_repository
|
||||
@@ -74,6 +73,8 @@ def create_branch():
|
||||
return jsonify({'success': True, **result}), 200
|
||||
else:
|
||||
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
|
||||
|
||||
|
||||
@@ -99,6 +100,8 @@ def checkout_branch():
|
||||
return jsonify({'success': True, **result}), 200
|
||||
else:
|
||||
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
|
||||
|
||||
|
||||
@@ -111,6 +114,8 @@ def delete_branch(branch_name):
|
||||
return jsonify({'success': True, **result}), 200
|
||||
else:
|
||||
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
|
||||
|
||||
|
||||
@@ -129,23 +134,43 @@ def push_branch():
|
||||
if success:
|
||||
return jsonify({"success": True, "data": result}), 200
|
||||
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'])
|
||||
def push_files():
|
||||
files = request.json.get('files', [])
|
||||
user_commit_message = request.json.get('commit_message',
|
||||
"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)
|
||||
logger.debug("Received request to push changes")
|
||||
success, message = git_operations.push()
|
||||
|
||||
if success:
|
||||
logger.debug("Successfully committed and pushed files")
|
||||
logger.debug("Successfully pushed changes")
|
||||
return jsonify({'success': True, 'message': message}), 200
|
||||
else:
|
||||
logger.error(f"Error pushing files: {message}")
|
||||
return jsonify({'success': False, 'error': message}), 400
|
||||
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
|
||||
# Otherwise it's a string error
|
||||
return jsonify({'success': False, 'error': str(message)}), 400
|
||||
|
||||
|
||||
@bp.route('/revert', methods=['POST'])
|
||||
@@ -193,28 +218,49 @@ def delete_file():
|
||||
@bp.route('/pull', methods=['POST'])
|
||||
def pull_branch():
|
||||
branch_name = request.json.get('branch')
|
||||
success, message = git_operations.pull(branch_name)
|
||||
success, response = git_operations.pull(branch_name)
|
||||
|
||||
# Handle different response types
|
||||
if isinstance(response, dict):
|
||||
if response.get('state') == 'resolve':
|
||||
# Merge conflict is now a success case with state='resolve'
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'state': 'resolve',
|
||||
'message': response['message'],
|
||||
'details': response['details']
|
||||
}), 200
|
||||
elif response.get('state') == 'error':
|
||||
# Handle error states
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'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, 'message': message}), 200
|
||||
else:
|
||||
logger.error(f"Error pulling branch: {message}")
|
||||
return jsonify({'success': False, 'error': message}), 400
|
||||
|
||||
|
||||
@bp.route('/diff', methods=['POST'])
|
||||
def diff_file():
|
||||
file_path = request.json.get('file_path')
|
||||
try:
|
||||
diff = get_diff(REPO_PATH, file_path)
|
||||
logger.debug(f"Diff for file {file_path}: {diff}")
|
||||
return jsonify({'success': True, 'diff': diff if diff else ""}), 200
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting diff for file {file_path}: {str(e)}",
|
||||
exc_info=True)
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f"Error getting diff for file: {str(e)}"
|
||||
}), 400
|
||||
'success': True,
|
||||
'state': 'complete',
|
||||
'message': response
|
||||
}), 200
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'state': 'error',
|
||||
'message': str(response)
|
||||
}), 400
|
||||
|
||||
|
||||
@bp.route('/stage', methods=['POST'])
|
||||
@@ -227,6 +273,16 @@ def handle_stage_files():
|
||||
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'])
|
||||
def unlink():
|
||||
data = request.get_json()
|
||||
@@ -239,20 +295,67 @@ def unlink():
|
||||
|
||||
|
||||
def generate_commit_message(user_message, files):
|
||||
file_changes = []
|
||||
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
|
||||
return user_message
|
||||
|
||||
|
||||
@bp.route('/dev', methods=['GET'])
|
||||
def dev_mode():
|
||||
is_dev_mode = check_dev_mode()
|
||||
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
|
||||
|
||||
@@ -1,23 +1,45 @@
|
||||
# git/branches/branches.py
|
||||
|
||||
import git
|
||||
import os
|
||||
from .create import create_branch
|
||||
from .checkout import checkout_branch
|
||||
from .delete import delete_branch
|
||||
from .get import get_branches, get_current_branch
|
||||
from .push import push_branch_to_remote
|
||||
|
||||
|
||||
class Branch_Manager:
|
||||
|
||||
def __init__(self, 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'):
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
def get_all(self):
|
||||
@@ -25,6 +47,10 @@ class Branch_Manager:
|
||||
|
||||
def get_current(self):
|
||||
return get_current_branch(self.repo_path)
|
||||
|
||||
|
||||
def push(self, branch_name):
|
||||
return push_branch_to_remote(self.repo_path, 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)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# git/operations/commit.py
|
||||
|
||||
import git
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def commit_changes(repo_path, files, message):
|
||||
try:
|
||||
repo = git.Repo(repo_path)
|
||||
@@ -13,4 +13,4 @@ def commit_changes(repo_path, files, message):
|
||||
return True, "Successfully committed changes."
|
||||
except Exception as e:
|
||||
logger.error(f"Error committing changes: {str(e)}", exc_info=True)
|
||||
return False, f"Error committing changes: {str(e)}"
|
||||
return False, f"Error committing changes: {str(e)}"
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
# git/operations/operations.py
|
||||
|
||||
import git
|
||||
from .stage import stage_files
|
||||
from .commit import commit_changes
|
||||
@@ -7,19 +5,50 @@ from .push import push_changes
|
||||
from .revert import revert_file, revert_all
|
||||
from .delete import delete_file
|
||||
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:
|
||||
|
||||
def __init__(self, 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):
|
||||
return stage_files(self.repo_path, files)
|
||||
|
||||
def unstage(self, files):
|
||||
return unstage_files(self.repo_path, files)
|
||||
|
||||
def commit(self, files, message):
|
||||
return commit_changes(self.repo_path, files, message)
|
||||
|
||||
def push(self, files, message):
|
||||
return push_changes(self.repo_path, files, message)
|
||||
def push(self):
|
||||
return push_changes(self.repo_path)
|
||||
|
||||
def revert(self, file_path):
|
||||
return revert_file(self.repo_path, file_path)
|
||||
@@ -31,4 +60,15 @@ class GitOperations:
|
||||
return delete_file(self.repo_path, file_path)
|
||||
|
||||
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)
|
||||
|
||||
96
backend/app/git/operations/merge.py
Normal file
96
backend/app/git/operations/merge.py
Normal 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)
|
||||
@@ -1,15 +1,49 @@
|
||||
# git/operations/pull.py
|
||||
|
||||
import git
|
||||
import logging
|
||||
from git import GitCommandError
|
||||
from ..status.status import get_git_status
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def pull_branch(repo_path, branch_name):
|
||||
try:
|
||||
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:
|
||||
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)}"
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
# git/operations/push.py
|
||||
|
||||
import git
|
||||
import logging
|
||||
from .commit import commit_changes
|
||||
from ..auth.authenticate import check_dev_mode, get_github_token
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def push_changes(repo_path, files, message):
|
||||
def push_changes(repo_path):
|
||||
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():
|
||||
logger.warning("Not in dev mode. Push operation not allowed.")
|
||||
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"
|
||||
|
||||
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')
|
||||
auth_repo_url = origin.url.replace('https://',
|
||||
f'https://{github_token}@')
|
||||
origin.set_url(auth_repo_url)
|
||||
|
||||
# Push changes
|
||||
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:
|
||||
raise git.GitCommandError("git push", push_info[0].summary)
|
||||
|
||||
return True, "Successfully pushed changes."
|
||||
try:
|
||||
# Push changes
|
||||
push_info = origin.push()
|
||||
if push_info and push_info[0].flags & push_info[0].ERROR:
|
||||
raise git.GitCommandError("git push", push_info[0].summary)
|
||||
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:
|
||||
logger.error(f"Git command error pushing changes: {str(e)}",
|
||||
exc_info=True)
|
||||
return False, f"Error pushing changes: {str(e)}"
|
||||
|
||||
return False, str(e)
|
||||
except Exception as e:
|
||||
logger.error(f"Error pushing changes: {str(e)}", exc_info=True)
|
||||
return False, f"Error pushing changes: {str(e)}"
|
||||
|
||||
223
backend/app/git/operations/resolve.py
Normal file
223
backend/app/git/operations/resolve.py
Normal 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)}
|
||||
@@ -1,36 +1,20 @@
|
||||
# git/operations/stage.py
|
||||
|
||||
import git
|
||||
import logging
|
||||
from ..auth.authenticate import check_dev_mode, get_github_token
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def stage_files(repo_path, files):
|
||||
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)
|
||||
|
||||
# Authenticate with GitHub token
|
||||
with repo.git.custom_environment(GIT_ASKPASS='echo',
|
||||
GIT_USERNAME=github_token):
|
||||
if not files:
|
||||
repo.git.add(A=True)
|
||||
message = "All changes have been staged."
|
||||
else:
|
||||
repo.index.add(files)
|
||||
message = "Specified files have been staged."
|
||||
if not files:
|
||||
repo.git.add(A=True)
|
||||
message = "All changes have been staged."
|
||||
else:
|
||||
repo.index.add(files)
|
||||
message = "Specified files have been staged."
|
||||
|
||||
return True, message
|
||||
|
||||
@@ -38,7 +22,6 @@ def stage_files(repo_path, files):
|
||||
logger.error(f"Git command error staging files: {str(e)}",
|
||||
exc_info=True)
|
||||
return False, f"Error staging files: {str(e)}"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error staging files: {str(e)}", exc_info=True)
|
||||
return False, f"Error staging files: {str(e)}"
|
||||
|
||||
52
backend/app/git/operations/types.py
Normal file
52
backend/app/git/operations/types.py
Normal 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]
|
||||
15
backend/app/git/operations/unstage.py
Normal file
15
backend/app/git/operations/unstage.py
Normal 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)}"
|
||||
@@ -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
|
||||
@@ -1,51 +1,256 @@
|
||||
# git/status/incoming_changes.py
|
||||
|
||||
import os
|
||||
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__)
|
||||
|
||||
|
||||
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):
|
||||
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:
|
||||
if file_path:
|
||||
full_path = os.path.join(repo.working_dir, file_path)
|
||||
file_data = extract_data_from_yaml(full_path) if os.path.exists(
|
||||
full_path) else None
|
||||
if not file_path:
|
||||
continue
|
||||
|
||||
# Correcting the git show command
|
||||
raw_commit_message = repo.git.show(f'HEAD...origin/{branch}',
|
||||
'--format=%B', '-s',
|
||||
file_path).strip()
|
||||
parsed_commit_message = parse_commit_message(
|
||||
raw_commit_message
|
||||
) # Parse commit message using the util function
|
||||
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}',
|
||||
'--format=%B', '-s', '--',
|
||||
file_path).strip()
|
||||
commit_message = parse_commit_message(raw_commit_message)
|
||||
except GitCommandError:
|
||||
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({
|
||||
'name':
|
||||
file_data.get('name', os.path.basename(file_path))
|
||||
if file_data else os.path.basename(file_path),
|
||||
'id':
|
||||
file_data.get('id') if file_data else None,
|
||||
'type':
|
||||
determine_type(file_path),
|
||||
'status':
|
||||
'Incoming',
|
||||
'file_path':
|
||||
file_path,
|
||||
'commit_message':
|
||||
parsed_commit_message, # Use parsed commit message
|
||||
'staged':
|
||||
False,
|
||||
'modified':
|
||||
True,
|
||||
'deleted':
|
||||
False
|
||||
'commit_message': commit_message,
|
||||
'deleted': False,
|
||||
'file_path': file_path,
|
||||
'id': file_id,
|
||||
'modified': True,
|
||||
'local_name': local_name,
|
||||
'incoming_name': incoming_name,
|
||||
'staged': False,
|
||||
'status': status,
|
||||
'type': file_type,
|
||||
'changes': changes,
|
||||
'will_conflict':
|
||||
will_conflict # Added conflict status per file
|
||||
})
|
||||
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
|
||||
115
backend/app/git/status/merge_conflicts.py
Normal file
115
backend/app/git/status/merge_conflicts.py
Normal 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
|
||||
@@ -1,13 +1,14 @@
|
||||
# git/status/outgoing_changes.py
|
||||
|
||||
import os
|
||||
import json
|
||||
import yaml
|
||||
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__)
|
||||
|
||||
|
||||
def get_outgoing_changes(repo):
|
||||
status = repo.git.status('--porcelain', '-z').split('\0')
|
||||
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:]
|
||||
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_deleted = x == 'D' or y == 'D'
|
||||
|
||||
full_path = os.path.join(repo.working_dir, file_path)
|
||||
|
||||
if is_deleted:
|
||||
try:
|
||||
# Get the content of the file from the last commit
|
||||
file_content = repo.git.show(f'HEAD:{file_path}')
|
||||
yaml_content = yaml.safe_load(file_content)
|
||||
original_name = yaml_content.get('name', 'Unknown')
|
||||
original_id = yaml_content.get('id', '')
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not retrieve original name for deleted file {file_path}: {str(e)}")
|
||||
original_name = "Unknown"
|
||||
original_id = ""
|
||||
|
||||
changes.append({
|
||||
'name': original_name,
|
||||
'id': original_id,
|
||||
'type': determine_type(file_path),
|
||||
'status': 'Deleted',
|
||||
'file_path': file_path,
|
||||
'staged': is_staged,
|
||||
'modified': False,
|
||||
'deleted': True
|
||||
})
|
||||
elif os.path.isdir(full_path):
|
||||
logger.debug(f"Found directory: {file_path}, going through folder.")
|
||||
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.")
|
||||
file_data = extract_data_from_yaml(file_full_path)
|
||||
if file_data:
|
||||
logger.debug(f"File contents: {file_data}")
|
||||
logger.debug(f"Found ID: {file_data.get('id')}")
|
||||
logger.debug(f"Found Name: {file_data.get('name')}")
|
||||
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': 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}")
|
||||
changes.append(process_deleted_file(repo, file_path, is_staged))
|
||||
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,
|
||||
'staged': is_staged,
|
||||
'modified': y != ' ',
|
||||
'deleted': False
|
||||
})
|
||||
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:
|
||||
file_content = repo.git.show(f'HEAD:{file_path}')
|
||||
yaml_content = yaml.safe_load(file_content)
|
||||
original_name = yaml_content.get('name', 'Unknown')
|
||||
original_id = yaml_content.get('id', '')
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Could not retrieve original content for deleted file {file_path}: {str(e)}"
|
||||
)
|
||||
original_name = "Unknown"
|
||||
original_id = ""
|
||||
|
||||
return {
|
||||
'name': original_name,
|
||||
'prior_name': original_name,
|
||||
'outgoing_name': None,
|
||||
'id': original_id,
|
||||
'type': determine_type(file_path),
|
||||
'status': 'Deleted',
|
||||
'file_path': file_path,
|
||||
'staged': is_staged,
|
||||
'modified': False,
|
||||
'deleted': True,
|
||||
'changes': [{
|
||||
'key': 'file',
|
||||
'change': 'deleted'
|
||||
}]
|
||||
}
|
||||
|
||||
|
||||
def process_modified_file(repo, file_path, x, y, is_staged):
|
||||
try:
|
||||
# Get the content of the file from the last commit
|
||||
old_content = repo.git.show(f'HEAD:{file_path}')
|
||||
old_data = yaml.safe_load(old_content)
|
||||
except GitCommandError:
|
||||
old_data = None
|
||||
|
||||
# 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),
|
||||
'status': 'Modified' if old_data else 'New',
|
||||
'file_path': file_path,
|
||||
'staged': is_staged,
|
||||
'modified': y != ' ',
|
||||
'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:
|
||||
changes.append({
|
||||
'name': os.path.basename(file_path).replace('.yml', ''),
|
||||
'id': '',
|
||||
'type': determine_type(file_path),
|
||||
'status': interpret_git_status(x, y),
|
||||
'file_path': file_path,
|
||||
'staged': is_staged,
|
||||
'modified': y != ' ',
|
||||
'deleted': False
|
||||
'key': key,
|
||||
'change': 'modified',
|
||||
'from': old_value,
|
||||
'to': new_value
|
||||
})
|
||||
|
||||
logger.debug(f"Final changes: {json.dumps(changes, indent=2)}")
|
||||
return changes
|
||||
return changes
|
||||
|
||||
|
||||
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]
|
||||
})
|
||||
|
||||
return changes
|
||||
|
||||
@@ -1,44 +1,95 @@
|
||||
# git/status/status.py
|
||||
|
||||
import git
|
||||
from git.exc import GitCommandError, InvalidGitRepositoryError
|
||||
import logging
|
||||
import json
|
||||
from .incoming_changes import get_incoming_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__)
|
||||
|
||||
|
||||
def get_commits_ahead(repo, branch):
|
||||
return list(repo.iter_commits(f'origin/{branch}..{branch}'))
|
||||
|
||||
|
||||
def get_commits_behind(repo, 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):
|
||||
try:
|
||||
logger.debug(f"Attempting to get status for repo at {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)
|
||||
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:
|
||||
repo.remotes.origin.fetch()
|
||||
commits_behind = get_commits_behind(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)
|
||||
unpushed_files = get_unpushed_changes(
|
||||
repo, branch) if commits_ahead else []
|
||||
else:
|
||||
commits_behind = []
|
||||
commits_ahead = []
|
||||
incoming_changes = []
|
||||
logger.debug("Remote branch does not exist, skipping commits ahead/behind and incoming changes calculation.")
|
||||
unpushed_files = []
|
||||
|
||||
status = {
|
||||
"branch": branch,
|
||||
@@ -47,15 +98,13 @@ def get_git_status(repo_path):
|
||||
"commits_behind": len(commits_behind),
|
||||
"commits_ahead": len(commits_ahead),
|
||||
"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
|
||||
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:
|
||||
logger.error(f"Unexpected error in get_git_status: {str(e)}", exc_info=True)
|
||||
return False, f"Unexpected error: {str(e)}"
|
||||
logger.error(f"Error in get_git_status: {str(e)}", exc_info=True)
|
||||
return False, str(e)
|
||||
|
||||
@@ -8,7 +8,7 @@ services:
|
||||
- ./frontend:/app
|
||||
- /app/node_modules
|
||||
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
|
||||
|
||||
backend:
|
||||
@@ -21,6 +21,6 @@ services:
|
||||
environment:
|
||||
- FLASK_ENV=development
|
||||
env_file:
|
||||
- .env
|
||||
- .env.1
|
||||
volumes:
|
||||
backend_data:
|
||||
|
||||
41
docs/diagrams/conflict-resolution.mmd
Normal file
41
docs/diagrams/conflict-resolution.mmd
Normal 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
|
||||
24
docs/diagrams/sync-flow.md
Normal file
24
docs/diagrams/sync-flow.md
Normal 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
|
||||
```
|
||||
@@ -1,16 +1,14 @@
|
||||
<!doctype html>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/regex.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Profilarr</title>
|
||||
</head>
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/regex.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Regexerr</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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 () => {
|
||||
try {
|
||||
@@ -133,7 +133,15 @@ export const getSettings = async () => {
|
||||
export const getGitStatus = async () => {
|
||||
try {
|
||||
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) {
|
||||
console.error('Error fetching Git status:', error);
|
||||
throw error;
|
||||
@@ -152,9 +160,21 @@ export const getBranches = async () => {
|
||||
|
||||
export const checkoutBranch = async branchName => {
|
||||
try {
|
||||
const response = await axios.post(`${API_BASE_URL}/git/checkout`, {
|
||||
branch: branchName
|
||||
});
|
||||
const response = await axios.post(
|
||||
`${API_BASE_URL}/git/checkout`,
|
||||
{
|
||||
branch: branchName
|
||||
},
|
||||
{
|
||||
validateStatus: status => {
|
||||
return (
|
||||
(status >= 200 && status < 300) ||
|
||||
status === 400 ||
|
||||
status === 409
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error checking out branch:', error);
|
||||
@@ -164,10 +184,22 @@ export const checkoutBranch = async branchName => {
|
||||
|
||||
export const createBranch = async (branchName, baseBranch) => {
|
||||
try {
|
||||
const response = await axios.post(`${API_BASE_URL}/git/branch`, {
|
||||
name: branchName,
|
||||
base: baseBranch
|
||||
});
|
||||
const response = await axios.post(
|
||||
`${API_BASE_URL}/git/branch`,
|
||||
{
|
||||
name: branchName,
|
||||
base: baseBranch
|
||||
},
|
||||
{
|
||||
validateStatus: status => {
|
||||
return (
|
||||
(status >= 200 && status < 300) ||
|
||||
status === 400 ||
|
||||
status === 409
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error creating branch:', error);
|
||||
@@ -178,7 +210,16 @@ export const createBranch = async (branchName, baseBranch) => {
|
||||
export const deleteBranch = async branchName => {
|
||||
try {
|
||||
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;
|
||||
} 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 => {
|
||||
try {
|
||||
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 {
|
||||
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,
|
||||
commit_message: commitMessage
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error pushing files:', error);
|
||||
console.error('Error committing files:', 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 => {
|
||||
try {
|
||||
const response = await axios.post(`${API_BASE_URL}/git/revert`, {
|
||||
@@ -251,20 +346,19 @@ export const pullBranch = async branchName => {
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error pulling branch:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getDiff = async filePath => {
|
||||
try {
|
||||
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;
|
||||
if (error.response?.data) {
|
||||
return {
|
||||
success: false,
|
||||
state: error.response.data.state || 'error',
|
||||
message: error.response.data.message,
|
||||
details: error.response.data.details
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
state: 'error',
|
||||
message: 'Failed to pull changes'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 () => {
|
||||
try {
|
||||
const response = await axios.get(`${API_BASE_URL}/git/dev`);
|
||||
@@ -356,3 +438,44 @@ export const checkDevMode = async () => {
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,150 +1,203 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import FormatCard from "./FormatCard";
|
||||
import FormatModal from "./FormatModal";
|
||||
import AddNewCard from "../ui/AddNewCard";
|
||||
import { getFormats } from "../../api/api";
|
||||
import FilterMenu from "../ui/FilterMenu";
|
||||
import SortMenu from "../ui/SortMenu";
|
||||
import { Loader } from "lucide-react";
|
||||
import React, {useState, useEffect} from 'react';
|
||||
import {useNavigate} from 'react-router-dom';
|
||||
import FormatCard from './FormatCard';
|
||||
import FormatModal from './FormatModal';
|
||||
import AddNewCard from '../ui/AddNewCard';
|
||||
import {getFormats, getGitStatus} from '../../api/api';
|
||||
import FilterMenu from '../ui/FilterMenu';
|
||||
import SortMenu from '../ui/SortMenu';
|
||||
import {Loader} from 'lucide-react';
|
||||
|
||||
function FormatPage() {
|
||||
const [formats, setFormats] = useState([]);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [selectedFormat, setSelectedFormat] = useState(null);
|
||||
const [sortBy, setSortBy] = useState("title");
|
||||
const [filterType, setFilterType] = useState("none");
|
||||
const [filterValue, setFilterValue] = useState("");
|
||||
const [allTags, setAllTags] = useState([]);
|
||||
const [isCloning, setIsCloning] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [formats, setFormats] = useState([]);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [selectedFormat, setSelectedFormat] = useState(null);
|
||||
const [sortBy, setSortBy] = useState('title');
|
||||
const [filterType, setFilterType] = useState('none');
|
||||
const [filterValue, setFilterValue] = useState('');
|
||||
const [allTags, setAllTags] = useState([]);
|
||||
const [isCloning, setIsCloning] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [mergeConflicts, setMergeConflicts] = useState([]);
|
||||
|
||||
const loadingMessages = [
|
||||
"Decoding the custom format matrix...",
|
||||
"Parsing the digital alphabet soup...",
|
||||
"Untangling the format spaghetti...",
|
||||
"Calibrating the format-o-meter...",
|
||||
"Indexing your media DNA...",
|
||||
];
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
fetchFormats();
|
||||
}, []);
|
||||
const loadingMessages = [
|
||||
'Decoding the custom format matrix...',
|
||||
'Parsing the digital alphabet soup...',
|
||||
'Untangling the format spaghetti...',
|
||||
'Calibrating the format-o-meter...',
|
||||
'Indexing your media DNA...'
|
||||
];
|
||||
|
||||
const fetchFormats = async () => {
|
||||
try {
|
||||
const fetchedFormats = await getFormats();
|
||||
setFormats(fetchedFormats);
|
||||
const tags = [
|
||||
...new Set(fetchedFormats.flatMap((format) => format.tags || [])),
|
||||
];
|
||||
setAllTags(tags);
|
||||
} catch (error) {
|
||||
console.error("Error fetching formats:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
fetchGitStatus();
|
||||
}, []);
|
||||
|
||||
const handleOpenModal = (format = null) => {
|
||||
setSelectedFormat(format);
|
||||
setIsModalOpen(true);
|
||||
setIsCloning(false);
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setSelectedFormat(null);
|
||||
setIsModalOpen(false);
|
||||
setIsCloning(false);
|
||||
};
|
||||
|
||||
const handleCloneFormat = (format) => {
|
||||
const clonedFormat = {
|
||||
...format,
|
||||
id: 0,
|
||||
name: `${format.name} [COPY]`,
|
||||
const fetchFormats = async () => {
|
||||
try {
|
||||
const fetchedFormats = await getFormats();
|
||||
setFormats(fetchedFormats);
|
||||
const tags = [
|
||||
...new Set(fetchedFormats.flatMap(format => format.tags || []))
|
||||
];
|
||||
setAllTags(tags);
|
||||
} catch (error) {
|
||||
console.error('Error fetching formats:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
setSelectedFormat(clonedFormat);
|
||||
setIsModalOpen(true);
|
||||
setIsCloning(true);
|
||||
};
|
||||
|
||||
const handleSaveFormat = () => {
|
||||
fetchFormats();
|
||||
handleCloseModal();
|
||||
};
|
||||
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 formatDate = (dateString) => {
|
||||
return new Date(dateString).toLocaleString();
|
||||
};
|
||||
const handleOpenModal = (format = null) => {
|
||||
setSelectedFormat(format);
|
||||
setIsModalOpen(true);
|
||||
setIsCloning(false);
|
||||
};
|
||||
|
||||
const sortedAndFilteredFormats = formats
|
||||
.filter((format) => {
|
||||
if (filterType === "tag") {
|
||||
return format.tags && format.tags.includes(filterValue);
|
||||
}
|
||||
if (filterType === "date") {
|
||||
const formatDate = new Date(format.date_modified);
|
||||
const filterDate = new Date(filterValue);
|
||||
return formatDate.toDateString() === filterDate.toDateString();
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (sortBy === "title") return a.name.localeCompare(b.name);
|
||||
if (sortBy === "dateCreated")
|
||||
return new Date(b.date_created) - new Date(a.date_created);
|
||||
if (sortBy === "dateModified")
|
||||
return new Date(b.date_modified) - new Date(a.date_modified);
|
||||
return 0;
|
||||
});
|
||||
const handleCloseModal = () => {
|
||||
setSelectedFormat(null);
|
||||
setIsModalOpen(false);
|
||||
setIsCloning(false);
|
||||
};
|
||||
|
||||
const handleCloneFormat = format => {
|
||||
const clonedFormat = {
|
||||
...format,
|
||||
id: 0,
|
||||
name: `${format.name} [COPY]`
|
||||
};
|
||||
setSelectedFormat(clonedFormat);
|
||||
setIsModalOpen(true);
|
||||
setIsCloning(true);
|
||||
};
|
||||
|
||||
const handleSaveFormat = () => {
|
||||
fetchFormats();
|
||||
handleCloseModal();
|
||||
};
|
||||
|
||||
const formatDate = dateString => {
|
||||
return new Date(dateString).toLocaleString();
|
||||
};
|
||||
|
||||
const sortedAndFilteredFormats = formats
|
||||
.filter(format => {
|
||||
if (filterType === 'tag') {
|
||||
return format.tags && format.tags.includes(filterValue);
|
||||
}
|
||||
if (filterType === 'date') {
|
||||
const formatDate = new Date(format.date_modified);
|
||||
const filterDate = new Date(filterValue);
|
||||
return formatDate.toDateString() === filterDate.toDateString();
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (sortBy === 'title') return a.name.localeCompare(b.name);
|
||||
if (sortBy === 'dateCreated')
|
||||
return new Date(b.date_created) - new Date(a.date_created);
|
||||
if (sortBy === 'dateModified')
|
||||
return new Date(b.date_modified) - new Date(a.date_modified);
|
||||
return 0;
|
||||
});
|
||||
|
||||
const hasConflicts = mergeConflicts.length > 0;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className='flex flex-col items-center justify-center h-screen'>
|
||||
<Loader size={48} className='animate-spin text-blue-500 mb-4' />
|
||||
<p className='text-lg font-medium text-gray-700 dark:text-gray-300'>
|
||||
{
|
||||
loadingMessages[
|
||||
Math.floor(Math.random() * loadingMessages.length)
|
||||
]
|
||||
}
|
||||
</p>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-screen">
|
||||
<Loader size={48} className="animate-spin text-blue-500 mb-4" />
|
||||
<p className="text-lg font-medium text-gray-700 dark:text-gray-300">
|
||||
{loadingMessages[Math.floor(Math.random() * loadingMessages.length)]}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className='text-2xl font-bold mb-4'>Manage Custom Formats</h2>
|
||||
<div className='mb-4 flex items-center space-x-4'>
|
||||
<SortMenu sortBy={sortBy} setSortBy={setSortBy} />
|
||||
<FilterMenu
|
||||
filterType={filterType}
|
||||
setFilterType={setFilterType}
|
||||
filterValue={filterValue}
|
||||
setFilterValue={setFilterValue}
|
||||
allTags={allTags}
|
||||
/>
|
||||
</div>
|
||||
<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 => (
|
||||
<FormatCard
|
||||
key={format.id}
|
||||
format={format}
|
||||
onEdit={() => handleOpenModal(format)}
|
||||
onClone={handleCloneFormat}
|
||||
showDate={sortBy !== 'title'}
|
||||
formatDate={formatDate}
|
||||
/>
|
||||
))}
|
||||
<AddNewCard onAdd={() => handleOpenModal()} />
|
||||
</div>
|
||||
<FormatModal
|
||||
format={selectedFormat}
|
||||
isOpen={isModalOpen}
|
||||
onClose={handleCloseModal}
|
||||
onSave={handleSaveFormat}
|
||||
allTags={allTags}
|
||||
isCloning={isCloning}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold mb-4">Manage Custom Formats</h2>
|
||||
<div className="mb-4 flex items-center space-x-4">
|
||||
<SortMenu sortBy={sortBy} setSortBy={setSortBy} />
|
||||
<FilterMenu
|
||||
filterType={filterType}
|
||||
setFilterType={setFilterType}
|
||||
filterValue={filterValue}
|
||||
setFilterValue={setFilterValue}
|
||||
allTags={allTags}
|
||||
/>
|
||||
</div>
|
||||
<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) => (
|
||||
<FormatCard
|
||||
key={format.id}
|
||||
format={format}
|
||||
onEdit={() => handleOpenModal(format)}
|
||||
onClone={handleCloneFormat} // Pass the clone handler
|
||||
showDate={sortBy !== "title"}
|
||||
formatDate={formatDate}
|
||||
/>
|
||||
))}
|
||||
<AddNewCard onAdd={() => handleOpenModal()} />
|
||||
</div>
|
||||
<FormatModal
|
||||
format={selectedFormat}
|
||||
isOpen={isModalOpen}
|
||||
onClose={handleCloseModal}
|
||||
onSave={handleSaveFormat}
|
||||
allTags={allTags}
|
||||
isCloning={isCloning}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FormatPage;
|
||||
|
||||
@@ -265,12 +265,7 @@ function ProfileModal({
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={modalTitle}
|
||||
width='screen-2xl'
|
||||
height='6xl'>
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={modalTitle}>
|
||||
{loading ? (
|
||||
<div className='flex justify-center items-center'>
|
||||
<Loader size={24} className='animate-spin text-gray-300' />
|
||||
|
||||
@@ -1,169 +1,223 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import ProfileCard from "./ProfileCard";
|
||||
import ProfileModal from "./ProfileModal";
|
||||
import AddNewCard from "../ui/AddNewCard";
|
||||
import { getProfiles, getFormats } from "../../api/api";
|
||||
import FilterMenu from "../ui/FilterMenu";
|
||||
import SortMenu from "../ui/SortMenu";
|
||||
import { Loader } from "lucide-react";
|
||||
import React, {useState, useEffect} from 'react';
|
||||
import {useNavigate} from 'react-router-dom';
|
||||
import ProfileCard from './ProfileCard';
|
||||
import ProfileModal from './ProfileModal';
|
||||
import AddNewCard from '../ui/AddNewCard';
|
||||
import {getProfiles, getFormats, getGitStatus} from '../../api/api';
|
||||
import FilterMenu from '../ui/FilterMenu';
|
||||
import SortMenu from '../ui/SortMenu';
|
||||
import {Loader} from 'lucide-react';
|
||||
|
||||
function ProfilePage() {
|
||||
const [profiles, setProfiles] = useState([]);
|
||||
const [formats, setFormats] = useState([]);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [selectedProfile, setSelectedProfile] = useState(null);
|
||||
const [sortBy, setSortBy] = useState("title");
|
||||
const [filterType, setFilterType] = useState("none");
|
||||
const [filterValue, setFilterValue] = useState("");
|
||||
const [allTags, setAllTags] = useState([]);
|
||||
const [isCloning, setIsCloning] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [profiles, setProfiles] = useState([]);
|
||||
const [formats, setFormats] = useState([]);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [selectedProfile, setSelectedProfile] = useState(null);
|
||||
const [sortBy, setSortBy] = useState('title');
|
||||
const [filterType, setFilterType] = useState('none');
|
||||
const [filterValue, setFilterValue] = useState('');
|
||||
const [allTags, setAllTags] = useState([]);
|
||||
const [isCloning, setIsCloning] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [mergeConflicts, setMergeConflicts] = useState([]);
|
||||
|
||||
const loadingMessages = [
|
||||
"Profiling your media collection...",
|
||||
"Organizing your digital hoard...",
|
||||
"Calibrating the flux capacitor...",
|
||||
"Synchronizing with the movie matrix...",
|
||||
"Optimizing your binge-watching potential...",
|
||||
];
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
fetchProfiles();
|
||||
fetchFormats();
|
||||
}, []);
|
||||
const loadingMessages = [
|
||||
'Profiling your media collection...',
|
||||
'Organizing your digital hoard...',
|
||||
'Calibrating the flux capacitor...',
|
||||
'Synchronizing with the movie matrix...',
|
||||
'Optimizing your binge-watching potential...'
|
||||
];
|
||||
|
||||
const fetchProfiles = async () => {
|
||||
try {
|
||||
const fetchedProfiles = await getProfiles();
|
||||
setProfiles(fetchedProfiles);
|
||||
const tags = [
|
||||
...new Set(fetchedProfiles.flatMap((profile) => profile.tags || [])),
|
||||
];
|
||||
setAllTags(tags);
|
||||
} catch (error) {
|
||||
console.error("Error fetching profiles:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
fetchGitStatus();
|
||||
}, []);
|
||||
|
||||
const fetchFormats = async () => {
|
||||
try {
|
||||
const fetchedFormats = await getFormats();
|
||||
setFormats(fetchedFormats);
|
||||
} catch (error) {
|
||||
console.error("Error fetching formats:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenModal = (profile = null) => {
|
||||
const safeProfile = profile
|
||||
? {
|
||||
...profile,
|
||||
custom_formats: profile.custom_formats || [],
|
||||
const fetchProfiles = async () => {
|
||||
try {
|
||||
const fetchedProfiles = await getProfiles();
|
||||
setProfiles(fetchedProfiles);
|
||||
const tags = [
|
||||
...new Set(
|
||||
fetchedProfiles.flatMap(profile => profile.tags || [])
|
||||
)
|
||||
];
|
||||
setAllTags(tags);
|
||||
} catch (error) {
|
||||
console.error('Error fetching profiles:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
: null;
|
||||
setSelectedProfile(safeProfile);
|
||||
setIsModalOpen(true);
|
||||
setIsCloning(false);
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setSelectedProfile(null);
|
||||
setIsModalOpen(false);
|
||||
setIsCloning(false);
|
||||
};
|
||||
|
||||
const handleCloneProfile = (profile) => {
|
||||
const clonedProfile = {
|
||||
...profile,
|
||||
id: 0,
|
||||
name: `${profile.name} [COPY]`,
|
||||
custom_formats: profile.custom_formats || [],
|
||||
};
|
||||
setSelectedProfile(clonedProfile);
|
||||
setIsModalOpen(true);
|
||||
setIsCloning(true);
|
||||
};
|
||||
|
||||
const handleSaveProfile = () => {
|
||||
fetchProfiles();
|
||||
handleCloseModal();
|
||||
};
|
||||
const fetchFormats = async () => {
|
||||
try {
|
||||
const fetchedFormats = await getFormats();
|
||||
setFormats(fetchedFormats);
|
||||
} catch (error) {
|
||||
console.error('Error fetching formats:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Define the missing formatDate function
|
||||
const formatDate = (dateString) => {
|
||||
return new Date(dateString).toLocaleString();
|
||||
};
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const sortedAndFilteredProfiles = profiles
|
||||
.filter((profile) => {
|
||||
if (filterType === "tag") {
|
||||
return profile.tags && profile.tags.includes(filterValue);
|
||||
}
|
||||
if (filterType === "date") {
|
||||
const profileDate = new Date(profile.date_modified);
|
||||
const filterDate = new Date(filterValue);
|
||||
return profileDate.toDateString() === filterDate.toDateString();
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (sortBy === "name") return a.name.localeCompare(b.name);
|
||||
if (sortBy === "dateCreated")
|
||||
return new Date(b.date_created) - new Date(a.date_created);
|
||||
if (sortBy === "dateModified")
|
||||
return new Date(b.date_modified) - new Date(a.date_modified);
|
||||
return 0;
|
||||
});
|
||||
const handleOpenModal = (profile = null) => {
|
||||
const safeProfile = profile
|
||||
? {
|
||||
...profile,
|
||||
custom_formats: profile.custom_formats || []
|
||||
}
|
||||
: null;
|
||||
setSelectedProfile(safeProfile);
|
||||
setIsModalOpen(true);
|
||||
setIsCloning(false);
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setSelectedProfile(null);
|
||||
setIsModalOpen(false);
|
||||
setIsCloning(false);
|
||||
};
|
||||
|
||||
const handleCloneProfile = profile => {
|
||||
const clonedProfile = {
|
||||
...profile,
|
||||
id: 0,
|
||||
name: `${profile.name} [COPY]`,
|
||||
custom_formats: profile.custom_formats || []
|
||||
};
|
||||
setSelectedProfile(clonedProfile);
|
||||
setIsModalOpen(true);
|
||||
setIsCloning(true);
|
||||
};
|
||||
|
||||
const handleSaveProfile = () => {
|
||||
fetchProfiles();
|
||||
handleCloseModal();
|
||||
};
|
||||
|
||||
const formatDate = dateString => {
|
||||
return new Date(dateString).toLocaleString();
|
||||
};
|
||||
|
||||
const sortedAndFilteredProfiles = profiles
|
||||
.filter(profile => {
|
||||
if (filterType === 'tag') {
|
||||
return profile.tags && profile.tags.includes(filterValue);
|
||||
}
|
||||
if (filterType === 'date') {
|
||||
const profileDate = new Date(profile.date_modified);
|
||||
const filterDate = new Date(filterValue);
|
||||
return profileDate.toDateString() === filterDate.toDateString();
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (sortBy === 'name') return a.name.localeCompare(b.name);
|
||||
if (sortBy === 'dateCreated')
|
||||
return new Date(b.date_created) - new Date(a.date_created);
|
||||
if (sortBy === 'dateModified')
|
||||
return new Date(b.date_modified) - new Date(a.date_modified);
|
||||
return 0;
|
||||
});
|
||||
|
||||
const hasConflicts = mergeConflicts.length > 0;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className='flex flex-col items-center justify-center h-screen'>
|
||||
<Loader size={48} className='animate-spin text-blue-500 mb-4' />
|
||||
<p className='text-lg font-medium text-gray-700 dark:text-gray-300'>
|
||||
{
|
||||
loadingMessages[
|
||||
Math.floor(Math.random() * loadingMessages.length)
|
||||
]
|
||||
}
|
||||
</p>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-screen">
|
||||
<Loader size={48} className="animate-spin text-blue-500 mb-4" />
|
||||
<p className="text-lg font-medium text-gray-700 dark:text-gray-300">
|
||||
{loadingMessages[Math.floor(Math.random() * loadingMessages.length)]}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className='text-2xl font-bold mb-4'>Manage Profiles</h2>
|
||||
<div className='mb-4 flex items-center space-x-4'>
|
||||
<SortMenu sortBy={sortBy} setSortBy={setSortBy} />
|
||||
<FilterMenu
|
||||
filterType={filterType}
|
||||
setFilterType={setFilterType}
|
||||
filterValue={filterValue}
|
||||
setFilterValue={setFilterValue}
|
||||
allTags={allTags}
|
||||
/>
|
||||
</div>
|
||||
<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 => (
|
||||
<ProfileCard
|
||||
key={profile.id}
|
||||
profile={profile}
|
||||
onEdit={() => handleOpenModal(profile)}
|
||||
onClone={handleCloneProfile}
|
||||
showDate={sortBy !== 'name'}
|
||||
formatDate={formatDate}
|
||||
/>
|
||||
))}
|
||||
<AddNewCard onAdd={() => handleOpenModal()} />
|
||||
</div>
|
||||
<ProfileModal
|
||||
profile={selectedProfile}
|
||||
isOpen={isModalOpen}
|
||||
onClose={handleCloseModal}
|
||||
onSave={handleSaveProfile}
|
||||
formats={formats}
|
||||
isCloning={isCloning}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold mb-4">Manage Profiles</h2>
|
||||
<div className="mb-4 flex items-center space-x-4">
|
||||
<SortMenu sortBy={sortBy} setSortBy={setSortBy} />
|
||||
<FilterMenu
|
||||
filterType={filterType}
|
||||
setFilterType={setFilterType}
|
||||
filterValue={filterValue}
|
||||
setFilterValue={setFilterValue}
|
||||
allTags={allTags}
|
||||
/>
|
||||
</div>
|
||||
<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) => (
|
||||
<ProfileCard
|
||||
key={profile.id}
|
||||
profile={profile}
|
||||
onEdit={() => handleOpenModal(profile)}
|
||||
onClone={handleCloneProfile}
|
||||
showDate={sortBy !== "name"}
|
||||
formatDate={formatDate} // Pass the formatDate function to the ProfileCard
|
||||
/>
|
||||
))}
|
||||
<AddNewCard onAdd={() => handleOpenModal()} />
|
||||
</div>
|
||||
<ProfileModal
|
||||
profile={selectedProfile}
|
||||
isOpen={isModalOpen}
|
||||
onClose={handleCloseModal}
|
||||
onSave={handleSaveProfile}
|
||||
formats={formats}
|
||||
isCloning={isCloning}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ProfilePage;
|
||||
|
||||
@@ -1,151 +1,208 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import RegexCard from "./RegexCard";
|
||||
import RegexModal from "./RegexModal";
|
||||
import AddNewCard from "../ui/AddNewCard";
|
||||
import { getRegexes } from "../../api/api";
|
||||
import FilterMenu from "../ui/FilterMenu";
|
||||
import SortMenu from "../ui/SortMenu";
|
||||
import { Loader } from "lucide-react";
|
||||
import React, {useState, useEffect} from 'react';
|
||||
import {useNavigate} from 'react-router-dom';
|
||||
import RegexCard from './RegexCard';
|
||||
import RegexModal from './RegexModal';
|
||||
import AddNewCard from '../ui/AddNewCard';
|
||||
import {getRegexes} from '../../api/api';
|
||||
import FilterMenu from '../ui/FilterMenu';
|
||||
import SortMenu from '../ui/SortMenu';
|
||||
import {Loader} from 'lucide-react';
|
||||
import {getGitStatus} from '../../api/api';
|
||||
|
||||
function RegexPage() {
|
||||
const [regexes, setRegexes] = useState([]);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [selectedRegex, setSelectedRegex] = useState(null);
|
||||
const [sortBy, setSortBy] = useState("title");
|
||||
const [filterType, setFilterType] = useState("none");
|
||||
const [filterValue, setFilterValue] = useState("");
|
||||
const [allTags, setAllTags] = useState([]);
|
||||
const [isCloning, setIsCloning] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [regexes, setRegexes] = useState([]);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [selectedRegex, setSelectedRegex] = useState(null);
|
||||
const [sortBy, setSortBy] = useState('title');
|
||||
const [filterType, setFilterType] = useState('none');
|
||||
const [filterValue, setFilterValue] = useState('');
|
||||
const [allTags, setAllTags] = useState([]);
|
||||
const [isCloning, setIsCloning] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [mergeConflicts, setMergeConflicts] = useState([]);
|
||||
|
||||
const loadingMessages = [
|
||||
"Matching patterns in the digital universe...",
|
||||
"Capturing groups of binary brilliance...",
|
||||
"Escaping special characters in the wild...",
|
||||
"Quantifying the unquantifiable...",
|
||||
"Regex-ing the un-regex-able...",
|
||||
];
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
fetchRegexes();
|
||||
}, []);
|
||||
const loadingMessages = [
|
||||
'Compiling complex patterns...',
|
||||
'Analyzing regex efficiency...',
|
||||
'Optimizing search algorithms...',
|
||||
'Testing pattern boundaries...',
|
||||
'Loading regex libraries...',
|
||||
'Parsing intricate expressions...',
|
||||
'Detecting pattern conflicts...',
|
||||
'Refactoring nested groups...'
|
||||
];
|
||||
|
||||
const fetchRegexes = async () => {
|
||||
try {
|
||||
const fetchedRegexes = await getRegexes();
|
||||
setRegexes(fetchedRegexes);
|
||||
const tags = [
|
||||
...new Set(fetchedRegexes.flatMap((regex) => regex.tags || [])),
|
||||
];
|
||||
setAllTags(tags);
|
||||
} catch (error) {
|
||||
console.error("Error fetching regexes:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
fetchGitStatus();
|
||||
}, []);
|
||||
|
||||
const handleOpenModal = (regex = null) => {
|
||||
setSelectedRegex(regex);
|
||||
setIsModalOpen(true);
|
||||
setIsCloning(false);
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setSelectedRegex(null);
|
||||
setIsModalOpen(false);
|
||||
setIsCloning(false);
|
||||
};
|
||||
|
||||
const handleCloneRegex = (regex) => {
|
||||
const clonedRegex = {
|
||||
...regex,
|
||||
id: 0,
|
||||
name: `${regex.name} [COPY]`,
|
||||
regex101Link: "",
|
||||
const fetchRegexes = async () => {
|
||||
try {
|
||||
const fetchedRegexes = await getRegexes();
|
||||
setRegexes(fetchedRegexes);
|
||||
const tags = [
|
||||
...new Set(fetchedRegexes.flatMap(regex => regex.tags || []))
|
||||
];
|
||||
setAllTags(tags);
|
||||
} catch (error) {
|
||||
console.error('Error fetching regexes:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
setSelectedRegex(clonedRegex);
|
||||
setIsModalOpen(true);
|
||||
setIsCloning(true);
|
||||
};
|
||||
|
||||
const handleSaveRegex = () => {
|
||||
fetchRegexes();
|
||||
handleCloseModal();
|
||||
};
|
||||
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 formatDate = (dateString) => {
|
||||
return new Date(dateString).toLocaleString();
|
||||
};
|
||||
const handleOpenModal = (regex = null) => {
|
||||
setSelectedRegex(regex);
|
||||
setIsModalOpen(true);
|
||||
setIsCloning(false);
|
||||
};
|
||||
|
||||
const sortedAndFilteredRegexes = regexes
|
||||
.filter((regex) => {
|
||||
if (filterType === "tag") {
|
||||
return regex.tags && regex.tags.includes(filterValue);
|
||||
}
|
||||
if (filterType === "date") {
|
||||
const regexDate = new Date(regex.date_modified);
|
||||
const filterDate = new Date(filterValue);
|
||||
return regexDate.toDateString() === filterDate.toDateString();
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (sortBy === "title") return a.name.localeCompare(b.name);
|
||||
if (sortBy === "dateCreated")
|
||||
return new Date(b.date_created) - new Date(a.date_created);
|
||||
if (sortBy === "dateModified")
|
||||
return new Date(b.date_modified) - new Date(a.date_modified);
|
||||
return 0;
|
||||
});
|
||||
const handleCloseModal = () => {
|
||||
setSelectedRegex(null);
|
||||
setIsModalOpen(false);
|
||||
setIsCloning(false);
|
||||
};
|
||||
|
||||
const handleCloneRegex = regex => {
|
||||
const clonedRegex = {
|
||||
...regex,
|
||||
id: 0,
|
||||
name: `${regex.name} [COPY]`,
|
||||
regex101Link: ''
|
||||
};
|
||||
setSelectedRegex(clonedRegex);
|
||||
setIsModalOpen(true);
|
||||
setIsCloning(true);
|
||||
};
|
||||
|
||||
const handleSaveRegex = () => {
|
||||
fetchRegexes();
|
||||
handleCloseModal();
|
||||
};
|
||||
|
||||
const formatDate = dateString => {
|
||||
return new Date(dateString).toLocaleString();
|
||||
};
|
||||
|
||||
const sortedAndFilteredRegexes = regexes
|
||||
.filter(regex => {
|
||||
if (filterType === 'tag') {
|
||||
return regex.tags && regex.tags.includes(filterValue);
|
||||
}
|
||||
if (filterType === 'date') {
|
||||
const regexDate = new Date(regex.date_modified);
|
||||
const filterDate = new Date(filterValue);
|
||||
return regexDate.toDateString() === filterDate.toDateString();
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (sortBy === 'title') return a.name.localeCompare(b.name);
|
||||
if (sortBy === 'dateCreated')
|
||||
return new Date(b.date_created) - new Date(a.date_created);
|
||||
if (sortBy === 'dateModified')
|
||||
return new Date(b.date_modified) - new Date(a.date_modified);
|
||||
return 0;
|
||||
});
|
||||
|
||||
const hasConflicts = mergeConflicts.length > 0;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className='flex flex-col items-center justify-center h-screen'>
|
||||
<Loader size={48} className='animate-spin text-blue-500 mb-4' />
|
||||
<p className='text-lg font-medium text-gray-700 dark:text-gray-300'>
|
||||
{
|
||||
loadingMessages[
|
||||
Math.floor(Math.random() * loadingMessages.length)
|
||||
]
|
||||
}
|
||||
</p>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-screen">
|
||||
<Loader size={48} className="animate-spin text-blue-500 mb-4" />
|
||||
<p className="text-lg font-medium text-gray-700 dark:text-gray-300">
|
||||
{loadingMessages[Math.floor(Math.random() * loadingMessages.length)]}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className='text-2xl font-bold mb-4'>Manage Regex Patterns</h2>
|
||||
<div className='mb-4 flex items-center space-x-4'>
|
||||
<SortMenu sortBy={sortBy} setSortBy={setSortBy} />
|
||||
<FilterMenu
|
||||
filterType={filterType}
|
||||
setFilterType={setFilterType}
|
||||
filterValue={filterValue}
|
||||
setFilterValue={setFilterValue}
|
||||
allTags={allTags}
|
||||
/>
|
||||
</div>
|
||||
<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 => (
|
||||
<RegexCard
|
||||
key={regex.id}
|
||||
regex={regex}
|
||||
onEdit={() => handleOpenModal(regex)}
|
||||
onClone={handleCloneRegex} // Pass the clone handler
|
||||
showDate={sortBy !== 'title'}
|
||||
formatDate={formatDate}
|
||||
/>
|
||||
))}
|
||||
<AddNewCard onAdd={() => handleOpenModal()} />
|
||||
</div>
|
||||
<RegexModal
|
||||
regex={selectedRegex}
|
||||
isOpen={isModalOpen}
|
||||
onClose={handleCloseModal}
|
||||
onSave={handleSaveRegex}
|
||||
allTags={allTags}
|
||||
isCloning={isCloning}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold mb-4">Manage Regex Patterns</h2>
|
||||
<div className="mb-4 flex items-center space-x-4">
|
||||
<SortMenu sortBy={sortBy} setSortBy={setSortBy} />
|
||||
<FilterMenu
|
||||
filterType={filterType}
|
||||
setFilterType={setFilterType}
|
||||
filterValue={filterValue}
|
||||
setFilterValue={setFilterValue}
|
||||
allTags={allTags}
|
||||
/>
|
||||
</div>
|
||||
<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) => (
|
||||
<RegexCard
|
||||
key={regex.id}
|
||||
regex={regex}
|
||||
onEdit={() => handleOpenModal(regex)}
|
||||
onClone={handleCloneRegex} // Pass the clone handler
|
||||
showDate={sortBy !== "title"}
|
||||
formatDate={formatDate}
|
||||
/>
|
||||
))}
|
||||
<AddNewCard onAdd={() => handleOpenModal()} />
|
||||
</div>
|
||||
<RegexModal
|
||||
regex={selectedRegex}
|
||||
isOpen={isModalOpen}
|
||||
onClose={handleCloseModal}
|
||||
onSave={handleSaveRegex}
|
||||
allTags={allTags}
|
||||
isCloning={isCloning}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default RegexPage;
|
||||
|
||||
@@ -3,6 +3,8 @@ import {
|
||||
getSettings,
|
||||
getGitStatus,
|
||||
addFiles,
|
||||
unstageFiles,
|
||||
commitFiles,
|
||||
pushFiles,
|
||||
revertFile,
|
||||
pullBranch,
|
||||
@@ -32,6 +34,7 @@ const SettingsPage = () => {
|
||||
const [noChangesMessage, setNoChangesMessage] = useState('');
|
||||
const [activeTab, setActiveTab] = useState('git');
|
||||
const tabsRef = useRef({});
|
||||
const [mergeConflicts, setMergeConflicts] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
@@ -67,10 +70,11 @@ const SettingsPage = () => {
|
||||
setStatusLoading(true);
|
||||
setStatusLoadingMessage(getRandomMessage(statusLoadingMessages));
|
||||
setNoChangesMessage(getRandomMessage(noChangesMessages));
|
||||
|
||||
try {
|
||||
const result = await getGitStatus();
|
||||
if (result.success) {
|
||||
setChanges({
|
||||
const gitStatus = {
|
||||
...result.data,
|
||||
outgoing_changes: Array.isArray(
|
||||
result.data.outgoing_changes
|
||||
@@ -81,8 +85,16 @@ const SettingsPage = () => {
|
||||
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) {
|
||||
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 (
|
||||
selectedChanges,
|
||||
commitMessage
|
||||
) => {
|
||||
setLoadingAction('commit_selected');
|
||||
try {
|
||||
const response = await pushFiles(selectedChanges, commitMessage);
|
||||
const response = await commitFiles(selectedChanges, commitMessage);
|
||||
if (response.success) {
|
||||
await fetchGitStatus();
|
||||
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 => {
|
||||
setLoadingAction('revert_selected');
|
||||
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 (
|
||||
<div>
|
||||
<nav className='flex space-x-4'>
|
||||
@@ -236,13 +308,18 @@ const SettingsPage = () => {
|
||||
status={changes}
|
||||
isDevMode={isDevMode}
|
||||
onStageSelected={handleStageSelectedChanges}
|
||||
onUnstageSelected={
|
||||
handleUnstageSelectedChanges
|
||||
}
|
||||
onCommitSelected={
|
||||
handleCommitSelectedChanges
|
||||
}
|
||||
onPushSelected={handlePushChanges}
|
||||
onRevertSelected={
|
||||
handleRevertSelectedChanges
|
||||
}
|
||||
onPullSelected={handlePullSelectedChanges}
|
||||
fetchGitStatus={fetchGitStatus}
|
||||
loadingAction={loadingAction}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,64 +1,42 @@
|
||||
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';
|
||||
|
||||
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 = ({
|
||||
isDevMode,
|
||||
selectedOutgoingChanges,
|
||||
selectedIncomingChanges,
|
||||
selectionType,
|
||||
commitMessage,
|
||||
loadingAction,
|
||||
onStageSelected,
|
||||
onCommitSelected,
|
||||
onPushSelected,
|
||||
onRevertSelected,
|
||||
onPullSelected
|
||||
hasUnpushedCommits
|
||||
}) => {
|
||||
const canStage =
|
||||
isDevMode &&
|
||||
selectedOutgoingChanges.length > 0 &&
|
||||
selectionType !== 'staged';
|
||||
|
||||
const canCommit =
|
||||
isDevMode &&
|
||||
selectedOutgoingChanges.length > 0 &&
|
||||
commitMessage.trim() &&
|
||||
selectionType !== 'unstaged';
|
||||
|
||||
const canPush = isDevMode && hasUnpushedCommits;
|
||||
const canRevert = selectedOutgoingChanges.length > 0;
|
||||
const canPull = selectedIncomingChanges.length > 0;
|
||||
|
||||
return (
|
||||
<div className='mt-4 flex justify-start space-x-2'>
|
||||
<div className='space-x-2 flex flex-wrap gap-2'>
|
||||
{isDevMode && (
|
||||
<>
|
||||
<ActionButton
|
||||
@@ -84,6 +62,17 @@ const ActionButtons = ({
|
||||
className='bg-blue-600'
|
||||
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
|
||||
@@ -95,17 +84,39 @@ const ActionButtons = ({
|
||||
className='bg-red-600'
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
@@ -5,24 +5,17 @@ import {
|
||||
MinusCircle,
|
||||
Edit,
|
||||
GitBranch,
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
Code,
|
||||
FileText,
|
||||
Settings,
|
||||
File
|
||||
} from 'lucide-react';
|
||||
import Tooltip from '../../ui/Tooltip';
|
||||
import ViewDiff from './modal/ViewDiff';
|
||||
import ViewChanges from './modal/ViewChanges';
|
||||
|
||||
const ChangeRow = ({
|
||||
change,
|
||||
isSelected,
|
||||
onSelect,
|
||||
isIncoming,
|
||||
isDevMode,
|
||||
diffContent
|
||||
}) => {
|
||||
const [showDiff, setShowDiff] = useState(false);
|
||||
const ChangeRow = ({change, isSelected, onSelect, isIncoming, isDevMode}) => {
|
||||
const [showChanges, setShowChanges] = useState(false);
|
||||
|
||||
const getStatusIcon = status => {
|
||||
switch (status) {
|
||||
@@ -40,7 +33,7 @@ const ChangeRow = ({
|
||||
case 'Renamed':
|
||||
return <GitBranch className='text-purple-400' size={16} />;
|
||||
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();
|
||||
console.log('Change Object: ', JSON.stringify(change, null, 2));
|
||||
setShowDiff(true);
|
||||
setShowChanges(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 (
|
||||
<>
|
||||
<tr
|
||||
className={`border-t border-gray-600 cursor-pointer hover:bg-gray-700 ${
|
||||
isSelected ? 'bg-gray-700' : ''
|
||||
}`}
|
||||
onClick={() => onSelect(change.file_path)}>
|
||||
<tr className={rowClasses} onClick={handleRowClick}>
|
||||
<td className='px-4 py-2 text-gray-300'>
|
||||
<div className='flex items-center'>
|
||||
<div className='flex items-center relative'>
|
||||
{getStatusIcon(change.status)}
|
||||
<span className='ml-2'>
|
||||
{change.staged
|
||||
? `${change.status} (Staged)`
|
||||
: change.status}
|
||||
</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>
|
||||
</td>
|
||||
<td className='px-4 py-2 text-gray-300'>
|
||||
@@ -87,38 +102,59 @@ const ChangeRow = ({
|
||||
</div>
|
||||
</td>
|
||||
<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 className='px-4 py-2 text-left align-middle'>
|
||||
<Tooltip content='View differences'>
|
||||
<Tooltip content='View changes'>
|
||||
<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'
|
||||
style={{width: '100%'}}>
|
||||
<Eye size={12} className='mr-1' />
|
||||
View Diff
|
||||
Changes
|
||||
</button>
|
||||
</Tooltip>
|
||||
</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>
|
||||
<ViewDiff
|
||||
key={`${change.file_path}-diff`}
|
||||
isOpen={showDiff}
|
||||
onClose={() => setShowDiff(false)}
|
||||
diffContent={diffContent}
|
||||
type={change.type}
|
||||
name={change.name}
|
||||
commitMessage={change.commit_message}
|
||||
isDevMode={isDevMode}
|
||||
<ViewChanges
|
||||
key={`${change.file_path}-changes`}
|
||||
isOpen={showChanges}
|
||||
onClose={() => setShowChanges(false)}
|
||||
change={change}
|
||||
isIncoming={isIncoming}
|
||||
isDevMode={isDevMode}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
import React from 'react';
|
||||
import {ArrowDown, ArrowUp} from 'lucide-react';
|
||||
import ChangeRow from './ChangeRow';
|
||||
import ConflictRow from './ConflictRow';
|
||||
|
||||
const ChangeTable = ({
|
||||
changes,
|
||||
title,
|
||||
icon,
|
||||
isIncoming,
|
||||
isMergeConflict,
|
||||
selectedChanges,
|
||||
onSelectChange,
|
||||
sortConfig,
|
||||
onRequestSort,
|
||||
isDevMode,
|
||||
diffContents
|
||||
fetchGitStatus
|
||||
}) => {
|
||||
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) => {
|
||||
if (a[sortConfig.key] < b[sortConfig.key]) {
|
||||
return sortConfig.direction === 'ascending' ? -1 : 1;
|
||||
@@ -47,61 +49,49 @@ const ChangeTable = ({
|
||||
};
|
||||
|
||||
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'>
|
||||
<thead className='bg-gray-600'>
|
||||
<tr>
|
||||
<SortableHeader sortKey='status' className='w-1/6'>
|
||||
Status
|
||||
</SortableHeader>
|
||||
<SortableHeader sortKey='type' className='w-1/6'>
|
||||
Type
|
||||
</SortableHeader>
|
||||
<SortableHeader sortKey='name' className='w-2/6'>
|
||||
Name
|
||||
</SortableHeader>
|
||||
<th className='px-4 py-2 text-left text-gray-300 w-1/12'>
|
||||
Actions
|
||||
</th>
|
||||
<th className='px-4 py-2 text-right text-gray-300 w-1/12'>
|
||||
Select
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedChanges(changes).map((change, index) => (
|
||||
<ChangeRow
|
||||
key={`${
|
||||
isIncoming ? 'incoming' : 'outgoing'
|
||||
}-${index}`}
|
||||
change={change}
|
||||
isSelected={selectedChanges.includes(
|
||||
change.file_path
|
||||
)}
|
||||
onSelect={filePath =>
|
||||
onSelectChange(filePath, isIncoming)
|
||||
}
|
||||
isIncoming={isIncoming}
|
||||
isDevMode={isDevMode}
|
||||
diffContent={diffContents[change.file_path]}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<table className='w-full text-sm'>
|
||||
<thead className='bg-gray-600'>
|
||||
<tr>
|
||||
<SortableHeader sortKey='status' className='w-1/5'>
|
||||
Status
|
||||
</SortableHeader>
|
||||
<SortableHeader sortKey='type' className='w-1/5'>
|
||||
Type
|
||||
</SortableHeader>
|
||||
<SortableHeader sortKey='name' className='w-1/2'>
|
||||
Name
|
||||
</SortableHeader>
|
||||
<th className='px-4 py-2 text-left text-gray-300 w-1/5'>
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedChanges(changes).map((change, index) =>
|
||||
isMergeConflict ? (
|
||||
<ConflictRow
|
||||
key={`merge-conflict-${index}`}
|
||||
change={change}
|
||||
isDevMode={isDevMode}
|
||||
fetchGitStatus={fetchGitStatus}
|
||||
/>
|
||||
) : (
|
||||
<ChangeRow
|
||||
key={`${
|
||||
isIncoming ? 'incoming' : 'outgoing'
|
||||
}-${index}`}
|
||||
change={change}
|
||||
isSelected={selectedChanges?.includes(
|
||||
change.file_path
|
||||
)}
|
||||
onSelect={!isIncoming ? onSelectChange : null}
|
||||
isIncoming={isIncoming}
|
||||
isDevMode={isDevMode}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,56 +1,214 @@
|
||||
import React from 'react';
|
||||
import Textarea from '../../ui/TextArea';
|
||||
import React, {useState, useEffect} from 'react';
|
||||
|
||||
const CommitSection = ({
|
||||
status,
|
||||
commitMessage,
|
||||
setCommitMessage,
|
||||
hasIncomingChanges,
|
||||
funMessage,
|
||||
isDevMode
|
||||
}) => {
|
||||
const hasUnstagedChanges = status.outgoing_changes.some(
|
||||
change => !change.staged || (change.staged && change.modified)
|
||||
);
|
||||
const hasStagedChanges = status.outgoing_changes.some(
|
||||
change => change.staged
|
||||
);
|
||||
const hasAnyChanges = status.outgoing_changes.length > 0;
|
||||
const COMMIT_TYPES = [
|
||||
{value: 'feat', label: 'Feature', description: 'A new feature'},
|
||||
{value: 'fix', label: 'Bug Fix', description: 'A bug fix'},
|
||||
{
|
||||
value: 'docs',
|
||||
label: 'Documentation',
|
||||
description: 'Documentation only changes'
|
||||
},
|
||||
{
|
||||
value: 'style',
|
||||
label: 'Style',
|
||||
description: 'Changes that do not affect code meaning'
|
||||
},
|
||||
{
|
||||
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 (
|
||||
<div className='mt-4'>
|
||||
{isDevMode ? (
|
||||
<>
|
||||
{hasAnyChanges || hasIncomingChanges ? (
|
||||
<>
|
||||
{hasStagedChanges && (
|
||||
<>
|
||||
<h3 className='text-sm font-semibold text-gray-100 mb-4'>
|
||||
Commit Message:
|
||||
</h3>
|
||||
<Textarea
|
||||
value={commitMessage}
|
||||
onChange={e =>
|
||||
setCommitMessage(e.target.value)
|
||||
}
|
||||
placeholder='Enter your commit message here...'
|
||||
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'
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className='text-gray-300 text-sm italic'>
|
||||
{funMessage}
|
||||
<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'>
|
||||
<div className='flex-none w-64 border-r border-gray-600'>
|
||||
<div className='relative'>
|
||||
<select
|
||||
value={type}
|
||||
onChange={e => setType(e.target.value)}
|
||||
className={`w-full px-3 py-2.5 appearance-none cursor-pointer ${selectStyles}`}>
|
||||
<option value='' disabled>
|
||||
Select Type
|
||||
</option>
|
||||
{COMMIT_TYPES.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 className='text-gray-300 text-sm italic'>
|
||||
Developer mode is disabled. Commit functionality is not
|
||||
available.
|
||||
{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>
|
||||
|
||||
<div className='flex-none w-64 border-r border-gray-600'>
|
||||
<div className='relative'>
|
||||
<select
|
||||
value={scope}
|
||||
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>
|
||||
{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>
|
||||
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
99
frontend/src/components/settings/git/ConflictRow.jsx
Normal file
99
frontend/src/components/settings/git/ConflictRow.jsx
Normal 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;
|
||||
39
frontend/src/components/settings/git/ConflictTable.jsx
Normal file
39
frontend/src/components/settings/git/ConflictTable.jsx
Normal 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;
|
||||
@@ -1,30 +1,60 @@
|
||||
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 ConflictTable from './ConflictTable';
|
||||
import CommitSection from './CommitMessage';
|
||||
import Modal from '../../ui/Modal';
|
||||
import Tooltip from '../../ui/Tooltip';
|
||||
import {getRandomMessage, noChangesMessages} from '../../../utils/messages';
|
||||
import ActionButtons from './ActionButtons';
|
||||
import {getDiff} from '../../../api/api';
|
||||
import IconButton from '../../ui/IconButton';
|
||||
import {abortMerge, finalizeMerge} from '../../../api/api';
|
||||
import Alert from '../../ui/Alert';
|
||||
|
||||
const StatusContainer = ({
|
||||
status,
|
||||
isDevMode,
|
||||
onStageSelected,
|
||||
onUnstageSelected,
|
||||
onCommitSelected,
|
||||
onRevertSelected,
|
||||
onPullSelected,
|
||||
loadingAction
|
||||
onPushSelected,
|
||||
loadingAction,
|
||||
fetchGitStatus
|
||||
}) => {
|
||||
const [sortConfig, setSortConfig] = useState({
|
||||
key: 'type',
|
||||
direction: 'ascending'
|
||||
});
|
||||
const [selectedIncomingChanges, setSelectedIncomingChanges] = useState([]);
|
||||
const [selectedOutgoingChanges, setSelectedOutgoingChanges] = useState([]);
|
||||
const [selectedMergeConflicts, setSelectedMergeConflicts] = useState([]);
|
||||
const [commitMessage, setCommitMessage] = useState('');
|
||||
const [selectionType, setSelectionType] = useState(null);
|
||||
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 => {
|
||||
let direction = 'ascending';
|
||||
@@ -34,48 +64,61 @@ const StatusContainer = ({
|
||||
setSortConfig({key, direction});
|
||||
};
|
||||
|
||||
const handleSelectChange = (filePath, isIncoming) => {
|
||||
if (isIncoming) {
|
||||
if (selectedOutgoingChanges.length > 0) {
|
||||
setSelectedOutgoingChanges([]);
|
||||
}
|
||||
setSelectedIncomingChanges(prevSelected => {
|
||||
if (prevSelected.includes(filePath)) {
|
||||
return prevSelected.filter(path => path !== filePath);
|
||||
} else {
|
||||
const handleSelectChange = filePath => {
|
||||
const change = status.outgoing_changes.find(
|
||||
c => c.file_path === filePath
|
||||
);
|
||||
|
||||
if (!change) {
|
||||
console.error('Could not find change for file path:', filePath);
|
||||
return;
|
||||
}
|
||||
|
||||
const isStaged = change.staged;
|
||||
|
||||
console.log('Selection change:', {
|
||||
filePath,
|
||||
isStaged,
|
||||
currentSelectionType: selectionType,
|
||||
currentSelected: selectedOutgoingChanges
|
||||
});
|
||||
|
||||
setSelectedOutgoingChanges(prevSelected => {
|
||||
if (prevSelected.includes(filePath)) {
|
||||
// Deselecting a file
|
||||
const newSelection = prevSelected.filter(
|
||||
path => path !== filePath
|
||||
);
|
||||
// If no more files are selected, reset selection type
|
||||
if (newSelection.length === 0) {
|
||||
setSelectionType(null);
|
||||
}
|
||||
return newSelection;
|
||||
} else {
|
||||
// Selecting a file
|
||||
if (prevSelected.length === 0) {
|
||||
// First selection sets the type
|
||||
setSelectionType(isStaged ? 'staged' : 'unstaged');
|
||||
return [filePath];
|
||||
} else if (
|
||||
(isStaged && selectionType === 'staged') ||
|
||||
(!isStaged && selectionType === 'unstaged')
|
||||
) {
|
||||
// Only allow selection if it matches current type
|
||||
return [...prevSelected, filePath];
|
||||
}
|
||||
});
|
||||
} else {
|
||||
if (selectedIncomingChanges.length > 0) {
|
||||
setSelectedIncomingChanges([]);
|
||||
// Don't add if it doesn't match the type
|
||||
return prevSelected;
|
||||
}
|
||||
const change = status.outgoing_changes.find(
|
||||
c => c.file_path === filePath
|
||||
);
|
||||
const isStaged = change.staged;
|
||||
});
|
||||
};
|
||||
|
||||
setSelectedOutgoingChanges(prevSelected => {
|
||||
if (prevSelected.includes(filePath)) {
|
||||
const newSelection = prevSelected.filter(
|
||||
path => path !== filePath
|
||||
);
|
||||
if (newSelection.length === 0) setSelectionType(null);
|
||||
return newSelection;
|
||||
} else {
|
||||
if (
|
||||
prevSelected.length === 0 ||
|
||||
(isStaged && selectionType === 'staged') ||
|
||||
(!isStaged && selectionType === 'unstaged')
|
||||
) {
|
||||
setSelectionType(isStaged ? 'staged' : 'unstaged');
|
||||
return [...prevSelected, filePath];
|
||||
} else {
|
||||
return prevSelected;
|
||||
}
|
||||
}
|
||||
});
|
||||
const handleCommitSelected = (files, message) => {
|
||||
if (!message.trim()) {
|
||||
console.error('Commit message cannot be empty');
|
||||
return;
|
||||
}
|
||||
onCommitSelected(files, message);
|
||||
};
|
||||
|
||||
const getStageButtonTooltip = () => {
|
||||
@@ -85,11 +128,21 @@ const StatusContainer = ({
|
||||
if (selectedOutgoingChanges.length === 0) {
|
||||
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 = () => {
|
||||
if (selectionType === 'unstaged') {
|
||||
if (selectionType !== 'staged') {
|
||||
return 'You can only commit staged files';
|
||||
}
|
||||
if (selectedOutgoingChanges.length === 0) {
|
||||
@@ -98,50 +151,85 @@ const StatusContainer = ({
|
||||
if (!commitMessage.trim()) {
|
||||
return 'Enter a commit message';
|
||||
}
|
||||
return 'Commit selected files';
|
||||
return 'Commit Changes';
|
||||
};
|
||||
|
||||
const getRevertButtonTooltip = () => {
|
||||
if (selectedOutgoingChanges.length === 0) {
|
||||
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(() => {
|
||||
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 (
|
||||
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));
|
||||
}
|
||||
}, [status]);
|
||||
}, [status, isDevMode]);
|
||||
|
||||
// Reset commit message when selection changes
|
||||
useEffect(() => {
|
||||
if (selectionType !== 'staged') {
|
||||
setCommitMessage('');
|
||||
}
|
||||
}, [selectionType]);
|
||||
|
||||
const hasChanges =
|
||||
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 (
|
||||
<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}
|
||||
</span>
|
||||
) : (
|
||||
<span className='text-gray-400 text-m flex items-center'>
|
||||
Out of Date!
|
||||
<span className='text-gray-400 text-m flex items-center space-x-2'>
|
||||
<span>Out of Date!</span>
|
||||
</span>
|
||||
)}
|
||||
</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>
|
||||
|
||||
{status.incoming_changes.length > 0 && (
|
||||
<ChangeTable
|
||||
changes={status.incoming_changes}
|
||||
title='Incoming Changes'
|
||||
icon={
|
||||
<ArrowDownToLine
|
||||
className='text-blue-400 mr-2'
|
||||
size={16}
|
||||
/>
|
||||
}
|
||||
isIncoming={true}
|
||||
selectedChanges={selectedIncomingChanges}
|
||||
onSelectChange={handleSelectChange}
|
||||
sortConfig={sortConfig}
|
||||
onRequestSort={requestSort}
|
||||
isDevMode={isDevMode}
|
||||
diffContents={diffContents}
|
||||
/>
|
||||
)}
|
||||
|
||||
{status.outgoing_changes.length > 0 && (
|
||||
<ChangeTable
|
||||
changes={status.outgoing_changes}
|
||||
title='Outgoing Changes'
|
||||
icon={
|
||||
<ArrowUpFromLine
|
||||
className='text-blue-400 mr-2'
|
||||
size={16}
|
||||
/>
|
||||
}
|
||||
isIncoming={false}
|
||||
selectedChanges={selectedOutgoingChanges}
|
||||
onSelectChange={handleSelectChange}
|
||||
sortConfig={sortConfig}
|
||||
onRequestSort={requestSort}
|
||||
isDevMode={isDevMode}
|
||||
diffContents={diffContents}
|
||||
/>
|
||||
)}
|
||||
|
||||
{hasChanges && (
|
||||
{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 && (
|
||||
<div className='mb-4'>
|
||||
<div className='flex items-center justify-between mb-2'>
|
||||
<h4 className='text-sm font-medium text-gray-200 flex items-center'>
|
||||
<ArrowDownToLine
|
||||
className='text-blue-400 mr-2'
|
||||
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}
|
||||
selectable={false}
|
||||
selectedChanges={[]}
|
||||
sortConfig={sortConfig}
|
||||
onRequestSort={requestSort}
|
||||
isDevMode={isDevMode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(status.outgoing_changes.length > 0 ||
|
||||
(isDevMode && status.has_unpushed_commits)) && (
|
||||
<div className='mb-4'>
|
||||
<div className='flex items-center justify-between mb-2'>
|
||||
<h4 className='text-sm font-medium text-gray-200 flex items-center'>
|
||||
<ArrowUpFromLine
|
||||
className='text-blue-400 mr-2'
|
||||
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}
|
||||
selectedChanges={
|
||||
selectedOutgoingChanges
|
||||
}
|
||||
onSelectChange={filePath =>
|
||||
handleSelectChange(filePath)
|
||||
}
|
||||
sortConfig={sortConfig}
|
||||
onRequestSort={requestSort}
|
||||
isDevMode={isDevMode}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectionType === 'staged' &&
|
||||
selectedOutgoingChanges.length > 0 && (
|
||||
<CommitSection
|
||||
status={status}
|
||||
commitMessage={commitMessage}
|
||||
setCommitMessage={setCommitMessage}
|
||||
selectedOutgoingChanges={selectedOutgoingChanges}
|
||||
loadingAction={loadingAction}
|
||||
hasIncomingChanges={status.incoming_changes.length > 0}
|
||||
hasMergeConflicts={status.merge_conflicts.length > 0}
|
||||
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}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,6 +8,11 @@ const DiffCommit = ({commitMessage}) => {
|
||||
|
||||
return (
|
||||
<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-1'>
|
||||
<div className='mb-2'>
|
||||
|
||||
427
frontend/src/components/settings/git/modal/ResolveConflicts.jsx
Normal file
427
frontend/src/components/settings/git/modal/ResolveConflicts.jsx
Normal 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;
|
||||
@@ -88,14 +88,14 @@ const SettingsBranchModal = ({
|
||||
Alert.success('Branch created successfully');
|
||||
resetForm();
|
||||
} else {
|
||||
Alert.error(response.error);
|
||||
Alert.error(response.error.error || response.error);
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
error.response &&
|
||||
error.response.status === 400 &&
|
||||
error.response.data.error
|
||||
) {
|
||||
if (error.response?.status === 409) {
|
||||
Alert.error(
|
||||
'Cannot perform operation - merge in progress. Please resolve conflicts first.'
|
||||
);
|
||||
} else if (error.response?.status === 400) {
|
||||
Alert.error(error.response.data.error);
|
||||
} else {
|
||||
console.error('Error branching off:', error);
|
||||
@@ -139,14 +139,15 @@ const SettingsBranchModal = ({
|
||||
Alert.success('Branch checked out successfully');
|
||||
onClose();
|
||||
} else {
|
||||
Alert.error(response.error);
|
||||
// The error is nested inside result.error from the backend
|
||||
Alert.error(response.error.error || response.error);
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
error.response &&
|
||||
error.response.status === 400 &&
|
||||
error.response.data.error
|
||||
) {
|
||||
if (error.response?.status === 409) {
|
||||
Alert.error(
|
||||
'Cannot perform operation - merge in progress. Please resolve conflicts first.'
|
||||
);
|
||||
} else if (error.response?.status === 400) {
|
||||
Alert.error(error.response.data.error);
|
||||
} else {
|
||||
Alert.error(
|
||||
@@ -179,13 +180,19 @@ const SettingsBranchModal = ({
|
||||
`Branch '${branchName}' deleted successfully`
|
||||
);
|
||||
} else {
|
||||
Alert.error(response.error);
|
||||
Alert.error(response.error.error || response.error);
|
||||
}
|
||||
} catch (error) {
|
||||
Alert.error(
|
||||
'An unexpected error occurred while deleting the branch.'
|
||||
);
|
||||
console.error('Error deleting branch:', error);
|
||||
if (error.response?.status === 409) {
|
||||
Alert.error(
|
||||
'Cannot perform operation - merge in progress. Please resolve conflicts first.'
|
||||
);
|
||||
} else {
|
||||
Alert.error(
|
||||
'An unexpected error occurred while deleting the branch.'
|
||||
);
|
||||
console.error('Error deleting branch:', error);
|
||||
}
|
||||
} finally {
|
||||
setLoadingAction('');
|
||||
setConfirmAction(null);
|
||||
@@ -194,7 +201,6 @@ const SettingsBranchModal = ({
|
||||
setConfirmAction(`delete-${branchName}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePushToRemote = async branchName => {
|
||||
if (confirmAction === `push-${branchName}`) {
|
||||
setLoadingAction(`push-${branchName}`);
|
||||
@@ -206,13 +212,19 @@ const SettingsBranchModal = ({
|
||||
);
|
||||
await fetchBranches();
|
||||
} else {
|
||||
Alert.error(response.error);
|
||||
Alert.error(response.error.error || response.error);
|
||||
}
|
||||
} catch (error) {
|
||||
Alert.error(
|
||||
'An unexpected error occurred while pushing the branch to remote.'
|
||||
);
|
||||
console.error('Error pushing branch to remote:', error);
|
||||
if (error.response?.status === 409) {
|
||||
Alert.error(
|
||||
'Cannot perform operation - merge in progress. Please resolve conflicts first.'
|
||||
);
|
||||
} else {
|
||||
Alert.error(
|
||||
'An unexpected error occurred while pushing the branch to remote.'
|
||||
);
|
||||
console.error('Error pushing branch to remote:', error);
|
||||
}
|
||||
} finally {
|
||||
setLoadingAction('');
|
||||
setConfirmAction(null);
|
||||
|
||||
207
frontend/src/components/settings/git/modal/ViewChanges.jsx
Normal file
207
frontend/src/components/settings/git/modal/ViewChanges.jsx
Normal 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;
|
||||
@@ -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;
|
||||
32
frontend/src/components/ui/IconButton.jsx
Normal file
32
frontend/src/components/ui/IconButton.jsx
Normal 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;
|
||||
@@ -9,8 +9,9 @@ function Modal({
|
||||
level = 0,
|
||||
disableCloseOnOutsideClick = false,
|
||||
disableCloseOnEscape = false,
|
||||
width = 'lg',
|
||||
height = 'auto'
|
||||
width = 'auto',
|
||||
height = 'auto',
|
||||
maxHeight = '80vh'
|
||||
}) {
|
||||
const modalRef = useRef();
|
||||
|
||||
@@ -39,6 +40,7 @@ function Modal({
|
||||
};
|
||||
|
||||
const widthClasses = {
|
||||
auto: 'w-auto max-w-[60%]',
|
||||
sm: 'max-w-sm',
|
||||
md: 'max-w-md',
|
||||
lg: 'max-w-lg',
|
||||
@@ -74,7 +76,7 @@ function Modal({
|
||||
|
||||
return (
|
||||
<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'
|
||||
}`}
|
||||
style={{zIndex: 1000 + level * 10}}
|
||||
@@ -86,7 +88,7 @@ function Modal({
|
||||
style={{zIndex: 1000 + level * 10}}></div>
|
||||
<div
|
||||
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]
|
||||
} ${
|
||||
heightClasses[height]
|
||||
@@ -95,7 +97,8 @@ function Modal({
|
||||
}`}
|
||||
style={{
|
||||
zIndex: 1001 + level * 10,
|
||||
overflowY: 'auto'
|
||||
overflowY: 'auto',
|
||||
maxHeight: maxHeight
|
||||
}}
|
||||
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'>
|
||||
@@ -134,6 +137,7 @@ Modal.propTypes = {
|
||||
disableCloseOnOutsideClick: PropTypes.bool,
|
||||
disableCloseOnEscape: PropTypes.bool,
|
||||
width: PropTypes.oneOf([
|
||||
'auto',
|
||||
'sm',
|
||||
'md',
|
||||
'lg',
|
||||
@@ -164,7 +168,8 @@ Modal.propTypes = {
|
||||
'5xl',
|
||||
'6xl',
|
||||
'full'
|
||||
])
|
||||
]),
|
||||
maxHeight: PropTypes.string
|
||||
};
|
||||
|
||||
export default Modal;
|
||||
|
||||
@@ -1,134 +1,153 @@
|
||||
import PropTypes from "prop-types";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import PropTypes from 'prop-types';
|
||||
import {useState, useEffect, useRef, useLayoutEffect} from 'react';
|
||||
import {Link, useLocation} from 'react-router-dom';
|
||||
|
||||
function ToggleSwitch({ checked, onChange }) {
|
||||
return (
|
||||
<label className="flex items-center cursor-pointer">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only"
|
||||
checked={checked}
|
||||
onChange={onChange}
|
||||
/>
|
||||
<div
|
||||
className={`block w-14 h-8 rounded-full ${
|
||||
checked ? "bg-blue-600" : "bg-gray-600"
|
||||
} transition-colors duration-300`}
|
||||
></div>
|
||||
<div
|
||||
className={`dot absolute left-1 top-1 bg-white w-6 h-6 rounded-full transition-transform duration-300 ${
|
||||
checked ? "transform translate-x-6" : ""
|
||||
}`}
|
||||
></div>
|
||||
</div>
|
||||
<div className="ml-3 text-gray-300 font-medium">
|
||||
{checked ? "Dark" : "Light"}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
function ToggleSwitch({checked, onChange}) {
|
||||
return (
|
||||
<label className='flex items-center cursor-pointer'>
|
||||
<div className='relative'>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='sr-only'
|
||||
checked={checked}
|
||||
onChange={onChange}
|
||||
/>
|
||||
<div
|
||||
className={`block w-14 h-8 rounded-full ${
|
||||
checked ? 'bg-blue-600' : 'bg-gray-600'
|
||||
} transition-colors duration-300`}></div>
|
||||
<div
|
||||
className={`dot absolute left-1 top-1 bg-white w-6 h-6 rounded-full transition-transform duration-300 ${
|
||||
checked ? 'transform translate-x-6' : ''
|
||||
}`}></div>
|
||||
</div>
|
||||
<div className='ml-3 text-gray-300 font-medium'>
|
||||
{checked ? 'Dark' : 'Light'}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
ToggleSwitch.propTypes = {
|
||||
checked: PropTypes.bool.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
checked: PropTypes.bool.isRequired,
|
||||
onChange: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
function Navbar({ darkMode, setDarkMode }) {
|
||||
const [tabOffset, setTabOffset] = useState(0);
|
||||
const [tabWidth, setTabWidth] = useState(0);
|
||||
const tabsRef = useRef({});
|
||||
const location = useLocation();
|
||||
function Navbar({darkMode, setDarkMode}) {
|
||||
const [tabOffset, setTabOffset] = useState(0);
|
||||
const [tabWidth, setTabWidth] = useState(0);
|
||||
const tabsRef = useRef({});
|
||||
const location = useLocation();
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
|
||||
const getActiveTab = (pathname) => {
|
||||
if (pathname.startsWith("/regex")) return "regex";
|
||||
if (pathname.startsWith("/format")) return "format";
|
||||
if (pathname.startsWith("/profile")) return "profile";
|
||||
if (pathname.startsWith("/settings")) return "settings";
|
||||
return "settings"; // default to settings if no match
|
||||
};
|
||||
const getActiveTab = pathname => {
|
||||
if (pathname === '/' || pathname === '') return 'settings';
|
||||
if (pathname.startsWith('/regex')) return 'regex';
|
||||
if (pathname.startsWith('/format')) return 'format';
|
||||
if (pathname.startsWith('/profile')) return 'profile';
|
||||
if (pathname.startsWith('/settings')) return 'settings';
|
||||
return 'settings';
|
||||
};
|
||||
|
||||
const activeTab = getActiveTab(location.pathname);
|
||||
const activeTab = getActiveTab(location.pathname);
|
||||
|
||||
useEffect(() => {
|
||||
if (tabsRef.current[activeTab]) {
|
||||
const tab = tabsRef.current[activeTab];
|
||||
setTabOffset(tab.offsetLeft);
|
||||
setTabWidth(tab.offsetWidth);
|
||||
}
|
||||
}, [activeTab]);
|
||||
const updateTabPosition = () => {
|
||||
if (tabsRef.current[activeTab]) {
|
||||
const tab = tabsRef.current[activeTab];
|
||||
setTabOffset(tab.offsetLeft);
|
||||
setTabWidth(tab.offsetWidth);
|
||||
if (!isInitialized) {
|
||||
setIsInitialized(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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="flex items-center justify-between h-16">
|
||||
<div className="flex items-center space-x-8">
|
||||
<h1 className="text-2xl font-bold text-white">Profilarr</h1>
|
||||
<div className="relative flex space-x-2">
|
||||
<div
|
||||
className="absolute top-0 bottom-0 bg-gray-900 rounded-md transition-all duration-300"
|
||||
style={{ left: tabOffset, width: tabWidth }}
|
||||
></div>
|
||||
<Link
|
||||
to="/regex"
|
||||
ref={(el) => (tabsRef.current["regex"] = el)}
|
||||
className={`px-3 py-2 rounded-md text-sm font-medium relative z-10 ${
|
||||
activeTab === "regex"
|
||||
? "text-white"
|
||||
: "text-gray-300 hover:bg-gray-700 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
Regex Patterns
|
||||
</Link>
|
||||
<Link
|
||||
to="/format"
|
||||
ref={(el) => (tabsRef.current["format"] = el)}
|
||||
className={`px-3 py-2 rounded-md text-sm font-medium relative z-10 ${
|
||||
activeTab === "format"
|
||||
? "text-white"
|
||||
: "text-gray-300 hover:bg-gray-700 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
Custom Formats
|
||||
</Link>
|
||||
<Link
|
||||
to="/profile"
|
||||
ref={(el) => (tabsRef.current["profile"] = el)}
|
||||
className={`px-3 py-2 rounded-md text-sm font-medium relative z-10 ${
|
||||
activeTab === "profile"
|
||||
? "text-white"
|
||||
: "text-gray-300 hover:bg-gray-700 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
Quality Profiles
|
||||
</Link>
|
||||
<Link
|
||||
to="/settings"
|
||||
ref={(el) => (tabsRef.current["settings"] = el)}
|
||||
className={`px-3 py-2 rounded-md text-sm font-medium relative z-10 ${
|
||||
activeTab === "settings"
|
||||
? "text-white"
|
||||
: "text-gray-300 hover:bg-gray-700 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
Settings
|
||||
</Link>
|
||||
// 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]);
|
||||
|
||||
return (
|
||||
<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='flex items-center justify-between h-16'>
|
||||
<div className='flex items-center space-x-8'>
|
||||
<h1 className='text-2xl font-bold text-white'>
|
||||
Profilarr
|
||||
</h1>
|
||||
<div className='relative flex space-x-2'>
|
||||
{isInitialized && (
|
||||
<div
|
||||
className='absolute top-0 bottom-0 bg-gray-900 rounded-md transition-all duration-300'
|
||||
style={{
|
||||
left: `${tabOffset}px`,
|
||||
width: `${tabWidth}px`
|
||||
}}></div>
|
||||
)}
|
||||
<Link
|
||||
to='/regex'
|
||||
ref={el => (tabsRef.current['regex'] = el)}
|
||||
className={`px-3 py-2 rounded-md text-sm font-medium relative z-10 ${
|
||||
activeTab === 'regex'
|
||||
? 'text-white'
|
||||
: 'text-gray-300 hover:bg-gray-700 hover:text-white'
|
||||
}`}>
|
||||
Regex Patterns
|
||||
</Link>
|
||||
<Link
|
||||
to='/format'
|
||||
ref={el => (tabsRef.current['format'] = el)}
|
||||
className={`px-3 py-2 rounded-md text-sm font-medium relative z-10 ${
|
||||
activeTab === 'format'
|
||||
? 'text-white'
|
||||
: 'text-gray-300 hover:bg-gray-700 hover:text-white'
|
||||
}`}>
|
||||
Custom Formats
|
||||
</Link>
|
||||
<Link
|
||||
to='/profile'
|
||||
ref={el => (tabsRef.current['profile'] = el)}
|
||||
className={`px-3 py-2 rounded-md text-sm font-medium relative z-10 ${
|
||||
activeTab === 'profile'
|
||||
? 'text-white'
|
||||
: 'text-gray-300 hover:bg-gray-700 hover:text-white'
|
||||
}`}>
|
||||
Quality Profiles
|
||||
</Link>
|
||||
<Link
|
||||
to='/settings'
|
||||
ref={el => (tabsRef.current['settings'] = el)}
|
||||
className={`px-3 py-2 rounded-md text-sm font-medium relative z-10 ${
|
||||
activeTab === 'settings'
|
||||
? 'text-white'
|
||||
: 'text-gray-300 hover:bg-gray-700 hover:text-white'
|
||||
}`}>
|
||||
Settings
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
checked={darkMode}
|
||||
onChange={() => setDarkMode(!darkMode)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
checked={darkMode}
|
||||
onChange={() => setDarkMode(!darkMode)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
Navbar.propTypes = {
|
||||
darkMode: PropTypes.bool.isRequired,
|
||||
setDarkMode: PropTypes.func.isRequired,
|
||||
darkMode: PropTypes.bool.isRequired,
|
||||
setDarkMode: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default Navbar;
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import React from 'react';
|
||||
|
||||
const Tooltip = ({ content, children }) => {
|
||||
return (
|
||||
<div className="relative flex items-center group">
|
||||
{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="bg-gray-900 text-white text-xs rounded py-1 px-2 shadow-lg whitespace-nowrap z-50">
|
||||
{content}
|
||||
const Tooltip = ({content, children}) => {
|
||||
return (
|
||||
<div className='relative group'>
|
||||
{children}
|
||||
<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'>
|
||||
{content}
|
||||
</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 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>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
export default Tooltip;
|
||||
export default Tooltip;
|
||||
|
||||
@@ -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=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=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=Schibsted+Grotesk:wght@400;700&display=swap');
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
html {
|
||||
font-family: "Schibsted Grotesk", sans-serif;
|
||||
font-family: 'Schibsted Grotesk', sans-serif;
|
||||
}
|
||||
|
||||
code,
|
||||
pre,
|
||||
.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 */
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user