feat(extensions): add action bar to extensions list

This commit adds an action bar to the extensions list, roughly mirroring Raycast's actions menu. It also migrates the Extensions view to the MainLayout.
This commit is contained in:
ByteAtATime 2025-07-06 10:16:26 -07:00
parent 8c395f0a60
commit 51805dbe7e
No known key found for this signature in database
2 changed files with 238 additions and 103 deletions

View file

@ -14,6 +14,14 @@
import { viewManager } from '$lib/viewManager.svelte';
import ExtensionInstallConfirm from './extensions/ExtensionInstallConfirm.svelte';
import { fetch } from '@tauri-apps/plugin-http';
import ActionBar from '$lib/components/nodes/shared/ActionBar.svelte';
import ActionMenu from '$lib/components/nodes/shared/ActionMenu.svelte';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import KeyboardShortcut from '$lib/components/KeyboardShortcut.svelte';
import { openUrl } from '@tauri-apps/plugin-opener';
import { writeText } from '@tauri-apps/plugin-clipboard-manager';
import { keyEventMatches, type KeyboardShortcut as Shortcut } from '$lib/props/actions';
import MainLayout from './layout/MainLayout.svelte';
type Props = {
onBack: () => void;
@ -25,6 +33,12 @@
reason: string;
};
type DisplayItem = {
id: string | number;
itemType: 'header' | 'item';
data: Extension | string;
};
let { onBack, onInstall }: Props = $props();
let selectedExtension = $state<Extension | null>(null);
@ -35,6 +49,65 @@
let vlistInstance = $state<VListHandle | null>(null);
let showConfirmationDialog = $state(false);
let confirmationViolations = $state<Violation[]>([]);
let extensionForConfirmation = $state<Extension | null>(null);
let displayedItems = $state<DisplayItem[]>([]);
$effect(() => {
const newItems: DisplayItem[] = [];
const addedIds = new Set<string>();
const addItems = (exts: Extension[]) => {
for (const ext of exts) {
if (!addedIds.has(ext.id)) {
newItems.push({ id: ext.id, itemType: 'item', data: ext });
addedIds.add(ext.id);
}
}
};
if (extensionsStore.searchText) {
if (extensionsStore.searchResults.length > 0) {
newItems.push({ id: 'header-search', itemType: 'header', data: 'Search Results' });
addItems(extensionsStore.searchResults);
}
} else if (extensionsStore.selectedCategory !== 'All Categories') {
const filtered =
extensionsStore.extensions.filter(
(ext) => ext.categories?.includes(extensionsStore.selectedCategory) ?? false
) ?? [];
if (filtered.length > 0) {
newItems.push({
id: `header-${extensionsStore.selectedCategory}`,
itemType: 'header',
data: extensionsStore.selectedCategory
});
addItems(filtered);
}
} else {
if (extensionsStore.featuredExtensions.length > 0) {
newItems.push({ id: 'header-featured', itemType: 'header', data: 'Featured' });
addItems(extensionsStore.featuredExtensions);
}
if (extensionsStore.trendingExtensions.length > 0) {
newItems.push({ id: 'header-trending', itemType: 'header', data: 'Trending' });
addItems(extensionsStore.trendingExtensions);
}
if (extensionsStore.extensions.length > 0) {
newItems.push({ id: 'header-all', itemType: 'header', data: 'All Extensions' });
addItems(extensionsStore.extensions);
}
}
if (!extensionsStore.isSearching) {
displayedItems = newItems;
}
});
const selectedListItem = $derived(displayedItems[extensionsStore.selectedIndex]);
const selectedListExtension = $derived(
selectedListItem?.itemType === 'item' ? (selectedListItem.data as Extension) : null
);
$effect(() => {
const ext = viewManager.extensionToSelect;
@ -82,6 +155,33 @@
}
};
const openInBrowserShortcut: Shortcut = { modifiers: ['opt', 'ctrl'], key: 'o' };
const copyUrlShortcut: Shortcut = { modifiers: ['ctrl'], key: '.' };
const viewReadmeShortcut: Shortcut = { modifiers: ['opt', 'shift', 'ctrl'], key: 'r' };
const viewSourceShortcut: Shortcut = { modifiers: ['shift', 'ctrl'], key: 'o' };
function handleOpenInBrowser() {
if (!selectedListExtension) return;
const { author, name: slug } = selectedListExtension;
openUrl(`https://raycast.com/${author.handle}/${slug}`);
}
function handleCopyExtensionUrl() {
if (!selectedListExtension) return;
const { author, name: slug } = selectedListExtension;
writeText(`https://raycast.com/${author.handle}/${slug}`);
}
function handleViewReadme() {
if (!selectedListExtension || !selectedListExtension.readme_url) return;
openUrl(selectedListExtension.readme_url);
}
function handleViewSourceCode() {
if (!selectedListExtension || !selectedListExtension.source_url) return;
openUrl(selectedListExtension.source_url);
}
function handleGlobalKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape' && !e.defaultPrevented) {
e.preventDefault();
@ -94,11 +194,26 @@
}
return;
}
if (!selectedExtension && selectedListExtension) {
if (keyEventMatches(e, openInBrowserShortcut)) {
e.preventDefault();
handleOpenInBrowser();
} else if (keyEventMatches(e, copyUrlShortcut)) {
e.preventDefault();
handleCopyExtensionUrl();
} else if (keyEventMatches(e, viewReadmeShortcut) && selectedListExtension.readme_url) {
e.preventDefault();
handleViewReadme();
} else if (keyEventMatches(e, viewSourceShortcut) && selectedListExtension.source_url) {
e.preventDefault();
handleViewSourceCode();
}
}
}
async function handleInstall() {
const extensionToInstall = detailedExtension || selectedExtension;
if (!extensionToInstall || isInstalling) return;
async function installExtension(extensionToInstall: Extension) {
if (isInstalling) return;
isInstalling = true;
try {
const result = await invoke<{
@ -113,6 +228,7 @@
if (result.status === 'success') {
onInstall();
} else if (result.status === 'requiresConfirmation' && result.violations) {
extensionForConfirmation = extensionToInstall;
confirmationViolations = result.violations;
showConfirmationDialog = true;
}
@ -123,9 +239,16 @@
}
}
async function handleInstall() {
const extensionToInstall = detailedExtension || selectedExtension;
if (extensionToInstall) {
await installExtension(extensionToInstall);
}
}
async function handleForceInstall() {
showConfirmationDialog = false;
const extensionToInstall = detailedExtension || selectedExtension;
const extensionToInstall = extensionForConfirmation;
if (!extensionToInstall) return;
isInstalling = true;
try {
@ -145,47 +268,111 @@
<svelte:window onkeydown={handleGlobalKeyDown} />
<main class="bg-background text-foreground flex h-screen flex-col">
<header class="relative flex h-15 shrink-0 items-center pr-4 pl-[18px]">
<Button
size="icon"
onclick={() => (selectedExtension ? (selectedExtension = null) : onBack())}
variant="secondary"
class="size-6"
>
<Icon icon="arrow-left-16" />
</Button>
{#if !selectedExtension}
<HeaderInput
placeholder="Search Store for extensions..."
bind:value={extensionsStore.searchText}
autofocus
<MainLayout>
{#snippet header()}
<header class="relative flex h-15 shrink-0 items-center pr-4 pl-[18px]">
<Button
size="icon"
onclick={() => (selectedExtension ? (selectedExtension = null) : onBack())}
variant="secondary"
class="size-6"
>
<Icon icon="arrow-left-16" />
</Button>
{#if !selectedExtension}
<HeaderInput
placeholder="Search Store for extensions..."
bind:value={extensionsStore.searchText}
autofocus
/>
<CategoryFilter />
{/if}
<LoadingIndicator
isLoading={(extensionsStore.isLoading && !selectedExtension) || isDetailLoading}
/>
<CategoryFilter />
{/if}
<LoadingIndicator
isLoading={(extensionsStore.isLoading && !selectedExtension) || isDetailLoading}
/>
</header>
</header>
{/snippet}
{#if selectedExtension}
{@const extensionToShow = detailedExtension || selectedExtension}
<ExtensionDetailView
extension={extensionToShow}
{isInstalling}
onInstall={handleInstall}
onOpenLightbox={(imageUrl) => (expandedImageUrl = imageUrl)}
/>
{:else}
<div class="grow overflow-y-auto" role="listbox" tabindex={-1}>
<ExtensionListView
onSelect={(ext) => (selectedExtension = ext)}
onScroll={handleScroll}
bind:vlistInstance
{#snippet content()}
{#if selectedExtension}
{@const extensionToShow = detailedExtension || selectedExtension}
<ExtensionDetailView
extension={extensionToShow}
{isInstalling}
onInstall={handleInstall}
onOpenLightbox={(imageUrl) => (expandedImageUrl = imageUrl)}
/>
</div>
{/if}
</main>
{:else}
<div class="grow overflow-y-auto" role="listbox" tabindex={-1}>
<ExtensionListView
items={displayedItems}
onSelect={(ext) => (selectedExtension = ext)}
onScroll={handleScroll}
bind:vlistInstance
/>
</div>
{/if}
{/snippet}
{#snippet footer()}
{#if !selectedExtension && selectedListExtension}
<ActionBar
title={selectedListExtension.title}
icon={selectedListExtension.icons.light
? { source: selectedListExtension.icons.light, mask: 'circle' }
: undefined}
>
{#snippet primaryAction({ props })}
<Button {...props} onclick={() => (selectedExtension = selectedListExtension)}>
Show Details
<KeyboardShortcut shortcut={{ key: 'enter', modifiers: [] }} />
</Button>
{/snippet}
{#snippet actions()}
<ActionMenu>
<DropdownMenu.Item
onclick={() => installExtension(selectedListExtension)}
disabled={isInstalling}
>
{isInstalling ? 'Installing...' : 'Install Extension'}
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item onclick={handleOpenInBrowser}>
Open in Browser
<DropdownMenu.Shortcut>
<KeyboardShortcut shortcut={{ key: 'o', modifiers: ['opt', 'ctrl'] }} />
</DropdownMenu.Shortcut>
</DropdownMenu.Item>
<DropdownMenu.Item onclick={handleCopyExtensionUrl}>
Copy Extension URL
<DropdownMenu.Shortcut>
<KeyboardShortcut shortcut={{ key: '.', modifiers: ['ctrl'] }} />
</DropdownMenu.Shortcut>
</DropdownMenu.Item>
<DropdownMenu.Item
onclick={handleViewReadme}
disabled={!selectedListExtension.readme_url}
>
View README
<DropdownMenu.Shortcut>
<KeyboardShortcut shortcut={{ key: 'r', modifiers: ['opt', 'shift', 'ctrl'] }} />
</DropdownMenu.Shortcut>
</DropdownMenu.Item>
<DropdownMenu.Item
onclick={handleViewSourceCode}
disabled={!selectedListExtension.source_url}
>
View Source Code
<DropdownMenu.Shortcut>
<KeyboardShortcut shortcut={{ key: 'o', modifiers: ['shift', 'ctrl'] }} />
</DropdownMenu.Shortcut>
</DropdownMenu.Item>
</ActionMenu>
{/snippet}
</ActionBar>
{/if}
{/snippet}
</MainLayout>
{#if expandedImageUrl}
<ImageLightbox imageUrl={expandedImageUrl} onClose={() => (expandedImageUrl = null)} />

View file

@ -5,79 +5,27 @@
import BaseList from '$lib/components/BaseList.svelte';
import type { VListHandle } from 'virtua/svelte';
type Props = {
onSelect: (ext: Extension) => void;
onScroll: (offset: number) => void;
vlistInstance: VListHandle | null;
};
let { onSelect, onScroll, vlistInstance = $bindable() }: Props = $props();
type DisplayItem = {
id: string | number;
itemType: 'header' | 'item';
data: Extension | string;
};
let currentItems = $state<DisplayItem[]>([]);
type Props = {
items: DisplayItem[];
onSelect: (ext: Extension) => void;
onScroll: (offset: number) => void;
vlistInstance: VListHandle | null;
};
$effect(() => {
const newItems: DisplayItem[] = [];
const addedIds = new Set<string>();
const addItems = (exts: Extension[]) => {
for (const ext of exts) {
if (!addedIds.has(ext.id)) {
newItems.push({ id: ext.id, itemType: 'item', data: ext });
addedIds.add(ext.id);
}
}
};
if (extensionsStore.searchText) {
if (extensionsStore.searchResults.length > 0) {
newItems.push({ id: 'header-search', itemType: 'header', data: 'Search Results' });
addItems(extensionsStore.searchResults);
}
} else if (extensionsStore.selectedCategory !== 'All Categories') {
const filtered =
extensionsStore.extensions.filter(
(ext) => ext.categories?.includes(extensionsStore.selectedCategory) ?? false
) ?? [];
if (filtered.length > 0) {
newItems.push({
id: `header-${extensionsStore.selectedCategory}`,
itemType: 'header',
data: extensionsStore.selectedCategory
});
addItems(filtered);
}
} else {
if (extensionsStore.featuredExtensions.length > 0) {
newItems.push({ id: 'header-featured', itemType: 'header', data: 'Featured' });
addItems(extensionsStore.featuredExtensions);
}
if (extensionsStore.trendingExtensions.length > 0) {
newItems.push({ id: 'header-trending', itemType: 'header', data: 'Trending' });
addItems(extensionsStore.trendingExtensions);
}
if (extensionsStore.extensions.length > 0) {
newItems.push({ id: 'header-all', itemType: 'header', data: 'All Extensions' });
addItems(extensionsStore.extensions);
}
}
if (!extensionsStore.isSearching) {
currentItems = newItems;
}
});
let { items, onSelect, onScroll, vlistInstance = $bindable() }: Props = $props();
</script>
{#if extensionsStore.error}
<div class="flex h-full items-center justify-center text-red-500">
Error: {extensionsStore.error}
</div>
{:else if currentItems.length === 0}
{:else if items.length === 0}
{#if !extensionsStore.isSearching}
<div class="text-muted-foreground flex h-full items-center justify-center">
{#if extensionsStore.searchText}
@ -89,7 +37,7 @@
{/if}
{:else}
<BaseList
items={currentItems}
{items}
onenter={(item) => onSelect(item.data as Extension)}
bind:selectedIndex={extensionsStore.selectedIndex}
isItemSelectable={(item) => item.itemType === 'item'}