mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-11 10:41:28 -10:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cccefcc078 | ||
|
|
4bb76acf66 | ||
|
|
187c0324fd | ||
|
|
2fca3e0607 |
No files matched your search
@@ -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()))?;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "구문",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "示例插件"
|
||||
}
|
||||
Reference in new issue
Block a user