UI: Improve theme extensions installation process

- Show toast with "Open settings" button
This commit is contained in:
aleksey-hoffman committed 2026-04-29 19:00:40 +03:00
1 parent 765788c9f4
commit 2ec8741663
23 files changed
+294 -9

No files matched your search

+1
View File
@@ -1904,6 +1904,7 @@
"showExtensionsFolder": "显示扩展程序文件夹",
"reinstalledSuccessfully": "重新安装成功",
"reusedDependencies": "重复使用了 {count} 个现有依赖",
"themesInstalled": "主题已安装",
"noLongerAvailable": "不再可用",
"removedBecauseUnapproved": "此扩展已被移除,因为它已不在已批准的注册表中。",
"enabled": "Enabled",
+1
View File
@@ -1904,6 +1904,7 @@
"showExtensionsFolder": "Erweiterungsordner anzeigen",
"reinstalledSuccessfully": "Erneut erfolgreich installiert",
"reusedDependencies": "{count} vorhandene Abhängigkeiten wiederverwendet",
"themesInstalled": "Designs wurden installiert",
"noLongerAvailable": "Nicht mehr verfügbar",
"removedBecauseUnapproved": "Diese Erweiterung wurde entfernt, weil sie nicht mehr in der genehmigten Registry enthalten ist.",
"enabled": "Enabled",
+1
View File
@@ -1853,6 +1853,7 @@
"reinstall": "Re-install",
"reinstalledSuccessfully": "Re-installed successfully",
"reusedDependencies": "Reused {count} existing dependencies",
"themesInstalled": "Themes were installed",
"noLongerAvailable": "No longer available",
"removedBecauseUnapproved": "This extension was removed because it is no longer in the approved registry.",
"update": "Update",
+1
View File
@@ -1904,6 +1904,7 @@
"showExtensionsFolder": "Mostrar carpeta de extensiones",
"reinstalledSuccessfully": "Reinstalado correctamente",
"reusedDependencies": "Reutilizadas {count} dependencias existentes",
"themesInstalled": "Los temas se instalaron",
"noLongerAvailable": "Ya no está disponible",
"removedBecauseUnapproved": "Esta extensión se eliminó porque ya no está en el registro aprobado.",
"enabled": "Enabled",
+1
View File
@@ -1903,6 +1903,7 @@
"showExtensionsFolder": "نمایش پوشه افزونه‌ها",
"reinstalledSuccessfully": "با موفقیت نصب مجدد شد",
"reusedDependencies": "{count} وابستگی موجود مجدداً استفاده شد",
"themesInstalled": "تم‌ها نصب شدند",
"noLongerAvailable": "دیگر در دسترس نیست",
"removedBecauseUnapproved": "این افزونه حذف شد چون دیگر در رجیستری مورد تأیید وجود ندارد.",
"enabled": "Enabled",
+1
View File
@@ -1904,6 +1904,7 @@
"showExtensionsFolder": "Afficher le dossier des extensions",
"reinstalledSuccessfully": "Réinstallation réussie",
"reusedDependencies": "{count} dépendances existantes réutilisées",
"themesInstalled": "Les thèmes ont été installés",
"noLongerAvailable": "N'est plus disponible",
"removedBecauseUnapproved": "Cette extension a été supprimée, car elle ne figure plus dans le registre approuvé.",
"enabled": "Enabled",
+1
View File
@@ -1853,6 +1853,7 @@
"reinstall": "पुन: इंस्टॉल",
"reinstalledSuccessfully": "सफलतापूर्वक पुनः स्थापित किया गया",
"reusedDependencies": "पुन: उपयोग किया गया {count} मौजूदा निर्भरताएँ",
"themesInstalled": "थीम इंस्टॉल हो गईं",
"noLongerAvailable": "अब उपलब्ध नहीं है",
"removedBecauseUnapproved": "यह एक्सटेंशन हटा दिया गया क्योंकि यह अब स्वीकृत रजिस्ट्री में नहीं है।",
"update": "अद्यतन",
+1
View File
@@ -1904,6 +1904,7 @@
"showExtensionsFolder": "Mostra cartella estensioni",
"reinstalledSuccessfully": "Reinstallato con successo",
"reusedDependencies": "Riusate {count} dipendenze esistenti",
"themesInstalled": "I temi sono stati installati",
"noLongerAvailable": "Non è più disponibile",
"removedBecauseUnapproved": "Questa estensione è stata rimossa perché non è più presente nel registro approvato.",
"enabled": "Enabled",
+1
View File
@@ -1904,6 +1904,7 @@
"showExtensionsFolder": "拡張機能フォルダーを表示",
"reinstalledSuccessfully": "再インストールが完了しました",
"reusedDependencies": "既存の依存関係 {count} 件を再利用しました",
"themesInstalled": "テーマがインストールされました",
"noLongerAvailable": "利用できなくなりました",
"removedBecauseUnapproved": "この拡張機能は承認済みレジストリに存在しなくなったため削除されました。",
"enabled": "有効",
+1
View File
@@ -1915,6 +1915,7 @@
"showExtensionsFolder": "Mostrar pasta de extensões",
"reinstalledSuccessfully": "Reinstalado com sucesso",
"reusedDependencies": "Reutilizadas {count} dependências existentes",
"themesInstalled": "Os temas foram instalados",
"noLongerAvailable": "Não está mais disponível",
"removedBecauseUnapproved": "Esta extensão foi removida porque não está mais no registro aprovado.",
"enabled": "Enabled",
+1
View File
@@ -1923,6 +1923,7 @@
"showExtensionsFolder": "Показать папку расширений",
"reinstalledSuccessfully": "Успешно переустановлено",
"reusedDependencies": "Использовано {count} существующих зависимостей",
"themesInstalled": "Темы установлены",
"noLongerAvailable": "Больше недоступно",
"removedBecauseUnapproved": "Это расширение было удалено, потому что его больше нет в одобренном реестре.",
"enabled": "Enabled",
+1
View File
@@ -1953,6 +1953,7 @@
"reinstall": "Re-install",
"reinstalledSuccessfully": "Ponovno uspešno nameščeno",
"reusedDependencies": "Ponovno uporabljenih {count} obstoječih odvisnosti",
"themesInstalled": "Teme so bile nameščene",
"noLongerAvailable": "Ni več na voljo",
"removedBecauseUnapproved": "Ta razširitev je bila odstranjena, ker je ni več v odobrenem registru.",
"update": "Update",
+1
View File
@@ -1904,6 +1904,7 @@
"showExtensionsFolder": "Uzantılar klasörünü göster",
"reinstalledSuccessfully": "Başarıyla yeniden yüklendi",
"reusedDependencies": "{count} mevcut bağımlılık yeniden kullanıldı",
"themesInstalled": "Temalar yüklendi",
"noLongerAvailable": "Artık kullanılamıyor",
"removedBecauseUnapproved": "Bu uzantı, artık onaylı kayıt listesinde olmadığı için kaldırıldı.",
"enabled": "Enabled",
+1
View File
@@ -1853,6 +1853,7 @@
"reinstall": "دوبارہ انسٹال",
"reinstalledSuccessfully": "دوبارہ انسٹال ہو گیا",
"reusedDependencies": "{count} موجودہ وابستگیاں دوبارہ استعمال",
"themesInstalled": "تھیمز انسٹال ہو گئے",
"noLongerAvailable": "اب دستیاب نہیں",
"removedBecauseUnapproved": "یہ ایکسٹینشن ہٹا دیا گیا کیونکہ منظور شدہ رجسٹری میں نہیں۔",
"update": "اپ ڈیٹ",
+1
View File
@@ -1904,6 +1904,7 @@
"showExtensionsFolder": "Hiển thị thư mục tiện ích mở rộng",
"reinstalledSuccessfully": "Cài đặt lại thành công",
"reusedDependencies": "Đã tái sử dụng {count} phụ thuộc hiện có",
"themesInstalled": "Đã cài đặt giao diện",
"noLongerAvailable": "Không còn khả dụng",
"removedBecauseUnapproved": "Tiện ích mở rộng này đã bị gỡ vì không còn trong danh sách đăng ký đã được phê duyệt.",
"enabled": "Enabled",
@@ -0,0 +1,154 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// License: GNU GPLv3 or later. See the license file in the project root for more information.
// Copyright © 2021 - present Aleksey Hoffman. All rights reserved.
import {
beforeEach, describe, expect, it, vi,
} from 'vitest';
import type { ExtensionManifest, InstalledExtensionData } from '@/types/extension';
const {
installedExtensions,
openSettingsTabMock,
toastCustomMock,
toastDismissMock,
} = vi.hoisted(() => ({
installedExtensions: {} as Record<string, InstalledExtensionData>,
openSettingsTabMock: vi.fn(),
toastCustomMock: vi.fn(),
toastDismissMock: vi.fn(),
}));
vi.mock('@/modules/settings/utils/open-settings', () => ({
openSettingsTab: openSettingsTabMock,
}));
vi.mock('@/stores/storage/extensions', () => ({
useExtensionsStorageStore: () => ({
extensionsData: {
installedExtensions,
},
}),
}));
vi.mock('@/components/ui/toaster', () => ({
toast: {
custom: toastCustomMock,
dismiss: toastDismissMock,
},
ToastStatic: {
name: 'ToastStatic',
},
}));
vi.mock('@/localization', () => ({
i18n: {
global: {
locale: {
value: 'en',
},
t: (key: string) => ({
'featureExtension': 'Extension',
'openSettings': 'Open settings',
'extensions.themesInstalled': 'Themes were installed',
})[key] ?? key,
},
},
}));
import { showThemesInstalledToast } from '@/modules/extensions/utils/toast-utils';
function createThemeManifest(): ExtensionManifest {
return {
id: 'test.palette',
name: 'Test Palette',
version: '1.0.0',
repository: 'https://github.com/example/test-palette',
license: 'MIT',
extensionType: 'api',
permissions: [],
contributes: {
themes: [
{
id: 'midnight',
title: 'Midnight',
baseTheme: 'dark',
variables: {
'--background': '230 20% 10%',
},
},
],
},
engines: {
sigmaFileManager: '>=2.0.0',
},
};
}
describe('extension toast utilities', () => {
beforeEach(() => {
Object.keys(installedExtensions).forEach((extensionId) => {
delete installedExtensions[extensionId];
});
openSettingsTabMock.mockReset();
toastCustomMock.mockReset();
toastDismissMock.mockReset();
});
it('shows a persistent settings action toast for contributed themes', () => {
const manifest = createThemeManifest();
installedExtensions[manifest.id] = {
version: manifest.version,
enabled: true,
autoUpdate: true,
installedAt: 1,
manifest: {
...manifest,
icon: 'icon.png',
},
settings: {
scopedDirectories: [],
customSettings: {},
},
};
showThemesInstalledToast(manifest.id, manifest);
expect(toastCustomMock).toHaveBeenCalledTimes(1);
const toastOptions = toastCustomMock.mock.calls[0]?.[1];
expect(toastOptions).toMatchObject({
id: 'ext-themes-installed-test.palette',
duration: Infinity,
componentProps: {
data: {
title: 'Extension | Test Palette',
subtitle: 'Themes were installed',
actionText: 'Open settings',
extensionId: 'test.palette',
extensionIconPath: 'icon.png',
},
},
});
toastOptions.componentProps.data.onAction();
expect(toastDismissMock).toHaveBeenCalledWith('ext-themes-installed-test.palette');
expect(openSettingsTabMock).toHaveBeenCalledWith('appearance');
toastOptions.componentProps.data.onDismiss();
expect(toastDismissMock).toHaveBeenCalledTimes(2);
expect(toastDismissMock).toHaveBeenLastCalledWith('ext-themes-installed-test.palette');
});
it('does not show a toast when the extension contributes no themes', () => {
const manifest = {
...createThemeManifest(),
contributes: {},
};
showThemesInstalledToast(manifest.id, manifest);
expect(toastCustomMock).not.toHaveBeenCalled();
});
});
@@ -6,6 +6,8 @@ import { markRaw } from 'vue';
import { toast, ToastStatic } from '@/components/ui/toaster';
import { i18n } from '@/localization';
import { useExtensionsStorageStore } from '@/stores/storage/extensions';
import { openSettingsTab } from '@/modules/settings/utils/open-settings';
import type { ExtensionManifest } from '@/types/extension';
import {
getBinaryDownloadCount, clearBinaryDownloadCount,
getBinaryReuseCount, clearBinaryReuseCount,
@@ -70,6 +72,36 @@ export function showDependenciesInstalledToast(extensionId: string): void {
}, 3000);
}
export function manifestContributesThemes(manifest: ExtensionManifest): boolean {
return (manifest.contributes?.themes?.length ?? 0) > 0;
}
export function showThemesInstalledToast(extensionId: string, manifest: ExtensionManifest): void {
if (!manifestContributesThemes(manifest)) return;
const { t } = i18n.global;
const toastId = `ext-themes-installed-${extensionId}`;
toast.custom(markRaw(ToastStatic), {
id: toastId,
duration: Infinity,
componentProps: {
data: {
title: getExtensionToastTitle(extensionId),
subtitle: t('extensions.themesInstalled'),
actionText: t('openSettings'),
onAction: () => {
toast.dismiss(toastId);
void openSettingsTab('appearance');
},
onDismiss: () => toast.dismiss(toastId),
extensionId,
extensionIconPath: getExtensionToastIconPath(extensionId),
},
},
});
}
export function showExtensionNoLongerAvailableToast(extensionId: string, extensionName: string): void {
const { t } = i18n.global;
const toastId = `ext-removed-${extensionId}`;
@@ -11,7 +11,7 @@ import {
ref,
watch,
} from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import {
ChevronDownIcon, HardDriveIcon, LoaderCircleIcon, SearchIcon, SettingsIcon, SlidersHorizontalIcon, UsbIcon, XIcon,
@@ -28,7 +28,7 @@ import {
} from '@/components/ui/number-field';
import { useGlobalSearchStore } from '@/stores/runtime/global-search';
import { useUserSettingsStore } from '@/stores/storage/user-settings';
import { useSettingsStore } from '@/stores/runtime/settings';
import { openSettingsTab } from '@/modules/settings/utils/open-settings';
import { getDriveByPath } from '@/modules/home/composables/use-drives';
import type { DirEntry } from '@/types/dir-entry';
import type { DriveInfo } from '@/types/drive-info';
@@ -43,11 +43,9 @@ const emit = defineEmits<{
'update:selectedEntries': [entries: DirEntry[]];
}>();
const router = useRouter();
const route = useRoute();
const globalSearchStore = useGlobalSearchStore();
const userSettingsStore = useUserSettingsStore();
const settingsStore = useSettingsStore();
const { t } = useI18n();
const inputRef = ref<InstanceType<typeof Input> | null>(null);
@@ -61,8 +59,7 @@ const typoTolerance = computed(() => userSettingsStore.userSettings.globalSearch
const scanDepth = computed(() => userSettingsStore.userSettings.globalSearch.scanDepth);
function openSearchSettings() {
settingsStore.setCurrentTab('search');
router.push({ name: 'settings' });
void openSettingsTab('search');
}
function setResultLimit(value: string | number | undefined) {
@@ -0,0 +1,46 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// License: GNU GPLv3 or later. See the license file in the project root for more information.
// Copyright © 2021 - present Aleksey Hoffman. All rights reserved.
import {
beforeEach, describe, expect, it, vi,
} from 'vitest';
const {
routerPushMock,
settingsStoreMock,
} = vi.hoisted(() => ({
routerPushMock: vi.fn(),
settingsStoreMock: {
clearSearch: vi.fn(),
setCurrentTab: vi.fn(),
},
}));
vi.mock('@/router', () => ({
default: {
push: routerPushMock,
},
}));
vi.mock('@/stores/runtime/settings', () => ({
useSettingsStore: () => settingsStoreMock,
}));
import { openSettingsTab } from '@/modules/settings/utils/open-settings';
describe('openSettingsTab', () => {
beforeEach(() => {
routerPushMock.mockReset();
settingsStoreMock.clearSearch.mockReset();
settingsStoreMock.setCurrentTab.mockReset();
});
it('clears settings search, selects a tab, and routes to settings', async () => {
await openSettingsTab('appearance');
expect(settingsStoreMock.clearSearch).toHaveBeenCalledTimes(1);
expect(settingsStoreMock.setCurrentTab).toHaveBeenCalledWith('appearance');
expect(routerPushMock).toHaveBeenCalledWith({ name: 'settings' });
});
});
@@ -0,0 +1,17 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// License: GNU GPLv3 or later. See the license file in the project root for more information.
// Copyright © 2021 - present Aleksey Hoffman. All rights reserved.
import router from '@/router';
import { useSettingsStore } from '@/stores/runtime/settings';
export async function openSettingsTab(tabName?: string): Promise<void> {
const settingsStore = useSettingsStore();
settingsStore.clearSearch();
if (tabName) {
settingsStore.setCurrentTab(tabName);
}
await router.push({ name: 'settings' });
}
@@ -585,6 +585,18 @@ describe('extensions runtime store', () => {
'test.video',
'https://github.com/example/test-video',
);
manifest.contributes = {
themes: [
{
id: 'midnight',
title: 'Midnight',
baseTheme: 'dark',
variables: {
'--background': '230 20% 10%',
},
},
],
};
const registryEntry = createRegistryEntry('test.video', manifest.repository);
const extensionsStore = useExtensionsStore();
extensionsStore.registry = {
@@ -628,6 +640,13 @@ describe('extensions runtime store', () => {
cancellationId: expect.any(String),
}),
);
expect(toastCustomMock).toHaveBeenCalledTimes(1);
expect(toastCustomMock.mock.calls[0]?.[1]?.componentProps?.data).toMatchObject({
title: 'Extension | test.video',
subtitle: 'Themes were installed',
actionText: 'Open settings',
extensionId: 'test.video',
});
});
it('passes install cancellation ids to extension archive updates', async () => {
+6
View File
@@ -60,6 +60,7 @@ import {
import {
getExtensionDisplayName,
showDependenciesInstalledToast,
showThemesInstalledToast,
showExtensionNoLongerAvailableToast,
showExtensionBusyToast,
} from '@/modules/extensions/utils/toast-utils';
@@ -723,6 +724,7 @@ export const useExtensionsStore = defineStore('extensions', () => {
await storageStore.completeExtensionInstall(extensionId);
showDependenciesInstalledToast(extensionId);
showThemesInstalledToast(extensionId, manifest);
}
catch (error) {
if (persistedInstall) {
@@ -815,6 +817,7 @@ export const useExtensionsStore = defineStore('extensions', () => {
await storageStore.completeExtensionInstall(extensionId);
showDependenciesInstalledToast(extensionId);
showThemesInstalledToast(extensionId, manifest);
}
catch (error) {
if (persistedInstall) {
@@ -899,6 +902,8 @@ export const useExtensionsStore = defineStore('extensions', () => {
if (extensionData.enabled && shouldActivateOnStartup(manifest)) {
await loadExtension(extensionId, 'onStartup');
}
showThemesInstalledToast(extensionId, manifest);
}
catch (error) {
await storageStore.restoreExtensionSnapshot(
@@ -1059,6 +1064,7 @@ export const useExtensionsStore = defineStore('extensions', () => {
}
showDependenciesInstalledToast(extensionId);
showThemesInstalledToast(extensionId, manifest);
}
catch (error) {
await storageStore.restoreExtensionSnapshot(
+2 -3
View File
@@ -10,8 +10,8 @@ import {
markRaw,
type Component,
} from 'vue';
import { useI18n } from 'vue-i18n';
import { messages } from '@/localization/data';
import { i18n } from '@/localization';
import { useUserSettingsStore } from '@/stores/storage/user-settings';
import { usePlatformStore } from '@/stores/runtime/platform';
@@ -107,7 +107,6 @@ function matchesAnyLocale(key: string, searchTerm: string): boolean {
}
export const useSettingsStore = defineStore('settings', () => {
const { t } = useI18n();
const userSettingsStore = useUserSettingsStore();
const platformStore = usePlatformStore();
@@ -127,7 +126,7 @@ export const useSettingsStore = defineStore('settings', () => {
const tabs = computed(() =>
settingsTabs.map(tab => ({
name: tab.name,
label: t(tab.labelKey),
label: i18n.global.t(tab.labelKey),
})),
);