feat(backend): Major overhaul of backend structure, git integration, and settings management

- **Backend Refactor:**
  - Merged route and operation files for regex and format.
  - Updated directory structure and consolidated utility functions.
  - Removed unnecessary app.py, using `__init__.py` for app creation.

- **Git Integration:**
  - Enhanced git cloning and merging methods, ensuring accurate local file updates.
  - Implemented comprehensive git status fetching with improved file status display and error handling.
  - Added branch management features, including branch creation, checkout, deletion, and associated UI improvements.
  - Integrated loading indicators and fun messages for better user feedback during git operations.

- **Settings Manager Enhancements:**
  - Expanded Git status display, including connected repository link, branch information, and detailed change listings.
  - Added revert functionality for individual files and all changes, with conditional UI updates based on file statuses.
  - Integrated `react-toastify` for alert notifications with improved styling.
  - Improved file name parsing, handling of file paths, and consistent API request structure.
  - Added UI components for a smooth tab transition and enhanced settings layout.

- **General Improvements:**
  - Revised sanitization logic for less aggressive handling, particularly for regex101 links.
  - Refactored backend logic to improve performance, specifically optimizing git status checks.
  - Implemented dynamic retrieval of default branches and enhanced handling of IDs in files.

**fixes, refactors, and additional features included**:
- Bug fixes for branch handling, git status accuracy, and file name adjustments.
- Improved error handling, logging, and user feedback across various components.
This commit is contained in:
Sam Chau
2024-08-19 00:29:17 +09:30
committed by Sam Chau
parent 7287fb061e
commit ae75baca26
41 changed files with 2368 additions and 456 deletions

View File

@@ -7,4 +7,4 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "app.py"]
CMD ["python", "run.py"]

View File

@@ -1,14 +0,0 @@
from flask import Flask
from flask_cors import CORS
from app.routes import regex_routes, format_routes
def create_app():
app = Flask(__name__)
CORS(app, resources={r"/*": {"origins": "*"}})
app.register_blueprint(regex_routes.bp)
app.register_blueprint(format_routes.bp)
return app
if __name__ == '__main__':
app = create_app()
app.run(debug=True, host='0.0.0.0')

View File

@@ -0,0 +1,27 @@
from flask import Flask
from flask_cors import CORS
from .regex import bp as regex_bp
from .format import bp as format_bp
from .settings import bp as settings_bp
import os
REGEX_DIR = os.path.join('data', 'db', 'regex_patterns')
FORMAT_DIR = os.path.join('data', 'db', 'custom_formats')
def create_app():
app = Flask(__name__)
CORS(app, resources={r"/*": {"origins": "*"}})
# Initialize directories to avoid issues with non-existing directories
initialize_directories()
# Register Blueprints
app.register_blueprint(regex_bp)
app.register_blueprint(format_bp)
app.register_blueprint(settings_bp)
return app
def initialize_directories():
os.makedirs(REGEX_DIR, exist_ok=True)
os.makedirs(FORMAT_DIR, exist_ok=True)

View File

@@ -1,48 +1,67 @@
from flask import Blueprint, request, jsonify
from collections import OrderedDict
import os
import yaml
import logging
from collections import OrderedDict
from .file_utils import get_next_id, generate_filename, get_current_timestamp, sanitize_input
from .utils import get_next_id, generate_filename, get_current_timestamp, sanitize_input
FORMAT_DIR = 'custom_formats'
bp = Blueprint('format', __name__, url_prefix='/format')
FORMAT_DIR = os.path.join('data', 'db', 'custom_formats')
# Set up basic logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def represent_ordereddict(dumper, data):
return dumper.represent_mapping('tag:yaml.org,2002:map', data.items())
@bp.route('', methods=['GET', 'POST'])
def handle_formats():
if request.method == 'POST':
data = request.json
saved_data = save_format(data)
return jsonify(saved_data), 201
else:
formats = load_all_formats()
return jsonify(formats)
yaml.add_representer(OrderedDict, represent_ordereddict, Dumper=yaml.SafeDumper)
@bp.route('/<int:id>', methods=['GET', 'PUT', 'DELETE'])
def handle_format(id):
if request.method == 'GET':
format = load_format(id)
if format:
return jsonify(format)
return jsonify({"error": "Format not found"}), 404
elif request.method == 'PUT':
data = request.json
data['id'] = id
saved_data = save_format(data)
return jsonify(saved_data)
elif request.method == 'DELETE':
if delete_format(id):
return jsonify({"message": f"Format with ID {id} deleted."}), 200
return jsonify({"error": f"Format with ID {id} not found."}), 404
def save_format(data):
# Log the received data
logger.info("Received data for saving format: %s", data)
# Sanitize inputs
# Sanitize and extract necessary fields
name = sanitize_input(data.get('name', ''))
description = sanitize_input(data.get('description', ''))
# Determine if it's a new format or an edit
format_id = data.get('id', None)
if format_id == 0: # If id is 0, treat it as a new format
# Determine if this is a new format or an existing one
if format_id == 0 or not format_id:
format_id = get_next_id(FORMAT_DIR)
logger.info("Assigned new format ID: %d", format_id)
date_created = get_current_timestamp()
else:
existing_data = load_format(format_id)
if existing_data:
date_created = existing_data.get('date_created')
old_filename = generate_filename(FORMAT_DIR, format_id, existing_data['name'])
# Delete the old file
if os.path.exists(old_filename):
os.remove(old_filename)
existing_filename = os.path.join(FORMAT_DIR, f"{format_id}.yml")
if os.path.exists(existing_filename):
existing_data = load_format(format_id)
date_created = existing_data.get('date_created', get_current_timestamp())
else:
date_created = get_current_timestamp()
raise FileNotFoundError(f"No existing file found for ID: {format_id}")
date_modified = get_current_timestamp()
# Prepare conditions
# Process conditions
conditions = []
for condition in data.get('conditions', []):
logger.info("Processing condition: %s", condition)
@@ -61,10 +80,10 @@ def save_format(data):
cond_dict['flag'] = sanitize_input(condition['flag'])
conditions.append(cond_dict)
# Prepare tags
# Process tags
tags = [sanitize_input(tag) for tag in data.get('tags', [])]
# Create ordered data dictionary
# Construct the ordered data
ordered_data = OrderedDict([
('id', format_id),
('name', name),
@@ -75,24 +94,24 @@ def save_format(data):
('tags', tags)
])
# Generate new filename based on the updated name
new_filename = generate_filename(FORMAT_DIR, format_id, name)
# Write the YAML file with the new name
with open(new_filename, 'w') as file:
# Generate the filename using only the ID
filename = os.path.join(FORMAT_DIR, f"{format_id}.yml")
# Write to the file
with open(filename, 'w') as file:
yaml.dump(ordered_data, file, default_flow_style=False, Dumper=yaml.SafeDumper)
return ordered_data
def load_format(id):
files = [f for f in os.listdir(FORMAT_DIR) if f.startswith(f"{id}_") and f.endswith('.yml')]
if files:
filename = os.path.join(FORMAT_DIR, files[0])
filename = os.path.join(FORMAT_DIR, f"{id}.yml")
if os.path.exists(filename):
with open(filename, 'r') as file:
data = yaml.safe_load(file)
return data
return None
def load_all_formats():
formats = []
for filename in os.listdir(FORMAT_DIR):
@@ -103,8 +122,8 @@ def load_all_formats():
return formats
def delete_format(id):
files = [f for f in os.listdir(FORMAT_DIR) if f.startswith(f"{id}_") and f.endswith('.yml')]
if files:
os.remove(os.path.join(FORMAT_DIR, files[0]))
filename = os.path.join(FORMAT_DIR, f"{id}.yml")
if os.path.exists(filename):
os.remove(filename)
return True
return False

178
backend/app/regex.py Normal file
View File

@@ -0,0 +1,178 @@
from flask import Blueprint, request, jsonify
from collections import OrderedDict
import os
import yaml
import json
import logging
import subprocess
from .utils import get_next_id, generate_filename, get_current_timestamp, sanitize_input
bp = Blueprint('regex', __name__, url_prefix='/regex')
REGEX_DIR = os.path.join('data', 'db', 'regex_patterns')
logging.basicConfig(level=logging.DEBUG)
@bp.route('/regex101', methods=['POST'])
def regex101_proxy():
try:
logging.debug(f"Received data from frontend: {request.json}")
required_fields = ['regex', 'delimiter', 'flavor']
for field in required_fields:
if field not in request.json:
logging.error(f"Missing required field: {field}")
return jsonify({"error": f"Missing required field: {field}"}), 400
request.json['flags'] = 'gmi'
if 'testString' not in request.json:
request.json['testString'] = "Sample test string"
request.json['unitTests'] = [
{
"description": "Sample DOES_MATCH test",
"testString": request.json['testString'],
"criteria": "DOES_MATCH",
"target": "REGEX"
},
{
"description": "Sample DOES_NOT_MATCH test",
"testString": "Non-matching string",
"criteria": "DOES_NOT_MATCH",
"target": "REGEX"
}
]
logging.debug(f"Final payload being sent to Regex101 API: {json.dumps(request.json, indent=2)}")
data = json.dumps(request.json)
curl_command = ["curl", "-s", "-X", "POST", "-H", "Content-Type: application/json", "-d", data, "https://regex101.com/api/regex"]
response = subprocess.check_output(curl_command)
logging.debug(f"Response from regex101: {response.decode('utf-8')}")
return jsonify(json.loads(response)), 200
except subprocess.CalledProcessError as e:
logging.error(f"cURL command failed: {str(e)}")
return jsonify({"error": "Failed to connect to regex101"}), 500
except Exception as e:
logging.error(f"An unexpected error occurred: {str(e)}")
return jsonify({"error": "An unexpected error occurred"}), 500
@bp.route('', methods=['GET', 'POST'])
def handle_regexes():
if request.method == 'POST':
data = request.json
saved_data = save_regex(data)
return jsonify(saved_data), 201
else:
regexes = load_all_regexes()
return jsonify(regexes)
@bp.route('/<int:id>', methods=['GET', 'PUT', 'DELETE'])
def handle_regex(id):
if request.method == 'GET':
regex = load_regex(id)
if regex:
return jsonify(regex)
return jsonify({"error": "Regex not found"}), 404
elif request.method == 'PUT':
data = request.json
data['id'] = id
saved_data = save_regex(data)
return jsonify(saved_data)
elif request.method == 'DELETE':
if delete_regex(id):
return jsonify({"message": f"Regex with ID {id} deleted."}), 200
return jsonify({"error": f"Regex with ID {id} not found."}), 404
def save_regex(data):
ordered_data = OrderedDict()
if 'id' in data and data['id'] != 0: # Editing an existing regex
ordered_data['id'] = data['id']
existing_filename = os.path.join(REGEX_DIR, f"{ordered_data['id']}.yml")
logging.debug(f"Existing filename determined: {existing_filename}")
# Check if the existing file actually exists
if os.path.exists(existing_filename):
existing_data = load_regex(ordered_data['id'])
if existing_data:
ordered_data['date_created'] = existing_data.get('date_created', get_current_timestamp())
else:
raise FileNotFoundError(f"Failed to load existing data for ID: {ordered_data['id']}")
else:
raise FileNotFoundError(f"No existing file found for ID: {ordered_data['id']}")
else: # New regex
ordered_data['id'] = get_next_id(REGEX_DIR)
ordered_data['date_created'] = get_current_timestamp()
logging.debug(f"New regex being created with ID: {ordered_data['id']}")
# Fill in other details in the desired order
ordered_data['name'] = sanitize_input(data.get('name', ''))
ordered_data['description'] = sanitize_input(data.get('description', ''))
ordered_data['tags'] = [sanitize_input(tag) for tag in data.get('tags', [])]
ordered_data['pattern'] = data.get('pattern', '') # Store pattern as-is
ordered_data['regex101Link'] = data.get('regex101Link', '')
ordered_data['date_created'] = ordered_data.get('date_created', get_current_timestamp())
ordered_data['date_modified'] = get_current_timestamp()
# Always use the ID as the filename
new_filename = os.path.join(REGEX_DIR, f"{ordered_data['id']}.yml")
logging.debug(f"Filename to be used: {new_filename}")
# Save the updated data to the file, writing each field in the specified order
with open(new_filename, 'w') as file:
file.write(f"id: {ordered_data['id']}\n")
file.write(f"name: '{ordered_data['name']}'\n")
file.write(f"description: '{ordered_data['description']}'\n")
file.write(f"tags:\n")
for tag in ordered_data['tags']:
file.write(f" - {tag}\n")
file.write(f"pattern: '{ordered_data['pattern']}'\n")
file.write(f"regex101Link: '{ordered_data['regex101Link']}'\n")
file.write(f"date_created: '{ordered_data['date_created']}'\n")
file.write(f"date_modified: '{ordered_data['date_modified']}'\n")
logging.debug(f"File saved: {new_filename}")
return ordered_data
def find_existing_file(regex_id):
"""Find the existing filename for a given regex ID."""
files = [f for f in os.listdir(REGEX_DIR) if f.startswith(f"{regex_id}_") and f.endswith('.yml')]
if files:
logging.debug(f"Existing file found: {files[0]}")
return os.path.join(REGEX_DIR, files[0])
logging.debug(f"No existing file found for ID: {regex_id}")
return None
def load_regex(id):
filename = os.path.join(REGEX_DIR, f"{id}.yml")
if os.path.exists(filename):
with open(filename, 'r') as file:
data = yaml.safe_load(file)
return data
return None
def load_all_regexes():
regexes = []
for filename in os.listdir(REGEX_DIR):
if filename.endswith('.yml'):
with open(os.path.join(REGEX_DIR, filename), 'r') as file:
data = yaml.safe_load(file)
regexes.append(data)
return regexes
def delete_regex(id):
filename = os.path.join(REGEX_DIR, f"{id}.yml")
if os.path.exists(filename):
os.remove(filename)
return True
return False

