fix(extensions): load data from extension specific endpoint

Originally, we were directly using the data provided by the paginated endoint. This was incorrect, as the endpoint did not provide a list of metadata files. This commit fixes that, as well as updating the store response schema to match.
This commit is contained in:
ByteAtATime 2025-07-06 09:35:56 -07:00
parent c8b4cb58e0
commit 828be15da3
No known key found for this signature in database
6 changed files with 122 additions and 61 deletions

View file

@ -1,7 +1,6 @@
<script lang="ts">
import type { Datum } from '$lib/store';
import { Button } from '$lib/components/ui/button';
import { ArrowLeft } from '@lucide/svelte';
import { type Extension, ExtensionSchema } from '$lib/store';
import Icon from './Icon.svelte';
import { invoke } from '@tauri-apps/api/core';
import ExtensionListView from './extensions/ExtensionListView.svelte';
@ -14,6 +13,7 @@
import HeaderInput from './HeaderInput.svelte';
import { viewManager } from '$lib/viewManager.svelte';
import ExtensionInstallConfirm from './extensions/ExtensionInstallConfirm.svelte';
import { fetch } from '@tauri-apps/plugin-http';
type Props = {
onBack: () => void;
@ -27,7 +27,9 @@
let { onBack, onInstall }: Props = $props();
let selectedExtension = $state<Datum | null>(null);
let selectedExtension = $state<Extension | null>(null);
let detailedExtension = $state<Extension | null>(null);
let isDetailLoading = $state(false);
let expandedImageUrl = $state<string | null>(null);
let isInstalling = $state(false);
let vlistInstance = $state<VListHandle | null>(null);
@ -42,6 +44,32 @@
}
});
$effect(() => {
if (selectedExtension && selectedExtension.id !== detailedExtension?.id) {
detailedExtension = null;
isDetailLoading = true;
const fetchDetails = async () => {
try {
const res = await fetch(
`https://backend.raycast.com/api/v1/extensions/${selectedExtension!.author.handle}/${selectedExtension!.name}`
);
if (!res.ok) throw new Error(`Failed to fetch extension details: ${res.status}`);
const json = await res.json();
const parsed = ExtensionSchema.parse(json);
detailedExtension = parsed;
} catch (e) {
console.error('Failed to fetch or parse extension details, using list data.', e);
detailedExtension = selectedExtension;
} finally {
isDetailLoading = false;
}
};
fetchDetails();
} else if (!selectedExtension) {
detailedExtension = null;
}
});
const handleScroll = () => {
if (!vlistInstance) return;
if (
@ -69,15 +97,16 @@
}
async function handleInstall() {
if (!selectedExtension || isInstalling) return;
const extensionToInstall = detailedExtension || selectedExtension;
if (!extensionToInstall || isInstalling) return;
isInstalling = true;
try {
const result = await invoke<{
status: 'success' | 'requiresConfirmation';
violations?: Violation[];
}>('install_extension', {
downloadUrl: selectedExtension.download_url,
slug: selectedExtension.name,
downloadUrl: extensionToInstall.download_url,
slug: extensionToInstall.name,
force: false
});
@ -96,12 +125,13 @@
async function handleForceInstall() {
showConfirmationDialog = false;
if (!selectedExtension) return;
const extensionToInstall = detailedExtension || selectedExtension;
if (!extensionToInstall) return;
isInstalling = true;
try {
await invoke('install_extension', {
downloadUrl: selectedExtension.download_url,
slug: selectedExtension.name,
downloadUrl: extensionToInstall.download_url,
slug: extensionToInstall.name,
force: true
});
onInstall();
@ -133,12 +163,15 @@
/>
<CategoryFilter />
{/if}
<LoadingIndicator isLoading={extensionsStore.isLoading && !selectedExtension} />
<LoadingIndicator
isLoading={(extensionsStore.isLoading && !selectedExtension) || isDetailLoading}
/>
</header>
{#if selectedExtension}
{@const extensionToShow = detailedExtension || selectedExtension}
<ExtensionDetailView
extension={selectedExtension}
extension={extensionToShow}
{isInstalling}
onInstall={handleInstall}
onOpenLightbox={(imageUrl) => (expandedImageUrl = imageUrl)}

View file

@ -1,5 +1,5 @@
<script lang="ts">
import type { Datum, Command as ExtensionCommand } from '$lib/store';
import type { Extension, Command as ExtensionCommand } from '$lib/store';
import { Button } from '$lib/components/ui/button';
import { ArrowUpRight } from '@lucide/svelte';
import Icon from '../Icon.svelte';
@ -15,10 +15,9 @@
import KeyboardShortcut from '../KeyboardShortcut.svelte';
import { uiStore } from '$lib/ui.svelte';
import { viewManager } from '$lib/viewManager.svelte';
import type { PluginInfo } from '@raycast-linux/protocol';
type Props = {
extension: Datum;
extension: Extension;
isInstalling: boolean;
onInstall: () => void;
onOpenLightbox: (imageUrl: string) => void;
@ -79,6 +78,19 @@
: []
);
const screenshots = $derived.by(() => {
if (extension.metadata && extension.metadata.length > 0) {
return extension.metadata;
}
if (extension.metadata_count > 0) {
return Array.from(
{ length: extension.metadata_count },
(_, i) => `${extension.readme_assets_path}metadata/${extension.name}-${i + 1}.png`
);
}
return [];
});
function handleOpenCommand(command: ExtensionCommand) {
const pluginInfo = installedCommandsInfo.find((p) => p.commandName === command.name);
if (pluginInfo) {
@ -89,7 +101,7 @@
}
</script>
<div class="flex grow flex-col gap-6 overflow-y-auto p-6">
<div class="flex grow flex-col gap-6 overflow-x-hidden overflow-y-auto p-6">
<div class="flex items-center gap-6">
<Icon
icon={extension.icons.light
@ -141,12 +153,11 @@
<Separator />
{#if extension.metadata_count > 0}
<Carousel.Root class="w-full">
{#if screenshots.length > 0}
<Carousel.Root>
<Carousel.Content>
{#each Array(extension.metadata_count) as _, i (i)}
<Carousel.Item class="shrink grow-0 basis-auto">
{@const imageUrl = `${extension.readme_assets_path}metadata/${extension.name}-${i + 1}.png`}
{#each screenshots as imageUrl, i (imageUrl)}
<Carousel.Item class="grow-0 basis-auto">
<button class="w-full cursor-pointer" onclick={() => onOpenLightbox(imageUrl)}>
<img
src={imageUrl}
@ -158,8 +169,8 @@
</Carousel.Item>
{/each}
</Carousel.Content>
<Carousel.Previous />
<Carousel.Next />
<Carousel.Previous class="-left-4" variant="default" />
<Carousel.Next class="-right-4" variant="default" />
</Carousel.Root>
{/if}
@ -227,7 +238,7 @@
>
<Icon
icon={contributor.avatar
? { source: contributor.avatar, mask: 'Circle' }
? { source: contributor.avatar, mask: 'circle' }
: undefined}
class="size-6"
/>

View file

@ -1,11 +1,11 @@
<script lang="ts">
import Icon from '../Icon.svelte';
import { Download } from '@lucide/svelte';
import type { Datum } from '$lib/store';
import type { Extension } from '$lib/store';
import ListItemBase from '../nodes/shared/ListItemBase.svelte';
type Props = {
ext: Datum;
ext: Extension;
isSelected: boolean;
onclick?: () => void;
};
@ -16,7 +16,7 @@
<ListItemBase
title={ext.title}
subtitle={ext.description}
icon={ext.icons.light ? { source: ext.icons.light, mask: 'Circle' } : undefined}
icon={ext.icons.light ? { source: ext.icons.light, mask: 'roundedRectangle' } : undefined}
{isSelected}
{onclick}
>
@ -29,7 +29,7 @@
{ext.download_count.toLocaleString()}
</div>
<Icon
icon={ext.author.avatar ? { source: ext.author.avatar, mask: 'Circle' } : undefined}
icon={ext.author.avatar ? { source: ext.author.avatar, mask: 'circle' } : undefined}
class="size-6"
/>
{/snippet}

View file

@ -1,12 +1,12 @@
<script lang="ts">
import type { Datum } from '$lib/store';
import type { Extension } from '$lib/store';
import ExtensionListItem from './ExtensionListItem.svelte';
import { extensionsStore } from './store.svelte';
import BaseList from '$lib/components/BaseList.svelte';
import type { VListHandle } from 'virtua/svelte';
type Props = {
onSelect: (ext: Datum) => void;
onSelect: (ext: Extension) => void;
onScroll: (offset: number) => void;
vlistInstance: VListHandle | null;
};
@ -16,7 +16,7 @@
type DisplayItem = {
id: string | number;
itemType: 'header' | 'item';
data: Datum | string;
data: Extension | string;
};
let currentItems = $state<DisplayItem[]>([]);
@ -25,7 +25,7 @@
const newItems: DisplayItem[] = [];
const addedIds = new Set<string>();
const addItems = (exts: Datum[]) => {
const addItems = (exts: Extension[]) => {
for (const ext of exts) {
if (!addedIds.has(ext.id)) {
newItems.push({ id: ext.id, itemType: 'item', data: ext });
@ -90,7 +90,7 @@
{:else}
<BaseList
items={currentItems}
onenter={(item) => onSelect(item.data as Datum)}
onenter={(item) => onSelect(item.data as Extension)}
bind:selectedIndex={extensionsStore.selectedIndex}
isItemSelectable={(item) => item.itemType === 'item'}
onscroll={onScroll}
@ -102,7 +102,7 @@
{item.data}
</h3>
{:else if item.itemType === 'item'}
<ExtensionListItem ext={item.data as Datum} {isSelected} {onclick} />
<ExtensionListItem ext={item.data as Extension} {isSelected} {onclick} />
{/if}
{/snippet}
</BaseList>

View file

@ -1,11 +1,11 @@
import { StoreListingsReturnTypeSchema, type Datum } from '$lib/store';
import { PaginatedExtensionsResponseSchema, type Extension } from '$lib/store';
import { fetch } from '@tauri-apps/plugin-http';
export class ExtensionsStore {
extensions = $state<Datum[]>([]);
searchResults = $state<Datum[]>([]);
featuredExtensions = $state<Datum[]>([]);
trendingExtensions = $state<Datum[]>([]);
extensions = $state<Extension[]>([]);
searchResults = $state<Extension[]>([]);
featuredExtensions = $state<Extension[]>([]);
trendingExtensions = $state<Extension[]>([]);
isLoading = $state(true);
isSearching = $state(false);
@ -73,7 +73,7 @@ export class ExtensionsStore {
`https://backend.raycast.com/api/v1/store_listings/search?q=${encodeURIComponent(value)}&per_page=${this.perPage}`
);
if (!res.ok) throw new Error(`Search failed: ${res.status}`);
const parsed = StoreListingsReturnTypeSchema.parse(await res.json());
const parsed = PaginatedExtensionsResponseSchema.parse(await res.json());
this.searchResults = parsed.data;
this.selectedIndex = 0;
} catch (e: unknown) {
@ -101,18 +101,18 @@ export class ExtensionsStore {
]);
if (!storeRes.ok) throw new Error(`Store fetch failed: ${storeRes.status}`);
const storeParsed = StoreListingsReturnTypeSchema.parse(await storeRes.json());
const storeParsed = PaginatedExtensionsResponseSchema.parse(await storeRes.json());
this.extensions = storeParsed.data;
this.currentPage = 1;
this.hasMore = storeParsed.data.length === this.perPage;
if (featuredRes.ok) {
const featuredParsed = StoreListingsReturnTypeSchema.parse(await featuredRes.json());
const featuredParsed = PaginatedExtensionsResponseSchema.parse(await featuredRes.json());
this.featuredExtensions = featuredParsed.data;
}
if (trendingRes.ok) {
const trendingParsed = StoreListingsReturnTypeSchema.parse(await trendingRes.json());
const trendingParsed = PaginatedExtensionsResponseSchema.parse(await trendingRes.json());
this.trendingExtensions = trendingParsed.data;
}
} catch (e: unknown) {
@ -141,7 +141,7 @@ export class ExtensionsStore {
`https://backend.raycast.com/api/v1/store_listings?page=${nextPage}&per_page=${this.perPage}`
);
if (!res.ok) throw new Error('Failed to fetch more extensions');
const parsed = StoreListingsReturnTypeSchema.parse(await res.json());
const parsed = PaginatedExtensionsResponseSchema.parse(await res.json());
if (parsed.data.length < this.perPage) {
this.hasMore = false;

View file

@ -30,7 +30,14 @@ export type Platform = z.infer<typeof PlatformSchema>;
export const StatusSchema = z.enum(['active', 'deprecated']);
export type Status = z.infer<typeof StatusSchema>;
export const IconsSchema = z.object({
light: z.union([z.null(), z.string()]),
dark: z.union([z.null(), z.string()])
});
export type Icons = z.infer<typeof IconsSchema>;
export const AuthorSchema = z.object({
id: z.string().optional(),
name: z.string(),
handle: z.string(),
bio: z.union([z.null(), z.string()]).optional(),
@ -41,21 +48,14 @@ export const AuthorSchema = z.object({
avatar_placeholder_color: AvatarPlaceholderColorSchema,
slack_community_username: z.union([z.null(), z.string()]).optional(),
slack_community_user_id: z.union([z.null(), z.string()]).optional(),
website_anchor: z.union([z.null(), z.string()]).optional(),
created_at: z.number().optional(),
website_anchor: z.union([z.null(), z.string()]).optional(),
website: z.union([z.null(), z.string()]).optional(),
username: z.string().optional(),
avatar: z.union([z.null(), z.string()]),
id: z.string().optional()
avatar: z.union([z.null(), z.string()])
});
export type Author = z.infer<typeof AuthorSchema>;
export const IconsSchema = z.object({
light: z.union([z.null(), z.string()]),
dark: z.union([z.null(), z.string()])
});
export type Icons = z.infer<typeof IconsSchema>;
export const ToolSchema = z.object({
id: z.string(),
name: z.string(),
@ -83,21 +83,34 @@ export const CommandSchema = z.object({
});
export type Command = z.infer<typeof CommandSchema>;
export const DatumSchema = z.object({
export const VersionSchema = z.object({
title: z.string(),
title_link: z.null(),
date: z.string(),
markdown: z.string()
});
export type Version = z.infer<typeof VersionSchema>;
export const ChangelogSchema = z.object({
versions: z.array(VersionSchema)
});
export type Changelog = z.infer<typeof ChangelogSchema>;
export const ExtensionSchema = z.object({
id: z.string(),
name: z.string(),
native_id: z.null(),
seo_categories: z.array(z.string()),
platforms: z.union([z.array(PlatformSchema), z.null()]),
created_at: z.number(),
author: AuthorSchema,
created_at: z.number(),
kill_listed_at: z.number().nullable(),
owner: AuthorSchema,
status: StatusSchema,
is_new: z.boolean(),
access: AccessSchema,
store_url: z.string(),
download_count: z.number(),
kill_listed_at: z.number().nullable(),
title: z.string(),
description: z.string(),
commit_sha: z.string(),
@ -111,14 +124,18 @@ export const DatumSchema = z.object({
readme_url: z.union([z.null(), z.string()]),
readme_assets_path: z.string(),
icons: IconsSchema,
download_url: z.string(),
commands: z.array(CommandSchema),
tools: z.array(ToolSchema),
download_url: z.string(),
contributors: z.array(AuthorSchema),
tools: z.array(ToolSchema)
past_contributors: z.array(z.any()).optional(),
listed: z.boolean().optional(),
metadata: z.array(z.string()).optional(),
changelog: ChangelogSchema.optional()
});
export type Datum = z.infer<typeof DatumSchema>;
export type Extension = z.infer<typeof ExtensionSchema>;
export const StoreListingsReturnTypeSchema = z.object({
data: z.array(DatumSchema)
export const PaginatedExtensionsResponseSchema = z.object({
data: z.array(ExtensionSchema)
});
export type StoreListingsReturnType = z.infer<typeof StoreListingsReturnTypeSchema>;
export type PaginatedExtensionsResponse = z.infer<typeof PaginatedExtensionsResponseSchema>;