mirror of
https://github.com/aleksey-hoffman/sigma-file-manager.git
synced 2026-10-19 01:31:05 -05:00
Fix: custom icons are loading with a noticable delay
This commit is contained in:
1 parent
d70cc169e2
commit
8c03bf4190
18 files changed
+878
-210
No files matched your search
@@ -0,0 +1,87 @@
|
||||
// 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 { DirEntry } from '@/types/dir-entry';
|
||||
|
||||
const invokeMock = vi.fn<(command: string, payload: unknown) => Promise<string | null>>();
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({
|
||||
invoke: invokeMock,
|
||||
}));
|
||||
|
||||
function createEntry(overrides: Partial<DirEntry> = {}): DirEntry {
|
||||
return {
|
||||
name: 'readme.txt',
|
||||
ext: 'txt',
|
||||
path: 'C:/docs/readme.txt',
|
||||
size: 0,
|
||||
item_count: null,
|
||||
modified_time: 0,
|
||||
created_time: 0,
|
||||
accessed_time: 0,
|
||||
is_dir: false,
|
||||
is_file: true,
|
||||
is_hidden: false,
|
||||
is_symlink: false,
|
||||
mime: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('system icon cache', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
invokeMock.mockReset();
|
||||
});
|
||||
|
||||
it('deduplicates generic file icons by extension', async () => {
|
||||
const { getSystemIconCacheKey } = await import('@/composables/system-icon-cache');
|
||||
const firstKey = getSystemIconCacheKey({
|
||||
path: 'C:/docs/readme.txt',
|
||||
isDir: false,
|
||||
extension: 'txt',
|
||||
size: 32,
|
||||
});
|
||||
const secondKey = getSystemIconCacheKey({
|
||||
path: 'C:/docs/notes.txt',
|
||||
isDir: false,
|
||||
extension: 'txt',
|
||||
size: 32,
|
||||
});
|
||||
|
||||
expect(firstKey).toBe('ext:txt:32');
|
||||
expect(secondKey).toBe(firstKey);
|
||||
});
|
||||
|
||||
it('prefetches unique cache keys only once per extension', async () => {
|
||||
invokeMock.mockResolvedValue('txt-icon');
|
||||
|
||||
const {
|
||||
fetchSystemIconCached,
|
||||
prefetchSystemIconsForEntries,
|
||||
} = await import('@/composables/system-icon-cache');
|
||||
|
||||
prefetchSystemIconsForEntries([
|
||||
createEntry({ path: 'C:/docs/readme.txt' }),
|
||||
createEntry({
|
||||
path: 'C:/docs/notes.txt',
|
||||
name: 'notes.txt',
|
||||
}),
|
||||
], [32]);
|
||||
|
||||
await fetchSystemIconCached({
|
||||
path: 'C:/docs/readme.txt',
|
||||
isDir: false,
|
||||
extension: 'txt',
|
||||
size: 32,
|
||||
});
|
||||
await Promise.resolve();
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
|
||||
expect(invokeMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -5,8 +5,8 @@
|
||||
import {
|
||||
beforeEach, describe, expect, it, vi,
|
||||
} from 'vitest';
|
||||
import { effectScope, nextTick, ref } from 'vue';
|
||||
import type { LoadedIconThemeDefinition } from '@/types/icon-theme';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { effectScope, ref } from 'vue';
|
||||
import { BUILTIN_NAVIGATOR_ICON_THEME_IDS } from '@/types/icon-theme';
|
||||
|
||||
const {
|
||||
@@ -16,6 +16,7 @@ const {
|
||||
} = vi.hoisted(() => ({
|
||||
extensionsStoreMock: {
|
||||
enabledExtensions: [],
|
||||
enabledIconThemeContributorsSignature: '',
|
||||
},
|
||||
loadInstalledIconThemeMock: vi.fn(),
|
||||
userSettingsStoreMock: {
|
||||
@@ -53,6 +54,7 @@ vi.mock('@/modules/icon-theme/extension-icon-themes', async (importOriginal) =>
|
||||
|
||||
describe('useNavigatorItemIcon', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
loadInstalledIconThemeMock.mockReset();
|
||||
userSettingsStoreMock.userSettings.navigator.folderIconTheme = BUILTIN_NAVIGATOR_ICON_THEME_IDS.default;
|
||||
userSettingsStoreMock.userSettings.navigator.fileIconTheme = BUILTIN_NAVIGATOR_ICON_THEME_IDS.default;
|
||||
@@ -150,91 +152,27 @@ describe('useNavigatorItemIcon', () => {
|
||||
fileScope.stop();
|
||||
});
|
||||
|
||||
it('ignores stale extension theme loads after a newer load starts', async () => {
|
||||
userSettingsStoreMock.userSettings.navigator.folderIconTheme = 'extension:acme.icons:shared';
|
||||
userSettingsStoreMock.userSettings.navigator.fileIconTheme = 'extension:acme.icons:shared';
|
||||
|
||||
const staleThemeLoad = createDeferredThemeLoad();
|
||||
const currentThemeLoad = createDeferredThemeLoad();
|
||||
|
||||
loadInstalledIconThemeMock
|
||||
.mockImplementationOnce(() => staleThemeLoad.promise)
|
||||
.mockImplementationOnce(() => currentThemeLoad.promise);
|
||||
it('falls back to system icons when the selected extension theme is unavailable', async () => {
|
||||
userSettingsStoreMock.userSettings.navigator.fileIconTheme = 'extension:acme.icons:missing';
|
||||
loadInstalledIconThemeMock.mockResolvedValue(null);
|
||||
|
||||
const { useNavigatorItemIcon } = await import('@/composables/use-navigator-item-icon');
|
||||
const iconScope = effectScope();
|
||||
const iconResolutionEnabled = ref(true);
|
||||
const iconResult = iconScope.run(() => useNavigatorItemIcon({
|
||||
const fileScope = effectScope();
|
||||
const fileResult = fileScope.run(() => useNavigatorItemIcon({
|
||||
path: ref('C:/Projects/app.ts'),
|
||||
name: ref('app.ts'),
|
||||
isDir: ref(false),
|
||||
extension: ref('ts'),
|
||||
size: ref(32),
|
||||
enabled: iconResolutionEnabled,
|
||||
}));
|
||||
|
||||
await waitForLoadCount(1);
|
||||
await waitForIcon(() => fileResult?.iconSrc.value, 'system-icon');
|
||||
expect(fileResult?.iconSrc.value).toBe('system-icon');
|
||||
|
||||
iconResolutionEnabled.value = false;
|
||||
await nextTick();
|
||||
iconResolutionEnabled.value = true;
|
||||
await nextTick();
|
||||
await waitForLoadCount(2);
|
||||
|
||||
currentThemeLoad.resolveTheme(createLoadedTheme('current'));
|
||||
await waitForIcon(() => iconResult?.iconSrc.value, 'current:file');
|
||||
|
||||
staleThemeLoad.resolveTheme(createLoadedTheme('stale'));
|
||||
await Promise.resolve();
|
||||
await new Promise(resolveTimer => setTimeout(resolveTimer, 0));
|
||||
|
||||
expect(iconResult?.iconSrc.value).toBe('current:file');
|
||||
|
||||
iconScope.stop();
|
||||
fileScope.stop();
|
||||
});
|
||||
});
|
||||
|
||||
function createLoadedTheme(iconPrefix: string): LoadedIconThemeDefinition {
|
||||
return {
|
||||
iconDefinitions: {
|
||||
file: {
|
||||
src: `${iconPrefix}:file`,
|
||||
},
|
||||
folder: {
|
||||
src: `${iconPrefix}:folder`,
|
||||
},
|
||||
},
|
||||
file: 'file',
|
||||
folder: 'folder',
|
||||
};
|
||||
}
|
||||
|
||||
function createDeferredThemeLoad() {
|
||||
let resolveTheme: (theme: LoadedIconThemeDefinition) => void = () => {};
|
||||
|
||||
const promise = new Promise<LoadedIconThemeDefinition>((resolvePromise) => {
|
||||
resolveTheme = resolvePromise;
|
||||
});
|
||||
|
||||
return {
|
||||
promise,
|
||||
resolveTheme,
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForLoadCount(expectedCallCount: number) {
|
||||
for (let attemptIndex = 0; attemptIndex < 20; attemptIndex += 1) {
|
||||
if (loadInstalledIconThemeMock.mock.calls.length >= expectedCallCount) {
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.resolve();
|
||||
await new Promise(resolveTimer => setTimeout(resolveTimer, 0));
|
||||
}
|
||||
|
||||
expect(loadInstalledIconThemeMock.mock.calls.length).toBeGreaterThanOrEqual(expectedCallCount);
|
||||
}
|
||||
|
||||
async function waitForIcon(readValue: () => string | null | undefined, expectedValue: string) {
|
||||
for (let attemptIndex = 0; attemptIndex < 20; attemptIndex += 1) {
|
||||
if (readValue() === expectedValue) {
|
||||
|
||||
@@ -98,6 +98,37 @@ describe('useSystemIcon', () => {
|
||||
firstScope.stop();
|
||||
secondScope.stop();
|
||||
});
|
||||
|
||||
it('reuses the cached icon for different file paths with the same extension', async () => {
|
||||
invokeMock.mockResolvedValue('txt-icon');
|
||||
|
||||
const { useSystemIcon } = await import('@/composables/use-system-icon');
|
||||
|
||||
const firstScope = effectScope();
|
||||
const firstResult = firstScope.run(() => useSystemIcon({
|
||||
path: ref('C:/docs/readme.txt'),
|
||||
isDir: ref(false),
|
||||
extension: ref('txt'),
|
||||
size: ref(32),
|
||||
}));
|
||||
|
||||
await waitForSystemIcon(() => firstResult?.systemIconSrc.value, 'txt-icon');
|
||||
|
||||
const secondScope = effectScope();
|
||||
const secondResult = secondScope.run(() => useSystemIcon({
|
||||
path: ref('C:/docs/notes.txt'),
|
||||
isDir: ref(false),
|
||||
extension: ref('txt'),
|
||||
size: ref(32),
|
||||
}));
|
||||
|
||||
await waitForSystemIcon(() => secondResult?.systemIconSrc.value, 'txt-icon');
|
||||
|
||||
expect(invokeMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
firstScope.stop();
|
||||
secondScope.stop();
|
||||
});
|
||||
});
|
||||
|
||||
async function waitForSystemIcon(readValue: () => string | null | undefined, expectedValue: string) {
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
// 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 { invoke } from '@tauri-apps/api/core';
|
||||
import type { DirEntry } from '@/types/dir-entry';
|
||||
|
||||
const UNIQUE_ICON_EXTENSIONS = new Set([
|
||||
'exe',
|
||||
'dll',
|
||||
'ico',
|
||||
'lnk',
|
||||
'scr',
|
||||
'cpl',
|
||||
'msi',
|
||||
'appx',
|
||||
'msix',
|
||||
'desktop',
|
||||
'appimage',
|
||||
'icns',
|
||||
]);
|
||||
|
||||
export const NAVIGATOR_ICON_PREFETCH_SIZES = [18, 24, 48] as const;
|
||||
|
||||
const systemIconCache = new Map<string, string | null>();
|
||||
const systemIconInFlight = new Map<string, Promise<string | null>>();
|
||||
|
||||
export interface SystemIconRequest {
|
||||
path: string;
|
||||
isDir: boolean;
|
||||
extension: string | null;
|
||||
size: number;
|
||||
}
|
||||
|
||||
function isSystemIconRequestPath(path: string): boolean {
|
||||
const trimmed = path.trim();
|
||||
|
||||
if (!trimmed || trimmed.includes('://')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const lower = trimmed.toLowerCase();
|
||||
|
||||
if (trimmed.startsWith('::{') || lower.startsWith('shell:')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getSystemIconCacheKey(request: SystemIconRequest): string {
|
||||
const iconSize = Math.max(8, Math.min(256, Math.round(request.size)));
|
||||
|
||||
if (request.isDir) {
|
||||
return `dir:${request.path.toLowerCase()}:${iconSize}`;
|
||||
}
|
||||
|
||||
const normalizedExtension = request.extension?.toLowerCase() ?? '';
|
||||
|
||||
if (normalizedExtension && UNIQUE_ICON_EXTENSIONS.has(normalizedExtension)) {
|
||||
return `path:${request.path.toLowerCase()}:${iconSize}`;
|
||||
}
|
||||
|
||||
return `ext:${normalizedExtension}:${iconSize}`;
|
||||
}
|
||||
|
||||
export async function fetchSystemIconCached(request: SystemIconRequest): Promise<string | null> {
|
||||
const iconSize = Math.max(8, Math.min(256, Math.round(request.size)));
|
||||
const requestCacheKey = getSystemIconCacheKey({
|
||||
...request,
|
||||
size: iconSize,
|
||||
});
|
||||
|
||||
if (!isSystemIconRequestPath(request.path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cached = systemIconCache.get(requestCacheKey);
|
||||
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const existingRequest = systemIconInFlight.get(requestCacheKey);
|
||||
|
||||
if (existingRequest) {
|
||||
return await existingRequest;
|
||||
}
|
||||
|
||||
const requestPromise = invoke<string | null>('get_system_icon', {
|
||||
path: request.path,
|
||||
isDir: request.isDir,
|
||||
extension: request.extension,
|
||||
size: iconSize,
|
||||
})
|
||||
.then((result) => {
|
||||
systemIconCache.set(requestCacheKey, result);
|
||||
return result;
|
||||
})
|
||||
.catch(() => {
|
||||
systemIconCache.set(requestCacheKey, null);
|
||||
return null;
|
||||
})
|
||||
.finally(() => {
|
||||
systemIconInFlight.delete(requestCacheKey);
|
||||
});
|
||||
|
||||
systemIconInFlight.set(requestCacheKey, requestPromise);
|
||||
return await requestPromise;
|
||||
}
|
||||
|
||||
export function prefetchSystemIconsForEntries(
|
||||
entries: DirEntry[],
|
||||
sizes: readonly number[] = NAVIGATOR_ICON_PREFETCH_SIZES,
|
||||
): void {
|
||||
const pendingRequests = new Map<string, SystemIconRequest>();
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.drive_metadata) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const size of sizes) {
|
||||
const request: SystemIconRequest = {
|
||||
path: entry.path,
|
||||
isDir: entry.is_dir,
|
||||
extension: entry.ext,
|
||||
size,
|
||||
};
|
||||
const cacheKey = getSystemIconCacheKey(request);
|
||||
|
||||
if (systemIconCache.has(cacheKey) || systemIconInFlight.has(cacheKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
pendingRequests.set(cacheKey, request);
|
||||
}
|
||||
}
|
||||
|
||||
for (const request of pendingRequests.values()) {
|
||||
void fetchSystemIconCached(request);
|
||||
}
|
||||
}
|
||||
@@ -516,6 +516,7 @@ export function useDirEntryActions() {
|
||||
if (entries.length === 1) {
|
||||
void disconnectDriveEntry(entries[0]);
|
||||
}
|
||||
|
||||
break;
|
||||
case 'rename':
|
||||
case 'open-with':
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
// 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 { computed, ref, watchEffect, type Ref } from 'vue';
|
||||
import { computed, type Ref } from 'vue';
|
||||
import type { Component } from 'vue';
|
||||
import { HardDriveIcon } from '@lucide/vue';
|
||||
import { useUserSettingsStore } from '@/stores/storage/user-settings';
|
||||
import { useExtensionsStore } from '@/stores/runtime/extensions';
|
||||
import { useNavigatorIconsStore } from '@/stores/runtime/navigator-icons';
|
||||
import { useSystemIcon } from './use-system-icon';
|
||||
import { getDefaultFileIconComponent } from '@/modules/navigator/components/file-browser/utils';
|
||||
import {
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
normalizeNavigatorIconThemeId,
|
||||
parseNavigatorIconThemeId,
|
||||
} from '@/types/icon-theme';
|
||||
import { loadInstalledIconTheme } from '@/modules/icon-theme/extension-icon-themes';
|
||||
import { resolveLoadedIconThemeIcon } from '@/modules/icon-theme/resolver';
|
||||
import { isVirtualLocationPath } from '@/utils/virtual-locations';
|
||||
import type { DriveEntryMetadata } from '@/types/drive-info';
|
||||
@@ -46,8 +45,7 @@ function getPathSegments(path: string): string[] {
|
||||
|
||||
export function useNavigatorItemIcon(params: NavigatorItemIconParams) {
|
||||
const userSettingsStore = useUserSettingsStore();
|
||||
const extensionsStore = useExtensionsStore();
|
||||
const loadedExtensionTheme = ref<Awaited<ReturnType<typeof loadInstalledIconTheme>>>(null);
|
||||
const navigatorIconsStore = useNavigatorIconsStore();
|
||||
|
||||
const path = computed(() => readMaybeRef(params.path, ''));
|
||||
const name = computed(() => {
|
||||
@@ -90,36 +88,35 @@ export function useNavigatorItemIcon(params: NavigatorItemIconParams) {
|
||||
return parsedTheme.value?.kind === 'builtin'
|
||||
&& parsedTheme.value.themeId === BUILTIN_NAVIGATOR_ICON_THEME_IDS.system;
|
||||
});
|
||||
const extensionThemeFailed = computed(() => {
|
||||
if (parsedTheme.value?.kind !== 'extension') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return navigatorIconsStore.isExtensionThemeSettled(selectedIconTheme.value)
|
||||
&& navigatorIconsStore.getLoadedExtensionTheme(selectedIconTheme.value) === null;
|
||||
});
|
||||
|
||||
const loadedExtensionTheme = computed(() => {
|
||||
if (parsedTheme.value?.kind !== 'extension') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const loadedTheme = navigatorIconsStore.getLoadedExtensionTheme(selectedIconTheme.value);
|
||||
|
||||
if (loadedTheme === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return loadedTheme;
|
||||
});
|
||||
|
||||
const { systemIconSrc } = useSystemIcon({
|
||||
path,
|
||||
isDir,
|
||||
extension,
|
||||
size,
|
||||
enabled: useSystemIcons,
|
||||
});
|
||||
|
||||
watchEffect(async (onCleanup) => {
|
||||
let isCurrentLoad = true;
|
||||
|
||||
onCleanup(() => {
|
||||
isCurrentLoad = false;
|
||||
});
|
||||
|
||||
if (parsedTheme.value?.kind !== 'extension') {
|
||||
loadedExtensionTheme.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const requestThemeId = selectedIconTheme.value;
|
||||
const loadedTheme = await loadInstalledIconTheme(
|
||||
extensionsStore.enabledExtensions,
|
||||
requestThemeId,
|
||||
);
|
||||
|
||||
if (isCurrentLoad && selectedIconTheme.value === requestThemeId) {
|
||||
loadedExtensionTheme.value = loadedTheme;
|
||||
}
|
||||
enabled: computed(() => useSystemIcons.value || extensionThemeFailed.value),
|
||||
});
|
||||
|
||||
const extensionThemeIconSrc = computed(() => {
|
||||
@@ -140,7 +137,7 @@ export function useNavigatorItemIcon(params: NavigatorItemIconParams) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (useSystemIcons.value) {
|
||||
if (useSystemIcons.value || extensionThemeFailed.value) {
|
||||
return systemIconSrc.value;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,26 +3,7 @@
|
||||
// Copyright © 2021 - present Aleksey Hoffman. All rights reserved.
|
||||
|
||||
import { computed, ref, watchEffect, type Ref } from 'vue';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
const systemIconCache = new Map<string, string | null>();
|
||||
const systemIconInFlight = new Map<string, Promise<string | null>>();
|
||||
|
||||
function isSystemIconRequestPath(path: string): boolean {
|
||||
const trimmed = path.trim();
|
||||
|
||||
if (!trimmed || trimmed.includes('://')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const lower = trimmed.toLowerCase();
|
||||
|
||||
if (trimmed.startsWith('::{') || lower.startsWith('shell:')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
import { fetchSystemIconCached } from './system-icon-cache';
|
||||
|
||||
interface SystemIconParams {
|
||||
path: Ref<string> | (() => string);
|
||||
@@ -47,65 +28,38 @@ export function useSystemIcon(params: SystemIconParams) {
|
||||
: params.enabled.value;
|
||||
});
|
||||
|
||||
const cacheKey = computed(() => {
|
||||
const entryType = isDir.value ? 'dir' : 'file';
|
||||
return `${entryType}:${path.value.toLowerCase()}:${size.value}`;
|
||||
});
|
||||
|
||||
const systemIconSrc = ref<string | null>(null);
|
||||
|
||||
async function fetchSystemIcon(): Promise<string | null> {
|
||||
const iconSize = Math.max(8, Math.min(256, Math.round(size.value)));
|
||||
const requestCacheKey = cacheKey.value;
|
||||
const requestPath = path.value;
|
||||
const requestIsDir = isDir.value;
|
||||
const requestExtension = extension.value;
|
||||
watchEffect(async (onCleanup) => {
|
||||
let isCurrentRequest = true;
|
||||
|
||||
if (!isSystemIconRequestPath(requestPath)) {
|
||||
return null;
|
||||
}
|
||||
onCleanup(() => {
|
||||
isCurrentRequest = false;
|
||||
});
|
||||
|
||||
const cached = systemIconCache.get(requestCacheKey);
|
||||
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const existingRequest = systemIconInFlight.get(requestCacheKey);
|
||||
|
||||
if (existingRequest) {
|
||||
return await existingRequest;
|
||||
}
|
||||
|
||||
const requestPromise = invoke<string | null>('get_system_icon', {
|
||||
path: requestPath,
|
||||
isDir: requestIsDir,
|
||||
extension: requestExtension,
|
||||
size: iconSize,
|
||||
})
|
||||
.then((result) => {
|
||||
systemIconCache.set(requestCacheKey, result);
|
||||
return result;
|
||||
})
|
||||
.catch(() => {
|
||||
systemIconCache.set(requestCacheKey, null);
|
||||
return null;
|
||||
})
|
||||
.finally(() => {
|
||||
systemIconInFlight.delete(requestCacheKey);
|
||||
});
|
||||
|
||||
systemIconInFlight.set(requestCacheKey, requestPromise);
|
||||
return await requestPromise;
|
||||
}
|
||||
|
||||
watchEffect(async () => {
|
||||
if (!useSystemIcons.value) {
|
||||
systemIconSrc.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
systemIconSrc.value = await fetchSystemIcon();
|
||||
const requestPath = path.value;
|
||||
const requestIsDir = isDir.value;
|
||||
const requestExtension = extension.value;
|
||||
const requestSize = size.value;
|
||||
const iconSrc = await fetchSystemIconCached({
|
||||
path: requestPath,
|
||||
isDir: requestIsDir,
|
||||
extension: requestExtension,
|
||||
size: requestSize,
|
||||
});
|
||||
|
||||
if (isCurrentRequest
|
||||
&& requestPath === path.value
|
||||
&& requestIsDir === isDir.value
|
||||
&& requestExtension === extension.value
|
||||
&& requestSize === size.value) {
|
||||
systemIconSrc.value = iconSrc;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import {
|
||||
clearInstalledIconThemeCache,
|
||||
getExtensionNavigatorIconThemeOptions,
|
||||
iconThemeReferencesExtension,
|
||||
loadInstalledIconTheme,
|
||||
resolveThemeRelativePath,
|
||||
} from '@/modules/icon-theme/extension-icon-themes';
|
||||
@@ -88,6 +89,14 @@ describe('extension icon theme asset path resolution', () => {
|
||||
expect(options[0].label).toBe('Example Dark');
|
||||
});
|
||||
|
||||
it('detects icon theme settings that reference an extension', () => {
|
||||
const extension = createInstalledExtension();
|
||||
const iconThemeId = createExtensionNavigatorIconThemeId(extension.id, 'example-dark');
|
||||
|
||||
expect(iconThemeReferencesExtension(iconThemeId, extension.id)).toBe(true);
|
||||
expect(iconThemeReferencesExtension(iconThemeId, 'other.extension')).toBe(false);
|
||||
});
|
||||
|
||||
it('reloads icon themes after clearing the cache', async () => {
|
||||
const encoder = new TextEncoder();
|
||||
const extension = createInstalledExtension();
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// 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 { BUILTIN_NAVIGATOR_ICON_THEME_IDS } from '@/types/icon-theme';
|
||||
|
||||
const { userSettingsStoreMock } = vi.hoisted(() => ({
|
||||
userSettingsStoreMock: {
|
||||
userSettings: {
|
||||
navigator: {
|
||||
folderIconTheme: 'extension:acme.icons:folders',
|
||||
fileIconTheme: 'extension:other.icons:files',
|
||||
},
|
||||
},
|
||||
set: vi.fn(async () => {}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/storage/user-settings', () => ({
|
||||
useUserSettingsStore: () => userSettingsStoreMock,
|
||||
}));
|
||||
|
||||
describe('resetNavigatorIconThemesForExtension', () => {
|
||||
beforeEach(() => {
|
||||
userSettingsStoreMock.userSettings.navigator.folderIconTheme = 'extension:acme.icons:folders';
|
||||
userSettingsStoreMock.userSettings.navigator.fileIconTheme = 'extension:other.icons:files';
|
||||
userSettingsStoreMock.set.mockClear();
|
||||
});
|
||||
|
||||
it('resets only icon themes that reference the removed extension', async () => {
|
||||
const { resetNavigatorIconThemesForExtension } = await import('@/modules/icon-theme/navigator-icon-theme-settings');
|
||||
|
||||
await resetNavigatorIconThemesForExtension('acme.icons');
|
||||
|
||||
expect(userSettingsStoreMock.set).toHaveBeenCalledExactlyOnceWith(
|
||||
'navigator.folderIconTheme',
|
||||
BUILTIN_NAVIGATOR_ICON_THEME_IDS.system,
|
||||
);
|
||||
expect(userSettingsStoreMock.userSettings.navigator.fileIconTheme).toBe('extension:other.icons:files');
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,11 @@ import { invokeAsExtension } from '@/modules/extensions/runtime/extension-invoke
|
||||
|
||||
const iconThemeCache = new Map<string, Promise<LoadedIconThemeDefinition | null>>();
|
||||
|
||||
type IconDefinitionEntry = {
|
||||
definitionId: string;
|
||||
iconPath: string;
|
||||
};
|
||||
|
||||
export function clearInstalledIconThemeCache(): void {
|
||||
iconThemeCache.clear();
|
||||
}
|
||||
@@ -106,10 +111,13 @@ async function loadContributedIconTheme(
|
||||
? rawTheme.iconDefinitions
|
||||
: {};
|
||||
const iconDefinitions: LoadedIconThemeDefinition['iconDefinitions'] = {};
|
||||
const definitionEntries: IconDefinitionEntry[] = [];
|
||||
const extensionPath = await invokeAsExtension<string>(
|
||||
extension.id,
|
||||
'get_extension_path',
|
||||
{ extensionId: extension.id },
|
||||
{
|
||||
extensionId: extension.id,
|
||||
},
|
||||
);
|
||||
|
||||
for (const [definitionId, definitionValue] of Object.entries(rawDefinitions)) {
|
||||
@@ -117,28 +125,49 @@ async function loadContributedIconTheme(
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const relativeAssetPath = resolveThemeRelativePath(themeFilePath, definitionValue.iconPath);
|
||||
const assetExists = await invokeAsExtension<boolean>(
|
||||
extension.id,
|
||||
'extension_path_exists',
|
||||
{
|
||||
extensionId: extension.id,
|
||||
filePath: relativeAssetPath,
|
||||
},
|
||||
);
|
||||
definitionEntries.push({
|
||||
definitionId,
|
||||
iconPath: definitionValue.iconPath,
|
||||
});
|
||||
}
|
||||
|
||||
if (!assetExists) {
|
||||
continue;
|
||||
const resolvedDefinitions = await Promise.all(
|
||||
definitionEntries.map(async ({ definitionId, iconPath }) => {
|
||||
try {
|
||||
const relativeAssetPath = resolveThemeRelativePath(themeFilePath, iconPath);
|
||||
const assetExists = await invokeAsExtension<boolean>(
|
||||
extension.id,
|
||||
'extension_path_exists',
|
||||
{
|
||||
extensionId: extension.id,
|
||||
filePath: relativeAssetPath,
|
||||
},
|
||||
);
|
||||
|
||||
if (!assetExists) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const assetPath = await join(extensionPath, relativeAssetPath);
|
||||
return {
|
||||
definitionId,
|
||||
src: convertFileSrc(assetPath),
|
||||
};
|
||||
}
|
||||
catch {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const assetPath = await join(extensionPath, relativeAssetPath);
|
||||
iconDefinitions[definitionId] = {
|
||||
src: convertFileSrc(assetPath),
|
||||
};
|
||||
}
|
||||
catch {
|
||||
for (const resolvedDefinition of resolvedDefinitions) {
|
||||
if (!resolvedDefinition) {
|
||||
continue;
|
||||
}
|
||||
|
||||
iconDefinitions[resolvedDefinition.definitionId] = {
|
||||
src: resolvedDefinition.src,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAssociationRecord(value: unknown): Record<string, string> | undefined {
|
||||
@@ -241,6 +270,15 @@ export function findInstalledIconThemeContribution(
|
||||
};
|
||||
}
|
||||
|
||||
export function iconThemeReferencesExtension(
|
||||
iconThemeId: string | null | undefined,
|
||||
extensionId: string,
|
||||
): boolean {
|
||||
const parsed = parseNavigatorIconThemeId(iconThemeId);
|
||||
|
||||
return parsed?.kind === 'extension' && parsed.extensionId === extensionId;
|
||||
}
|
||||
|
||||
export async function loadInstalledIconTheme(
|
||||
extensions: InstalledExtension[],
|
||||
iconThemeId: string | null | undefined,
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
// 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 { useUserSettingsStore } from '@/stores/storage/user-settings';
|
||||
import { BUILTIN_NAVIGATOR_ICON_THEME_IDS } from '@/types/icon-theme';
|
||||
import { iconThemeReferencesExtension } from './extension-icon-themes';
|
||||
|
||||
export async function resetNavigatorIconThemesForExtension(extensionId: string): Promise<void> {
|
||||
const userSettingsStore = useUserSettingsStore();
|
||||
const resetTasks: Array<Promise<void>> = [];
|
||||
|
||||
if (iconThemeReferencesExtension(userSettingsStore.userSettings.navigator.folderIconTheme, extensionId)) {
|
||||
resetTasks.push(userSettingsStore.set('navigator.folderIconTheme', BUILTIN_NAVIGATOR_ICON_THEME_IDS.system));
|
||||
}
|
||||
|
||||
if (iconThemeReferencesExtension(userSettingsStore.userSettings.navigator.fileIconTheme, extensionId)) {
|
||||
resetTasks.push(userSettingsStore.set('navigator.fileIconTheme', BUILTIN_NAVIGATOR_ICON_THEME_IDS.system));
|
||||
}
|
||||
|
||||
await Promise.all(resetTasks);
|
||||
}
|
||||
@@ -38,7 +38,6 @@ import {
|
||||
ChevronRightIcon,
|
||||
} from '@lucide/vue';
|
||||
import { toast, ToastStatic } from '@/components/ui/toaster';
|
||||
import type { DirContents } from '@/types/dir-entry';
|
||||
import { DirEntryInteractive } from '@/components/dir-entry-interactive';
|
||||
import { registerDropContainer, unregisterDropContainer } from '@/composables/use-drop-target-registry';
|
||||
import { useShortcutsStore } from '@/stores/runtime/shortcuts';
|
||||
|
||||
+3
-1
@@ -10,7 +10,9 @@ import {
|
||||
type Ref,
|
||||
} from 'vue';
|
||||
import { mount, type VueWrapper } from '@vue/test-utils';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
afterEach, describe, expect, it, vi,
|
||||
} from 'vitest';
|
||||
import type { DirEntry } from '@/types/dir-entry';
|
||||
import type { FileBrowserVirtualRow } from '../../utils/file-browser-virtual-rows';
|
||||
import { useFileBrowserBoxSelection } from '../use-file-browser-box-selection';
|
||||
|
||||
+11
-5
@@ -19,14 +19,13 @@ import { useWorkspacesStore } from '@/stores/storage/workspaces';
|
||||
import { useUserStatsStore } from '@/stores/storage/user-stats';
|
||||
import { useUserSettingsStore } from '@/stores/storage/user-settings';
|
||||
import { useDirSizesStore } from '@/stores/runtime/dir-sizes';
|
||||
import { useNavigatorIconsStore } from '@/stores/runtime/navigator-icons';
|
||||
import { useLinkMetadataStore } from '@/stores/runtime/link-metadata';
|
||||
import { useItemCountsStore } from '@/stores/runtime/item-counts';
|
||||
import { useStatusCenterStore } from '@/stores/runtime/status-center';
|
||||
import { usePlatformStore } from '@/stores/runtime/platform';
|
||||
import { DIR_SIZE_CONSTANTS } from '@/constants';
|
||||
import normalizePath, {
|
||||
getPathDisplayName,
|
||||
} from '@/utils/normalize-path';
|
||||
import normalizePath, { getPathDisplayName } from '@/utils/normalize-path';
|
||||
import { resolveNavigableItemTarget } from '@/utils/resolve-navigable-item-target';
|
||||
import {
|
||||
buildVirtualDirectoryFromDrives,
|
||||
@@ -80,6 +79,7 @@ export function useFileBrowserNavigation(
|
||||
const userStatsStore = useUserStatsStore();
|
||||
const userSettingsStore = useUserSettingsStore();
|
||||
const dirSizesStore = useDirSizesStore();
|
||||
const navigatorIconsStore = useNavigatorIconsStore();
|
||||
const linkMetadataStore = useLinkMetadataStore();
|
||||
const itemCountsStore = useItemCountsStore();
|
||||
const statusCenterStore = useStatusCenterStore();
|
||||
@@ -290,6 +290,12 @@ export function useFileBrowserNavigation(
|
||||
const canGoBack = computed(() => historyIndex.value > 0);
|
||||
const canGoForward = computed(() => historyIndex.value < history.value.length - 1);
|
||||
|
||||
async function loadDirectoryWithIconPrefetch(path: string, options?: ReadDirOptions): Promise<DirContents> {
|
||||
const directoryContents = await loadDirectoryContents(path, options ?? createReadDirOptions());
|
||||
navigatorIconsStore.prefetchForDirectoryEntries(directoryContents.entries);
|
||||
return directoryContents;
|
||||
}
|
||||
|
||||
async function silentRefresh(): Promise<void> {
|
||||
const refreshPath = currentPath.value;
|
||||
|
||||
@@ -303,7 +309,7 @@ export function useFileBrowserNavigation(
|
||||
const virtualDirectory = buildVirtualDirectoryFromDrives(refreshPath, sharedDrives.value);
|
||||
const result = virtualDirectory
|
||||
? virtualDirectory
|
||||
: await loadDirectoryContents(refreshPath, createReadDirOptions());
|
||||
: await loadDirectoryWithIconPrefetch(refreshPath, createReadDirOptions());
|
||||
|
||||
if (currentPath.value !== refreshPath) {
|
||||
return;
|
||||
@@ -417,7 +423,7 @@ export function useFileBrowserNavigation(
|
||||
|
||||
try {
|
||||
logDirWatcherDiag('readDir loading directory', { path: normalizedPath });
|
||||
const result = await loadDirectoryContents(path, createReadDirOptions());
|
||||
const result = await loadDirectoryWithIconPrefetch(path, createReadDirOptions());
|
||||
|
||||
dirContents.value = result;
|
||||
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
// 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 { createPinia, setActivePinia } from 'pinia';
|
||||
import type { LoadedIconThemeDefinition } from '@/types/icon-theme';
|
||||
import type { DirEntry } from '@/types/dir-entry';
|
||||
|
||||
const {
|
||||
extensionsStoreMock,
|
||||
loadInstalledIconThemeMock,
|
||||
prefetchSystemIconsForEntriesMock,
|
||||
userSettingsStoreMock,
|
||||
} = vi.hoisted(() => ({
|
||||
extensionsStoreMock: {
|
||||
enabledExtensions: [],
|
||||
enabledIconThemeContributorsSignature: '',
|
||||
},
|
||||
loadInstalledIconThemeMock: vi.fn(),
|
||||
prefetchSystemIconsForEntriesMock: vi.fn(),
|
||||
userSettingsStoreMock: {
|
||||
userSettings: {
|
||||
navigator: {
|
||||
folderIconTheme: 'builtin:system',
|
||||
fileIconTheme: 'builtin:system',
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/storage/user-settings', () => ({
|
||||
useUserSettingsStore: () => userSettingsStoreMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/runtime/extensions', () => ({
|
||||
useExtensionsStore: () => extensionsStoreMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/composables/system-icon-cache', () => ({
|
||||
prefetchSystemIconsForEntries: prefetchSystemIconsForEntriesMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/modules/icon-theme/extension-icon-themes', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('@/modules/icon-theme/extension-icon-themes')>();
|
||||
|
||||
return {
|
||||
...original,
|
||||
loadInstalledIconTheme: loadInstalledIconThemeMock,
|
||||
};
|
||||
});
|
||||
|
||||
describe('useNavigatorIconsStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
loadInstalledIconThemeMock.mockReset();
|
||||
prefetchSystemIconsForEntriesMock.mockReset();
|
||||
userSettingsStoreMock.userSettings.navigator.folderIconTheme = 'builtin:system';
|
||||
userSettingsStoreMock.userSettings.navigator.fileIconTheme = 'builtin:system';
|
||||
});
|
||||
|
||||
it('deduplicates concurrent loads for the same extension theme', async () => {
|
||||
loadInstalledIconThemeMock.mockImplementation(async () => createLoadedTheme('shared'));
|
||||
|
||||
const { useNavigatorIconsStore } = await import('@/stores/runtime/navigator-icons');
|
||||
const store = useNavigatorIconsStore();
|
||||
const themeId = 'extension:acme.icons:shared';
|
||||
|
||||
await Promise.all([
|
||||
store.ensureExtensionThemeLoaded(themeId),
|
||||
store.ensureExtensionThemeLoaded(themeId),
|
||||
]);
|
||||
|
||||
expect(loadInstalledIconThemeMock).toHaveBeenCalledTimes(1);
|
||||
expect(store.getLoadedExtensionTheme(themeId)?.iconDefinitions.file.src).toBe('shared:file');
|
||||
});
|
||||
|
||||
it('does not let stale loads write after invalidation', async () => {
|
||||
const staleThemeLoad = createDeferredThemeLoad();
|
||||
const currentThemeLoad = createDeferredThemeLoad();
|
||||
loadInstalledIconThemeMock
|
||||
.mockImplementationOnce(() => staleThemeLoad.promise)
|
||||
.mockImplementationOnce(() => currentThemeLoad.promise);
|
||||
|
||||
const { useNavigatorIconsStore } = await import('@/stores/runtime/navigator-icons');
|
||||
const store = useNavigatorIconsStore();
|
||||
const themeId = 'extension:acme.icons:shared';
|
||||
const staleLoad = store.ensureExtensionThemeLoaded(themeId);
|
||||
|
||||
await waitForLoadCount(1);
|
||||
store.clearExtensionThemes();
|
||||
const currentLoad = store.ensureExtensionThemeLoaded(themeId);
|
||||
await waitForLoadCount(2);
|
||||
|
||||
currentThemeLoad.resolveTheme(createLoadedTheme('current'));
|
||||
await currentLoad;
|
||||
|
||||
staleThemeLoad.resolveTheme(createLoadedTheme('stale'));
|
||||
await staleLoad;
|
||||
await flushPromises();
|
||||
|
||||
expect(store.getLoadedExtensionTheme(themeId)?.iconDefinitions.file.src).toBe('current:file');
|
||||
});
|
||||
|
||||
it('prefetches system icons immediately only for entries that use system themes', async () => {
|
||||
userSettingsStoreMock.userSettings.navigator.folderIconTheme = 'builtin:default';
|
||||
userSettingsStoreMock.userSettings.navigator.fileIconTheme = 'builtin:system';
|
||||
|
||||
const { useNavigatorIconsStore } = await import('@/stores/runtime/navigator-icons');
|
||||
const store = useNavigatorIconsStore();
|
||||
const fileEntry = createEntry({
|
||||
is_dir: false,
|
||||
is_file: true,
|
||||
path: 'C:/Projects/app.ts',
|
||||
ext: 'ts',
|
||||
});
|
||||
const folderEntry = createEntry({
|
||||
is_dir: true,
|
||||
is_file: false,
|
||||
path: 'C:/Projects/src',
|
||||
ext: null,
|
||||
});
|
||||
|
||||
store.prefetchForDirectoryEntries([fileEntry, folderEntry]);
|
||||
|
||||
expect(prefetchSystemIconsForEntriesMock).toHaveBeenCalledWith([fileEntry]);
|
||||
});
|
||||
|
||||
it('prefetches system icons for extension-theme entries only after the theme fails', async () => {
|
||||
userSettingsStoreMock.userSettings.navigator.folderIconTheme = 'builtin:default';
|
||||
userSettingsStoreMock.userSettings.navigator.fileIconTheme = 'extension:acme.icons:missing';
|
||||
loadInstalledIconThemeMock.mockResolvedValue(null);
|
||||
|
||||
const { useNavigatorIconsStore } = await import('@/stores/runtime/navigator-icons');
|
||||
const store = useNavigatorIconsStore();
|
||||
const fileEntry = createEntry({
|
||||
is_dir: false,
|
||||
is_file: true,
|
||||
path: 'C:/Projects/app.ts',
|
||||
ext: 'ts',
|
||||
});
|
||||
const folderEntry = createEntry({
|
||||
is_dir: true,
|
||||
is_file: false,
|
||||
path: 'C:/Projects/src',
|
||||
ext: null,
|
||||
});
|
||||
|
||||
store.prefetchForDirectoryEntries([fileEntry, folderEntry]);
|
||||
expect(prefetchSystemIconsForEntriesMock).not.toHaveBeenCalled();
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(prefetchSystemIconsForEntriesMock).toHaveBeenCalledWith([fileEntry]);
|
||||
});
|
||||
});
|
||||
|
||||
function createLoadedTheme(iconPrefix: string): LoadedIconThemeDefinition {
|
||||
return {
|
||||
iconDefinitions: {
|
||||
file: {
|
||||
src: `${iconPrefix}:file`,
|
||||
},
|
||||
folder: {
|
||||
src: `${iconPrefix}:folder`,
|
||||
},
|
||||
},
|
||||
file: 'file',
|
||||
folder: 'folder',
|
||||
};
|
||||
}
|
||||
|
||||
function createDeferredThemeLoad() {
|
||||
let resolveTheme: (theme: LoadedIconThemeDefinition) => void = () => {};
|
||||
|
||||
const promise = new Promise<LoadedIconThemeDefinition>((resolvePromise) => {
|
||||
resolveTheme = resolvePromise;
|
||||
});
|
||||
|
||||
return {
|
||||
promise,
|
||||
resolveTheme,
|
||||
};
|
||||
}
|
||||
|
||||
function createEntry(overrides: Partial<DirEntry>): DirEntry {
|
||||
return {
|
||||
name: 'app.ts',
|
||||
ext: 'ts',
|
||||
path: 'C:/Projects/app.ts',
|
||||
size: 0,
|
||||
item_count: null,
|
||||
modified_time: 0,
|
||||
created_time: 0,
|
||||
accessed_time: 0,
|
||||
is_dir: false,
|
||||
is_file: true,
|
||||
is_hidden: false,
|
||||
is_symlink: false,
|
||||
mime: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForLoadCount(expectedCallCount: number) {
|
||||
for (let attemptIndex = 0; attemptIndex < 20; attemptIndex += 1) {
|
||||
if (loadInstalledIconThemeMock.mock.calls.length >= expectedCallCount) {
|
||||
return;
|
||||
}
|
||||
|
||||
await flushPromises();
|
||||
}
|
||||
|
||||
expect(loadInstalledIconThemeMock.mock.calls.length).toBeGreaterThanOrEqual(expectedCallCount);
|
||||
}
|
||||
|
||||
async function flushPromises() {
|
||||
await Promise.resolve();
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
}
|
||||
@@ -100,6 +100,7 @@ import { i18n } from '@/localization';
|
||||
import type { ShortcutKeys } from '@/types/user-settings';
|
||||
import type { ExtensionKeybindingWhen } from '@/types/extension';
|
||||
import { clearInstalledIconThemeCache } from '@/modules/icon-theme/extension-icon-themes';
|
||||
import { resetNavigatorIconThemesForExtension } from '@/modules/icon-theme/navigator-icon-theme-settings';
|
||||
|
||||
export const useExtensionsStore = defineStore('extensions', () => {
|
||||
const storageStore = useExtensionsStorageStore();
|
||||
@@ -1142,6 +1143,11 @@ export const useExtensionsStore = defineStore('extensions', () => {
|
||||
|
||||
await storageStore.removeInstalledExtension(extensionId);
|
||||
clearInstalledIconThemeCache();
|
||||
|
||||
if (installed?.manifest.contributes?.iconThemes?.length) {
|
||||
await resetNavigatorIconThemesForExtension(extensionId);
|
||||
}
|
||||
|
||||
brokenExtensionIds.value = new Set([...brokenExtensionIds.value].filter(id => id !== extensionId));
|
||||
|
||||
filterRecentCommandsToExisting();
|
||||
@@ -2133,6 +2139,7 @@ export const useExtensionsStore = defineStore('extensions', () => {
|
||||
availableExtensions,
|
||||
installedExtensions,
|
||||
enabledExtensions,
|
||||
enabledIconThemeContributorsSignature,
|
||||
featuredExtensions,
|
||||
officialExtensions,
|
||||
isRegistryCacheValid,
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
// 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 { defineStore } from 'pinia';
|
||||
import { ref, watch } from 'vue';
|
||||
import { useUserSettingsStore } from '@/stores/storage/user-settings';
|
||||
import { useExtensionsStore } from '@/stores/runtime/extensions';
|
||||
import { prefetchSystemIconsForEntries } from '@/composables/system-icon-cache';
|
||||
import { loadInstalledIconTheme } from '@/modules/icon-theme/extension-icon-themes';
|
||||
import {
|
||||
BUILTIN_NAVIGATOR_ICON_THEME_IDS,
|
||||
normalizeNavigatorIconThemeId,
|
||||
parseNavigatorIconThemeId,
|
||||
type LoadedIconThemeDefinition,
|
||||
} from '@/types/icon-theme';
|
||||
import type { DirEntry } from '@/types/dir-entry';
|
||||
|
||||
export const useNavigatorIconsStore = defineStore('navigatorIcons', () => {
|
||||
const userSettingsStore = useUserSettingsStore();
|
||||
const extensionsStore = useExtensionsStore();
|
||||
|
||||
const loadedExtensionThemes = ref(new Map<string, LoadedIconThemeDefinition | null>());
|
||||
const settledExtensionThemeIds = ref(new Set<string>());
|
||||
const loadingExtensionThemes = new Map<string, Promise<LoadedIconThemeDefinition | null>>();
|
||||
let loadGeneration = 0;
|
||||
|
||||
function clearExtensionThemes(): void {
|
||||
loadGeneration += 1;
|
||||
loadedExtensionThemes.value = new Map();
|
||||
settledExtensionThemeIds.value = new Set();
|
||||
loadingExtensionThemes.clear();
|
||||
}
|
||||
|
||||
function getLoadedExtensionTheme(iconThemeId: string): LoadedIconThemeDefinition | null | undefined {
|
||||
if (!settledExtensionThemeIds.value.has(iconThemeId)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return loadedExtensionThemes.value.get(iconThemeId) ?? null;
|
||||
}
|
||||
|
||||
function isExtensionThemeSettled(iconThemeId: string): boolean {
|
||||
return settledExtensionThemeIds.value.has(iconThemeId);
|
||||
}
|
||||
|
||||
async function ensureExtensionThemeLoaded(iconThemeId: string): Promise<LoadedIconThemeDefinition | null> {
|
||||
const parsedTheme = parseNavigatorIconThemeId(iconThemeId);
|
||||
|
||||
if (!parsedTheme || parsedTheme.kind !== 'extension') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (settledExtensionThemeIds.value.has(iconThemeId)) {
|
||||
return loadedExtensionThemes.value.get(iconThemeId) ?? null;
|
||||
}
|
||||
|
||||
const existingLoad = loadingExtensionThemes.get(iconThemeId);
|
||||
|
||||
if (existingLoad) {
|
||||
return await existingLoad;
|
||||
}
|
||||
|
||||
const requestGeneration = loadGeneration;
|
||||
const loadPromise = loadInstalledIconTheme(
|
||||
extensionsStore.enabledExtensions,
|
||||
iconThemeId,
|
||||
)
|
||||
.then((loadedTheme) => {
|
||||
if (requestGeneration === loadGeneration && loadingExtensionThemes.get(iconThemeId) === loadPromise) {
|
||||
const nextThemes = new Map(loadedExtensionThemes.value);
|
||||
nextThemes.set(iconThemeId, loadedTheme);
|
||||
loadedExtensionThemes.value = nextThemes;
|
||||
settledExtensionThemeIds.value = new Set([...settledExtensionThemeIds.value, iconThemeId]);
|
||||
}
|
||||
|
||||
return loadedTheme;
|
||||
})
|
||||
.finally(() => {
|
||||
if (loadingExtensionThemes.get(iconThemeId) === loadPromise) {
|
||||
loadingExtensionThemes.delete(iconThemeId);
|
||||
}
|
||||
});
|
||||
|
||||
loadingExtensionThemes.set(iconThemeId, loadPromise);
|
||||
return await loadPromise;
|
||||
}
|
||||
|
||||
function getConfiguredExtensionThemeIds(): string[] {
|
||||
const themeIds = new Set<string>();
|
||||
|
||||
for (const iconThemeId of [
|
||||
userSettingsStore.userSettings.navigator.folderIconTheme,
|
||||
userSettingsStore.userSettings.navigator.fileIconTheme,
|
||||
]) {
|
||||
const parsedTheme = parseNavigatorIconThemeId(iconThemeId);
|
||||
|
||||
if (parsedTheme?.kind === 'extension') {
|
||||
themeIds.add(iconThemeId);
|
||||
}
|
||||
}
|
||||
|
||||
return [...themeIds];
|
||||
}
|
||||
|
||||
async function preloadConfiguredThemes(): Promise<void> {
|
||||
await Promise.all(
|
||||
getConfiguredExtensionThemeIds().map(iconThemeId => ensureExtensionThemeLoaded(iconThemeId)),
|
||||
);
|
||||
}
|
||||
|
||||
function getThemeIdForEntry(entry: DirEntry): string {
|
||||
return normalizeNavigatorIconThemeId(
|
||||
entry.is_dir
|
||||
? userSettingsStore.userSettings.navigator.folderIconTheme
|
||||
: userSettingsStore.userSettings.navigator.fileIconTheme,
|
||||
);
|
||||
}
|
||||
|
||||
function usesSystemIcons(iconThemeId: string): boolean {
|
||||
const parsedTheme = parseNavigatorIconThemeId(iconThemeId);
|
||||
|
||||
return parsedTheme?.kind === 'builtin'
|
||||
&& parsedTheme.themeId === BUILTIN_NAVIGATOR_ICON_THEME_IDS.system;
|
||||
}
|
||||
|
||||
function hasFailedExtensionTheme(iconThemeId: string): boolean {
|
||||
const parsedTheme = parseNavigatorIconThemeId(iconThemeId);
|
||||
|
||||
return parsedTheme?.kind === 'extension'
|
||||
&& settledExtensionThemeIds.value.has(iconThemeId)
|
||||
&& loadedExtensionThemes.value.get(iconThemeId) === null;
|
||||
}
|
||||
|
||||
function prefetchForDirectoryEntries(entries: DirEntry[]): void {
|
||||
const directoryEntries = [...entries];
|
||||
const systemIconEntries = directoryEntries.filter(entry => usesSystemIcons(getThemeIdForEntry(entry)));
|
||||
|
||||
if (systemIconEntries.length > 0) {
|
||||
prefetchSystemIconsForEntries(systemIconEntries);
|
||||
}
|
||||
|
||||
if (getConfiguredExtensionThemeIds().length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
void preloadConfiguredThemes().then(() => {
|
||||
const fallbackEntries = directoryEntries.filter(entry => hasFailedExtensionTheme(getThemeIdForEntry(entry)));
|
||||
|
||||
if (fallbackEntries.length > 0) {
|
||||
prefetchSystemIconsForEntries(fallbackEntries);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [
|
||||
extensionsStore.enabledIconThemeContributorsSignature,
|
||||
userSettingsStore.userSettings.navigator.folderIconTheme,
|
||||
userSettingsStore.userSettings.navigator.fileIconTheme,
|
||||
],
|
||||
() => {
|
||||
clearExtensionThemes();
|
||||
void preloadConfiguredThemes();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
return {
|
||||
getLoadedExtensionTheme,
|
||||
isExtensionThemeSettled,
|
||||
ensureExtensionThemeLoaded,
|
||||
preloadConfiguredThemes,
|
||||
prefetchForDirectoryEntries,
|
||||
clearExtensionThemes,
|
||||
};
|
||||
});
|
||||
@@ -1587,6 +1587,7 @@ export const useShortcutsStore = defineStore('shortcuts', () => {
|
||||
return routeName === 'navigator';
|
||||
case 'settings':
|
||||
return routeName === 'settings';
|
||||
|
||||
default: {
|
||||
const unexpectedScope: never = scope;
|
||||
return unexpectedScope;
|
||||
@@ -1672,16 +1673,6 @@ export const useShortcutsStore = defineStore('shortcuts', () => {
|
||||
return registration.handler();
|
||||
}
|
||||
|
||||
function canCallShortcutHandler(shortcutId: ShortcutId): boolean {
|
||||
const definition = getShortcutDefinition(shortcutId);
|
||||
if (!definition) return false;
|
||||
|
||||
const registration = handlers.value.get(shortcutId);
|
||||
if (!registration) return false;
|
||||
|
||||
return checkConditions(definition, registration);
|
||||
}
|
||||
|
||||
async function executeShortcut(shortcutId: ShortcutId): Promise<boolean> {
|
||||
const result = callShortcutHandler(shortcutId);
|
||||
if (result === null) return false;
|
||||
|
||||
Reference in new issue
Block a user