View File

@@ -1,31 +0,0 @@
from flask import Blueprint, request, jsonify
from app.utils.format_operations import save_format, load_all_formats, delete_format, load_format
bp = Blueprint('format', __name__, url_prefix='/format')
@bp.route('', methods=['GET', 'POST'])
def handle_formats():
if request.method == 'POST':
data = request.json
saved_data = save_format(data)
return jsonify(saved_data), 201
else:
formats = load_all_formats()
return jsonify(formats)
@bp.route('/<int:id>', methods=['GET', 'PUT', 'DELETE'])
def handle_format(id):
if request.method == 'GET':
format = load_format(id)
if format:
return jsonify(format)
return jsonify({"error": "Format not found"}), 404
elif request.method == 'PUT':
data = request.json
data['id'] = id
saved_data = save_format(data)
return jsonify(saved_data)
elif request.method == 'DELETE':
if delete_format(id):
return jsonify({"message": f"Format with ID {id} deleted."}), 200
return jsonify({"error": f"Format with ID {id} not found."}), 404

View File

@@ -1,103 +0,0 @@
from flask import Blueprint, request, jsonify
from app.utils.regex_operations import save_regex, load_all_regexes, delete_regex, load_regex
from app.utils.format_operations import load_all_formats
import json
import logging
import subprocess
bp = Blueprint('regex', __name__, url_prefix='/regex')
logging.basicConfig(level=logging.DEBUG)
@bp.route('/regex101', methods=['POST'])
def regex101_proxy():
try:
# Log the incoming request data
logging.debug(f"Received data from frontend: {request.json}")
# Validate the request data before sending
required_fields = ['regex', 'delimiter', 'flavor']
for field in required_fields:
if field not in request.json:
logging.error(f"Missing required field: {field}")
return jsonify({"error": f"Missing required field: {field}"}), 400
# Set default flags to 'gmi' for global, multiline, and case-insensitive matching
request.json['flags'] = 'gmi'
# Include a separate test string if not provided
if 'testString' not in request.json:
request.json['testString'] = "Sample test string"
# Always include unit tests with every request
request.json['unitTests'] = [
{
"description": "Sample DOES_MATCH test",
"testString": request.json['testString'], # Use the main test string
"criteria": "DOES_MATCH",
"target": "REGEX"
},
{
"description": "Sample DOES_NOT_MATCH test",
"testString": "Non-matching string", # This should not match the regex
"criteria": "DOES_NOT_MATCH",
"target": "REGEX"
}
]
# Log the complete payload before sending
logging.debug(f"Final payload being sent to Regex101 API: {json.dumps(request.json, indent=2)}")
# Construct the data payload for curl
data = json.dumps(request.json)
curl_command = ["curl", "-s", "-X", "POST", "-H", "Content-Type: application/json", "-d", data, "https://regex101.com/api/regex"]
# Execute the curl command
response = subprocess.check_output(curl_command)
# Log the response from regex101
logging.debug(f"Response from regex101: {response.decode('utf-8')}")
# Return the JSON response back to the frontend
return jsonify(json.loads(response)), 200
except subprocess.CalledProcessError as e:
logging.error(f"cURL command failed: {str(e)}")
return jsonify({"error": "Failed to connect to regex101"}), 500
except Exception as e:
logging.error(f"An unexpected error occurred: {str(e)}")
return jsonify({"error": "An unexpected error occurred"}), 500
@bp.route('', methods=['GET', 'POST'])
def handle_regexes():
if request.method == 'POST':
data = request.json
saved_data = save_regex(data)
return jsonify(saved_data), 201
else:
regexes = load_all_regexes()
return jsonify(regexes)
@bp.route('/<int:id>', methods=['GET', 'PUT', 'DELETE'])
def handle_regex(id):
if request.method == 'GET':
regex = load_regex(id)
if regex:
return jsonify(regex)
return jsonify({"error": "Regex not found"}), 404
elif request.method == 'PUT':
data = request.json
data['id'] = id
saved_data = save_regex(data)
return jsonify(saved_data)
elif request.method == 'DELETE':
# Check if the regex is used in any custom formats
formats_using_regex = [format for format in load_all_formats() if any(condition.get('regex_id') == id for condition in format.get('conditions', []))]
if formats_using_regex:
return jsonify({"error": "Regex in use"}), 409 # 409 Conflict if in use
if delete_regex(id):
return jsonify({"message": f"Regex with ID {id} deleted."}), 200
return jsonify({"error": f"Regex with ID {id} not found."}), 404

712
backend/app/settings.py Normal file
View File

