git projects - basic implementation
keyboardstaff committed
Feb 2, 2026 at 23:23 UTC
360379f603e3b16d7ee003a9d785992e4807a4bf
9 files changed
+633
-10
prompts/agent.system.projects.active.md
+1
@@ -2,6 +2,7 @@
2
Path: {{project_path}}
3
Title: {{project_name}}
4
Description: {{project_description}}
5
+{% if project_git_url %}Git URL: {{project_git_url}}{% endif %}
6
7
8
### Important project instructions MUST follow
python/api/projects.py
+46
@@ -1,5 +1,6 @@
1
from python.helpers.api import ApiHandler, Input, Output, Request, Response
2
from python.helpers import projects
3
+from python.helpers.notification import NotificationManager, NotificationType, NotificationPriority
4
5
6
class Projects(ApiHandler):
@@ -17,6 +18,8 @@ class Projects(ApiHandler):
18
data = self.load_project(input.get("name", None))
19
elif action == "create":
20
data = self.create_project(input.get("project", None))
21
+ elif action == "clone":
22
+ data = self.clone_project(input.get("project", None))
23
elif action == "update":
24
data = self.update_project(input.get("project", None))
25
elif action == "delete":
@@ -50,6 +53,49 @@ class Projects(ApiHandler):
53
name = projects.create_project(project["name"], data)
54
return projects.load_edit_project_data(name)
55
56
+ def clone_project(self, project: dict|None):
57
+ if project is None:
58
+ raise Exception("Project data is required")
59
+ git_url = project.get("git_url", "")
60
+ if not git_url:
61
+ raise Exception("Git URL is required")
62
+
63
+ # Progress notification
64
+ notification = NotificationManager.send_notification(
65
+ NotificationType.PROGRESS,
66
+ NotificationPriority.NORMAL,
67
+ f"Cloning repository...",
68
+ "Git Clone",
69
+ display_time=999,
70
+ group="git_clone"
71
+ )
72
+
73
+ try:
74
+ data = projects.BasicProjectData(**project)
75
+ name = projects.clone_git_project(project["name"], git_url, data)
76
+
77
+ # Success notification
78
+ NotificationManager.send_notification(
79
+ NotificationType.SUCCESS,
80
+ NotificationPriority.NORMAL,
81
+ f"Repository cloned successfully",
82
+ "Git Clone",
83
+ display_time=3,
84
+ group="git_clone"
85
+ )
86
+ return projects.load_edit_project_data(name)
87
+ except Exception as e:
88
+ # Error notification
89
+ NotificationManager.send_notification(
90
+ NotificationType.ERROR,
91
+ NotificationPriority.HIGH,
92
+ f"Clone failed: {str(e)}",
93
+ "Git Clone",
94
+ display_time=5,
95
+ group="git_clone"
96
+ )
97
+ raise
98
+
99
def load_project(self, name: str|None):
100
if name is None:
101
raise Exception("Project name is required")
python/helpers/git.py
+73
-1
@@ -54,4 +54,76 @@ def get_version():
54
git_info = get_git_info()
55
return str(git_info.get("short_tag", "")).strip() or "unknown"
56
except Exception:
57
- return "unknown"
\ No newline at end of file
57
+ return "unknown"
58
+
59
+
60
+def clone_repo(url: str, dest: str, progress_callback=None):
61
+ """Clone a git repository to destination."""
62
+ class Progress:
63
+ def __call__(self, op_code, cur_count, max_count=None, message=''):
64
+ if progress_callback and max_count:
65
+ progress_callback(cur_count, max_count, message)
66
+ return Repo.clone_from(url, dest, progress=Progress() if progress_callback else None)
67
+
68
+
69
+# Files to ignore when checking dirty status (A0 project metadata)
70
+A0_IGNORE_PATTERNS = {".a0proj", ".a0proj/"}
71
+
72
+
73
+def get_repo_status(repo_path: str) -> dict:
74
+ """Get Git repository status, ignoring A0 project metadata files."""
75
+ try:
76
+ repo = Repo(repo_path)
77
+ if repo.bare:
78
+ return {"is_git_repo": False, "error": "Repository is bare"}
79
+
80
+ # Remote URL
81
+ remote_url = ""
82
+ try:
83
+ if repo.remotes:
84
+ remote_url = repo.remotes.origin.url
85
+ except Exception:
86
+ pass
87
+
88
+ # Current branch
89
+ try:
90
+ current_branch = repo.active_branch.name if not repo.head.is_detached else f"HEAD@{repo.head.commit.hexsha[:7]}"
91
+ except Exception:
92
+ current_branch = "unknown"
93
+
94
+ # Check dirty status, excluding A0 metadata
95
+ def is_a0_file(path: str) -> bool:
96
+ return path.startswith(".a0proj") or path == ".a0proj"
97
+
98
+ # Filter out A0 files from diff and untracked
99
+ changed_files = [d.a_path for d in repo.index.diff(None)] + [d.a_path for d in repo.index.diff("HEAD")]
100
+ untracked = repo.untracked_files
101
+
102
+ real_changes = [f for f in changed_files if not is_a0_file(f)]
103
+ real_untracked = [f for f in untracked if not is_a0_file(f)]
104
+
105
+ is_dirty = len(real_changes) > 0 or len(real_untracked) > 0
106
+ untracked_count = len(real_untracked)
107
+
108
+ last_commit = None
109
+ try:
110
+ commit = repo.head.commit
111
+ last_commit = {
112
+ "hash": commit.hexsha[:7],
113
+ "message": commit.message.split('\n')[0][:80],
114
+ "author": str(commit.author),
115
+ "date": datetime.fromtimestamp(commit.committed_date).strftime('%Y-%m-%d %H:%M')
116
+ }
117
+ except Exception:
118
+ pass
119
+
120
+ return {
121
+ "is_git_repo": True,
122
+ "remote_url": remote_url,
123
+ "current_branch": current_branch,
124
+ "is_dirty": is_dirty,
125
+ "untracked_count": untracked_count,
126
+ "last_commit": last_commit
127
+ }
128
+ except Exception as e:
129
+ return {"is_git_repo": False, "error": str(e)}
\ No newline at end of file
python/helpers/projects.py
+44
-3
@@ -33,11 +33,21 @@ class BasicProjectData(TypedDict):
33
description: str
34
instructions: str
35
color: str
36
+ git_url: str
37
memory: Literal[
38
"own", "global"
39
] # in the future we can add cutom and point to another existing folder
40
file_structure: FileStructureInjectionSettings
41
42
+class GitStatusData(TypedDict, total=False):
43
+ is_git_repo: bool
44
+ remote_url: str
45
+ current_branch: str
46
+ is_dirty: bool
47
+ untracked_count: int
48
+ last_commit: dict
49
+ error: str
50
+
51
class EditProjectData(BasicProjectData):
52
name: str
53
instruction_files_count: int
@@ -45,6 +55,7 @@ class EditProjectData(BasicProjectData):
55
variables: str
56
secrets: str
57
subagents: dict[str, SubAgentSettings]
58
+ git_status: GitStatusData
59
60
61
@@ -77,6 +88,30 @@ def create_project(name: str, data: BasicProjectData):
88
return name
89
90
91
+def clone_git_project(name: str, git_url: str, data: BasicProjectData):
92
+ """Clone a git repository as a new A0 project."""
93
+ from python.helpers import git
94
+
95
+ abs_path = files.create_dir_safe(
96
+ files.get_abs_path(PROJECTS_PARENT_DIR, name), rename_format="{name}_{number}"
97
+ )
98
+ actual_name = files.basename(abs_path)
99
+
100
+ try:
101
+ git.clone_repo(git_url, abs_path)
102
+ create_project_meta_folders(actual_name)
103
+ data = _normalizeBasicData(data)
104
+ data["git_url"] = git_url
105
+ save_project_header(actual_name, data)
106
+ return actual_name
107
+ except Exception as e:
108
+ try:
109
+ files.delete_dir(abs_path)
110
+ except Exception:
111
+ pass
112
+ raise e
113
+
114
+
115
def load_project_header(name: str):
116
abs_path = files.get_abs_path(
117
PROJECTS_PARENT_DIR, name, PROJECT_META_DIR, PROJECT_HEADER_FILE
@@ -107,6 +142,7 @@ def _normalizeBasicData(data: BasicProjectData):
142
description=data.get("description", ""),
143
instructions=data.get("instructions", ""),
144
color=data.get("color", ""),
145
+ git_url=data.get("git_url", ""),
146
memory=data.get("memory", "own"),
147
file_structure=data.get(
148
"file_structure",
@@ -123,6 +159,7 @@ def _normalizeEditData(data: EditProjectData):
159
instructions=data.get("instructions", ""),
160
variables=data.get("variables", ""),
161
color=data.get("color", ""),
162
+ git_status=data.get("git_status", {"is_git_repo": False}),
163
instruction_files_count=data.get("instruction_files_count", 0),
164
knowledge_files_count=data.get("knowledge_files_count", 0),
165
secrets=data.get("secrets", ""),
@@ -169,14 +206,16 @@ def load_basic_project_data(name: str) -> BasicProjectData:
206
207
208
def load_edit_project_data(name: str) -> EditProjectData:
209
+ from python.helpers import git
210
+
211
data = load_basic_project_data(name)
173
- additional_instructions = get_additional_instructions_files(
174
- name
175
- ) # for additional info
212
+ additional_instructions = get_additional_instructions_files(name)
213
variables = load_project_variables(name)
214
secrets = load_project_secrets_masked(name)
215
subagents = load_project_subagents(name)
216
knowledge_files_count = get_knowledge_files_count(name)
217
+ git_status = git.get_repo_status(get_project_folder(name))
218
+
219
data = EditProjectData(
220
**data,
221
name=name,
@@ -185,6 +224,7 @@ def load_edit_project_data(name: str) -> EditProjectData:
224
variables=variables,
225
secrets=secrets,
226
subagents=subagents,
227
+ git_status=git_status,
228
)
229
data = _normalizeEditData(data)
230
return data
@@ -308,6 +348,7 @@ def build_system_prompt_vars(name: str):
348
"project_description": project_data.get("description", ""),
349
"project_instructions": complete_instructions or "",
350
"project_path": files.normalize_a0_path(get_project_folder(name)),
351
+ "project_git_url": project_data.get("git_url", ""),
352
}
353
354
webui/components/projects/project-create.html
+52
-6
@@ -16,12 +16,33 @@
16
<x-component path="projects/project-edit-basic-data.html">
17
</x-component>
18
19
- <div class="buttons-right">
20
- <button type="button" class="button cancel"
21
- @click="$store.projects.cancelCreate()">Cancel</button>
22
- <button type="button" class="button confirm" @click="$store.projects.confirmCreate()">Create and
23
- continue</button>
24
- </div>
19
+ <div class="projects-form-group">
20
+ <label class="projects-form-label">Git Repository (optional)</label>
21
+ <span class="projects-form-description">Clone from an existing git repository. Leave empty to create an empty project.</span>
22
+ <input class="projects-form-input" type="text"
23
+ x-model="$store.projects.selectedProject.git_url"
24
+ :disabled="$store.projects.selectedProject._cloning"
25
+ placeholder="https://github.com/user/repo.git">
26
+ </div>
27
+
28
+ <div class="buttons-right">
29
+ <button type="button" class="button cancel"
30
+ @click="$store.projects.cancelCreate()"
31
+ :disabled="$store.projects.selectedProject._cloning">Cancel</button>
32
+ <button type="button" class="button confirm"
33
+ @click="$store.projects.confirmCreate()"
34
+ :disabled="$store.projects.selectedProject._cloning">
35
+ <template x-if="$store.projects.selectedProject._cloning">
36
+ <span class="button-loading">
37
+ <span class="spinner"></span>
38
+ <span>Cloning...</span>
39
+ </span>
40
+ </template>
41
+ <template x-if="!$store.projects.selectedProject._cloning">
42
+ <span x-text="$store.projects.selectedProject.git_url ? 'Clone and continue' : 'Create and continue'"></span>
43
+ </template>
44
+ </button>
45
+ </div>
46
</div>
47
48
@@ -41,6 +62,31 @@
62
.project-detail-header {
63
margin-bottom: 1em;
64
}
65
+
66
+ .button-loading {
67
+ display: inline-flex;
68
+ align-items: center;
69
+ gap: 0.5em;
70
+ }
71
+
72
+ .spinner {
73
+ width: 14px;
74
+ height: 14px;
75
+ border: 2px solid rgba(255, 255, 255, 0.3);
76
+ border-top: 2px solid white;
77
+ border-radius: 50%;
78
+ animation: spin 1s linear infinite;
79
+ }
80
+
81
+ @keyframes spin {
82
+ 0% { transform: rotate(0deg); }
83
+ 100% { transform: rotate(360deg); }
84
+ }
85
+
86
+ .button:disabled {
87
+ opacity: 0.6;
88
+ cursor: not-allowed;
89
+ }
90
</style>
91
92
</html>
\ No newline at end of file
webui/components/projects/project-edit-basic-data.html
+191
@@ -59,6 +59,81 @@
59
</div>
60
</div>
61
62
+ <!-- Git Status Section -->
63
+ <template x-if="!$store.projects.selectedProject._meta.creating && $store.projects.selectedProject.git_status?.is_git_repo">
64
+ <div class="projects-form-group git-status-section">
65
+ <label class="projects-form-label">
66
+ <span class="material-symbols-outlined git-icon">commit</span>
67
+ Git Status
68
+ </label>
69
+ <div class="git-status-card">
70
+ <!-- Repository URL -->
71
+ <template x-if="$store.projects.selectedProject.git_status.remote_url">
72
+ <div class="git-status-row">
73
+ <span class="git-status-label">
74
+ <span class="material-symbols-outlined">link</span>
75
+ Repository
76
+ </span>
77
+ <div class="git-status-value-with-action">
78
+ <span class="git-status-value git-url" x-text="$store.projects.selectedProject.git_status.remote_url"></span>
79
+ <a class="git-action-link" :href="$store.projects.selectedProject.git_status.remote_url" target="_blank" rel="noopener noreferrer" title="Open in browser">
80
+ <span class="material-symbols-outlined">open_in_new</span>
81
+ </a>
82
+ </div>
83
+ </div>
84
+ </template>
85
+
86
+ <!-- Branch -->
87
+ <div class="git-status-row">
88
+ <span class="git-status-label">
89
+ <span class="material-symbols-outlined">fork_right</span>
90
+ Branch
91
+ </span>
92
+ <span class="git-status-value git-branch" x-text="$store.projects.selectedProject.git_status.current_branch"></span>
93
+ </div>
94
+
95
+ <!-- Status -->
96
+ <div class="git-status-row">
97
+ <span class="git-status-label">
98
+ <span class="material-symbols-outlined">info</span>
99
+ Status
100
+ </span>
101
+ <span class="git-status-value"
102
+ :class="$store.projects.selectedProject.git_status.is_dirty ? 'git-dirty' : 'git-clean'">
103
+ <template x-if="!$store.projects.selectedProject.git_status.is_dirty">
104
+ <span>✓ Clean</span>
105
+ </template>
106
+ <template x-if="$store.projects.selectedProject.git_status.is_dirty">
107
+ <span>
108
+ ● Has uncommitted changes
109
+ <template x-if="$store.projects.selectedProject.git_status.untracked_count > 0">
110
+ <span x-text="'(' + $store.projects.selectedProject.git_status.untracked_count + ' untracked)'"></span>
111
+ </template>
112
+ </span>
113
+ </template>
114
+ </span>
115
+ </div>
116
+
117
+ <!-- Last Commit -->
118
+ <template x-if="$store.projects.selectedProject.git_status.last_commit">
119
+ <div class="git-status-row">
120
+ <span class="git-status-label">
121
+ <span class="material-symbols-outlined">history</span>
122
+ Last Commit
123
+ </span>
124
+ <div class="git-commit-info">
125
+ <span class="git-commit-hash" x-text="$store.projects.selectedProject.git_status.last_commit.hash"></span>
126
+ <span class="git-commit-message" x-text="$store.projects.selectedProject.git_status.last_commit.message"></span>
127
+ <span class="git-commit-meta">
128
+ by <span x-text="$store.projects.selectedProject.git_status.last_commit.author"></span>
129
+ on <span x-text="$store.projects.selectedProject.git_status.last_commit.date"></span>
130
+ </span>
131
+ </div>
132
+ </div>
133
+ </template>
134
+ </div>
135
+ </div>
136
+ </template>
137
</div>
138
139
@@ -119,6 +194,10 @@
194
gap: 0.5em;
195
align-items: center;
196
flex-wrap: wrap;
197
+ background: var(--color-input);
198
+ border: 1px solid var(--color-border);
199
+ border-radius: 0.5em;
200
+ padding: 0.75em 1em;
201
}
202
203
.projects-color-ball {
@@ -176,6 +255,118 @@
255
}
256
}
257
258
+ /* Git Status Styles */
259
+ .git-status-section .projects-form-label {
260
+ display: flex;
261
+ align-items: center;
262
+ gap: 0.5em;
263
+ }
264
+
265
+ .git-status-section .git-icon {
266
+ font-size: 1.2em;
267
+ color: var(--color-primary);
268
+ }
269
+
270
+ .git-status-card {
271
+ background: var(--color-input);
272
+ border: 1px solid var(--color-border);
273
+ border-radius: 0.5em;
274
+ padding: 0.75em 1em;
275
+ }
276
+
277
+ .git-status-row {
278
+ display: flex;
279
+ align-items: flex-start;
280
+ gap: 1em;
281
+ padding: 0.5em 0;
282
+ border-bottom: 1px solid var(--color-border);
283
+ }
284
+
285
+ .git-status-row:last-child {
286
+ border-bottom: none;
287
+ }
288
+
289
+ .git-status-label {
290
+ display: flex;
291
+ align-items: center;
292
+ gap: 0.4em;
293
+ min-width: 100px;
294
+ font-size: 0.85em;
295
+ color: var(--color-text-secondary, #888);
296
+ }
297
+
298
+ .git-status-label .material-symbols-outlined {
299
+ font-size: 1.1em;
300
+ }
301
+
302
+ .git-status-value {
303
+ flex: 1;
304
+ font-size: 0.9em;
305
+ word-break: break-all;
306
+ }
307
+
308
+ .git-status-value-with-action {
309
+ flex: 1;
310
+ display: flex;
311
+ align-items: center;
312
+ gap: 0.5em;
313
+ }
314
+
315
+ .git-action-link {
316
+ color: var(--color-primary);
317
+ opacity: 0.7;
318
+ transition: opacity 0.15s;
319
+ }
320
+
321
+ .git-action-link:hover {
322
+ opacity: 1;
323
+ }
324
+
325
+ .git-action-link .material-symbols-outlined {
326
+ font-size: 1.1em;
327
+ }
328
+
329
+ .git-url {
330
+ font-family: monospace;
331
+ font-size: 0.85em;
332
+ opacity: 0.9;
333
+ }
334
+
335
+ .git-branch {
336
+ font-family: monospace;
337
+ color: var(--color-primary);
338
+ font-weight: 500;
339
+ }
340
+
341
+ .git-clean {
342
+ color: #22c55e;
343
+ }
344
+
345
+ .git-dirty {
346
+ color: #f59e0b;
347
+ }
348
+
349
+ .git-commit-info {
350
+ display: flex;
351
+ flex-direction: column;
352
+ gap: 0.2em;
353
+ }
354
+
355
+ .git-commit-hash {
356
+ font-family: monospace;
357
+ color: var(--color-primary);
358
+ font-size: 0.9em;
359
+ }
360
+
361
+ .git-commit-message {
362
+ font-size: 0.9em;
363
+ }
364
+
365
+ .git-commit-meta {
366
+ font-size: 0.8em;
367
+ opacity: 0.7;
368
+ }
369
+
370
</style>
371
372
</html>
\ No newline at end of file
webui/components/projects/projects-store.js
+78
@@ -5,6 +5,7 @@ import * as notifications from "/components/notifications/notification-store.js"
5
import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
6
import { store as browserStore } from "/components/modals/file-browser/file-browser-store.js";
7
import * as shortcuts from "/js/shortcuts.js";
8
+import { showConfirmDialog } from "/js/confirmDialog.js";
9
10
const listModal = "projects/project-list.html";
11
const createModal = "projects/project-create.html";
@@ -90,6 +91,11 @@ const model = {
91
},
92
93
async confirmCreate() {
94
+ // If git_url is provided, use clone flow
95
+ if (this.selectedProject.git_url && this.selectedProject.git_url.trim()) {
96
+ await this.cloneProject();
97
+ return;
98
+ }
99
// create folder name based on title
100
this.selectedProject.name = this._toFolderName(this.selectedProject.title);
101
const project = await this.saveSelectedProject(true);
@@ -98,6 +104,76 @@ const model = {
104
await this.openEditModal(project.name);
105
},
106
107
+ async cloneProject() {
108
+ // Security warning with custom dialog
109
+ const confirmed = await showConfirmDialog({
110
+ title: "Security Warning",
111
+ message: `
112
+ <p><strong>Cloning repositories from untrusted sources may pose security risks:</strong></p>
113
+ <ul style="margin: 0.75em 0; padding-left: 1.5em;">
114
+ <li>Malicious code execution</li>
115
+ <li>Exposure of sensitive data</li>
116
+ <li>System compromise</li>
117
+ </ul>
118
+ <p style="margin-top: 0.75em;">Only clone from sources you trust.</p>
119
+ `,
120
+ type: "warning",
121
+ confirmText: "Clone Anyway",
122
+ cancelText: "Cancel"
123
+ });
124
+ if (!confirmed) return;
125
+
126
+ // Save reference before async operations
127
+ const project = this.selectedProject;
128
+ if (!project) return;
129
+
130
+ // Disable button state handled by UI
131
+ project._cloning = true;
132
+ project.name = this._toFolderName(project.title);
133
+
134
+ try {
135
+ const response = await api.callJsonApi("projects", {
136
+ action: "clone",
137
+ project: {
138
+ name: project.name,
139
+ title: project.title,
140
+ color: project.color,
141
+ git_url: project.git_url,
142
+ },
143
+ });
144
+
145
+ if (response?.ok) {
146
+ await this.loadProjectsList();
147
+ await modals.closeModal(createModal);
148
+ await this.openEditModal(response.data.name);
149
+ } else {
150
+ notifications.toastFrontendError(
151
+ response?.error || "Clone failed",
152
+ "Git Clone",
153
+ 5,
154
+ "git_clone",
155
+ notifications.NotificationPriority.NORMAL,
156
+ true
157
+ );
158
+ }
159
+ } catch (error) {
160
+ console.error("Error cloning project:", error);
161
+ notifications.toastFrontendError(
162
+ "Error cloning project: " + error,
163
+ "Git Clone",
164
+ 5,
165
+ "git_clone",
166
+ notifications.NotificationPriority.NORMAL,
167
+ true
168
+ );
169
+ } finally {
170
+ // Use the saved reference instead of this.selectedProject
171
+ if (project) {
172
+ project._cloning = false;
173
+ }
174
+ }
175
+ },
176
+
177
async confirmEdit() {
178
const project = await this.saveSelectedProject(false);
179
await this.loadProjectsList();
@@ -304,10 +380,12 @@ const model = {
380
creating: true,
381
},
382
_ownMemory: true,
383
+ _cloning: false,
384
name: ``,
385
title: `Project #${this.projectList.length + 1}`,
386
description: "",
387
color: "",
388
+ git_url: "",
389
};
390
},
391
webui/css/modals.css
+77
@@ -525,3 +525,80 @@ input[type="range"]::-moz-range-thumb {
525
background-position: -200% 0;
526
}
527
}
528
+
529
+/* Confirm Dialog */
530
+.confirm-dialog-backdrop {
531
+ position: fixed;
532
+ top: 0;
533
+ left: 0;
534
+ right: 0;
535
+ bottom: 0;
536
+ background: rgba(0, 0, 0, 0.5);
537
+ display: flex;
538
+ align-items: center;
539
+ justify-content: center;
540
+ z-index: 10000;
541
+ opacity: 0;
542
+ transition: opacity 0.2s ease;
543
+}
544
+
545
+.confirm-dialog-backdrop.visible {
546
+ opacity: 1;
547
+}
548
+
549
+.confirm-dialog {
550
+ background: var(--color-panel);
551
+ border: 1px solid var(--color-border);
552
+ border-radius: 8px;
553
+ max-width: 450px;
554
+ width: 90%;
555
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
556
+ transform: scale(0.95);
557
+ transition: transform 0.2s ease;
558
+}
559
+
560
+.confirm-dialog-backdrop.visible .confirm-dialog {
561
+ transform: scale(1);
562
+}
563
+
564
+.confirm-dialog-header {
565
+ display: flex;
566
+ align-items: center;
567
+ gap: 0.75em;
568
+ padding: 1em 1.25em;
569
+ border-bottom: 1px solid var(--color-border);
570
+}
571
+
572
+.confirm-dialog-icon {
573
+ font-size: 1.5em;
574
+}
575
+
576
+.confirm-dialog-title {
577
+ font-size: 1.1em;
578
+ font-weight: 600;
579
+ color: var(--color-text);
580
+}
581
+
582
+.confirm-dialog-body {
583
+ padding: 1.25em;
584
+ color: var(--color-text);
585
+ line-height: 1.6;
586
+ font-size: 0.95em;
587
+}
588
+
589
+.confirm-dialog-body ul {
590
+ margin: 0.75em 0;
591
+ padding-left: 1.5em;
592
+}
593
+
594
+.confirm-dialog-body p {
595
+ margin: 0;
596
+}
597
+
598
+.confirm-dialog-footer {
599
+ display: flex;
600
+ justify-content: flex-end;
601
+ gap: 0.75em;
602
+ padding: 1em 1.25em;
603
+ border-top: 1px solid var(--color-border);
604
+}
webui/js/confirmDialog.js
new
+71
@@ -0,0 +1,71 @@
1
+// Custom confirmation dialog. CSS in /css/modals.css
2
+
3
+const DIALOG_TYPES = {
4
+ warning: { icon: 'warning', color: 'var(--color-warning, #f59e0b)' },
5
+ danger: { icon: 'error', color: 'var(--color-error, #ef4444)' },
6
+ info: { icon: 'info', color: 'var(--color-primary, #3b82f6)' }
7
+};
8
+
9
+export function showConfirmDialog(options) {
10
+ const {
11
+ title = 'Confirm',
12
+ message = '',
13
+ confirmText = 'Confirm',
14
+ cancelText = 'Cancel',
15
+ type = 'warning'
16
+ } = options;
17
+
18
+ const typeConfig = DIALOG_TYPES[type] || DIALOG_TYPES.warning;
19
+
20
+ return new Promise((resolve) => {
21
+ // Create backdrop
22
+ const backdrop = document.createElement('div');
23
+ backdrop.className = 'confirm-dialog-backdrop';
24
+
25
+ // Create dialog
26
+ const dialog = document.createElement('div');
27
+ dialog.className = 'confirm-dialog';
28
+ dialog.innerHTML = `
29
+ <div class="confirm-dialog-header">
30
+ <span class="confirm-dialog-icon material-symbols-outlined" style="color: ${typeConfig.color}">${typeConfig.icon}</span>
31
+ <span class="confirm-dialog-title">${title}</span>
32
+ </div>
33
+ <div class="confirm-dialog-body">${message}</div>
34
+ <div class="confirm-dialog-footer">
35
+ <button class="button cancel confirm-dialog-cancel">${cancelText}</button>
36
+ <button class="button confirm confirm-dialog-confirm">${confirmText}</button>
37
+ </div>
38
+ `;
39
+
40
+ backdrop.appendChild(dialog);
41
+ document.body.appendChild(backdrop);
42
+
43
+ // Show with animation
44
+ requestAnimationFrame(() => {
45
+ backdrop.classList.add('visible');
46
+ dialog.querySelector('.confirm-dialog-cancel').focus();
47
+ });
48
+
49
+ // Close handler
50
+ const close = (result) => {
51
+ backdrop.classList.remove('visible');
52
+ document.removeEventListener('keydown', handleKeydown);
53
+ setTimeout(() => {
54
+ backdrop.remove();
55
+ resolve(result);
56
+ }, 200);
57
+ };
58
+
59
+ // Event listeners
60
+ dialog.querySelector('.confirm-dialog-cancel').addEventListener('click', () => close(false));
61
+ dialog.querySelector('.confirm-dialog-confirm').addEventListener('click', () => close(true));
62
+ backdrop.addEventListener('click', (e) => e.target === backdrop && close(false));
63
+
64
+ // Keyboard handling
65
+ const handleKeydown = (e) => {
66
+ if (e.key === 'Escape') close(false);
67
+ else if (e.key === 'Enter') close(true);
68
+ };
69
+ document.addEventListener('keydown', handleKeydown);
70
+ });
71
+}