mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-01-22 10:51:02 +01: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
|
# Environment variables
|
||||||
.env
|
.env
|
||||||
|
.env.1
|
||||||
|
.env.2
|
||||||
|
|
||||||
# OS files
|
# OS files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
# app/__init__.py
|
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from flask import Flask, jsonify
|
from flask import Flask, jsonify
|
||||||
from flask_cors import CORS
|
from flask_cors import CORS
|
||||||
@@ -12,6 +10,8 @@ from .settings_utils import create_empty_settings_if_not_exists, load_settings
|
|||||||
REGEX_DIR = os.path.join('data', 'db', 'regex_patterns')
|
REGEX_DIR = os.path.join('data', 'db', 'regex_patterns')
|
||||||
FORMAT_DIR = os.path.join('data', 'db', 'custom_formats')
|
FORMAT_DIR = os.path.join('data', 'db', 'custom_formats')
|
||||||
PROFILE_DIR = os.path.join('data', 'db', 'profiles')
|
PROFILE_DIR = os.path.join('data', 'db', 'profiles')
|
||||||
|
DATA_DIR = '/app/data'
|
||||||
|
|
||||||
|
|
||||||
def create_app():
|
def create_app():
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
@@ -35,7 +35,9 @@ def create_app():
|
|||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
def initialize_directories():
|
def initialize_directories():
|
||||||
os.makedirs(REGEX_DIR, exist_ok=True)
|
os.makedirs(REGEX_DIR, exist_ok=True)
|
||||||
os.makedirs(FORMAT_DIR, exist_ok=True)
|
os.makedirs(FORMAT_DIR, exist_ok=True)
|
||||||
os.makedirs(PROFILE_DIR, exist_ok=True)
|
os.makedirs(PROFILE_DIR, exist_ok=True)
|
||||||
|
os.makedirs(DATA_DIR, exist_ok=True)
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
from flask import Blueprint, request, jsonify
|
from flask import Blueprint, request, jsonify
|
||||||
from .status.status import get_git_status
|
from .status.status import get_git_status
|
||||||
from .status.diff import get_diff
|
|
||||||
from .branches.manager import Branch_Manager
|
from .branches.manager import Branch_Manager
|
||||||
from .operations.manager import GitOperations
|
from .operations.manager import GitOperations
|
||||||
from .repo.unlink import unlink_repository
|
from .repo.unlink import unlink_repository
|
||||||
@@ -74,6 +73,8 @@ def create_branch():
|
|||||||
return jsonify({'success': True, **result}), 200
|
return jsonify({'success': True, **result}), 200
|
||||||
else:
|
else:
|
||||||
logger.error(f"Failed to create branch: {result}")
|
logger.error(f"Failed to create branch: {result}")
|
||||||
|
if 'merging' in result.get('error', '').lower():
|
||||||
|
return jsonify({'success': False, 'error': result}), 409
|
||||||
return jsonify({'success': False, 'error': result}), 400
|
return jsonify({'success': False, 'error': result}), 400
|
||||||
|
|
||||||
|
|
||||||
@@ -99,6 +100,8 @@ def checkout_branch():
|
|||||||
return jsonify({'success': True, **result}), 200
|
return jsonify({'success': True, **result}), 200
|
||||||
else:
|
else:
|
||||||
logger.error(f"Failed to checkout branch: {result}")
|
logger.error(f"Failed to checkout branch: {result}")
|
||||||
|
if 'merging' in result.get('error', '').lower():
|
||||||
|
return jsonify({'success': False, 'error': result}), 409
|
||||||
return jsonify({'success': False, 'error': result}), 400
|
return jsonify({'success': False, 'error': result}), 400
|
||||||
|
|
||||||
|
|
||||||
@@ -111,6 +114,8 @@ def delete_branch(branch_name):
|
|||||||
return jsonify({'success': True, **result}), 200
|
return jsonify({'success': True, **result}), 200
|
||||||
else:
|
else:
|
||||||
logger.error(f"Failed to delete branch: {result}")
|
logger.error(f"Failed to delete branch: {result}")
|
||||||
|
if 'merging' in result.get('error', '').lower():
|
||||||
|
return jsonify({'success': False, 'error': result}), 409
|
||||||
return jsonify({'success': False, 'error': result}), 400
|
return jsonify({'success': False, 'error': result}), 400
|
||||||
|
|
||||||
|
|
||||||
@@ -129,23 +134,43 @@ def push_branch():
|
|||||||
if success:
|
if success:
|
||||||
return jsonify({"success": True, "data": result}), 200
|
return jsonify({"success": True, "data": result}), 200
|
||||||
else:
|
else:
|
||||||
return jsonify({"success": False, "error": result["error"]}), 500
|
if 'merging' in result.get('error', '').lower():
|
||||||
|
return jsonify({'success': False, 'error': result}), 409
|
||||||
|
return jsonify({'success': False, 'error': result["error"]}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/commit', methods=['POST'])
|
||||||
|
def commit_files():
|
||||||
|
files = request.json.get('files', [])
|
||||||
|
user_commit_message = request.json.get('commit_message', "Commit changes")
|
||||||
|
logger.debug(f"Received request to commit files: {files}")
|
||||||
|
|
||||||
|
commit_message = generate_commit_message(user_commit_message, files)
|
||||||
|
success, message = git_operations.commit(files, commit_message)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
logger.debug("Successfully committed files")
|
||||||
|
return jsonify({'success': True, 'message': message}), 200
|
||||||
|
else:
|
||||||
|
logger.error(f"Error committing files: {message}")
|
||||||
|
return jsonify({'success': False, 'error': message}), 400
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/push', methods=['POST'])
|
@bp.route('/push', methods=['POST'])
|
||||||
def push_files():
|
def push_files():
|
||||||
files = request.json.get('files', [])
|
logger.debug("Received request to push changes")
|
||||||
user_commit_message = request.json.get('commit_message',
|
success, message = git_operations.push()
|
||||||
"Commit and push staged files")
|
|
||||||
logger.debug(f"Received request to push files: {files}")
|
|
||||||
commit_message = generate_commit_message(user_commit_message, files)
|
|
||||||
success, message = git_operations.push(files, commit_message)
|
|
||||||
if success:
|
if success:
|
||||||
logger.debug("Successfully committed and pushed files")
|
logger.debug("Successfully pushed changes")
|
||||||
return jsonify({'success': True, 'message': message}), 200
|
return jsonify({'success': True, 'message': message}), 200
|
||||||
else:
|
else:
|
||||||
logger.error(f"Error pushing files: {message}")
|
logger.error(f"Error pushing changes: {message}")
|
||||||
|
# If message is a dict, it's a structured error
|
||||||
|
if isinstance(message, dict):
|
||||||
return jsonify({'success': False, 'error': message}), 400
|
return jsonify({'success': False, 'error': message}), 400
|
||||||
|
# Otherwise it's a string error
|
||||||
|
return jsonify({'success': False, 'error': str(message)}), 400
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/revert', methods=['POST'])
|
@bp.route('/revert', methods=['POST'])
|
||||||
@@ -193,27 +218,48 @@ def delete_file():
|
|||||||
@bp.route('/pull', methods=['POST'])
|
@bp.route('/pull', methods=['POST'])
|
||||||
def pull_branch():
|
def pull_branch():
|
||||||
branch_name = request.json.get('branch')
|
branch_name = request.json.get('branch')
|
||||||
success, message = git_operations.pull(branch_name)
|
success, response = git_operations.pull(branch_name)
|
||||||
if success:
|
|
||||||
return jsonify({'success': True, 'message': message}), 200
|
|
||||||
else:
|
|
||||||
logger.error(f"Error pulling branch: {message}")
|
|
||||||
return jsonify({'success': False, 'error': message}), 400
|
|
||||||
|
|
||||||
|
# Handle different response types
|
||||||
@bp.route('/diff', methods=['POST'])
|
if isinstance(response, dict):
|
||||||
def diff_file():
|
if response.get('state') == 'resolve':
|
||||||
file_path = request.json.get('file_path')
|
# Merge conflict is now a success case with state='resolve'
|
||||||
try:
|
return jsonify({
|
||||||
diff = get_diff(REPO_PATH, file_path)
|
'success': True,
|
||||||
logger.debug(f"Diff for file {file_path}: {diff}")
|
'state': 'resolve',
|
||||||
return jsonify({'success': True, 'diff': diff if diff else ""}), 200
|
'message': response['message'],
|
||||||
except Exception as e:
|
'details': response['details']
|
||||||
logger.error(f"Error getting diff for file {file_path}: {str(e)}",
|
}), 200
|
||||||
exc_info=True)
|
elif response.get('state') == 'error':
|
||||||
|
# Handle error states
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'success': False,
|
'success': False,
|
||||||
'error': f"Error getting diff for file: {str(e)}"
|
'state': 'error',
|
||||||
|
'message': response['message'],
|
||||||
|
'details': response.get('details', {})
|
||||||
|
}), 409 if response.get('type') in [
|
||||||
|
'merge_conflict', 'uncommitted_changes'
|
||||||
|
] else 400
|
||||||
|
elif response.get('state') == 'complete':
|
||||||
|
# Normal success case
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'state': 'complete',
|
||||||
|
'message': response['message'],
|
||||||
|
'details': response.get('details', {})
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
# Fallback for string responses or unexpected formats
|
||||||
|
if success:
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'state': 'complete',
|
||||||
|
'message': response
|
||||||
|
}), 200
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'state': 'error',
|
||||||
|
'message': str(response)
|
||||||
}), 400
|
}), 400
|
||||||
|
|
||||||
|
|
||||||
@@ -227,6 +273,16 @@ def handle_stage_files():
|
|||||||
return jsonify({'success': False, 'error': message}), 400
|
return jsonify({'success': False, 'error': message}), 400
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/unstage', methods=['POST'])
|
||||||
|
def handle_unstage_files():
|
||||||
|
files = request.json.get('files', [])
|
||||||
|
success, message = git_operations.unstage(files)
|
||||||
|
if success:
|
||||||
|
return jsonify({'success': True, 'message': message}), 200
|
||||||
|
else:
|
||||||
|
return jsonify({'success': False, 'error': message}), 400
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/unlink', methods=['POST'])
|
@bp.route('/unlink', methods=['POST'])
|
||||||
def unlink():
|
def unlink():
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
@@ -239,20 +295,67 @@ def unlink():
|
|||||||
|
|
||||||
|
|
||||||
def generate_commit_message(user_message, files):
|
def generate_commit_message(user_message, files):
|
||||||
file_changes = []
|
return user_message
|
||||||
for file in files:
|
|
||||||
if 'regex_patterns' in file:
|
|
||||||
file_changes.append(f"Update regex pattern: {file.split('/')[-1]}")
|
|
||||||
elif 'custom_formats' in file:
|
|
||||||
file_changes.append(f"Update custom format: {file.split('/')[-1]}")
|
|
||||||
else:
|
|
||||||
file_changes.append(f"Update: {file}")
|
|
||||||
|
|
||||||
commit_message = f"{user_message}\n\nChanges:\n" + "\n".join(file_changes)
|
|
||||||
return commit_message
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/dev', methods=['GET'])
|
@bp.route('/dev', methods=['GET'])
|
||||||
def dev_mode():
|
def dev_mode():
|
||||||
is_dev_mode = check_dev_mode()
|
is_dev_mode = check_dev_mode()
|
||||||
return jsonify({'devMode': is_dev_mode}), 200
|
return jsonify({'devMode': is_dev_mode}), 200
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/resolve', methods=['POST'])
|
||||||
|
def resolve_conflicts():
|
||||||
|
logger.debug("Received request to resolve conflicts")
|
||||||
|
resolutions = request.json.get('resolutions')
|
||||||
|
|
||||||
|
if not resolutions:
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'error': "Resolutions are required"
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
result = git_operations.resolve(resolutions)
|
||||||
|
|
||||||
|
if result.get('success'):
|
||||||
|
logger.debug("Successfully resolved conflicts")
|
||||||
|
return jsonify(result), 200
|
||||||
|
else:
|
||||||
|
logger.error(f"Error resolving conflicts: {result.get('error')}")
|
||||||
|
return jsonify(result), 400
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/merge/finalize', methods=['POST'])
|
||||||
|
def finalize_merge():
|
||||||
|
"""
|
||||||
|
Route to finalize a merge after all conflicts have been resolved.
|
||||||
|
Expected to be called only after all conflicts are resolved and changes are staged.
|
||||||
|
"""
|
||||||
|
logger.debug("Received request to finalize merge")
|
||||||
|
|
||||||
|
result = git_operations.finalize_merge()
|
||||||
|
|
||||||
|
if result.get('success'):
|
||||||
|
logger.debug(
|
||||||
|
f"Successfully finalized merge with files: {result.get('committed_files', [])}"
|
||||||
|
)
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'message': result.get('message'),
|
||||||
|
'committed_files': result.get('committed_files', [])
|
||||||
|
}), 200
|
||||||
|
else:
|
||||||
|
logger.error(f"Error finalizing merge: {result.get('error')}")
|
||||||
|
return jsonify({'success': False, 'error': result.get('error')}), 400
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/merge/abort', methods=['POST'])
|
||||||
|
def abort_merge():
|
||||||
|
logger.debug("Received request to abort merge")
|
||||||
|
success, message = git_operations.abort_merge()
|
||||||
|
if success:
|
||||||
|
logger.debug("Successfully aborted merge")
|
||||||
|
return jsonify({'success': True, 'message': message}), 200
|
||||||
|
else:
|
||||||
|
logger.error(f"Error aborting merge: {message}")
|
||||||
|
return jsonify({'success': False, 'error': message}), 400
|
||||||
|
|||||||
@@ -1,23 +1,45 @@
|
|||||||
# git/branches/branches.py
|
# git/branches/branches.py
|
||||||
|
|
||||||
import git
|
import git
|
||||||
|
import os
|
||||||
from .create import create_branch
|
from .create import create_branch
|
||||||
from .checkout import checkout_branch
|
from .checkout import checkout_branch
|
||||||
from .delete import delete_branch
|
from .delete import delete_branch
|
||||||
from .get import get_branches, get_current_branch
|
from .get import get_branches, get_current_branch
|
||||||
from .push import push_branch_to_remote
|
from .push import push_branch_to_remote
|
||||||
|
|
||||||
|
|
||||||
class Branch_Manager:
|
class Branch_Manager:
|
||||||
|
|
||||||
def __init__(self, repo_path):
|
def __init__(self, repo_path):
|
||||||
self.repo_path = repo_path
|
self.repo_path = repo_path
|
||||||
|
|
||||||
|
def is_merging(self):
|
||||||
|
repo = git.Repo(self.repo_path)
|
||||||
|
return os.path.exists(os.path.join(repo.git_dir, 'MERGE_HEAD'))
|
||||||
|
|
||||||
def create(self, branch_name, base_branch='main'):
|
def create(self, branch_name, base_branch='main'):
|
||||||
|
if self.is_merging():
|
||||||
|
return False, {
|
||||||
|
'error':
|
||||||
|
'Cannot create branch while merging. Resolve conflicts first.'
|
||||||
|
}
|
||||||
return create_branch(self.repo_path, branch_name, base_branch)
|
return create_branch(self.repo_path, branch_name, base_branch)
|
||||||
|
|
||||||
def checkout(self, branch_name):
|
def checkout(self, branch_name):
|
||||||
|
if self.is_merging():
|
||||||
|
return False, {
|
||||||
|
'error':
|
||||||
|
'Cannot checkout while merging. Resolve conflicts first.'
|
||||||
|
}
|
||||||
return checkout_branch(self.repo_path, branch_name)
|
return checkout_branch(self.repo_path, branch_name)
|
||||||
|
|
||||||
def delete(self, branch_name):
|
def delete(self, branch_name):
|
||||||
|
if self.is_merging():
|
||||||
|
return False, {
|
||||||
|
'error':
|
||||||
|
'Cannot delete branch while merging. Resolve conflicts first.'
|
||||||
|
}
|
||||||
return delete_branch(self.repo_path, branch_name)
|
return delete_branch(self.repo_path, branch_name)
|
||||||
|
|
||||||
def get_all(self):
|
def get_all(self):
|
||||||
@@ -27,4 +49,8 @@ class Branch_Manager:
|
|||||||
return get_current_branch(self.repo_path)
|
return get_current_branch(self.repo_path)
|
||||||
|
|
||||||
def push(self, branch_name):
|
def push(self, branch_name):
|
||||||
|
if self.is_merging():
|
||||||
|
return False, {
|
||||||
|
'error': 'Cannot push while merging. Resolve conflicts first.'
|
||||||
|
}
|
||||||
return push_branch_to_remote(self.repo_path, branch_name)
|
return push_branch_to_remote(self.repo_path, branch_name)
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
# git/operations/commit.py
|
# git/operations/commit.py
|
||||||
|
|
||||||
import git
|
import git
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def commit_changes(repo_path, files, message):
|
def commit_changes(repo_path, files, message):
|
||||||
try:
|
try:
|
||||||
repo = git.Repo(repo_path)
|
repo = git.Repo(repo_path)
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
# git/operations/operations.py
|
|
||||||
|
|
||||||
import git
|
import git
|
||||||
from .stage import stage_files
|
from .stage import stage_files
|
||||||
from .commit import commit_changes
|
from .commit import commit_changes
|
||||||
@@ -7,19 +5,50 @@ from .push import push_changes
|
|||||||
from .revert import revert_file, revert_all
|
from .revert import revert_file, revert_all
|
||||||
from .delete import delete_file
|
from .delete import delete_file
|
||||||
from .pull import pull_branch
|
from .pull import pull_branch
|
||||||
|
from .unstage import unstage_files
|
||||||
|
from .merge import abort_merge, finalize_merge
|
||||||
|
from .resolve import resolve_conflicts
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class GitOperations:
|
class GitOperations:
|
||||||
|
|
||||||
def __init__(self, repo_path):
|
def __init__(self, repo_path):
|
||||||
self.repo_path = repo_path
|
self.repo_path = repo_path
|
||||||
|
self.configure_git()
|
||||||
|
|
||||||
|
def configure_git(self):
|
||||||
|
try:
|
||||||
|
repo = git.Repo(self.repo_path)
|
||||||
|
# Get user info from env variables
|
||||||
|
git_name = os.environ.get('GITHUB_USER_NAME')
|
||||||
|
git_email = os.environ.get('GITHUB_USER_EMAIL')
|
||||||
|
|
||||||
|
logger.debug(f"Git config - Name: {git_name}, Email: {git_email}"
|
||||||
|
) # Add this
|
||||||
|
|
||||||
|
if git_name and git_email:
|
||||||
|
with repo.config_writer() as config:
|
||||||
|
config.set_value('user', 'name', git_name)
|
||||||
|
config.set_value('user', 'email', git_email)
|
||||||
|
logger.debug("Git identity configured successfully")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error configuring git user: {str(e)}")
|
||||||
|
|
||||||
def stage(self, files):
|
def stage(self, files):
|
||||||
return stage_files(self.repo_path, files)
|
return stage_files(self.repo_path, files)
|
||||||
|
|
||||||
|
def unstage(self, files):
|
||||||
|
return unstage_files(self.repo_path, files)
|
||||||
|
|
||||||
def commit(self, files, message):
|
def commit(self, files, message):
|
||||||
return commit_changes(self.repo_path, files, message)
|
return commit_changes(self.repo_path, files, message)
|
||||||
|
|
||||||
def push(self, files, message):
|
def push(self):
|
||||||
return push_changes(self.repo_path, files, message)
|
return push_changes(self.repo_path)
|
||||||
|
|
||||||
def revert(self, file_path):
|
def revert(self, file_path):
|
||||||
return revert_file(self.repo_path, file_path)
|
return revert_file(self.repo_path, file_path)
|
||||||
@@ -32,3 +61,14 @@ class GitOperations:
|
|||||||
|
|
||||||
def pull(self, branch_name):
|
def pull(self, branch_name):
|
||||||
return pull_branch(self.repo_path, branch_name)
|
return pull_branch(self.repo_path, branch_name)
|
||||||
|
|
||||||
|
def finalize_merge(self):
|
||||||
|
repo = git.Repo(self.repo_path)
|
||||||
|
return finalize_merge(repo)
|
||||||
|
|
||||||
|
def abort_merge(self):
|
||||||
|
return abort_merge(self.repo_path)
|
||||||
|
|
||||||
|
def resolve(self, resolutions):
|
||||||
|
repo = git.Repo(self.repo_path)
|
||||||
|
return resolve_conflicts(repo, resolutions)
|
||||||
|
|||||||
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 git
|
||||||
import logging
|
import logging
|
||||||
|
from git import GitCommandError
|
||||||
|
from ..status.status import get_git_status
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def pull_branch(repo_path, branch_name):
|
def pull_branch(repo_path, branch_name):
|
||||||
try:
|
try:
|
||||||
repo = git.Repo(repo_path)
|
repo = git.Repo(repo_path)
|
||||||
repo.git.pull('origin', branch_name)
|
|
||||||
return True, f"Successfully pulled changes for branch {branch_name}."
|
# Check for uncommitted changes first
|
||||||
|
if repo.is_dirty(untracked_files=True):
|
||||||
|
return False, {
|
||||||
|
'type': 'uncommitted_changes',
|
||||||
|
'message':
|
||||||
|
'Cannot pull: You have uncommitted local changes that would be lost',
|
||||||
|
'details': 'Please commit or stash your changes before pulling'
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Fetch first to get remote changes
|
||||||
|
repo.remotes.origin.fetch()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Try to pull with explicit merge strategy
|
||||||
|
repo.git.pull('origin', branch_name, '--no-rebase')
|
||||||
|
return True, "Successfully pulled changes for branch {branch_name}"
|
||||||
|
except GitCommandError as e:
|
||||||
|
if "CONFLICT" in str(e):
|
||||||
|
# Don't reset - let Git stay in merge conflict state
|
||||||
|
return True, {
|
||||||
|
'state': 'resolve',
|
||||||
|
'type': 'merge_conflict',
|
||||||
|
'message':
|
||||||
|
'Repository is now in conflict resolution state. Please resolve conflicts to continue merge.',
|
||||||
|
'details': 'Please resolve conflicts to continue merge'
|
||||||
|
}
|
||||||
|
raise e
|
||||||
|
|
||||||
|
except GitCommandError as e:
|
||||||
|
logger.error(f"Git command error pulling branch: {str(e)}",
|
||||||
|
exc_info=True)
|
||||||
|
return False, f"Error pulling branch: {str(e)}"
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error pulling branch: {str(e)}", exc_info=True)
|
logger.error(f"Error pulling branch: {str(e)}", exc_info=True)
|
||||||
return False, f"Error pulling branch: {str(e)}"
|
return False, f"Error pulling branch: {str(e)}"
|
||||||
@@ -1,16 +1,14 @@
|
|||||||
# git/operations/push.py
|
# git/operations/push.py
|
||||||
|
|
||||||
import git
|
import git
|
||||||
import logging
|
import logging
|
||||||
from .commit import commit_changes
|
|
||||||
from ..auth.authenticate import check_dev_mode, get_github_token
|
from ..auth.authenticate import check_dev_mode, get_github_token
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def push_changes(repo_path, files, message):
|
def push_changes(repo_path):
|
||||||
try:
|
try:
|
||||||
# Check if we're in dev mode
|
# Check if we're in dev mode - keep this check for push operations
|
||||||
if not check_dev_mode():
|
if not check_dev_mode():
|
||||||
logger.warning("Not in dev mode. Push operation not allowed.")
|
logger.warning("Not in dev mode. Push operation not allowed.")
|
||||||
return False, "Push operation not allowed in production mode."
|
return False, "Push operation not allowed in production mode."
|
||||||
@@ -22,37 +20,36 @@ def push_changes(repo_path, files, message):
|
|||||||
return False, "GitHub token not available"
|
return False, "GitHub token not available"
|
||||||
|
|
||||||
repo = git.Repo(repo_path)
|
repo = git.Repo(repo_path)
|
||||||
|
|
||||||
# Commit changes
|
|
||||||
commit_success, commit_message = commit_changes(
|
|
||||||
repo_path, files, message)
|
|
||||||
if not commit_success:
|
|
||||||
return False, commit_message
|
|
||||||
|
|
||||||
# Modify the remote URL to include the token
|
|
||||||
origin = repo.remote(name='origin')
|
origin = repo.remote(name='origin')
|
||||||
auth_repo_url = origin.url.replace('https://',
|
auth_repo_url = origin.url.replace('https://',
|
||||||
f'https://{github_token}@')
|
f'https://{github_token}@')
|
||||||
origin.set_url(auth_repo_url)
|
origin.set_url(auth_repo_url)
|
||||||
|
|
||||||
|
try:
|
||||||
# Push changes
|
# Push changes
|
||||||
push_info = origin.push()
|
push_info = origin.push()
|
||||||
|
|
||||||
# Restore the original remote URL (without the token)
|
|
||||||
origin.set_url(
|
|
||||||
origin.url.replace(f'https://{github_token}@', 'https://'))
|
|
||||||
|
|
||||||
# Check if the push was successful
|
|
||||||
if push_info and push_info[0].flags & push_info[0].ERROR:
|
if push_info and push_info[0].flags & push_info[0].ERROR:
|
||||||
raise git.GitCommandError("git push", push_info[0].summary)
|
raise git.GitCommandError("git push", push_info[0].summary)
|
||||||
|
|
||||||
return True, "Successfully pushed changes."
|
return True, "Successfully pushed changes."
|
||||||
|
except git.GitCommandError as e:
|
||||||
|
error_msg = str(e)
|
||||||
|
if "non-fast-forward" in error_msg:
|
||||||
|
return False, {
|
||||||
|
"type":
|
||||||
|
"non_fast_forward",
|
||||||
|
"message":
|
||||||
|
"Push rejected: Remote contains work that you do not have locally. Please pull the latest changes first."
|
||||||
|
}
|
||||||
|
raise e
|
||||||
|
finally:
|
||||||
|
# Always restore the original URL (without token)
|
||||||
|
origin.set_url(
|
||||||
|
origin.url.replace(f'https://{github_token}@', 'https://'))
|
||||||
|
|
||||||
except git.GitCommandError as e:
|
except git.GitCommandError as e:
|
||||||
logger.error(f"Git command error pushing changes: {str(e)}",
|
logger.error(f"Git command error pushing changes: {str(e)}",
|
||||||
exc_info=True)
|
exc_info=True)
|
||||||
return False, f"Error pushing changes: {str(e)}"
|
return False, str(e)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error pushing changes: {str(e)}", exc_info=True)
|
logger.error(f"Error pushing changes: {str(e)}", exc_info=True)
|
||||||
return False, f"Error pushing changes: {str(e)}"
|
return False, f"Error pushing changes: {str(e)}"
|
||||||
|
|||||||
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,30 +1,14 @@
|
|||||||
# git/operations/stage.py
|
# git/operations/stage.py
|
||||||
|
|
||||||
import git
|
import git
|
||||||
import logging
|
import logging
|
||||||
from ..auth.authenticate import check_dev_mode, get_github_token
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def stage_files(repo_path, files):
|
def stage_files(repo_path, files):
|
||||||
try:
|
try:
|
||||||
# Check if we're in dev mode
|
|
||||||
if not check_dev_mode():
|
|
||||||
logger.warning("Not in dev mode. Staging operation not allowed.")
|
|
||||||
return False, "Staging operation not allowed in production mode."
|
|
||||||
|
|
||||||
# Get the GitHub token
|
|
||||||
github_token = get_github_token()
|
|
||||||
if not github_token:
|
|
||||||
logger.error("GitHub token not available")
|
|
||||||
return False, "GitHub token not available"
|
|
||||||
|
|
||||||
repo = git.Repo(repo_path)
|
repo = git.Repo(repo_path)
|
||||||
|
|
||||||
# Authenticate with GitHub token
|
|
||||||
with repo.git.custom_environment(GIT_ASKPASS='echo',
|
|
||||||
GIT_USERNAME=github_token):
|
|
||||||
if not files:
|
if not files:
|
||||||
repo.git.add(A=True)
|
repo.git.add(A=True)
|
||||||
message = "All changes have been staged."
|
message = "All changes have been staged."
|
||||||
@@ -38,7 +22,6 @@ def stage_files(repo_path, files):
|
|||||||
logger.error(f"Git command error staging files: {str(e)}",
|
logger.error(f"Git command error staging files: {str(e)}",
|
||||||
exc_info=True)
|
exc_info=True)
|
||||||
return False, f"Error staging files: {str(e)}"
|
return False, f"Error staging files: {str(e)}"
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error staging files: {str(e)}", exc_info=True)
|
logger.error(f"Error staging files: {str(e)}", exc_info=True)
|
||||||
return False, f"Error staging files: {str(e)}"
|
return False, f"Error staging files: {str(e)}"
|
||||||
|
|||||||
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
|
# git/status/incoming_changes.py
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
from .utils import extract_data_from_yaml, determine_type, parse_commit_message
|
import yaml
|
||||||
|
from git import GitCommandError
|
||||||
|
from .utils import determine_type, parse_commit_message, extract_data_from_yaml
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def check_merge_conflict(repo, branch, file_path):
|
||||||
|
"""
|
||||||
|
Checks if an incoming change will conflict with local changes.
|
||||||
|
Returns True if there would be a merge conflict, False otherwise.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Check for both uncommitted and committed changes
|
||||||
|
has_changes = False
|
||||||
|
|
||||||
|
# 1. Check uncommitted changes
|
||||||
|
status = repo.git.status('--porcelain', file_path).strip()
|
||||||
|
if status:
|
||||||
|
status_code = status[:2] if len(status) >= 2 else ''
|
||||||
|
has_changes = 'M' in status_code or 'A' in status_code or 'D' in status_code
|
||||||
|
|
||||||
|
# 2. Check committed changes not in remote
|
||||||
|
try:
|
||||||
|
# Get the merge-base (common ancestor) of local and remote
|
||||||
|
merge_base = repo.git.merge_base('HEAD',
|
||||||
|
f'origin/{branch}').strip()
|
||||||
|
|
||||||
|
# Check if there are any commits affecting this file between merge-base and HEAD
|
||||||
|
committed_changes = repo.git.log(f'{merge_base}..HEAD',
|
||||||
|
'--',
|
||||||
|
file_path,
|
||||||
|
ignore_missing=True).strip()
|
||||||
|
has_changes = has_changes or bool(committed_changes)
|
||||||
|
except GitCommandError as e:
|
||||||
|
logger.warning(f"Error checking committed changes: {str(e)}")
|
||||||
|
|
||||||
|
if has_changes:
|
||||||
|
try:
|
||||||
|
# Use correct merge-tree syntax
|
||||||
|
merge_test = repo.git.merge_tree('--write-tree', 'HEAD',
|
||||||
|
f'origin/{branch}')
|
||||||
|
|
||||||
|
# Check if this specific file has conflicts in the merge result
|
||||||
|
return any(
|
||||||
|
line.startswith('<<<<<<< ')
|
||||||
|
for line in merge_test.splitlines() if file_path in line)
|
||||||
|
except GitCommandError as e:
|
||||||
|
logger.warning(
|
||||||
|
f"Merge tree test failed, assuming conflict: {str(e)}")
|
||||||
|
return True # If merge-tree fails, assume there's a conflict
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Error checking merge conflict for {file_path}: {str(e)}")
|
||||||
|
return False # Default to no conflict if we can't determine
|
||||||
|
|
||||||
|
|
||||||
|
def get_file_data(repo, file_path, ref):
|
||||||
|
try:
|
||||||
|
content = repo.git.show(f'{ref}:{file_path}')
|
||||||
|
return yaml.safe_load(content)
|
||||||
|
except GitCommandError:
|
||||||
|
logger.warning(
|
||||||
|
f"Failed to retrieve content for file: {file_path} at {ref}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def get_incoming_changes(repo, branch):
|
def get_incoming_changes(repo, branch):
|
||||||
incoming_changes = []
|
incoming_changes = []
|
||||||
diff = repo.git.diff(f'HEAD...origin/{branch}', name_only=True)
|
|
||||||
changed_files = diff.split('\n') if diff else []
|
try:
|
||||||
|
# Get changed files between local and remote
|
||||||
|
diff_index = repo.git.diff(f'HEAD...origin/{branch}',
|
||||||
|
'--name-only').split('\n')
|
||||||
|
untracked = repo.git.ls_files('--others',
|
||||||
|
'--exclude-standard').split('\n')
|
||||||
|
changed_files = list(filter(None, set(diff_index + untracked)))
|
||||||
|
except GitCommandError as e:
|
||||||
|
logger.error(f"Error getting changed files: {str(e)}")
|
||||||
|
return []
|
||||||
|
|
||||||
for file_path in changed_files:
|
for file_path in changed_files:
|
||||||
if file_path:
|
if not file_path:
|
||||||
full_path = os.path.join(repo.working_dir, file_path)
|
continue
|
||||||
file_data = extract_data_from_yaml(full_path) if os.path.exists(
|
|
||||||
full_path) else None
|
|
||||||
|
|
||||||
# Correcting the git show command
|
try:
|
||||||
|
# Get both versions of the file
|
||||||
|
local_data = get_file_data(repo, file_path, 'HEAD')
|
||||||
|
remote_data = get_file_data(repo, file_path, f'origin/{branch}')
|
||||||
|
|
||||||
|
if local_data == remote_data:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check for potential merge conflicts
|
||||||
|
will_conflict = check_merge_conflict(repo, branch, file_path)
|
||||||
|
|
||||||
|
# Get commit message
|
||||||
|
try:
|
||||||
raw_commit_message = repo.git.show(f'HEAD...origin/{branch}',
|
raw_commit_message = repo.git.show(f'HEAD...origin/{branch}',
|
||||||
'--format=%B', '-s',
|
'--format=%B', '-s', '--',
|
||||||
file_path).strip()
|
file_path).strip()
|
||||||
parsed_commit_message = parse_commit_message(
|
commit_message = parse_commit_message(raw_commit_message)
|
||||||
raw_commit_message
|
except GitCommandError:
|
||||||
) # Parse commit message using the util function
|
commit_message = {
|
||||||
|
"body": "",
|
||||||
|
"footer": "",
|
||||||
|
"scope": "",
|
||||||
|
"subject": "Unable to retrieve commit message",
|
||||||
|
"type": ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if not local_data and remote_data:
|
||||||
|
status = 'New'
|
||||||
|
local_name = remote_data.get('name')
|
||||||
|
incoming_name = None
|
||||||
|
changes = [{
|
||||||
|
'key': key,
|
||||||
|
'change': 'added',
|
||||||
|
'value': value
|
||||||
|
} for key, value in remote_data.items()]
|
||||||
|
else:
|
||||||
|
status = 'Modified'
|
||||||
|
local_name = local_data.get(
|
||||||
|
'name') if local_data else os.path.basename(file_path)
|
||||||
|
incoming_name = remote_data.get(
|
||||||
|
'name') if remote_data else None
|
||||||
|
changes = compare_data(local_data, remote_data)
|
||||||
|
|
||||||
|
if not changes:
|
||||||
|
continue
|
||||||
|
|
||||||
|
file_type = determine_type(file_path)
|
||||||
|
file_id = remote_data.get('id') if remote_data else None
|
||||||
|
|
||||||
incoming_changes.append({
|
incoming_changes.append({
|
||||||
'name':
|
'commit_message': commit_message,
|
||||||
file_data.get('name', os.path.basename(file_path))
|
'deleted': False,
|
||||||
if file_data else os.path.basename(file_path),
|
'file_path': file_path,
|
||||||
'id':
|
'id': file_id,
|
||||||
file_data.get('id') if file_data else None,
|
'modified': True,
|
||||||
'type':
|
'local_name': local_name,
|
||||||
determine_type(file_path),
|
'incoming_name': incoming_name,
|
||||||
'status':
|
'staged': False,
|
||||||
'Incoming',
|
'status': status,
|
||||||
'file_path':
|
'type': file_type,
|
||||||
file_path,
|
'changes': changes,
|
||||||
'commit_message':
|
'will_conflict':
|
||||||
parsed_commit_message, # Use parsed commit message
|
will_conflict # Added conflict status per file
|
||||||
'staged':
|
|
||||||
False,
|
|
||||||
'modified':
|
|
||||||
True,
|
|
||||||
'deleted':
|
|
||||||
False
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Error processing incoming change for {file_path}: {str(e)}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
logger.info(f"Found {len(incoming_changes)} incoming changes")
|
||||||
return incoming_changes
|
return incoming_changes
|
||||||
|
|
||||||
|
|
||||||
|
def compare_data(local_data, remote_data):
|
||||||
|
if local_data is None and remote_data is not None:
|
||||||
|
# File is entirely new
|
||||||
|
return [{'key': 'file', 'change': 'added'}]
|
||||||
|
|
||||||
|
if local_data is not None and remote_data is None:
|
||||||
|
# File has been deleted
|
||||||
|
return [{'key': 'file', 'change': 'deleted'}]
|
||||||
|
|
||||||
|
changes = []
|
||||||
|
all_keys = set(local_data.keys()).union(set(remote_data.keys()))
|
||||||
|
|
||||||
|
for key in all_keys:
|
||||||
|
local_value = local_data.get(key)
|
||||||
|
remote_value = remote_data.get(key)
|
||||||
|
|
||||||
|
if local_value != remote_value:
|
||||||
|
if key == 'tags':
|
||||||
|
changes.extend(compare_tags(local_value, remote_value))
|
||||||
|
elif key == 'custom_formats':
|
||||||
|
changes.extend(
|
||||||
|
compare_custom_formats(local_value, remote_value))
|
||||||
|
else:
|
||||||
|
changes.append({
|
||||||
|
'key': key,
|
||||||
|
'change': 'modified',
|
||||||
|
'from': local_value,
|
||||||
|
'to': remote_value
|
||||||
|
})
|
||||||
|
|
||||||
|
return changes
|
||||||
|
|
||||||
|
|
||||||
|
def compare_tags(local_tags, remote_tags):
|
||||||
|
local_tags = set(local_tags or [])
|
||||||
|
remote_tags = set(remote_tags or [])
|
||||||
|
|
||||||
|
added = remote_tags - local_tags
|
||||||
|
removed = local_tags - remote_tags
|
||||||
|
|
||||||
|
changes = []
|
||||||
|
if added:
|
||||||
|
changes.append({
|
||||||
|
'key': 'tags',
|
||||||
|
'change': 'added',
|
||||||
|
'value': list(added)
|
||||||
|
})
|
||||||
|
if removed:
|
||||||
|
changes.append({
|
||||||
|
'key': 'tags',
|
||||||
|
'change': 'removed',
|
||||||
|
'value': list(removed)
|
||||||
|
})
|
||||||
|
|
||||||
|
return changes
|
||||||
|
|
||||||
|
|
||||||
|
def compare_custom_formats(local_cfs, remote_cfs):
|
||||||
|
local_cfs = {cf['id']: cf for cf in local_cfs or []}
|
||||||
|
remote_cfs = {cf['id']: cf for cf in remote_cfs or []}
|
||||||
|
|
||||||
|
all_ids = set(local_cfs.keys()).union(set(remote_cfs.keys()))
|
||||||
|
changes = []
|
||||||
|
|
||||||
|
for cf_id in all_ids:
|
||||||
|
local_cf = local_cfs.get(cf_id)
|
||||||
|
remote_cf = remote_cfs.get(cf_id)
|
||||||
|
|
||||||
|
if local_cf != remote_cf:
|
||||||
|
if local_cf and remote_cf:
|
||||||
|
if local_cf['score'] != remote_cf['score']:
|
||||||
|
changes.append({
|
||||||
|
'key': f'custom_format_{cf_id}',
|
||||||
|
'change': 'modified',
|
||||||
|
'from': local_cf['score'],
|
||||||
|
'to': remote_cf['score']
|
||||||
|
})
|
||||||
|
elif local_cf and not remote_cf:
|
||||||
|
changes.append({
|
||||||
|
'key': f'custom_format_{cf_id}',
|
||||||
|
'change': 'removed',
|
||||||
|
'value': local_cf['score']
|
||||||
|
})
|
||||||
|
elif not local_cf and remote_cf:
|
||||||
|
changes.append({
|
||||||
|
'key': f'custom_format_{cf_id}',
|
||||||
|
'change': 'added',
|
||||||
|
'value': remote_cf['score']
|
||||||
|
})
|
||||||
|
|
||||||
|
return changes
|
||||||
|
|||||||
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
|
# git/status/outgoing_changes.py
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import json
|
|
||||||
import yaml
|
import yaml
|
||||||
import logging
|
import logging
|
||||||
from .utils import extract_data_from_yaml, determine_type, interpret_git_status
|
from git import GitCommandError
|
||||||
|
from .utils import determine_type, parse_commit_message
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def get_outgoing_changes(repo):
|
def get_outgoing_changes(repo):
|
||||||
status = repo.git.status('--porcelain', '-z').split('\0')
|
status = repo.git.status('--porcelain', '-z').split('\0')
|
||||||
logger.debug(f"Raw porcelain status: {status}")
|
logger.debug(f"Raw porcelain status: {status}")
|
||||||
@@ -26,81 +27,213 @@ def get_outgoing_changes(repo):
|
|||||||
x, y, file_path = item[0], item[1], item[3:]
|
x, y, file_path = item[0], item[1], item[3:]
|
||||||
logger.debug(f"Parsed status: x={x}, y={y}, file_path={file_path}")
|
logger.debug(f"Parsed status: x={x}, y={y}, file_path={file_path}")
|
||||||
|
|
||||||
|
# Skip files in conflict state
|
||||||
|
if x == 'U' or y == 'U':
|
||||||
|
continue
|
||||||
|
|
||||||
is_staged = x != ' ' and x != '?'
|
is_staged = x != ' ' and x != '?'
|
||||||
is_deleted = x == 'D' or y == 'D'
|
is_deleted = x == 'D' or y == 'D'
|
||||||
|
|
||||||
full_path = os.path.join(repo.working_dir, file_path)
|
|
||||||
|
|
||||||
if is_deleted:
|
if is_deleted:
|
||||||
|
changes.append(process_deleted_file(repo, file_path, is_staged))
|
||||||
|
else:
|
||||||
|
changes.append(
|
||||||
|
process_modified_file(repo, file_path, x, y, is_staged))
|
||||||
|
|
||||||
|
logger.debug(f"Final changes: {changes}")
|
||||||
|
return changes
|
||||||
|
|
||||||
|
|
||||||
|
def process_deleted_file(repo, file_path, is_staged):
|
||||||
try:
|
try:
|
||||||
# Get the content of the file from the last commit
|
|
||||||
file_content = repo.git.show(f'HEAD:{file_path}')
|
file_content = repo.git.show(f'HEAD:{file_path}')
|
||||||
yaml_content = yaml.safe_load(file_content)
|
yaml_content = yaml.safe_load(file_content)
|
||||||
original_name = yaml_content.get('name', 'Unknown')
|
original_name = yaml_content.get('name', 'Unknown')
|
||||||
original_id = yaml_content.get('id', '')
|
original_id = yaml_content.get('id', '')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not retrieve original name for deleted file {file_path}: {str(e)}")
|
logger.warning(
|
||||||
|
f"Could not retrieve original content for deleted file {file_path}: {str(e)}"
|
||||||
|
)
|
||||||
original_name = "Unknown"
|
original_name = "Unknown"
|
||||||
original_id = ""
|
original_id = ""
|
||||||
|
|
||||||
changes.append({
|
return {
|
||||||
'name': original_name,
|
'name': original_name,
|
||||||
|
'prior_name': original_name,
|
||||||
|
'outgoing_name': None,
|
||||||
'id': original_id,
|
'id': original_id,
|
||||||
'type': determine_type(file_path),
|
'type': determine_type(file_path),
|
||||||
'status': 'Deleted',
|
'status': 'Deleted',
|
||||||
'file_path': file_path,
|
'file_path': file_path,
|
||||||
'staged': is_staged,
|
'staged': is_staged,
|
||||||
'modified': False,
|
'modified': False,
|
||||||
'deleted': True
|
'deleted': True,
|
||||||
})
|
'changes': [{
|
||||||
elif os.path.isdir(full_path):
|
'key': 'file',
|
||||||
logger.debug(f"Found directory: {file_path}, going through folder.")
|
'change': 'deleted'
|
||||||
for root, dirs, files in os.walk(full_path):
|
}]
|
||||||
for file in files:
|
}
|
||||||
if file.endswith('.yml') or file.endswith('.yaml'):
|
|
||||||
file_full_path = os.path.join(root, file)
|
|
||||||
logger.debug(f"Found file: {file_full_path}, going through file.")
|
def process_modified_file(repo, file_path, x, y, is_staged):
|
||||||
file_data = extract_data_from_yaml(file_full_path)
|
try:
|
||||||
if file_data:
|
# Get the content of the file from the last commit
|
||||||
logger.debug(f"File contents: {file_data}")
|
old_content = repo.git.show(f'HEAD:{file_path}')
|
||||||
logger.debug(f"Found ID: {file_data.get('id')}")
|
old_data = yaml.safe_load(old_content)
|
||||||
logger.debug(f"Found Name: {file_data.get('name')}")
|
except GitCommandError:
|
||||||
changes.append({
|
old_data = None
|
||||||
'name': file_data.get('name', ''),
|
|
||||||
'id': file_data.get('id', ''),
|
# Get the current content of the file
|
||||||
|
with open(os.path.join(repo.working_dir, file_path), 'r') as f:
|
||||||
|
new_content = f.read()
|
||||||
|
new_data = yaml.safe_load(new_content)
|
||||||
|
|
||||||
|
detailed_changes = compare_data(old_data, new_data)
|
||||||
|
|
||||||
|
# Determine prior_name and outgoing_name
|
||||||
|
prior_name = old_data.get('name') if old_data else None
|
||||||
|
outgoing_name = new_data.get('name') if new_data else None
|
||||||
|
|
||||||
|
# If there's no name change, set outgoing_name to None
|
||||||
|
if prior_name == outgoing_name:
|
||||||
|
outgoing_name = None
|
||||||
|
|
||||||
|
return {
|
||||||
|
'name': new_data.get('name', os.path.basename(file_path)),
|
||||||
|
'prior_name': prior_name,
|
||||||
|
'outgoing_name': outgoing_name,
|
||||||
|
'id': new_data.get('id', ''),
|
||||||
'type': determine_type(file_path),
|
'type': determine_type(file_path),
|
||||||
'status': interpret_git_status(x, y),
|
'status': 'Modified' if old_data else 'New',
|
||||||
'file_path': os.path.relpath(file_full_path, repo.working_dir),
|
|
||||||
'staged': x != '?' and x != ' ',
|
|
||||||
'modified': y == 'M',
|
|
||||||
'deleted': False
|
|
||||||
})
|
|
||||||
else:
|
|
||||||
logger.debug(f"No data extracted from file: {file_full_path}")
|
|
||||||
else:
|
|
||||||
file_data = extract_data_from_yaml(full_path) if os.path.exists(full_path) else None
|
|
||||||
if file_data:
|
|
||||||
changes.append({
|
|
||||||
'name': file_data.get('name', ''),
|
|
||||||
'id': file_data.get('id', ''),
|
|
||||||
'type': determine_type(file_path),
|
|
||||||
'status': interpret_git_status(x, y),
|
|
||||||
'file_path': file_path,
|
'file_path': file_path,
|
||||||
'staged': is_staged,
|
'staged': is_staged,
|
||||||
'modified': y != ' ',
|
'modified': y != ' ',
|
||||||
'deleted': False
|
'deleted': False,
|
||||||
})
|
'changes': detailed_changes
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def compare_data(old_data, new_data):
|
||||||
|
if old_data is None and new_data is not None:
|
||||||
|
return [{'key': 'file', 'change': 'added'}]
|
||||||
|
|
||||||
|
if old_data is not None and new_data is None:
|
||||||
|
return [{'key': 'file', 'change': 'deleted'}]
|
||||||
|
|
||||||
|
changes = []
|
||||||
|
all_keys = set(old_data.keys()).union(set(new_data.keys()))
|
||||||
|
|
||||||
|
for key in all_keys:
|
||||||
|
old_value = old_data.get(key)
|
||||||
|
new_value = new_data.get(key)
|
||||||
|
|
||||||
|
if old_value != new_value:
|
||||||
|
if key == 'tags':
|
||||||
|
changes.extend(compare_tags(old_value, new_value))
|
||||||
|
elif key == 'custom_formats':
|
||||||
|
changes.extend(compare_custom_formats(old_value, new_value))
|
||||||
|
elif key == 'conditions':
|
||||||
|
changes.extend(compare_conditions(old_value, new_value))
|
||||||
else:
|
else:
|
||||||
changes.append({
|
changes.append({
|
||||||
'name': os.path.basename(file_path).replace('.yml', ''),
|
'key': key,
|
||||||
'id': '',
|
'change': 'modified',
|
||||||
'type': determine_type(file_path),
|
'from': old_value,
|
||||||
'status': interpret_git_status(x, y),
|
'to': new_value
|
||||||
'file_path': file_path,
|
})
|
||||||
'staged': is_staged,
|
|
||||||
'modified': y != ' ',
|
return changes
|
||||||
'deleted': False
|
|
||||||
|
|
||||||
|
def compare_tags(old_tags, new_tags):
|
||||||
|
old_tags = set(old_tags or [])
|
||||||
|
new_tags = set(new_tags or [])
|
||||||
|
|
||||||
|
added = new_tags - old_tags
|
||||||
|
removed = old_tags - new_tags
|
||||||
|
|
||||||
|
changes = []
|
||||||
|
if added:
|
||||||
|
changes.append({
|
||||||
|
'key': 'tags',
|
||||||
|
'change': 'added',
|
||||||
|
'value': list(added)
|
||||||
|
})
|
||||||
|
if removed:
|
||||||
|
changes.append({
|
||||||
|
'key': 'tags',
|
||||||
|
'change': 'removed',
|
||||||
|
'value': list(removed)
|
||||||
|
})
|
||||||
|
|
||||||
|
return changes
|
||||||
|
|
||||||
|
|
||||||
|
def compare_custom_formats(old_cfs, new_cfs):
|
||||||
|
old_cfs = {cf['id']: cf for cf in old_cfs or []}
|
||||||
|
new_cfs = {cf['id']: cf for cf in new_cfs or []}
|
||||||
|
|
||||||
|
all_ids = set(old_cfs.keys()).union(set(new_cfs.keys()))
|
||||||
|
changes = []
|
||||||
|
|
||||||
|
for cf_id in all_ids:
|
||||||
|
old_cf = old_cfs.get(cf_id)
|
||||||
|
new_cf = new_cfs.get(cf_id)
|
||||||
|
|
||||||
|
if old_cf != new_cf:
|
||||||
|
if old_cf and new_cf:
|
||||||
|
if old_cf['score'] != new_cf['score']:
|
||||||
|
changes.append({
|
||||||
|
'key': f'custom_format_{cf_id}',
|
||||||
|
'change': 'modified',
|
||||||
|
'from': old_cf['score'],
|
||||||
|
'to': new_cf['score']
|
||||||
|
})
|
||||||
|
elif old_cf and not new_cf:
|
||||||
|
changes.append({
|
||||||
|
'key': f'custom_format_{cf_id}',
|
||||||
|
'change': 'removed',
|
||||||
|
'value': old_cf['score']
|
||||||
|
})
|
||||||
|
elif not old_cf and new_cf:
|
||||||
|
changes.append({
|
||||||
|
'key': f'custom_format_{cf_id}',
|
||||||
|
'change': 'added',
|
||||||
|
'value': new_cf['score']
|
||||||
|
})
|
||||||
|
|
||||||
|
return changes
|
||||||
|
|
||||||
|
|
||||||
|
def compare_conditions(old_conditions, new_conditions):
|
||||||
|
changes = []
|
||||||
|
old_conditions = old_conditions or []
|
||||||
|
new_conditions = new_conditions or []
|
||||||
|
|
||||||
|
# Check for removed or modified conditions
|
||||||
|
for i, old_cond in enumerate(old_conditions):
|
||||||
|
if i >= len(new_conditions):
|
||||||
|
changes.append({
|
||||||
|
'key': f'conditions[{i}]',
|
||||||
|
'change': 'removed',
|
||||||
|
'value': old_cond
|
||||||
|
})
|
||||||
|
elif old_cond != new_conditions[i]:
|
||||||
|
for key in old_cond.keys():
|
||||||
|
if old_cond.get(key) != new_conditions[i].get(key):
|
||||||
|
changes.append({
|
||||||
|
'key': f'conditions[{i}].{key}',
|
||||||
|
'change': 'modified',
|
||||||
|
'from': old_cond.get(key),
|
||||||
|
'to': new_conditions[i].get(key)
|
||||||
|
})
|
||||||
|
|
||||||
|
# Check for added conditions
|
||||||
|
for i in range(len(old_conditions), len(new_conditions)):
|
||||||
|
changes.append({
|
||||||
|
'key': f'conditions[{i}]',
|
||||||
|
'change': 'added',
|
||||||
|
'value': new_conditions[i]
|
||||||
})
|
})
|
||||||
|
|
||||||
logger.debug(f"Final changes: {json.dumps(changes, indent=2)}")
|
|
||||||
return changes
|
return changes
|
||||||
@@ -1,44 +1,95 @@
|
|||||||
# git/status/status.py
|
# git/status/status.py
|
||||||
|
|
||||||
import git
|
import git
|
||||||
from git.exc import GitCommandError, InvalidGitRepositoryError
|
from git.exc import GitCommandError, InvalidGitRepositoryError
|
||||||
import logging
|
import logging
|
||||||
import json
|
|
||||||
from .incoming_changes import get_incoming_changes
|
from .incoming_changes import get_incoming_changes
|
||||||
from .outgoing_changes import get_outgoing_changes
|
from .outgoing_changes import get_outgoing_changes
|
||||||
|
from .merge_conflicts import get_merge_conflicts
|
||||||
|
from .utils import determine_type
|
||||||
|
import os
|
||||||
|
import yaml
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def get_commits_ahead(repo, branch):
|
def get_commits_ahead(repo, branch):
|
||||||
return list(repo.iter_commits(f'origin/{branch}..{branch}'))
|
return list(repo.iter_commits(f'origin/{branch}..{branch}'))
|
||||||
|
|
||||||
|
|
||||||
def get_commits_behind(repo, branch):
|
def get_commits_behind(repo, branch):
|
||||||
return list(repo.iter_commits(f'{branch}..origin/{branch}'))
|
return list(repo.iter_commits(f'{branch}..origin/{branch}'))
|
||||||
|
|
||||||
|
|
||||||
|
def get_unpushed_changes(repo, branch):
|
||||||
|
"""Get detailed info about files modified in unpushed commits"""
|
||||||
|
try:
|
||||||
|
# Get the file paths
|
||||||
|
unpushed_files = repo.git.diff(f'origin/{branch}..{branch}',
|
||||||
|
'--name-only').split('\n')
|
||||||
|
unpushed_files = [f for f in unpushed_files if f]
|
||||||
|
|
||||||
|
detailed_changes = []
|
||||||
|
for file_path in unpushed_files:
|
||||||
|
try:
|
||||||
|
# Get the current content of the file to extract name
|
||||||
|
with open(os.path.join(repo.working_dir, file_path), 'r') as f:
|
||||||
|
content = yaml.safe_load(f.read())
|
||||||
|
|
||||||
|
detailed_changes.append({
|
||||||
|
'type':
|
||||||
|
determine_type(file_path),
|
||||||
|
'name':
|
||||||
|
content.get('name', os.path.basename(file_path)),
|
||||||
|
'file_path':
|
||||||
|
file_path
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"Could not get details for {file_path}: {str(e)}")
|
||||||
|
# Fallback to basic info if we can't read the file
|
||||||
|
detailed_changes.append({
|
||||||
|
'type': determine_type(file_path),
|
||||||
|
'name': os.path.basename(file_path),
|
||||||
|
'file_path': file_path
|
||||||
|
})
|
||||||
|
|
||||||
|
return detailed_changes
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting unpushed changes: {str(e)}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
def get_git_status(repo_path):
|
def get_git_status(repo_path):
|
||||||
try:
|
try:
|
||||||
logger.debug(f"Attempting to get status for repo at {repo_path}")
|
logger.debug(f"Attempting to get status for repo at {repo_path}")
|
||||||
repo = git.Repo(repo_path)
|
repo = git.Repo(repo_path)
|
||||||
logger.debug(f"Successfully created Repo object")
|
branch = repo.active_branch.name
|
||||||
|
remote_branch_exists = f"origin/{branch}" in [
|
||||||
|
ref.name for ref in repo.remotes.origin.refs
|
||||||
|
]
|
||||||
|
|
||||||
|
# Check for merge state
|
||||||
|
is_merging = os.path.exists(os.path.join(repo.git_dir, 'MERGE_HEAD'))
|
||||||
|
|
||||||
|
# Get merge conflicts if we're in a merge state
|
||||||
|
merge_conflicts = get_merge_conflicts(repo) if is_merging else []
|
||||||
|
|
||||||
|
# Get all changes first
|
||||||
outgoing_changes = get_outgoing_changes(repo)
|
outgoing_changes = get_outgoing_changes(repo)
|
||||||
logger.debug(f"Outgoing changes detected: {outgoing_changes}")
|
logger.debug(f"Outgoing changes detected: {outgoing_changes}")
|
||||||
|
|
||||||
branch = repo.active_branch.name
|
|
||||||
remote_branch_exists = f"origin/{branch}" in [ref.name for ref in repo.remotes.origin.refs]
|
|
||||||
|
|
||||||
if remote_branch_exists:
|
if remote_branch_exists:
|
||||||
repo.remotes.origin.fetch()
|
repo.remotes.origin.fetch()
|
||||||
commits_behind = get_commits_behind(repo, branch)
|
commits_behind = get_commits_behind(repo, branch)
|
||||||
commits_ahead = get_commits_ahead(repo, branch)
|
commits_ahead = get_commits_ahead(repo, branch)
|
||||||
logger.debug(f"Commits behind: {len(commits_behind)}, Commits ahead: {len(commits_ahead)}")
|
|
||||||
|
|
||||||
incoming_changes = get_incoming_changes(repo, branch)
|
incoming_changes = get_incoming_changes(repo, branch)
|
||||||
|
unpushed_files = get_unpushed_changes(
|
||||||
|
repo, branch) if commits_ahead else []
|
||||||
else:
|
else:
|
||||||
commits_behind = []
|
commits_behind = []
|
||||||
commits_ahead = []
|
commits_ahead = []
|
||||||
incoming_changes = []
|
incoming_changes = []
|
||||||
logger.debug("Remote branch does not exist, skipping commits ahead/behind and incoming changes calculation.")
|
unpushed_files = []
|
||||||
|
|
||||||
status = {
|
status = {
|
||||||
"branch": branch,
|
"branch": branch,
|
||||||
@@ -47,15 +98,13 @@ def get_git_status(repo_path):
|
|||||||
"commits_behind": len(commits_behind),
|
"commits_behind": len(commits_behind),
|
||||||
"commits_ahead": len(commits_ahead),
|
"commits_ahead": len(commits_ahead),
|
||||||
"incoming_changes": incoming_changes,
|
"incoming_changes": incoming_changes,
|
||||||
|
"has_unpushed_commits": len(commits_ahead) > 0,
|
||||||
|
"unpushed_files": unpushed_files,
|
||||||
|
"is_merging": is_merging,
|
||||||
|
"merge_conflicts": merge_conflicts,
|
||||||
|
"has_conflicts": bool(merge_conflicts)
|
||||||
}
|
}
|
||||||
logger.debug(f"Final status object: {json.dumps(status, indent=2)}")
|
|
||||||
return True, status
|
return True, status
|
||||||
except GitCommandError as e:
|
|
||||||
logger.error(f"GitCommandError: {str(e)}")
|
|
||||||
return False, f"Git error: {str(e)}"
|
|
||||||
except InvalidGitRepositoryError:
|
|
||||||
logger.error(f"InvalidGitRepositoryError for path: {repo_path}")
|
|
||||||
return False, "Invalid Git repository"
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Unexpected error in get_git_status: {str(e)}", exc_info=True)
|
logger.error(f"Error in get_git_status: {str(e)}", exc_info=True)
|
||||||
return False, f"Unexpected error: {str(e)}"
|
return False, str(e)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ services:
|
|||||||
- ./frontend:/app
|
- ./frontend:/app
|
||||||
- /app/node_modules
|
- /app/node_modules
|
||||||
environment:
|
environment:
|
||||||
- VITE_API_URL=http://192.168.1.111:5000 # Replace with your host machine's IP
|
- VITE_API_URL=http://localhost:5000
|
||||||
- CHOKIDAR_USEPOLLING=true
|
- CHOKIDAR_USEPOLLING=true
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
@@ -21,6 +21,6 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- FLASK_ENV=development
|
- FLASK_ENV=development
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env.1
|
||||||
volumes:
|
volumes:
|
||||||
backend_data:
|
backend_data:
|
||||||
|
|||||||
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">
|
<html lang="en">
|
||||||
|
<head>
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/regex.svg" />
|
<link rel="icon" type="image/svg+xml" href="/regex.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Regexerr</title>
|
<title>Profilarr</title>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.jsx"></script>
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
const API_BASE_URL = 'http://localhost:5000';
|
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:5000';
|
||||||
|
|
||||||
export const getRegexes = async () => {
|
export const getRegexes = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -133,7 +133,15 @@ export const getSettings = async () => {
|
|||||||
export const getGitStatus = async () => {
|
export const getGitStatus = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`${API_BASE_URL}/git/status`);
|
const response = await axios.get(`${API_BASE_URL}/git/status`);
|
||||||
return response.data;
|
// Ensure has_unpushed_commits is included in the response
|
||||||
|
return {
|
||||||
|
...response.data,
|
||||||
|
data: {
|
||||||
|
...response.data.data,
|
||||||
|
has_unpushed_commits:
|
||||||
|
response.data.data.has_unpushed_commits || false
|
||||||
|
}
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching Git status:', error);
|
console.error('Error fetching Git status:', error);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -152,9 +160,21 @@ export const getBranches = async () => {
|
|||||||
|
|
||||||
export const checkoutBranch = async branchName => {
|
export const checkoutBranch = async branchName => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.post(`${API_BASE_URL}/git/checkout`, {
|
const response = await axios.post(
|
||||||
|
`${API_BASE_URL}/git/checkout`,
|
||||||
|
{
|
||||||
branch: branchName
|
branch: branchName
|
||||||
});
|
},
|
||||||
|
{
|
||||||
|
validateStatus: status => {
|
||||||
|
return (
|
||||||
|
(status >= 200 && status < 300) ||
|
||||||
|
status === 400 ||
|
||||||
|
status === 409
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error checking out branch:', error);
|
console.error('Error checking out branch:', error);
|
||||||
@@ -164,10 +184,22 @@ export const checkoutBranch = async branchName => {
|
|||||||
|
|
||||||
export const createBranch = async (branchName, baseBranch) => {
|
export const createBranch = async (branchName, baseBranch) => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.post(`${API_BASE_URL}/git/branch`, {
|
const response = await axios.post(
|
||||||
|
`${API_BASE_URL}/git/branch`,
|
||||||
|
{
|
||||||
name: branchName,
|
name: branchName,
|
||||||
base: baseBranch
|
base: baseBranch
|
||||||
});
|
},
|
||||||
|
{
|
||||||
|
validateStatus: status => {
|
||||||
|
return (
|
||||||
|
(status >= 200 && status < 300) ||
|
||||||
|
status === 400 ||
|
||||||
|
status === 409
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating branch:', error);
|
console.error('Error creating branch:', error);
|
||||||
@@ -178,7 +210,16 @@ export const createBranch = async (branchName, baseBranch) => {
|
|||||||
export const deleteBranch = async branchName => {
|
export const deleteBranch = async branchName => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.delete(
|
const response = await axios.delete(
|
||||||
`${API_BASE_URL}/git/branch/${branchName}`
|
`${API_BASE_URL}/git/branch/${branchName}`,
|
||||||
|
{
|
||||||
|
validateStatus: status => {
|
||||||
|
return (
|
||||||
|
(status >= 200 && status < 300) ||
|
||||||
|
status === 400 ||
|
||||||
|
status === 409
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -187,6 +228,30 @@ export const deleteBranch = async branchName => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const pushBranchToRemote = async branchName => {
|
||||||
|
try {
|
||||||
|
const response = await axios.post(
|
||||||
|
`${API_BASE_URL}/git/branch/push`,
|
||||||
|
{
|
||||||
|
branch: branchName
|
||||||
|
},
|
||||||
|
{
|
||||||
|
validateStatus: status => {
|
||||||
|
return (
|
||||||
|
(status >= 200 && status < 300) ||
|
||||||
|
status === 400 ||
|
||||||
|
status === 409
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error pushing branch to remote:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const addFiles = async files => {
|
export const addFiles = async files => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.post(`${API_BASE_URL}/git/stage`, {files});
|
const response = await axios.post(`${API_BASE_URL}/git/stage`, {files});
|
||||||
@@ -197,19 +262,49 @@ export const addFiles = async files => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const pushFiles = async (files, commitMessage) => {
|
export const unstageFiles = async files => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.post(`${API_BASE_URL}/git/push`, {
|
const response = await axios.post(`${API_BASE_URL}/git/unstage`, {
|
||||||
|
files
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error unstaging files:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const commitFiles = async (files, commitMessage) => {
|
||||||
|
try {
|
||||||
|
const response = await axios.post(`${API_BASE_URL}/git/commit`, {
|
||||||
files,
|
files,
|
||||||
commit_message: commitMessage
|
commit_message: commitMessage
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error pushing files:', error);
|
console.error('Error committing files:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const pushFiles = async () => {
|
||||||
|
try {
|
||||||
|
const response = await axios.post(`${API_BASE_URL}/git/push`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
// Pass through the structured error from the backend
|
||||||
|
if (error.response?.data) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response.data.error
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to push changes'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
export const revertFile = async filePath => {
|
export const revertFile = async filePath => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.post(`${API_BASE_URL}/git/revert`, {
|
const response = await axios.post(`${API_BASE_URL}/git/revert`, {
|
||||||
@@ -251,20 +346,19 @@ export const pullBranch = async branchName => {
|
|||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error pulling branch:', error);
|
if (error.response?.data) {
|
||||||
throw error;
|
return {
|
||||||
|
success: false,
|
||||||
|
state: error.response.data.state || 'error',
|
||||||
|
message: error.response.data.message,
|
||||||
|
details: error.response.data.details
|
||||||
|
};
|
||||||
}
|
}
|
||||||
};
|
return {
|
||||||
|
success: false,
|
||||||
export const getDiff = async filePath => {
|
state: 'error',
|
||||||
try {
|
message: 'Failed to pull changes'
|
||||||
const response = await axios.post(`${API_BASE_URL}/git/diff`, {
|
};
|
||||||
file_path: filePath
|
|
||||||
});
|
|
||||||
return response.data;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching diff:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -335,18 +429,6 @@ export const unlinkRepo = async (removeFiles = false) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const pushBranchToRemote = async branchName => {
|
|
||||||
try {
|
|
||||||
const response = await axios.post(`${API_BASE_URL}/git/branch/push`, {
|
|
||||||
branch: branchName
|
|
||||||
});
|
|
||||||
return response.data;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error pushing branch to remote:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const checkDevMode = async () => {
|
export const checkDevMode = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`${API_BASE_URL}/git/dev`);
|
const response = await axios.get(`${API_BASE_URL}/git/dev`);
|
||||||
@@ -356,3 +438,44 @@ export const checkDevMode = async () => {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const resolveConflict = async resolutions => {
|
||||||
|
try {
|
||||||
|
const response = await axios.post(`${API_BASE_URL}/git/resolve`, {
|
||||||
|
resolutions
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error resolving conflicts:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const finalizeMerge = async () => {
|
||||||
|
try {
|
||||||
|
const response = await axios.post(`${API_BASE_URL}/git/merge/finalize`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error finalizing merge:', error);
|
||||||
|
if (error.response?.data) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response.data.error
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to finalize merge'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const abortMerge = async () => {
|
||||||
|
try {
|
||||||
|
const response = await axios.post(`${API_BASE_URL}/git/merge/abort`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error aborting merge:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,33 +1,37 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, {useState, useEffect} from 'react';
|
||||||
import FormatCard from "./FormatCard";
|
import {useNavigate} from 'react-router-dom';
|
||||||
import FormatModal from "./FormatModal";
|
import FormatCard from './FormatCard';
|
||||||
import AddNewCard from "../ui/AddNewCard";
|
import FormatModal from './FormatModal';
|
||||||
import { getFormats } from "../../api/api";
|
import AddNewCard from '../ui/AddNewCard';
|
||||||
import FilterMenu from "../ui/FilterMenu";
|
import {getFormats, getGitStatus} from '../../api/api';
|
||||||
import SortMenu from "../ui/SortMenu";
|
import FilterMenu from '../ui/FilterMenu';
|
||||||
import { Loader } from "lucide-react";
|
import SortMenu from '../ui/SortMenu';
|
||||||
|
import {Loader} from 'lucide-react';
|
||||||
|
|
||||||
function FormatPage() {
|
function FormatPage() {
|
||||||
const [formats, setFormats] = useState([]);
|
const [formats, setFormats] = useState([]);
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [selectedFormat, setSelectedFormat] = useState(null);
|
const [selectedFormat, setSelectedFormat] = useState(null);
|
||||||
const [sortBy, setSortBy] = useState("title");
|
const [sortBy, setSortBy] = useState('title');
|
||||||
const [filterType, setFilterType] = useState("none");
|
const [filterType, setFilterType] = useState('none');
|
||||||
const [filterValue, setFilterValue] = useState("");
|
const [filterValue, setFilterValue] = useState('');
|
||||||
const [allTags, setAllTags] = useState([]);
|
const [allTags, setAllTags] = useState([]);
|
||||||
const [isCloning, setIsCloning] = useState(false);
|
const [isCloning, setIsCloning] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [mergeConflicts, setMergeConflicts] = useState([]);
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const loadingMessages = [
|
const loadingMessages = [
|
||||||
"Decoding the custom format matrix...",
|
'Decoding the custom format matrix...',
|
||||||
"Parsing the digital alphabet soup...",
|
'Parsing the digital alphabet soup...',
|
||||||
"Untangling the format spaghetti...",
|
'Untangling the format spaghetti...',
|
||||||
"Calibrating the format-o-meter...",
|
'Calibrating the format-o-meter...',
|
||||||
"Indexing your media DNA...",
|
'Indexing your media DNA...'
|
||||||
];
|
];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchFormats();
|
fetchGitStatus();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fetchFormats = async () => {
|
const fetchFormats = async () => {
|
||||||
@@ -35,16 +39,33 @@ function FormatPage() {
|
|||||||
const fetchedFormats = await getFormats();
|
const fetchedFormats = await getFormats();
|
||||||
setFormats(fetchedFormats);
|
setFormats(fetchedFormats);
|
||||||
const tags = [
|
const tags = [
|
||||||
...new Set(fetchedFormats.flatMap((format) => format.tags || [])),
|
...new Set(fetchedFormats.flatMap(format => format.tags || []))
|
||||||
];
|
];
|
||||||
setAllTags(tags);
|
setAllTags(tags);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching formats:", error);
|
console.error('Error fetching formats:', error);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fetchGitStatus = async () => {
|
||||||
|
try {
|
||||||
|
const result = await getGitStatus();
|
||||||
|
if (result.success) {
|
||||||
|
setMergeConflicts(result.data.merge_conflicts || []);
|
||||||
|
if (result.data.merge_conflicts.length === 0) {
|
||||||
|
fetchFormats();
|
||||||
|
} else {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching Git status:', error);
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleOpenModal = (format = null) => {
|
const handleOpenModal = (format = null) => {
|
||||||
setSelectedFormat(format);
|
setSelectedFormat(format);
|
||||||
setIsModalOpen(true);
|
setIsModalOpen(true);
|
||||||
@@ -57,11 +78,11 @@ function FormatPage() {
|
|||||||
setIsCloning(false);
|
setIsCloning(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCloneFormat = (format) => {
|
const handleCloneFormat = format => {
|
||||||
const clonedFormat = {
|
const clonedFormat = {
|
||||||
...format,
|
...format,
|
||||||
id: 0,
|
id: 0,
|
||||||
name: `${format.name} [COPY]`,
|
name: `${format.name} [COPY]`
|
||||||
};
|
};
|
||||||
setSelectedFormat(clonedFormat);
|
setSelectedFormat(clonedFormat);
|
||||||
setIsModalOpen(true);
|
setIsModalOpen(true);
|
||||||
@@ -73,16 +94,16 @@ function FormatPage() {
|
|||||||
handleCloseModal();
|
handleCloseModal();
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (dateString) => {
|
const formatDate = dateString => {
|
||||||
return new Date(dateString).toLocaleString();
|
return new Date(dateString).toLocaleString();
|
||||||
};
|
};
|
||||||
|
|
||||||
const sortedAndFilteredFormats = formats
|
const sortedAndFilteredFormats = formats
|
||||||
.filter((format) => {
|
.filter(format => {
|
||||||
if (filterType === "tag") {
|
if (filterType === 'tag') {
|
||||||
return format.tags && format.tags.includes(filterValue);
|
return format.tags && format.tags.includes(filterValue);
|
||||||
}
|
}
|
||||||
if (filterType === "date") {
|
if (filterType === 'date') {
|
||||||
const formatDate = new Date(format.date_modified);
|
const formatDate = new Date(format.date_modified);
|
||||||
const filterDate = new Date(filterValue);
|
const filterDate = new Date(filterValue);
|
||||||
return formatDate.toDateString() === filterDate.toDateString();
|
return formatDate.toDateString() === filterDate.toDateString();
|
||||||
@@ -90,29 +111,61 @@ function FormatPage() {
|
|||||||
return true;
|
return true;
|
||||||
})
|
})
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
if (sortBy === "title") return a.name.localeCompare(b.name);
|
if (sortBy === 'title') return a.name.localeCompare(b.name);
|
||||||
if (sortBy === "dateCreated")
|
if (sortBy === 'dateCreated')
|
||||||
return new Date(b.date_created) - new Date(a.date_created);
|
return new Date(b.date_created) - new Date(a.date_created);
|
||||||
if (sortBy === "dateModified")
|
if (sortBy === 'dateModified')
|
||||||
return new Date(b.date_modified) - new Date(a.date_modified);
|
return new Date(b.date_modified) - new Date(a.date_modified);
|
||||||
return 0;
|
return 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const hasConflicts = mergeConflicts.length > 0;
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center h-screen">
|
<div className='flex flex-col items-center justify-center h-screen'>
|
||||||
<Loader size={48} className="animate-spin text-blue-500 mb-4" />
|
<Loader size={48} className='animate-spin text-blue-500 mb-4' />
|
||||||
<p className="text-lg font-medium text-gray-700 dark:text-gray-300">
|
<p className='text-lg font-medium text-gray-700 dark:text-gray-300'>
|
||||||
{loadingMessages[Math.floor(Math.random() * loadingMessages.length)]}
|
{
|
||||||
|
loadingMessages[
|
||||||
|
Math.floor(Math.random() * loadingMessages.length)
|
||||||
|
]
|
||||||
|
}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (hasConflicts) {
|
||||||
|
return (
|
||||||
|
<div className='bg-gray-900 text-white'>
|
||||||
|
<div className='mt-8 flex justify-between items-center'>
|
||||||
|
<h4 className='text-xl font-extrabold'>
|
||||||
|
Merge Conflicts Detected
|
||||||
|
</h4>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/settings')}
|
||||||
|
className='bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded transition'>
|
||||||
|
Resolve Conflicts
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='mt-6 p-4 bg-gray-800 rounded-lg shadow-md'>
|
||||||
|
<h3 className='text-xl font-semibold'>What Happened?</h3>
|
||||||
|
<p className='mt-2 text-gray-300'>
|
||||||
|
This page is locked because there are unresolved merge
|
||||||
|
conflicts. You need to address these conflicts in the
|
||||||
|
settings page before continuing.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold mb-4">Manage Custom Formats</h2>
|
<h2 className='text-2xl font-bold mb-4'>Manage Custom Formats</h2>
|
||||||
<div className="mb-4 flex items-center space-x-4">
|
<div className='mb-4 flex items-center space-x-4'>
|
||||||
<SortMenu sortBy={sortBy} setSortBy={setSortBy} />
|
<SortMenu sortBy={sortBy} setSortBy={setSortBy} />
|
||||||
<FilterMenu
|
<FilterMenu
|
||||||
filterType={filterType}
|
filterType={filterType}
|
||||||
@@ -122,14 +175,14 @@ function FormatPage() {
|
|||||||
allTags={allTags}
|
allTags={allTags}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 mb-4">
|
<div className='grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 mb-4'>
|
||||||
{sortedAndFilteredFormats.map((format) => (
|
{sortedAndFilteredFormats.map(format => (
|
||||||
<FormatCard
|
<FormatCard
|
||||||
key={format.id}
|
key={format.id}
|
||||||
format={format}
|
format={format}
|
||||||
onEdit={() => handleOpenModal(format)}
|
onEdit={() => handleOpenModal(format)}
|
||||||
onClone={handleCloneFormat} // Pass the clone handler
|
onClone={handleCloneFormat}
|
||||||
showDate={sortBy !== "title"}
|
showDate={sortBy !== 'title'}
|
||||||
formatDate={formatDate}
|
formatDate={formatDate}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -265,12 +265,7 @@ function ProfileModal({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal isOpen={isOpen} onClose={onClose} title={modalTitle}>
|
||||||
isOpen={isOpen}
|
|
||||||
onClose={onClose}
|
|
||||||
title={modalTitle}
|
|
||||||
width='screen-2xl'
|
|
||||||
height='6xl'>
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className='flex justify-center items-center'>
|
<div className='flex justify-center items-center'>
|
||||||
<Loader size={24} className='animate-spin text-gray-300' />
|
<Loader size={24} className='animate-spin text-gray-300' />
|
||||||
|
|||||||
@@ -1,35 +1,38 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, {useState, useEffect} from 'react';
|
||||||
import ProfileCard from "./ProfileCard";
|
import {useNavigate} from 'react-router-dom';
|
||||||
import ProfileModal from "./ProfileModal";
|
import ProfileCard from './ProfileCard';
|
||||||
import AddNewCard from "../ui/AddNewCard";
|
import ProfileModal from './ProfileModal';
|
||||||
import { getProfiles, getFormats } from "../../api/api";
|
import AddNewCard from '../ui/AddNewCard';
|
||||||
import FilterMenu from "../ui/FilterMenu";
|
import {getProfiles, getFormats, getGitStatus} from '../../api/api';
|
||||||
import SortMenu from "../ui/SortMenu";
|
import FilterMenu from '../ui/FilterMenu';
|
||||||
import { Loader } from "lucide-react";
|
import SortMenu from '../ui/SortMenu';
|
||||||
|
import {Loader} from 'lucide-react';
|
||||||
|
|
||||||
function ProfilePage() {
|
function ProfilePage() {
|
||||||
const [profiles, setProfiles] = useState([]);
|
const [profiles, setProfiles] = useState([]);
|
||||||
const [formats, setFormats] = useState([]);
|
const [formats, setFormats] = useState([]);
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [selectedProfile, setSelectedProfile] = useState(null);
|
const [selectedProfile, setSelectedProfile] = useState(null);
|
||||||
const [sortBy, setSortBy] = useState("title");
|
const [sortBy, setSortBy] = useState('title');
|
||||||
const [filterType, setFilterType] = useState("none");
|
const [filterType, setFilterType] = useState('none');
|
||||||
const [filterValue, setFilterValue] = useState("");
|
const [filterValue, setFilterValue] = useState('');
|
||||||
const [allTags, setAllTags] = useState([]);
|
const [allTags, setAllTags] = useState([]);
|
||||||
const [isCloning, setIsCloning] = useState(false);
|
const [isCloning, setIsCloning] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [mergeConflicts, setMergeConflicts] = useState([]);
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const loadingMessages = [
|
const loadingMessages = [
|
||||||
"Profiling your media collection...",
|
'Profiling your media collection...',
|
||||||
"Organizing your digital hoard...",
|
'Organizing your digital hoard...',
|
||||||
"Calibrating the flux capacitor...",
|
'Calibrating the flux capacitor...',
|
||||||
"Synchronizing with the movie matrix...",
|
'Synchronizing with the movie matrix...',
|
||||||
"Optimizing your binge-watching potential...",
|
'Optimizing your binge-watching potential...'
|
||||||
];
|
];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchProfiles();
|
fetchGitStatus();
|
||||||
fetchFormats();
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fetchProfiles = async () => {
|
const fetchProfiles = async () => {
|
||||||
@@ -37,11 +40,13 @@ function ProfilePage() {
|
|||||||
const fetchedProfiles = await getProfiles();
|
const fetchedProfiles = await getProfiles();
|
||||||
setProfiles(fetchedProfiles);
|
setProfiles(fetchedProfiles);
|
||||||
const tags = [
|
const tags = [
|
||||||
...new Set(fetchedProfiles.flatMap((profile) => profile.tags || [])),
|
...new Set(
|
||||||
|
fetchedProfiles.flatMap(profile => profile.tags || [])
|
||||||
|
)
|
||||||
];
|
];
|
||||||
setAllTags(tags);
|
setAllTags(tags);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching profiles:", error);
|
console.error('Error fetching profiles:', error);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -52,7 +57,25 @@ function ProfilePage() {
|
|||||||
const fetchedFormats = await getFormats();
|
const fetchedFormats = await getFormats();
|
||||||
setFormats(fetchedFormats);
|
setFormats(fetchedFormats);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching formats:", error);
|
console.error('Error fetching formats:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchGitStatus = async () => {
|
||||||
|
try {
|
||||||
|
const result = await getGitStatus();
|
||||||
|
if (result.success) {
|
||||||
|
setMergeConflicts(result.data.merge_conflicts || []);
|
||||||
|
if (result.data.merge_conflicts.length === 0) {
|
||||||
|
fetchProfiles();
|
||||||
|
fetchFormats();
|
||||||
|
} else {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching Git status:', error);
|
||||||
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -60,7 +83,7 @@ function ProfilePage() {
|
|||||||
const safeProfile = profile
|
const safeProfile = profile
|
||||||
? {
|
? {
|
||||||
...profile,
|
...profile,
|
||||||
custom_formats: profile.custom_formats || [],
|
custom_formats: profile.custom_formats || []
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
setSelectedProfile(safeProfile);
|
setSelectedProfile(safeProfile);
|
||||||
@@ -74,12 +97,12 @@ function ProfilePage() {
|
|||||||
setIsCloning(false);
|
setIsCloning(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCloneProfile = (profile) => {
|
const handleCloneProfile = profile => {
|
||||||
const clonedProfile = {
|
const clonedProfile = {
|
||||||
...profile,
|
...profile,
|
||||||
id: 0,
|
id: 0,
|
||||||
name: `${profile.name} [COPY]`,
|
name: `${profile.name} [COPY]`,
|
||||||
custom_formats: profile.custom_formats || [],
|
custom_formats: profile.custom_formats || []
|
||||||
};
|
};
|
||||||
setSelectedProfile(clonedProfile);
|
setSelectedProfile(clonedProfile);
|
||||||
setIsModalOpen(true);
|
setIsModalOpen(true);
|
||||||
@@ -91,17 +114,16 @@ function ProfilePage() {
|
|||||||
handleCloseModal();
|
handleCloseModal();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Define the missing formatDate function
|
const formatDate = dateString => {
|
||||||
const formatDate = (dateString) => {
|
|
||||||
return new Date(dateString).toLocaleString();
|
return new Date(dateString).toLocaleString();
|
||||||
};
|
};
|
||||||
|
|
||||||
const sortedAndFilteredProfiles = profiles
|
const sortedAndFilteredProfiles = profiles
|
||||||
.filter((profile) => {
|
.filter(profile => {
|
||||||
if (filterType === "tag") {
|
if (filterType === 'tag') {
|
||||||
return profile.tags && profile.tags.includes(filterValue);
|
return profile.tags && profile.tags.includes(filterValue);
|
||||||
}
|
}
|
||||||
if (filterType === "date") {
|
if (filterType === 'date') {
|
||||||
const profileDate = new Date(profile.date_modified);
|
const profileDate = new Date(profile.date_modified);
|
||||||
const filterDate = new Date(filterValue);
|
const filterDate = new Date(filterValue);
|
||||||
return profileDate.toDateString() === filterDate.toDateString();
|
return profileDate.toDateString() === filterDate.toDateString();
|
||||||
@@ -109,29 +131,61 @@ function ProfilePage() {
|
|||||||
return true;
|
return true;
|
||||||
})
|
})
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
if (sortBy === "name") return a.name.localeCompare(b.name);
|
if (sortBy === 'name') return a.name.localeCompare(b.name);
|
||||||
if (sortBy === "dateCreated")
|
if (sortBy === 'dateCreated')
|
||||||
return new Date(b.date_created) - new Date(a.date_created);
|
return new Date(b.date_created) - new Date(a.date_created);
|
||||||
if (sortBy === "dateModified")
|
if (sortBy === 'dateModified')
|
||||||
return new Date(b.date_modified) - new Date(a.date_modified);
|
return new Date(b.date_modified) - new Date(a.date_modified);
|
||||||
return 0;
|
return 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const hasConflicts = mergeConflicts.length > 0;
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center h-screen">
|
<div className='flex flex-col items-center justify-center h-screen'>
|
||||||
<Loader size={48} className="animate-spin text-blue-500 mb-4" />
|
<Loader size={48} className='animate-spin text-blue-500 mb-4' />
|
||||||
<p className="text-lg font-medium text-gray-700 dark:text-gray-300">
|
<p className='text-lg font-medium text-gray-700 dark:text-gray-300'>
|
||||||
{loadingMessages[Math.floor(Math.random() * loadingMessages.length)]}
|
{
|
||||||
|
loadingMessages[
|
||||||
|
Math.floor(Math.random() * loadingMessages.length)
|
||||||
|
]
|
||||||
|
}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (hasConflicts) {
|
||||||
|
return (
|
||||||
|
<div className='bg-gray-900 text-white'>
|
||||||
|
<div className='mt-8 flex justify-between items-center'>
|
||||||
|
<h4 className='text-xl font-extrabold'>
|
||||||
|
Merge Conflicts Detected
|
||||||
|
</h4>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/settings')}
|
||||||
|
className='bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded transition'>
|
||||||
|
Resolve Conflicts
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='mt-6 p-4 bg-gray-800 rounded-lg shadow-md'>
|
||||||
|
<h3 className='text-xl font-semibold'>What Happened?</h3>
|
||||||
|
<p className='mt-2 text-gray-300'>
|
||||||
|
This page is locked because there are unresolved merge
|
||||||
|
conflicts. You need to address these conflicts in the
|
||||||
|
settings page before continuing.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold mb-4">Manage Profiles</h2>
|
<h2 className='text-2xl font-bold mb-4'>Manage Profiles</h2>
|
||||||
<div className="mb-4 flex items-center space-x-4">
|
<div className='mb-4 flex items-center space-x-4'>
|
||||||
<SortMenu sortBy={sortBy} setSortBy={setSortBy} />
|
<SortMenu sortBy={sortBy} setSortBy={setSortBy} />
|
||||||
<FilterMenu
|
<FilterMenu
|
||||||
filterType={filterType}
|
filterType={filterType}
|
||||||
@@ -141,15 +195,15 @@ function ProfilePage() {
|
|||||||
allTags={allTags}
|
allTags={allTags}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 mb-4">
|
<div className='grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 mb-4'>
|
||||||
{sortedAndFilteredProfiles.map((profile) => (
|
{sortedAndFilteredProfiles.map(profile => (
|
||||||
<ProfileCard
|
<ProfileCard
|
||||||
key={profile.id}
|
key={profile.id}
|
||||||
profile={profile}
|
profile={profile}
|
||||||
onEdit={() => handleOpenModal(profile)}
|
onEdit={() => handleOpenModal(profile)}
|
||||||
onClone={handleCloneProfile}
|
onClone={handleCloneProfile}
|
||||||
showDate={sortBy !== "name"}
|
showDate={sortBy !== 'name'}
|
||||||
formatDate={formatDate} // Pass the formatDate function to the ProfileCard
|
formatDate={formatDate}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<AddNewCard onAdd={() => handleOpenModal()} />
|
<AddNewCard onAdd={() => handleOpenModal()} />
|
||||||
|
|||||||
@@ -1,33 +1,41 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, {useState, useEffect} from 'react';
|
||||||
import RegexCard from "./RegexCard";
|
import {useNavigate} from 'react-router-dom';
|
||||||
import RegexModal from "./RegexModal";
|
import RegexCard from './RegexCard';
|
||||||
import AddNewCard from "../ui/AddNewCard";
|
import RegexModal from './RegexModal';
|
||||||
import { getRegexes } from "../../api/api";
|
import AddNewCard from '../ui/AddNewCard';
|
||||||
import FilterMenu from "../ui/FilterMenu";
|
import {getRegexes} from '../../api/api';
|
||||||
import SortMenu from "../ui/SortMenu";
|
import FilterMenu from '../ui/FilterMenu';
|
||||||
import { Loader } from "lucide-react";
|
import SortMenu from '../ui/SortMenu';
|
||||||
|
import {Loader} from 'lucide-react';
|
||||||
|
import {getGitStatus} from '../../api/api';
|
||||||
|
|
||||||
function RegexPage() {
|
function RegexPage() {
|
||||||
const [regexes, setRegexes] = useState([]);
|
const [regexes, setRegexes] = useState([]);
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [selectedRegex, setSelectedRegex] = useState(null);
|
const [selectedRegex, setSelectedRegex] = useState(null);
|
||||||
const [sortBy, setSortBy] = useState("title");
|
const [sortBy, setSortBy] = useState('title');
|
||||||
const [filterType, setFilterType] = useState("none");
|
const [filterType, setFilterType] = useState('none');
|
||||||
const [filterValue, setFilterValue] = useState("");
|
const [filterValue, setFilterValue] = useState('');
|
||||||
const [allTags, setAllTags] = useState([]);
|
const [allTags, setAllTags] = useState([]);
|
||||||
const [isCloning, setIsCloning] = useState(false);
|
const [isCloning, setIsCloning] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [mergeConflicts, setMergeConflicts] = useState([]);
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const loadingMessages = [
|
const loadingMessages = [
|
||||||
"Matching patterns in the digital universe...",
|
'Compiling complex patterns...',
|
||||||
"Capturing groups of binary brilliance...",
|
'Analyzing regex efficiency...',
|
||||||
"Escaping special characters in the wild...",
|
'Optimizing search algorithms...',
|
||||||
"Quantifying the unquantifiable...",
|
'Testing pattern boundaries...',
|
||||||
"Regex-ing the un-regex-able...",
|
'Loading regex libraries...',
|
||||||
|
'Parsing intricate expressions...',
|
||||||
|
'Detecting pattern conflicts...',
|
||||||
|
'Refactoring nested groups...'
|
||||||
];
|
];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchRegexes();
|
fetchGitStatus();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fetchRegexes = async () => {
|
const fetchRegexes = async () => {
|
||||||
@@ -35,16 +43,33 @@ function RegexPage() {
|
|||||||
const fetchedRegexes = await getRegexes();
|
const fetchedRegexes = await getRegexes();
|
||||||
setRegexes(fetchedRegexes);
|
setRegexes(fetchedRegexes);
|
||||||
const tags = [
|
const tags = [
|
||||||
...new Set(fetchedRegexes.flatMap((regex) => regex.tags || [])),
|
...new Set(fetchedRegexes.flatMap(regex => regex.tags || []))
|
||||||
];
|
];
|
||||||
setAllTags(tags);
|
setAllTags(tags);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching regexes:", error);
|
console.error('Error fetching regexes:', error);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fetchGitStatus = async () => {
|
||||||
|
try {
|
||||||
|
const result = await getGitStatus();
|
||||||
|
if (result.success) {
|
||||||
|
setMergeConflicts(result.data.merge_conflicts || []);
|
||||||
|
if (result.data.merge_conflicts.length === 0) {
|
||||||
|
fetchRegexes();
|
||||||
|
} else {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching Git status:', error);
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleOpenModal = (regex = null) => {
|
const handleOpenModal = (regex = null) => {
|
||||||
setSelectedRegex(regex);
|
setSelectedRegex(regex);
|
||||||
setIsModalOpen(true);
|
setIsModalOpen(true);
|
||||||
@@ -57,12 +82,12 @@ function RegexPage() {
|
|||||||
setIsCloning(false);
|
setIsCloning(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCloneRegex = (regex) => {
|
const handleCloneRegex = regex => {
|
||||||
const clonedRegex = {
|
const clonedRegex = {
|
||||||
...regex,
|
...regex,
|
||||||
id: 0,
|
id: 0,
|
||||||
name: `${regex.name} [COPY]`,
|
name: `${regex.name} [COPY]`,
|
||||||
regex101Link: "",
|
regex101Link: ''
|
||||||
};
|
};
|
||||||
setSelectedRegex(clonedRegex);
|
setSelectedRegex(clonedRegex);
|
||||||
setIsModalOpen(true);
|
setIsModalOpen(true);
|
||||||
@@ -74,16 +99,16 @@ function RegexPage() {
|
|||||||
handleCloseModal();
|
handleCloseModal();
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (dateString) => {
|
const formatDate = dateString => {
|
||||||
return new Date(dateString).toLocaleString();
|
return new Date(dateString).toLocaleString();
|
||||||
};
|
};
|
||||||
|
|
||||||
const sortedAndFilteredRegexes = regexes
|
const sortedAndFilteredRegexes = regexes
|
||||||
.filter((regex) => {
|
.filter(regex => {
|
||||||
if (filterType === "tag") {
|
if (filterType === 'tag') {
|
||||||
return regex.tags && regex.tags.includes(filterValue);
|
return regex.tags && regex.tags.includes(filterValue);
|
||||||
}
|
}
|
||||||
if (filterType === "date") {
|
if (filterType === 'date') {
|
||||||
const regexDate = new Date(regex.date_modified);
|
const regexDate = new Date(regex.date_modified);
|
||||||
const filterDate = new Date(filterValue);
|
const filterDate = new Date(filterValue);
|
||||||
return regexDate.toDateString() === filterDate.toDateString();
|
return regexDate.toDateString() === filterDate.toDateString();
|
||||||
@@ -91,29 +116,61 @@ function RegexPage() {
|
|||||||
return true;
|
return true;
|
||||||
})
|
})
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
if (sortBy === "title") return a.name.localeCompare(b.name);
|
if (sortBy === 'title') return a.name.localeCompare(b.name);
|
||||||
if (sortBy === "dateCreated")
|
if (sortBy === 'dateCreated')
|
||||||
return new Date(b.date_created) - new Date(a.date_created);
|
return new Date(b.date_created) - new Date(a.date_created);
|
||||||
if (sortBy === "dateModified")
|
if (sortBy === 'dateModified')
|
||||||
return new Date(b.date_modified) - new Date(a.date_modified);
|
return new Date(b.date_modified) - new Date(a.date_modified);
|
||||||
return 0;
|
return 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const hasConflicts = mergeConflicts.length > 0;
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center h-screen">
|
<div className='flex flex-col items-center justify-center h-screen'>
|
||||||
<Loader size={48} className="animate-spin text-blue-500 mb-4" />
|
<Loader size={48} className='animate-spin text-blue-500 mb-4' />
|
||||||
<p className="text-lg font-medium text-gray-700 dark:text-gray-300">
|
<p className='text-lg font-medium text-gray-700 dark:text-gray-300'>
|
||||||
{loadingMessages[Math.floor(Math.random() * loadingMessages.length)]}
|
{
|
||||||
|
loadingMessages[
|
||||||
|
Math.floor(Math.random() * loadingMessages.length)
|
||||||
|
]
|
||||||
|
}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (hasConflicts) {
|
||||||
|
return (
|
||||||
|
<div className='bg-gray-900 text-white'>
|
||||||
|
<div className='mt-8 flex justify-between items-center'>
|
||||||
|
<h4 className='text-xl font-extrabold'>
|
||||||
|
Merge Conflicts Detected
|
||||||
|
</h4>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/settings')}
|
||||||
|
className='bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded transition'>
|
||||||
|
Resolve Conflicts
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='mt-6 p-4 bg-gray-800 rounded-lg shadow-md'>
|
||||||
|
<h3 className='text-xl font-semibold'>What Happened?</h3>
|
||||||
|
<p className='mt-2 text-gray-300'>
|
||||||
|
This page is locked because there are unresolved merge
|
||||||
|
conflicts. You need to address these conflicts in the
|
||||||
|
settings page before continuing.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold mb-4">Manage Regex Patterns</h2>
|
<h2 className='text-2xl font-bold mb-4'>Manage Regex Patterns</h2>
|
||||||
<div className="mb-4 flex items-center space-x-4">
|
<div className='mb-4 flex items-center space-x-4'>
|
||||||
<SortMenu sortBy={sortBy} setSortBy={setSortBy} />
|
<SortMenu sortBy={sortBy} setSortBy={setSortBy} />
|
||||||
<FilterMenu
|
<FilterMenu
|
||||||
filterType={filterType}
|
filterType={filterType}
|
||||||
@@ -123,14 +180,14 @@ function RegexPage() {
|
|||||||
allTags={allTags}
|
allTags={allTags}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 mb-4">
|
<div className='grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 mb-4'>
|
||||||
{sortedAndFilteredRegexes.map((regex) => (
|
{sortedAndFilteredRegexes.map(regex => (
|
||||||
<RegexCard
|
<RegexCard
|
||||||
key={regex.id}
|
key={regex.id}
|
||||||
regex={regex}
|
regex={regex}
|
||||||
onEdit={() => handleOpenModal(regex)}
|
onEdit={() => handleOpenModal(regex)}
|
||||||
onClone={handleCloneRegex} // Pass the clone handler
|
onClone={handleCloneRegex} // Pass the clone handler
|
||||||
showDate={sortBy !== "title"}
|
showDate={sortBy !== 'title'}
|
||||||
formatDate={formatDate}
|
formatDate={formatDate}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import {
|
|||||||
getSettings,
|
getSettings,
|
||||||
getGitStatus,
|
getGitStatus,
|
||||||
addFiles,
|
addFiles,
|
||||||
|
unstageFiles,
|
||||||
|
commitFiles,
|
||||||
pushFiles,
|
pushFiles,
|
||||||
revertFile,
|
revertFile,
|
||||||
pullBranch,
|
pullBranch,
|
||||||
@@ -32,6 +34,7 @@ const SettingsPage = () => {
|
|||||||
const [noChangesMessage, setNoChangesMessage] = useState('');
|
const [noChangesMessage, setNoChangesMessage] = useState('');
|
||||||
const [activeTab, setActiveTab] = useState('git');
|
const [activeTab, setActiveTab] = useState('git');
|
||||||
const tabsRef = useRef({});
|
const tabsRef = useRef({});
|
||||||
|
const [mergeConflicts, setMergeConflicts] = useState([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchSettings();
|
fetchSettings();
|
||||||
@@ -67,10 +70,11 @@ const SettingsPage = () => {
|
|||||||
setStatusLoading(true);
|
setStatusLoading(true);
|
||||||
setStatusLoadingMessage(getRandomMessage(statusLoadingMessages));
|
setStatusLoadingMessage(getRandomMessage(statusLoadingMessages));
|
||||||
setNoChangesMessage(getRandomMessage(noChangesMessages));
|
setNoChangesMessage(getRandomMessage(noChangesMessages));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await getGitStatus();
|
const result = await getGitStatus();
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setChanges({
|
const gitStatus = {
|
||||||
...result.data,
|
...result.data,
|
||||||
outgoing_changes: Array.isArray(
|
outgoing_changes: Array.isArray(
|
||||||
result.data.outgoing_changes
|
result.data.outgoing_changes
|
||||||
@@ -81,8 +85,16 @@ const SettingsPage = () => {
|
|||||||
result.data.incoming_changes
|
result.data.incoming_changes
|
||||||
)
|
)
|
||||||
? result.data.incoming_changes
|
? result.data.incoming_changes
|
||||||
|
: [],
|
||||||
|
merge_conflicts: Array.isArray(result.data.merge_conflicts)
|
||||||
|
? result.data.merge_conflicts
|
||||||
: []
|
: []
|
||||||
});
|
};
|
||||||
|
|
||||||
|
setChanges(gitStatus);
|
||||||
|
setMergeConflicts(gitStatus.merge_conflicts);
|
||||||
|
|
||||||
|
console.log('Git Status:', JSON.stringify(gitStatus, null, 2));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching Git status:', error);
|
console.error('Error fetching Git status:', error);
|
||||||
@@ -114,13 +126,33 @@ const SettingsPage = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleUnstageSelectedChanges = async selectedChanges => {
|
||||||
|
setLoadingAction('unstage_selected');
|
||||||
|
try {
|
||||||
|
const response = await unstageFiles(selectedChanges);
|
||||||
|
if (response.success) {
|
||||||
|
await fetchGitStatus();
|
||||||
|
Alert.success(response.message);
|
||||||
|
} else {
|
||||||
|
Alert.error(response.error);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Alert.error(
|
||||||
|
'An unexpected error occurred while unstaging changes.'
|
||||||
|
);
|
||||||
|
console.error('Error unstaging changes:', error);
|
||||||
|
} finally {
|
||||||
|
setLoadingAction('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleCommitSelectedChanges = async (
|
const handleCommitSelectedChanges = async (
|
||||||
selectedChanges,
|
selectedChanges,
|
||||||
commitMessage
|
commitMessage
|
||||||
) => {
|
) => {
|
||||||
setLoadingAction('commit_selected');
|
setLoadingAction('commit_selected');
|
||||||
try {
|
try {
|
||||||
const response = await pushFiles(selectedChanges, commitMessage);
|
const response = await commitFiles(selectedChanges, commitMessage);
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
await fetchGitStatus();
|
await fetchGitStatus();
|
||||||
Alert.success(response.message);
|
Alert.success(response.message);
|
||||||
@@ -137,6 +169,64 @@ const SettingsPage = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handlePushChanges = async () => {
|
||||||
|
setLoadingAction('push_changes');
|
||||||
|
try {
|
||||||
|
const response = await pushFiles();
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
await fetchGitStatus();
|
||||||
|
Alert.success(response.message);
|
||||||
|
} else {
|
||||||
|
if (typeof response.error === 'object' && response.error.type) {
|
||||||
|
// Handle structured errors
|
||||||
|
Alert.error(response.error.message);
|
||||||
|
} else {
|
||||||
|
// Handle string errors
|
||||||
|
Alert.error(response.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in handlePushChanges:', error);
|
||||||
|
Alert.error('An unexpected error occurred while pushing changes.');
|
||||||
|
} finally {
|
||||||
|
setLoadingAction('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handlePullSelectedChanges = async () => {
|
||||||
|
setLoadingAction('pull_changes');
|
||||||
|
try {
|
||||||
|
const response = await pullBranch(changes.branch);
|
||||||
|
|
||||||
|
// First update status regardless of what happened
|
||||||
|
await fetchGitStatus();
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
if (response.state === 'resolve') {
|
||||||
|
Alert.info(
|
||||||
|
response.message ||
|
||||||
|
'Repository is now in conflict resolution state. Please resolve conflicts to continue. ',
|
||||||
|
{
|
||||||
|
autoClose: true,
|
||||||
|
closeOnClick: true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
Alert.success(
|
||||||
|
response.message || 'Successfully pulled changes'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Alert.error(response.message || 'Failed to pull changes');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in pullBranch:', error);
|
||||||
|
Alert.error('Failed to pull changes');
|
||||||
|
} finally {
|
||||||
|
setLoadingAction('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleRevertSelectedChanges = async selectedChanges => {
|
const handleRevertSelectedChanges = async selectedChanges => {
|
||||||
setLoadingAction('revert_selected');
|
setLoadingAction('revert_selected');
|
||||||
try {
|
try {
|
||||||
@@ -164,24 +254,6 @@ const SettingsPage = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePullSelectedChanges = async selectedChanges => {
|
|
||||||
setLoadingAction('pull_changes');
|
|
||||||
try {
|
|
||||||
const response = await pullBranch(changes.branch, selectedChanges);
|
|
||||||
if (response.success) {
|
|
||||||
await fetchGitStatus();
|
|
||||||
Alert.success(response.message);
|
|
||||||
} else {
|
|
||||||
Alert.error(response.error);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
Alert.error('An unexpected error occurred while pulling changes.');
|
|
||||||
console.error('Error pulling changes:', error);
|
|
||||||
} finally {
|
|
||||||
setLoadingAction('');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<nav className='flex space-x-4'>
|
<nav className='flex space-x-4'>
|
||||||
@@ -236,13 +308,18 @@ const SettingsPage = () => {
|
|||||||
status={changes}
|
status={changes}
|
||||||
isDevMode={isDevMode}
|
isDevMode={isDevMode}
|
||||||
onStageSelected={handleStageSelectedChanges}
|
onStageSelected={handleStageSelectedChanges}
|
||||||
|
onUnstageSelected={
|
||||||
|
handleUnstageSelectedChanges
|
||||||
|
}
|
||||||
onCommitSelected={
|
onCommitSelected={
|
||||||
handleCommitSelectedChanges
|
handleCommitSelectedChanges
|
||||||
}
|
}
|
||||||
|
onPushSelected={handlePushChanges}
|
||||||
onRevertSelected={
|
onRevertSelected={
|
||||||
handleRevertSelectedChanges
|
handleRevertSelectedChanges
|
||||||
}
|
}
|
||||||
onPullSelected={handlePullSelectedChanges}
|
onPullSelected={handlePullSelectedChanges}
|
||||||
|
fetchGitStatus={fetchGitStatus}
|
||||||
loadingAction={loadingAction}
|
loadingAction={loadingAction}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,64 +1,42 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {Loader, RotateCcw, Download, CheckCircle, Plus} from 'lucide-react';
|
import {
|
||||||
|
Loader,
|
||||||
|
RotateCcw,
|
||||||
|
Download,
|
||||||
|
CheckCircle,
|
||||||
|
Plus,
|
||||||
|
Upload
|
||||||
|
} from 'lucide-react';
|
||||||
import Tooltip from '../../ui/Tooltip';
|
import Tooltip from '../../ui/Tooltip';
|
||||||
|
|
||||||
const ActionButton = ({
|
|
||||||
onClick,
|
|
||||||
disabled,
|
|
||||||
loading,
|
|
||||||
icon,
|
|
||||||
text,
|
|
||||||
className,
|
|
||||||
disabledTooltip
|
|
||||||
}) => {
|
|
||||||
const baseClassName =
|
|
||||||
'flex items-center px-4 py-2 text-white rounded-md transition-all duration-200 ease-in-out text-xs';
|
|
||||||
const enabledClassName = `${baseClassName} ${className} hover:opacity-80`;
|
|
||||||
const disabledClassName = `${baseClassName} ${className} opacity-50 cursor-not-allowed`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip content={disabled ? disabledTooltip : text}>
|
|
||||||
<button
|
|
||||||
onClick={onClick}
|
|
||||||
className={disabled ? disabledClassName : enabledClassName}
|
|
||||||
disabled={disabled || loading}>
|
|
||||||
{loading ? (
|
|
||||||
<Loader size={12} className='animate-spin mr-1' />
|
|
||||||
) : (
|
|
||||||
React.cloneElement(icon, {className: 'mr-1', size: 12})
|
|
||||||
)}
|
|
||||||
{text}
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const ActionButtons = ({
|
const ActionButtons = ({
|
||||||
isDevMode,
|
isDevMode,
|
||||||
selectedOutgoingChanges,
|
selectedOutgoingChanges,
|
||||||
selectedIncomingChanges,
|
|
||||||
selectionType,
|
selectionType,
|
||||||
commitMessage,
|
commitMessage,
|
||||||
loadingAction,
|
loadingAction,
|
||||||
onStageSelected,
|
onStageSelected,
|
||||||
onCommitSelected,
|
onCommitSelected,
|
||||||
|
onPushSelected,
|
||||||
onRevertSelected,
|
onRevertSelected,
|
||||||
onPullSelected
|
hasUnpushedCommits
|
||||||
}) => {
|
}) => {
|
||||||
const canStage =
|
const canStage =
|
||||||
isDevMode &&
|
isDevMode &&
|
||||||
selectedOutgoingChanges.length > 0 &&
|
selectedOutgoingChanges.length > 0 &&
|
||||||
selectionType !== 'staged';
|
selectionType !== 'staged';
|
||||||
|
|
||||||
const canCommit =
|
const canCommit =
|
||||||
isDevMode &&
|
isDevMode &&
|
||||||
selectedOutgoingChanges.length > 0 &&
|
selectedOutgoingChanges.length > 0 &&
|
||||||
commitMessage.trim() &&
|
commitMessage.trim() &&
|
||||||
selectionType !== 'unstaged';
|
selectionType !== 'unstaged';
|
||||||
|
|
||||||
|
const canPush = isDevMode && hasUnpushedCommits;
|
||||||
const canRevert = selectedOutgoingChanges.length > 0;
|
const canRevert = selectedOutgoingChanges.length > 0;
|
||||||
const canPull = selectedIncomingChanges.length > 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='mt-4 flex justify-start space-x-2'>
|
<div className='space-x-2 flex flex-wrap gap-2'>
|
||||||
{isDevMode && (
|
{isDevMode && (
|
||||||
<>
|
<>
|
||||||
<ActionButton
|
<ActionButton
|
||||||
@@ -84,6 +62,17 @@ const ActionButtons = ({
|
|||||||
className='bg-blue-600'
|
className='bg-blue-600'
|
||||||
disabledTooltip='Select staged files and enter a commit message to enable committing'
|
disabledTooltip='Select staged files and enter a commit message to enable committing'
|
||||||
/>
|
/>
|
||||||
|
<ActionButton
|
||||||
|
onClick={onPushSelected}
|
||||||
|
disabled={!canPush}
|
||||||
|
loading={loadingAction === 'push_changes'}
|
||||||
|
icon={<Upload />}
|
||||||
|
text={`Push${hasUnpushedCommits ? ' Changes' : ''}`}
|
||||||
|
className='bg-purple-600'
|
||||||
|
disabledTooltip={
|
||||||
|
hasUnpushedCommits ? '' : 'No changes to push'
|
||||||
|
}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<ActionButton
|
<ActionButton
|
||||||
@@ -95,17 +84,39 @@ const ActionButtons = ({
|
|||||||
className='bg-red-600'
|
className='bg-red-600'
|
||||||
disabledTooltip='Select files to revert'
|
disabledTooltip='Select files to revert'
|
||||||
/>
|
/>
|
||||||
<ActionButton
|
|
||||||
onClick={() => onPullSelected(selectedIncomingChanges)}
|
|
||||||
disabled={!canPull}
|
|
||||||
loading={loadingAction === 'pull_changes'}
|
|
||||||
icon={<Download />}
|
|
||||||
text='Pull'
|
|
||||||
className='bg-yellow-600'
|
|
||||||
disabledTooltip='Select incoming changes to pull'
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ActionButton = ({
|
||||||
|
onClick,
|
||||||
|
disabled,
|
||||||
|
loading,
|
||||||
|
icon,
|
||||||
|
tooltip,
|
||||||
|
className,
|
||||||
|
disabledTooltip
|
||||||
|
}) => {
|
||||||
|
const baseClassName =
|
||||||
|
'flex items-center justify-center w-8 h-8 text-white rounded-md transition-all duration-200 ease-in-out hover:opacity-80';
|
||||||
|
const buttonClassName = `${baseClassName} ${className} ${
|
||||||
|
disabled ? 'opacity-50 cursor-not-allowed' : ''
|
||||||
|
}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip content={disabled ? disabledTooltip : tooltip}>
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className={buttonClassName}
|
||||||
|
disabled={disabled || loading}>
|
||||||
|
{loading ? (
|
||||||
|
<Loader size={14} className='animate-spin' />
|
||||||
|
) : (
|
||||||
|
React.cloneElement(icon, {size: 14})
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default ActionButtons;
|
export default ActionButtons;
|
||||||
|
|||||||
@@ -5,24 +5,17 @@ import {
|
|||||||
MinusCircle,
|
MinusCircle,
|
||||||
Edit,
|
Edit,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
AlertCircle,
|
AlertTriangle,
|
||||||
Code,
|
Code,
|
||||||
FileText,
|
FileText,
|
||||||
Settings,
|
Settings,
|
||||||
File
|
File
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import Tooltip from '../../ui/Tooltip';
|
import Tooltip from '../../ui/Tooltip';
|
||||||
import ViewDiff from './modal/ViewDiff';
|
import ViewChanges from './modal/ViewChanges';
|
||||||
|
|
||||||
const ChangeRow = ({
|
const ChangeRow = ({change, isSelected, onSelect, isIncoming, isDevMode}) => {
|
||||||
change,
|
const [showChanges, setShowChanges] = useState(false);
|
||||||
isSelected,
|
|
||||||
onSelect,
|
|
||||||
isIncoming,
|
|
||||||
isDevMode,
|
|
||||||
diffContent
|
|
||||||
}) => {
|
|
||||||
const [showDiff, setShowDiff] = useState(false);
|
|
||||||
|
|
||||||
const getStatusIcon = status => {
|
const getStatusIcon = status => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
@@ -40,7 +33,7 @@ const ChangeRow = ({
|
|||||||
case 'Renamed':
|
case 'Renamed':
|
||||||
return <GitBranch className='text-purple-400' size={16} />;
|
return <GitBranch className='text-purple-400' size={16} />;
|
||||||
default:
|
default:
|
||||||
return <AlertCircle className='text-gray-400' size={16} />;
|
return <AlertTriangle className='text-gray-400' size={16} />;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -57,27 +50,49 @@ const ChangeRow = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleViewDiff = e => {
|
const handleViewChanges = e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
console.log('Change Object: ', JSON.stringify(change, null, 2));
|
setShowChanges(true);
|
||||||
setShowDiff(true);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRowClick = () => {
|
||||||
|
if (!isIncoming && onSelect) {
|
||||||
|
onSelect(change.file_path);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Determine row classes based on whether it's incoming or selected
|
||||||
|
const rowClasses = `border-t border-gray-600 ${
|
||||||
|
isIncoming
|
||||||
|
? 'cursor-default'
|
||||||
|
: `cursor-pointer ${
|
||||||
|
isSelected ? 'bg-blue-700 bg-opacity-30' : 'hover:bg-gray-700'
|
||||||
|
}`
|
||||||
|
}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<tr
|
<tr className={rowClasses} onClick={handleRowClick}>
|
||||||
className={`border-t border-gray-600 cursor-pointer hover:bg-gray-700 ${
|
|
||||||
isSelected ? 'bg-gray-700' : ''
|
|
||||||
}`}
|
|
||||||
onClick={() => onSelect(change.file_path)}>
|
|
||||||
<td className='px-4 py-2 text-gray-300'>
|
<td className='px-4 py-2 text-gray-300'>
|
||||||
<div className='flex items-center'>
|
<div className='flex items-center relative'>
|
||||||
{getStatusIcon(change.status)}
|
{getStatusIcon(change.status)}
|
||||||
<span className='ml-2'>
|
<span className='ml-2'>
|
||||||
{change.staged
|
{change.staged
|
||||||
? `${change.status} (Staged)`
|
? `${change.status} (Staged)`
|
||||||
: change.status}
|
: change.status}
|
||||||
</span>
|
</span>
|
||||||
|
{isIncoming && change.will_conflict && (
|
||||||
|
<span
|
||||||
|
className='inline-block relative'
|
||||||
|
style={{zIndex: 1}}>
|
||||||
|
<Tooltip content='Potential Merge Conflict Detected'>
|
||||||
|
<AlertTriangle
|
||||||
|
className='text-yellow-400 ml-2'
|
||||||
|
size={16}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className='px-4 py-2 text-gray-300'>
|
<td className='px-4 py-2 text-gray-300'>
|
||||||
@@ -87,38 +102,59 @@ const ChangeRow = ({
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className='px-4 py-2 text-gray-300'>
|
<td className='px-4 py-2 text-gray-300'>
|
||||||
{change.name || 'Unnamed'}
|
{isIncoming ? (
|
||||||
|
change.incoming_name &&
|
||||||
|
change.incoming_name !== change.local_name ? (
|
||||||
|
<>
|
||||||
|
<span className='mr-3'>
|
||||||
|
<strong>Local:</strong>{' '}
|
||||||
|
{change.local_name || 'Unnamed'}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<strong>Incoming:</strong>{' '}
|
||||||
|
{change.incoming_name || 'Unnamed'}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
change.local_name ||
|
||||||
|
change.incoming_name ||
|
||||||
|
'Unnamed'
|
||||||
|
)
|
||||||
|
) : change.outgoing_name &&
|
||||||
|
change.outgoing_name !== change.prior_name ? (
|
||||||
|
<>
|
||||||
|
<span className='mr-3'>
|
||||||
|
<strong>Prior:</strong>{' '}
|
||||||
|
{change.prior_name || 'Unnamed'}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<strong>Outgoing:</strong>{' '}
|
||||||
|
{change.outgoing_name || 'Unnamed'}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
change.outgoing_name || change.prior_name || 'Unnamed'
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className='px-4 py-2 text-left align-middle'>
|
<td className='px-4 py-2 text-left align-middle'>
|
||||||
<Tooltip content='View differences'>
|
<Tooltip content='View changes'>
|
||||||
<button
|
<button
|
||||||
onClick={handleViewDiff}
|
onClick={handleViewChanges}
|
||||||
className='flex items-center justify-center px-2 py-1 bg-gray-600 text-white rounded hover:bg-gray-700 transition-colors text-xs'
|
className='flex items-center justify-center px-2 py-1 bg-gray-600 text-white rounded hover:bg-gray-700 transition-colors text-xs'
|
||||||
style={{width: '100%'}}>
|
style={{width: '100%'}}>
|
||||||
<Eye size={12} className='mr-1' />
|
<Eye size={12} className='mr-1' />
|
||||||
View Diff
|
Changes
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</td>
|
</td>
|
||||||
<td className='px-4 py-2 text-right text-gray-300 align-middle'>
|
|
||||||
<input
|
|
||||||
type='checkbox'
|
|
||||||
checked={isSelected}
|
|
||||||
onChange={e => e.stopPropagation()}
|
|
||||||
disabled={!isIncoming && change.staged}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
<ViewDiff
|
<ViewChanges
|
||||||
key={`${change.file_path}-diff`}
|
key={`${change.file_path}-changes`}
|
||||||
isOpen={showDiff}
|
isOpen={showChanges}
|
||||||
onClose={() => setShowDiff(false)}
|
onClose={() => setShowChanges(false)}
|
||||||
diffContent={diffContent}
|
change={change}
|
||||||
type={change.type}
|
|
||||||
name={change.name}
|
|
||||||
commitMessage={change.commit_message}
|
|
||||||
isDevMode={isDevMode}
|
|
||||||
isIncoming={isIncoming}
|
isIncoming={isIncoming}
|
||||||
|
isDevMode={isDevMode}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,21 +1,23 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {ArrowDown, ArrowUp} from 'lucide-react';
|
import {ArrowDown, ArrowUp} from 'lucide-react';
|
||||||
import ChangeRow from './ChangeRow';
|
import ChangeRow from './ChangeRow';
|
||||||
|
import ConflictRow from './ConflictRow';
|
||||||
|
|
||||||
const ChangeTable = ({
|
const ChangeTable = ({
|
||||||
changes,
|
changes,
|
||||||
title,
|
|
||||||
icon,
|
|
||||||
isIncoming,
|
isIncoming,
|
||||||
|
isMergeConflict,
|
||||||
selectedChanges,
|
selectedChanges,
|
||||||
onSelectChange,
|
onSelectChange,
|
||||||
sortConfig,
|
sortConfig,
|
||||||
onRequestSort,
|
onRequestSort,
|
||||||
isDevMode,
|
isDevMode,
|
||||||
diffContents
|
fetchGitStatus
|
||||||
}) => {
|
}) => {
|
||||||
const sortedChanges = changesArray => {
|
const sortedChanges = changesArray => {
|
||||||
if (!sortConfig.key) return changesArray;
|
// Don't sort if we're showing merge conflicts or if no sort config
|
||||||
|
if (isMergeConflict || !sortConfig?.key) return changesArray;
|
||||||
|
|
||||||
return [...changesArray].sort((a, b) => {
|
return [...changesArray].sort((a, b) => {
|
||||||
if (a[sortConfig.key] < b[sortConfig.key]) {
|
if (a[sortConfig.key] < b[sortConfig.key]) {
|
||||||
return sortConfig.direction === 'ascending' ? -1 : 1;
|
return sortConfig.direction === 'ascending' ? -1 : 1;
|
||||||
@@ -47,61 +49,49 @@ const ChangeTable = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='mb-4'>
|
|
||||||
<h4 className='text-sm font-medium text-gray-200 mb-4 flex items-center mt-3'>
|
|
||||||
{icon}
|
|
||||||
<span>
|
|
||||||
{isIncoming
|
|
||||||
? title
|
|
||||||
: isDevMode
|
|
||||||
? 'Outgoing Changes'
|
|
||||||
: 'Local Changes'}{' '}
|
|
||||||
({changes.length})
|
|
||||||
</span>
|
|
||||||
</h4>
|
|
||||||
<div className='border border-gray-600 rounded-md overflow-hidden'>
|
|
||||||
<table className='w-full text-sm'>
|
<table className='w-full text-sm'>
|
||||||
<thead className='bg-gray-600'>
|
<thead className='bg-gray-600'>
|
||||||
<tr>
|
<tr>
|
||||||
<SortableHeader sortKey='status' className='w-1/6'>
|
<SortableHeader sortKey='status' className='w-1/5'>
|
||||||
Status
|
Status
|
||||||
</SortableHeader>
|
</SortableHeader>
|
||||||
<SortableHeader sortKey='type' className='w-1/6'>
|
<SortableHeader sortKey='type' className='w-1/5'>
|
||||||
Type
|
Type
|
||||||
</SortableHeader>
|
</SortableHeader>
|
||||||
<SortableHeader sortKey='name' className='w-2/6'>
|
<SortableHeader sortKey='name' className='w-1/2'>
|
||||||
Name
|
Name
|
||||||
</SortableHeader>
|
</SortableHeader>
|
||||||
<th className='px-4 py-2 text-left text-gray-300 w-1/12'>
|
<th className='px-4 py-2 text-left text-gray-300 w-1/5'>
|
||||||
Actions
|
Actions
|
||||||
</th>
|
</th>
|
||||||
<th className='px-4 py-2 text-right text-gray-300 w-1/12'>
|
|
||||||
Select
|
|
||||||
</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{sortedChanges(changes).map((change, index) => (
|
{sortedChanges(changes).map((change, index) =>
|
||||||
|
isMergeConflict ? (
|
||||||
|
<ConflictRow
|
||||||
|
key={`merge-conflict-${index}`}
|
||||||
|
change={change}
|
||||||
|
isDevMode={isDevMode}
|
||||||
|
fetchGitStatus={fetchGitStatus}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<ChangeRow
|
<ChangeRow
|
||||||
key={`${
|
key={`${
|
||||||
isIncoming ? 'incoming' : 'outgoing'
|
isIncoming ? 'incoming' : 'outgoing'
|
||||||
}-${index}`}
|
}-${index}`}
|
||||||
change={change}
|
change={change}
|
||||||
isSelected={selectedChanges.includes(
|
isSelected={selectedChanges?.includes(
|
||||||
change.file_path
|
change.file_path
|
||||||
)}
|
)}
|
||||||
onSelect={filePath =>
|
onSelect={!isIncoming ? onSelectChange : null}
|
||||||
onSelectChange(filePath, isIncoming)
|
|
||||||
}
|
|
||||||
isIncoming={isIncoming}
|
isIncoming={isIncoming}
|
||||||
isDevMode={isDevMode}
|
isDevMode={isDevMode}
|
||||||
diffContent={diffContents[change.file_path]}
|
|
||||||
/>
|
/>
|
||||||
))}
|
)
|
||||||
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,57 +1,215 @@
|
|||||||
import React from 'react';
|
import React, {useState, useEffect} from 'react';
|
||||||
import Textarea from '../../ui/TextArea';
|
|
||||||
|
|
||||||
const CommitSection = ({
|
const COMMIT_TYPES = [
|
||||||
status,
|
{value: 'feat', label: 'Feature', description: 'A new feature'},
|
||||||
commitMessage,
|
{value: 'fix', label: 'Bug Fix', description: 'A bug fix'},
|
||||||
setCommitMessage,
|
{
|
||||||
hasIncomingChanges,
|
value: 'docs',
|
||||||
funMessage,
|
label: 'Documentation',
|
||||||
isDevMode
|
description: 'Documentation only changes'
|
||||||
}) => {
|
},
|
||||||
const hasUnstagedChanges = status.outgoing_changes.some(
|
{
|
||||||
change => !change.staged || (change.staged && change.modified)
|
value: 'style',
|
||||||
);
|
label: 'Style',
|
||||||
const hasStagedChanges = status.outgoing_changes.some(
|
description: 'Changes that do not affect code meaning'
|
||||||
change => change.staged
|
},
|
||||||
);
|
{
|
||||||
const hasAnyChanges = status.outgoing_changes.length > 0;
|
value: 'refactor',
|
||||||
|
label: 'Refactor',
|
||||||
|
description: 'Code change that neither fixes a bug nor adds a feature'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'perf',
|
||||||
|
label: 'Performance',
|
||||||
|
description: 'A code change that improves performance'
|
||||||
|
},
|
||||||
|
{value: 'test', label: 'Test', description: 'Adding or correcting tests'},
|
||||||
|
{
|
||||||
|
value: 'chore',
|
||||||
|
label: 'Chore',
|
||||||
|
description: "Other changes that don't modify src or test files"
|
||||||
|
},
|
||||||
|
{value: 'custom', label: 'Custom', description: 'Custom type'}
|
||||||
|
];
|
||||||
|
|
||||||
|
const SCOPES = [
|
||||||
|
{
|
||||||
|
value: 'regex',
|
||||||
|
label: 'Regex',
|
||||||
|
description: 'Changes related to regex patterns'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'format',
|
||||||
|
label: 'Format',
|
||||||
|
description: 'Changes related to custom formats'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'profile',
|
||||||
|
label: 'Profile',
|
||||||
|
description: 'Changes related to quality profiles'
|
||||||
|
},
|
||||||
|
{value: 'custom', label: 'Custom', description: 'Custom scope'}
|
||||||
|
];
|
||||||
|
|
||||||
|
const formatBodyLines = text => {
|
||||||
|
if (!text) return '';
|
||||||
|
return text
|
||||||
|
.split('\n')
|
||||||
|
.map(line => {
|
||||||
|
const trimmedLine = line.trim();
|
||||||
|
if (!trimmedLine) return '';
|
||||||
|
const cleanLine = trimmedLine.startsWith('- ')
|
||||||
|
? trimmedLine.substring(2).trim()
|
||||||
|
: trimmedLine;
|
||||||
|
return cleanLine ? `- ${cleanLine}` : '';
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n');
|
||||||
|
};
|
||||||
|
|
||||||
|
const CommitSection = ({commitMessage, setCommitMessage}) => {
|
||||||
|
const [type, setType] = useState('');
|
||||||
|
const [customType, setCustomType] = useState('');
|
||||||
|
const [scope, setScope] = useState('');
|
||||||
|
const [customScope, setCustomScope] = useState('');
|
||||||
|
const [subject, setSubject] = useState('');
|
||||||
|
const [body, setBody] = useState('');
|
||||||
|
const [footer, setFooter] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const effectiveType = type === 'custom' ? customType : type;
|
||||||
|
const effectiveScope = scope === 'custom' ? customScope : scope;
|
||||||
|
|
||||||
|
if (effectiveType && subject) {
|
||||||
|
let message = `${effectiveType}${
|
||||||
|
effectiveScope ? `(${effectiveScope})` : ''
|
||||||
|
}: ${subject}`;
|
||||||
|
|
||||||
|
if (body) {
|
||||||
|
message += `\n\n${formatBodyLines(body)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (footer) {
|
||||||
|
message += `\n\n${footer}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCommitMessage(message);
|
||||||
|
} else {
|
||||||
|
setCommitMessage('');
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
type,
|
||||||
|
customType,
|
||||||
|
scope,
|
||||||
|
customScope,
|
||||||
|
subject,
|
||||||
|
body,
|
||||||
|
footer,
|
||||||
|
setCommitMessage
|
||||||
|
]);
|
||||||
|
|
||||||
|
const selectStyles =
|
||||||
|
'bg-gray-700 text-sm text-gray-200 focus:outline-none focus:bg-gray-600 hover:bg-gray-600 transition-colors duration-150';
|
||||||
|
const inputStyles = 'bg-gray-700 text-sm text-gray-200 focus:outline-none';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='mt-4'>
|
<div className='mt-4'>
|
||||||
{isDevMode ? (
|
<div className='bg-gray-800 rounded-md overflow-hidden border border-gray-700 shadow-sm'>
|
||||||
<>
|
<div className='flex items-center bg-gray-700 border-b border-gray-600'>
|
||||||
{hasAnyChanges || hasIncomingChanges ? (
|
<div className='flex-none w-64 border-r border-gray-600'>
|
||||||
<>
|
<div className='relative'>
|
||||||
{hasStagedChanges && (
|
<select
|
||||||
<>
|
value={type}
|
||||||
<h3 className='text-sm font-semibold text-gray-100 mb-4'>
|
onChange={e => setType(e.target.value)}
|
||||||
Commit Message:
|
className={`w-full px-3 py-2.5 appearance-none cursor-pointer ${selectStyles}`}>
|
||||||
</h3>
|
<option value='' disabled>
|
||||||
<Textarea
|
Select Type
|
||||||
value={commitMessage}
|
</option>
|
||||||
onChange={e =>
|
{COMMIT_TYPES.map(({value, label}) => (
|
||||||
setCommitMessage(e.target.value)
|
<option key={value} value={value}>
|
||||||
}
|
{label}
|
||||||
placeholder='Enter your commit message here...'
|
</option>
|
||||||
className='w-full p-2 text-sm text-gray-200 bg-gray-600 border border-gray-500 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 resize-y h-[75px] mb-2'
|
))}
|
||||||
|
</select>
|
||||||
|
<div className='absolute inset-y-0 right-0 flex items-center px-2 pointer-events-none'>
|
||||||
|
<svg
|
||||||
|
className='h-4 w-4 fill-current text-gray-400'
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
viewBox='0 0 20 20'>
|
||||||
|
<path d='M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z' />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{type === 'custom' && (
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
value={customType}
|
||||||
|
onChange={e => setCustomType(e.target.value)}
|
||||||
|
placeholder='Enter custom type'
|
||||||
|
className={`w-full px-3 py-2 border-t border-gray-600 bg-gray-800 ${inputStyles}`}
|
||||||
/>
|
/>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className='text-gray-300 text-sm italic'>
|
|
||||||
{funMessage}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</>
|
<div className='flex-none w-64 border-r border-gray-600'>
|
||||||
) : (
|
<div className='relative'>
|
||||||
<div className='text-gray-300 text-sm italic'>
|
<select
|
||||||
Developer mode is disabled. Commit functionality is not
|
value={scope}
|
||||||
available.
|
onChange={e => setScope(e.target.value)}
|
||||||
|
className={`w-full px-3 py-2.5 appearance-none cursor-pointer ${selectStyles}`}>
|
||||||
|
<option value=''>No Scope</option>
|
||||||
|
{SCOPES.map(({value, label}) => (
|
||||||
|
<option key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<div className='absolute inset-y-0 right-0 flex items-center px-2 pointer-events-none'>
|
||||||
|
<svg
|
||||||
|
className='h-4 w-4 fill-current text-gray-400'
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
viewBox='0 0 20 20'>
|
||||||
|
<path d='M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z' />
|
||||||
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
{scope === 'custom' && (
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
value={customScope}
|
||||||
|
onChange={e => setCustomScope(e.target.value)}
|
||||||
|
placeholder='Enter custom scope'
|
||||||
|
className={`w-full px-3 py-2 border-t border-gray-600 bg-gray-800 ${inputStyles}`}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
value={subject}
|
||||||
|
onChange={e => setSubject(e.target.value)}
|
||||||
|
placeholder='Brief description of the changes'
|
||||||
|
maxLength={50}
|
||||||
|
className={`flex-1 px-3 py-2.5 ${inputStyles}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
value={body}
|
||||||
|
onChange={e => setBody(e.target.value)}
|
||||||
|
placeholder='Detailed description of changes'
|
||||||
|
className={`w-full px-3 py-3 resize-none h-32 border-b border-gray-600 bg-gray-800 ${inputStyles}`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
value={footer}
|
||||||
|
onChange={e => setFooter(e.target.value)}
|
||||||
|
placeholder='References to issues, PRs (optional)'
|
||||||
|
className={`w-full px-3 py-2.5 bg-gray-800 ${inputStyles}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
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 React, {useState, useEffect} from 'react';
|
||||||
import {GitMerge, ArrowUpFromLine, ArrowDownToLine} from 'lucide-react';
|
import {
|
||||||
|
GitMerge,
|
||||||
|
ArrowUpFromLine,
|
||||||
|
ArrowDownToLine,
|
||||||
|
AlertTriangle,
|
||||||
|
Download,
|
||||||
|
Plus,
|
||||||
|
CheckCircle,
|
||||||
|
RotateCcw,
|
||||||
|
Upload,
|
||||||
|
MinusCircle,
|
||||||
|
XCircle
|
||||||
|
} from 'lucide-react';
|
||||||
import ChangeTable from './ChangeTable';
|
import ChangeTable from './ChangeTable';
|
||||||
|
import ConflictTable from './ConflictTable';
|
||||||
import CommitSection from './CommitMessage';
|
import CommitSection from './CommitMessage';
|
||||||
|
import Modal from '../../ui/Modal';
|
||||||
|
import Tooltip from '../../ui/Tooltip';
|
||||||
import {getRandomMessage, noChangesMessages} from '../../../utils/messages';
|
import {getRandomMessage, noChangesMessages} from '../../../utils/messages';
|
||||||
import ActionButtons from './ActionButtons';
|
import IconButton from '../../ui/IconButton';
|
||||||
import {getDiff} from '../../../api/api';
|
import {abortMerge, finalizeMerge} from '../../../api/api';
|
||||||
|
import Alert from '../../ui/Alert';
|
||||||
|
|
||||||
const StatusContainer = ({
|
const StatusContainer = ({
|
||||||
status,
|
status,
|
||||||
isDevMode,
|
isDevMode,
|
||||||
onStageSelected,
|
onStageSelected,
|
||||||
|
onUnstageSelected,
|
||||||
onCommitSelected,
|
onCommitSelected,
|
||||||
onRevertSelected,
|
onRevertSelected,
|
||||||
onPullSelected,
|
onPullSelected,
|
||||||
loadingAction
|
onPushSelected,
|
||||||
|
loadingAction,
|
||||||
|
fetchGitStatus
|
||||||
}) => {
|
}) => {
|
||||||
const [sortConfig, setSortConfig] = useState({
|
const [sortConfig, setSortConfig] = useState({
|
||||||
key: 'type',
|
key: 'type',
|
||||||
direction: 'ascending'
|
direction: 'ascending'
|
||||||
});
|
});
|
||||||
const [selectedIncomingChanges, setSelectedIncomingChanges] = useState([]);
|
|
||||||
const [selectedOutgoingChanges, setSelectedOutgoingChanges] = useState([]);
|
const [selectedOutgoingChanges, setSelectedOutgoingChanges] = useState([]);
|
||||||
|
const [selectedMergeConflicts, setSelectedMergeConflicts] = useState([]);
|
||||||
const [commitMessage, setCommitMessage] = useState('');
|
const [commitMessage, setCommitMessage] = useState('');
|
||||||
const [selectionType, setSelectionType] = useState(null);
|
const [selectionType, setSelectionType] = useState(null);
|
||||||
const [noChangesMessage, setNoChangesMessage] = useState('');
|
const [noChangesMessage, setNoChangesMessage] = useState('');
|
||||||
const [diffContents, setDiffContents] = useState({});
|
const [isAbortModalOpen, setIsAbortModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const canStage =
|
||||||
|
selectedOutgoingChanges.length > 0 && selectionType !== 'staged';
|
||||||
|
|
||||||
|
const canCommit =
|
||||||
|
selectedOutgoingChanges.length > 0 &&
|
||||||
|
selectionType === 'staged' &&
|
||||||
|
commitMessage.trim().length > 0;
|
||||||
|
|
||||||
|
const canRevert = selectedOutgoingChanges.length > 0;
|
||||||
|
const canPush = isDevMode && status.has_unpushed_commits;
|
||||||
|
|
||||||
const requestSort = key => {
|
const requestSort = key => {
|
||||||
let direction = 'ascending';
|
let direction = 'ascending';
|
||||||
@@ -34,48 +64,61 @@ const StatusContainer = ({
|
|||||||
setSortConfig({key, direction});
|
setSortConfig({key, direction});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectChange = (filePath, isIncoming) => {
|
const handleSelectChange = filePath => {
|
||||||
if (isIncoming) {
|
|
||||||
if (selectedOutgoingChanges.length > 0) {
|
|
||||||
setSelectedOutgoingChanges([]);
|
|
||||||
}
|
|
||||||
setSelectedIncomingChanges(prevSelected => {
|
|
||||||
if (prevSelected.includes(filePath)) {
|
|
||||||
return prevSelected.filter(path => path !== filePath);
|
|
||||||
} else {
|
|
||||||
return [...prevSelected, filePath];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
if (selectedIncomingChanges.length > 0) {
|
|
||||||
setSelectedIncomingChanges([]);
|
|
||||||
}
|
|
||||||
const change = status.outgoing_changes.find(
|
const change = status.outgoing_changes.find(
|
||||||
c => c.file_path === filePath
|
c => c.file_path === filePath
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!change) {
|
||||||
|
console.error('Could not find change for file path:', filePath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const isStaged = change.staged;
|
const isStaged = change.staged;
|
||||||
|
|
||||||
|
console.log('Selection change:', {
|
||||||
|
filePath,
|
||||||
|
isStaged,
|
||||||
|
currentSelectionType: selectionType,
|
||||||
|
currentSelected: selectedOutgoingChanges
|
||||||
|
});
|
||||||
|
|
||||||
setSelectedOutgoingChanges(prevSelected => {
|
setSelectedOutgoingChanges(prevSelected => {
|
||||||
if (prevSelected.includes(filePath)) {
|
if (prevSelected.includes(filePath)) {
|
||||||
|
// Deselecting a file
|
||||||
const newSelection = prevSelected.filter(
|
const newSelection = prevSelected.filter(
|
||||||
path => path !== filePath
|
path => path !== filePath
|
||||||
);
|
);
|
||||||
if (newSelection.length === 0) setSelectionType(null);
|
// If no more files are selected, reset selection type
|
||||||
|
if (newSelection.length === 0) {
|
||||||
|
setSelectionType(null);
|
||||||
|
}
|
||||||
return newSelection;
|
return newSelection;
|
||||||
} else {
|
} else {
|
||||||
if (
|
// Selecting a file
|
||||||
prevSelected.length === 0 ||
|
if (prevSelected.length === 0) {
|
||||||
|
// First selection sets the type
|
||||||
|
setSelectionType(isStaged ? 'staged' : 'unstaged');
|
||||||
|
return [filePath];
|
||||||
|
} else if (
|
||||||
(isStaged && selectionType === 'staged') ||
|
(isStaged && selectionType === 'staged') ||
|
||||||
(!isStaged && selectionType === 'unstaged')
|
(!isStaged && selectionType === 'unstaged')
|
||||||
) {
|
) {
|
||||||
setSelectionType(isStaged ? 'staged' : 'unstaged');
|
// Only allow selection if it matches current type
|
||||||
return [...prevSelected, filePath];
|
return [...prevSelected, filePath];
|
||||||
} else {
|
}
|
||||||
|
// Don't add if it doesn't match the type
|
||||||
return prevSelected;
|
return prevSelected;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCommitSelected = (files, message) => {
|
||||||
|
if (!message.trim()) {
|
||||||
|
console.error('Commit message cannot be empty');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
onCommitSelected(files, message);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStageButtonTooltip = () => {
|
const getStageButtonTooltip = () => {
|
||||||
@@ -85,11 +128,21 @@ const StatusContainer = ({
|
|||||||
if (selectedOutgoingChanges.length === 0) {
|
if (selectedOutgoingChanges.length === 0) {
|
||||||
return 'Select files to stage';
|
return 'Select files to stage';
|
||||||
}
|
}
|
||||||
return 'Stage selected files';
|
return 'Stage Changes';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUnstageButtonTooltip = () => {
|
||||||
|
if (selectionType === 'unstaged') {
|
||||||
|
return 'These files are not staged';
|
||||||
|
}
|
||||||
|
if (selectedOutgoingChanges.length === 0) {
|
||||||
|
return 'Select files to unstage';
|
||||||
|
}
|
||||||
|
return 'Unstage Changes';
|
||||||
};
|
};
|
||||||
|
|
||||||
const getCommitButtonTooltip = () => {
|
const getCommitButtonTooltip = () => {
|
||||||
if (selectionType === 'unstaged') {
|
if (selectionType !== 'staged') {
|
||||||
return 'You can only commit staged files';
|
return 'You can only commit staged files';
|
||||||
}
|
}
|
||||||
if (selectedOutgoingChanges.length === 0) {
|
if (selectedOutgoingChanges.length === 0) {
|
||||||
@@ -98,50 +151,85 @@ const StatusContainer = ({
|
|||||||
if (!commitMessage.trim()) {
|
if (!commitMessage.trim()) {
|
||||||
return 'Enter a commit message';
|
return 'Enter a commit message';
|
||||||
}
|
}
|
||||||
return 'Commit selected files';
|
return 'Commit Changes';
|
||||||
};
|
};
|
||||||
|
|
||||||
const getRevertButtonTooltip = () => {
|
const getRevertButtonTooltip = () => {
|
||||||
if (selectedOutgoingChanges.length === 0) {
|
if (selectedOutgoingChanges.length === 0) {
|
||||||
return 'Select files to revert';
|
return 'Select files to revert';
|
||||||
}
|
}
|
||||||
return 'Revert selected files';
|
return 'Revert Changes';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAbortMergeClick = () => {
|
||||||
|
setIsAbortModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmAbortMerge = async () => {
|
||||||
|
try {
|
||||||
|
const response = await abortMerge();
|
||||||
|
if (response.success) {
|
||||||
|
await fetchGitStatus();
|
||||||
|
Alert.success(response.message);
|
||||||
|
} else {
|
||||||
|
Alert.error(response.error);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error aborting merge:', error);
|
||||||
|
Alert.error(
|
||||||
|
'An unexpected error occurred while aborting the merge.'
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsAbortModalOpen(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchDiffs = async () => {
|
|
||||||
const allChanges = [
|
|
||||||
...status.incoming_changes,
|
|
||||||
...status.outgoing_changes
|
|
||||||
];
|
|
||||||
const diffPromises = allChanges.map(change =>
|
|
||||||
getDiff(change.file_path)
|
|
||||||
);
|
|
||||||
const diffs = await Promise.all(diffPromises);
|
|
||||||
|
|
||||||
const newDiffContents = {};
|
|
||||||
allChanges.forEach((change, index) => {
|
|
||||||
if (diffs[index].success) {
|
|
||||||
newDiffContents[change.file_path] = diffs[index].diff;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
setDiffContents(newDiffContents);
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchDiffs();
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
status.incoming_changes.length === 0 &&
|
status.incoming_changes.length === 0 &&
|
||||||
status.outgoing_changes.length === 0
|
status.outgoing_changes.length === 0 &&
|
||||||
|
status.merge_conflicts.length === 0 &&
|
||||||
|
(!isDevMode || !status.has_unpushed_commits)
|
||||||
) {
|
) {
|
||||||
setNoChangesMessage(getRandomMessage(noChangesMessages));
|
setNoChangesMessage(getRandomMessage(noChangesMessages));
|
||||||
}
|
}
|
||||||
}, [status]);
|
}, [status, isDevMode]);
|
||||||
|
|
||||||
|
// Reset commit message when selection changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectionType !== 'staged') {
|
||||||
|
setCommitMessage('');
|
||||||
|
}
|
||||||
|
}, [selectionType]);
|
||||||
|
|
||||||
const hasChanges =
|
const hasChanges =
|
||||||
status.incoming_changes.length > 0 ||
|
status.incoming_changes.length > 0 ||
|
||||||
status.outgoing_changes.length > 0;
|
status.outgoing_changes.length > 0 ||
|
||||||
|
status.merge_conflicts.length > 0 ||
|
||||||
|
(isDevMode && status.has_unpushed_commits);
|
||||||
|
|
||||||
|
const areAllConflictsResolved = () => {
|
||||||
|
return status.merge_conflicts.every(
|
||||||
|
conflict => conflict.status === 'RESOLVED'
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMergeCommit = async () => {
|
||||||
|
try {
|
||||||
|
const response = await finalizeMerge();
|
||||||
|
if (response.success) {
|
||||||
|
await fetchGitStatus();
|
||||||
|
Alert.success(response.message);
|
||||||
|
} else {
|
||||||
|
Alert.error(response.error);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error finalizing merge:', error);
|
||||||
|
Alert.error(
|
||||||
|
'An unexpected error occurred while finalizing the merge.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='dark:bg-gray-800 border border-gray-200 dark:border-gray-700 p-4 rounded-md'>
|
<div className='dark:bg-gray-800 border border-gray-200 dark:border-gray-700 p-4 rounded-md'>
|
||||||
@@ -156,102 +244,283 @@ const StatusContainer = ({
|
|||||||
{noChangesMessage}
|
{noChangesMessage}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className='text-gray-400 text-m flex items-center'>
|
<span className='text-gray-400 text-m flex items-center space-x-2'>
|
||||||
Out of Date!
|
<span>Out of Date!</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!hasChanges && (
|
|
||||||
<div className='flex-shrink-0'>
|
|
||||||
<ActionButtons
|
|
||||||
isDevMode={isDevMode}
|
|
||||||
selectedOutgoingChanges={selectedOutgoingChanges}
|
|
||||||
selectedIncomingChanges={selectedIncomingChanges}
|
|
||||||
selectionType={selectionType}
|
|
||||||
commitMessage={commitMessage}
|
|
||||||
loadingAction={loadingAction}
|
|
||||||
onStageSelected={onStageSelected}
|
|
||||||
onCommitSelected={onCommitSelected}
|
|
||||||
onRevertSelected={onRevertSelected}
|
|
||||||
onPullSelected={onPullSelected}
|
|
||||||
getStageButtonTooltip={getStageButtonTooltip}
|
|
||||||
getCommitButtonTooltip={getCommitButtonTooltip}
|
|
||||||
getRevertButtonTooltip={getRevertButtonTooltip}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{status.is_merging ? (
|
||||||
|
<div className='mb-4'>
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<h4 className='text-sm font-medium text-gray-200 flex items-center'>
|
||||||
|
<AlertTriangle
|
||||||
|
className='text-yellow-400 mr-2'
|
||||||
|
size={16}
|
||||||
|
/>
|
||||||
|
<span>Merge Conflicts</span>
|
||||||
|
</h4>
|
||||||
|
<div className='flex space-x-2'>
|
||||||
|
<Tooltip
|
||||||
|
content={
|
||||||
|
areAllConflictsResolved()
|
||||||
|
? 'Commit merge changes'
|
||||||
|
: 'Resolve all conflicts first'
|
||||||
|
}>
|
||||||
|
<button
|
||||||
|
onClick={handleMergeCommit}
|
||||||
|
disabled={!areAllConflictsResolved()}
|
||||||
|
className={`p-1.5 rounded-md text-white focus:outline-none focus:ring-2 focus:ring-offset-2
|
||||||
|
${
|
||||||
|
areAllConflictsResolved()
|
||||||
|
? 'bg-green-500 hover:bg-green-600 focus:ring-green-500'
|
||||||
|
: 'bg-gray-400 cursor-not-allowed'
|
||||||
|
}`}>
|
||||||
|
<CheckCircle size={16} />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip content='Abort Merge'>
|
||||||
|
<button
|
||||||
|
onClick={handleAbortMergeClick}
|
||||||
|
className='p-1.5 text-white bg-red-600 rounded-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500'>
|
||||||
|
<XCircle size={16} />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ConflictTable
|
||||||
|
conflicts={status.merge_conflicts}
|
||||||
|
isDevMode={isDevMode}
|
||||||
|
fetchGitStatus={fetchGitStatus}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
{status.incoming_changes.length > 0 && (
|
{status.incoming_changes.length > 0 && (
|
||||||
<ChangeTable
|
<div className='mb-4'>
|
||||||
changes={status.incoming_changes}
|
<div className='flex items-center justify-between mb-2'>
|
||||||
title='Incoming Changes'
|
<h4 className='text-sm font-medium text-gray-200 flex items-center'>
|
||||||
icon={
|
|
||||||
<ArrowDownToLine
|
<ArrowDownToLine
|
||||||
className='text-blue-400 mr-2'
|
className='text-blue-400 mr-2'
|
||||||
size={16}
|
size={16}
|
||||||
/>
|
/>
|
||||||
}
|
<span>
|
||||||
|
Incoming Changes (
|
||||||
|
{status.incoming_changes.length})
|
||||||
|
</span>
|
||||||
|
</h4>
|
||||||
|
<IconButton
|
||||||
|
onClick={onPullSelected}
|
||||||
|
disabled={false}
|
||||||
|
loading={loadingAction === 'pull_changes'}
|
||||||
|
icon={<Download />}
|
||||||
|
tooltip='Pull Changes'
|
||||||
|
className='bg-yellow-600'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className='border border-gray-600 rounded-md overflow-hidden'>
|
||||||
|
<ChangeTable
|
||||||
|
changes={status.incoming_changes}
|
||||||
isIncoming={true}
|
isIncoming={true}
|
||||||
selectedChanges={selectedIncomingChanges}
|
selectable={false}
|
||||||
onSelectChange={handleSelectChange}
|
selectedChanges={[]}
|
||||||
sortConfig={sortConfig}
|
sortConfig={sortConfig}
|
||||||
onRequestSort={requestSort}
|
onRequestSort={requestSort}
|
||||||
isDevMode={isDevMode}
|
isDevMode={isDevMode}
|
||||||
diffContents={diffContents}
|
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{status.outgoing_changes.length > 0 && (
|
{(status.outgoing_changes.length > 0 ||
|
||||||
<ChangeTable
|
(isDevMode && status.has_unpushed_commits)) && (
|
||||||
changes={status.outgoing_changes}
|
<div className='mb-4'>
|
||||||
title='Outgoing Changes'
|
<div className='flex items-center justify-between mb-2'>
|
||||||
icon={
|
<h4 className='text-sm font-medium text-gray-200 flex items-center'>
|
||||||
<ArrowUpFromLine
|
<ArrowUpFromLine
|
||||||
className='text-blue-400 mr-2'
|
className='text-blue-400 mr-2'
|
||||||
size={16}
|
size={16}
|
||||||
/>
|
/>
|
||||||
|
<span>
|
||||||
|
Outgoing Changes (
|
||||||
|
{status.outgoing_changes.length +
|
||||||
|
(isDevMode && status.unpushed_files
|
||||||
|
? status.unpushed_files.length
|
||||||
|
: 0)}
|
||||||
|
)
|
||||||
|
</span>
|
||||||
|
</h4>
|
||||||
|
<div className='space-x-2 flex'>
|
||||||
|
<IconButton
|
||||||
|
onClick={() =>
|
||||||
|
onStageSelected(
|
||||||
|
selectedOutgoingChanges
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
disabled={
|
||||||
|
selectionType !== 'unstaged' ||
|
||||||
|
selectedOutgoingChanges.length === 0
|
||||||
|
}
|
||||||
|
loading={
|
||||||
|
loadingAction === 'stage_selected'
|
||||||
|
}
|
||||||
|
icon={<Plus />}
|
||||||
|
tooltip='Stage Changes'
|
||||||
|
className='bg-green-600'
|
||||||
|
disabledTooltip={getStageButtonTooltip()}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
onClick={() =>
|
||||||
|
onUnstageSelected(
|
||||||
|
selectedOutgoingChanges
|
||||||
|
)
|
||||||
|
}
|
||||||
|
disabled={
|
||||||
|
selectionType !== 'staged' ||
|
||||||
|
selectedOutgoingChanges.length === 0
|
||||||
|
}
|
||||||
|
loading={
|
||||||
|
loadingAction === 'unstage_selected'
|
||||||
|
}
|
||||||
|
icon={<MinusCircle />}
|
||||||
|
tooltip='Unstage Changes'
|
||||||
|
className='bg-yellow-600'
|
||||||
|
disabledTooltip={getUnstageButtonTooltip()}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
onClick={() =>
|
||||||
|
handleCommitSelected(
|
||||||
|
selectedOutgoingChanges,
|
||||||
|
commitMessage
|
||||||
|
)
|
||||||
|
}
|
||||||
|
disabled={!canCommit}
|
||||||
|
loading={
|
||||||
|
loadingAction === 'commit_selected'
|
||||||
|
}
|
||||||
|
icon={<CheckCircle />}
|
||||||
|
tooltip='Commit Changes'
|
||||||
|
className='bg-blue-600'
|
||||||
|
disabledTooltip={getCommitButtonTooltip()}
|
||||||
|
/>
|
||||||
|
{isDevMode && (
|
||||||
|
<IconButton
|
||||||
|
onClick={onPushSelected}
|
||||||
|
disabled={!canPush}
|
||||||
|
loading={
|
||||||
|
loadingAction === 'push_changes'
|
||||||
|
}
|
||||||
|
icon={<Upload />}
|
||||||
|
tooltip={
|
||||||
|
<div>
|
||||||
|
<div>Push Changes</div>
|
||||||
|
{status.unpushed_files
|
||||||
|
?.length > 0 && (
|
||||||
|
<div className='mt-1 text-xs'>
|
||||||
|
{status.unpushed_files.map(
|
||||||
|
(
|
||||||
|
file,
|
||||||
|
index
|
||||||
|
) => (
|
||||||
|
<div
|
||||||
|
key={
|
||||||
|
index
|
||||||
|
}>
|
||||||
|
•{' '}
|
||||||
|
{
|
||||||
|
file.type
|
||||||
|
}
|
||||||
|
:{' '}
|
||||||
|
{
|
||||||
|
file.name
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
className='bg-purple-600'
|
||||||
|
disabledTooltip='No changes to push'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<IconButton
|
||||||
|
onClick={() =>
|
||||||
|
onRevertSelected(
|
||||||
|
selectedOutgoingChanges
|
||||||
|
)
|
||||||
|
}
|
||||||
|
disabled={!canRevert}
|
||||||
|
loading={
|
||||||
|
loadingAction === 'revert_selected'
|
||||||
|
}
|
||||||
|
icon={<RotateCcw />}
|
||||||
|
tooltip='Revert Changes'
|
||||||
|
className='bg-red-600'
|
||||||
|
disabledTooltip={getRevertButtonTooltip()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{status.outgoing_changes.length > 0 && (
|
||||||
|
<div className='border border-gray-600 rounded-md overflow-hidden'>
|
||||||
|
<ChangeTable
|
||||||
|
changes={status.outgoing_changes}
|
||||||
isIncoming={false}
|
isIncoming={false}
|
||||||
selectedChanges={selectedOutgoingChanges}
|
selectedChanges={
|
||||||
onSelectChange={handleSelectChange}
|
selectedOutgoingChanges
|
||||||
|
}
|
||||||
|
onSelectChange={filePath =>
|
||||||
|
handleSelectChange(filePath)
|
||||||
|
}
|
||||||
sortConfig={sortConfig}
|
sortConfig={sortConfig}
|
||||||
onRequestSort={requestSort}
|
onRequestSort={requestSort}
|
||||||
isDevMode={isDevMode}
|
isDevMode={isDevMode}
|
||||||
diffContents={diffContents}
|
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{hasChanges && (
|
{selectionType === 'staged' &&
|
||||||
<>
|
selectedOutgoingChanges.length > 0 && (
|
||||||
<CommitSection
|
<CommitSection
|
||||||
status={status}
|
status={status}
|
||||||
commitMessage={commitMessage}
|
commitMessage={commitMessage}
|
||||||
setCommitMessage={setCommitMessage}
|
setCommitMessage={setCommitMessage}
|
||||||
|
selectedOutgoingChanges={selectedOutgoingChanges}
|
||||||
loadingAction={loadingAction}
|
loadingAction={loadingAction}
|
||||||
hasIncomingChanges={status.incoming_changes.length > 0}
|
hasIncomingChanges={status.incoming_changes.length > 0}
|
||||||
|
hasMergeConflicts={status.merge_conflicts.length > 0}
|
||||||
isDevMode={isDevMode}
|
isDevMode={isDevMode}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className='mt-4 flex justify-end'>
|
|
||||||
<ActionButtons
|
|
||||||
isDevMode={isDevMode}
|
|
||||||
selectedOutgoingChanges={selectedOutgoingChanges}
|
|
||||||
selectedIncomingChanges={selectedIncomingChanges}
|
|
||||||
selectionType={selectionType}
|
|
||||||
commitMessage={commitMessage}
|
|
||||||
loadingAction={loadingAction}
|
|
||||||
onStageSelected={onStageSelected}
|
|
||||||
onCommitSelected={onCommitSelected}
|
|
||||||
onRevertSelected={onRevertSelected}
|
|
||||||
onPullSelected={onPullSelected}
|
|
||||||
getStageButtonTooltip={getStageButtonTooltip}
|
|
||||||
getCommitButtonTooltip={getCommitButtonTooltip}
|
|
||||||
getRevertButtonTooltip={getRevertButtonTooltip}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isOpen={isAbortModalOpen}
|
||||||
|
onClose={() => setIsAbortModalOpen(false)}
|
||||||
|
title='Confirm Abort Merge'
|
||||||
|
width='md'>
|
||||||
|
<div className='space-y-4'>
|
||||||
|
<div className='text-gray-700 dark:text-gray-300'>
|
||||||
|
<p>Are you sure you want to abort the current merge?</p>
|
||||||
|
<p className='mt-2 text-yellow-600 dark:text-yellow-400'>
|
||||||
|
This will discard all merge progress and restore
|
||||||
|
your repository to its state before the merge began.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className='flex justify-end space-x-3'>
|
||||||
|
<button
|
||||||
|
onClick={handleConfirmAbortMerge}
|
||||||
|
className='px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500'>
|
||||||
|
Abort Merge
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ const DiffCommit = ({commitMessage}) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='bg-gray-100 dark:bg-gray-800 p-4 rounded-lg'>
|
<div className='bg-gray-100 dark:bg-gray-800 p-4 rounded-lg'>
|
||||||
|
<div className='mb-2'>
|
||||||
|
<span className='text-xl font-semibold text-gray-700 dark:text-gray-300 mr-3'>
|
||||||
|
Details
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div className='flex items-start space-x-3'>
|
<div className='flex items-start space-x-3'>
|
||||||
<div className='flex-1'>
|
<div className='flex-1'>
|
||||||
<div className='mb-2'>
|
<div className='mb-2'>
|
||||||
|
|||||||
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');
|
Alert.success('Branch created successfully');
|
||||||
resetForm();
|
resetForm();
|
||||||
} else {
|
} else {
|
||||||
Alert.error(response.error);
|
Alert.error(response.error.error || response.error);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (
|
if (error.response?.status === 409) {
|
||||||
error.response &&
|
Alert.error(
|
||||||
error.response.status === 400 &&
|
'Cannot perform operation - merge in progress. Please resolve conflicts first.'
|
||||||
error.response.data.error
|
);
|
||||||
) {
|
} else if (error.response?.status === 400) {
|
||||||
Alert.error(error.response.data.error);
|
Alert.error(error.response.data.error);
|
||||||
} else {
|
} else {
|
||||||
console.error('Error branching off:', error);
|
console.error('Error branching off:', error);
|
||||||
@@ -139,14 +139,15 @@ const SettingsBranchModal = ({
|
|||||||
Alert.success('Branch checked out successfully');
|
Alert.success('Branch checked out successfully');
|
||||||
onClose();
|
onClose();
|
||||||
} else {
|
} else {
|
||||||
Alert.error(response.error);
|
// The error is nested inside result.error from the backend
|
||||||
|
Alert.error(response.error.error || response.error);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (
|
if (error.response?.status === 409) {
|
||||||
error.response &&
|
Alert.error(
|
||||||
error.response.status === 400 &&
|
'Cannot perform operation - merge in progress. Please resolve conflicts first.'
|
||||||
error.response.data.error
|
);
|
||||||
) {
|
} else if (error.response?.status === 400) {
|
||||||
Alert.error(error.response.data.error);
|
Alert.error(error.response.data.error);
|
||||||
} else {
|
} else {
|
||||||
Alert.error(
|
Alert.error(
|
||||||
@@ -179,13 +180,19 @@ const SettingsBranchModal = ({
|
|||||||
`Branch '${branchName}' deleted successfully`
|
`Branch '${branchName}' deleted successfully`
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
Alert.error(response.error);
|
Alert.error(response.error.error || response.error);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error.response?.status === 409) {
|
||||||
|
Alert.error(
|
||||||
|
'Cannot perform operation - merge in progress. Please resolve conflicts first.'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
Alert.error(
|
Alert.error(
|
||||||
'An unexpected error occurred while deleting the branch.'
|
'An unexpected error occurred while deleting the branch.'
|
||||||
);
|
);
|
||||||
console.error('Error deleting branch:', error);
|
console.error('Error deleting branch:', error);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingAction('');
|
setLoadingAction('');
|
||||||
setConfirmAction(null);
|
setConfirmAction(null);
|
||||||
@@ -194,7 +201,6 @@ const SettingsBranchModal = ({
|
|||||||
setConfirmAction(`delete-${branchName}`);
|
setConfirmAction(`delete-${branchName}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePushToRemote = async branchName => {
|
const handlePushToRemote = async branchName => {
|
||||||
if (confirmAction === `push-${branchName}`) {
|
if (confirmAction === `push-${branchName}`) {
|
||||||
setLoadingAction(`push-${branchName}`);
|
setLoadingAction(`push-${branchName}`);
|
||||||
@@ -206,13 +212,19 @@ const SettingsBranchModal = ({
|
|||||||
);
|
);
|
||||||
await fetchBranches();
|
await fetchBranches();
|
||||||
} else {
|
} else {
|
||||||
Alert.error(response.error);
|
Alert.error(response.error.error || response.error);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error.response?.status === 409) {
|
||||||
|
Alert.error(
|
||||||
|
'Cannot perform operation - merge in progress. Please resolve conflicts first.'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
Alert.error(
|
Alert.error(
|
||||||
'An unexpected error occurred while pushing the branch to remote.'
|
'An unexpected error occurred while pushing the branch to remote.'
|
||||||
);
|
);
|
||||||
console.error('Error pushing branch to remote:', error);
|
console.error('Error pushing branch to remote:', error);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingAction('');
|
setLoadingAction('');
|
||||||
setConfirmAction(null);
|
setConfirmAction(null);
|
||||||
|
|||||||
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,
|
level = 0,
|
||||||
disableCloseOnOutsideClick = false,
|
disableCloseOnOutsideClick = false,
|
||||||
disableCloseOnEscape = false,
|
disableCloseOnEscape = false,
|
||||||
width = 'lg',
|
width = 'auto',
|
||||||
height = 'auto'
|
height = 'auto',
|
||||||
|
maxHeight = '80vh'
|
||||||
}) {
|
}) {
|
||||||
const modalRef = useRef();
|
const modalRef = useRef();
|
||||||
|
|
||||||
@@ -39,6 +40,7 @@ function Modal({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const widthClasses = {
|
const widthClasses = {
|
||||||
|
auto: 'w-auto max-w-[60%]',
|
||||||
sm: 'max-w-sm',
|
sm: 'max-w-sm',
|
||||||
md: 'max-w-md',
|
md: 'max-w-md',
|
||||||
lg: 'max-w-lg',
|
lg: 'max-w-lg',
|
||||||
@@ -74,7 +76,7 @@ function Modal({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`fixed inset-0 overflow-y-auto h-full w-full flex items-center justify-center transition-opacity duration-300 ease-out ${
|
className={`fixed inset-0 overflow-y-auto h-full w-full flex items-center justify-center transition-opacity duration-300 ease-out scrollable ${
|
||||||
isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
||||||
}`}
|
}`}
|
||||||
style={{zIndex: 1000 + level * 10}}
|
style={{zIndex: 1000 + level * 10}}
|
||||||
@@ -86,7 +88,7 @@ function Modal({
|
|||||||
style={{zIndex: 1000 + level * 10}}></div>
|
style={{zIndex: 1000 + level * 10}}></div>
|
||||||
<div
|
<div
|
||||||
ref={modalRef}
|
ref={modalRef}
|
||||||
className={`relative bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full shadow-md ${
|
className={`relative bg-white dark:bg-gray-800 rounded-lg shadow-xl ${
|
||||||
widthClasses[width]
|
widthClasses[width]
|
||||||
} ${
|
} ${
|
||||||
heightClasses[height]
|
heightClasses[height]
|
||||||
@@ -95,7 +97,8 @@ function Modal({
|
|||||||
}`}
|
}`}
|
||||||
style={{
|
style={{
|
||||||
zIndex: 1001 + level * 10,
|
zIndex: 1001 + level * 10,
|
||||||
overflowY: 'auto'
|
overflowY: 'auto',
|
||||||
|
maxHeight: maxHeight
|
||||||
}}
|
}}
|
||||||
onClick={e => e.stopPropagation()}>
|
onClick={e => e.stopPropagation()}>
|
||||||
<div className='flex justify-between items-center px-6 py-4 pb-3 border-b border-gray-300 dark:border-gray-700'>
|
<div className='flex justify-between items-center px-6 py-4 pb-3 border-b border-gray-300 dark:border-gray-700'>
|
||||||
@@ -134,6 +137,7 @@ Modal.propTypes = {
|
|||||||
disableCloseOnOutsideClick: PropTypes.bool,
|
disableCloseOnOutsideClick: PropTypes.bool,
|
||||||
disableCloseOnEscape: PropTypes.bool,
|
disableCloseOnEscape: PropTypes.bool,
|
||||||
width: PropTypes.oneOf([
|
width: PropTypes.oneOf([
|
||||||
|
'auto',
|
||||||
'sm',
|
'sm',
|
||||||
'md',
|
'md',
|
||||||
'lg',
|
'lg',
|
||||||
@@ -164,7 +168,8 @@ Modal.propTypes = {
|
|||||||
'5xl',
|
'5xl',
|
||||||
'6xl',
|
'6xl',
|
||||||
'full'
|
'full'
|
||||||
])
|
]),
|
||||||
|
maxHeight: PropTypes.string
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Modal;
|
export default Modal;
|
||||||
|
|||||||
@@ -1,30 +1,28 @@
|
|||||||
import PropTypes from "prop-types";
|
import PropTypes from 'prop-types';
|
||||||
import { useState, useEffect, useRef } from "react";
|
import {useState, useEffect, useRef, useLayoutEffect} from 'react';
|
||||||
import { Link, useLocation } from "react-router-dom";
|
import {Link, useLocation} from 'react-router-dom';
|
||||||
|
|
||||||
function ToggleSwitch({ checked, onChange }) {
|
function ToggleSwitch({checked, onChange}) {
|
||||||
return (
|
return (
|
||||||
<label className="flex items-center cursor-pointer">
|
<label className='flex items-center cursor-pointer'>
|
||||||
<div className="relative">
|
<div className='relative'>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type='checkbox'
|
||||||
className="sr-only"
|
className='sr-only'
|
||||||
checked={checked}
|
checked={checked}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className={`block w-14 h-8 rounded-full ${
|
className={`block w-14 h-8 rounded-full ${
|
||||||
checked ? "bg-blue-600" : "bg-gray-600"
|
checked ? 'bg-blue-600' : 'bg-gray-600'
|
||||||
} transition-colors duration-300`}
|
} transition-colors duration-300`}></div>
|
||||||
></div>
|
|
||||||
<div
|
<div
|
||||||
className={`dot absolute left-1 top-1 bg-white w-6 h-6 rounded-full transition-transform duration-300 ${
|
className={`dot absolute left-1 top-1 bg-white w-6 h-6 rounded-full transition-transform duration-300 ${
|
||||||
checked ? "transform translate-x-6" : ""
|
checked ? 'transform translate-x-6' : ''
|
||||||
}`}
|
}`}></div>
|
||||||
></div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-3 text-gray-300 font-medium">
|
<div className='ml-3 text-gray-300 font-medium'>
|
||||||
{checked ? "Dark" : "Light"}
|
{checked ? 'Dark' : 'Light'}
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
@@ -32,86 +30,107 @@ function ToggleSwitch({ checked, onChange }) {
|
|||||||
|
|
||||||
ToggleSwitch.propTypes = {
|
ToggleSwitch.propTypes = {
|
||||||
checked: PropTypes.bool.isRequired,
|
checked: PropTypes.bool.isRequired,
|
||||||
onChange: PropTypes.func.isRequired,
|
onChange: PropTypes.func.isRequired
|
||||||
};
|
};
|
||||||
|
|
||||||
function Navbar({ darkMode, setDarkMode }) {
|
function Navbar({darkMode, setDarkMode}) {
|
||||||
const [tabOffset, setTabOffset] = useState(0);
|
const [tabOffset, setTabOffset] = useState(0);
|
||||||
const [tabWidth, setTabWidth] = useState(0);
|
const [tabWidth, setTabWidth] = useState(0);
|
||||||
const tabsRef = useRef({});
|
const tabsRef = useRef({});
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const [isInitialized, setIsInitialized] = useState(false);
|
||||||
|
|
||||||
const getActiveTab = (pathname) => {
|
const getActiveTab = pathname => {
|
||||||
if (pathname.startsWith("/regex")) return "regex";
|
if (pathname === '/' || pathname === '') return 'settings';
|
||||||
if (pathname.startsWith("/format")) return "format";
|
if (pathname.startsWith('/regex')) return 'regex';
|
||||||
if (pathname.startsWith("/profile")) return "profile";
|
if (pathname.startsWith('/format')) return 'format';
|
||||||
if (pathname.startsWith("/settings")) return "settings";
|
if (pathname.startsWith('/profile')) return 'profile';
|
||||||
return "settings"; // default to settings if no match
|
if (pathname.startsWith('/settings')) return 'settings';
|
||||||
|
return 'settings';
|
||||||
};
|
};
|
||||||
|
|
||||||
const activeTab = getActiveTab(location.pathname);
|
const activeTab = getActiveTab(location.pathname);
|
||||||
|
|
||||||
useEffect(() => {
|
const updateTabPosition = () => {
|
||||||
if (tabsRef.current[activeTab]) {
|
if (tabsRef.current[activeTab]) {
|
||||||
const tab = tabsRef.current[activeTab];
|
const tab = tabsRef.current[activeTab];
|
||||||
setTabOffset(tab.offsetLeft);
|
setTabOffset(tab.offsetLeft);
|
||||||
setTabWidth(tab.offsetWidth);
|
setTabWidth(tab.offsetWidth);
|
||||||
|
if (!isInitialized) {
|
||||||
|
setIsInitialized(true);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use useLayoutEffect for initial position
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
updateTabPosition();
|
||||||
|
}, [activeTab]);
|
||||||
|
|
||||||
|
// Use ResizeObserver to handle window resizing
|
||||||
|
useEffect(() => {
|
||||||
|
const resizeObserver = new ResizeObserver(updateTabPosition);
|
||||||
|
if (tabsRef.current[activeTab]) {
|
||||||
|
resizeObserver.observe(tabsRef.current[activeTab]);
|
||||||
|
}
|
||||||
|
return () => resizeObserver.disconnect();
|
||||||
}, [activeTab]);
|
}, [activeTab]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="bg-gray-800 shadow-md">
|
<nav className='bg-gray-800 shadow-md'>
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 relative">
|
<div className='max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 relative'>
|
||||||
<div className="flex items-center justify-between h-16">
|
<div className='flex items-center justify-between h-16'>
|
||||||
<div className="flex items-center space-x-8">
|
<div className='flex items-center space-x-8'>
|
||||||
<h1 className="text-2xl font-bold text-white">Profilarr</h1>
|
<h1 className='text-2xl font-bold text-white'>
|
||||||
<div className="relative flex space-x-2">
|
Profilarr
|
||||||
|
</h1>
|
||||||
|
<div className='relative flex space-x-2'>
|
||||||
|
{isInitialized && (
|
||||||
<div
|
<div
|
||||||
className="absolute top-0 bottom-0 bg-gray-900 rounded-md transition-all duration-300"
|
className='absolute top-0 bottom-0 bg-gray-900 rounded-md transition-all duration-300'
|
||||||
style={{ left: tabOffset, width: tabWidth }}
|
style={{
|
||||||
></div>
|
left: `${tabOffset}px`,
|
||||||
|
width: `${tabWidth}px`
|
||||||
|
}}></div>
|
||||||
|
)}
|
||||||
<Link
|
<Link
|
||||||
to="/regex"
|
to='/regex'
|
||||||
ref={(el) => (tabsRef.current["regex"] = el)}
|
ref={el => (tabsRef.current['regex'] = el)}
|
||||||
className={`px-3 py-2 rounded-md text-sm font-medium relative z-10 ${
|
className={`px-3 py-2 rounded-md text-sm font-medium relative z-10 ${
|
||||||
activeTab === "regex"
|
activeTab === 'regex'
|
||||||
? "text-white"
|
? 'text-white'
|
||||||
: "text-gray-300 hover:bg-gray-700 hover:text-white"
|
: 'text-gray-300 hover:bg-gray-700 hover:text-white'
|
||||||
}`}
|
}`}>
|
||||||
>
|
|
||||||
Regex Patterns
|
Regex Patterns
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="/format"
|
to='/format'
|
||||||
ref={(el) => (tabsRef.current["format"] = el)}
|
ref={el => (tabsRef.current['format'] = el)}
|
||||||
className={`px-3 py-2 rounded-md text-sm font-medium relative z-10 ${
|
className={`px-3 py-2 rounded-md text-sm font-medium relative z-10 ${
|
||||||
activeTab === "format"
|
activeTab === 'format'
|
||||||
? "text-white"
|
? 'text-white'
|
||||||
: "text-gray-300 hover:bg-gray-700 hover:text-white"
|
: 'text-gray-300 hover:bg-gray-700 hover:text-white'
|
||||||
}`}
|
}`}>
|
||||||
>
|
|
||||||
Custom Formats
|
Custom Formats
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="/profile"
|
to='/profile'
|
||||||
ref={(el) => (tabsRef.current["profile"] = el)}
|
ref={el => (tabsRef.current['profile'] = el)}
|
||||||
className={`px-3 py-2 rounded-md text-sm font-medium relative z-10 ${
|
className={`px-3 py-2 rounded-md text-sm font-medium relative z-10 ${
|
||||||
activeTab === "profile"
|
activeTab === 'profile'
|
||||||
? "text-white"
|
? 'text-white'
|
||||||
: "text-gray-300 hover:bg-gray-700 hover:text-white"
|
: 'text-gray-300 hover:bg-gray-700 hover:text-white'
|
||||||
}`}
|
}`}>
|
||||||
>
|
|
||||||
Quality Profiles
|
Quality Profiles
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="/settings"
|
to='/settings'
|
||||||
ref={(el) => (tabsRef.current["settings"] = el)}
|
ref={el => (tabsRef.current['settings'] = el)}
|
||||||
className={`px-3 py-2 rounded-md text-sm font-medium relative z-10 ${
|
className={`px-3 py-2 rounded-md text-sm font-medium relative z-10 ${
|
||||||
activeTab === "settings"
|
activeTab === 'settings'
|
||||||
? "text-white"
|
? 'text-white'
|
||||||
: "text-gray-300 hover:bg-gray-700 hover:text-white"
|
: 'text-gray-300 hover:bg-gray-700 hover:text-white'
|
||||||
}`}
|
}`}>
|
||||||
>
|
|
||||||
Settings
|
Settings
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -128,7 +147,7 @@ function Navbar({ darkMode, setDarkMode }) {
|
|||||||
|
|
||||||
Navbar.propTypes = {
|
Navbar.propTypes = {
|
||||||
darkMode: PropTypes.bool.isRequired,
|
darkMode: PropTypes.bool.isRequired,
|
||||||
setDarkMode: PropTypes.func.isRequired,
|
setDarkMode: PropTypes.func.isRequired
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Navbar;
|
export default Navbar;
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
const Tooltip = ({ content, children }) => {
|
const Tooltip = ({content, children}) => {
|
||||||
return (
|
return (
|
||||||
<div className="relative flex items-center group">
|
<div className='relative group'>
|
||||||
{children}
|
{children}
|
||||||
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-3 opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none">
|
<div className='absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none z-[9999]'>
|
||||||
<div className="bg-gray-900 text-white text-xs rounded py-1 px-2 shadow-lg whitespace-nowrap z-50">
|
<div className='bg-gray-900 text-white text-xs rounded py-1 px-2 shadow-lg whitespace-nowrap'>
|
||||||
{content}
|
{content}
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute w-2.5 h-2.5 bg-gray-900 transform rotate-45 -bottom-1 left-1/2 -translate-x-1/2"></div>
|
<div className='absolute w-2.5 h-2.5 bg-gray-900 transform rotate-45 -bottom-1 left-1/2 -translate-x-1/2'></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,17 +1,70 @@
|
|||||||
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Fira+Code:wght@400;500&display=swap");
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Fira+Code:wght@400;500&display=swap');
|
||||||
@import url("https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;700&display=swap");
|
@import url('https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;700&display=swap');
|
||||||
@import url("https://fonts.googleapis.com/css2?family=Schibsted+Grotesk:wght@400;700&display=swap");
|
@import url('https://fonts.googleapis.com/css2?family=Schibsted+Grotesk:wght@400;700&display=swap');
|
||||||
|
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
html {
|
html {
|
||||||
font-family: "Schibsted Grotesk", sans-serif;
|
font-family: 'Schibsted Grotesk', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
code,
|
code,
|
||||||
pre,
|
pre,
|
||||||
.font-mono {
|
.font-mono {
|
||||||
font-family: "Fira Code", monospace;
|
font-family: 'Fira Code', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom Scrollbar for Light Mode */
|
||||||
|
.scrollable::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollable::-webkit-scrollbar-track {
|
||||||
|
background: #f1f1f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollable::-webkit-scrollbar-thumb {
|
||||||
|
background-color: #c1c1c1;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 2px solid #f1f1f1;
|
||||||
|
transition: background-color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollable::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: #a1a1a1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom Scrollbar for Dark Mode */
|
||||||
|
.dark .scrollable::-webkit-scrollbar-track {
|
||||||
|
background: #1e293b; /* dark-100 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .scrollable::-webkit-scrollbar-thumb {
|
||||||
|
background-color: #334155; /* dark-200 */
|
||||||
|
border: 2px solid #1e293b; /* dark-100 */
|
||||||
|
transition: background-color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .scrollable::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: #475569; /* dark-300 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* For Firefox */
|
||||||
|
.scrollable {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: #c1c1c1 #f1f1f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollable:hover {
|
||||||
|
scrollbar-color: #a1a1a1 #f1f1f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .scrollable {
|
||||||
|
scrollbar-color: #334155 #1e293b; /* dark-200 and dark-100 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .scrollable:hover {
|
||||||
|
scrollbar-color: #475569 #1e293b; /* dark-300 and dark-100 */
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user