From 360379f603e3b16d7ee003a9d785992e4807a4bf Mon Sep 17 00:00:00 2001 From: keyboardstaff Date: Mon, 2 Feb 2026 23:23:19 -0800 Subject: [PATCH 1/4] git projects - basic implementation --- prompts/agent.system.projects.active.md | 1 + python/api/projects.py | 46 +++++ python/helpers/git.py | 74 ++++++- python/helpers/projects.py | 47 ++++- webui/components/projects/project-create.html | 58 +++++- .../projects/project-edit-basic-data.html | 191 ++++++++++++++++++ webui/components/projects/projects-store.js | 78 +++++++ webui/css/modals.css | 77 +++++++ webui/js/confirmDialog.js | 71 +++++++ 9 files changed, 633 insertions(+), 10 deletions(-) create mode 100644 webui/js/confirmDialog.js diff --git a/prompts/agent.system.projects.active.md b/prompts/agent.system.projects.active.md index 3d2ae63a9..9d75f0687 100644 --- a/prompts/agent.system.projects.active.md +++ b/prompts/agent.system.projects.active.md @@ -2,6 +2,7 @@ Path: {{project_path}} Title: {{project_name}} Description: {{project_description}} +{% if project_git_url %}Git URL: {{project_git_url}}{% endif %} ### Important project instructions MUST follow diff --git a/python/api/projects.py b/python/api/projects.py index 3e06fadcd..ba82e383c 100644 --- a/python/api/projects.py +++ b/python/api/projects.py @@ -1,5 +1,6 @@ from python.helpers.api import ApiHandler, Input, Output, Request, Response from python.helpers import projects +from python.helpers.notification import NotificationManager, NotificationType, NotificationPriority class Projects(ApiHandler): @@ -17,6 +18,8 @@ async def process(self, input: Input, request: Request) -> Output: data = self.load_project(input.get("name", None)) elif action == "create": data = self.create_project(input.get("project", None)) + elif action == "clone": + data = self.clone_project(input.get("project", None)) elif action == "update": data = self.update_project(input.get("project", None)) elif action == "delete": @@ -50,6 +53,49 @@ def create_project(self, project: dict|None): name = projects.create_project(project["name"], data) return projects.load_edit_project_data(name) + def clone_project(self, project: dict|None): + if project is None: + raise Exception("Project data is required") + git_url = project.get("git_url", "") + if not git_url: + raise Exception("Git URL is required") + + # Progress notification + notification = NotificationManager.send_notification( + NotificationType.PROGRESS, + NotificationPriority.NORMAL, + f"Cloning repository...", + "Git Clone", + display_time=999, + group="git_clone" + ) + + try: + data = projects.BasicProjectData(**project) + name = projects.clone_git_project(project["name"], git_url, data) + + # Success notification + NotificationManager.send_notification( + NotificationType.SUCCESS, + NotificationPriority.NORMAL, + f"Repository cloned successfully", + "Git Clone", + display_time=3, + group="git_clone" + ) + return projects.load_edit_project_data(name) + except Exception as e: + # Error notification + NotificationManager.send_notification( + NotificationType.ERROR, + NotificationPriority.HIGH, + f"Clone failed: {str(e)}", + "Git Clone", + display_time=5, + group="git_clone" + ) + raise + def load_project(self, name: str|None): if name is None: raise Exception("Project name is required") diff --git a/python/helpers/git.py b/python/helpers/git.py index 33e3bec22..f89fce5be 100644 --- a/python/helpers/git.py +++ b/python/helpers/git.py @@ -54,4 +54,76 @@ def get_version(): git_info = get_git_info() return str(git_info.get("short_tag", "")).strip() or "unknown" except Exception: - return "unknown" \ No newline at end of file + return "unknown" + + +def clone_repo(url: str, dest: str, progress_callback=None): + """Clone a git repository to destination.""" + class Progress: + def __call__(self, op_code, cur_count, max_count=None, message=''): + if progress_callback and max_count: + progress_callback(cur_count, max_count, message) + return Repo.clone_from(url, dest, progress=Progress() if progress_callback else None) + + +# Files to ignore when checking dirty status (A0 project metadata) +A0_IGNORE_PATTERNS = {".a0proj", ".a0proj/"} + + +def get_repo_status(repo_path: str) -> dict: + """Get Git repository status, ignoring A0 project metadata files.""" + try: + repo = Repo(repo_path) + if repo.bare: + return {"is_git_repo": False, "error": "Repository is bare"} + + # Remote URL + remote_url = "" + try: + if repo.remotes: + remote_url = repo.remotes.origin.url + except Exception: + pass + + # Current branch + try: + current_branch = repo.active_branch.name if not repo.head.is_detached else f"HEAD@{repo.head.commit.hexsha[:7]}" + except Exception: + current_branch = "unknown" + + # Check dirty status, excluding A0 metadata + def is_a0_file(path: str) -> bool: + return path.startswith(".a0proj") or path == ".a0proj" + + # Filter out A0 files from diff and untracked + changed_files = [d.a_path for d in repo.index.diff(None)] + [d.a_path for d in repo.index.diff("HEAD")] + untracked = repo.untracked_files + + real_changes = [f for f in changed_files if not is_a0_file(f)] + real_untracked = [f for f in untracked if not is_a0_file(f)] + + is_dirty = len(real_changes) > 0 or len(real_untracked) > 0 + untracked_count = len(real_untracked) + + last_commit = None + try: + commit = repo.head.commit + last_commit = { + "hash": commit.hexsha[:7], + "message": commit.message.split('\n')[0][:80], + "author": str(commit.author), + "date": datetime.fromtimestamp(commit.committed_date).strftime('%Y-%m-%d %H:%M') + } + except Exception: + pass + + return { + "is_git_repo": True, + "remote_url": remote_url, + "current_branch": current_branch, + "is_dirty": is_dirty, + "untracked_count": untracked_count, + "last_commit": last_commit + } + except Exception as e: + return {"is_git_repo": False, "error": str(e)} \ No newline at end of file diff --git a/python/helpers/projects.py b/python/helpers/projects.py index 73deeba46..d008f158a 100644 --- a/python/helpers/projects.py +++ b/python/helpers/projects.py @@ -33,11 +33,21 @@ class BasicProjectData(TypedDict): description: str instructions: str color: str + git_url: str memory: Literal[ "own", "global" ] # in the future we can add cutom and point to another existing folder file_structure: FileStructureInjectionSettings +class GitStatusData(TypedDict, total=False): + is_git_repo: bool + remote_url: str + current_branch: str + is_dirty: bool + untracked_count: int + last_commit: dict + error: str + class EditProjectData(BasicProjectData): name: str instruction_files_count: int @@ -45,6 +55,7 @@ class EditProjectData(BasicProjectData): variables: str secrets: str subagents: dict[str, SubAgentSettings] + git_status: GitStatusData @@ -77,6 +88,30 @@ def create_project(name: str, data: BasicProjectData): return name +def clone_git_project(name: str, git_url: str, data: BasicProjectData): + """Clone a git repository as a new A0 project.""" + from python.helpers import git + + abs_path = files.create_dir_safe( + files.get_abs_path(PROJECTS_PARENT_DIR, name), rename_format="{name}_{number}" + ) + actual_name = files.basename(abs_path) + + try: + git.clone_repo(git_url, abs_path) + create_project_meta_folders(actual_name) + data = _normalizeBasicData(data) + data["git_url"] = git_url + save_project_header(actual_name, data) + return actual_name + except Exception as e: + try: + files.delete_dir(abs_path) + except Exception: + pass + raise e + + def load_project_header(name: str): abs_path = files.get_abs_path( PROJECTS_PARENT_DIR, name, PROJECT_META_DIR, PROJECT_HEADER_FILE @@ -107,6 +142,7 @@ def _normalizeBasicData(data: BasicProjectData): description=data.get("description", ""), instructions=data.get("instructions", ""), color=data.get("color", ""), + git_url=data.get("git_url", ""), memory=data.get("memory", "own"), file_structure=data.get( "file_structure", @@ -123,6 +159,7 @@ def _normalizeEditData(data: EditProjectData): instructions=data.get("instructions", ""), variables=data.get("variables", ""), color=data.get("color", ""), + git_status=data.get("git_status", {"is_git_repo": False}), instruction_files_count=data.get("instruction_files_count", 0), knowledge_files_count=data.get("knowledge_files_count", 0), secrets=data.get("secrets", ""), @@ -169,14 +206,16 @@ def load_basic_project_data(name: str) -> BasicProjectData: def load_edit_project_data(name: str) -> EditProjectData: + from python.helpers import git + data = load_basic_project_data(name) - additional_instructions = get_additional_instructions_files( - name - ) # for additional info + additional_instructions = get_additional_instructions_files(name) variables = load_project_variables(name) secrets = load_project_secrets_masked(name) subagents = load_project_subagents(name) knowledge_files_count = get_knowledge_files_count(name) + git_status = git.get_repo_status(get_project_folder(name)) + data = EditProjectData( **data, name=name, @@ -185,6 +224,7 @@ def load_edit_project_data(name: str) -> EditProjectData: variables=variables, secrets=secrets, subagents=subagents, + git_status=git_status, ) data = _normalizeEditData(data) return data @@ -308,6 +348,7 @@ def build_system_prompt_vars(name: str): "project_description": project_data.get("description", ""), "project_instructions": complete_instructions or "", "project_path": files.normalize_a0_path(get_project_folder(name)), + "project_git_url": project_data.get("git_url", ""), } diff --git a/webui/components/projects/project-create.html b/webui/components/projects/project-create.html index caab8194a..486dbc400 100644 --- a/webui/components/projects/project-create.html +++ b/webui/components/projects/project-create.html @@ -16,12 +16,33 @@ -
- - -
+
+ + Clone from an existing git repository. Leave empty to create an empty project. + +
+ +
+ + +
@@ -41,6 +62,31 @@ .project-detail-header { margin-bottom: 1em; } + + .button-loading { + display: inline-flex; + align-items: center; + gap: 0.5em; + } + + .spinner { + width: 14px; + height: 14px; + border: 2px solid rgba(255, 255, 255, 0.3); + border-top: 2px solid white; + border-radius: 50%; + animation: spin 1s linear infinite; + } + + @keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } + } + + .button:disabled { + opacity: 0.6; + cursor: not-allowed; + } \ No newline at end of file diff --git a/webui/components/projects/project-edit-basic-data.html b/webui/components/projects/project-edit-basic-data.html index 3f2f4df54..bd43678cf 100644 --- a/webui/components/projects/project-edit-basic-data.html +++ b/webui/components/projects/project-edit-basic-data.html @@ -59,6 +59,81 @@ + + @@ -119,6 +194,10 @@ gap: 0.5em; align-items: center; flex-wrap: wrap; + background: var(--color-input); + border: 1px solid var(--color-border); + border-radius: 0.5em; + padding: 0.75em 1em; } .projects-color-ball { @@ -176,6 +255,118 @@ } } + /* Git Status Styles */ + .git-status-section .projects-form-label { + display: flex; + align-items: center; + gap: 0.5em; + } + + .git-status-section .git-icon { + font-size: 1.2em; + color: var(--color-primary); + } + + .git-status-card { + background: var(--color-input); + border: 1px solid var(--color-border); + border-radius: 0.5em; + padding: 0.75em 1em; + } + + .git-status-row { + display: flex; + align-items: flex-start; + gap: 1em; + padding: 0.5em 0; + border-bottom: 1px solid var(--color-border); + } + + .git-status-row:last-child { + border-bottom: none; + } + + .git-status-label { + display: flex; + align-items: center; + gap: 0.4em; + min-width: 100px; + font-size: 0.85em; + color: var(--color-text-secondary, #888); + } + + .git-status-label .material-symbols-outlined { + font-size: 1.1em; + } + + .git-status-value { + flex: 1; + font-size: 0.9em; + word-break: break-all; + } + + .git-status-value-with-action { + flex: 1; + display: flex; + align-items: center; + gap: 0.5em; + } + + .git-action-link { + color: var(--color-primary); + opacity: 0.7; + transition: opacity 0.15s; + } + + .git-action-link:hover { + opacity: 1; + } + + .git-action-link .material-symbols-outlined { + font-size: 1.1em; + } + + .git-url { + font-family: monospace; + font-size: 0.85em; + opacity: 0.9; + } + + .git-branch { + font-family: monospace; + color: var(--color-primary); + font-weight: 500; + } + + .git-clean { + color: #22c55e; + } + + .git-dirty { + color: #f59e0b; + } + + .git-commit-info { + display: flex; + flex-direction: column; + gap: 0.2em; + } + + .git-commit-hash { + font-family: monospace; + color: var(--color-primary); + font-size: 0.9em; + } + + .git-commit-message { + font-size: 0.9em; + } + + .git-commit-meta { + font-size: 0.8em; + opacity: 0.7; + } + \ No newline at end of file diff --git a/webui/components/projects/projects-store.js b/webui/components/projects/projects-store.js index 0ecaa838f..2e6466908 100644 --- a/webui/components/projects/projects-store.js +++ b/webui/components/projects/projects-store.js @@ -5,6 +5,7 @@ import * as notifications from "/components/notifications/notification-store.js" import { store as chatsStore } from "/components/sidebar/chats/chats-store.js"; import { store as browserStore } from "/components/modals/file-browser/file-browser-store.js"; import * as shortcuts from "/js/shortcuts.js"; +import { showConfirmDialog } from "/js/confirmDialog.js"; const listModal = "projects/project-list.html"; const createModal = "projects/project-create.html"; @@ -90,6 +91,11 @@ const model = { }, async confirmCreate() { + // If git_url is provided, use clone flow + if (this.selectedProject.git_url && this.selectedProject.git_url.trim()) { + await this.cloneProject(); + return; + } // create folder name based on title this.selectedProject.name = this._toFolderName(this.selectedProject.title); const project = await this.saveSelectedProject(true); @@ -98,6 +104,76 @@ const model = { await this.openEditModal(project.name); }, + async cloneProject() { + // Security warning with custom dialog + const confirmed = await showConfirmDialog({ + title: "Security Warning", + message: ` +

Cloning repositories from untrusted sources may pose security risks:

+ +

Only clone from sources you trust.

+ `, + type: "warning", + confirmText: "Clone Anyway", + cancelText: "Cancel" + }); + if (!confirmed) return; + + // Save reference before async operations + const project = this.selectedProject; + if (!project) return; + + // Disable button state handled by UI + project._cloning = true; + project.name = this._toFolderName(project.title); + + try { + const response = await api.callJsonApi("projects", { + action: "clone", + project: { + name: project.name, + title: project.title, + color: project.color, + git_url: project.git_url, + }, + }); + + if (response?.ok) { + await this.loadProjectsList(); + await modals.closeModal(createModal); + await this.openEditModal(response.data.name); + } else { + notifications.toastFrontendError( + response?.error || "Clone failed", + "Git Clone", + 5, + "git_clone", + notifications.NotificationPriority.NORMAL, + true + ); + } + } catch (error) { + console.error("Error cloning project:", error); + notifications.toastFrontendError( + "Error cloning project: " + error, + "Git Clone", + 5, + "git_clone", + notifications.NotificationPriority.NORMAL, + true + ); + } finally { + // Use the saved reference instead of this.selectedProject + if (project) { + project._cloning = false; + } + } + }, + async confirmEdit() { const project = await this.saveSelectedProject(false); await this.loadProjectsList(); @@ -304,10 +380,12 @@ const model = { creating: true, }, _ownMemory: true, + _cloning: false, name: ``, title: `Project #${this.projectList.length + 1}`, description: "", color: "", + git_url: "", }; }, diff --git a/webui/css/modals.css b/webui/css/modals.css index 4203ba412..2705233a8 100644 --- a/webui/css/modals.css +++ b/webui/css/modals.css @@ -525,3 +525,80 @@ input[type="range"]::-moz-range-thumb { background-position: -200% 0; } } + +/* Confirm Dialog */ +.confirm-dialog-backdrop { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 10000; + opacity: 0; + transition: opacity 0.2s ease; +} + +.confirm-dialog-backdrop.visible { + opacity: 1; +} + +.confirm-dialog { + background: var(--color-panel); + border: 1px solid var(--color-border); + border-radius: 8px; + max-width: 450px; + width: 90%; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); + transform: scale(0.95); + transition: transform 0.2s ease; +} + +.confirm-dialog-backdrop.visible .confirm-dialog { + transform: scale(1); +} + +.confirm-dialog-header { + display: flex; + align-items: center; + gap: 0.75em; + padding: 1em 1.25em; + border-bottom: 1px solid var(--color-border); +} + +.confirm-dialog-icon { + font-size: 1.5em; +} + +.confirm-dialog-title { + font-size: 1.1em; + font-weight: 600; + color: var(--color-text); +} + +.confirm-dialog-body { + padding: 1.25em; + color: var(--color-text); + line-height: 1.6; + font-size: 0.95em; +} + +.confirm-dialog-body ul { + margin: 0.75em 0; + padding-left: 1.5em; +} + +.confirm-dialog-body p { + margin: 0; +} + +.confirm-dialog-footer { + display: flex; + justify-content: flex-end; + gap: 0.75em; + padding: 1em 1.25em; + border-top: 1px solid var(--color-border); +} diff --git a/webui/js/confirmDialog.js b/webui/js/confirmDialog.js new file mode 100644 index 000000000..5e1478f30 --- /dev/null +++ b/webui/js/confirmDialog.js @@ -0,0 +1,71 @@ +// Custom confirmation dialog. CSS in /css/modals.css + +const DIALOG_TYPES = { + warning: { icon: 'warning', color: 'var(--color-warning, #f59e0b)' }, + danger: { icon: 'error', color: 'var(--color-error, #ef4444)' }, + info: { icon: 'info', color: 'var(--color-primary, #3b82f6)' } +}; + +export function showConfirmDialog(options) { + const { + title = 'Confirm', + message = '', + confirmText = 'Confirm', + cancelText = 'Cancel', + type = 'warning' + } = options; + + const typeConfig = DIALOG_TYPES[type] || DIALOG_TYPES.warning; + + return new Promise((resolve) => { + // Create backdrop + const backdrop = document.createElement('div'); + backdrop.className = 'confirm-dialog-backdrop'; + + // Create dialog + const dialog = document.createElement('div'); + dialog.className = 'confirm-dialog'; + dialog.innerHTML = ` +
+ ${typeConfig.icon} + ${title} +
+
${message}
+ + `; + + backdrop.appendChild(dialog); + document.body.appendChild(backdrop); + + // Show with animation + requestAnimationFrame(() => { + backdrop.classList.add('visible'); + dialog.querySelector('.confirm-dialog-cancel').focus(); + }); + + // Close handler + const close = (result) => { + backdrop.classList.remove('visible'); + document.removeEventListener('keydown', handleKeydown); + setTimeout(() => { + backdrop.remove(); + resolve(result); + }, 200); + }; + + // Event listeners + dialog.querySelector('.confirm-dialog-cancel').addEventListener('click', () => close(false)); + dialog.querySelector('.confirm-dialog-confirm').addEventListener('click', () => close(true)); + backdrop.addEventListener('click', (e) => e.target === backdrop && close(false)); + + // Keyboard handling + const handleKeydown = (e) => { + if (e.key === 'Escape') close(false); + else if (e.key === 'Enter') close(true); + }; + document.addEventListener('keydown', handleKeydown); + }); +} From e2824cd6e904ac86fcc7f57e21ee6468d8f44f02 Mon Sep 17 00:00:00 2001 From: keyboardstaff Date: Tue, 3 Feb 2026 06:10:30 -0800 Subject: [PATCH 2/4] Git Clone Authentication for Private Repositories - Uses 'git -c http.extraHeader=Authorization: Basic ' for authentication - Token is encoded as 'base64("x-access-token:TOKEN")' following GitHub's Basic Auth format - Token is never stored in URL, git config, or project metadata - 'GIT_TERMINAL_PROMPT=0' prevents interactive credential prompts - Remote URL displayed in UI always has auth info stripped for security --- python/api/projects.py | 3 +- python/helpers/git.py | 50 +++++++++++++++---- python/helpers/projects.py | 12 +++-- webui/components/projects/project-create.html | 11 ++++ webui/components/projects/projects-store.js | 2 + 5 files changed, 64 insertions(+), 14 deletions(-) diff --git a/python/api/projects.py b/python/api/projects.py index ba82e383c..6995d9da7 100644 --- a/python/api/projects.py +++ b/python/api/projects.py @@ -57,6 +57,7 @@ def clone_project(self, project: dict|None): if project is None: raise Exception("Project data is required") git_url = project.get("git_url", "") + git_token = project.get("git_token", "") if not git_url: raise Exception("Git URL is required") @@ -72,7 +73,7 @@ def clone_project(self, project: dict|None): try: data = projects.BasicProjectData(**project) - name = projects.clone_git_project(project["name"], git_url, data) + name = projects.clone_git_project(project["name"], git_url, git_token, data) # Success notification NotificationManager.send_notification( diff --git a/python/helpers/git.py b/python/helpers/git.py index f89fce5be..da0e80183 100644 --- a/python/helpers/git.py +++ b/python/helpers/git.py @@ -1,8 +1,25 @@ from git import Repo from datetime import datetime import os +import subprocess +import base64 +from urllib.parse import urlparse, urlunparse from python.helpers import files + +def strip_auth_from_url(url: str) -> str: + """Remove any authentication info from URL.""" + if not url: + return url + parsed = urlparse(url) + if not parsed.hostname: + return url + clean_netloc = parsed.hostname + if parsed.port: + clean_netloc += f":{parsed.port}" + return urlunparse((parsed.scheme, clean_netloc, parsed.path, '', '', '')) + + def get_git_info(): # Get the current working directory (assuming the repo is in the same folder as the script) repo_path = files.get_base_dir() @@ -57,13 +74,28 @@ def get_version(): return "unknown" -def clone_repo(url: str, dest: str, progress_callback=None): - """Clone a git repository to destination.""" - class Progress: - def __call__(self, op_code, cur_count, max_count=None, message=''): - if progress_callback and max_count: - progress_callback(cur_count, max_count, message) - return Repo.clone_from(url, dest, progress=Progress() if progress_callback else None) +def clone_repo(url: str, dest: str, token: str | None = None): + """Clone a git repository. Uses http.extraHeader for token auth (never stored in URL/config).""" + cmd = ['git'] + + if token: + # GitHub Git HTTP requires Basic Auth, not Bearer + auth_string = f"x-access-token:{token}" + auth_base64 = base64.b64encode(auth_string.encode()).decode() + cmd.extend(['-c', f'http.extraHeader=Authorization: Basic {auth_base64}']) + + cmd.extend(['clone', '--progress', '--', url, dest]) + + env = os.environ.copy() + env['GIT_TERMINAL_PROMPT'] = '0' + + result = subprocess.run(cmd, capture_output=True, text=True, env=env) + + if result.returncode != 0: + error_msg = result.stderr.strip() or result.stdout.strip() or 'Unknown error' + raise Exception(f"Git clone failed: {error_msg}") + + return Repo(dest) # Files to ignore when checking dirty status (A0 project metadata) @@ -77,11 +109,11 @@ def get_repo_status(repo_path: str) -> dict: if repo.bare: return {"is_git_repo": False, "error": "Repository is bare"} - # Remote URL + # Remote URL (always strip auth info for security) remote_url = "" try: if repo.remotes: - remote_url = repo.remotes.origin.url + remote_url = strip_auth_from_url(repo.remotes.origin.url) except Exception: pass diff --git a/python/helpers/projects.py b/python/helpers/projects.py index d008f158a..d1b512112 100644 --- a/python/helpers/projects.py +++ b/python/helpers/projects.py @@ -88,8 +88,8 @@ def create_project(name: str, data: BasicProjectData): return name -def clone_git_project(name: str, git_url: str, data: BasicProjectData): - """Clone a git repository as a new A0 project.""" +def clone_git_project(name: str, git_url: str, git_token: str, data: BasicProjectData): + """Clone a git repository as a new A0 project. Token is used only for cloning via http header.""" from python.helpers import git abs_path = files.create_dir_safe( @@ -98,10 +98,14 @@ def clone_git_project(name: str, git_url: str, data: BasicProjectData): actual_name = files.basename(abs_path) try: - git.clone_repo(git_url, abs_path) + # Clone with token via http.extraHeader (token never in URL or git config) + git.clone_repo(git_url, abs_path, token=git_token) + + # Store clean URL only (in case user provided URL with auth) + clean_url = git.strip_auth_from_url(git_url) create_project_meta_folders(actual_name) data = _normalizeBasicData(data) - data["git_url"] = git_url + data["git_url"] = clean_url save_project_header(actual_name, data) return actual_name except Exception as e: diff --git a/webui/components/projects/project-create.html b/webui/components/projects/project-create.html index 486dbc400..b8140cd9a 100644 --- a/webui/components/projects/project-create.html +++ b/webui/components/projects/project-create.html @@ -25,6 +25,17 @@ placeholder="https://github.com/user/repo.git"> + +