@@ -0,0 +1,712 @@
import os
import yaml
import git
from flask import Blueprint, request, jsonify
from git.exc import GitCommandError, InvalidGitRepositoryError
import shutil
import subprocess
import logging
from datetime import datetime
import json
import requests
logging.basicConfig(level=logging.DEBUG)
logging.getLogger('git').setLevel(logging.WARNING)
logger = logging.getLogger(__name__)
bp = Blueprint('settings', __name__, url_prefix='/settings')
SETTINGS_FILE = 'data/config/settings.yml'
def load_settings():
try:
if not os.path.exists(SETTINGS_FILE):
return None # Indicate that the settings file does not exist
with open(SETTINGS_FILE, 'r') as file:
settings = yaml.safe_load(file)
return settings if settings else None
except Exception as e:
return None
def save_settings(settings):
try:
os.makedirs(os.path.dirname(SETTINGS_FILE), exist_ok=True)
with open(SETTINGS_FILE, 'w') as file:
yaml.dump(settings, file)
except Exception as e:
pass
def validate_git_token(repo_url, git_token):
try:
parts = repo_url.strip('/').split('/')
if len(parts) < 2:
return False
repo_owner, repo_name = parts[-2], parts[-1].replace('.git', '')
api_url = f"https://api.github.com/repos/{repo_owner}/{repo_name}"
curl_command = [
'curl', '-s', '-o', '/dev/null', '-w', '%{http_code}',
'-H', f'Authorization: Bearer {git_token}',
'-H', 'Accept: application/vnd.github+json',
api_url
]
result = subprocess.run(curl_command, capture_output=True, text=True)
http_status_code = int(result.stdout.strip())
if http_status_code == 200:
return True
elif http_status_code == 401:
return False
else:
return False
except Exception as e:
return False
def parse_diff(diff_text):
diff_lines = diff_text.splitlines()
parsed_diff = []
local_version = []
incoming_version = []
in_local = False
in_incoming = False
for line in diff_lines:
if line.startswith('--- a/'):
in_local = True
in_incoming = False
elif line.startswith('+++ b/'):
in_incoming = True
in_local = False
elif line.startswith('@@'):
# Context lines that indicate a change
parsed_diff.append({'context': line, 'type': 'context'})
elif line.startswith('-'):
local_version.append(line[1:])
parsed_diff.append({'text': line[1:], 'type': 'local'})
elif line.startswith('+'):
incoming_version.append(line[1:])
parsed_diff.append({'text': line[1:], 'type': 'incoming'})
else:
parsed_diff.append({'text': line, 'type': 'unchanged'})
return parsed_diff, local_version, incoming_version
def get_changes(repo):
status = repo.git.status('--porcelain', '-z').split('\0')
logger.debug(f"Raw porcelain status: {status}")
changes = []
for item in status:
if not item:
continue
logger.debug(f"Processing status item: {item}")
if len(item) < 4:
logger.warning(f"Unexpected status item format: {item}")
continue
x, y, file_path = item[0], item[1], item[3:]
logger.debug(f"Parsed status: x={x}, y={y}, file_path={file_path}")
full_path = os.path.join(repo.working_dir, file_path)
if 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': x == 'D' or y == 'D'
})
else:
logger.debug(f"No data extracted from file: {file_full_path}")
else:
logger.debug(f"Found file: {full_path}, going through file.")
file_data = extract_data_from_yaml(full_path)
if file_data or x == 'D' or y == 'D': # Ensure that deleted files are handled
if not file_data:
logger.debug(f"No data found, using default file name as name")
file_data = {'name': os.path.basename(file_path).replace('.yml', ''), 'id': None}
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': file_path,
'staged': x != '?' and x != ' ',
'modified': y == 'M',
'deleted': x == 'D' or y == 'D'
})
logger.debug(f"Final changes: {json.dumps(changes, indent=2)}")
return changes
def extract_data_from_yaml(file_path):
logger.debug(f"Extracting data from file: {file_path}")
try:
with open(file_path, 'r') as f:
content = yaml.safe_load(f)
logger.debug(f"File content: {content}") # Log the full file content
if content is None:
logger.error(f"Failed to parse YAML file or file is empty: {file_path}")
return None
# Check if expected keys are in the content
if 'name' not in content or 'id' not in content:
logger.warning(f"'name' or 'id' not found in file: {file_path}")
return {
'name': content.get('name'),
'id': content.get('id')
}
except Exception as e:
logger.warning(f"Error reading file {file_path}: {str(e)}")
return None
def determine_type(file_path):
if 'regex_patterns' in file_path:
return 'Regex Pattern'
elif 'custom_formats' in file_path:
return 'Custom Format'
return 'Unknown'
def interpret_git_status(x, y):
if x == 'D' or y == 'D':
return 'Deleted'
elif x == 'A':
return 'Added'
elif x == 'M' or y == 'M':
return 'Modified'
elif x == 'R':
return 'Renamed'
elif x == 'C':
return 'Copied'
elif x == 'U':
return 'Updated but unmerged'
elif x == '?' and y == '?':
return 'Untracked'
else:
return 'Unknown'
def get_staged_files(repo):
return [item.a_path for item in repo.index.diff('HEAD')]
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}'))
class SettingsManager:
def __init__(self):
self.settings = load_settings()
self.repo_url = self.settings.get('gitRepo') if self.settings else None
self.repo_path = self.settings.get('localRepoPath') if self.settings else None
def get_default_branch(self):
try:
logger.info(f"Fetching default branch for repo: {self.repo_url}")
parts = self.repo_url.strip('/').split('/')
if len(parts) < 2:
logger.error("Invalid repository URL")
return None
repo_owner, repo_name = parts[-2], parts[-1].replace('.git', '')
api_url = f"https://api.github.com/repos/{repo_owner}/{repo_name}"
headers = {
'Authorization': f'Bearer {self.settings["gitToken"]}',
'Accept': 'application/vnd.github+json'
}
response = requests.get(api_url, headers=headers)
if response.status_code == 200:
repo_info = response.json()
default_branch = repo_info.get('default_branch', 'main')
logger.info(f"Default branch: {default_branch}")
return default_branch
else:
logger.error(f"Failed to fetch default branch, status code: {response.status_code}")
return None
except Exception as e:
logger.error(f"Error fetching default branch: {str(e)}", exc_info=True)
return None
def clone_repository(self):
try:
if not validate_git_token(self.repo_url, self.settings["gitToken"]):
return False, "Invalid Git token. Please check your credentials and try again."
default_branch = self.get_default_branch()
if not default_branch:
return False, "Unable to determine the default branch."
temp_dir = f"{self.repo_path}_temp"
auth_repo_url = self.repo_url.replace('https://', f'https://{self.settings["gitToken"]}:x-oauth-basic@')
git.Repo.clone_from(auth_repo_url, temp_dir, branch=default_branch, single_branch=True)
backup_dir = f"{self.repo_path}_backup"
if os.path.exists(self.repo_path):
shutil.move(self.repo_path, backup_dir)
shutil.move(temp_dir, self.repo_path)
for folder_name in ['regex_patterns', 'custom_formats']:
folder_path = os.path.join(self.repo_path, folder_name)
backup_folder_path = os.path.join(backup_dir, folder_name)
if not os.path.exists(folder_path):
os.makedirs(folder_path)
logger.debug(f"Created missing folder: {folder_name} in the cloned repository.")
cloned_files = [f for f in os.listdir(folder_path) if f.endswith('.yml')]
max_id = max([int(f.split('_')[0]) for f in cloned_files], default=0)
if os.path.exists(backup_folder_path):
local_files = [f for f in os.listdir(backup_folder_path) if f.endswith('.yml')]
for file_name in local_files:
old_file_path = os.path.join(backup_folder_path, file_name)
with open(old_file_path, 'r') as file:
data = yaml.safe_load(file)
max_id += 1
data['id'] = max_id
new_file_name = f"{max_id}_{data['name'].replace(' ', '_').lower()}.yml"
new_file_path = os.path.join(folder_path, new_file_name)
with open(new_file_path, 'w') as file:
yaml.dump(data, file)
if os.path.exists(backup_dir):
shutil.rmtree(backup_dir)
return True, "Repository cloned successfully and local files updated"
except GitCommandError as e:
if os.path.exists(temp_dir):
shutil.rmtree(temp_dir)
if os.path.exists(backup_dir):
shutil.move(backup_dir, self.repo_path)
return False, f"Git error: {str(e)}"
except Exception as e:
if os.path.exists(temp_dir):
shutil.rmtree(temp_dir)
if os.path.exists(backup_dir):
shutil.move(backup_dir, self.repo_path)
return False, f"Unexpected error: {str(e)}"
def get_git_status(self):
try:
logger.debug(f"Attempting to get status for repo at {self.repo_path}")
repo = git.Repo(self.repo_path)
logger.debug(f"Successfully created Repo object")
# Now, actually call get_changes to process the status lines
changes = get_changes(repo)
logger.debug(f"Changes detected by get_changes: {changes}")
# Fetch only if necessary
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)}")
else:
commits_behind = []
commits_ahead = []
logger.debug("Remote branch does not exist, skipping commits ahead/behind calculation.")
status = {
"branch": branch,
"remote_branch_exists": remote_branch_exists,
"changes": changes,
"commits_behind": len(commits_behind),
"commits_ahead": len(commits_ahead),
}
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: {self.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)}"
def get_branches(self):
try:
logger.debug("Attempting to get branches")
repo = git.Repo(self.repo_path)
# Get local branches
local_branches = [{'name': branch.name} for branch in repo.heads]
# Get remote branches
remote_branches = [{'name': ref.remote_head} for ref in repo.remote().refs]
# Combine and remove duplicates, and exclude 'HEAD'
all_branches = {branch['name']: branch for branch in local_branches + remote_branches if branch['name'] != 'HEAD'}.values()
logger.debug(f"Successfully retrieved branches: {[branch['name'] for branch in all_branches]}")
# Log the branches before sending
logger.info(f"Branches being sent: {[branch['name'] for branch in all_branches]}")
return True, {"branches": list(all_branches)}
except Exception as e:
logger.error(f"Error getting branches: {str(e)}", exc_info=True)
return False, {"error": f"Error getting branches: {str(e)}"}
def create_branch(self, branch_name, base_branch='main'):
try:
logger.debug(f"Attempting to create branch {branch_name} from {base_branch}")
repo = git.Repo(self.repo_path)
# Check if the branch already exists
if branch_name in repo.heads:
return False, f"Branch '{branch_name}' already exists."
# Create and checkout the new branch
new_branch = repo.create_head(branch_name, commit=base_branch)
new_branch.checkout()
# Push the new branch to remote and set the upstream branch
origin = repo.remote(name='origin')
origin.push(refspec=f"{branch_name}:{branch_name}", set_upstream=True)
logger.debug(f"Successfully created and pushed branch: {branch_name}")
return True, {"message": f"Created and set upstream for branch: {branch_name}", "current_branch": branch_name}
except Exception as e:
logger.error(f"Error creating branch: {str(e)}", exc_info=True)
return False, {"error": f"Error creating branch: {str(e)}"}
def checkout_branch(self, branch_name):
try:
logger.debug(f"Attempting to checkout branch: {branch_name}")
repo = git.Repo(self.repo_path)
# Check if the branch exists
if branch_name not in repo.heads:
return False, f"Branch '{branch_name}' does not exist."
# Checkout the branch
repo.git.checkout(branch_name)
logger.debug(f"Successfully checked out branch: {branch_name}")
return True, {"message": f"Checked out branch: {branch_name}", "current_branch": branch_name}
except Exception as e:
logger.error(f"Error checking out branch: {str(e)}", exc_info=True)
return False, {"error": f"Error checking out branch: {str(e)}"}
def delete_branch(self, branch_name):
try:
logger.debug(f"Attempting to delete branch: {branch_name}")
repo = git.Repo(self.repo_path)
# Check if the branch exists
if branch_name not in repo.heads:
return False, f"Branch '{branch_name}' does not exist."
# Check if it's the current branch
if repo.active_branch.name == branch_name:
return False, f"Cannot delete the current branch: {branch_name}"
# Delete the branch locally
repo.delete_head(branch_name, force=True)
# Delete the branch remotely
try:
repo.git.push('origin', '--delete', branch_name)
except GitCommandError:
logger.warning(f"Failed to delete remote branch: {branch_name}. It may not exist on remote.")
logger.debug(f"Successfully deleted branch: {branch_name}")
return True, {"message": f"Deleted branch: {branch_name}", "current_branch": repo.active_branch.name}
except Exception as e:
logger.error(f"Error deleting branch: {str(e)}", exc_info=True)
return False, {"error": f"Error deleting branch: {str(e)}"}
def get_current_branch(self):
try:
repo = git.Repo(self.repo_path)
return repo.active_branch.name
except Exception as e:
logger.error(f"Error getting current branch: {str(e)}", exc_info=True)
return None
def stage_files(self, files):
try:
repo = git.Repo(self.repo_path)
for file_path in files:
repo.index.add([file_path])
return True, "Successfully staged files."
except Exception as e:
logger.error(f"Error staging files: {str(e)}", exc_info=True)
return False, f"Error staging files: {str(e)}"
def push_files(self, files, commit_message):
try:
repo = git.Repo(self.repo_path)
# Stage the files
self.stage_files(files)
# Commit the staged files
repo.index.commit(commit_message)
# Push the commit to the remote repository
origin = repo.remote(name='origin')
origin.push()
return True, "Successfully committed and pushed files."
except Exception as e:
logger.error(f"Error pushing files: {str(e)}", exc_info=True)
return False, f"Error pushing files: {str(e)}"
settings_manager = SettingsManager()
@bp.route('', methods=['GET'])
def handle_settings():
settings = load_settings()
if not settings:
return jsonify({}), 204
return jsonify(settings), 200
@bp.route('', methods=['POST'])
def update_settings():
try:
new_settings = request.json
save_settings(new_settings)
settings_manager.__init__()
success, message = settings_manager.clone_repository()
if success:
return jsonify(new_settings), 200
else:
if "Invalid Git token" in message:
return jsonify({"error": message}), 401
return jsonify({"error": message}), 400
except Exception as e:
return jsonify({"error": "Failed to update settings"}), 500
@bp.route('/status', methods=['GET'])
def get_status():
logger.debug("Received request for git status")
success, message = settings_manager.get_git_status()
if success:
logger.debug("Successfully retrieved git status")
return jsonify({'success': True, 'data': message}), 200
else:
logger.error(f"Failed to retrieve git status: {message}")
return jsonify({'success': False, 'error': message}), 400
# Update the route handlers
@bp.route('/branch', methods=['POST'])
def create_branch():
branch_name = request.json.get('name')
base_branch = request.json.get('base', 'main')
logger.debug(f"Received request to create branch {branch_name} from {base_branch}")
success, result = settings_manager.create_branch(branch_name, base_branch)
if success:
logger.debug(f"Successfully created branch: {branch_name}")
return jsonify({'success': True, **result}), 200
else:
logger.error(f"Failed to create branch: {result}")
return jsonify({'success': False, 'error': result}), 400
@bp.route('/branches', methods=['GET'])
def get_branches():
logger.debug("Received request for branches")
success, result = settings_manager.get_branches()
if success:
logger.debug("Successfully retrieved branches")
return jsonify({'success': True, 'data': result}), 200
else:
logger.error(f"Failed to retrieve branches: {result}")
return jsonify({'success': False, 'error': result}), 400
@bp.route('/checkout', methods=['POST'])
def checkout_branch():
branch_name = request.json.get('branch')
logger.debug(f"Received request to checkout branch: {branch_name}")
success, result = settings_manager.checkout_branch(branch_name)
if success:
logger.debug(f"Successfully checked out branch: {branch_name}")
return jsonify({'success': True, **result}), 200
else:
logger.error(f"Failed to checkout branch: {result}")
return jsonify({'success': False, 'error': result}), 400
@bp.route('/branch/<branch_name>', methods=['DELETE'])
def delete_branch(branch_name):
logger.debug(f"Received request to delete branch: {branch_name}")
success, result = settings_manager.delete_branch(branch_name)
if success:
logger.debug(f"Successfully deleted branch: {branch_name}")
return jsonify({'success': True, **result}), 200
else:
logger.error(f"Failed to delete branch: {result}")
return jsonify({'success': False, 'error': result}), 400
@bp.route('/current-branch', methods=['GET'])
def get_current_branch():
current_branch = settings_manager.get_current_branch()
if current_branch:
return jsonify({'success': True, 'current_branch': current_branch}), 200
else:
return jsonify({'success': False, 'error': 'Failed to get current branch'}), 400
@bp.route('/stage', methods=['POST'])
def stage_files():
files = request.json.get('files', [])
try:
repo = git.Repo(settings_manager.repo_path)
if not files: # If no files are specified, stage all changes
repo.git.add(A=True) # This adds all changes to staging, including deletions
message = "All changes have been staged."
else:
for file_path in files:
# Staging a deleted file requires just adding the file path.
repo.git.add(file_path)
message = "Specified files have been staged."
return jsonify({'success': True, 'message': message}), 200
except Exception as e:
logger.error(f"Error staging files: {str(e)}", exc_info=True)
return jsonify({'success': False, 'error': f"Error staging files: {str(e)}"}), 400
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
@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}")
try:
repo = git.Repo(settings_manager.repo_path)
# Instead of restaging the files, we directly commit the staged changes
staged_files = repo.index.diff("HEAD") # Get the list of staged files
if not staged_files:
return jsonify({'success': False, 'error': "No staged changes to commit."}), 400
# Generate the structured commit message
commit_message = generate_commit_message(user_commit_message, files)
# Commit the staged changes
repo.index.commit(commit_message)
# Push the commit to the remote repository
origin = repo.remote(name='origin')
origin.push()
logger.debug("Successfully committed and pushed files")
return jsonify({'success': True, 'message': "Successfully committed and pushed files."}), 200
except Exception as e:
logger.error(f"Error pushing files: {str(e)}", exc_info=True)
return jsonify({'success': False, 'error': f"Error pushing files: {str(e)}"}), 400
@bp.route('/revert', methods=['POST'])
def revert_file():
file_path = request.json.get('file_path')
if not file_path:
return jsonify({'success': False, 'error': "File path is required."}), 400
try:
repo = git.Repo(settings_manager.repo_path)
repo.git.restore(file_path)
repo.git.restore('--staged', file_path) # Ensure staged changes are also reverted
message = f"File {file_path} has been reverted."
return jsonify({'success': True, 'message': message}), 200
except Exception as e:
logger.error(f"Error reverting file: {str(e)}", exc_info=True)
return jsonify({'success': False, 'error': f"Error reverting file: {str(e)}"}), 400
@bp.route('/revert-all', methods=['POST'])
def revert_all():
try:
repo = git.Repo(settings_manager.repo_path)
# Revert all files to the state of the last commit
repo.git.restore('--staged', '.')
repo.git.restore('.')
return jsonify({'success': True, 'message': "All changes have been reverted to the last commit."}), 200
except Exception as e:
logger.error(f"Error reverting all changes: {str(e)}", exc_info=True)
return jsonify({'success': False, 'error': f"Error reverting all changes: {str(e)}"}), 400
@bp.route('/file', methods=['DELETE'])
def delete_file():
file_path = request.json.get('file_path')
if not file_path:
return jsonify({'success': False, 'error': "File path is required."}), 400
try:
full_file_path = os.path.join(settings_manager.repo_path, file_path)
if os.path.exists(full_file_path):
os.remove(full_file_path)
message = f"File {file_path} has been deleted."
return jsonify({'success': True, 'message': message}), 200
else:
return jsonify({'success': False, 'error': "File does not exist."}), 404
except Exception as e:
logger.error(f"Error deleting file: {str(e)}", exc_info=True)
return jsonify({'success': False, 'error': f"Error deleting file: {str(e)}"}), 400

