feature: merge conflict detection and resolution (#6)

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

2
.gitignore vendored
View File

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

View File

@@ -1,5 +1,3 @@
# app/__init__.py
import os
from flask import Flask, jsonify
from flask_cors import CORS
@@ -12,15 +10,17 @@ from .settings_utils import create_empty_settings_if_not_exists, load_settings
REGEX_DIR = os.path.join('data', 'db', 'regex_patterns')
FORMAT_DIR = os.path.join('data', 'db', 'custom_formats')
PROFILE_DIR = os.path.join('data', 'db', 'profiles')
DATA_DIR = '/app/data'
def create_app():
app = Flask(__name__)
CORS(app, resources={r"/*": {"origins": "*"}})
# Initialize directories and create empty settings file if it doesn't exist
initialize_directories()
create_empty_settings_if_not_exists()
# Register Blueprints
app.register_blueprint(regex_bp)
app.register_blueprint(format_bp)
@@ -35,7 +35,9 @@ def create_app():
return app
def initialize_directories():
os.makedirs(REGEX_DIR, exist_ok=True)
os.makedirs(FORMAT_DIR, exist_ok=True)
os.makedirs(PROFILE_DIR, exist_ok=True)
os.makedirs(DATA_DIR, exist_ok=True)

View File

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

View File

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

View File

@@ -1,10 +1,10 @@
# git/operations/commit.py
import git
import logging
logger = logging.getLogger(__name__)
def commit_changes(repo_path, files, message):
try:
repo = git.Repo(repo_path)
@@ -13,4 +13,4 @@ def commit_changes(repo_path, files, message):
return True, "Successfully committed changes."
except Exception as e:
logger.error(f"Error committing changes: {str(e)}", exc_info=True)
return False, f"Error committing changes: {str(e)}"
return False, f"Error committing changes: {str(e)}"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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