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