1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-08-01 19:18:28 -04:00

feat(plugins): add update all CLI flag and settings updates dialog (#2682)

* feat(plugins): add update all CLI flag and settings update dialog

* feat(plugins): add comparison diff URL support and update button styling

* feat(plugins): skip system plugins in bulk CLI update

* fix(plugins): remove check shorthand to resolve conflict with config flag

* feat(plugins): inline update dialog, show version tags, restructure buttons
This commit is contained in:
Huỳnh Thiện Lộc
2026-07-04 08:51:39 +07:00
committed by GitHub
parent 6e3e178721
commit 8465ed4311
8 changed files with 554 additions and 26 deletions
+197 -5
View File
@@ -106,6 +106,8 @@ func init() {
ipcCmd.SetHelpFunc(func(cmd *cobra.Command, args []string) {
printIPCHelp()
})
pluginsUpdateCmd.Flags().BoolP("all", "a", false, "Update all installed plugins")
pluginsUpdateCmd.Flags().Bool("check", false, "Check for available updates without applying them")
}
var debugSrvCmd = &cobra.Command{
@@ -184,10 +186,22 @@ var pluginsUninstallCmd = &cobra.Command{
}
var pluginsUpdateCmd = &cobra.Command{
Use: "update <plugin-id>",
Short: "Update a plugin by ID",
Long: "Update an installed DMS plugin using its ID (e.g., 'myPlugin'). Plugin names are also supported.",
Args: cobra.ExactArgs(1),
Use: "update [plugin-id]",
Short: "Update a plugin by ID, or all plugins",
Long: "Update an installed DMS plugin using its ID (e.g., 'myPlugin'). If --all or -a is specified, all installed plugins will be updated.",
Args: func(cmd *cobra.Command, args []string) error {
updateAll, _ := cmd.Flags().GetBool("all")
if updateAll {
if len(args) > 0 {
return fmt.Errorf("cannot specify plugin ID when using --all/-a")
}
return nil
}
if len(args) != 1 {
return fmt.Errorf("requires exactly 1 arg (plugin ID) or use --all/-a")
}
return nil
},
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) != 0 {
return nil, cobra.ShellCompDirectiveNoFileComp
@@ -195,6 +209,26 @@ var pluginsUpdateCmd = &cobra.Command{
return getInstalledPluginIDs(), cobra.ShellCompDirectiveNoFileComp
},
Run: func(cmd *cobra.Command, args []string) {
checkOnly, _ := cmd.Flags().GetBool("check")
updateAll, _ := cmd.Flags().GetBool("all")
if checkOnly {
if updateAll {
if err := checkAllPluginsCLI(); err != nil {
log.Fatalf("Error checking updates: %v", err)
}
return
}
if err := checkPluginCLI(args[0]); err != nil {
log.Fatalf("Error checking update: %v", err)
}
return
}
if updateAll {
if err := updateAllPluginsCLI(); err != nil {
log.Fatalf("Error updating plugins: %v", err)
}
return
}
if err := updatePluginCLI(args[0]); err != nil {
log.Fatalf("Error updating plugin: %v", err)
}
@@ -370,7 +404,11 @@ func listInstalledPlugins() error {
fmt.Printf("\nInstalled Plugins (%d):\n\n", len(installedNames))
for _, id := range installedNames {
if plugin, ok := pluginMap[id]; ok {
fmt.Printf(" %s\n", plugin.Name)
hasUpdateStr := ""
if hasUpdates, _, err := manager.HasUpdates(id, plugin); err == nil && hasUpdates {
hasUpdateStr = " (update available)"
}
fmt.Printf(" %s%s\n", plugin.Name, hasUpdateStr)
fmt.Printf(" ID: %s\n", plugin.ID)
fmt.Printf(" Category: %s\n", plugin.Category)
fmt.Printf(" Author: %s\n", plugin.Author)
@@ -550,6 +588,160 @@ func updatePluginCLI(idOrName string) error {
return nil
}
func updateAllPluginsCLI() error {
manager, err := plugins.NewManager()
if err != nil {
return fmt.Errorf("failed to create manager: %w", err)
}
registry, err := plugins.NewRegistry()
if err != nil {
return fmt.Errorf("failed to create registry: %w", err)
}
installed, err := manager.ListInstalled()
if err != nil {
return fmt.Errorf("failed to list installed plugins: %w", err)
}
pluginList, _ := registry.List()
var errs []error
for _, pluginID := range installed {
plugin := plugins.FindByIDOrName(pluginID, pluginList)
if plugin != nil {
fmt.Printf("Updating plugin: %s (ID: %s)\n", plugin.Name, plugin.ID)
if err := manager.Update(*plugin); err != nil {
if strings.Contains(err.Error(), "cannot update system plugin") {
fmt.Printf("Skipping system plugin: %s\n", plugin.Name)
} else {
errs = append(errs, fmt.Errorf("failed to update %s: %w", plugin.Name, err))
}
} else {
fmt.Printf("Plugin updated successfully: %s\n", plugin.Name)
}
} else {
fmt.Printf("Updating plugin: %s\n", pluginID)
if err := manager.UpdateByIDOrName(pluginID); err != nil {
if strings.Contains(err.Error(), "cannot update system plugin") {
fmt.Printf("Skipping system plugin: %s\n", pluginID)
} else {
errs = append(errs, fmt.Errorf("failed to update %s: %w", pluginID, err))
}
} else {
fmt.Printf("Plugin updated successfully: %s\n", pluginID)
}
}
}
if len(errs) > 0 {
for _, err := range errs {
fmt.Fprintf(os.Stderr, "%v\n", err)
}
return fmt.Errorf("failed to update some plugins")
}
return nil
}
func checkPluginCLI(idOrName string) error {
manager, err := plugins.NewManager()
if err != nil {
return fmt.Errorf("failed to create manager: %w", err)
}
registry, err := plugins.NewRegistry()
if err != nil {
return fmt.Errorf("failed to create registry: %w", err)
}
pluginList, _ := registry.List()
plugin := plugins.FindByIDOrName(idOrName, pluginList)
if plugin != nil {
installed, err := manager.IsInstalled(*plugin)
if err != nil {
return fmt.Errorf("failed to check install status: %w", err)
}
if !installed {
return fmt.Errorf("plugin not installed: %s", plugin.Name)
}
hasUpdates, _, err := manager.HasUpdates(plugin.ID, *plugin)
if err != nil {
return fmt.Errorf("failed to check updates: %w", err)
}
if hasUpdates {
fmt.Printf("Update available for plugin: %s (ID: %s)\n", plugin.Name, plugin.ID)
} else {
fmt.Printf("Plugin is up to date: %s\n", plugin.Name)
}
return nil
}
dummyPlugin := plugins.Plugin{ID: idOrName}
hasUpdates, _, err := manager.HasUpdates(idOrName, dummyPlugin)
if err != nil {
return fmt.Errorf("failed to check updates: %w", err)
}
if hasUpdates {
fmt.Printf("Update available for plugin: %s\n", idOrName)
} else {
fmt.Printf("Plugin is up to date: %s\n", idOrName)
}
return nil
}
func checkAllPluginsCLI() error {
manager, err := plugins.NewManager()
if err != nil {
return fmt.Errorf("failed to create manager: %w", err)
}
registry, err := plugins.NewRegistry()
if err != nil {
return fmt.Errorf("failed to create registry: %w", err)
}
installed, err := manager.ListInstalled()
if err != nil {
return fmt.Errorf("failed to list installed plugins: %w", err)
}
pluginList, _ := registry.List()
var count int
for _, pluginID := range installed {
plugin := plugins.FindByIDOrName(pluginID, pluginList)
var hasUpdates bool
var name string
if plugin != nil {
name = plugin.Name
hasUpdates, _, _ = manager.HasUpdates(pluginID, *plugin)
} else {
name = pluginID
dummyPlugin := plugins.Plugin{ID: pluginID}
hasUpdates, _, _ = manager.HasUpdates(pluginID, dummyPlugin)
}
if hasUpdates {
fmt.Printf("Update available for plugin: %s (ID: %s)\n", name, pluginID)
count++
}
}
if count > 0 {
fmt.Printf("\nFound %d plugin(s) with available updates.\n", count)
} else {
fmt.Println("All plugins are up to date.")
}
return nil
}
func getCommonCommands() []*cobra.Command {
return []*cobra.Command{
versionCmd,
+23 -8
View File
@@ -554,35 +554,50 @@ func (m *Manager) findInDirByIDOrName(dir, idOrName string) (string, error) {
return "", nil
}
func (m *Manager) HasUpdates(pluginID string, plugin Plugin) (bool, error) {
func (m *Manager) HasUpdates(pluginID string, plugin Plugin) (hasUpdates bool, diffURL string, err error) {
pluginPath, err := m.findInstalledPath(pluginID)
if err != nil {
return false, fmt.Errorf("failed to find plugin: %w", err)
return false, "", fmt.Errorf("failed to find plugin: %w", err)
}
if pluginPath == "" {
return false, fmt.Errorf("plugin not installed: %s", pluginID)
return false, "", fmt.Errorf("plugin not installed: %s", pluginID)
}
if strings.HasPrefix(pluginPath, "/etc/xdg/quickshell/dms-plugins") {
return false, nil
return false, "", nil
}
metaPath := pluginPath + ".meta"
metaExists, err := afero.Exists(m.fs, metaPath)
if err != nil {
return false, fmt.Errorf("failed to check metadata: %w", err)
return false, "", fmt.Errorf("failed to check metadata: %w", err)
}
var hasUp bool
var localHash, remoteHash string
if metaExists {
// Plugin is from a monorepo, check the repo directory
reposDir := filepath.Join(m.pluginsDir, ".repos")
repoName := m.getRepoName(plugin.Repo)
repoPath := filepath.Join(reposDir, repoName)
return m.gitClient.HasUpdates(repoPath)
hasUp, localHash, remoteHash, err = m.gitClient.HasUpdates(repoPath)
} else {
// Plugin is a standalone repo
hasUp, localHash, remoteHash, err = m.gitClient.HasUpdates(pluginPath)
}
// Plugin is a standalone repo
return m.gitClient.HasUpdates(pluginPath)
if err != nil {
return false, "", err
}
diffURL = plugin.Repo
if diffURL != "" {
diffURL = strings.TrimSuffix(diffURL, ".git")
if hasUp && len(localHash) >= 7 && len(remoteHash) >= 7 {
diffURL = fmt.Sprintf("%s/compare/%s...%s", diffURL, localHash[:7], remoteHash[:7])
}
}
return hasUp, diffURL, nil
}
+10 -9
View File
@@ -33,7 +33,7 @@ type Plugin struct {
type GitClient interface {
PlainClone(path string, url string) error
Pull(path string) error
HasUpdates(path string) (bool, error)
HasUpdates(path string) (hasUpdates bool, localHash string, remoteHash string, err error)
}
type realGitClient struct{}
@@ -65,10 +65,10 @@ func (g *realGitClient) Pull(path string) error {
return nil
}
func (g *realGitClient) HasUpdates(path string) (bool, error) {
func (g *realGitClient) HasUpdates(path string) (bool, string, string, error) {
repo, err := git.PlainOpen(path)
if err != nil {
return false, err
return false, "", "", err
}
// Fetch remote changes
@@ -76,24 +76,24 @@ func (g *realGitClient) HasUpdates(path string) (bool, error) {
if err != nil && err.Error() != "already up-to-date" {
// If fetch fails, we can't determine if there are updates
// Return false and the error
return false, err
return false, "", "", err
}
// Get the HEAD reference
head, err := repo.Head()
if err != nil {
return false, err
return false, "", "", err
}
// Get the remote HEAD reference (typically origin/HEAD or origin/main or origin/master)
remote, err := repo.Remote("origin")
if err != nil {
return false, err
return false, "", "", err
}
refs, err := remote.List(&git.ListOptions{})
if err != nil {
return false, err
return false, "", "", err
}
// Find the default branch remote ref
@@ -108,13 +108,14 @@ func (g *realGitClient) HasUpdates(path string) (bool, error) {
}
}
localHash := head.Hash().String()
// If we couldn't find a remote HEAD, assume no updates
if remoteHead == "" {
return false, nil
return false, localHash, "", nil
}
// Compare local HEAD with remote HEAD
return head.Hash().String() != remoteHead, nil
return localHash != remoteHead, localHash, remoteHead, nil
}
type Registry struct {
+3 -3
View File
@@ -13,7 +13,7 @@ import (
type mockGitClient struct {
cloneFunc func(path string, url string) error
pullFunc func(path string) error
hasUpdatesFunc func(path string) (bool, error)
hasUpdatesFunc func(path string) (bool, string, string, error)
}
func (m *mockGitClient) PlainClone(path string, url string) error {
@@ -30,11 +30,11 @@ func (m *mockGitClient) Pull(path string) error {
return nil
}
func (m *mockGitClient) HasUpdates(path string) (bool, error) {
func (m *mockGitClient) HasUpdates(path string) (bool, string, string, error) {
if m.hasUpdatesFunc != nil {
return m.hasUpdatesFunc(path)
}
return false, nil
return false, "", "", nil
}
func TestNewRegistry(t *testing.T) {
@@ -42,12 +42,15 @@ func HandleListInstalled(conn net.Conn, req models.Request) {
for _, id := range installedNames {
if plugin, ok := pluginMap[id]; ok {
hasUpdate := false
if hasUpdates, err := manager.HasUpdates(id, plugin); err == nil {
diffURL := plugin.Repo
if hasUpdates, dURL, err := manager.HasUpdates(id, plugin); err == nil {
hasUpdate = hasUpdates
diffURL = dURL
}
info := pluginInfoFromPlugin(plugin)
info.HasUpdate = hasUpdate
info.DiffURL = diffURL
result = append(result, info)
} else {
result = append(result, PluginInfo{
+1
View File
@@ -18,6 +18,7 @@ type PluginInfo struct {
Note string `json:"note,omitempty"`
HasUpdate bool `json:"hasUpdate,omitempty"`
RequiresDMS string `json:"requires_dms,omitempty"`
DiffURL string `json:"diffUrl,omitempty"`
Upvotes int `json:"upvotes,omitempty"`
Status []string `json:"status,omitempty"`
IssueURL string `json:"issueUrl,omitempty"`
@@ -0,0 +1,293 @@
import QtQuick
import qs.Common
import qs.Widgets
import qs.Services
StyledRect {
id: root
property var updatesList: []
property bool isUpdating: false
property string currentUpdatingPlugin: ""
width: parent.width
height: visible ? Math.max(200, innerColumn.implicitHeight + Theme.spacingL * 2) : 0
radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh
border.width: 0
clip: true
visible: false
Behavior on height {
enabled: Theme.currentAnimationSpeed !== SettingsData.AnimationSpeed.None
NumberAnimation {
duration: Theme.mediumDuration
easing.type: Theme.standardEasing
}
}
Behavior on opacity {
NumberAnimation {
duration: Theme.shortDuration
}
}
function show(list) {
updatesList = list || [];
visible = true;
}
function hide() {
if (!isUpdating) {
visible = false;
updatesList = [];
}
}
function updateSingle(plugin) {
if (isUpdating) return;
isUpdating = true;
currentUpdatingPlugin = plugin.name;
DMSService.update(plugin.name, response => {
isUpdating = false;
currentUpdatingPlugin = "";
if (response.error) {
ToastService.showError(I18n.tr("Failed to update %1: %2").arg(plugin.name).arg(response.error));
} else {
ToastService.showInfo(I18n.tr("Plugin updated: %1").arg(plugin.name));
PluginService.forceRescanPlugin(plugin.id);
DMSService.listInstalled();
updatesList = updatesList.filter(p => p.id !== plugin.id);
if (updatesList.length === 0) {
root.hide();
}
}
});
}
function updateAll() {
if (isUpdating) return;
isUpdating = true;
var list = updatesList.slice();
var idx = 0;
function updateNext() {
if (idx >= list.length) {
isUpdating = false;
currentUpdatingPlugin = "";
DMSService.listInstalled();
root.hide();
return;
}
var plugin = list[idx];
currentUpdatingPlugin = plugin.name;
DMSService.update(plugin.name, response => {
if (response.error) {
ToastService.showError(I18n.tr("Failed to update %1: %2").arg(plugin.name).arg(response.error));
} else {
PluginService.forceRescanPlugin(plugin.id);
updatesList = updatesList.filter(p => p.id !== plugin.id);
}
idx++;
updateNext();
});
}
updateNext();
}
Column {
id: innerColumn
anchors.fill: parent
anchors.margins: Theme.spacingL
spacing: Theme.spacingM
Row {
width: parent.width
spacing: Theme.spacingM
DankIcon {
name: "download"
size: Theme.iconSize
color: Theme.primary
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
text: I18n.tr("Available Updates (%1)").arg(root.updatesList.length)
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.Medium
color: Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
Item {
width: parent.width - parent.spacing * 2 - Theme.iconSize - parent.children[1].implicitWidth - collapseBtn.width
height: 1
}
DankActionButton {
id: collapseBtn
iconName: "close"
iconSize: Theme.iconSize - 2
iconColor: Theme.outline
anchors.verticalCenter: parent.verticalCenter
enabled: !root.isUpdating
onClicked: root.hide()
}
}
Item {
width: parent.width
height: isUpdating ? 40 : 0
visible: isUpdating
clip: true
Behavior on height {
NumberAnimation { duration: Theme.shortDuration }
}
Row {
anchors.centerIn: parent
spacing: Theme.spacingM
DankSpinner {
running: root.isUpdating
anchors.verticalCenter: parent.verticalCenter
}
StyledText {
text: root.currentUpdatingPlugin ? I18n.tr("Updating %1...").arg(root.currentUpdatingPlugin) : I18n.tr("Updating plugins...")
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
}
}
DankFlickable {
width: parent.width
height: Math.min(listCol.implicitHeight, 300)
clip: true
contentHeight: listCol.implicitHeight
visible: !isUpdating
Column {
id: listCol
width: parent.width
spacing: Theme.spacingM
Repeater {
model: root.updatesList
delegate: StyledRect {
width: parent.width
height: 64
radius: Theme.cornerRadius
color: Theme.surfaceContainerHighest
border.width: 0
Row {
anchors.fill: parent
anchors.margins: Theme.spacingM
spacing: Theme.spacingM
DankIcon {
name: modelData.icon || "extension"
size: Theme.iconSize
color: Theme.primary
anchors.verticalCenter: parent.verticalCenter
}
Column {
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingXS
width: parent.width - Theme.iconSize - Theme.spacingM - actionButtonsRow.width - Theme.spacingM
StyledText {
text: modelData.name || ""
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: Theme.surfaceText
elide: Text.ElideRight
width: parent.width
horizontalAlignment: Text.AlignLeft
}
StyledText {
text: modelData.author ? I18n.tr("By %1").arg(modelData.author) : ""
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
elide: Text.ElideRight
width: parent.width
horizontalAlignment: Text.AlignLeft
}
}
Row {
id: actionButtonsRow
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingS
DankButton {
text: I18n.tr("Diff")
iconName: "open_in_new"
visible: !!modelData.diffUrl || !!modelData.repo
backgroundColor: Theme.surfaceContainerHigh
textColor: Theme.surfaceText
onClicked: {
Qt.openUrlExternally(modelData.diffUrl || modelData.repo);
}
}
DankButton {
text: I18n.tr("Update")
iconName: "download"
enabled: !root.isUpdating
onClicked: {
root.updateSingle(modelData);
}
}
}
}
}
}
StyledText {
width: parent.width
text: I18n.tr("No updates available.")
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceVariantText
horizontalAlignment: Text.AlignHCenter
visible: root.updatesList.length === 0
}
}
}
Row {
anchors.right: parent.right
spacing: Theme.spacingM
visible: !isUpdating
DankButton {
text: I18n.tr("Cancel")
iconName: "close"
backgroundColor: Theme.surfaceContainerHighest
textColor: Theme.surfaceText
onClicked: root.hide()
}
DankButton {
text: I18n.tr("Update All")
iconName: "download"
enabled: root.updatesList.length > 0
onClicked: root.updateAll()
}
}
}
}
@@ -20,6 +20,11 @@ FocusScope {
property string searchQuery: ""
property var filteredPlugins: []
readonly property var pluginsWithUpdates: {
if (!DMSService.installedPlugins) return [];
return DMSService.installedPlugins.filter(p => p.hasUpdate === true);
}
function updateFilteredPlugins() {
var query = searchQuery.toLowerCase();
filteredPlugins = PluginService.availablePluginsList.filter(plugin => {
@@ -261,10 +266,24 @@ FocusScope {
}
}
}
DankButton {
text: I18n.tr("Update All")
iconName: "download"
enabled: DMSService.dmsAvailable && pluginsTab.pluginsWithUpdates.length > 0
onClicked: {
showPluginUpdatesDialog();
}
}
}
}
}
PluginUpdatesDialog {
id: pluginUpdatesDialogItem
width: parent.width
}
StyledRect {
width: parent.width
height: directoryColumn.implicitHeight + Theme.spacingL * 2
@@ -533,4 +552,8 @@ FocusScope {
if (pluginBrowserLoader.item)
pluginBrowserLoader.item.show();
}
function showPluginUpdatesDialog() {
pluginUpdatesDialogItem.show(pluginsTab.pluginsWithUpdates);
}
}