refactor(git): Restructure git helpers with dataclasses and add remote update checking
Replace get_git_info implementation with structured dataclass-based approach. Add GitHeadInfo, GitReleaseInfo, GitRemoteReleaseInfo, GitRemoteReleasesResult, GitRemoteCommitsInfo, GitRepoReleaseInfo, and PluginUpdateInfo dataclasses. Implement get_remote_releases to query GitHub tags, get_remote_commits_since_local to check for upstream commits, get_repo_release_info to extract repository metadata, and update
frdel committed
Mar 13, 2026 at 15:18 UTC
482fa42d980629df54f14b06ba1f43290c56104b
7 files changed
+854
-69
helpers/git.py
+340
-36
@@ -1,6 +1,7 @@
1
-from git import Repo
1
+from git import Git, Repo
2
from giturlparse import parse
3
from datetime import datetime
4
+from dataclasses import dataclass
5
import os
6
import subprocess
7
import base64
@@ -34,51 +35,332 @@ def extract_author_repo(url: str) -> tuple[str, str]:
35
return author, repo
36
37
37
-def get_git_info():
38
- # Get the current working directory (assuming the repo is in the same folder as the script)
39
- repo_path = files.get_base_dir()
40
-
41
- # Open the Git repository
42
- repo = Repo(repo_path)
38
+@dataclass
39
+class GitHeadInfo:
40
+ hash: str
41
+ short_hash: str
42
+ message: str
43
+ author: str
44
+ committed_at: str
45
+ authored_at: str
46
44
- # Ensure the repository is not bare
45
- if repo.bare:
46
- raise ValueError(f"Repository at {repo_path} is bare and cannot be used.")
47
48
- # Get the current branch name
49
- branch = repo.active_branch.name if repo.head.is_detached is False else ""
48
+@dataclass
49
+class GitReleaseInfo:
50
+ tag: str
51
+ short_tag: str
52
+ version: str
53
+ released_at: str
54
+
55
+
56
+@dataclass
57
+class GitRemoteReleaseInfo:
58
+ tag: str
59
+ commit_hash: str
60
+ short_commit_hash: str
61
+ released_at: str
62
+
63
+
64
+@dataclass
65
+class GitRemoteReleasesResult:
66
+ is_git_repo: bool
67
+ is_remote: bool
68
+ author: str
69
+ repo: str
70
+ releases: list[GitRemoteReleaseInfo]
71
+ error: str = ""
72
+
73
+
74
+@dataclass
75
+class GitRemoteCommitsInfo:
76
+ is_git_repo: bool
77
+ is_remote: bool
78
+ path: str
79
+ branch: str
80
+ remote_branch: str
81
+ commits_since_local: int
82
+ last_remote_commit_at: str
83
+ error: str = ""
84
+
85
+
86
+@dataclass
87
+class GitRepoReleaseInfo:
88
+ is_git_repo: bool
89
+ is_remote: bool
90
+ path: str
91
+ author: str
92
+ repo: str
93
+ branch: str
94
+ head: GitHeadInfo | None
95
+ release: GitReleaseInfo | None
96
+ error: str = ""
97
+
98
+
99
+def _format_git_timestamp(timestamp: int) -> str:
100
+ return datetime.fromtimestamp(timestamp).strftime('%Y-%m-%d %H:%M:%S')
101
+
102
+
103
+def get_remote_releases(author: str, repo: str) -> GitRemoteReleasesResult:
104
+ try:
105
+ author = author.strip()
106
+ repo = repo.strip()
107
+
108
+ if not author or not repo:
109
+ return GitRemoteReleasesResult(
110
+ is_remote=False,
111
+ is_git_repo=False,
112
+ author=author,
113
+ repo=repo,
114
+ releases=[],
115
+ error="Both author and repo are required.",
116
+ )
117
+
118
+ remote_url = f"https://github.com/{author}/{repo}.git"
119
51
- # Get the latest commit hash
52
- commit_hash = repo.head.commit.hexsha
120
+ env = os.environ.copy()
121
+ env['GIT_TERMINAL_PROMPT'] = '0'
122
54
- # Get the commit date (ISO 8601 format)
55
- commit_time = datetime.fromtimestamp(repo.head.commit.committed_date).strftime('%y-%m-%d %H:%M')
123
+ try:
124
+ output = Git().ls_remote('--tags', '--refs', '--', remote_url, with_extended_output=False, env=env)
125
+ except Exception as e:
126
+ return GitRemoteReleasesResult(
127
+ is_remote=True,
128
+ is_git_repo=False,
129
+ author=author,
130
+ repo=repo,
131
+ releases=[],
132
+ error=f"Git remote query failed: {str(e)}",
133
+ )
134
+
135
+ releases: list[GitRemoteReleaseInfo] = []
136
+
137
+ for line in output.splitlines():
138
+ line = line.strip()
139
+ if not line:
140
+ continue
141
+
142
+ parts = line.split()
143
+ if len(parts) != 2:
144
+ continue
145
+
146
+ commit_hash, ref_name = parts
147
+ prefix = 'refs/tags/'
148
+ if not ref_name.startswith(prefix):
149
+ continue
150
+
151
+ tag_name = ref_name[len(prefix):]
152
+ releases.append(GitRemoteReleaseInfo(
153
+ tag=tag_name,
154
+ commit_hash=commit_hash,
155
+ short_commit_hash=commit_hash[:7],
156
+ released_at="",
157
+ ))
158
+
159
+ releases.sort(key=lambda release: release.tag, reverse=True)
160
+
161
+ return GitRemoteReleasesResult(
162
+ is_git_repo=True,
163
+ is_remote=True,
164
+ author=author,
165
+ repo=repo,
166
+ releases=releases,
167
+ )
168
+ except Exception as e:
169
+ return GitRemoteReleasesResult(
170
+ is_git_repo=False,
171
+ is_remote=False,
172
+ author=author,
173
+ repo=repo,
174
+ releases=[],
175
+ error=str(e),
176
+ )
177
57
- # Get the latest tag description (if available)
58
- short_tag = ""
178
+
179
+def get_remote_commits_since_local(repo_path: str) -> GitRemoteCommitsInfo:
180
try:
60
- tag = repo.git.describe(tags=True)
61
- tag_split = tag.split('-')
62
- if len(tag_split) >= 3:
63
- short_tag = "-".join(tag_split[:-1])
64
- else:
65
- short_tag = tag
66
- except:
181
+ repo = Repo(repo_path)
182
+ if repo.bare:
183
+ return GitRemoteCommitsInfo(
184
+ is_git_repo=False,
185
+ is_remote=False,
186
+ path=repo_path,
187
+ branch="",
188
+ remote_branch="",
189
+ commits_since_local=0,
190
+ last_remote_commit_at="",
191
+ error=f"Repository at {repo_path} is bare and cannot be used.",
192
+ )
193
+
194
+ if repo.head.is_detached:
195
+ return GitRemoteCommitsInfo(
196
+ is_git_repo=True,
197
+ is_remote=False,
198
+ path=repo_path,
199
+ branch="",
200
+ remote_branch="",
201
+ commits_since_local=0,
202
+ last_remote_commit_at="",
203
+ error="Repository HEAD is detached.",
204
+ )
205
+
206
+ branch = repo.active_branch.name
207
+
208
+ tracking_branch = repo.active_branch.tracking_branch()
209
+ if tracking_branch is None:
210
+ return GitRemoteCommitsInfo(
211
+ is_git_repo=True,
212
+ is_remote=False,
213
+ path=repo_path,
214
+ branch=branch,
215
+ remote_branch="",
216
+ commits_since_local=0,
217
+ last_remote_commit_at="",
218
+ error="Current branch has no tracking remote branch.",
219
+ )
220
+
221
+ remote_name = tracking_branch.remote_name
222
+ remote = repo.remotes[remote_name]
223
+ env = os.environ.copy()
224
+ env['GIT_TERMINAL_PROMPT'] = '0'
225
+ with repo.git.custom_environment(**env):
226
+ remote.fetch(repo.active_branch.name)
227
+
228
+ remote_commit = tracking_branch.commit
229
+ commits = list(repo.iter_commits(f"{repo.head.commit.hexsha}..{tracking_branch.path}"))
230
+
231
+ return GitRemoteCommitsInfo(
232
+ is_git_repo=True,
233
+ is_remote=True,
234
+ path=repo_path,
235
+ branch=branch,
236
+ remote_branch=tracking_branch.path,
237
+ commits_since_local=len(commits),
238
+ last_remote_commit_at=_format_git_timestamp(remote_commit.committed_date) if commits else "",
239
+ )
240
+ except Exception as e:
241
+ return GitRemoteCommitsInfo(
242
+ is_git_repo=False,
243
+ is_remote=False,
244
+ path=repo_path,
245
+ branch="",
246
+ remote_branch="",
247
+ commits_since_local=0,
248
+ last_remote_commit_at="",
249
+ error=str(e),
250
+ )
251
+
252
+
253
+def get_repo_release_info(repo_path: str) -> GitRepoReleaseInfo:
254
+ try:
255
+ repo = Repo(repo_path)
256
+ if repo.bare:
257
+ return GitRepoReleaseInfo(
258
+ is_git_repo=False,
259
+ is_remote=False,
260
+ path=repo_path,
261
+ author="",
262
+ repo="",
263
+ branch="",
264
+ head=None,
265
+ release=None,
266
+ error=f"Repository at {repo_path} is bare and cannot be used.",
267
+ )
268
+
269
+ commit = repo.head.commit
270
+ author = ""
271
+ repo_name = ""
272
+ is_remote = False
273
+
274
+ try:
275
+ if repo.remotes:
276
+ author, repo_name = extract_author_repo(repo.remotes.origin.url)
277
+ is_remote = bool(author and repo_name)
278
+ except Exception:
279
+ author = ""
280
+ repo_name = ""
281
+ is_remote = False
282
+
283
+ branch = ""
284
+ try:
285
+ branch = repo.active_branch.name if repo.head.is_detached is False else ""
286
+ except Exception:
287
+ branch = ""
288
+
289
tag = ""
290
+ short_tag = ""
291
+ release_time = ""
292
+ try:
293
+ tag = repo.git.describe(tags=True)
294
+ tag_split = tag.split('-')
295
+ if len(tag_split) >= 3:
296
+ short_tag = "-".join(tag_split[:-1])
297
+ else:
298
+ short_tag = tag
299
69
- version = branch[0].upper() + " " + ( short_tag or commit_hash[:7] )
300
+ tag_ref = next((t for t in repo.tags if t.name == short_tag), None)
301
+ if tag_ref:
302
+ release_commit = tag_ref.commit
303
+ release_time = _format_git_timestamp(release_commit.committed_date)
304
+ except Exception:
305
+ tag = ""
306
+ short_tag = ""
307
+ release_time = ""
308
71
- # Create the dictionary with collected information
72
- git_info = {
73
- "branch": branch,
74
- "commit_hash": commit_hash,
75
- "commit_time": commit_time,
76
- "tag": tag,
77
- "short_tag": short_tag,
78
- "version": version
79
- }
309
+ version_prefix = branch[0].upper() if branch else "D"
310
+ version = version_prefix + " " + (short_tag or commit.hexsha[:7])
311
81
- return git_info
312
+ return GitRepoReleaseInfo(
313
+ is_git_repo=True,
314
+ is_remote=is_remote,
315
+ path=repo_path,
316
+ author=author,
317
+ repo=repo_name,
318
+ branch=branch,
319
+ head=GitHeadInfo(
320
+ hash=commit.hexsha,
321
+ short_hash=commit.hexsha[:7],
322
+ message=str(commit.message).split("\n")[0][:200],
323
+ author=str(commit.author),
324
+ committed_at=_format_git_timestamp(commit.committed_date),
325
+ authored_at=_format_git_timestamp(commit.authored_date),
326
+ ),
327
+ release=GitReleaseInfo(
328
+ tag=tag,
329
+ short_tag=short_tag,
330
+ version=version,
331
+ released_at=release_time,
332
+ ),
333
+ )
334
+ except Exception as e:
335
+ return GitRepoReleaseInfo(
336
+ is_git_repo=False,
337
+ is_remote=False,
338
+ path=repo_path,
339
+ author="",
340
+ repo="",
341
+ branch="",
342
+ head=None,
343
+ release=None,
344
+ error=str(e),
345
+ )
346
+
347
+
348
+def get_git_info():
349
+ # Get the current working directory (assuming the repo is in the same folder as the script)
350
+ repo_path = files.get_base_dir()
351
+
352
+ state = get_repo_release_info(repo_path)
353
+ if not state.is_git_repo:
354
+ raise ValueError(state.error or f"Repository at {repo_path} is not usable.")
355
+
356
+ return {
357
+ "branch": state.branch,
358
+ "commit_hash": state.head.hash if state.head else "",
359
+ "commit_time": state.head.committed_at if state.head else "",
360
+ "tag": state.release.tag if state.release else "",
361
+ "short_tag": state.release.short_tag if state.release else "",
362
+ "version": state.release.version if state.release else "",
363
+ }
364
365
def get_version():
366
try:
@@ -137,6 +419,28 @@ def clone_repo(url: str, dest: str, token: str | None = None):
419
return Repo(dest)
420
421
422
+def update_repo(repo_path: str) -> Repo:
423
+ repo = Repo(repo_path)
424
+ if repo.bare:
425
+ raise ValueError(f"Repository at {repo_path} is bare and cannot be updated.")
426
+
427
+ if repo.head.is_detached:
428
+ raise ValueError("Repository HEAD is detached.")
429
+
430
+ branch = repo.active_branch.name
431
+ tracking_branch = repo.active_branch.tracking_branch()
432
+ if tracking_branch is None:
433
+ raise ValueError("Current branch has no tracking remote branch.")
434
+
435
+ env = os.environ.copy()
436
+ env['GIT_TERMINAL_PROMPT'] = '0'
437
+
438
+ with repo.git.custom_environment(**env):
439
+ repo.remotes[tracking_branch.remote_name].pull(branch)
440
+
441
+ return repo
442
+
443
+
444
# Files to ignore when checking dirty status (A0 project metadata)
445
A0_IGNORE_PATTERNS = {".a0proj", ".a0proj/"}
446
helpers/plugins.py
+53
-1
@@ -17,6 +17,7 @@ from typing import (
17
18
from helpers import (
19
files,
20
+ git,
21
notification,
22
print_style,
23
yaml as yaml_helper,
@@ -88,6 +89,21 @@ class PluginListItem(BaseModel):
89
has_license: bool = False
90
has_init_script: bool = False
91
toggle_state: ToggleState = "disabled"
92
+ current_commit: str = ""
93
+ current_commit_timestamp: str = ""
94
+
95
+
96
+class PluginUpdateInfo(BaseModel):
97
+ name: str
98
+ path: str
99
+ display_name: str = ""
100
+ commits_since_local: int = 0
101
+ last_remote_commit_at: str = ""
102
+ branch: str = ""
103
+ remote_branch: str = ""
104
+ is_git_repo: bool = False
105
+ is_remote: bool = False
106
+ error: str = ""
107
108
109
@extension.extensible
@@ -125,16 +141,19 @@ def get_plugins_list():
141
142
143
def get_enhanced_plugins_list(
128
- custom: bool = True, builtin: bool = True
144
+ custom: bool = True, builtin: bool = True, plugin_names: list[str] | None = None
145
) -> List[PluginListItem]:
146
"""Discover plugins by directory convention. First root wins on ID conflict."""
147
results = []
148
+ allowed_names = set(plugin_names) if plugin_names else None
149
150
def load_plugins(root_path: str, is_custom: bool):
151
for d in sorted(Path(root_path).iterdir(), key=lambda p: p.name):
152
try:
153
if not d.is_dir() or d.name.startswith("."):
154
continue
155
+ if allowed_names is not None and d.name not in allowed_names:
156
+ continue
157
meta_file = str(d / META_FILE_NAME)
158
if not files.exists(meta_file):
159
continue
@@ -145,6 +164,13 @@ def get_enhanced_plugins_list(
164
has_license = files.exists(str(d / "LICENSE"))
165
has_init_script = files.exists(str(d / "initialize.py"))
166
toggle_state = get_toggle_state(d.name)
167
+ current_commit = ""
168
+ current_commit_timestamp = ""
169
+ if is_custom:
170
+ repo_info = git.get_repo_release_info(str(d))
171
+ if repo_info.is_git_repo and repo_info.head:
172
+ current_commit = repo_info.head.hash
173
+ current_commit_timestamp = repo_info.head.committed_at
174
results.append(
175
PluginListItem(
176
name=d.name,
@@ -163,6 +189,8 @@ def get_enhanced_plugins_list(
189
has_license=has_license,
190
has_init_script=has_init_script,
191
toggle_state=toggle_state,
192
+ current_commit=current_commit,
193
+ current_commit_timestamp=current_commit_timestamp,
194
)
195
)
196
except Exception as e:
@@ -176,6 +204,30 @@ def get_enhanced_plugins_list(
204
return results
205
206
207
+def get_custom_plugins_updates(plugin_names: list[str] | None = None) -> List[PluginUpdateInfo]:
208
+ plugins = get_enhanced_plugins_list(custom=True, builtin=False, plugin_names=plugin_names)
209
+ results: list[PluginUpdateInfo] = []
210
+
211
+ for plugin in plugins:
212
+ update = git.get_remote_commits_since_local(plugin.path)
213
+ results.append(
214
+ PluginUpdateInfo(
215
+ name=plugin.name,
216
+ path=plugin.path,
217
+ display_name=plugin.display_name,
218
+ commits_since_local=update.commits_since_local,
219
+ last_remote_commit_at=update.last_remote_commit_at,
220
+ branch=update.branch,
221
+ remote_branch=update.remote_branch,
222
+ is_git_repo=update.is_git_repo,
223
+ is_remote=update.is_remote,
224
+ error=update.error,
225
+ )
226
+ )
227
+
228
+ return results
229
+
230
+
231
def get_plugin_meta(plugin_name: str):
232
plugin_dir = find_plugin_dir(plugin_name)
233
if not plugin_dir:
plugins/_plugin_installer/api/plugin_install.py
+6
@@ -7,6 +7,7 @@ from plugins._plugin_installer.helpers.install import (
7
get_marketplace_index,
8
install_from_git,
9
install_uploaded_zip,
10
+ update_from_git,
11
)
12
13
class PluginInstall(ApiHandler):
@@ -20,6 +21,8 @@ class PluginInstall(ApiHandler):
21
return self._install_zip(request)
22
elif action == "install_git":
23
return self._install_git(input)
24
+ elif action == "update_plugin":
25
+ return self._update_git(input)
26
elif action == "fetch_index":
27
return self._fetch_index(input)
28
else:
@@ -48,5 +51,8 @@ class PluginInstall(ApiHandler):
51
52
return install_from_git(url=git_url, token=git_token, plugin_name=plugin_name)
53
54
+ def _update_git(self, input: dict) -> dict:
55
+ return update_from_git(input.get("plugin_name", ""))
56
+
57
def _fetch_index(self, input: dict) -> dict:
58
return {"success": True, **get_marketplace_index()}
plugins/_plugin_installer/helpers/install.py
+48
-1
@@ -1,5 +1,6 @@
1
from __future__ import annotations
2
3
+from datetime import datetime, timezone
4
import json
5
import os
6
import time
@@ -10,7 +11,7 @@ import zipfile
11
from pathlib import Path
12
from typing import Any
13
13
-from helpers import files, print_style, plugins
14
+from helpers import files, print_style, plugins, git
15
from helpers import yaml as yaml_helper
16
from helpers.plugins import (
17
META_FILE_NAME,
@@ -192,6 +193,52 @@ def install_from_git(url: str, token: str | None = None, plugin_name: str = "")
193
}
194
195
196
+def update_from_git(plugin_name: str) -> dict:
197
+ plugin_name = (plugin_name or "").strip()
198
+ if not plugin_name:
199
+ raise ValueError("Missing plugin_name")
200
+
201
+ plugin_dir = plugins.find_plugin_dir(plugin_name)
202
+ if not plugin_dir:
203
+ raise ValueError("Plugin not found")
204
+
205
+ custom_plugins_dir = files.get_abs_path(files.USER_DIR, files.PLUGINS_DIR)
206
+ if not files.is_in_dir(plugin_dir, custom_plugins_dir):
207
+ raise ValueError("Only custom plugins can be updated")
208
+
209
+ try:
210
+ repo = git.update_repo(plugin_dir)
211
+ meta = plugins.get_plugin_meta(plugin_name)
212
+ except Exception as e:
213
+ print_style.PrintStyle.error(f"Failed to update plugin: {e}")
214
+ raise
215
+
216
+ try:
217
+ run_install_hook(plugin_name)
218
+ except Exception as e:
219
+ print_style.PrintStyle.error(
220
+ f"Failed to run installation hook for {plugin_name}: {e}"
221
+ )
222
+ raise
223
+
224
+ after_plugin_change([plugin_name])
225
+ head = repo.head.commit
226
+
227
+ return {
228
+ "ok": True,
229
+ "success": True,
230
+ "plugin_name": plugin_name,
231
+ "title": meta.title if meta else plugin_name,
232
+ "path": files.deabsolute_path(plugin_dir),
233
+ "current_commit": head.hexsha,
234
+ "current_commit_timestamp": datetime.fromtimestamp(head.committed_date, timezone.utc).strftime("%Y-%m-%d %H:%M:%S"),
235
+ "version": getattr(meta, "version", "") or "",
236
+ "branch": repo.active_branch.name if not repo.head.is_detached else "",
237
+ "remote_url": git.strip_auth_from_url(repo.remotes.origin.url) if repo.remotes else "",
238
+ "directory_name": Path(plugin_dir).name,
239
+ }
240
+
241
+
242
def run_install_hook(plugin_name: str):
243
return plugins.call_plugin_hook(plugin_name, "install")
244
plugins/_plugin_installer/webui/install-detail.html
+147
-17
@@ -25,12 +25,14 @@
25
<div class="pi-hero-title-row">
26
<div class="pi-hero-main">
27
<h2 class="pi-hero-title" x-text="$store.pluginInstallStore.selectedPlugin.title || $store.pluginInstallStore.selectedPlugin.key"></h2>
28
- <template x-if="$store.pluginInstallStore.selectedPlugin.installed">
29
- <div class="pi-installed-badge-simple">
30
- <span class="material-symbols-outlined">check_circle</span>
31
- <span>Installed</span>
32
- </div>
33
- </template>
28
+ <div class="pi-status-badges">
29
+ <template x-if="$store.pluginInstallStore.selectedPlugin.installed">
30
+ <span class="pi-card-installed-pill">Installed</span>
31
+ </template>
32
+ <template x-if="$store.pluginInstallStore.selectedPlugin.has_update">
33
+ <span class="pi-card-update-pill">New version</span>
34
+ </template>
35
+ </div>
36
<div class="pi-hero-meta">
37
<template x-if="$store.pluginInstallStore.selectedPlugin.author">
38
<span class="pi-hero-author">
@@ -133,6 +135,24 @@
135
</template>
136
</button>
137
</template>
138
+ <template x-if="$store.pluginInstallStore.selectedPlugin.installed && $store.pluginInstallStore.installedPluginInfo?.is_custom && $store.pluginInstallStore.selectedPlugin.has_update">
139
+ <button type="button" class="pi-btn-install"
140
+ @click="$store.pluginInstallStore.handleUpdatePlugin()"
141
+ :disabled="$store.pluginInstallStore.loading">
142
+ <template x-if="$store.pluginInstallStore.loading">
143
+ <span class="pi-btn-loading">
144
+ <span class="spinner"></span>
145
+ <span x-text="$store.pluginInstallStore.loadingMessage || 'Installing...'"></span>
146
+ </span>
147
+ </template>
148
+ <template x-if="!$store.pluginInstallStore.loading">
149
+ <span class="material-symbols-outlined">system_update</span>
150
+ </template>
151
+ <template x-if="!$store.pluginInstallStore.loading">
152
+ <span>Update</span>
153
+ </template>
154
+ </button>
155
+ </template>
156
<template x-if="$store.pluginInstallStore.selectedPlugin.installed && $store.pluginInstallStore.installedPluginInfo?.is_custom">
157
<button type="button" class="pi-btn-uninstall"
158
@click="$confirmClick($event, () => $store.pluginInstallStore.handleDeletePlugin())"
@@ -152,7 +172,7 @@
172
<template x-if="$store.pluginInstallStore.selectedPlugin.discussion">
173
<a :href="$store.pluginInstallStore.selectedPlugin.discussion" target="_blank" class="pi-btn-discussion">
174
<span class="material-symbols-outlined">forum</span>
155
- <span>Join Discussion</span>
175
+ <span>Discussion</span>
176
</a>
177
</template>
178
</div>
@@ -166,6 +186,51 @@
186
class="pi-readme-content" x-html="$store.pluginInstallStore.readmeContent"></div>
187
</div>
188
189
+ <div class="pi-version-section"
190
+ x-show="$store.pluginInstallStore.getCurrentInstalledVersion() || $store.pluginInstallStore.getCurrentInstalledCommit() || $store.pluginInstallStore.getLatestMarketplaceVersion() || $store.pluginInstallStore.getLatestMarketplaceCommit()">
191
+ <div class="pi-version-header">Version</div>
192
+ <div class="pi-version-grid">
193
+ <div class="pi-version-card"
194
+ x-show="$store.pluginInstallStore.selectedPlugin.installed && ($store.pluginInstallStore.getCurrentInstalledVersion() || $store.pluginInstallStore.getCurrentInstalledCommit())">
195
+ <div class="pi-version-label">Current</div>
196
+ <div class="pi-version-name"
197
+ x-text="$store.pluginInstallStore.getCurrentInstalledVersion() || 'Unknown version'"></div>
198
+ <template x-if="$store.pluginInstallStore.getCurrentInstalledCommit() && $store.pluginInstallStore.getRepoCommitUrl($store.pluginInstallStore.selectedPlugin, $store.pluginInstallStore.getCurrentInstalledCommit())">
199
+ <a class="pi-version-commit"
200
+ :href="$store.pluginInstallStore.getRepoCommitUrl($store.pluginInstallStore.selectedPlugin, $store.pluginInstallStore.getCurrentInstalledCommit())"
201
+ target="_blank"
202
+ rel="noopener noreferrer"
203
+ x-text="$store.pluginInstallStore.getCommitShortHash($store.pluginInstallStore.getCurrentInstalledCommit())"></a>
204
+ </template>
205
+ <template x-if="!($store.pluginInstallStore.getCurrentInstalledCommit() && $store.pluginInstallStore.getRepoCommitUrl($store.pluginInstallStore.selectedPlugin, $store.pluginInstallStore.getCurrentInstalledCommit()))">
206
+ <div class="pi-version-commit"
207
+ x-text="$store.pluginInstallStore.getCommitShortHash($store.pluginInstallStore.getCurrentInstalledCommit()) || 'Unknown'"></div>
208
+ </template>
209
+ <div class="pi-version-time"
210
+ x-text="$store.pluginInstallStore.formatUserLocaleDateTime($store.pluginInstallStore.getCurrentInstalledCommitTimestamp()) || 'Timestamp unavailable'"></div>
211
+ </div>
212
+ <div class="pi-version-card"
213
+ x-show="$store.pluginInstallStore.getLatestMarketplaceVersion() || $store.pluginInstallStore.getLatestMarketplaceCommit()">
214
+ <div class="pi-version-label">Latest</div>
215
+ <div class="pi-version-name"
216
+ x-text="$store.pluginInstallStore.getLatestMarketplaceVersion() || 'Unknown version'"></div>
217
+ <template x-if="$store.pluginInstallStore.getLatestMarketplaceCommit() && $store.pluginInstallStore.getRepoCommitUrl($store.pluginInstallStore.selectedPlugin, $store.pluginInstallStore.getLatestMarketplaceCommit())">
218
+ <a class="pi-version-commit"
219
+ :href="$store.pluginInstallStore.getRepoCommitUrl($store.pluginInstallStore.selectedPlugin, $store.pluginInstallStore.getLatestMarketplaceCommit())"
220
+ target="_blank"
221
+ rel="noopener noreferrer"
222
+ x-text="$store.pluginInstallStore.getCommitShortHash($store.pluginInstallStore.getLatestMarketplaceCommit())"></a>
223
+ </template>
224
+ <template x-if="!($store.pluginInstallStore.getLatestMarketplaceCommit() && $store.pluginInstallStore.getRepoCommitUrl($store.pluginInstallStore.selectedPlugin, $store.pluginInstallStore.getLatestMarketplaceCommit()))">
225
+ <div class="pi-version-commit"
226
+ x-text="$store.pluginInstallStore.getCommitShortHash($store.pluginInstallStore.getLatestMarketplaceCommit()) || 'Unknown'"></div>
227
+ </template>
228
+ <div class="pi-version-time"
229
+ x-text="$store.pluginInstallStore.formatUserLocaleDateTime($store.pluginInstallStore.getLatestMarketplaceCommitTimestamp()) || 'Timestamp unavailable'"></div>
230
+ </div>
231
+ </div>
232
+ </div>
233
+
234
<div class="pi-developer-section">
235
<div class="pi-developer-header">Plugin Code</div>
236
<div class="pi-developer-links">
@@ -256,6 +321,13 @@
321
row-gap: var(--spacing-xxs);
322
}
323
324
+ .pi-status-badges {
325
+ display: flex;
326
+ flex-wrap: wrap;
327
+ gap: 0.85rem;
328
+ margin: var(--spacing-xs) 0;
329
+ }
330
+
331
.pi-hero-title {
332
margin: 0;
333
font-size: 1.75rem;
@@ -327,18 +399,16 @@
399
gap: 0.4rem;
400
}
401
330
- .pi-installed-badge-simple {
331
- display: inline-flex;
332
- align-items: center;
333
- gap: 0.35rem;
334
- font-size: 0.9rem;
335
- font-weight: 600;
336
- color: #22c55e;
337
- margin: var(--spacing-xs) 0;
402
+ .pi-status-badges .pi-card-installed-pill {
403
+ position: static;
404
+ top: auto;
405
+ right: auto;
406
}
407
340
- .pi-installed-badge-simple .material-symbols-outlined {
341
- font-size: 1.1rem;
408
+ .pi-status-badges .pi-card-update-pill {
409
+ position: static;
410
+ top: auto;
411
+ right: auto;
412
}
413
414
.pi-tag {
@@ -481,6 +551,66 @@
551
border-top: 1px solid var(--color-border);
552
}
553
554
+ .pi-version-section {
555
+ margin-bottom: 1.5rem;
556
+ padding-top: 1.5rem;
557
+ border-top: 1px solid var(--color-border);
558
+ }
559
+
560
+ .pi-version-header {
561
+ font-size: 0.85rem;
562
+ font-weight: 600;
563
+ color: var(--color-text-muted);
564
+ margin-bottom: 0.75rem;
565
+ }
566
+
567
+ .pi-version-grid {
568
+ display: grid;
569
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
570
+ gap: 0.75rem;
571
+ }
572
+
573
+ .pi-version-card {
574
+ display: flex;
575
+ flex-direction: column;
576
+ gap: 0.3rem;
577
+ padding: 0.9rem 1rem;
578
+ border: 1px solid var(--color-border);
579
+ border-radius: 10px;
580
+ }
581
+
582
+ .pi-version-label {
583
+ font-size: 0.78rem;
584
+ font-weight: 700;
585
+ letter-spacing: 0.04em;
586
+ text-transform: uppercase;
587
+ color: var(--color-text-muted);
588
+ }
589
+
590
+ .pi-version-name {
591
+ font-size: 1rem;
592
+ font-weight: 700;
593
+ color: var(--color-text-primary);
594
+ line-height: 1.35;
595
+ }
596
+
597
+ .pi-version-commit {
598
+ font-size: 0.92rem;
599
+ font-weight: 600;
600
+ color: var(--color-text-secondary);
601
+ text-decoration: none;
602
+ }
603
+
604
+ a.pi-version-commit:hover {
605
+ color: var(--color-highlight);
606
+ }
607
+
608
+ .pi-version-time {
609
+ font-size: 0.85rem;
610
+ color: var(--color-text-secondary);
611
+ line-height: 1.4;
612
+ }
613
+
614
.pi-screenshots-header {
615
font-size: 0.85rem;
616
font-weight: 600;
plugins/_plugin_installer/webui/install-index.html
+70
-2
@@ -46,7 +46,10 @@
46
<template x-for="filter in $store.pluginInstallStore.browseFilters" :key="filter.key">
47
<button type="button"
48
class="pi-filter-chip"
49
- :class="{ active: $store.pluginInstallStore.browseFilter === filter.key }"
49
+ :class="{
50
+ active: $store.pluginInstallStore.browseFilter === filter.key,
51
+ 'pi-filter-chip-update': filter.key === 'update' && filter.count > 0
52
+ }"
53
@click="$store.pluginInstallStore.setBrowseFilter(filter.key)">
54
<span x-text="filter.label"></span>
55
<span class="pi-filter-count" x-text="filter.count"></span>
@@ -77,6 +80,10 @@
80
<template x-if="plugin.installed">
81
<span class="pi-card-installed-pill">Installed</span>
82
</template>
83
+
84
+ <template x-if="plugin.has_update">
85
+ <span class="pi-card-update-pill" :class="{ 'pi-card-update-pill-offset': plugin.installed }">New version</span>
86
+ </template>
87
</div>
88
89
<div class="pi-card-body">
@@ -294,6 +301,41 @@
301
color: var(--color-text-primary);
302
}
303
304
+ .pi-filter-chip-update {
305
+ border-color: rgba(59, 130, 246, 0.22);
306
+ color: #93c5fd;
307
+ }
308
+
309
+ .pi-filter-chip-update:hover,
310
+ .pi-filter-chip-update.active {
311
+ border-color: rgba(59, 130, 246, 0.38);
312
+ background: rgba(59, 130, 246, 0.12);
313
+ color: #bfdbfe;
314
+ }
315
+
316
+ .pi-filter-chip-update .pi-filter-count {
317
+ background: rgba(59, 130, 246, 0.16);
318
+ color: #bfdbfe;
319
+ }
320
+
321
+ body.light-mode .pi-filter-chip-update {
322
+ border-color: rgba(37, 99, 235, 0.35);
323
+ background: rgba(37, 99, 235, 0.08);
324
+ color: #1d4ed8;
325
+ }
326
+
327
+ body.light-mode .pi-filter-chip-update:hover,
328
+ body.light-mode .pi-filter-chip-update.active {
329
+ border-color: rgba(37, 99, 235, 0.5);
330
+ background: rgba(37, 99, 235, 0.14);
331
+ color: #1e40af;
332
+ }
333
+
334
+ body.light-mode .pi-filter-chip-update .pi-filter-count {
335
+ background: rgba(37, 99, 235, 0.18);
336
+ color: #1e3a8a;
337
+ }
338
+
339
.pi-filter-count {
340
padding: 0.12rem 0.4rem;
341
border-radius: 0.5rem;
@@ -369,7 +411,7 @@
411
height: 148px;
412
padding: 0.5rem;
413
background:
372
- radial-gradient(circle at top, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0)),
414
+ radial-gradient(circle at top, #6b728089, var(--color-panel)),
415
var(--color-panel);
416
border-bottom: 1px solid var(--color-border);
417
}
@@ -397,6 +439,32 @@
439
font-weight: 700;
440
}
441
442
+ body.light-mode .pi-card-installed-pill {
443
+ background: rgba(34, 197, 94, 0.22);
444
+ color: #166534;
445
+ }
446
+
447
+ .pi-card-update-pill {
448
+ position: absolute;
449
+ top: 0.75rem;
450
+ right: 0.75rem;
451
+ padding: 0.24rem 0.5rem;
452
+ border-radius: 0.5rem;
453
+ background: rgba(59, 130, 246, 0.16);
454
+ color: #60a5fa;
455
+ font-size: 0.72rem;
456
+ font-weight: 700;
457
+ }
458
+
459
+ body.light-mode .pi-card-update-pill {
460
+ background: rgba(59, 130, 246, 0.2);
461
+ color: #1d4ed8;
462
+ }
463
+
464
+ .pi-card-update-pill-offset {
465
+ top: 2.35rem;
466
+ }
467
+
468
.pi-card-body {
469
flex: 1;
470
display: flex;
plugins/_plugin_installer/webui/pluginInstallStore.js
+190
-12
@@ -39,6 +39,7 @@ const model = {
39
// Index state
40
index: { authors: {}, plugins: {} },
41
installedPlugins: [],
42
+ installedPluginDetails: {},
43
search: "",
44
page: 1,
45
sortBy: "stars",
@@ -97,6 +98,7 @@ const model = {
98
_matchesBrowseFilter(plugin, filterKey) {
99
if (!filterKey || filterKey === "all") return true;
100
if (filterKey === "installed") return !!plugin?.installed;
101
+ if (filterKey === "update") return !!plugin?.has_update;
102
if (filterKey === "popular") return (plugin?.stars || 0) > 0;
103
if (filterKey.startsWith("tag:")) {
104
return this._pluginPrimaryTag(plugin) === filterKey.slice(4);
@@ -104,6 +106,28 @@ const model = {
106
return false;
107
},
108
109
+ _compareTimestamp(a, b) {
110
+ const aTime = a ? Date.parse(a) : NaN;
111
+ const bTime = b ? Date.parse(b) : NaN;
112
+ if (Number.isNaN(aTime) || Number.isNaN(bTime)) return 0;
113
+ if (aTime === bTime) return 0;
114
+ return aTime > bTime ? 1 : -1;
115
+ },
116
+
117
+ _hasMarketplaceUpdate(indexPlugin, installedPlugin) {
118
+ const latestCommit = (indexPlugin?.commit || "").trim();
119
+ const currentCommit = (installedPlugin?.current_commit || "").trim();
120
+ if (!latestCommit || !currentCommit) return false;
121
+ if (latestCommit === currentCommit) return false;
122
+
123
+ const latestTimestamp = indexPlugin?.updated || "";
124
+ const currentTimestamp = installedPlugin?.current_commit_timestamp || "";
125
+ const timestampComparison = this._compareTimestamp(latestTimestamp, currentTimestamp);
126
+ if (timestampComparison !== 0) return timestampComparison > 0;
127
+
128
+ return true;
129
+ },
130
+
131
// ── ZIP Install ──────────────────────────────
132
133
handleFileUpload(event) {
@@ -219,6 +243,13 @@ const model = {
243
244
this.index = data.index;
245
this.installedPlugins = data.installed_plugins || [];
246
+ const installedResponse = await api.callJsonApi("plugins_list", {
247
+ filter: { custom: true, builtin: false, search: "" },
248
+ });
249
+ const installedList = Array.isArray(installedResponse.plugins) ? installedResponse.plugins : [];
250
+ this.installedPluginDetails = Object.fromEntries(
251
+ installedList.map((plugin) => [plugin.name, plugin])
252
+ );
253
this.page = 1;
254
} catch (e) {
255
const message = e instanceof Error ? e.message : String(e);
@@ -231,24 +262,39 @@ const model = {
262
263
get pluginsList() {
264
if (!this.index?.plugins) return [];
234
- return Object.entries(this.index.plugins).map(([key, val]) => ({
235
- key,
236
- ...val,
237
- installed: this.installedPlugins.includes(key),
238
- }));
265
+ return Object.entries(this.index.plugins).map(([key, val]) => {
266
+ const installedPlugin = this.installedPluginDetails[key] || null;
267
+ const installed = this.installedPlugins.some((pluginKey) => pluginKey === key);
268
+ const plugin = {
269
+ key,
270
+ ...val,
271
+ commit: val?.commit || val?.latest_commit || "",
272
+ updated: val?.updated || val?.latest_commit_timestamp || "",
273
+ version: val?.version || "",
274
+ installed,
275
+ };
276
+
277
+ return {
278
+ ...plugin,
279
+ current_commit: installedPlugin?.current_commit || "",
280
+ current_commit_timestamp: installedPlugin?.current_commit_timestamp || "",
281
+ has_update: this._hasMarketplaceUpdate(plugin, installedPlugin),
282
+ };
283
+ });
284
},
285
286
get browseFilters() {
287
const plugins = this.pluginsList;
288
const filters = [{ key: "all", label: "All", count: plugins.length }];
289
245
- if (!plugins.length) return filters;
246
-
290
const installedCount = plugins.filter((plugin) => plugin.installed).length;
291
if (installedCount) {
292
filters.push({ key: "installed", label: "Installed", count: installedCount });
293
}
294
295
+ const updateCount = plugins.filter((plugin) => plugin.has_update).length;
296
+ filters.push({ key: "update", label: "Update", count: updateCount });
297
+
298
const popularCount = plugins.filter((plugin) => (plugin.stars || 0) > 0).length;
299
if (popularCount) {
300
filters.push({ key: "popular", label: "Popular", count: popularCount });
@@ -397,7 +443,7 @@ const model = {
443
444
try {
445
this.loading = true;
400
- this.loadingMessage = `Installing ${plugin.title || plugin.key}...`;
446
+ this.loadingMessage = "Installing";
447
448
const data = await api.callJsonApi(PLUGIN_API, {
449
action: "install_git",
@@ -411,7 +457,7 @@ const model = {
457
}
458
459
const installedKey = plugin.key || data.plugin_name;
414
- if (installedKey && !this.installedPlugins.includes(installedKey)) {
460
+ if (installedKey && !this.installedPlugins.some((pluginKey) => pluginKey === installedKey)) {
461
this.installedPlugins = [...this.installedPlugins, installedKey];
462
}
463
@@ -436,6 +482,25 @@ const model = {
482
}
483
},
484
485
+ async _refreshSelectedPluginState(pluginKey) {
486
+ await this.fetchInstalledPluginInfo(pluginKey);
487
+
488
+ const latestInstalled = this.installedPluginInfo || null;
489
+ const currentSelectedPlugin = this.selectedPlugin ? Object.assign({}, this.selectedPlugin) : null;
490
+ const indexPlugin = this.pluginsList.find((plugin) => plugin.key === pluginKey) || currentSelectedPlugin;
491
+ if (!indexPlugin) return;
492
+
493
+ this.selectedPlugin = {
494
+ ...indexPlugin,
495
+ name: pluginKey || indexPlugin["name"] || "",
496
+ installed: true,
497
+ current_commit: latestInstalled?.["current_commit"] || indexPlugin["current_commit"] || "",
498
+ current_commit_timestamp: latestInstalled?.["current_commit_timestamp"] || indexPlugin["current_commit_timestamp"] || "",
499
+ has_update: this._hasMarketplaceUpdate(indexPlugin, latestInstalled),
500
+ };
501
+ this.detailThumbnailUrl = this.getThumbnailUrl(this.selectedPlugin);
502
+ },
503
+
504
// ── Installed Plugin Info ─────────────────────
505
506
async fetchInstalledPluginInfo(pluginName) {
@@ -490,11 +555,11 @@ const model = {
555
this.loadingMessage = "Uninstalling plugin...";
556
557
await pluginListStore.deletePlugin(this.installedPluginInfo);
493
- const currentPlugin = this.selectedPlugin;
558
+ const currentPlugin = this.selectedPlugin ? Object.assign({}, this.selectedPlugin) : null;
559
if (currentPlugin) {
560
this.selectedPlugin = { ...currentPlugin, installed: false };
561
this.installedPlugins = this.installedPlugins.filter(
497
- (key) => key !== currentPlugin.key
562
+ (key) => key !== currentPlugin["key"]
563
);
564
}
565
this.installedPluginInfo = null;
@@ -509,6 +574,118 @@ const model = {
574
return `https://github.com/agent0ai/a0-plugins/tree/main/plugins/${pluginKey}`;
575
},
576
577
+ getCommitShortHash(commitHash) {
578
+ if (!commitHash || typeof commitHash !== "string") return "";
579
+ return commitHash.slice(0, 7);
580
+ },
581
+
582
+ formatUserLocaleDateTime(value) {
583
+ if (!value || typeof value !== "string") return "";
584
+
585
+ const normalizedValue = /t/i.test(value) ? value : value.replace(" ", "T");
586
+ const date = new Date(normalizedValue);
587
+ if (Number.isNaN(date.getTime())) return value;
588
+
589
+ return new Intl.DateTimeFormat(undefined, {
590
+ year: "numeric",
591
+ month: "2-digit",
592
+ day: "2-digit",
593
+ hour: "2-digit",
594
+ minute: "2-digit",
595
+ second: "2-digit",
596
+ }).format(date);
597
+ },
598
+
599
+ getRepoCommitUrl(plugin, commitHash) {
600
+ const githubUrl = (plugin?.github || "").trim().replace(/\.git$/i, "");
601
+ if (!githubUrl || !commitHash) return "";
602
+ return `${githubUrl}/commit/${commitHash}`;
603
+ },
604
+
605
+ getCurrentInstalledCommit() {
606
+ return this.installedPluginInfo?.["current_commit"] || this.selectedPlugin?.["current_commit"] || "";
607
+ },
608
+
609
+ getCurrentInstalledVersion() {
610
+ return this.installedPluginInfo?.["version"] || "";
611
+ },
612
+
613
+ getCurrentInstalledCommitTimestamp() {
614
+ return this.installedPluginInfo?.["current_commit_timestamp"] || this.selectedPlugin?.["current_commit_timestamp"] || "";
615
+ },
616
+
617
+ getLatestMarketplaceVersion() {
618
+ return this.selectedPlugin?.["version"] || "";
619
+ },
620
+
621
+ getLatestMarketplaceCommit() {
622
+ return this.selectedPlugin?.["commit"] || "";
623
+ },
624
+
625
+ getLatestMarketplaceCommitTimestamp() {
626
+ return this.selectedPlugin?.["updated"] || "";
627
+ },
628
+
629
+ async handleUpdatePlugin() {
630
+ const selectedPlugin = this["selectedPlugin"];
631
+ const pluginRecord = selectedPlugin && typeof selectedPlugin === "object" ? selectedPlugin : {};
632
+ const pluginKey = pluginRecord["key"] || pluginRecord["name"] || this.installedPluginInfo?.name || "";
633
+ if (!pluginKey) {
634
+ void toastFrontendError("Plugin name is missing", "Plugin Installer");
635
+ return;
636
+ }
637
+
638
+ const confirmed = await showConfirmDialog({
639
+ ...SECURITY_WARNING,
640
+ extensionContext: {
641
+ kind: "marketplace_plugin_install_warning",
642
+ source: "plugin_installer",
643
+ pluginKey,
644
+ pluginTitle: pluginRecord["title"] || pluginKey,
645
+ gitUrl: pluginRecord["github"] || "",
646
+ },
647
+ });
648
+ if (!confirmed) return;
649
+
650
+ try {
651
+ this.loading = true;
652
+ this.loadingMessage = "Updating";
653
+
654
+ const data = await api.callJsonApi(PLUGIN_API, {
655
+ action: "update_plugin",
656
+ plugin_name: pluginKey,
657
+ });
658
+
659
+ if (!(data?.ok && data?.success)) {
660
+ void toastFrontendError(data?.error || "Update failed", "Plugin Installer");
661
+ return;
662
+ }
663
+
664
+ await this.fetchIndex();
665
+
666
+ const installedPluginsSource = this["installedPlugins"];
667
+ const installedPlugins = Array.isArray(installedPluginsSource) ? Array.from(installedPluginsSource) : [];
668
+ if (!installedPlugins.some((installedKey) => installedKey === pluginKey)) {
669
+ installedPlugins.push(String(pluginKey));
670
+ Reflect.set(this, "installedPlugins", installedPlugins);
671
+ }
672
+
673
+ await this._refreshSelectedPluginState(pluginKey);
674
+ this.refreshPluginList();
675
+
676
+ toastFrontendSuccess(
677
+ `Plugin "${data.title || data.plugin_name}" updated`,
678
+ "Plugin Installer"
679
+ );
680
+ } catch (e) {
681
+ const message = e instanceof Error ? e.message : String(e);
682
+ void toastFrontendError(`Update error: ${message}`, "Plugin Installer");
683
+ } finally {
684
+ this.loading = false;
685
+ this.loadingMessage = "";
686
+ }
687
+ },
688
+
689
getThumbnailUrl(plugin) {
690
if (!plugin) return null;
691
if (plugin.thumbnail && typeof plugin.thumbnail === "string") return plugin.thumbnail;
@@ -522,8 +699,9 @@ const model = {
699
700
openScreenshot(url) {
701
if (!url) return;
702
+ const selectedPlugin = this.selectedPlugin || null;
703
imageViewerStore.open(url, {
526
- name: this.selectedPlugin?.title || this.selectedPlugin?.key || "Plugin screenshot",
704
+ name: selectedPlugin?.["title"] || selectedPlugin?.["key"] || "Plugin screenshot",
705
});
706
},
707