a Marketplace section, and the git field lives where its result appears

a new row above the tags, and where the store now opens. the tags say what a
plugin is FOR; this says what state the machine is in, which is the question
somebody opening a store asks first — narrowing by kind is what you do after.

THREE TABS, because there are genuinely three states and the old grid had two.
`Installed` is what runs. the other two are both "not installed" and are not the
same thing:

  Official   the catalogue — somewhere else, a download away.
  Available  already ON THIS MACHINE, not installed. where a plugin fetched from
             a git URL lands, and where one deleted-but-not-purged sits.

collapsing those hid the entire point of fetching from a repository: you add
something and it is indistinguishable from twenty things you have not downloaded.

it opens on Installed rather than the catalogue. a store that opens on what it
can sell you is selling; this is mostly where somebody checks on something they
already have.

THE GIT FIELD MOVED INTO THE AVAILABLE TAB, which is the tab its result appears
in — and only shows there. floating above a filtered grid it was an action whose
outcome landed somewhere off screen, which is how the first version installed a
plugin with no feedback at all. same mistake, one layer up.

the tag grid is untouched and still what a filter shows. two views because they
answer different questions: "what have I got" wants installed-versus-not, "what
is there for my calendar" wants everything of that kind whatever its state.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
2026-08-28 23:56:27 +01:00
co-authored by Claude Opus 5
parent e2d03394e1
commit 9a70089d6b
3 changed files with 171 additions and 14 deletions
@@ -0,0 +1,110 @@
import { useState, type ReactNode } from 'react';
import { Check, Download, GitBranch, Store } from 'lucide-react';
import { InstallFromUrl } from './InstallFromUrl';
import type { AvailablePlugin, PluginItem } from './usePlugins';
/**
* The Marketplace section: what this machine has, and what it could have.
*
* ── Three tabs, because there are genuinely three states ──
*
* `Installed` is what is running. The other two are both "not installed" and they are NOT the same
* thing, which is the distinction the old single grid lost:
*
* `Official` the marketplace catalogue — somewhere else, one click and a download away.
* `Available` already ON THIS MACHINE and not installed. A plugin fetched from a git URL lands
* here, and so does one that was deleted without being purged.
*
* Collapsing those two hid the whole point of fetching from a repository: you add something, and it is
* indistinguishable from twenty things you have not downloaded yet.
*
* ── Why the git field lives in Available ──
*
* That is the tab its result appears in. A button that adds something to the list you are looking at is
* legible; the same button floating above a filtered grid was not, which is how the first version
* managed to install a plugin with no feedback at all.
*/
type Entry = (PluginItem | AvailablePlugin) & { installed?: boolean };
type TabKey = 'installed' | 'official' | 'available';
export const MarketplaceOverview = ({
installed,
official,
available,
renderCard,
onFetch,
}: {
installed: Entry[];
official: Entry[];
available: Entry[];
renderCard: (entry: Entry) => ReactNode;
onFetch: (url: string) => Promise<{ ok: boolean; appName?: string; error?: string }>;
}) => {
// Opens on what you HAVE. A store that opens on the catalogue is selling; this is mostly a place
// people come to check on something they already installed.
const [tab, setTab] = useState<TabKey>('installed');
const tabs: { key: TabKey; label: string; icon: typeof Check; items: Entry[] }[] = [
{ key: 'installed', label: 'Installed', icon: Check, items: installed },
{ key: 'official', label: 'Official', icon: Store, items: official },
{ key: 'available', label: 'Available', icon: Download, items: available },
];
const current = tabs.find((t) => t.key === tab) ?? tabs[0]!;
return (
<div className="flex h-full flex-col">
{/* One ruled row, same border as the nav beside it, so the two panels read as one screen. */}
<div className="flex shrink-0 items-center gap-1 border-b border-border px-3">
{tabs.map(({ key, label, icon: Icon, items }) => (
<button
key={key}
onClick={() => setTab(key)}
className={`flex items-center gap-1.5 border-b-2 px-3 py-2.5 text-sm transition-colors ${
tab === key ? 'border-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'
}`}
>
<Icon className="size-3.5 shrink-0" />
{label}
<span className="text-xs tabular-nums text-muted-foreground">{items.length}</span>
</button>
))}
{/* Only on the tab it affects. Elsewhere it would be an action whose result appears somewhere
you cannot see, which is the mistake this whole section is correcting. */}
{tab === 'available' && (
<div className="ml-auto flex min-w-0 flex-1 justify-end py-1.5 pl-3">
<InstallFromUrl onFetch={onFetch} />
</div>
)}
</div>
<div className="min-h-0 flex-1 overflow-auto p-4">
{current.items.length === 0 ? (
<Empty tab={current.key} />
) : (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
{current.items.map((entry) => renderCard(entry))}
</div>
)}
</div>
</div>
);
};
/** Says what the tab is FOR, not just that it is empty — an empty tab is otherwise a dead end. */
const Empty = ({ tab }: { tab: TabKey }) => {
const text =
tab === 'installed'
? 'Nothing installed yet. Official has the catalogue.'
: tab === 'official'
? 'The marketplace catalogue is empty or unreachable.'
: 'Nothing waiting to be installed. Add one from a Git repository above.';
return (
<div className="flex h-full flex-col items-center justify-center gap-2 p-8 text-center">
<GitBranch className="size-5 opacity-30" />
<p className="text-sm text-muted-foreground">{text}</p>
</div>
);
};
@@ -2,7 +2,7 @@ import { Link, useSearchParams } from 'react-router';
import { Check, Download } from 'lucide-react';
import { usePlugins, type AvailablePlugin, type PluginItem } from './usePlugins';
import { resolveIcon } from '../../utils/resolve-icon';
import { InstallFromUrl } from './InstallFromUrl';
import { MarketplaceOverview } from './MarketplaceOverview';
/**
* The store: cards in a grid, filtered by the tag on the left.
@@ -85,7 +85,28 @@ export const PluginGrid = () => {
const [params] = useSearchParams();
const tag = params.get('tag');
if (isLoading) return <div className="p-4 text-sm text-duck-dark/50">Loading…</div>;
if (isLoading) return <div className="p-4 text-sm text-muted-foreground">Loading…</div>;
/*
No tag means the Marketplace section — the state of this machine, in three tabs.
The tag grid below is unchanged and still what a filter shows. Two views rather than one because
they answer different questions: "what have I got" wants installed-versus-not, and "what is there
for my calendar" wants everything of that kind together regardless of state.
*/
if (!tag) {
const installed = plugins.filter((p) => p.installed);
const onDisk = plugins.filter((p) => !p.installed);
return (
<MarketplaceOverview
installed={installed}
official={available}
available={onDisk}
onFetch={fetchFromUrl}
renderCard={(entry) => <Card key={entry.appName} entry={entry} search={params.toString()} />}
/>
);
}
// Installed first, then the rest. Both are the same thing to a person browsing — a plugin — and the
// ones they already have are the ones they are most likely looking for.
@@ -107,16 +128,11 @@ export const PluginGrid = () => {
return (
<div className="h-full overflow-auto p-4">
{/* The count and the escape hatch share a row: the catalogue is the way in, and this is the
exception beside it rather than an equal option above it. */}
<div className="mb-3 flex items-center gap-3 text-xs text-duck-dark/40">
<span className="shrink-0">
{shown.length} plugin{shown.length === 1 ? '' : 's'}
{tag && tag !== '__untagged' ? ` in ${tag}` : ''}
</span>
<div className="ml-auto flex min-w-0 flex-1 justify-end">
<InstallFromUrl onFetch={fetchFromUrl} />
</div>
{/* No git field here any more: it lives in the Marketplace section's Available tab, which is where
what it adds actually shows up. */}
<div className="mb-3 text-xs text-muted-foreground">
{shown.length} plugin{shown.length === 1 ? '' : 's'}
{tag && tag !== '__untagged' ? ` in ${tag}` : ''}
</div>
<div className="grid grid-cols-[repeat(auto-fill,minmax(230px,1fr))] gap-3">
{shown.map((entry) => (
@@ -1,5 +1,5 @@
import { Link, useSearchParams } from 'react-router';
import { Puzzle, AlertTriangle, Database, Package } from 'lucide-react';
import { Puzzle, AlertTriangle, Database, Package, Store } from 'lucide-react';
import { usePlugins, type OrphanTable } from './usePlugins';
import { resolveIcon } from '../../utils/resolve-icon';
@@ -72,6 +72,25 @@ export const TagNav = () => {
);
const untagged = all.filter((p) => !p.tags?.length).length;
// On disk and not installed — a plugin fetched from a git URL lands here, which is why it is counted
// beside the catalogue rather than folded into it.
const notInstalled = plugins.filter((p) => !p.installed).length;
/**
* The Marketplace section — a view, not a filter.
*
* `?tag=` narrows the same grid; this replaces it, so it clears the tag rather than setting one. It is
* also the DEFAULT: arriving with neither parameter lands here, because "what do I have, and what can
* I get" is the question somebody opening a store is asking, and the tag list is how you narrow it
* afterwards.
*/
const overviewLink = () => {
const next = new URLSearchParams(params);
next.delete('tag');
next.delete('selected');
return { search: next.toString() ? `?${next}` : '' };
};
const onOverview = !active;
const link = (tag: string | null) => {
const next = new URLSearchParams(params);
@@ -108,7 +127,19 @@ export const TagNav = () => {
return (
<nav className="flex h-full flex-col overflow-auto">
<div className="flex flex-col">
{row('__all', link(null), Package, 'All', all.length, !active)}
{/* Above the tags and separated from them: it answers a different question. The tags say what a
plugin is FOR; this says what state this machine is in. */}
<Link to={overviewLink()} className={`${rowClass(onOverview)} border-b-2`}>
<Store className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm">Marketplace</span>
<span className="block truncate text-xs text-muted-foreground tabular-nums">
{plugins.filter((p) => p.installed).length} installed · {notInstalled + available.length} available
</span>
</span>
</Link>
{row('__all', link(null), Package, 'All', all.length, false)}
{ordered.map(([tag, n]) =>
row(tag, link(tag), resolveIcon(TAG_ICONS[tag] ?? 'Puzzle'), tag, n, active === tag),
)}