<script lang="ts">
import { onMount } from "svelte";
import { getExtensionAssetUrl } from "../../../api/client.js";
import { workbench } from "../../../stores/workbench.svelte.js";
import { explorer } from "../../../stores/explorer.svelte.js";
import { marketplace } from "../../../stores/marketplace.svelte.js";
import { notifications } from "../../../stores/notifications.svelte.js";
import AppIcon from "../../layout/AppIcon.svelte";
import SidebarHeader from "../SidebarHeader.svelte";
import { remembered, oneOf } from "../../../utils/remember.svelte.js";
/// Which half of the panel, and what was being searched for — both kept, so
/// coming back to Extensions lands where it was left.
const tab = remembered<"installed" | "marketplace">(
"extensions.tab",
"marketplace",
oneOf("installed", "marketplace"),
);
const searchInput = remembered("extensions.search", "");
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
onMount(() => {
void explorer.ensureWorkbenchModel();
// Searched for what was searched for last time, rather than showing the
// unfiltered list under a filled-in box.
void marketplace.search(searchInput.value);
});
function handleSearchInput(value: string) {
searchInput.value = value;
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
void marketplace.search(value);
}, 300);
}
function formatCount(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
return String(n);
}
function isDisabled(extensionId: string): boolean {
return explorer.disabledExtensionIds.has(extensionId);
}
function isInstalledActive(extensionId: string): boolean {
const input = workbench.activeTab?.input;
return input?.kind === "extension"
&& input.source === "installed"
&& input.extensionId === extensionId;
}
function isMarketplaceActive(namespace: string, name: string): boolean {
const input = workbench.activeTab?.input;
return input?.kind === "extension"
&& input.source === "marketplace"
&& input.namespace === namespace
&& input.name === name;
}
async function handleUninstall(extensionId: string) {
try {
await marketplace.uninstall(extensionId);
} catch (e) {
notifications.upsert(
`ext-uninstall:${extensionId}`,
"error",
"Uninstall failed",
e instanceof Error ? e.message : String(e),
"extensions",
);
}
}
async function handleToggleEnabled(extensionId: string) {
try {
if (isDisabled(extensionId)) {
await marketplace.enable(extensionId);
} else {
await marketplace.disable(extensionId);
}
} catch (e) {
notifications.upsert(
`ext-toggle:${extensionId}`,
"error",
"Toggle failed",
e instanceof Error ? e.message : String(e),
"extensions",
);
}
}
</script>
<div class="panel">
<SidebarHeader
title="Extensions"
onrefresh={() => Promise.all([
explorer.loadWorkbenchModel(),
marketplace.search(searchInput.value),
]).then(() => undefined)}
refreshing={marketplace.loading}
refreshTitle="Re-read what is installed and search again"
/>
<div class="tab-bar">
<button
class="tab-btn"
class:active={tab.value === "marketplace"}
onclick={() => (tab.value = "marketplace")}
>
Marketplace
</button>
<button
class="tab-btn"
class:active={tab.value === "installed"}
onclick={() => (tab.value = "installed")}
>
Installed
</button>
</div>
{#if tab.value === "installed"}
<div class="panel-body">
{#if explorer.workbenchModel.loading && !explorer.workbenchModel.loaded}
<div class="skel-list">
{#each { length: 5 } as _, i}
<div class="skel-ext-item">
<div class="skel" style="width: var(--wb-control-size-28); height: var(--wb-control-size-28); border-radius: var(--wb-corner-radius-4); flex-shrink: 0;"></div>
<div style="flex: 1; display: flex; flex-direction: column; gap: var(--wb-spacing-4);">
<div class="skel" style="width: {50 + ((i * 23) % 40)}%; height: var(--wb-control-size-10);"></div>
<div class="skel" style="width: {30 + ((i * 19) % 50)}%; height: var(--wb-control-size-8);"></div>
</div>
</div>
{/each}
</div>
{:else if explorer.workbenchModel.error}
<div class="panel-error">{explorer.workbenchModel.error}</div>
{:else if (explorer.workbenchModel.model?.extensions.length ?? 0) === 0}
<div class="panel-state empty-state">
<AppIcon name="code" size={24} />
<span>No extensions installed</span>
<button class="browse-btn" onclick={() => (tab.value = "marketplace")}>
Browse Marketplace
</button>
</div>
{:else}
{#each explorer.workbenchModel.model?.extensions ?? [] as extension (extension.id)}
{@const disabled = isDisabled(extension.id)}
<div
class="extension-item"
class:active={isInstalledActive(extension.id)}
class:disabled
>
<div class="installed-icon">
{#if extension.icon}
<img
src={getExtensionAssetUrl(extension.id, extension.icon)}
alt=""
width="32"
height="32"
/>
{:else}
<div class="icon-fallback">
<AppIcon name="code" size={16} />
</div>
{/if}
</div>
<button
class="ext-info"
onclick={() => workbench.openInstalledExtension(extension.id)}
>
<div class="ext-header">
<div class="extension-item-title">{extension.display_name}</div>
<div class="extension-item-version">{extension.version}</div>
</div>
<div class="extension-item-meta">
{extension.publisher}.{extension.name}
{#if disabled}
<span class="disabled-badge">Disabled</span>
{/if}
</div>
</button>
<div class="ext-actions">
<button
class="ext-action-btn"
title={disabled ? "Enable" : "Disable"}
onclick={() => handleToggleEnabled(extension.id)}
>
<AppIcon name={disabled ? "eye-off" : "eye"} size={12} />
</button>
<button
class="ext-action-btn danger"
title="Uninstall"
onclick={() => handleUninstall(extension.id)}
>
<AppIcon name="trash" size={12} />
</button>
</div>
</div>
{/each}
{/if}
</div>
{:else}
<div class="search-bar">
<AppIcon name="search" size={12} />
<input
class="search-input"
type="text"
placeholder="Search extensions..."
value={searchInput.value}
oninput={(e) => handleSearchInput((e.target as HTMLInputElement).value)}
/>
{#if searchInput}
<button class="search-clear" onclick={() => handleSearchInput("")}>
<AppIcon name="x" size={10} />
</button>
{/if}
</div>
<div class="panel-body">
{#if marketplace.loading}
<div class="panel-state">Searching...</div>
{:else if marketplace.error}
<div class="panel-error">{marketplace.error}</div>
{:else if marketplace.results.length === 0}
<div class="panel-state">
{#if marketplace.showingPopular}
No marketplace extensions available right now.
{:else}
No extensions found for "{searchInput}"
{/if}
</div>
{:else}
<div class="results-count">
{#if marketplace.showingPopular}
Popular on Open VSX
{:else}
{marketplace.total.toLocaleString()} results
{/if}
</div>
{#each marketplace.results as ext (`${ext.namespace}.${ext.name}`)}
{@const installed = marketplace.isInstalled(ext.namespace, ext.name)}
{@const installing = marketplace.isInstalling(ext.namespace, ext.name)}
<div
class="marketplace-item"
class:active={isMarketplaceActive(ext.namespace, ext.name)}
onclick={() => workbench.openMarketplaceExtension(ext)}
onkeydown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
workbench.openMarketplaceExtension(ext);
}
}}
tabindex="0"
role="button"
>
<div class="marketplace-icon">
{#if ext.icon_url}
<img src={ext.icon_url} alt="" width="32" height="32" />
{:else}
<div class="icon-fallback">
<AppIcon name="code" size={16} />
</div>
{/if}
</div>
<div class="marketplace-body">
<div class="marketplace-title">
{ext.display_name}
{#if ext.verified}
<AppIcon name="badge-check" size={11} />
{/if}
</div>
<div class="marketplace-publisher">{ext.namespace}</div>
<div class="marketplace-desc">{ext.description}</div>
<div class="marketplace-stats">
<span><AppIcon name="download" size={10} /> {formatCount(ext.download_count)}</span>
{#if ext.average_rating != null}
<span><AppIcon name="star" size={10} /> {ext.average_rating.toFixed(1)}</span>
{/if}
</div>
</div>
<div class="marketplace-action">
{#if installed}
<span class="installed-badge">Installed</span>
{:else if installing}
<span class="installing-badge">Installing...</span>
{:else if ext.download_url}
<button
class="install-btn"
onclick={(event) => {
event.stopPropagation();
void marketplace.install(ext);
}}
>
Install
</button>
{/if}
</div>
</div>
{/each}
{/if}
</div>
{/if}
</div>
<style>
.panel {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.tab-bar {
display: flex;
border-bottom: 1px solid var(--vscode-panel-border);
flex-shrink: 0;
}
.tab-btn {
flex: 1;
padding: var(--wb-spacing-7) 0;
font-size: var(--wb-font-size-11);
font-weight: 600;
color: var(--vscode-disabledForeground);
text-align: center;
border-bottom: 2px solid transparent;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.tab-btn:hover {
color: var(--vscode-editor-foreground);
}
.tab-btn.active {
color: var(--vscode-editor-foreground);
border-bottom-color: var(--vscode-focusBorder);
}
.search-bar {
display: flex;
align-items: center;
gap: var(--wb-spacing-6);
padding: var(--wb-spacing-6) var(--wb-spacing-8);
border-bottom: 1px solid var(--vscode-panel-border);
color: var(--vscode-disabledForeground);
flex-shrink: 0;
}
.search-input {
flex: 1;
background: none;
border: none;
font-size: var(--wb-font-size-12);
color: var(--vscode-editor-foreground);
outline: none;
min-width: 0;
}
.search-input::placeholder {
color: var(--vscode-disabledForeground);
}
.search-clear {
width: var(--wb-control-size-16);
height: var(--wb-control-size-16);
display: flex;
align-items: center;
justify-content: center;
color: var(--vscode-disabledForeground);
border-radius: var(--wsb-radius-sm);
flex-shrink: 0;
}
.search-clear:hover {
color: var(--vscode-editor-foreground);
background: var(--vscode-list-hoverBackground);
}
.panel-body {
flex: 1;
min-height: 0;
overflow: auto;
padding: var(--wb-spacing-6) var(--wb-spacing-8);
display: flex;
flex-direction: column;
gap: var(--wb-spacing-4);
}
.panel-state,
.panel-error {
padding: var(--wb-spacing-8) var(--wb-spacing-6);
font-size: var(--wb-font-size-12);
}
.panel-state {
color: var(--vscode-disabledForeground);
}
.panel-error {
color: var(--vscode-errorForeground);
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--wb-spacing-8);
padding: var(--wb-spacing-24) var(--wb-spacing-12);
text-align: center;
}
.browse-btn {
font-size: var(--wb-font-size-11);
padding: var(--wb-spacing-4) var(--wb-spacing-12);
border-radius: var(--wsb-radius-sm);
background: var(--vscode-focusBorder);
color: white;
font-weight: 500;
}
.browse-btn:hover {
opacity: 0.9;
}
.results-count {
font-size: var(--wb-font-size-10);
color: var(--vscode-disabledForeground);
padding: var(--wb-spacing-2) var(--wb-spacing-4);
}
/* Installed extension items */
.extension-item {
display: flex;
align-items: center;
padding: var(--wb-spacing-6) var(--wb-spacing-10);
border-radius: var(--wsb-radius-sm);
background: transparent;
border: 1px solid transparent;
gap: var(--wb-spacing-10);
}
.extension-item:hover {
background: var(--vscode-list-hoverBackground);
}
.extension-item.active {
background: color-mix(in srgb, var(--vscode-focusBorder) 10%, transparent);
border-color: color-mix(in srgb, var(--vscode-focusBorder) 35%, transparent);
}
.extension-item.disabled .extension-item-title {
opacity: 0.5;
}
.ext-info {
flex: 1;
min-width: 0;
text-align: left;
}
.installed-icon {
flex-shrink: 0;
width: var(--wb-control-size-32);
height: var(--wb-control-size-32);
border-radius: var(--wsb-radius-sm);
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--vscode-panel-border) 70%, transparent);
}
.installed-icon img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.ext-header {
display: flex;
align-items: baseline;
gap: var(--wb-spacing-6);
}
.extension-item-title {
font-size: var(--wb-font-size-12);
font-weight: 600;
color: var(--vscode-editor-foreground);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.extension-item-version {
font-size: var(--wb-font-size-10);
color: var(--vscode-disabledForeground);
font-family: var(--wsb-font-mono);
flex-shrink: 0;
}
.extension-item-meta {
margin-top: var(--wb-spacing-1);
font-size: var(--wb-font-size-11);
color: var(--vscode-disabledForeground);
font-family: var(--wsb-font-mono);
display: flex;
align-items: center;
gap: var(--wb-spacing-6);
}
.disabled-badge {
font-size: var(--wb-font-size-9);
padding: var(--wb-spacing-1) var(--wb-spacing-4);
border-radius: var(--wsb-radius-sm);
background: color-mix(in srgb, var(--vscode-editorWarning-foreground) 15%, transparent);
color: var(--vscode-editorWarning-foreground);
font-weight: 600;
font-family: var(--wsb-font-sans);
}
.ext-actions {
display: flex;
gap: var(--wb-spacing-2);
flex-shrink: 0;
opacity: 0;
transition: opacity 0.1s;
}
.extension-item:hover .ext-actions {
opacity: 1;
}
.ext-action-btn {
width: var(--wb-control-size-22);
height: var(--wb-control-size-22);
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--wsb-radius-sm);
color: var(--vscode-disabledForeground);
}
.ext-action-btn:hover {
background: var(--vscode-input-background);
color: var(--vscode-editor-foreground);
}
.ext-action-btn.danger:hover {
color: var(--vscode-errorForeground);
}
/* Marketplace result items */
.marketplace-item {
display: flex;
gap: var(--wb-spacing-8);
padding: var(--wb-spacing-8) var(--wb-spacing-6);
border-radius: var(--wsb-radius-sm);
border: 1px solid transparent;
outline: none;
}
.marketplace-item:hover {
background: var(--vscode-list-hoverBackground);
}
.marketplace-item.active {
background: color-mix(in srgb, var(--vscode-focusBorder) 10%, transparent);
border-color: color-mix(in srgb, var(--vscode-focusBorder) 35%, transparent);
}
.marketplace-item:focus-visible {
border-color: var(--vscode-focusBorder);
}
.marketplace-icon {
flex-shrink: 0;
width: var(--wb-control-size-32);
height: var(--wb-control-size-32);
border-radius: var(--wsb-radius-sm);
overflow: hidden;
}
.marketplace-icon img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.icon-fallback {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: var(--vscode-input-background);
color: var(--vscode-disabledForeground);
}
.marketplace-body {
flex: 1;
min-width: 0;
}
.marketplace-title {
font-size: var(--wb-font-size-12);
font-weight: 600;
color: var(--vscode-editor-foreground);
display: flex;
align-items: center;
gap: var(--wb-spacing-4);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.marketplace-publisher {
font-size: var(--wb-font-size-10);
color: var(--vscode-disabledForeground);
}
.marketplace-desc {
font-size: var(--wb-font-size-11);
color: var(--vscode-foreground);
margin-top: var(--wb-spacing-2);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.marketplace-stats {
display: flex;
gap: var(--wb-spacing-8);
margin-top: var(--wb-spacing-3);
font-size: var(--wb-font-size-10);
color: var(--vscode-disabledForeground);
}
.marketplace-stats span {
display: inline-flex;
align-items: center;
gap: var(--wb-spacing-2);
}
.marketplace-action {
flex-shrink: 0;
display: flex;
align-items: flex-start;
padding-top: var(--wb-spacing-2);
}
.install-btn {
font-size: var(--wb-font-size-11);
padding: var(--wb-spacing-3) var(--wb-spacing-10);
border-radius: var(--wsb-radius-sm);
background: var(--vscode-focusBorder);
color: white;
font-weight: 500;
}
.install-btn:hover {
opacity: 0.9;
}
.installed-badge {
font-size: var(--wb-font-size-10);
padding: var(--wb-spacing-2) var(--wb-spacing-8);
border-radius: var(--wsb-radius-sm);
background: var(--vscode-input-background);
color: var(--vscode-disabledForeground);
font-weight: 500;
}
.installing-badge {
font-size: var(--wb-font-size-10);
padding: var(--wb-spacing-2) var(--wb-spacing-8);
border-radius: var(--wsb-radius-sm);
background: color-mix(in srgb, var(--vscode-focusBorder) 15%, transparent);
color: var(--vscode-focusBorder);
font-weight: 500;
}
.skel-list {
display: flex;
flex-direction: column;
padding: var(--wb-spacing-8) 0;
}
.skel-ext-item {
display: flex;
align-items: center;
gap: var(--wb-spacing-10);
padding: var(--wb-spacing-6) var(--wb-spacing-14);
}
</style>