From 8465ed43113853e5451bc678c914ba3c82071e56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hu=E1=BB=B3nh=20Thi=E1=BB=87n=20L=E1=BB=99c?= Date: Sat, 4 Jul 2026 08:51:39 +0700 Subject: [PATCH] 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 --- core/cmd/dms/commands_common.go | 202 +++++++++++- core/internal/plugins/manager.go | 31 +- core/internal/plugins/registry.go | 19 +- core/internal/plugins/registry_test.go | 6 +- .../internal/server/plugins/list_installed.go | 5 +- core/internal/server/plugins/types.go | 1 + .../Modules/Settings/PluginUpdatesDialog.qml | 293 ++++++++++++++++++ quickshell/Modules/Settings/PluginsTab.qml | 23 ++ 8 files changed, 554 insertions(+), 26 deletions(-) create mode 100644 quickshell/Modules/Settings/PluginUpdatesDialog.qml diff --git a/core/cmd/dms/commands_common.go b/core/cmd/dms/commands_common.go index 854c3200a..b85918510 100644 --- a/core/cmd/dms/commands_common.go +++ b/core/cmd/dms/commands_common.go @@ -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 ", - 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, diff --git a/core/internal/plugins/manager.go b/core/internal/plugins/manager.go index 1c0ddf58f..8ded155cd 100644 --- a/core/internal/plugins/manager.go +++ b/core/internal/plugins/manager.go @@ -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 } diff --git a/core/internal/plugins/registry.go b/core/internal/plugins/registry.go index 1728e7236..edcbc2487 100644 --- a/core/internal/plugins/registry.go +++ b/core/internal/plugins/registry.go @@ -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 { diff --git a/core/internal/plugins/registry_test.go b/core/internal/plugins/registry_test.go index e535e6f4c..4efd5e842 100644 --- a/core/internal/plugins/registry_test.go +++ b/core/internal/plugins/registry_test.go @@ -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) { diff --git a/core/internal/server/plugins/list_installed.go b/core/internal/server/plugins/list_installed.go index 1d1a06d5a..7eeadeaa7 100644 --- a/core/internal/server/plugins/list_installed.go +++ b/core/internal/server/plugins/list_installed.go @@ -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{ diff --git a/core/internal/server/plugins/types.go b/core/internal/server/plugins/types.go index 9127b89e2..3e5e6392d 100644 --- a/core/internal/server/plugins/types.go +++ b/core/internal/server/plugins/types.go @@ -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"` diff --git a/quickshell/Modules/Settings/PluginUpdatesDialog.qml b/quickshell/Modules/Settings/PluginUpdatesDialog.qml new file mode 100644 index 000000000..331d19f39 --- /dev/null +++ b/quickshell/Modules/Settings/PluginUpdatesDialog.qml @@ -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() + } + } + } +} diff --git a/quickshell/Modules/Settings/PluginsTab.qml b/quickshell/Modules/Settings/PluginsTab.qml index a5e1445dd..f47afea2a 100644 --- a/quickshell/Modules/Settings/PluginsTab.qml +++ b/quickshell/Modules/Settings/PluginsTab.qml @@ -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); + } }