Fix: custom icons are loading with a noticable delay

This commit is contained in:
aleksey-hoffman committed 2026-07-08 12:53:23 +03:00
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) {
+143
View File
@@ -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);
}
}
+1
View File
@@ -516,6 +516,7 @@ export function useDirEntryActions() {
if (entries.length === 1) {
void disconnectDriveEntry(entries[0]);
}
break;
case 'rename':
case 'open-with':
+27 -30
View File
@@ -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;
}
+24 -70
View File
@@ -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');
});
});
+57 -19
View File
@@ -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';
@@ -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';
@@ -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));
}
+7
View File
@@ -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,
+177
View File
@@ -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,
};
});
+1 -10
View File
@@ -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;