59
backend/app/utils.py Normal file
View File

@@ -0,0 +1,59 @@
import os
import git
import datetime
import re
import yaml
from yaml import safe_load
from collections import OrderedDict
def get_next_id(directory):
max_id = 0
for filename in os.listdir(directory):
if filename.endswith('.yml'):
file_path = os.path.join(directory, filename)
with open(file_path, 'r') as file:
content = yaml.safe_load(file)
file_id = content.get('id', 0)
if isinstance(file_id, int) and file_id > max_id:
max_id = file_id
return max_id + 1
def generate_filename(directory, id, name):
sanitized_name = name.replace(' ', '_').lower()
return os.path.join(directory, f"{id}_{sanitized_name}.yml")
def get_current_timestamp():
return datetime.datetime.now().isoformat()
def sanitize_input(input_str):
if not isinstance(input_str, str):
return input_str
# Remove any leading/trailing whitespace
sanitized_str = input_str.strip()
# Replace tabs with spaces
sanitized_str = sanitized_str.replace('\t', ' ')
# Collapse multiple spaces into a single space
sanitized_str = re.sub(r'\s+', ' ', sanitized_str)
# Escape special characters for YAML
special_chars = r'[:#&*?|<>%@`]'
sanitized_str = re.sub(special_chars, lambda m: '\\' + m.group(0), sanitized_str)
# If the string starts with any of these characters, quote the entire string
if re.match(r'^[\'"-]', sanitized_str):
sanitized_str = yaml.dump(sanitized_str, default_style='"').strip()
# Handle multi-line strings
if '\n' in sanitized_str:
sanitized_str = '|\n' + '\n'.join(f' {line}' for line in sanitized_str.split('\n'))
return sanitized_str
def represent_ordereddict(dumper, data):
return dumper.represent_mapping('tag:yaml.org,2002:map', data.items())
yaml.add_representer(OrderedDict, represent_ordereddict, Dumper=yaml.SafeDumper)

View File

@@ -1,24 +0,0 @@
import os
import datetime
def get_next_id(directory):
files = [f for f in os.listdir(directory) if f.endswith('.yml')]
if not files:
return 1
return max(int(f.split('_')[0]) for f in files) + 1
def generate_filename(directory, id, name):
sanitized_name = name.replace(' ', '_').lower()
return os.path.join(directory, f"{id}_{sanitized_name}.yml")
def get_current_timestamp():
return datetime.datetime.now().isoformat()
import re
def sanitize_input(input_str):
sanitized_str = input_str.strip()
sanitized_str = re.sub(r'[:#\-\*>\|&]', '', sanitized_str)
sanitized_str = sanitized_str.replace('\t', ' ')
sanitized_str = re.sub(r'\s+', ' ', sanitized_str)
return sanitized_str

View File

@@ -1,71 +0,0 @@
import os
import yaml
from collections import OrderedDict
from .file_utils import get_next_id, generate_filename, get_current_timestamp, sanitize_input
REGEX_DIR = 'regex_patterns'
def save_regex(data):
ordered_data = OrderedDict()
if 'id' in data and data['id'] != 0:
ordered_data['id'] = data['id']
else:
ordered_data['id'] = get_next_id(REGEX_DIR)
ordered_data['name'] = sanitize_input(data.get('name', ''))
ordered_data['description'] = sanitize_input(data.get('description', ''))
ordered_data['pattern'] = sanitize_input(data.get('pattern', ''))
ordered_data['regex101Link'] = sanitize_input(data.get('regex101Link', ''))
ordered_data['regex101DeleteCode'] = sanitize_input(data.get('regex101DeleteCode', '')) # Add this line to save the delete code
if ordered_data['id'] != 0: # Existing regex
existing_data = load_regex(ordered_data['id'])
if existing_data:
ordered_data['date_created'] = existing_data.get('date_created', get_current_timestamp())
else:
ordered_data['date_created'] = get_current_timestamp()
else: # New regex
ordered_data['date_created'] = get_current_timestamp()
ordered_data['date_modified'] = get_current_timestamp()
ordered_data['tags'] = [sanitize_input(tag) for tag in data.get('tags', [])]
filename = generate_filename(REGEX_DIR, ordered_data['id'], ordered_data['name'])
with open(filename, 'w') as file:
for key, value in ordered_data.items():
if key in ['description', 'date_created', 'date_modified', 'regex101Link', 'regex101DeleteCode']: # Add 'regex101DeleteCode' to this list
file.write(f"{key}: '{value}'\n")
elif key == 'tags':
file.write('tags:\n')
for tag in value:
file.write(f'- {tag}\n')
else:
file.write(f'{key}: {value}\n')
return ordered_data
def load_regex(id):
files = [f for f in os.listdir(REGEX_DIR) if f.startswith(f"{id}_") and f.endswith('.yml')]
if files:
filename = os.path.join(REGEX_DIR, files[0])
with open(filename, 'r') as file:
data = yaml.safe_load(file)
return data
return None
def load_all_regexes():
regexes = []
for filename in os.listdir(REGEX_DIR):
if filename.endswith('.yml'):
with open(os.path.join(REGEX_DIR, filename), 'r') as file:
data = yaml.safe_load(file)
regexes.append(data)
return regexes
def delete_regex(id):
files = [f for f in os.listdir(REGEX_DIR) if f.startswith(f"{id}_") and f.endswith('.yml')]
if files:
os.remove(os.path.join(REGEX_DIR, files[0]))
return True
return False

View File

@@ -1,32 +0,0 @@
id: 2
name: Release Group Tier 1
description: Test 1
date_created: '2024-08-16T06:46:47.169722'
date_modified: '2024-08-16T07:35:37.622397'
conditions:
- type: regex
name: EbP
negate: false
required: false
regex_id: 1
- type: regex
name: DON
negate: false
required: false
regex_id: 2
- type: regex
name: Geek
negate: false
required: false
regex_id: 4
- type: flag
name: Tracker Internal
negate: false
required: false
flag: internal
- type: regex
name: DZ0N3
negate: false
required: false
regex_id: 3
tags: []

View File

@@ -1,32 +0,0 @@
id: 3
name: Release Group Tier 2
description: Test 2
date_created: '2024-08-16T07:16:57.505528'
date_modified: '2024-08-16T07:16:57.505543'
conditions:
- type: regex
name: Geek
negate: false
required: false
regex_id: 4
- type: flag
name: Tracker Internal
negate: false
required: false
flag: internal
- type: regex
name: Tayto
negate: false
required: false
regex_id: 5
- type: regex
name: ZQ
negate: false
required: false
regex_id: 6
- type: regex
name: Chotab
negate: false
required: false
regex_id: 8
tags: []

View File

@@ -1,10 +0,0 @@
id: 1
name: EbP
description: 'Release group: EbP'
tags:
- Release Group
- HDB Internal
pattern: (?<=^|[\s.-])EbP\b
regex101Link: https://regex101.com/r/5fR6ms
date_created: '2024-08-15T10:46:19.929335'
date_modified: '2024-08-15T15:43:51.614741'

View File

@@ -1,10 +0,0 @@
id: 2
name: DON
description: 'Release Group: DON'
tags:
- Release Group
- HDB Internal
pattern: (?<=^|[\s.-])DON\b
regex101Link: https://regex101.com/r/rH36F9
date_created: '2024-08-15T10:46:36.718655'
date_modified: '2024-08-15T15:38:30.549125'

View File

@@ -1,10 +0,0 @@
id: 3
name: D-Z0N3
description: 'Release Group: D-Z0N3'
tags:
- Release Group
- HDB Internal
pattern: (?<=^|[\s.-])D-Z0N3\b
regex101Link: https://regex101.com/r/OWv3R9
date_created: '2024-08-15T15:37:02.733456'
date_modified: '2024-08-15T16:24:11.809191'

View File

@@ -1,10 +0,0 @@
id: 4
name: Geek
description: 'Release group: Geek'
pattern: (?<=^|[\s.-])Geek\b
regex101Link: 'https://regex101.com/r/fjF1dV'
date_created: '2024-08-15T23:23:39.653442'
date_modified: '2024-08-16T04:23:58.217800'
tags:
- HDB Internal
- Release Group

View File

@@ -1,10 +0,0 @@
id: 5
name: TayTo
description: 'Release Group: TayTo'
pattern: (?<=^|[\s.-])TayTo\b
regex101Link: 'https://regex101.com/r/cRbFpF'
date_created: '2024-08-15T23:25:57.501112'
date_modified: '2024-08-16T04:24:07.026677'
tags:
- HDB Internal
- Release Group

