diff --git a/core/internal/server/plugins/handlers_test.go b/core/internal/server/plugins/handlers_test.go index 70202e3f..28978abf 100644 --- a/core/internal/server/plugins/handlers_test.go +++ b/core/internal/server/plugins/handlers_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/net" + coreplugins "github.com/AvengeMedia/DankMaterialShell/core/internal/plugins" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" @@ -165,6 +166,7 @@ func TestPluginInfoJSON(t *testing.T) { info := PluginInfo{ Name: "test", Description: "test description", + Screenshot: "https://raw.githubusercontent.com/test/repo/main/screenshot.png", Installed: true, FirstParty: true, } @@ -177,6 +179,59 @@ func TestPluginInfoJSON(t *testing.T) { assert.NoError(t, err) assert.Equal(t, info.Name, unmarshaled.Name) assert.Equal(t, info.Installed, unmarshaled.Installed) + assert.Equal(t, info.Screenshot, unmarshaled.Screenshot) +} + +func TestNormalizeScreenshotURL(t *testing.T) { + tests := []struct { + name string + raw string + want string + }{ + { + name: "raw github url is unchanged", + raw: "https://raw.githubusercontent.com/alcxyz/DankVault/main/docs/screenshot.png", + want: "https://raw.githubusercontent.com/alcxyz/DankVault/main/docs/screenshot.png", + }, + { + name: "github blob url becomes raw content url", + raw: "https://github.com/acmagn/DMS-UPS-Monitor/blob/main/assets/screenshot.png", + want: "https://raw.githubusercontent.com/acmagn/DMS-UPS-Monitor/main/assets/screenshot.png", + }, + { + name: "github raw url becomes raw content url", + raw: "https://github.com/antonjah/nix-monitor/raw/master/assets/scrot.png", + want: "https://raw.githubusercontent.com/antonjah/nix-monitor/master/assets/scrot.png", + }, + { + name: "non github url is unchanged", + raw: "https://example.com/screenshot.png", + want: "https://example.com/screenshot.png", + }, + { + name: "empty url is empty", + raw: " ", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, normalizeScreenshotURL(tt.raw)) + }) + } +} + +func TestPluginInfoFromPluginIncludesScreenshot(t *testing.T) { + info := pluginInfoFromPlugin(coreplugins.Plugin{ + ID: "dankVault", + Name: "Vault", + Repo: "https://github.com/AvengeMedia/dms-plugins", + Screenshot: "https://github.com/AvengeMedia/dms-plugins/blob/master/DankNotepadModule/screenshot.png", + }) + + assert.Equal(t, "https://raw.githubusercontent.com/AvengeMedia/dms-plugins/master/DankNotepadModule/screenshot.png", info.Screenshot) + assert.True(t, info.FirstParty) } func TestSuccessResult(t *testing.T) { diff --git a/core/internal/server/plugins/list.go b/core/internal/server/plugins/list.go index 056386ae..31fb122a 100644 --- a/core/internal/server/plugins/list.go +++ b/core/internal/server/plugins/list.go @@ -3,7 +3,6 @@ package plugins import ( "fmt" "net" - "strings" "github.com/AvengeMedia/DankMaterialShell/core/internal/plugins" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" @@ -34,25 +33,12 @@ func HandleList(conn net.Conn, req models.Request) { for i, p := range pluginList { installed, _ := manager.IsInstalled(p) fb := feedback[p.ID] - result[i] = PluginInfo{ - ID: p.ID, - Name: p.Name, - Category: p.Category, - Author: p.Author, - Description: p.Description, - Repo: p.Repo, - Path: p.Path, - Capabilities: p.Capabilities, - Compositors: p.Compositors, - Dependencies: p.Dependencies, - Installed: installed, - FirstParty: strings.HasPrefix(p.Repo, "https://github.com/AvengeMedia"), - Featured: p.Featured, - RequiresDMS: p.RequiresDMS, - Upvotes: fb.Upvotes, - Status: fb.Status, - IssueURL: fb.IssueURL, - } + info := pluginInfoFromPlugin(p) + info.Installed = installed + info.Upvotes = fb.Upvotes + info.Status = fb.Status + info.IssueURL = fb.IssueURL + result[i] = info } models.Respond(conn, req.ID, result) diff --git a/core/internal/server/plugins/list_installed.go b/core/internal/server/plugins/list_installed.go index b771b0e6..1d1a06d5 100644 --- a/core/internal/server/plugins/list_installed.go +++ b/core/internal/server/plugins/list_installed.go @@ -3,7 +3,6 @@ package plugins import ( "fmt" "net" - "strings" "github.com/AvengeMedia/DankMaterialShell/core/internal/plugins" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" @@ -47,21 +46,9 @@ func HandleListInstalled(conn net.Conn, req models.Request) { hasUpdate = hasUpdates } - result = append(result, PluginInfo{ - ID: plugin.ID, - Name: plugin.Name, - Category: plugin.Category, - Author: plugin.Author, - Description: plugin.Description, - Repo: plugin.Repo, - Path: plugin.Path, - Capabilities: plugin.Capabilities, - Compositors: plugin.Compositors, - Dependencies: plugin.Dependencies, - FirstParty: strings.HasPrefix(plugin.Repo, "https://github.com/AvengeMedia"), - HasUpdate: hasUpdate, - RequiresDMS: plugin.RequiresDMS, - }) + info := pluginInfoFromPlugin(plugin) + info.HasUpdate = hasUpdate + result = append(result, info) } else { result = append(result, PluginInfo{ ID: id, diff --git a/core/internal/server/plugins/search.go b/core/internal/server/plugins/search.go index 9dbea6d8..11ec973f 100644 --- a/core/internal/server/plugins/search.go +++ b/core/internal/server/plugins/search.go @@ -3,7 +3,6 @@ package plugins import ( "fmt" "net" - "strings" "github.com/AvengeMedia/DankMaterialShell/core/internal/plugins" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" @@ -53,21 +52,9 @@ func HandleSearch(conn net.Conn, req models.Request) { result := make([]PluginInfo, len(searchResults)) for i, p := range searchResults { installed, _ := manager.IsInstalled(p) - result[i] = PluginInfo{ - ID: p.ID, - Name: p.Name, - Category: p.Category, - Author: p.Author, - Description: p.Description, - Repo: p.Repo, - Path: p.Path, - Capabilities: p.Capabilities, - Compositors: p.Compositors, - Dependencies: p.Dependencies, - Installed: installed, - FirstParty: strings.HasPrefix(p.Repo, "https://github.com/AvengeMedia"), - RequiresDMS: p.RequiresDMS, - } + info := pluginInfoFromPlugin(p) + info.Installed = installed + result[i] = info } models.Respond(conn, req.ID, result) diff --git a/core/internal/server/plugins/types.go b/core/internal/server/plugins/types.go index 9dbb6a1d..ba464bbe 100644 --- a/core/internal/server/plugins/types.go +++ b/core/internal/server/plugins/types.go @@ -8,6 +8,7 @@ type PluginInfo struct { Description string `json:"description,omitempty"` Repo string `json:"repo,omitempty"` Path string `json:"path,omitempty"` + Screenshot string `json:"screenshot,omitempty"` Capabilities []string `json:"capabilities,omitempty"` Compositors []string `json:"compositors,omitempty"` Dependencies []string `json:"dependencies,omitempty"` diff --git a/core/internal/server/plugins/utils.go b/core/internal/server/plugins/utils.go index 8b89f440..06cd5539 100644 --- a/core/internal/server/plugins/utils.go +++ b/core/internal/server/plugins/utils.go @@ -1,14 +1,65 @@ package plugins import ( + "net/url" "sort" "strings" + + coreplugins "github.com/AvengeMedia/DankMaterialShell/core/internal/plugins" ) +func pluginInfoFromPlugin(plugin coreplugins.Plugin) PluginInfo { + return PluginInfo{ + ID: plugin.ID, + Name: plugin.Name, + Category: plugin.Category, + Author: plugin.Author, + Description: plugin.Description, + Repo: plugin.Repo, + Path: plugin.Path, + Screenshot: normalizeScreenshotURL(plugin.Screenshot), + Capabilities: plugin.Capabilities, + Compositors: plugin.Compositors, + Dependencies: plugin.Dependencies, + FirstParty: isFirstPartyRepo(plugin.Repo), + Featured: plugin.Featured, + RequiresDMS: plugin.RequiresDMS, + } +} + +func isFirstPartyRepo(repo string) bool { + return strings.HasPrefix(repo, "https://github.com/AvengeMedia") +} + +func normalizeScreenshotURL(raw string) string { + screenshotURL := strings.TrimSpace(raw) + if screenshotURL == "" { + return "" + } + + parsed, err := url.Parse(screenshotURL) + if err != nil { + return screenshotURL + } + + host := strings.ToLower(parsed.Host) + if host != "github.com" && host != "www.github.com" { + return screenshotURL + } + + parts := strings.Split(strings.Trim(parsed.EscapedPath(), "/"), "/") + if len(parts) < 5 || (parts[2] != "blob" && parts[2] != "raw") { + return screenshotURL + } + + rawParts := append([]string{parts[0], parts[1], parts[3]}, parts[4:]...) + return "https://raw.githubusercontent.com/" + strings.Join(rawParts, "/") +} + func SortPluginInfoByFirstParty(pluginInfos []PluginInfo) { sort.SliceStable(pluginInfos, func(i, j int) bool { - isFirstPartyI := strings.HasPrefix(pluginInfos[i].Repo, "https://github.com/AvengeMedia") - isFirstPartyJ := strings.HasPrefix(pluginInfos[j].Repo, "https://github.com/AvengeMedia") + isFirstPartyI := isFirstPartyRepo(pluginInfos[i].Repo) + isFirstPartyJ := isFirstPartyRepo(pluginInfos[j].Repo) if isFirstPartyI != isFirstPartyJ { return isFirstPartyI } diff --git a/quickshell/Modules/Settings/PluginBrowser.qml b/quickshell/Modules/Settings/PluginBrowser.qml index 0a4f79b8..9b116e84 100644 --- a/quickshell/Modules/Settings/PluginBrowser.qml +++ b/quickshell/Modules/Settings/PluginBrowser.qml @@ -18,12 +18,14 @@ FloatingWindow { property bool keyboardNavigationActive: false property bool isLoading: false property var parentModal: null - parentWindow: parentModal + parentWindow: null property bool pendingInstallHandled: false property string typeFilter: "" property string categoryFilter: "all" property var categoryFilterOptions: [] property var availableLetters: [] + property string expandedPluginId: "" + property string enlargedPreviewPluginId: "" readonly property bool activeCategorySort: normalizedSortMode(SessionData.pluginBrowserSortMode) === "category" readonly property bool showCategoryFilters: activeCategorySort && categoryFilterOptions.length > 1 @@ -255,6 +257,9 @@ FloatingWindow { } function updateFilteredPlugins() { + expandedPluginId = ""; + enlargedPreviewPluginId = ""; + var baseFiltered = []; var query = searchQuery ? searchQuery.toLowerCase() : ""; @@ -340,6 +345,35 @@ FloatingWindow { refreshListLayout(); } + function pluginKey(plugin, fallbackIndex) { + if (!plugin) + return "plugin-" + fallbackIndex; + return plugin.id || plugin.name || ("plugin-" + fallbackIndex); + } + + function toggleExpandedPlugin(pluginId) { + if (expandedPluginId === pluginId) { + expandedPluginId = ""; + enlargedPreviewPluginId = ""; + } else { + expandedPluginId = pluginId; + enlargedPreviewPluginId = ""; + } + keyboardNavigationActive = false; + Qt.callLater(() => { + if (pluginBrowserList) + pluginBrowserList.forceLayout(); + }); + } + + function toggleEnlargedPreview(pluginId) { + enlargedPreviewPluginId = enlargedPreviewPluginId === pluginId ? "" : pluginId; + Qt.callLater(() => { + if (pluginBrowserList) + pluginBrowserList.forceLayout(); + }); + } + function selectNext() { if (filteredPlugins.length === 0) return; @@ -445,6 +479,8 @@ FloatingWindow { selectedIndex = -1; keyboardNavigationActive = false; isLoading = false; + expandedPluginId = ""; + enlargedPreviewPluginId = ""; } Connections { @@ -828,6 +864,8 @@ FloatingWindow { } delegate: Rectangle { + id: pluginDelegate + width: pluginBrowserList.width height: pluginDelegateColumn.implicitHeight + Theme.spacingM * 2 radius: Theme.cornerRadius @@ -836,12 +874,26 @@ FloatingWindow { property bool isFirstParty: modelData.firstParty || false property bool isFeatured: modelData.featured || false property bool isCompatible: PluginService.checkPluginCompatibility(modelData.requires_dms) - color: isSelected ? Theme.primarySelected : Theme.withAlpha(Theme.surfaceVariant, 0.3) + property string pluginId: root.pluginKey(modelData, index) + property bool isExpanded: root.expandedPluginId === pluginId + property bool isPreviewEnlarged: root.enlargedPreviewPluginId === pluginId + property string screenshotUrl: modelData.screenshot || "" + color: isSelected ? Theme.primarySelected : rowMouseArea.containsMouse ? Theme.withAlpha(Theme.surfaceVariant, 0.45) : Theme.withAlpha(Theme.surfaceVariant, 0.3) border.color: isSelected ? Theme.primary : Theme.withAlpha(Theme.outline, 0.2) border.width: isSelected ? 2 : 1 + MouseArea { + id: rowMouseArea + z: 0 + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.toggleExpandedPlugin(pluginDelegate.pluginId) + } + Column { id: pluginDelegateColumn + z: 1 anchors.fill: parent anchors.margins: Theme.spacingM spacing: Theme.spacingXS @@ -1171,6 +1223,76 @@ FloatingWindow { } } } + + Rectangle { + id: screenshotPreview + width: parent.width + height: pluginDelegate.isExpanded ? (pluginDelegate.isPreviewEnlarged ? Math.min(620, Math.max(320, width * 0.78)) : Math.min(260, Math.max(150, width * 0.42))) : 0 + visible: height > 0 + clip: true + radius: Theme.cornerRadius + color: Theme.surfaceContainerHigh + border.color: Theme.withAlpha(Theme.outline, 0.2) + border.width: 1 + + Behavior on height { + NumberAnimation { + duration: Theme.shortDuration + easing.type: Theme.standardEasing + } + } + + Loader { + id: screenshotImageLoader + anchors.fill: parent + anchors.margins: 1 + active: pluginDelegate.isExpanded && pluginDelegate.screenshotUrl.length > 0 + + sourceComponent: CachingImage { + imagePath: pluginDelegate.screenshotUrl + maxCacheSize: pluginDelegate.isPreviewEnlarged ? 1600 : 960 + fillMode: Image.PreserveAspectFit + visible: status !== Image.Error + } + } + + MouseArea { + anchors.fill: parent + enabled: screenshotImageLoader.item && screenshotImageLoader.item.status === Image.Ready + hoverEnabled: enabled + cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: mouse => { + mouse.accepted = true; + root.toggleEnlargedPreview(pluginDelegate.pluginId); + } + } + + DankSpinner { + anchors.centerIn: parent + running: screenshotImageLoader.active && screenshotImageLoader.item && screenshotImageLoader.item.status === Image.Loading + visible: running + } + + Column { + anchors.centerIn: parent + spacing: Theme.spacingXS + visible: pluginDelegate.isExpanded && (pluginDelegate.screenshotUrl.length === 0 || (screenshotImageLoader.item && screenshotImageLoader.item.status === Image.Error)) + + DankIcon { + anchors.horizontalCenter: parent.horizontalCenter + name: screenshotImageLoader.item && screenshotImageLoader.item.status === Image.Error ? "broken_image" : "image_not_supported" + size: Theme.iconSize + color: Theme.outline + } + + StyledText { + anchors.horizontalCenter: parent.horizontalCenter + text: screenshotImageLoader.item && screenshotImageLoader.item.status === Image.Error ? I18n.tr("Screenshot unavailable", "plugin browser screenshot error") : I18n.tr("No screenshot provided", "plugin browser no screenshot") + font.pixelSize: Theme.fontSizeSmall + color: Theme.outline + } + } + } } } }