<script lang="ts">
import { onMount } from "svelte";
import {
getExtensionAssetUrl,
getInstalledExtensionReadme,
inspectMarketplaceExtension,
getMarketplaceExtensionReadme,
} from "../../api/client.js";
import type { EditorInput } from "../../api/types/workbench.js";
import type { MarketplaceInspectionResponse } from "../../api/types/marketplace.js";
import { marketplace } from "../../stores/marketplace.svelte.js";
import { notifications } from "../../stores/notifications.svelte.js";
import { workbench } from "../../stores/workbench.svelte.js";
import { explorer } from "../../stores/explorer.svelte.js";
import MarkdownPreview from "../content/MarkdownPreview.svelte";
import AppIcon from "../layout/AppIcon.svelte";
interface ReadmeState {
content: string | null;
loading: boolean;
error: string | null;
key: string | null;
}
interface InspectionState {
data: MarketplaceInspectionResponse | null;
loading: boolean;
error: string | null;
key: string | null;
}
const readme = $state<ReadmeState>({
content: null,
loading: false,
error: null,
key: null,
});
const inspection = $state<InspectionState>({
data: null,
loading: false,
error: null,
key: null,
});
onMount(() => {
void explorer.ensureWorkbenchModel();
});
interface Props {
input: Extract<EditorInput, { kind: "extension" }>;
}
let { input: extensionInput }: Props = $props();
const installedExtension = $derived.by(() => {
const input = extensionInput;
const extensions = explorer.workbenchModel.model?.extensions ?? [];
if (!input) {
return null;
}
if (input.source === "installed") {
return extensions.find((extension) => extension.id === input.extensionId) ?? null;
}
return extensions.find(
(extension) => extension.publisher === input.namespace && extension.name === input.name,
) ?? null;
});
const disabled = $derived.by(() =>
installedExtension ? explorer.disabledExtensionIds.has(installedExtension.id) : false,
);
const extensionCommands = $derived.by(() =>
installedExtension
? explorer.commands.filter((command) => command.extension_id === installedExtension.id)
: [],
);
const extensionLanguages = $derived.by(() =>
installedExtension
? explorer.languages.filter((language) => language.extension_id === installedExtension.id)
: [],
);
const extensionGrammars = $derived.by(() =>
installedExtension
? explorer.grammars.filter((grammar) => grammar.extension_id === installedExtension.id)
: [],
);
const extensionConfigurationProperties = $derived.by(() =>
installedExtension && explorer.workbenchModel.model
? explorer.workbenchModel.model.configuration_properties.filter(
(property) => property.extension_id === installedExtension.id,
)
: [],
);
const extensionMenuContributions = $derived.by(() => {
if (!installedExtension || !explorer.workbenchModel.model) {
return [] as Array<{ menuId: string; command: string; when: string | null; group: string | null }>;
}
return Object.entries(explorer.workbenchModel.model.menus)
.flatMap(([menuId, entries]) =>
entries
.filter((entry) => entry.extension_id === installedExtension.id)
.map((entry) => ({
menuId,
command: entry.command,
when: entry.when,
group: entry.group,
})),
)
.sort((left, right) => {
const menuComparison = left.menuId.localeCompare(right.menuId);
if (menuComparison !== 0) {
return menuComparison;
}
return left.command.localeCompare(right.command);
});
});
const inputSummary = $derived.by(() => {
const input = extensionInput;
if (!input) {
return null;
}
if (input.source === "installed") {
return installedExtension
? {
title: installedExtension.display_name,
publisher: installedExtension.publisher,
name: installedExtension.name,
version: installedExtension.version,
description: installedExtension.description ?? "",
iconUrl: installedExtension.icon
? getExtensionAssetUrl(installedExtension.id, installedExtension.icon)
: null,
installState: "installed" as const,
}
: null;
}
return {
title: input.displayName,
publisher: input.namespace,
name: input.name,
version: input.version,
description: input.description,
iconUrl: input.iconUrl,
installState: installedExtension ? "installed" as const : "marketplace" as const,
};
});
const contributionSections = $derived.by(() => {
if (installedExtension) {
return [
{
label: "Commands",
items: extensionCommands.map((command) => `${command.title} (${command.command})`),
},
{
label: "Languages",
items: extensionLanguages.map((language) => language.id),
},
{
label: "Grammars",
items: extensionGrammars.map((grammar) =>
grammar.language ? `${grammar.language} (${grammar.scope_name})` : grammar.scope_name,
),
},
{
label: "Menus",
items: extensionMenuContributions.map((contribution) => `${contribution.menuId}:${contribution.command}`),
},
{
label: "Settings",
items: extensionConfigurationProperties.map((property) => property.key),
},
{
label: "Activation Events",
items: installedExtension.activation_events,
},
].filter((section) => section.items.length > 0);
}
if (inspection.data) {
return [
{ label: "Commands", items: inspection.data.commands },
{ label: "Languages", items: inspection.data.languages },
{ label: "Grammars", items: inspection.data.grammars },
{ label: "Menus", items: inspection.data.menus },
{ label: "Settings", items: inspection.data.configuration_properties },
{ label: "Activation Events", items: inspection.data.activation_events },
].filter((section) => section.items.length > 0);
}
return [];
});
function inspectionKey(input: EditorInput & { kind: "extension" }): string {
return input.source === "marketplace" ? input.downloadUrl : `installed:${input.extensionId}`;
}
async function loadInspection(input: EditorInput & { kind: "extension" }) {
if (input.source !== "marketplace" || installedExtension) {
inspection.key = inspectionKey(input);
inspection.loading = false;
inspection.error = null;
inspection.data = null;
return;
}
const key = inspectionKey(input);
inspection.key = key;
inspection.loading = true;
inspection.error = null;
inspection.data = null;
try {
const data = await inspectMarketplaceExtension(input.downloadUrl);
if (inspection.key !== key) {
return;
}
inspection.data = data;
for (const warning of data.warnings) {
notifications.upsert(
`extension-inspect-warning:${key}:${warning}`,
"warning",
"Extension inspection warning",
warning,
"extensions",
);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (inspection.key !== key) {
return;
}
inspection.error = message;
notifications.upsert(
`extension-inspect:${key}`,
"error",
"Extension inspection failed",
message,
"extensions",
);
} finally {
if (inspection.key === key) {
inspection.loading = false;
}
}
}
function readmeKey(input: EditorInput & { kind: "extension" }): string {
return input.source === "installed"
? `installed:${input.extensionId}`
: `marketplace:${input.namespace}.${input.name}@${input.version}`;
}
async function loadReadme(input: EditorInput & { kind: "extension" }) {
const key = readmeKey(input);
readme.key = key;
readme.loading = true;
readme.error = null;
readme.content = null;
try {
const content = input.source === "installed"
? await getInstalledExtensionReadme(input.extensionId)
: await getMarketplaceExtensionReadme(input.namespace, input.name, input.version);
if (readme.key !== key) {
return;
}
readme.content = content;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (readme.key !== key) {
return;
}
readme.error = message;
notifications.upsert(
`extension-readme:${key}`,
"error",
"Extension README failed",
message,
"extensions",
);
} finally {
if (readme.key === key) {
readme.loading = false;
}
}
}
$effect(() => {
const input = extensionInput;
if (!input) {
readme.key = null;
readme.loading = false;
readme.error = null;
readme.content = null;
return;
}
const key = readmeKey(input);
if (readme.key === key && (readme.loading || readme.content !== null || readme.error !== null)) {
return;
}
void loadReadme(input);
});
$effect(() => {
const input = extensionInput;
if (!input) {
inspection.key = null;
inspection.loading = false;
inspection.error = null;
inspection.data = null;
return;
}
const key = inspectionKey(input);
if (inspection.key === key && (inspection.loading || inspection.data !== null || inspection.error !== null)) {
return;
}
void loadInspection(input);
});
async function handleInstall() {
const input = extensionInput;
if (!input || input.source !== "marketplace") {
return;
}
try {
await marketplace.install({
namespace: input.namespace,
name: input.name,
display_name: input.displayName,
description: input.description,
version: input.version,
download_url: input.downloadUrl,
icon_url: input.iconUrl,
average_rating: null,
download_count: 0,
verified: input.verified,
});
const installed = explorer.workbenchModel.model?.extensions.find(
(extension) => extension.publisher === input.namespace && extension.name === input.name,
);
if (!installed) {
throw new Error(`installed extension '${input.namespace}.${input.name}' was not discovered after install`);
}
workbench.openInstalledExtension(installed.id);
} catch (error) {
notifications.upsert(
`extension-install:${input.namespace}.${input.name}`,
"error",
"Install failed",
error instanceof Error ? error.message : String(error),
"extensions",
);
}
}
async function handleUninstall() {
if (!installedExtension) return;
try {
await marketplace.uninstall(installedExtension.id);
} catch (error) {
notifications.upsert(
`extension-uninstall:${installedExtension.id}`,
"error",
"Uninstall failed",
error instanceof Error ? error.message : String(error),
"extensions",
);
}
}
async function handleToggleEnabled() {
if (!installedExtension) return;
try {
if (disabled) {
await marketplace.enable(installedExtension.id);
} else {
await marketplace.disable(installedExtension.id);
}
} catch (error) {
notifications.upsert(
`extension-toggle:${installedExtension.id}`,
"error",
"Toggle failed",
error instanceof Error ? error.message : String(error),
"extensions",
);
}
}
function openInstalledRecord() {
if (installedExtension) {
workbench.openInstalledExtension(installedExtension.id);
}
}
const inspectionButtonLabel = $derived.by(() => {
if (extensionInput?.source === "marketplace" && !installedExtension) {
return marketplace.isInstalling(extensionInput.namespace, extensionInput.name) ? "Installing..." : "Install";
}
return "Inspect";
});
</script>
<div class="extensions-page">
{#if explorer.workbenchModel.loading && !explorer.workbenchModel.loaded}
<div class="page-state">Loading extensions...</div>
{:else if explorer.workbenchModel.error}
<div class="page-error">{explorer.workbenchModel.error}</div>
{:else if !extensionInput}
<div class="page-state">Select an extension from Marketplace or Installed.</div>
{:else if extensionInput.source === "installed" && !installedExtension}
<div class="page-error">Installed extension record no longer exists: {extensionInput.extensionId}</div>
{:else if !inputSummary}
<div class="page-error">Extension metadata is unavailable.</div>
{:else}
<div class="extension-editor">
<header class="extension-header">
<div class="extension-identity">
<div class="extension-icon">
{#if inputSummary.iconUrl}
<img src={inputSummary.iconUrl} alt="" width="52" height="52" />
{:else}
<div class="extension-icon-fallback">
<AppIcon name="code" size={22} />
</div>
{/if}
</div>
<div class="extension-meta">
<div class="title-row">
<h1>{inputSummary.title}</h1>
<span class="version-pill">{inputSummary.version}</span>
{#if installedExtension && disabled}
<span class="status-pill warning">Disabled</span>
{:else if inputSummary.installState === "installed"}
<span class="status-pill">Installed</span>
{/if}
</div>
<div class="extension-id">{inputSummary.publisher}.{inputSummary.name}</div>
{#if inputSummary.description}
<p class="extension-description">{inputSummary.description}</p>
{/if}
</div>
</div>
<div class="extension-actions">
{#if extensionInput.source === "marketplace" && !installedExtension}
<div class="inspect-trigger">
<button
class="action-btn action-btn--primary"
disabled={!extensionInput.downloadUrl || marketplace.isInstalling(extensionInput.namespace, extensionInput.name)}
onclick={handleInstall}
>
<AppIcon name="download" size={12} />
{inspectionButtonLabel}
</button>
<div class="inspect-panel" role="dialog" aria-label="Extension contributions">
<div class="inspect-panel-header">
<div>
<div class="inspect-panel-title">Extension Surface</div>
<div class="inspect-panel-subtitle">Parsed from the VSIX before install.</div>
</div>
</div>
{#if inspection.loading}
<div class="inspect-state">Inspecting packageā¦</div>
{:else if inspection.error}
<div class="inspect-state inspect-state--error">{inspection.error}</div>
{:else if contributionSections.length === 0}
<div class="inspect-state">No contributions declared by this extension.</div>
{:else}
{#if inspection.data?.runtime_entrypoint}
<div class="inspect-inline-meta">
<span class="inspect-meta-pill">{inspection.data.runtime_entrypoint}</span>
</div>
{/if}
<div class="inspect-section-list">
{#each contributionSections as section}
<section class="inspect-section">
<div class="inspect-section-title">{section.label}</div>
<div class="inspect-item-list">
{#each section.items as item}
<div class="inspect-item">{item}</div>
{/each}
</div>
</section>
{/each}
</div>
{/if}
</div>
</div>
{/if}
{#if extensionInput.source === "marketplace" && installedExtension}
<button class="action-btn" onclick={openInstalledRecord}>
<AppIcon name="code" size={12} />
Open Installed
</button>
{/if}
{#if installedExtension}
<div class="inspect-trigger">
<button class="action-btn" type="button">
<AppIcon name="layout-list" size={12} />
Inspect
</button>
<div class="inspect-panel" role="dialog" aria-label="Extension contributions">
<div class="inspect-panel-header">
<div>
<div class="inspect-panel-title">Extension Surface</div>
<div class="inspect-panel-subtitle">Declared and discovered contributions.</div>
</div>
</div>
<div class="inspect-inline-meta">
<span class="inspect-meta-pill">{installedExtension.package_path}</span>
{#if installedExtension.runtime?.entrypoint}
<span class="inspect-meta-pill">{installedExtension.runtime.entrypoint}</span>
{/if}
</div>
{#if contributionSections.length === 0}
<div class="inspect-state">No contributions declared by this extension.</div>
{:else}
<div class="inspect-section-list">
{#each contributionSections as section}
<section class="inspect-section">
<div class="inspect-section-title">{section.label}</div>
<div class="inspect-item-list">
{#each section.items as item}
<div class="inspect-item">{item}</div>
{/each}
</div>
</section>
{/each}
{#if installedExtension.runtime}
<section class="inspect-section">
<div class="inspect-section-title">Runtime Surface</div>
<div class="inspect-item-list">
{#each installedExtension.runtime.export_names as name}
<div class="inspect-item">{name}</div>
{/each}
{#each installedExtension.runtime.builtin_modules as builtin}
<div class="inspect-item">{builtin}</div>
{/each}
{#each installedExtension.runtime.server_entrypoints as entrypoint}
<div class="inspect-item">{entrypoint}</div>
{/each}
{#each installedExtension.runtime.registered_commands as command}
<div class="inspect-item">{command}</div>
{/each}
{#each installedExtension.runtime.output_channels as outputChannel}
<div class="inspect-item">{outputChannel}</div>
{/each}
{#each installedExtension.runtime.workspace_listeners as listener}
<div class="inspect-item">{listener}</div>
{/each}
<div class="inspect-item">subscriptions:{installedExtension.runtime.subscription_count}</div>
</div>
</section>
{/if}
</div>
{/if}
</div>
</div>
{/if}
{#if installedExtension}
<button class="action-btn" onclick={handleToggleEnabled}>
<AppIcon name={disabled ? "eye-off" : "eye"} size={12} />
{disabled ? "Enable" : "Disable"}
</button>
<button class="action-btn action-btn--danger" onclick={handleUninstall}>
<AppIcon name="trash" size={12} />
Uninstall
</button>
{/if}
<button class="action-btn" onclick={() => void explorer.loadWorkbenchModel()}>
<AppIcon name="refresh" size={12} />
Refresh
</button>
</div>
</header>
<div class="readme-panel">
{#if readme.loading}
<div class="page-state">Loading README...</div>
{:else if readme.error}
<div class="page-error">{readme.error}</div>
{:else if readme.content === null}
<div class="page-state">README is unavailable.</div>
{:else}
<MarkdownPreview content={readme.content} />
{/if}
</div>
</div>
{/if}
</div>
<style>
.extensions-page {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
}
.extension-editor {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
background: var(--vscode-editor-background);
}
.extension-header {
display: flex;
flex-direction: column;
gap: var(--wb-spacing-12);
padding: var(--wb-spacing-14) var(--wb-spacing-18) var(--wb-spacing-12);
border-bottom: 1px solid var(--vscode-panel-border);
background: color-mix(in srgb, var(--vscode-sideBar-background) 94%, black 6%);
}
.extension-identity {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--wb-spacing-14);
}
.extension-icon,
.extension-icon-fallback {
width: var(--wb-control-size-52);
height: var(--wb-control-size-52);
border-radius: var(--wb-corner-radius-14);
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background: var(--vscode-input-background);
border: 1px solid var(--vscode-panel-border);
flex-shrink: 0;
}
.extension-icon img {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
.extension-meta {
flex: 1;
min-width: 0;
}
.title-row {
display: flex;
align-items: center;
gap: var(--wb-spacing-10);
flex-wrap: wrap;
}
h1 {
margin: 0;
font-size: var(--wb-font-size-18);
line-height: 1.1;
}
.version-pill,
.status-pill {
display: inline-flex;
align-items: center;
height: var(--wb-control-size-24);
padding: 0 var(--wb-spacing-10);
border-radius: 999px;
border: 1px solid var(--vscode-widget-border);
background: var(--vscode-input-background);
font-size: var(--wb-font-size-11);
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--vscode-foreground);
}
.status-pill.warning {
color: var(--vscode-editorWarning-foreground);
}
.extension-id,
.extension-description,
.page-state {
color: var(--vscode-disabledForeground);
}
.extension-id {
margin-top: var(--wb-spacing-4);
font-size: var(--wb-font-size-11);
}
.extension-description {
margin: var(--wb-spacing-8) 0 0;
max-width: 900px;
font-size: var(--wb-font-size-12);
line-height: 1.5;
}
.extension-actions {
display: flex;
align-items: center;
gap: var(--wb-spacing-8);
flex-wrap: wrap;
}
.action-btn {
display: inline-flex;
align-items: center;
gap: var(--wb-spacing-6);
height: var(--wb-control-size-32);
padding: 0 var(--wb-spacing-12);
border-radius: var(--wsb-radius-sm);
border: 1px solid var(--vscode-panel-border);
background: var(--vscode-sideBar-background);
color: var(--vscode-editor-foreground);
font-size: var(--wb-font-size-12);
}
.inspect-trigger {
position: relative;
display: inline-flex;
}
.inspect-panel {
position: absolute;
top: calc(100% + var(--wb-spacing-10));
left: 0;
z-index: 20;
width: min(560px, calc(100vw - var(--wb-control-size-48)));
max-height: 420px;
overflow: auto;
padding: var(--wb-spacing-14);
border-radius: var(--wb-corner-radius-14);
border: 1px solid var(--vscode-widget-border);
background: color-mix(in srgb, var(--vscode-editor-background) 96%, black 4%);
box-shadow: 0 18px 40px color-mix(in srgb, var(--vscode-widget-shadow) 75%, transparent);
opacity: 0;
visibility: hidden;
transform: translateY(-4px);
transition: opacity 120ms ease, transform 120ms ease, visibility 120ms ease;
pointer-events: none;
}
.inspect-trigger:hover .inspect-panel,
.inspect-trigger:focus-within .inspect-panel {
opacity: 1;
visibility: visible;
transform: translateY(0);
pointer-events: auto;
}
.inspect-panel-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--wb-spacing-12);
margin-bottom: var(--wb-spacing-12);
}
.inspect-panel-title {
font-size: var(--wb-font-size-13);
font-weight: 700;
color: var(--vscode-editor-foreground);
}
.inspect-panel-subtitle,
.inspect-state {
font-size: var(--wb-font-size-11);
color: var(--vscode-disabledForeground);
}
.inspect-state--error {
color: var(--vscode-errorForeground);
}
.inspect-inline-meta {
display: flex;
flex-wrap: wrap;
gap: var(--wb-spacing-6);
margin-bottom: var(--wb-spacing-12);
}
.inspect-meta-pill {
display: inline-flex;
align-items: center;
min-height: var(--wb-control-size-22);
padding: 0 var(--wb-spacing-8);
border-radius: 999px;
background: var(--vscode-input-background);
border: 1px solid var(--vscode-widget-border);
font-size: var(--wb-font-size-10);
font-family: var(--wsb-font-mono);
color: var(--vscode-foreground);
}
.inspect-section-list {
display: grid;
gap: var(--wb-spacing-12);
}
.inspect-section {
display: grid;
gap: var(--wb-spacing-6);
}
.inspect-section-title {
font-size: var(--wb-font-size-10);
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--vscode-disabledForeground);
}
.inspect-item-list {
display: flex;
flex-wrap: wrap;
gap: var(--wb-spacing-6);
}
.inspect-item {
display: inline-flex;
align-items: center;
min-height: var(--wb-control-size-24);
padding: 0 var(--wb-spacing-8);
border-radius: var(--wb-corner-radius-8);
background: color-mix(in srgb, var(--vscode-input-background) 82%, transparent);
border: 1px solid color-mix(in srgb, var(--vscode-widget-border) 88%, transparent);
color: var(--vscode-editor-foreground);
font-size: var(--wb-font-size-11);
line-height: 1.4;
}
.action-btn--primary {
background: var(--vscode-button-background);
border-color: transparent;
color: var(--vscode-button-foreground);
}
.action-btn--danger {
color: var(--vscode-errorForeground);
}
.action-btn:disabled {
opacity: 0.55;
}
.readme-panel {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.page-state,
.page-error {
padding: var(--wb-spacing-24);
font-size: var(--wb-font-size-13);
}
.page-error {
color: var(--vscode-errorForeground);
}
@media (max-width: 900px) {
.extension-header {
padding: var(--wb-spacing-16);
}
.extension-identity {
flex-direction: column;
}
}
</style>