Compare commits

...
Author SHA1 Message Date
l0ng-ai cccefcc078 feat(plugins): open the plugins page on Available 2026-11-11 20:06:13 +08:00
l0ng-ai 4bb76acf66 feat(plugins): drop the Preview badge from the plugins page 2026-11-11 20:04:43 +08:00
l0ng-ai 187c0324fd fix(plugins): plugin icons fill their tile like the initials 2026-11-11 20:03:46 +08:00
l0ng-ai 2fca3e0607 feat(plugins): a marketplace that helps pick plugins
Rows show each plugin's description, publisher, downloads and an inline
Install / Update button; plugins without an icon get a tinted initial.
While browsing, the list opens with suggestions for the open project and
the maintainers' picks, then all community plugins by downloads; the
official plugins come last as examples. The first plugin is selected so
the details are never empty, and they show release notes, the homepage
and why a plugin is suggested.

The index gains optional homepage, notes, downloads, featured and suggest
fields; openidea-plugin index fills homepage and notes from the manifest.
2026-11-11 19:43:34 +08:00
19 changed files with 827 additions and 165 deletions

No files matched your search

+40
View File
@@ -352,6 +352,22 @@ pub struct IndexEntry {
/// the IDE before showing it.
#[serde(default)]
pub icon: Option<PluginIcon>,
/// Where to read more: the manifest's homepage, else the registry's source repository.
#[serde(default)]
pub homepage: Option<String>,
/// This version's release notes (the manifest's `change-notes`).
#[serde(default)]
pub notes: Option<String>,
/// Downloads of the plugin's packages, as counted by whoever serves them.
#[serde(default)]
pub downloads: Option<u64>,
/// Picked by the marketplace's maintainers.
#[serde(default)]
pub featured: bool,
/// Project-relative paths whose presence makes the plugin worth suggesting
/// (`pom.xml`, `config/routes.rb`).
#[serde(default)]
pub suggest: Vec<String>,
}
impl IndexEntry {
@@ -685,9 +701,33 @@ mod tests {
engine: "^1.0".into(),
categories: Vec::new(),
icon: None,
homepage: None,
notes: None,
downloads: None,
featured: false,
suggest: Vec::new(),
}
}
#[test]
fn listing_details_are_optional() {
let mut json = serde_json::to_value(entry("acme.a", "acme")).unwrap();
for key in ["homepage", "notes", "downloads", "featured", "suggest"] {
json.as_object_mut().unwrap().remove(key);
}
assert_eq!(serde_json::from_value::<IndexEntry>(json).unwrap(), entry("acme.a", "acme"));
let full = IndexEntry {
homepage: Some("https://example.com".into()),
notes: Some("Fixes".into()),
downloads: Some(42),
featured: true,
suggest: vec!["pom.xml".into()],
..entry("acme.a", "acme")
};
let back: IndexEntry = serde_json::from_str(&serde_json::to_string(&full).unwrap()).unwrap();
assert_eq!(back, full);
}
fn version(id: &str, version: &str, engine: &str) -> IndexEntry {
IndexEntry { version: version.into(), engine: engine.into(), ..entry(id, "acme") }
}
@@ -653,6 +653,11 @@ fn cmd_index(args: &[String]) -> Result<(), String> {
engine: package::engine_requirement(&m.engine, built_for.as_ref()),
categories: m.categories(),
icon: plugin_icon(&ide_extension::Package::new(m.clone(), scratch.clone()))?,
homepage: m.homepage.clone().filter(|h| !h.trim().is_empty()),
notes: m.change_notes.clone().filter(|n| !n.trim().is_empty()),
downloads: None,
featured: false,
suggest: Vec::new(),
})
})();
let entry = entry.map_err(|e| format!("{}: {e}", path.display()))?;
+27 -1
View File
@@ -31,6 +31,7 @@ const MAX_STAGED_FILES: usize = 256;
const MAX_STAGED_BYTES: usize = 16 << 20;
/// `moved` links followed before giving up (a loop, or a misconfiguration).
const MAX_MOVES: usize = 3;
const MAX_SUGGESTIONS: usize = 8;
/// A marketplace entry with what is installed.
#[derive(Clone, Debug, Serialize, Deserialize, specta::Type)]
@@ -42,6 +43,9 @@ pub struct MarketPlugin {
pub installed: Option<String>,
/// Whether this IDE can load `entry` (the newest version it can, else the newest).
pub compatible: bool,
/// The publisher's display name, as the index lists it.
#[serde(default)]
pub publisher_name: Option<String>,
}
impl MarketPlugin {
@@ -76,6 +80,19 @@ fn safe_icon(icon: PluginIcon) -> Option<PluginIcon> {
Some(PluginIcon { light: clean(icon.light)?, dark: icon.dark.and_then(clean) })
}
/// Paths an index suggests a plugin for, as the UI may look them up: relative,
/// inside the project, and few.
fn safe_suggestions(paths: Vec<String>) -> Vec<String> {
let ok = |p: &String| {
!p.is_empty()
&& p.len() <= 128
&& !p.starts_with('/')
&& !p.contains(['\\', ':', '\0', '*', '?'])
&& p.split('/').all(|part| !matches!(part, "" | "." | ".."))
};
paths.into_iter().filter(ok).take(MAX_SUGGESTIONS).collect()
}
/// Reads `url`: `http(s)://…`, `file://…` or a local path.
pub fn read_url(url: &str, max: usize) -> Result<Vec<u8>, String> {
if url.starts_with("https://") || url.starts_with("http://") {
@@ -253,7 +270,9 @@ impl PluginHost {
.map(|p| p.manifest().version.clone());
let mut entry = entry.clone();
entry.icon = entry.icon.and_then(safe_icon);
MarketPlugin { entry, installed, compatible }
entry.suggest = safe_suggestions(std::mem::take(&mut entry.suggest));
let publisher_name = index.publishers.iter().find(|p| p.id == entry.publisher).map(|p| p.name.clone());
MarketPlugin { entry, installed, compatible, publisher_name }
})
.collect()
}
@@ -482,4 +501,11 @@ mod tests {
assert_eq!(resolve(r"C:\market\index.json", "a.oiplugin"), r"C:\market\a.oiplugin");
assert_eq!(resolve("https://x.dev/index.json", "https://cdn.dev/a"), "https://cdn.dev/a");
}
#[test]
fn suggestions_stay_inside_the_project() {
let paths = ["pom.xml", "config/routes.rb", "/etc/passwd", "../x", "a//b", "C:x", "*.php", ""];
assert_eq!(safe_suggestions(paths.iter().map(|p| p.to_string()).collect()), ["pom.xml", "config/routes.rb"]);
assert_eq!(safe_suggestions(vec!["a".into(); 20]).len(), MAX_SUGGESTIONS);
}
}
+5
View File
@@ -177,6 +177,11 @@ fn signed(
engine: "^1.0".into(),
categories: vec!["tools".into()],
icon: None,
homepage: None,
notes: None,
downloads: None,
featured: false,
suggest: vec![],
};
(out, entry)
}
+15
View File
@@ -5515,6 +5515,19 @@ export type IndexEntry = {
* the IDE before showing it.
*/
icon?: PluginIcon | null,
/** Where to read more: the manifest's homepage, else the registry's source repository. */
homepage?: string | null,
/** This version's release notes (the manifest's `change-notes`). */
notes?: string | null,
/** Downloads of the plugin's packages, as counted by whoever serves them. */
downloads?: number | null,
/** Picked by the marketplace's maintainers. */
featured?: boolean,
/**
* Project-relative paths whose presence makes the plugin worth suggesting
* (`pom.xml`, `config/routes.rb`).
*/
suggest?: string[],
};
export type IndexStatus = {
@@ -6460,6 +6473,8 @@ export type MarketPlugin = {
installed: string | null,
/** Whether this IDE can load `entry` (the newest version it can, else the newest). */
compatible: boolean,
/** The publisher's display name, as the index lists it. */
publisherName?: string | null,
} & IndexEntry;
export type Markup = {
+90 -7
View File
@@ -26,13 +26,20 @@
display: flex;
flex: none;
flex-direction: column;
width: 280px;
width: 340px;
min-height: 0;
border-right: 1px solid var(--border);
}
.search {
.searchRow {
display: flex;
align-items: center;
gap: 6px;
margin: 8px;
}
.searchField {
flex: 1;
min-width: 0;
}
.spacer {
flex: 1;
}
@@ -398,13 +405,89 @@
margin-bottom: 12px;
}
.marketItem {
list-style: none;
padding: 4px 8px;
.categorySelect {
flex: none;
width: 150px;
}
.ownIcon {
background: transparent;
}
.categorySelect {
width: 100%;
.marketRow .rowMain {
align-items: flex-start;
min-height: 60px;
}
.marketRow .icon {
margin-top: 2px;
}
.rowTitle {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.rowDescription {
overflow: hidden;
color: var(--fg-secondary);
font-size: var(--fs-small);
text-overflow: ellipsis;
white-space: nowrap;
}
.downloads {
display: inline-flex;
flex: none;
align-items: center;
gap: 2px;
color: var(--fg-muted);
font-size: var(--fs-small);
font-variant-numeric: tabular-nums;
}
.exampleTag {
flex: none;
padding: 0 4px;
border-radius: var(--radius-small);
background: var(--bg-control);
color: var(--fg-muted);
font-size: var(--fs-tiny);
line-height: 14px;
}
.groupNote {
padding: 0 12px 4px;
color: var(--fg-muted);
font-size: var(--fs-small);
}
.suggestNotice {
background: color-mix(in srgb, var(--success) 12%, transparent);
}
/* plugins without an icon: their initial on a tint picked by id */
.monogram {
font-weight: 600;
font-size: var(--fs-input-large);
--tone: var(--info);
background: color-mix(in srgb, var(--tone) 18%, transparent);
color: var(--tone);
}
.iconLarge.monogram {
font-size: 24px;
}
.tone0 {
--tone: var(--info);
}
.tone1 {
--tone: var(--success);
}
.tone2 {
--tone: var(--warning);
}
.tone3 {
--tone: var(--danger);
}
.tone4 {
--tone: var(--ansi-magenta);
}
.tone5 {
--tone: var(--ansi-cyan);
}
.detailsNotice {
+333 -107
View File
@@ -34,6 +34,8 @@ import {
} from "@/ui";
import { RISK_LABEL, sortByRisk, capabilityDescription } from "./consent";
import { pluginIconName } from "./pluginIcon";
import { formatDownloads, groupMarket, isExample, monogram, suggestedFor, suggestionPaths } from "./market";
import { projectHas } from "./workspaceContains";
import { localizeContributions, usePluginCatalogs, usePluginT } from "./pluginText";
import { itemsOf, uiPoint } from "./projection";
import {
@@ -65,7 +67,13 @@ import { pluginsBackend } from "./backend";
import { confirm } from "@/workbench/messages";
import { tx } from "@/platform/i18n";
const STATUS: Record<PluginStatus, { label: string; tone: "success" | "accent" | "neutral" | "danger" | "warning" }> = {
const STATUS: Record<
PluginStatus,
{
label: string;
tone: "success" | "accent" | "neutral" | "danger" | "warning";
}
> = {
active: { label: "Running", tone: "success" },
inactive: { label: "Enabled", tone: "accent" },
static: { label: "Enabled", tone: "accent" },
@@ -138,14 +146,47 @@ function useMarketUpdate(plugin: PluginInfo): MarketPlugin | undefined {
);
}
function PluginIcon({ id, icon, large }: { id: string; icon: PluginIconData | null | undefined; large?: boolean }) {
function PluginIcon({
id,
icon,
large,
name,
}: {
id: string;
icon: PluginIconData | null | undefined;
large?: boolean;
/** Without an icon: its first letter on a tint of its own instead of the generic icon. */
name?: string;
}) {
if (!icon && name) {
const { letter, tone } = monogram(id, name);
return (
<span
className={clsx(styles.icon, large && styles.iconLarge, styles.monogram, styles[`tone${tone}`])}
aria-hidden
>
{letter}
</span>
);
}
// A plugin's own icon fills the tile, as the initials do; the generic one sits on a plate.
return (
<span className={clsx(styles.icon, large && styles.iconLarge)} aria-hidden>
<Icon name={pluginIconName(id, icon)} size={large ? 32 : 20} />
<span className={clsx(styles.icon, large && styles.iconLarge, icon && styles.ownIcon)} aria-hidden>
<Icon name={pluginIconName(id, icon)} size={icon ? (large ? 56 : 32) : large ? 32 : 20} />
</span>
);
}
function ExampleTag() {
return (
<span className={styles.exampleTag} title={exampleNote()}>
<T>Example</T>
</span>
);
}
const exampleNote = () => tx("Shows how to use the plugin API; not maintained as a product.");
function ListHeader({ title, count }: { title: string; count: number }) {
return (
<li className={styles.groupHeader} aria-hidden>
@@ -169,7 +210,10 @@ function InstalledRow({ plugin, selected, onSelect }: { plugin: PluginInfo; sele
>
<PluginIcon id={plugin.id} icon={plugin.icon} />
<span className={styles.rowText}>
<span className={styles.rowName}>{plugin.name}</span>
<span className={styles.rowTitle}>
<span className={styles.rowName}>{plugin.name}</span>
{plugin.source === "installed" && isExample(plugin) && <ExampleTag />}
</span>
<span className={styles.rowMeta}>
<span className={styles.rowVendor}>{vendorOf(plugin.authors) || plugin.id}</span>
<span>{plugin.version}</span>
@@ -434,7 +478,14 @@ function ProgramPermission({ plugin, cap, disabled }: { plugin: PluginInfo; cap:
const message = reset
? tx("Revalidate the default executable? If it is unavailable, this permission will be revoked.")
: tx`Allow ${plugin.name} to run ${selected}? This is equivalent to running arbitrary code on this machine.`;
if (!(await confirm({ title: tx("Approve plugin executable"), message, ok: tx("Allow"), icon: "warning" })))
if (
!(await confirm({
title: tx("Approve plugin executable"),
message,
ok: tx("Allow"),
icon: "warning",
}))
)
return;
await pluginsBackend().chooseProgram(plugin.id, cap.id, scope, selected);
await refreshPlugins();
@@ -649,6 +700,7 @@ function InstalledDetails({ plugin }: { plugin: PluginInfo }) {
{plugin.source !== "installed" && (
<Badge tone="warning">{plugin.source === "linked" ? tx("Linked plugin") : tx("Project plugin")}</Badge>
)}
{plugin.source === "installed" && isExample(plugin) && <ExampleTag />}
{plugin.source === "installed" &&
(plugin.publisher ? (
<Badge tone="success">{tx`Signed by ${plugin.publisher}`}</Badge>
@@ -821,14 +873,27 @@ function gearMenu(plugins: readonly PluginInfo[], updates: number, actions: Gear
run: () => void updateAllPlugins(),
},
{ kind: "separator" },
{ label: "Install Plugin from Disk…", icon: "package", run: () => actions.install(false) },
{ label: "Install Plugin from Package File…", run: () => actions.install(true) },
{
label: "Install Plugin from Disk…",
icon: "package",
run: () => actions.install(false),
},
{
label: "Install Plugin from Package File…",
run: () => actions.install(true),
},
{ label: "Plugin Repository…", run: actions.editRepository },
...(actions.canImport
? [
{ kind: "separator" as const },
{ label: "Import Theme from JetBrains IDE…", run: actions.importInstalledIntellij },
{ label: "Import IntelliJ Theme from File…", run: actions.importIntellijFile },
{
label: "Import Theme from JetBrains IDE…",
run: actions.importInstalledIntellij,
},
{
label: "Import IntelliJ Theme from File…",
run: actions.importIntellijFile,
},
]
: []),
{ kind: "separator" },
@@ -910,7 +975,11 @@ function CategoryFilter({
const counts = new Map<Category, number>();
for (const m of market) for (const c of categoriesOf(m)) counts.set(c, (counts.get(c) ?? 0) + 1);
const options = [
{ value: "all" as const, label: tx("All Categories"), detail: String(market.length) },
{
value: "all" as const,
label: tx("All Categories"),
detail: String(market.length),
},
...CATEGORIES.filter((c) => counts.has(c)).map((c) => ({
value: c,
label: categoryLabel(c),
@@ -928,10 +997,46 @@ function CategoryFilter({
);
}
function MarketRow({ entry, selected, onSelect }: { entry: MarketPlugin; selected: boolean; onSelect: () => void }) {
const publisherOf = (m: MarketPlugin) => m.publisherName || m.publisher;
/** Install / Update in a row or the details; what is installed otherwise. */
function MarketAction({
entry,
compact,
onInstalled,
}: {
entry: MarketPlugin;
compact?: boolean;
onInstalled?: (id: string) => void;
}) {
const installing = usePlugins((s) => s.installing);
const update = isUpdate(entry);
if (!entry.compatible) {
return compact ? <Badge tone="warning">{tx("Incompatible")}</Badge> : null;
}
if (entry.installed && !update) {
return compact ? <span className={styles.installedTag}>{tx("Installed")}</span> : null;
}
const label = update ? (compact ? tx("Update") : tx`Update to ${entry.version}`) : tx("Install");
return (
<li className={clsx(styles.row, selected && styles.rowSelected)}>
<Button
size="small"
variant={update || !compact ? "primary" : "secondary"}
disabled={installing}
aria-label={compact ? `${update ? tx("Update") : tx("Install")} ${entry.name}` : undefined}
onClick={(e) => {
e.stopPropagation();
void installFromMarket(entry.id).then((id) => id && onInstalled?.(id));
}}
>
{label}
</Button>
);
}
function MarketRow({ entry, selected, onSelect }: { entry: MarketPlugin; selected: boolean; onSelect: () => void }) {
return (
<li className={clsx(styles.row, styles.marketRow, selected && styles.rowSelected)}>
<button
type="button"
className={styles.rowMain}
@@ -939,99 +1044,189 @@ function MarketRow({ entry, selected, onSelect }: { entry: MarketPlugin; selecte
onClick={onSelect}
title={entry.description || entry.name}
>
<PluginIcon id={entry.id} icon={entry.icon} />
<PluginIcon id={entry.id} icon={entry.icon} name={entry.name} />
<span className={styles.rowText}>
<span className={styles.rowName}>{entry.name}</span>
<span className={styles.rowTitle}>
<span className={styles.rowName}>{entry.name}</span>
{isExample(entry) && <ExampleTag />}
</span>
{entry.description && <span className={styles.rowDescription}>{entry.description}</span>}
<span className={styles.rowMeta}>
<span className={styles.rowVendor}>{entry.publisher}</span>
<span>{entry.version}</span>
{!isExample(entry) && <span className={styles.rowVendor}>{publisherOf(entry)}</span>}
{entry.downloads != null && (
<span className={styles.downloads} title={tx`${entry.downloads} downloads`}>
<Icon name="download" size={12} />
{formatDownloads(entry.downloads)}
</span>
)}
</span>
</span>
</button>
{update ? (
<Badge tone="accent">{tx("Update")}</Badge>
) : entry.installed ? (
<Badge tone="neutral">{tx("Installed")}</Badge>
) : !entry.compatible ? (
<Badge tone="warning">{tx("Incompatible")}</Badge>
) : null}
<MarketAction entry={entry} compact />
</li>
);
}
function MarketDetails({ entry, onInstalled }: { entry: MarketPlugin; onInstalled: (id: string) => void }) {
const installing = usePlugins((s) => s.installing);
const update = isUpdate(entry);
const install = async () => {
const id = await installFromMarket(entry.id);
if (id) onInstalled(id);
};
function MarketGroup({
title,
entries,
selectedId,
onSelect,
note,
idPrefix,
}: {
title: string;
entries: readonly MarketPlugin[];
selectedId: string | undefined;
onSelect: (id: string) => void;
note?: string;
idPrefix: string;
}) {
if (entries.length === 0) return null;
return (
<>
<ListHeader title={title} count={entries.length} />
{note && <li className={styles.groupNote}>{note}</li>}
{entries.map((m) => (
<MarketRow
key={`${idPrefix}:${m.id}`}
entry={m}
selected={m.id === selectedId}
onSelect={() => onSelect(m.id)}
/>
))}
</>
);
}
function MarketDetails({
entry,
suggestedBy,
onInstalled,
}: {
entry: MarketPlugin;
suggestedBy: string | undefined;
onInstalled: (id: string) => void;
}) {
const example = isExample(entry);
const overview = (
<>
{entry.description && <p className={styles.description}>{entry.description}</p>}
<dl className={styles.facts}>
<dt>
<T>Id</T>
</dt>
<dd className={styles.mono}>{entry.id}</dd>
<dt>
<T>Category</T>
</dt>
<dd>{categoriesOf(entry).map(categoryLabel).join(", ")}</dd>
{entry.homepage && (
<>
<dt>
<T>Homepage</T>
</dt>
<Homepage value={entry.homepage} />
</>
)}
{entry.installed && (
<>
<dt>
<T>Installed</T>
</dt>
<dd>{entry.installed}</dd>
</>
)}
</dl>
{(entry.capabilities?.length ?? 0) > 0 && (
<Section title="Permissions">
<ul className={styles.items}>
{(entry.capabilities ?? []).map((c) => (
<li key={c} className={clsx(styles.item, styles.mono)}>
{c}
</li>
))}
</ul>
</Section>
)}
</>
);
return (
<div className={styles.details} aria-label={`${entry.name} details`}>
<header className={styles.header}>
<PluginIcon id={entry.id} icon={entry.icon} large />
<PluginIcon id={entry.id} icon={entry.icon} name={entry.name} large />
<div className={styles.headerText}>
<h2 className={styles.title}>{entry.name}</h2>
<div className={styles.authors}>{entry.authors?.length ? vendorOf(entry.authors) : entry.publisher}</div>
<div className={styles.authors}>{entry.authors?.length ? vendorOf(entry.authors) : publisherOf(entry)}</div>
<div className={styles.titleLine}>
<span className={styles.version}>{entry.version}</span>
<Badge tone="success">{tx`Signed by ${entry.publisher}`}</Badge>
{example && <ExampleTag />}
<Badge tone="success">{tx`Signed by ${publisherOf(entry)}`}</Badge>
{entry.downloads != null && (
<span className={styles.downloads}>
<Icon name="download" size={12} />
{tx`${formatDownloads(entry.downloads)} downloads`}
</span>
)}
</div>
</div>
<div className={styles.headerActions}>
{entry.compatible && (!entry.installed || update) && (
<Button size="small" variant="primary" disabled={installing} onClick={() => void install()}>
{update ? tx`Update to ${entry.version}` : tx("Install")}
</Button>
)}
<MarketAction entry={entry} onInstalled={onInstalled} />
</div>
</header>
<div className={styles.marketOverview}>
{!entry.compatible && (
<p className={clsx(styles.notice, styles.detailsNotice)} role="note">
<Icon name="info" size={14} />
<span>{tx`Needs plugin API ${entry.engine}. Update OpenIDEA to install it.`}</span>
</p>
)}
{entry.description && <p className={styles.description}>{entry.description}</p>}
<dl className={styles.facts}>
<dt>
<T>Id</T>
</dt>
<dd className={styles.mono}>{entry.id}</dd>
<dt>
<T>Category</T>
</dt>
<dd>{categoriesOf(entry).map(categoryLabel).join(", ")}</dd>
{entry.installed && (
<>
<dt>
<T>Installed</T>
</dt>
<dd>{entry.installed}</dd>
</>
)}
</dl>
{(entry.capabilities?.length ?? 0) > 0 && (
<Section title="Permissions">
<ul className={styles.items}>
{(entry.capabilities ?? []).map((c) => (
<li key={c} className={clsx(styles.item, styles.mono)}>
{c}
</li>
))}
</ul>
</Section>
)}
</div>
{!entry.compatible && (
<p className={clsx(styles.notice, styles.detailsNotice)} role="note">
<Icon name="info" size={14} />
<span>{tx`Needs plugin API ${entry.engine}. Update OpenIDEA to install it.`}</span>
</p>
)}
{example && (
<p className={clsx(styles.notice, styles.detailsNotice)} role="note">
<Icon name="info" size={14} />
<span>
{tx(
"This plugin shows how to use the plugin API and is not maintained as a product. Build on it in your own repository.",
)}
</span>
</p>
)}
{suggestedBy && (
<p className={clsx(styles.notice, styles.suggestNotice, styles.detailsNotice)} role="note">
<Icon name="info" size={14} />
<span>{tx`Suggested because this project has ${suggestedBy}.`}</span>
</p>
)}
<DetailTabs notes={entry.notes ?? null} overview={overview} />
</div>
);
}
export function PluginsPage() {
/** Each marketplace plugin the open project has a suggestion path for (id → path). */
function useSuggestions(market: readonly MarketPlugin[] | null): Map<string, string> {
const root = useProject((s) => s.project?.root);
const [present, setPresent] = useState<ReadonlySet<string>>(new Set());
const paths = useMemo(() => suggestionPaths(market ?? []), [market]);
useEffect(() => {
let live = true;
if (!root || paths.length === 0) {
setPresent(new Set());
return;
}
void projectHas(paths).then((found) => {
if (live) setPresent(found);
});
return () => {
live = false;
};
}, [root, paths]);
return useMemo(() => suggestedFor(market ?? [], present), [market, present]);
}
/** Opens on Available, where plugins are found; `initialTab` picks another. */
export function PluginsPage({ initialTab = "marketplace" }: { initialTab?: Tab } = {}) {
const { plugins, loaded, loading, error, installing, catalog, catalogError, market, marketUrl, marketError } =
usePlugins();
const [tab, setTab] = useState<Tab>("installed");
const [tab, setTab] = useState<Tab>(initialTab);
const [query, setQuery] = useState("");
const [selectedId, setSelectedId] = useState<string | null>(null);
const [selectedCatalogId, setSelectedCatalogId] = useState<string | null>(null);
@@ -1052,7 +1247,13 @@ export function PluginsPage() {
}),
[market, query, category],
);
const selectedMarket = marketShown.find((m) => m.id === selectedMarketId) ?? null;
const suggestions = useSuggestions(market);
const browsing = !query.trim() && category === "all";
const groups = useMemo(
() => groupMarket(marketShown, query, browsing, suggestions),
[marketShown, query, browsing, suggestions],
);
const marketOrder = [...groups.suggested, ...groups.featured, ...groups.community, ...groups.examples];
const shown = useMemo(() => filterPlugins(plugins, query), [plugins, query]);
const downloaded = shown.filter((p) => p.source === "installed");
@@ -1065,7 +1266,16 @@ export function PluginsPage() {
() => (category === "all" ? filterCatalog(catalog ?? [], query) : []),
[catalog, query, category],
);
const selectedEntry = catalogShown.find((c) => c.id === selectedCatalogId) ?? catalogShown[0] ?? null;
// The clicked plugin; else the first one listed, so the details are never empty.
const pickedCatalog = catalogShown.find((c) => c.id === selectedCatalogId) ?? null;
const selectedMarket = pickedCatalog
? null
: (marketShown.find((m) => m.id === selectedMarketId) ?? marketOrder[0] ?? null);
const selectedEntry = selectedMarket ? null : (pickedCatalog ?? catalogShown[0] ?? null);
const selectMarket = (id: string) => {
setSelectedCatalogId(null);
setSelectedMarketId(id);
};
const showInstalled = (id: string | null) => {
if (!id) return;
@@ -1105,11 +1315,6 @@ export function PluginsPage() {
},
]}
/>
<span title={tx("The plugin system is a preview: its API and the plugins may still change.")}>
<Badge tone="accent">
<T>Preview</T>
</Badge>
</span>
<span className={styles.spacer} />
{(loading || installing) && <Spinner size={14} label={installing ? "Installing plugin" : "Loading plugins"} />}
<IconButton icon="settings" title="Manage Plugins" aria-haspopup="menu" onClick={openGear} />
@@ -1123,13 +1328,18 @@ export function PluginsPage() {
<div className={styles.body}>
<div className={styles.side}>
<SearchField
className={styles.search}
value={query}
onChange={setQuery}
placeholder={tab === "installed" ? "Search installed plugins" : "Search plugins"}
aria-label="Filter plugins"
/>
<div className={styles.searchRow}>
<SearchField
className={styles.searchField}
value={query}
onChange={setQuery}
placeholder={tab === "installed" ? "Search installed plugins" : "Search plugins"}
aria-label="Filter plugins"
/>
{tab === "marketplace" && (market?.length ?? 0) > 0 && (
<CategoryFilter market={market ?? []} value={category} onChange={setCategory} />
)}
</div>
{tab === "installed" ? (
<ul className={styles.list} aria-label={tx("Installed plugins")}>
{downloaded.length > 0 && <ListHeader title="Downloaded" count={downloaded.length} />}
@@ -1167,21 +1377,36 @@ export function PluginsPage() {
</ul>
) : (
<ul className={styles.list} aria-label={tx("Available plugins")}>
{(market?.length ?? 0) > 0 && (
<li className={styles.marketItem}>
<CategoryFilter market={market ?? []} value={category} onChange={setCategory} />
</li>
)}
{marketError && <li className={clsx(styles.listEmpty, styles.errorText)}>{marketError}</li>}
{marketShown.length > 0 && <ListHeader title="Marketplace" count={marketShown.length} />}
{marketShown.map((m) => (
<MarketRow
key={`market:${m.id}`}
entry={m}
selected={m.id === selectedMarket?.id}
onSelect={() => setSelectedMarketId(m.id)}
/>
))}
<MarketGroup
idPrefix="suggested"
title={tx("Suggested for This Project")}
entries={groups.suggested}
selectedId={selectedMarket?.id}
onSelect={selectMarket}
/>
<MarketGroup
idPrefix="featured"
title={tx("Featured")}
entries={groups.featured}
selectedId={selectedMarket?.id}
onSelect={selectMarket}
/>
<MarketGroup
idPrefix="community"
title={browsing ? tx("All Plugins") : tx("Marketplace")}
entries={groups.community}
selectedId={selectedMarket?.id}
onSelect={selectMarket}
/>
<MarketGroup
idPrefix="examples"
title={tx("Examples")}
note={exampleNote()}
entries={groups.examples}
selectedId={selectedMarket?.id}
onSelect={selectMarket}
/>
{catalogShown.length > 0 && <ListHeader title="Local" count={catalogShown.length} />}
{catalogShown.map((c) => (
<CatalogRow
@@ -1218,6 +1443,7 @@ export function PluginsPage() {
<MarketDetails
key={`market:${selectedMarket.id}`}
entry={selectedMarket}
suggestedBy={suggestions.get(selectedMarket.id)}
onInstalled={(id) => {
setTab("installed");
setSelectedId(id);
@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import type { MarketPlugin } from "@/bindings";
import { formatDownloads, groupMarket, monogram, suggestedFor, suggestionPaths } from "../market";
const plugin = (id: string, more: Partial<MarketPlugin> = {}): MarketPlugin => ({
id,
name: id.split(".")[1]!,
version: "1.0.0",
description: "",
authors: [],
publisher: id.split(".")[0]!,
download: `${id}.oiplugin`,
digest: "x",
capabilities: [],
engine: "^1.0",
categories: [],
icon: null,
installed: null,
compatible: true,
publisherName: null,
...more,
});
const ids = (list: readonly MarketPlugin[]) => list.map((m) => m.id);
describe("groupMarket", () => {
const market = [
plugin("openidea.checkstyle", {
downloads: 9000,
featured: true,
suggest: ["pom.xml"],
}),
plugin("acme.rare", { downloads: 3 }),
plugin("acme.popular", { downloads: 500, featured: true }),
plugin("bob.maven", { suggest: ["pom.xml"] }),
plugin("bob.done", { suggest: ["pom.xml"], installed: "1.0.0" }),
];
const suggested = suggestedFor(market, new Set(["pom.xml"]));
it("browses suggestions and picks, then community plugins by downloads, then the examples", () => {
const g = groupMarket(market, "", true, suggested);
expect(ids(g.suggested)).toEqual(["bob.maven"]);
expect(ids(g.featured)).toEqual(["acme.popular"]);
expect(ids(g.community)).toEqual(["acme.popular", "acme.rare", "bob.done", "bob.maven"]);
expect(ids(g.examples)).toEqual(["openidea.checkstyle"]);
});
it("lists matches by name first when searching, examples still last", () => {
const shown = [plugin("acme.zeta", { downloads: 100 }), plugin("acme.lint-z"), plugin("openidea.z-ex")];
const g = groupMarket(shown, "z", false, suggested);
expect(g.suggested).toEqual([]);
expect(g.featured).toEqual([]);
expect(ids(g.community)).toEqual(["acme.zeta", "acme.lint-z"]);
expect(ids(g.examples)).toEqual(["openidea.z-ex"]);
});
it("never suggests an example", () => {
expect(suggestionPaths(market)).toEqual(["pom.xml"]);
expect([...suggested.keys()]).toEqual(["bob.maven", "bob.done"]);
});
});
describe("helpers", () => {
it("formats downloads compactly", () => {
expect(formatDownloads(42)).toBe("42");
expect(formatDownloads(1234)).toMatch(/^1\.2\s?K$/);
});
it("gives a plugin without an icon a stable initial and tone", () => {
expect(monogram("acme.tool", ".env files").letter).toBe("E");
expect(monogram("acme.tool", "x").tone).toBe(monogram("acme.tool", "renamed").tone);
});
});
+74 -25
View File
@@ -813,7 +813,7 @@ describe("plugin prompts", () => {
describe("PluginsPage", () => {
it("lists plugins, shows details, toggles and applies themes", async () => {
const user = userEvent.setup();
render(<PluginsPage />);
render(<PluginsPage initialTab="installed" />);
const list = await screen.findByRole("list", { name: "Installed plugins" });
await waitFor(() => expect(within(list).getAllByRole("listitem")).toHaveLength(3));
@@ -841,7 +841,7 @@ describe("PluginsPage", () => {
const user = userEvent.setup();
render(
<>
<PluginsPage />
<PluginsPage initialTab="installed" />
<ContextMenuHost />
</>,
);
@@ -866,7 +866,7 @@ describe("PluginsPage", () => {
it("shows a plugin's own icon in its row and details", async () => {
const icon = { light: '<svg viewBox="0 0 16 16"><circle cx="8" cy="8" r="8"/></svg>', dark: null };
setPluginsBackend(createMemoryPluginsBackend([plugin({ id: "iconic", name: "Iconic", icon })]));
render(<PluginsPage />);
render(<PluginsPage initialTab="installed" />);
const list = await screen.findByRole("list", { name: "Installed plugins" });
await waitFor(() => expect(within(list).getByText("Iconic")).toBeInTheDocument());
expect(list.querySelector("circle")).not.toBeNull();
@@ -879,7 +879,7 @@ describe("PluginsPage", () => {
const catalog = { ...sampleCatalog()[0]!, id: entry.id, name: entry.name, homepage };
setPluginsBackend(createMemoryPluginsBackend(source === "installed" ? [entry] : [], [catalog]));
const user = userEvent.setup();
render(<PluginsPage />);
render(<PluginsPage initialTab="installed" />);
if (source === "catalog") await user.click(screen.getByRole("tab", { name: /Available/ }));
await screen.findByRole("heading", { level: 2, name: entry.name });
return user;
@@ -925,14 +925,14 @@ describe("PluginsPage", () => {
it("restarts a failed plugin instead of disabling it", async () => {
setPluginsBackend(createMemoryPluginsBackend([plugin({ id: "broken", status: "failed", error: "trap" })]));
const user = userEvent.setup();
render(<PluginsPage />);
render(<PluginsPage initialTab="installed" />);
await user.click(await screen.findByRole("button", { name: "Restart" }));
await waitFor(() => expect(usePlugins.getState().plugins[0]).toMatchObject({ enabled: true, status: "inactive" }));
});
it("uninstalls after confirmation", async () => {
const user = userEvent.setup();
render(<PluginsPage />);
render(<PluginsPage initialTab="installed" />);
await screen.findByRole("heading", { level: 2 });
await user.click(screen.getByRole("button", { name: "Uninstall…" }));
await user.click(screen.getByRole("button", { name: "Confirm uninstall Hello" }));
@@ -961,7 +961,7 @@ describe("PluginsPage", () => {
await uninstall(id);
});
const user = userEvent.setup();
render(<PluginsPage />);
render(<PluginsPage initialTab="installed" />);
await screen.findByRole("heading", { level: 2 });
await user.click(screen.getByRole("button", { name: "Uninstall…" }));
await user.click(screen.getByRole("button", { name: "Confirm uninstall Hello" }));
@@ -983,7 +983,7 @@ describe("PluginsPage", () => {
setPluginsBackend(
createMemoryPluginsBackend([plugin({ id: "hello", name: "Hello", changeNotes: "Added word count." })]),
);
render(<PluginsPage />);
render(<PluginsPage initialTab="installed" />);
await screen.findByRole("heading", { level: 2 });
expect(screen.queryByText("Added word count.")).not.toBeInTheDocument();
await user.click(screen.getByRole("tab", { name: "What's New" }));
@@ -994,7 +994,7 @@ describe("PluginsPage", () => {
const user = userEvent.setup();
const catalog = sampleCatalog();
setPluginsBackend(createMemoryPluginsBackend([], catalog));
render(<PluginsPage />);
render(<PluginsPage initialTab="installed" />);
await user.click(screen.getByRole("tab", { name: /Available/ }));
const list = await screen.findByRole("list", { name: "Available plugins" });
await waitFor(() => expect(within(list).getByText("Toy Language")).toBeInTheDocument());
@@ -1013,7 +1013,7 @@ describe("PluginsPage", () => {
marketUrl: () => Promise.reject({ kind: "unavailable", message: "Index unreachable", data: null }),
});
const user = userEvent.setup();
render(<PluginsPage />);
render(<PluginsPage initialTab="installed" />);
await user.click(screen.getByRole("tab", { name: /Available/ }));
const list = await screen.findByRole("list", { name: "Available plugins" });
expect(await within(list).findByText("Index unreachable")).toBeInTheDocument();
@@ -1111,10 +1111,10 @@ describe("marketplace", () => {
},
});
const user = userEvent.setup();
render(<PluginsPage />);
render(<PluginsPage initialTab="installed" />);
await user.click(screen.getByRole("tab", { name: /Available/ }));
const list = await screen.findByRole("list", { name: "Available plugins" });
await waitFor(() => expect(within(list).getByText("Marketplace")).toBeInTheDocument());
await waitFor(() => expect(within(list).getByText("All Plugins")).toBeInTheDocument());
expect(within(list).queryByText(new RegExp(NO_REPOSITORY))).not.toBeInTheDocument();
await user.click(within(list).getAllByText("Hello")[0]!);
expect(screen.getByText("Signed by acme")).toBeInTheDocument();
@@ -1124,6 +1124,55 @@ describe("marketplace", () => {
expect(asked[0]).toMatchObject({ pluginId: "hello", kind: "install" });
});
it("lists community plugins before the examples, selects the first and installs from a row", async () => {
setConsentPrompt(async () => ({ optional: [] }));
const entry = (id: string, name: string, more: object = {}) => ({
id,
name,
version: "1.0.0",
description: `${name} does things`,
authors: [],
publisher: id.split(".")[0]!,
download: `${id}.oiplugin`,
digest: "x",
capabilities: [],
engine: "^1.0",
installed: null,
compatible: true,
...more,
});
const downloads: string[] = [];
setPluginsBackend({
...createMemoryPluginsBackend([], sampleCatalog()),
marketUrl: async () => "https://plugins.example/index.json",
market: async () => [
entry("openidea.sample", "Sample"),
entry("acme.rare", "Rare", { downloads: 2 }),
entry("acme.popular", "Popular", { downloads: 1500, featured: true, publisherName: "Acme Inc." }),
],
marketDownload: async (id) => {
downloads.push(id);
return "plugins/hello";
},
});
const user = userEvent.setup();
render(<PluginsPage initialTab="installed" />);
await user.click(screen.getByRole("tab", { name: /Available/ }));
const list = await screen.findByRole("list", { name: "Available plugins" });
await waitFor(() => expect(within(list).getByText("Examples")).toBeInTheDocument());
const headers = within(list)
.getAllByText(/^(Featured|All Plugins|Examples|Local)$/)
.map((h) => h.textContent);
expect(headers).toEqual(["Featured", "All Plugins", "Examples", "Local"]);
// The first listed plugin is shown without a click.
expect(screen.getByRole("heading", { name: "Popular" })).toBeInTheDocument();
expect(screen.getByText("Signed by Acme Inc.")).toBeInTheDocument();
await user.click(within(list).getByText("Sample"));
expect(screen.getByRole("note")).toHaveTextContent("not maintained as a product");
await user.click(within(list).getByRole("button", { name: "Install Rare" }));
await waitFor(() => expect(downloads).toEqual(["acme.rare"]));
});
it("doesn't claim there is no repository while one is configured", async () => {
setPluginsBackend({
...createMemoryPluginsBackend([], []),
@@ -1131,7 +1180,7 @@ describe("marketplace", () => {
market: async () => [],
});
const user = userEvent.setup();
render(<PluginsPage />);
render(<PluginsPage initialTab="installed" />);
await user.click(screen.getByRole("tab", { name: /Available/ }));
expect(await screen.findByText("No plugin selected")).toBeInTheDocument();
expect(screen.queryByText(new RegExp(NO_REPOSITORY))).not.toBeInTheDocument();
@@ -1150,7 +1199,7 @@ describe("marketplace", () => {
const user = userEvent.setup();
render(
<>
<PluginsPage />
<PluginsPage initialTab="installed" />
<ContextMenuHost />
<InputDialogsHost />
</>,
@@ -1197,11 +1246,11 @@ describe("marketplace", () => {
],
});
const user = userEvent.setup();
render(<PluginsPage />);
render(<PluginsPage initialTab="installed" />);
await user.click(screen.getByRole("tab", { name: /Available/ }));
const list = await screen.findByRole("list", { name: "Available plugins" });
await waitFor(() => expect(within(list).getByText("Dusk")).toBeInTheDocument());
await user.click(within(list).getByRole("button", { name: "Category" }));
await user.click(screen.getByRole("button", { name: "Category" }));
const options = within(screen.getByRole("listbox"))
.getAllByRole("option")
.map((o) => o.textContent);
@@ -1235,7 +1284,7 @@ describe("marketplace", () => {
],
});
const user = userEvent.setup();
render(<PluginsPage />);
render(<PluginsPage initialTab="installed" />);
await user.click(screen.getByRole("tab", { name: /Available/ }));
const list = await screen.findByRole("list", { name: "Available plugins" });
await waitFor(() => expect(within(list).getByText("Incompatible")).toBeInTheDocument());
@@ -1344,7 +1393,7 @@ describe("marketplace", () => {
const user = userEvent.setup();
render(
<>
<PluginsPage />
<PluginsPage initialTab="installed" />
<ContextMenuHost />
</>,
);
@@ -1358,7 +1407,7 @@ describe("marketplace", () => {
setConsentPrompt(async () => ({ optional: [] }));
const consents = updatesBackend({ a: preview("a", "1.1.0"), b: preview("b", "2.0.0") });
const user = userEvent.setup();
render(<PluginsPage />);
render(<PluginsPage initialTab="installed" />);
await waitFor(() => expect(usePlugins.getState().market).toHaveLength(3));
const list = screen.getByRole("list", { name: "Installed plugins" });
expect(within(list).getAllByText("Update")).toHaveLength(2);
@@ -1400,7 +1449,7 @@ describe("marketplace", () => {
]);
const revokeNetHost = vi.fn((id: string, host: string) => base.revokeNetHost(id, host));
setPluginsBackend({ ...base, revokeNetHost });
render(<PluginsPage />);
render(<PluginsPage initialTab="installed" />);
await screen.findByText("net:relay.example.org");
expect(screen.getAllByText(`net:${host}`)).toHaveLength(host === "ask" ? 2 : 1);
expect(screen.getByText("net:relay.example.org")).toBeInTheDocument();
@@ -1425,7 +1474,7 @@ describe("marketplace", () => {
},
});
const user = userEvent.setup();
render(<PluginsPage />);
render(<PluginsPage initialTab="installed" />);
expect(await screen.findByText("Unsigned")).toBeInTheDocument();
await user.click(await screen.findByRole("button", { name: "Roll Back to 1.0.0" }));
await waitFor(() => expect(rolled).toEqual(["p"]));
@@ -1455,7 +1504,7 @@ describe("marketplace", () => {
}),
]),
);
render(<PluginsPage />);
render(<PluginsPage initialTab="installed" />);
expect(await screen.findByText("Blocked")).toBeInTheDocument();
expect(screen.getByRole("alert")).toHaveTextContent("Requires base ^1.0, but version 2.0.0 is installed");
@@ -1484,7 +1533,7 @@ describe("marketplace", () => {
const user = userEvent.setup();
render(
<>
<PluginsPage />
<PluginsPage initialTab="installed" />
<MessagesHost />
</>,
);
@@ -1515,7 +1564,7 @@ describe("marketplace", () => {
const user = userEvent.setup();
render(
<>
<PluginsPage />
<PluginsPage initialTab="installed" />
<MessagesHost />
</>,
);
@@ -1536,7 +1585,7 @@ describe("marketplace", () => {
const user = userEvent.setup();
render(
<>
<PluginsPage />
<PluginsPage initialTab="installed" />
<MessagesHost />
</>,
);
@@ -4,6 +4,7 @@ import { useProject } from "@/platform/project";
import { usePlugins } from "../store";
import {
affectsWorkspaceContains,
projectHas,
refreshWorkspaceContains,
startWorkspaceContains,
workspaceContainsKeys,
@@ -153,10 +154,16 @@ describe.each(["menus", "views"])("startWorkspaceContains (%s)", (point) => {
let stop: () => void;
beforeEach(() => {
ipc.files = { "/p": ["src"], "/q": [".devcontainer"], "/q/.devcontainer": ["devcontainer.json"] };
ipc.files = {
"/p": ["src"],
"/q": [".devcontainer"],
"/q/.devcontainer": ["devcontainer.json"],
};
ipc.readDir.mockReset();
ipc.readDir.mockImplementation(async (dir: string) => (ipc.files[dir] ?? []).map((name) => ({ name })));
usePlugins.setState({ ui: [{ pluginId: "x", point, key: "k", decl: { when: key } }] as never });
usePlugins.setState({
ui: [{ pluginId: "x", point, key: "k", decl: { when: key } }] as never,
});
setProject("/p");
setContext(key, undefined);
stop = startWorkspaceContains();
@@ -168,11 +175,19 @@ describe.each(["menus", "views"])("startWorkspaceContains (%s)", (point) => {
await keyIs(false);
ipc.files["/p"] = [".devcontainer"];
ipc.files["/p/.devcontainer"] = ["devcontainer.json"];
emit("fsChanged", { created: ["/p/.devcontainer"], modified: [], removed: [] });
emit("fsChanged", {
created: ["/p/.devcontainer"],
modified: [],
removed: [],
});
await keyIs(true);
ipc.files["/p/.devcontainer"] = [];
emit("fsChanged", { created: [], modified: [], removed: ["/p/.devcontainer/devcontainer.json"] });
emit("fsChanged", {
created: [],
modified: [],
removed: ["/p/.devcontainer/devcontainer.json"],
});
await keyIs(false);
});
@@ -204,3 +219,18 @@ describe.each(["menus", "views"])("startWorkspaceContains (%s)", (point) => {
expect(ipc.handlers.excludedDirsChanged).toBeUndefined();
});
});
describe("projectHas", () => {
afterEach(() => useProject.setState({ project: null }));
it("finds which relative paths the project's roots have, ignoring ones outside it", async () => {
useProject.setState({ project: { root: "/p" } as never });
const files: Record<string, string[]> = {
"/p": ["pom.xml"],
"/p/config": ["routes.rb"],
};
const list = async (dir: string) => files[dir] ?? [];
const found = await projectHas(["pom.xml", "config/routes.rb", "go.mod", "../pom.xml"], list);
expect([...found].sort()).toEqual(["config/routes.rb", "pom.xml"]);
});
});
+85
View File
@@ -0,0 +1,85 @@
/** How the Available tab orders and groups the marketplace. */
import type { MarketPlugin } from "@/bindings";
/** The official plugins: examples of the plugin API, not maintained products. */
export const EXAMPLE_PUBLISHER = "openidea";
export const isExample = (m: { publisher?: string | null }) => m.publisher === EXAMPLE_PUBLISHER;
const SUGGESTED_MAX = 5;
const FEATURED_MAX = 6;
export interface MarketGroups {
/** Not installed, and the project has a file the plugin is suggested for. */
suggested: MarketPlugin[];
featured: MarketPlugin[];
/** Every community plugin shown, most downloaded first (best match first when searching). */
community: MarketPlugin[];
/** The official plugins, always last. */
examples: MarketPlugin[];
}
const byPopularity = (a: MarketPlugin, b: MarketPlugin) =>
(b.downloads ?? 0) - (a.downloads ?? 0) || a.name.localeCompare(b.name);
/** 0 = name starts with the query, 1 = name contains it, 2 = matched elsewhere. */
function rank(m: MarketPlugin, q: string): number {
const name = m.name.toLowerCase();
return name.startsWith(q) ? 0 : name.includes(q) ? 1 : 2;
}
/**
* Splits the shown plugins: suggestions and picks only while browsing (no query,
* no category); community plugins before the examples.
*/
export function groupMarket(
shown: readonly MarketPlugin[],
query: string,
browsing: boolean,
suggestedFor: ReadonlyMap<string, string>,
): MarketGroups {
const q = query.trim().toLowerCase();
const order = q ? (a: MarketPlugin, b: MarketPlugin) => rank(a, q) - rank(b, q) || byPopularity(a, b) : byPopularity;
const community = shown.filter((m) => !isExample(m)).sort(order);
const examples = shown.filter(isExample).sort(order);
if (!browsing) return { suggested: [], featured: [], community, examples };
return {
suggested: community.filter((m) => !m.installed && suggestedFor.has(m.id)).slice(0, SUGGESTED_MAX),
featured: community.filter((m) => m.featured).slice(0, FEATURED_MAX),
community,
examples,
};
}
/** 1234 → "1.2K", in the UI's number style. */
export function formatDownloads(n: number): string {
return new Intl.NumberFormat(undefined, {
notation: "compact",
maximumFractionDigits: 1,
}).format(n);
}
/** The paths any entry is suggested for. */
export function suggestionPaths(market: readonly MarketPlugin[]): string[] {
return [...new Set(market.flatMap((m) => (isExample(m) ? [] : (m.suggest ?? []))))];
}
/** Each plugin's first suggestion path the project has. */
export function suggestedFor(market: readonly MarketPlugin[], present: ReadonlySet<string>): Map<string, string> {
const out = new Map<string, string>();
for (const m of market) {
const hit = isExample(m) ? undefined : m.suggest?.find((p) => present.has(p));
if (hit) out.set(m.id, hit);
}
return out;
}
/** Tints for plugins without an icon, picked by id so each keeps its own. */
export const MONOGRAM_TONES = 6;
export function monogram(id: string, name: string): { letter: string; tone: number } {
let h = 0;
for (let i = 0; i < id.length; i++) h = (Math.imul(h, 31) + id.charCodeAt(i)) | 0;
const letter = [...name.trim().replace(/^[^\p{L}\p{N}]+/u, "")][0] ?? "?";
return { letter: letter.toUpperCase(), tone: Math.abs(h) % MONOGRAM_TONES };
}
+26 -13
View File
@@ -31,6 +31,20 @@ export function affectsWorkspaceContains(
return changed.some((p) => targets.some((t) => t === p || t.startsWith(`${p}/`)));
}
/** Whether `path` (relative) exists under any of `roots`. */
async function existsUnder(roots: readonly string[], path: string, list: (dir: string) => Promise<string[]>) {
const slash = path.lastIndexOf("/");
const parent = slash < 0 ? "" : path.slice(0, slash);
const name = path.slice(slash + 1);
const found = await Promise.all(
roots.map(async (root) => {
const names: string[] = await list(parent ? `${root}/${parent}` : root).catch(() => []);
return names.includes(name);
}),
);
return found.some(Boolean);
}
export async function refreshWorkspaceContains(
roots: readonly string[],
keys: readonly string[],
@@ -40,19 +54,7 @@ export async function refreshWorkspaceContains(
const results = await Promise.all(
keys.map(async (key) => {
const path = relativePath(key);
if (path === null) {
return [key, false] as const;
}
const slash = path.lastIndexOf("/");
const parent = slash < 0 ? "" : path.slice(0, slash);
const name = path.slice(slash + 1);
const found = await Promise.all(
roots.map(async (root) => {
const names: string[] = await list(parent ? `${root}/${parent}` : root).catch(() => []);
return names.includes(name);
}),
);
return [key, found.some(Boolean)] as const;
return [key, path !== null && (await existsUnder(roots, path, list))] as const;
}),
);
if (!isCurrent()) {
@@ -63,6 +65,17 @@ export async function refreshWorkspaceContains(
}
}
/** Which of the relative `paths` the open project has. */
export async function projectHas(
paths: readonly string[],
list: (dir: string) => Promise<string[]> = listNames,
): Promise<Set<string>> {
const roots = projectRoots();
const valid = paths.filter((p) => relativePath(`${PREFIX}${p}`) !== null);
const found = await Promise.all(valid.map(async (p) => [p, await existsUnder(roots, p, list)] as const));
return new Set(found.filter(([, ok]) => ok).map(([p]) => p));
}
const listNames = (dir: string) => call(commands.fsReadDir(dir)).then((entries) => entries.map((e) => e.name));
const projectRoots = () => {
+10 -1
View File
@@ -1256,5 +1256,14 @@
"{program} is not installed.": "{program} is not installed.",
"Create a project, open a folder from disk or clone a repository from version control.": "Create a project, open a folder from disk or clone a repository from version control.",
"New Project": "New Project",
"{path} already exists and is not empty": "{path} already exists and is not empty"
"{path} already exists and is not empty": "{path} already exists and is not empty",
"Example": "Example",
"Shows how to use the plugin API; not maintained as a product.": "Shows how to use the plugin API; not maintained as a product.",
"{count} downloads": "{count} downloads",
"This plugin shows how to use the plugin API and is not maintained as a product. Build on it in your own repository.": "This plugin shows how to use the plugin API and is not maintained as a product. Build on it in your own repository.",
"Suggested because this project has {path}.": "Suggested because this project has {path}.",
"Suggested for This Project": "Suggested for This Project",
"Featured": "Featured",
"All Plugins": "All Plugins",
"Examples": "Examples"
}
-1
View File
@@ -5857,7 +5857,6 @@
"File icon theme": "Tema de iconos de archivo",
"Plugin not enabled": "Plugin no habilitado",
"Problems the {plugin} plugin reports. Severity \"As reported\" keeps the level the plugin assigns; a code below can override it.": "Problemas que informa el plugin {plugin}. La gravedad \"Según lo informado\" conserva el nivel que asigna el plugin; un código a continuación puede reemplazarlo.",
"The plugin system is a preview: its API and the plugins may still change.": "El sistema de plugins es una versión preliminar: su API y los plugins aún pueden cambiar.",
"'{expected}' expected": "Se esperaba '{expected}'",
"Unexpected token": "Token inesperado",
"Syntax": "Sintaxis",
-1
View File
@@ -5513,7 +5513,6 @@
"Unsigned": "Non signé",
"Roll Back to {previousVersion}": "Revenir à {previousVersion}",
"Install Plugin from Package File…": "Installer un plugin depuis un fichier de paquet…",
"The plugin system is a preview: its API and the plugins may still change.": "Le système de plugins est en préversion : son API et les plugins peuvent encore changer.",
"Linked for development": "Lié pour le développement",
"Marketplace": "Marketplace",
"Low": "Faible",
-1
View File
@@ -5930,7 +5930,6 @@
"File icon theme": "파일 아이콘 테마",
"Plugin not enabled": "플러그인이 활성화되지 않음",
"Problems the {plugin} plugin reports. Severity \"As reported\" keeps the level the plugin assigns; a code below can override it.": "{plugin} 플러그인이 보고하는 문제입니다. 심각도 \"보고된 대로\"는 플러그인이 지정한 수준을 유지하며, 아래의 코드별로 재정의할 수 있습니다.",
"The plugin system is a preview: its API and the plugins may still change.": "플러그인 시스템은 미리 보기 버전입니다. API와 플러그인은 변경될 수 있습니다.",
"'{expected}' expected": "'{expected}'이(가) 필요합니다",
"Unexpected token": "예기치 않은 토큰",
"Syntax": "구문",
-1
View File
@@ -5767,7 +5767,6 @@
"File icon theme": "Motyw ikon plików",
"Plugin not enabled": "Wtyczka nie jest włączona",
"Problems the {plugin} plugin reports. Severity \"As reported\" keeps the level the plugin assigns; a code below can override it.": "Problemy zgłaszane przez wtyczkę {plugin}. Poziom ważności \"Zgodnie ze zgłoszeniem\" zachowuje poziom przypisany przez wtyczkę; kod poniżej może go nadpisać.",
"The plugin system is a preview: its API and the plugins may still change.": "System wtyczek działa w wersji zapoznawczej: jego interfejs API oraz wtyczki mogą ulec zmianie.",
"'{expected}' expected": "Oczekiwano '{expected}'",
"Unexpected token": "Niekontrolowany token",
"Syntax": "Składnia",
-1
View File
@@ -5781,7 +5781,6 @@
"File icon theme": "Tema de ícones de arquivo",
"Plugin not enabled": "Plugin não ativado",
"Problems the {plugin} plugin reports. Severity \"As reported\" keeps the level the plugin assigns; a code below can override it.": "Problemas relatados pelo plugin {plugin}. A gravidade \"Conforme relatado\" mantém o nível que o plugin atribui; um código abaixo pode substituí-lo.",
"The plugin system is a preview: its API and the plugins may still change.": "O sistema de plugins é uma prévia: sua API e os plugins ainda podem mudar.",
"'{expected}' expected": "Esperado '{expected}'",
"Unexpected token": "Token inesperado",
"Syntax": "Sintaxe",
+10 -2
View File
@@ -6386,7 +6386,6 @@
"File icon theme": "文件图标主题",
"Plugin not enabled": "插件未启用",
"Problems the {plugin} plugin reports. Severity \"As reported\" keeps the level the plugin assigns; a code below can override it.": "{plugin} 插件报告的问题。严重性“按报告”保留插件分配的级别;下方的代码可以覆盖它。",
"The plugin system is a preview: its API and the plugins may still change.": "插件系统目前是预览版:API 和插件都可能还会变化。",
"'{expected}' expected": "缺少 '{expected}'",
"Unexpected token": "意外的标记",
"Syntax": "语法",
@@ -7579,5 +7578,14 @@
"{program} {found} is installed, but this project needs {min} or later.": "已安装 {program} {found},但该项目需要 {min} 或更高版本。",
"{program} is not installed.": "未安装 {program}。",
"Create a project, open a folder from disk or clone a repository from version control.": "新建项目、打开磁盘上的文件夹,或从版本控制克隆仓库。",
"{path} already exists and is not empty": "{path} 已存在且不为空"
"{path} already exists and is not empty": "{path} 已存在且不为空",
"Example": "示例",
"Shows how to use the plugin API; not maintained as a product.": "演示插件 API 的用法,不作长期维护。",
"{count} downloads": "{count} 次下载",
"This plugin shows how to use the plugin API and is not maintained as a product. Build on it in your own repository.": "这个插件用来演示插件 API 的用法,不作为产品长期维护。可以在你自己的仓库里基于它开发。",
"Suggested because this project has {path}.": "推荐原因:当前项目包含 {path}。",
"Suggested for This Project": "为当前项目推荐",
"Featured": "精选",
"All Plugins": "全部插件",
"Examples": "示例插件"
}