View File

@@ -1,10 +0,0 @@
id: 6
name: ZQ
description: 'Release Group: ZQ'
pattern: (?<=^|[\s.-])ZQ\b
regex101Link: ''
date_created: '2024-08-15T23:27:19.233352'
date_modified: '2024-08-16T04:24:16.511572'
tags:
- AHD Internal
- Release Group

View File

@@ -2,4 +2,5 @@ Flask==2.0.1
Flask-CORS==3.0.10
PyYAML==5.4.1
requests==2.26.0
Werkzeug==2.0.1
Werkzeug==2.0.1
GitPython==3.1.24

5
backend/run.py Normal file
View File

@@ -0,0 +1,5 @@
from app import create_app
if __name__ == '__main__':
app = create_app()
app.run(debug=True, host='0.0.0.0')

2
frontend/.gitignore vendored
View File

@@ -22,3 +22,5 @@ dist-ssr
*.njsproj
*.sln
*.sw?
/backend/data

View File

@@ -8,10 +8,16 @@
"name": "frontend",
"version": "0.0.0",
"dependencies": {
"@radix-ui/react-slot": "^1.1.0",
"axios": "^0.21.1",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"lucide-react": "^0.428.0",
"prop-types": "^15.8.1",
"react": "^18.2.0",
"react-dom": "^18.2.0"
"react-dom": "^18.2.0",
"react-toastify": "^10.0.5",
"tailwind-merge": "^2.5.2"
},
"devDependencies": {
"@types/react": "^18.0.28",
@@ -828,17 +834,48 @@
"node": ">=14"
}
},
"node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz",
"integrity": "sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slot": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz",
"integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@types/prop-types": {
"version": "15.7.12",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz",
"integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==",
"dev": true
"devOptional": true
},
"node_modules/@types/react": {
"version": "18.3.3",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.3.tgz",
"integrity": "sha512-hti/R0pS0q1/xx+TsI73XIqk26eBsISZ2R0wUijXIngRK9R/e7Xw/cXVxQK7R5JjW+SV4zGcn5hXjudkN/pLIw==",
"dev": true,
"devOptional": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.0.2"
@@ -1104,6 +1141,33 @@
"node": ">= 6"
}
},
"node_modules/class-variance-authority": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.0.tgz",
"integrity": "sha512-jFI8IQw4hczaL4ALINxqLEXQbWcNjoSkloa4IaufXCJr6QawJyw7tuRysRsrE8w2p/4gGaxKIt/hX3qz/IbD1A==",
"dependencies": {
"clsx": "2.0.0"
},
"funding": {
"url": "https://joebell.co.uk"
}
},
"node_modules/class-variance-authority/node_modules/clsx": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.0.0.tgz",
"integrity": "sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==",
"engines": {
"node": ">=6"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"engines": {
"node": ">=6"
}
},
"node_modules/color-convert": {
"version": "1.9.3",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
@@ -1164,7 +1228,7 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"dev": true
"devOptional": true
},
"node_modules/debug": {
"version": "4.3.6",
@@ -1634,6 +1698,14 @@
"yallist": "^3.0.2"
}
},
"node_modules/lucide-react": {
"version": "0.428.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.428.0.tgz",
"integrity": "sha512-rGrzslfEcgqwh+TLBC5qJ8wvVIXhLvAIXVFKNHndYyb1utSxxn9rXOC+1CNJLi6yNOooyPqIs6+3YCp6uSiEvg==",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
}
},
"node_modules/magic-string": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.27.0.tgz",
@@ -2071,6 +2143,18 @@
"node": ">=0.10.0"
}
},
"node_modules/react-toastify": {
"version": "10.0.5",
"resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-10.0.5.tgz",
"integrity": "sha512-mNKt2jBXJg4O7pSdbNUfDdTsK9FIdikfsIE/yUCxbAEXl4HMyJaivrVFcn3Elvt5xvCQYhUZm+hqTIu1UXM3Pw==",
"dependencies": {
"clsx": "^2.1.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@@ -2336,6 +2420,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/tailwind-merge": {
"version": "2.5.2",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.5.2.tgz",
"integrity": "sha512-kjEBm+pvD+6eAwzJL2Bi+02/9LFLal1Gs61+QB7HvTfQQ0aXwC5LGT8PEt1gS0CWKktKe6ysPTAy3cBC5MeiIg==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/dcastil"
}
},
"node_modules/tailwindcss": {
"version": "3.4.10",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.10.tgz",

View File

@@ -9,10 +9,16 @@
"preview": "vite preview"
},
"dependencies": {
"@radix-ui/react-slot": "^1.1.0",
"axios": "^0.21.1",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"lucide-react": "^0.428.0",
"prop-types": "^15.8.1",
"react": "^18.2.0",
"react-dom": "^18.2.0"
"react-dom": "^18.2.0",
"react-toastify": "^10.0.5",
"tailwind-merge": "^2.5.2"
},
"devDependencies": {
"@types/react": "^18.0.28",

View File

@@ -1,10 +1,13 @@
import { useState, useEffect } from 'react';
import RegexManager from './components/regex/RegexManager';
import CustomFormatManager from './components/format/FormatManager';
import Settings from './components/settings/SettingsManager';
import Navbar from './components/ui/Navbar';
import { ToastContainer } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
function App() {
const [activeTab, setActiveTab] = useState('format');
const [activeTab, setActiveTab] = useState('settings');
const [darkMode, setDarkMode] = useState(true);
useEffect(() => {
@@ -16,19 +19,24 @@ function App() {
}, [darkMode]);
return (
<div className="min-h-screen bg-gray-900 text-gray-100">
<Navbar
activeTab={activeTab}
setActiveTab={setActiveTab}
darkMode={darkMode}
setDarkMode={setDarkMode}
/>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 mt-6">
{activeTab === 'regex' ? <RegexManager /> : <CustomFormatManager />}
</div>
</div>
<>
<div className="min-h-screen bg-gray-900 text-gray-100">
<Navbar
activeTab={activeTab}
setActiveTab={setActiveTab}
darkMode={darkMode}
setDarkMode={setDarkMode}
/>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 mt-6">
{activeTab === 'regex' && <RegexManager />}
{activeTab === 'format' && <CustomFormatManager />}
{activeTab === 'settings' && <Settings />}
</div>
</div>
<ToastContainer position="top-right" autoClose={5000} hideProgressBar={false} newestOnTop={false} closeOnClick rtl={false} pauseOnFocusLoss draggable pauseOnHover />
</>
);
}
export default App;
export default App;

View File

@@ -46,7 +46,6 @@ export const deleteRegex = async (id) => {
}
};
export const getFormats = async () => {
try {
const response = await axios.get(`${API_BASE_URL}/format`);
@@ -96,3 +95,142 @@ export const createRegex101Link = async (regexData) => {
throw error;
}
};
export const getSettings = async () => {
try {
const response = await axios.get(`${API_BASE_URL}/settings`);
return response.data;
} catch (error) {
console.error('Error fetching settings:', error);
throw error;
}
};
export const saveSettings = async (settings) => {
try {
const response = await axios.post(`${API_BASE_URL}/settings`, settings);
return response.data;
} catch (error) {
console.error('Error saving settings:', error);
throw error;
}
};
export const getGitStatus = async () => {
try {
const response = await axios.get(`${API_BASE_URL}/settings/status`);
return response.data;
} catch (error) {
console.error('Error fetching Git status:', error);
throw error;
}
};
export const getBranches = async () => {
try {
const response = await axios.get(`${API_BASE_URL}/settings/branches`);
return response.data;
} catch (error) {
console.error('Error fetching branches:', error);
throw error;
}
};
export const checkoutBranch = async (branchName) => {
try {
const response = await axios.post(`${API_BASE_URL}/settings/checkout`, { branch: branchName });
return response.data;
} catch (error) {
console.error('Error checking out branch:', error);
throw error;
}
};
export const createBranch = async (branchName, baseBranch) => {
try {
const response = await axios.post(`${API_BASE_URL}/settings/branch`, { name: branchName, base: baseBranch });
return response.data;
} catch (error) {
console.error('Error creating branch:', error);
throw error;
}
};
export const deleteBranch = async (branchName) => {
try {
const response = await axios.delete(`${API_BASE_URL}/settings/branch/${branchName}`);
return response.data;
} catch (error) {
console.error('Error deleting branch:', error);
throw error;
}
};
export const pullBranch = async (branchName) => {
try {
const response = await axios.post(`${API_BASE_URL}/settings/pull`, { branch: branchName });
return response.data;
} catch (error) {
console.error('Error pulling branch:', error);
throw error;
}
};
export const addFiles = async (files) => {
try {
const response = await axios.post(`${API_BASE_URL}/settings/stage`, { files });
return response.data;
} catch (error) {
console.error('Error staging files:', error);
throw error;
}
};
export const pushFiles = async (files, commitMessage) => {
try {
const response = await axios.post(`${API_BASE_URL}/settings/push`, {
files,
commit_message: commitMessage
});
return response.data;
} catch (error) {
console.error('Error pushing files:', error);
throw error;
}
};
export const revertFile = async (filePath) => {
try {
const response = await axios.post(`${API_BASE_URL}/settings/revert`, {
file_path: filePath
});
return response.data;
} catch (error) {
console.error('Error reverting file:', error);
throw error;
}
};
export const revertAll = async () => {
try {
const response = await axios.post(`${API_BASE_URL}/settings/revert-all`);
return response.data;
} catch (error) {
console.error('Error reverting all changes:', error);
throw error;
}
};
export const deleteFile = async (filePath) => {
try {
const response = await axios.delete(`${API_BASE_URL}/settings/file`, {
data: { file_path: filePath },
});
return response.data;
} catch (error) {
console.error('Error deleting file:', error);
return { success: false, error: 'Error deleting file' };
}
};

View File

@@ -1,5 +1,9 @@
import PropTypes from 'prop-types';
function unsanitize(text) {
return text.replace(/\\:/g, ':').replace(/\\n/g, '\n');
}
function RegexCard({ regex, onEdit, onClone, showDate, formatDate }) {
return (
<div
@@ -8,7 +12,7 @@ function RegexCard({ regex, onEdit, onClone, showDate, formatDate }) {
>
<div className="flex justify-between items-center">
<h3 className="font-bold text-lg text-gray-800 dark:text-gray-200">
{regex.name}
{unsanitize(regex.name)}
</h3>
<button
onClick={(e) => {
@@ -31,7 +35,7 @@ function RegexCard({ regex, onEdit, onClone, showDate, formatDate }) {
</pre>
</div>
<p className="text-gray-500 dark:text-gray-400 text-xs mb-2">
{regex.description}
{unsanitize(regex.description)}
</p>
{showDate && (
<p className="text-gray-500 dark:text-gray-400 text-xs mb-2">

View File

@@ -3,6 +3,10 @@ import PropTypes from 'prop-types';
import { saveRegex, deleteRegex, createRegex101Link } from '../../api/api';
import Modal from '../ui/Modal';
function unsanitize(text) {
return text.replace(/\\:/g, ':').replace(/\\n/g, '\n');
}
function RegexModal({ regex = null, isOpen, onClose, onSave }) {
const [name, setName] = useState('');
const [pattern, setPattern] = useState('');
@@ -18,17 +22,17 @@ function RegexModal({ regex = null, isOpen, onClose, onSave }) {
if (isOpen) {
if (regex && regex.id !== 0) {
initialRegexRef.current = regex;
setName(regex.name);
setName(unsanitize(regex.name));
setPattern(regex.pattern);
setDescription(regex.description);
setTags(regex.tags || []);
setDescription(unsanitize(regex.description));
setTags(regex.tags ? regex.tags.map(unsanitize) : []);
setRegex101Link(regex.regex101Link || '');
} else {
initialRegexRef.current = null;
setName(regex ? regex.name : '');
setName(regex ? unsanitize(regex.name) : '');
setPattern(regex ? regex.pattern : '');
setDescription(regex ? regex.description : '');
setTags(regex ? regex.tags : []);
setDescription(regex ? unsanitize(regex.description) : '');
setTags(regex ? regex.tags.map(unsanitize) : []);
setRegex101Link('');
}
setError('');
@@ -36,7 +40,6 @@ function RegexModal({ regex = null, isOpen, onClose, onSave }) {
setIsLoading(false);
}
}, [regex, isOpen]);
const handleCreateRegex101Link = async () => {
if (!pattern.trim()) {
@@ -44,7 +47,6 @@ function RegexModal({ regex = null, isOpen, onClose, onSave }) {
return;
}
// Define your unit tests here
const unitTests = [
{
description: "Test if 'D-Z0N3' is detected correctly",
@@ -58,7 +60,6 @@ function RegexModal({ regex = null, isOpen, onClose, onSave }) {
criteria: "DOES_NOT_MATCH",
target: "REGEX"
}
// Add more unit tests as needed
];
setIsLoading(true);
@@ -85,7 +86,7 @@ function RegexModal({ regex = null, isOpen, onClose, onSave }) {
});
window.open(regex101Link, '_blank');
onSave(); // Refresh the list after saving
onSave();
setError('');
} catch (error) {
console.error('Error creating regex101 link:', error);
@@ -100,7 +101,7 @@ function RegexModal({ regex = null, isOpen, onClose, onSave }) {
if (!confirmRemoval) return;
setIsLoading(true);
setRegex101Link(''); // Clear the regex101Link in state
setRegex101Link('');
try {
await saveRegex({
@@ -109,10 +110,10 @@ function RegexModal({ regex = null, isOpen, onClose, onSave }) {
pattern,
description,
tags,
regex101Link: '', // Save the regex with an empty link
regex101Link: '',
});
onSave(); // Refresh the list after saving
onSave();
setError('');
} catch (error) {
console.error('Error removing regex101 link:', error);
@@ -149,7 +150,6 @@ function RegexModal({ regex = null, isOpen, onClose, onSave }) {
if (!confirmDeletion) return;
try {
// Attempt to delete the regex
const response = await deleteRegex(regex.id);
if (response.error) {
if (response.error === 'Regex in use') {
@@ -165,8 +165,7 @@ function RegexModal({ regex = null, isOpen, onClose, onSave }) {
console.error('Error deleting regex:', error);
setError('Failed to delete regex. Please try again.');
}
};
};
const handleAddTag = () => {
if (newTag.trim() && !tags.includes(newTag.trim())) {
@@ -296,6 +295,7 @@ function RegexModal({ regex = null, isOpen, onClose, onSave }) {
</Modal>
);
}
RegexModal.propTypes = {
regex: PropTypes.shape({
id: PropTypes.number,

View File

@@ -0,0 +1,87 @@
import React from 'react';
import { CheckSquare, GitCommit, RotateCcw, Loader } from 'lucide-react';
import Textarea from '../ui/TextArea';
import Tooltip from '../ui/Tooltip';
const CommitSection = ({ status, commitMessage, setCommitMessage, handleStageAll, handleCommitAll, handleRevertAll, loadingAction }) => {
const hasUnstagedChanges = status.changes.some(change => !change.staged || (change.staged && change.modified));
const hasStagedChanges = status.changes.some(change => change.staged);
const hasAnyChanges = status.changes.length > 0;
const funMessages = [
"No changes detected. Your regex is so precise, it could find a needle in a haystack... made of needles. 🧵🔍",
"All quiet on the commit front. Your custom formats are so perfect, even perfectionists are jealous. 🏆",
"No updates needed. Your media automation is running so smoothly, it's making butter jealous. 🧈",
"Zero modifications. Your torrent setup is seeding so efficiently, farmers are asking for advice. 🌾",
"No edits required. Your regex fu is so strong, it's bench-pressing parentheses for fun. 💪()",
"Unchanged status. Your Plex library is so well-organized, librarians are taking notes. 📚🤓",
"No alterations found. Your file naming scheme is so consistent, it's bringing tears to OCD eyes. 😢👀",
"All systems nominal. Your download queue is so orderly, it's making Marie Kondo question her career. 🧹✨",
"No revisions necessary. Your automation scripts are so smart, they're solving captchas for fun. 🤖🧩",
"Steady as she goes. Your media collection is so complete, Netflix is asking you for recommendations. 🎬👑"
];
const randomMessage = funMessages[Math.floor(Math.random() * funMessages.length)];
const CommitButton = () => (
<button
onClick={handleCommitAll}
className="flex items-center justify-center px-3 py-2 bg-purple-600 text-white rounded hover:bg-purple-700 transition-colors text-sm"
disabled={loadingAction === 'commit_all' || !commitMessage.trim()}
>
{loadingAction === 'commit_all' ? <Loader size={16} className="animate-spin mr-2" /> : <GitCommit className="mr-2" size={16} />}
Commit All
</button>
);
return (
<div className="mt-4">
<h3 className="text-sm font-semibold text-gray-100 mb-4">Changes:</h3>
{hasAnyChanges ? (
<>
{hasStagedChanges && (
<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="flex justify-end space-x-2">
{hasUnstagedChanges && (
<button
onClick={handleStageAll}
className="flex items-center justify-center px-3 py-2 bg-green-600 text-white rounded hover:bg-green-700 transition-colors text-sm"
disabled={loadingAction === 'stage_all'}
>
{loadingAction === 'stage_all' ? <Loader size={16} className="animate-spin mr-2" /> : <CheckSquare className="mr-2" size={16} />}
Stage All
</button>
)}
{hasStagedChanges && (
!commitMessage.trim() ? (
<Tooltip content="Commit message is required">
<CommitButton />
</Tooltip>
) : (
<CommitButton />
)
)}
<button
onClick={handleRevertAll}
className="flex items-center justify-center px-3 py-2 bg-red-600 text-white rounded hover:bg-red-700 transition-colors text-sm"
disabled={loadingAction === 'revert_all'}
>
{loadingAction === 'revert_all' ? <Loader size={16} className="animate-spin mr-2" /> : <RotateCcw className="mr-2" size={16} />}
Revert All
</button>
</div>
</>
) : (
<div className="text-gray-300 text-sm italic">{randomMessage}</div>
)}
</div>
);
};
export default CommitSection;

View File

@@ -0,0 +1,59 @@
import React from 'react';
import Modal from '../ui/Modal';
const DiffViewer = ({ isOpen, onClose, diffContent = [] }) => {
// Ensure diffContent is an array before proceeding
if (!Array.isArray(diffContent)) {
diffContent = []; // Default to an empty array if diffContent is not an array
}
// Separate content for local and incoming
const localContent = diffContent.filter(line => line.type === 'local' || line.type === 'context' || line.type === 'unchanged');
const incomingContent = diffContent.filter(line => line.type === 'incoming' || line.type === 'context' || line.type === 'unchanged');
const renderDiffColumn = (content) => {
return content.map((line, index) => {
let lineClass = 'text-gray-200'; // Default line class for unchanged lines
if (line.type === 'local') {
lineClass = 'bg-red-400 text-black'; // Highlight local changes
} else if (line.type === 'incoming') {
lineClass = 'bg-yellow-400 text-black'; // Highlight incoming changes
} else if (line.type === 'context') {
lineClass = 'text-gray-400'; // Context lines
}
return (
<div key={index} className={`p-1 ${lineClass}`}>
{line.text}
</div>
);
});
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title="Diff Viewer"
size="xl" // Making the modal larger
>
<div className="bg-gray-900 p-4 rounded-md overflow-auto text-xs flex space-x-4">
<div className="w-1/2">
<h3 className="text-gray-300 mb-2">Remote</h3>
<div className="bg-gray-800 p-2 rounded-md">
{renderDiffColumn(localContent)}
</div>
</div>
<div className="w-1/2">
<h3 className="text-gray-300 mb-2">Local</h3>
<div className="bg-gray-800 p-2 rounded-md">
{renderDiffColumn(incomingContent)}
</div>
</div>
</div>
</Modal>
);
};
export default DiffViewer;

View File

@@ -0,0 +1,262 @@
import React, { useState, useEffect } from 'react';
import Modal from '../ui/Modal';
import { getBranches, checkoutBranch, createBranch, deleteBranch } from '../../api/api';
import { ExternalLink, Trash2, GitBranchPlus, ArrowRightCircle, Loader } from 'lucide-react';
import Tooltip from '../ui/Tooltip';
import Alert from '../ui/Alert';
const SettingsBranchModal = ({ isOpen, onClose, repoUrl, currentBranch, onBranchChange }) => {
const [branches, setBranches] = useState([]);
const [branchOffMode, setBranchOffMode] = useState(null);
const [newBranchName, setNewBranchName] = useState('');
const [validBranchName, setValidBranchName] = useState(true);
const [branchToDelete, setBranchToDelete] = useState(null);
const [loadingAction, setLoadingAction] = useState('');
useEffect(() => {
if (isOpen) {
fetchBranches();
resetForm();
}
}, [isOpen]);
const fetchBranches = async () => {
try {
const response = await getBranches();
if (response.success && response.data.branches) {
setBranches(response.data.branches);
} else {
console.error('Error fetching branches:', response.data.error);
}
} catch (error) {
console.error('Error fetching branches:', error);
}
};
const resetForm = () => {
setBranchOffMode(null);
setNewBranchName('');
setValidBranchName(true);
setBranchToDelete(null);
setLoadingAction('');
};
const handleCheckout = async (branchName, isNewBranch = false) => {
setLoadingAction(`checkout-${branchName}`);
try {
const response = await checkoutBranch(branchName);
if (response.success) {
// Refresh branches after successful checkout
await fetchBranches();
// Notify parent component to update the status
onBranchChange(); // <-- Call the callback to update status in the parent component
if (!isNewBranch) {
Alert.success('Branch checked out successfully');
}
onClose(); // Close the modal
} else {
Alert.error(response.error); // Use the Alert component for error
}
} catch (error) {
if (error.response && error.response.status === 400 && error.response.data.error) {
Alert.error(error.response.data.error); // Show alert with specific backend error message
} else {
Alert.error('An unexpected error occurred while checking out the branch.');
console.error('Error checking out branch:', error);
}
} finally {
setLoadingAction('');
}
};
const handleBranchOff = async () => {
setLoadingAction('branchOff');
if (newBranchName && validBranchName) {
try {
const response = await createBranch(newBranchName, branchOffMode);
if (response.success) {
// Checkout the new branch without showing the checkout alert
await handleCheckout(newBranchName, true);
Alert.success('Branch created and checked out successfully');
} else {
Alert.error(response.error); // Handle known errors from the backend
}
} catch (error) {
if (error.response && error.response.status === 400 && error.response.data.error) {
Alert.error(error.response.data.error); // Specific error from the backend
} else {
console.error('Error branching off:', error); // Log unexpected errors
Alert.error('An unexpected error occurred while creating the branch. Please try again.');
}
} finally {
setLoadingAction('');
}
} else {
Alert.error('Please enter a valid branch name.');
}
};
const handleBranchOffClick = (branchName) => {
setBranchOffMode(branchName);
setNewBranchName('');
setValidBranchName(true);
};
const validateBranchName = (name) => {
const isValid = /^[a-zA-Z0-9._-]+$/.test(name);
setValidBranchName(isValid);
setNewBranchName(name);
};
const handleOpenInGitHub = (branchName) => {
const branchUrl = `${repoUrl}/tree/${encodeURIComponent(branchName)}`;
window.open(branchUrl, '_blank');
};
const confirmDeleteBranch = (branchName) => {
setBranchToDelete(branchName);
};
const handleDeleteBranch = async () => {
if (branchToDelete && branchToDelete.toLowerCase() === 'main') {
Alert.warning("The 'main' branch cannot be deleted.");
return;
}
setLoadingAction(`delete-${branchToDelete}`);
try {
const response = await deleteBranch(branchToDelete);
if (response.success) {
onBranchChange(); // <-- Call the callback to update status in the parent component
await fetchBranches(); // Refresh the list after deletion
Alert.success(`Branch '${branchToDelete}' deleted successfully`);
setBranchToDelete(null);
} else {
Alert.error(response.error); // Use the Alert component for error
}
} catch (error) {
Alert.error('An unexpected error occurred while deleting the branch.');
console.error('Error deleting branch:', error);
} finally {
setLoadingAction('');
}
};
return (
<Modal isOpen={isOpen} onClose={onClose} title="Manage Git Branches">
<div className="space-y-4 text-xs">
<div>
<h3 className="text-lg font-medium mb-2 text-gray-100">Branches:</h3>
<ul className="space-y-2">
{branches.map((branch, index) => (
<li
key={index} // Ensure you use a unique key
className={`flex items-center justify-between p-2 rounded ${
branch.name === currentBranch ? 'border border-blue-400 bg-gray-800' : 'bg-gray-700'
}`}
>
<div className="flex items-center space-x-2">
<span className={branch.name === currentBranch ? 'text-white' : 'text-gray-300'}>
{branch.name ? branch.name : 'Unknown Branch'} {/* Fallback if branch.name is undefined */}
</span>
</div>
<div className="flex items-center space-x-1">
{branch.name !== currentBranch && (
<Tooltip content="Checkout">
<button
onClick={() => handleCheckout(branch.name)}
className="p-1 bg-blue-500 text-white rounded hover:bg-blue-600 hover:scale-105 transition-transform duration-200"
disabled={loadingAction === `checkout-${branch.name}`}
>
{loadingAction === `checkout-${branch.name}` ? (
<Loader size={14} className="animate-spin" />
) : (
<ArrowRightCircle size={14} />
)}
</button>
</Tooltip>
)}
<Tooltip content="Branch Off">
<button
onClick={() => handleBranchOffClick(branch.name)}
className="p-1 bg-green-500 text-white rounded hover:bg-green-600 hover:scale-105 transition-transform duration-200"
disabled={loadingAction === 'branchOff'}
>
{loadingAction === 'branchOff' ? (
<Loader size={14} className="animate-spin" />
) : (
<GitBranchPlus size={14} />
)}
</button>
</Tooltip>
{branch.name !== currentBranch && branch.name.toLowerCase() !== 'main' && (
<Tooltip content="Delete">
<button
onClick={() => confirmDeleteBranch(branch.name)}
className="p-1 bg-red-500 text-white rounded hover:bg-red-600 hover:scale-105 transition-transform duration-200"
disabled={loadingAction === `delete-${branch.name}`}
>
{loadingAction === `delete-${branch.name}` ? (
<Loader size={14} className="animate-spin" />
) : (
<Trash2 size={14} />
)}
</button>
</Tooltip>
)}
<Tooltip content="View on GitHub">
<button
onClick={() => handleOpenInGitHub(branch.name)}
className="p-1 bg-gray-600 text-white rounded hover:bg-gray-700 hover:scale-105 transition-transform duration-200"
>
<ExternalLink size={14} />
</button>
</Tooltip>
</div>
</li>
))}
</ul>
</div>
{branchOffMode && (
<div className="flex items-center space-x-2 mt-4">
<input
type="text"
value={newBranchName}
onChange={(e) => validateBranchName(e.target.value)}
placeholder={`New branch from ${branchOffMode}`}
className={`flex-grow p-2 border rounded bg-gray-800 text-gray-300 ${!validBranchName ? 'border-red-500' : ''}`}
/>
<button
onClick={handleBranchOff}
disabled={!newBranchName || !validBranchName || loadingAction === 'branchOff'}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors text-xs"
>
{loadingAction === 'branchOff' ? 'Creating...' : 'Create'}
</button>
</div>
)}
{branchToDelete && (
<div className="mt-4 text-sm text-gray-200">
<p>Are you sure you want to delete the branch <strong>{branchToDelete}</strong>? This action cannot be undone.</p>
<div className="flex space-x-4 mt-2">
<button
onClick={handleDeleteBranch}
disabled={loadingAction === `delete-${branchToDelete}`}
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600 transition-colors text-xs"
>
{loadingAction === `delete-${branchToDelete}` ? 'Deleting...' : 'Confirm Delete'}
</button>
<button
onClick={() => setBranchToDelete(null)}
className="px-4 py-2 bg-gray-500 text-white rounded hover:bg-gray-600 transition-colors text-xs"
>
Cancel
</button>
</div>
</div>
)}
</div>
</Modal>
);
};
export default SettingsBranchModal;

View File

@@ -0,0 +1,438 @@
import React, { useState, useEffect } from 'react';
import { getSettings, saveSettings, getGitStatus, pullBranch, addFiles, pushFiles, revertFile, revertAll, deleteFile } from '../../api/api';
import SettingsModal from './SettingsModal';
import SettingsBranchModal from './SettingsBranchModal';
import DiffViewer from './DiffViewer';
import {
FileText,
Code,
AlertCircle,
Plus,
MinusCircle,
Edit,
GitBranch,
Loader,
Eye,
RotateCcw
} from 'lucide-react';
import Alert from '../ui/Alert';
import CommitSection from './CommitSection';
const SettingsManager = () => {
const [settings, setSettings] = useState(null);
const [status, setStatus] = useState(null);
const [showModal, setShowModal] = useState(false);
const [showBranchModal, setShowBranchModal] = useState(false);
const [showDiffModal, setShowDiffModal] = useState(false);
const [diffContent, setDiffContent] = useState('');
const [loadingAction, setLoadingAction] = useState('');
const [loadingStatus, setLoadingStatus] = useState(true);
const [commitMessage, setCommitMessage] = useState('');
useEffect(() => {
fetchSettings();
}, []);
const fetchSettings = async () => {
try {
const fetchedSettings = await getSettings();
if (fetchedSettings) {
setSettings(fetchedSettings);
await fetchGitStatus();
} else {
setShowModal(true);
}
} catch (error) {
console.error('Error fetching settings:', error);
}
};
const handleSaveSettings = async (newSettings) => {
try {
setLoadingAction('save_settings'); // Set a loading state if needed
const response = await saveSettings(newSettings);
if (response) {
setSettings(response); // Update the settings in the state
Alert.success('Settings saved successfully!');
await fetchGitStatus(); // Optionally refresh the Git status after saving
} else {
Alert.error('Failed to save settings. Please try again.');
}
} catch (error) {
Alert.error('An unexpected error occurred while saving the settings.');
console.error('Error saving settings:', error);
} finally {
setLoadingAction(''); // Reset the loading state
setShowModal(false); // Close the modal after saving
}
};
const fetchGitStatus = async () => {
setLoadingStatus(true);
try {
const result = await getGitStatus();
console.log('================ Git Status Response ================');
console.log(JSON.stringify(result, null, 2));
console.log('======================================================');
if (result.success) {
setStatus({
...result.data,
changes: Array.isArray(result.data.changes) ? result.data.changes : [],
});
}
} catch (error) {
console.error('Error fetching Git status:', error);
Alert.error('Failed to fetch Git status');
} finally {
setLoadingStatus(false);
}
};
const handleAddFile = async (filePath) => {
setLoadingAction(`add-${filePath}`);
try {
const response = await addFiles([filePath]);
if (response.success) {
await fetchGitStatus(); // Refresh status
Alert.success(response.message);
} else {
Alert.error(response.error);
}
} catch (error) {
Alert.error('An unexpected error occurred while adding the file.');
console.error('Error adding untracked file:', error);
} finally {
setLoadingAction('');
}
};
const handleStageAll = async () => {
const unstagedChanges = status.changes.filter(change =>
!change.staged || (change.staged && change.modified)
);
if (unstagedChanges.length === 0) {
Alert.warning('There are no changes to stage.');
return;
}
setLoadingAction('stage_all');
try {
const response = await addFiles([]);
if (response.success) {
await fetchGitStatus();
Alert.success(response.message);
} else {
Alert.error(response.error);
}
} catch (error) {
Alert.error('An unexpected error occurred while staging files.');
console.error('Error staging files:', error);
} finally {
setLoadingAction('');
}
};
const handleCommitAll = async () => {
if (!status.changes || !status.changes.some(change => change.staged)) {
Alert.warning('There are no staged changes to commit.');
return;
}
if (!commitMessage.trim()) {
Alert.warning('Please enter a commit message.');
return;
}
setLoadingAction('commit_all');
try {
const stagedFiles = status.changes.filter(change => change.staged).map(change => change.file_path);
const response = await pushFiles(stagedFiles, commitMessage);
if (response.success) {
await fetchGitStatus();
setCommitMessage('');
Alert.success(response.message);
} else {
Alert.error(response.error);
}
} catch (error) {
Alert.error('An unexpected error occurred while committing files.');
console.error('Error committing files:', error);
} finally {
setLoadingAction('');
}
};
const handleRevertFile = async (filePath) => {
const fileToRevert = status.changes.find(change => change.file_path === filePath);
const isDeletedFile = fileToRevert && fileToRevert.status.includes('Deleted');
if (!fileToRevert || (!fileToRevert.staged && !fileToRevert.modified && !isDeletedFile)) {
Alert.warning('There is nothing to revert for this file.');
return;
}
setLoadingAction(`revert-${filePath}`);
try {
const response = await revertFile(filePath);
if (response.success) {
await fetchGitStatus();
Alert.success(response.message);
} else {
Alert.error(response.error);
}
} catch (error) {
Alert.error('An unexpected error occurred while reverting the file.');
console.error('Error reverting file:', error);
} finally {
setLoadingAction('');
}
};
const handleRevertAll = async () => {
const hasChangesToRevert = status.changes.some(change => change.staged || change.modified);
if (!hasChangesToRevert) {
Alert.warning('There are no changes to revert.');
return;
}
setLoadingAction('revert_all');
try {
const response = await revertAll();
if (response.success) {
await fetchGitStatus();
Alert.success(response.message);
} else {
Alert.error(response.error);
}
} catch (error) {
Alert.error('An unexpected error occurred while reverting all changes.');
console.error('Error reverting all changes:', error);
} finally {
setLoadingAction('');
}
};
const handleDeleteFile = async (filePath) => {
setLoadingAction(`delete-${filePath}`);
try {
const response = await deleteFile(filePath);
if (response.success) {
await fetchGitStatus(); // Refresh the status after deletion
Alert.success(`File ${filePath} has been deleted.`);
} else {
Alert.error(response.error);
}
} catch (error) {
Alert.error('An unexpected error occurred while deleting the file.');
console.error('Error deleting file:', error);
} finally {
setLoadingAction('');
}
};
const getActionButton = (change) => {
if (change.status === 'Untracked') {
return (
<button
onClick={() => handleDeleteFile(change.file_path)}
className="flex items-center px-2 py-1 bg-red-600 text-white rounded hover:bg-red-700 transition-colors text-xs"
disabled={loadingAction === `delete-${change.file_path}`}
>
{loadingAction === `delete-${change.file_path}` ? <Loader size={12} className="animate-spin" /> : <MinusCircle className="mr-1" size={12} />}
Delete
</button>
);
} else {
return (
<button
onClick={() => handleRevertFile(change.file_path)}
className="flex items-center px-2 py-1 bg-red-600 text-white rounded hover:bg-red-700 transition-colors text-xs"
disabled={loadingAction === `revert-${change.file_path}`}
>
{loadingAction === `revert-${change.file_path}` ? <Loader size={12} className="animate-spin" /> : <RotateCcw className="mr-1" size={12} />}
Revert
</button>
);
}
};
const loadingMessages = [
"Checking for changes... don't blink!",
"Syncing with the mothership...",
"Peeking under the hood...",
"Counting bits and bytes...",
"Scanning for modifications...",
"Looking for new stuff...",
"Comparing local and remote...",
"Checking your project's pulse...",
"Analyzing your code's mood...",
"Reading the project's diary..."
];
const getRandomLoadingMessage = () => {
return loadingMessages[Math.floor(Math.random() * loadingMessages.length)];
};
const getStatusIcon = (status) => {
switch (status) {
case 'Untracked':
return <Plus className="text-blue-400" size={16} />;
case 'Staged (New)':
return <Plus className="text-green-400" size={16} />;
case 'Staged (Modified)':
case 'Modified':
return <Edit className="text-yellow-400" size={16} />;
case 'Deleted':
return <MinusCircle className="text-red-400" size={16} />;
case 'Deleted (Staged)':
return <MinusCircle className="text-red-600" size={16} />;
case 'Renamed':
return <GitBranch className="text-purple-400" size={16} />;
default:
return <AlertCircle className="text-gray-400" size={16} />;
}
};
const getTypeIcon = (type) => {
switch (type) {
case 'Regex Pattern':
return <Code className="text-blue-400" size={16} />;
case 'Custom Format':
return <FileText className="text-green-400" size={16} />;
default:
return <AlertCircle className="text-gray-400" size={16} />;
}
};
return (
<div className="max-w-4xl mx-auto mt-8 p-6 bg-gray-800 rounded-lg shadow-lg">
<h2 className="text-xl font-bold mb-4 text-gray-100">Git Repository Settings</h2>
{settings && (
<div className="space-y-4">
<div className="bg-gray-700 p-4 rounded-md">
<h3 className="text-sm font-semibold text-gray-100 mb-2">Connected Repository</h3>
<a
href={settings.gitRepo}
target="_blank"
rel="noopener noreferrer"
className="text-blue-400 hover:text-blue-300 transition-colors text-sm"
>
{settings.gitRepo}
</a>
</div>
<div className="bg-gray-700 p-4 rounded-md">
<h3 className="text-sm font-semibold text-gray-100 mb-2">Git Status</h3>
{loadingStatus ? (
<div className="flex items-center justify-center">
<Loader size={24} className="animate-spin text-gray-300" />
<span className="ml-2 text-gray-300 text-sm">{getRandomLoadingMessage()}</span>
</div>
) : (
status && (
<>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center">
<GitBranch className="mr-2 text-green-400" size={14} />
<span className="text-gray-200 text-sm">Current Branch: {status.branch}</span>
</div>
<button
onClick={() => setShowBranchModal(true)}
className="flex items-center px-3 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors duration-200 ease-in-out text-xs"
>
<Eye size={14} className="mr-2" />
View Branches
</button>
</div>
{status.changes.length > 0 && (
<div className="mb-2">
<h4 className="text-sm font-medium text-gray-200 mb-1">Changes:</h4>
<div className="grid grid-cols-5 gap-2 text-xs">
<div className="text-gray-400 font-semibold">Status</div>
<div className="text-gray-400 font-semibold">Type</div>
<div className="text-gray-400 font-semibold">Name</div>
<div className="text-gray-400 font-semibold text-right"></div>
<div className="text-gray-400 font-semibold text-right"></div>
{Array.isArray(status.changes) && status.changes.map((change, index) => (
<React.Fragment key={`change-${index}`}>
<div className="py-1 text-gray-300">
<div className="flex items-center">
{getStatusIcon(change.staged ? `${change.status} (Staged)` : change.status)}
<span className="ml-1">{change.staged ? `${change.status} (Staged)` : change.status}</span>
</div>
</div>
<div className="py-1 text-gray-300">
<div className="flex items-center">
{getTypeIcon(change.type)}
<span className="ml-1">{change.type}</span>
</div>
</div>
<div className="py-1 text-gray-300">{change.name || 'Unnamed'}</div>
<div className="py-1 text-right col-span-2">
<div className="inline-flex space-x-2">
{(!change.staged || change.modified) && (
<button
onClick={() => handleAddFile(change.file_path)}
className="flex items-center px-2 py-1 bg-green-600 text-white rounded hover:bg-green-700 transition-colors text-xs"
>
<Plus size={12} className="mr-1" />
{change.staged ? 'Re-stage' : 'Stage'}
</button>
)}
{getActionButton(change)}
</div>
</div>
</React.Fragment>
))}
</div>
</div>
)}
<CommitSection
status={status}
commitMessage={commitMessage}
setCommitMessage={setCommitMessage}
handleStageAll={handleStageAll}
handleCommitAll={handleCommitAll}
handleRevertAll={handleRevertAll}
loadingAction={loadingAction}
/>
</>
)
)}
</div>
</div>
)}
<SettingsModal
isOpen={showModal}
onClose={() => setShowModal(false)}
onSave={(newSettings) => handleSaveSettings(newSettings)}
/>
{settings && status && (
<SettingsBranchModal
isOpen={showBranchModal}
onClose={() => setShowBranchModal(false)}
repoUrl={settings.gitRepo}
currentBranch={status.branch}
onBranchChange={fetchGitStatus}
/>
)}
<DiffViewer
isOpen={showDiffModal}
onClose={() => setShowDiffModal(false)}
diffContent={diffContent}
/>
</div>
);
}
export default SettingsManager;

View File

@@ -0,0 +1,64 @@
import React, { useState } from 'react';
import Modal from '../ui/Modal';
import { Loader } from 'lucide-react';
const SettingsModal = ({ isOpen, onClose, onSave }) => {
const [gitRepo, setGitRepo] = useState('');
const [gitToken, setGitToken] = useState('');
const [loading, setLoading] = useState(false);
const handleSave = () => {
if (!gitRepo || !gitToken) {
alert("Please fill in all fields.");
return;
}
setLoading(true);
onSave({ gitRepo, gitToken, localRepoPath: 'data/db' })
.finally(() => setLoading(false));
};
return (
<Modal isOpen={isOpen} onClose={onClose} title="Setup Git Repository">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Git Repository URL:</label>
<input
type="text"
value={gitRepo}
onChange={(e) => setGitRepo(e.target.value)}
className="w-full p-2 border rounded bg-gray-900 text-gray-100 border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="https://github.com/your-repo.git"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">GitHub Access Token:</label>
<input
type="password"
value={gitToken}
onChange={(e) => setGitToken(e.target.value)}
className="w-full p-2 border rounded bg-gray-900 text-gray-100 border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Your GitHub Token"
/>
</div>
<div className="mt-6 flex justify-end">
<button
onClick={handleSave}
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 transition-colors flex items-center"
disabled={loading}
>
{loading ? (
<>
<Loader size={16} className="animate-spin mr-2" />
Saving...
</>
) : (
'Save'
)}
</button>
</div>
</div>
</Modal>
);
};
export default SettingsModal;

View File

@@ -0,0 +1,50 @@
// src/components/ui/Alert.js
import { toast } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
const baseStyles = {
className: 'rounded-lg shadow-lg p-4',
bodyClassName: 'text-sm font-medium',
progressClassName: 'h-1 rounded-lg',
};
const Alert = {
success: (message, options = {}) => {
toast.success(message, {
...baseStyles,
className: `${baseStyles.className} bg-green-600 text-white`,
progressClassName: `${baseStyles.progressClassName} bg-green-300`,
...options,
});
},
error: (message, options = {}) => {
toast.error(message, {
...baseStyles,
className: `${baseStyles.className} bg-red-600 text-white`,
progressClassName: `${baseStyles.progressClassName} bg-red-300`,
...options,
});
},
warning: (message, options = {}) => {
toast.warn(message, {
...baseStyles,
className: `${baseStyles.className} bg-yellow-600 text-white`,
progressClassName: `${baseStyles.progressClassName} bg-yellow-300`,
...options,
});
},
info: (message, options = {}) => {
toast.info(message, {
...baseStyles,
className: `${baseStyles.className} bg-blue-600 text-white`,
progressClassName: `${baseStyles.progressClassName} bg-blue-300`,
...options,
});
},
};
export default Alert;

View File

@@ -1,13 +1,13 @@
import PropTypes from 'prop-types';
import { useState, useEffect, useRef } from 'react';
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'}`}></div>
<div className={`dot absolute left-1 top-1 bg-white w-6 h-6 rounded-full transition ${checked ? 'transform translate-x-6' : ''}`}></div>
<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'}
@@ -22,31 +22,58 @@ ToggleSwitch.propTypes = {
};
function Navbar({ activeTab, setActiveTab, darkMode, setDarkMode }) {
const [tabOffset, setTabOffset] = useState(0);
const [tabWidth, setTabWidth] = useState(0);
const tabsRef = useRef([]);
useEffect(() => {
if (tabsRef.current[activeTab]) {
const tab = tabsRef.current[activeTab];
setTabOffset(tab.offsetLeft);
setTabWidth(tab.offsetWidth);
}
}, [activeTab]);
return (
<nav className="bg-gray-800 shadow-md">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<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">
Regexerr
</h1>
<div className="flex space-x-2">
<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>
<button
className={`px-3 py-2 rounded-md text-sm font-medium ${
activeTab === 'regex' ? 'bg-gray-900 text-white' : 'text-gray-300 hover:bg-gray-700 hover:text-white'
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'
}`}
onClick={() => setActiveTab('regex')}
>
Regex
</button>
<button
className={`px-3 py-2 rounded-md text-sm font-medium ${
activeTab === 'format' ? 'bg-gray-900 text-white' : 'text-gray-300 hover:bg-gray-700 hover:text-white'
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'
}`}
onClick={() => setActiveTab('format')}
>
Custom Format
</button>
<button
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'
}`}
onClick={() => setActiveTab('settings')}
>
Settings
</button>
</div>
</div>
<ToggleSwitch checked={darkMode} onChange={() => setDarkMode(!darkMode)} />
@@ -63,4 +90,4 @@ Navbar.propTypes = {
setDarkMode: PropTypes.func.isRequired,
};
export default Navbar;
export default Navbar;

View File

@@ -0,0 +1,15 @@
import React from "react";
const Textarea = React.forwardRef(({ className, ...props }, ref) => {
return (
<textarea
className={`flex min-h-[80px] w-full rounded-md border border-dark-border bg-dark-card px-3 py-2 text-sm text-dark-text placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent ${className}`}
ref={ref}
{...props}
/>
);
});
Textarea.displayName = "Textarea";
export default Textarea;

View File

@@ -0,0 +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}
</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;

View File

@@ -4,7 +4,5 @@ import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
<App />
);

View File

@@ -13,9 +13,14 @@ module.exports = {
'0%': { opacity: 0, transform: 'scale(0.95)' },
'100%': { opacity: 1, transform: 'scale(1)' },
},
'fade-in': {
'0%': { opacity: 0 },
'100%': { opacity: 1 },
},
},
animation: {
'modal-open': 'modal-open 0.3s ease-out forwards',
'fade-in': 'fade-in 0.5s ease-in-out forwards',
},
colors: {
'dark-bg': '#1a1c23',
@@ -25,6 +30,11 @@ module.exports = {
'dark-button': '#3182ce',
'dark-button-hover': '#2c5282',
},
borderRadius: {
'lg': '0.5rem',
'md': '0.375rem',
'sm': '0.25rem',
},
},
},
plugins: [],