diff --git a/src/servers/api/plugins/router.ts b/src/servers/api/plugins/router.ts index c53c2845..346ec85f 100644 --- a/src/servers/api/plugins/router.ts +++ b/src/servers/api/plugins/router.ts @@ -8,6 +8,7 @@ import { fetchCatalogue, fetchReadme } from '../../plugins/marketplace'; import { findOrphanTables } from '../../plugins/orphans'; import { fetchPlugin } from '../../plugins/fetch'; import { discoverPlugins } from '../../plugins/discover'; +import { listPluginInstalls } from 'officerdb'; import { iconUrl } from '../../app-store/assets'; import { installPlugin, @@ -309,7 +310,18 @@ pluginsRouter.post('/fetch-from-url', async (ctx) => { ); } - return ctx.json({ ok: true, appName, label: found.manifest?.label ?? appName }); + /* + Whether it is ALREADY installed, so the UI can say the true thing and show the right tab. + + A directory and an install row are separate facts — deleting a plugin's source leaves the row, so a + re-fetch can land on something the platform still considers installed. Reported here because the + caller has just been told "added" and needs to know where to look; "click it to install" pointing at + an empty tab is worse than no message. + */ + const installs = await listPluginInstalls(); + const installed = installs.some((entry) => entry.appName === appName && entry.enabled); + + return ctx.json({ ok: true, appName, label: found.manifest?.label ?? appName, installed }); }); pluginsRouter.post('/:appName/install', async (ctx) => { diff --git a/src/workspaces/officerdev/src/apps/Plugins/InstallFromUrl.tsx b/src/workspaces/officerdev/src/apps/Plugins/InstallFromUrl.tsx index 8be0bf78..3c865826 100644 --- a/src/workspaces/officerdev/src/apps/Plugins/InstallFromUrl.tsx +++ b/src/workspaces/officerdev/src/apps/Plugins/InstallFromUrl.tsx @@ -27,7 +27,7 @@ import { GitBranch, X, Loader2 } from 'lucide-react'; export const InstallFromUrl = ({ onFetch, }: { - onFetch: (url: string) => Promise<{ ok: boolean; appName?: string; error?: string }>; + onFetch: (url: string) => Promise<{ ok: boolean; appName?: string; installed?: boolean; error?: string }>; }) => { const [open, setOpen] = useState(false); const [url, setUrl] = useState(''); @@ -62,9 +62,18 @@ export const InstallFromUrl = ({ const result = await onFetch(trimmed); setBusy(false); if (result.ok) { - // Named, because the name was derived rather than typed — this is where you find out what to look - // for in the grid. - setNote({ ok: true, text: `${result.appName} added — click it to install` }); + /* + Named, because the name was derived rather than typed — this is where you find out what to look + for. And it says which of the two things happened: a plugin whose source was deleted but whose + install row survived comes back ALREADY installed, and telling somebody to "click it to install" + while it sits in a different tab is worse than saying nothing. + */ + setNote({ + ok: true, + text: result.installed + ? `${result.appName} added — already installed` + : `${result.appName} added — click it to install`, + }); setOpen(false); setUrl(''); } else { diff --git a/src/workspaces/officerdev/src/apps/Plugins/MarketplaceOverview.tsx b/src/workspaces/officerdev/src/apps/Plugins/MarketplaceOverview.tsx index 0bce3384..f911b66b 100644 --- a/src/workspaces/officerdev/src/apps/Plugins/MarketplaceOverview.tsx +++ b/src/workspaces/officerdev/src/apps/Plugins/MarketplaceOverview.tsx @@ -39,7 +39,7 @@ export const MarketplaceOverview = ({ official: Entry[]; available: Entry[]; renderCard: (entry: Entry) => ReactNode; - onFetch: (url: string) => Promise<{ ok: boolean; appName?: string; error?: string }>; + onFetch: (url: string) => Promise<{ ok: boolean; appName?: string; installed?: boolean; 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. @@ -74,7 +74,16 @@ export const MarketplaceOverview = ({ you cannot see, which is the mistake this whole section is correcting. */} {tab === 'available' && (
- + {/* The tab FOLLOWS the result. A plugin whose directory was removed but whose install row + survived comes back already installed, so "click it to install" would be pointing at a + tab it is not in — which is exactly what happened the first time this shipped. */} + { + const result = await onFetch(url); + if (result.ok && result.installed) setTab('installed'); + return result; + }} + />
)} diff --git a/src/workspaces/officerdev/src/apps/Plugins/usePlugins.ts b/src/workspaces/officerdev/src/apps/Plugins/usePlugins.ts index 5fe0b2a1..2c65ce7f 100644 --- a/src/workspaces/officerdev/src/apps/Plugins/usePlugins.ts +++ b/src/workspaces/officerdev/src/apps/Plugins/usePlugins.ts @@ -288,18 +288,30 @@ export function usePlugins() { * have already used. */ const fetchFromUrl = useCallback( - async (url: string): Promise<{ ok: boolean; appName?: string; error?: string }> => { + async (url: string): Promise<{ ok: boolean; appName?: string; installed?: boolean; error?: string }> => { const res = await fetch('/api/plugins/fetch-from-url', { method: 'POST', headers: { ...getHeaders(), 'content-type': 'application/json' }, body: JSON.stringify({ url }), }); - const body = (await res.json().catch(() => ({}))) as { appName?: string; error?: string }; - // Refresh either way: a clone that failed discovery still left something on disk worth showing. + const body = (await res.json().catch(() => ({}))) as { + appName?: string; + installed?: boolean; + error?: string; + }; + + /* + AWAITED, so the caller resolves after the list has actually been refetched. + `invalidateQueries` is fire-and-forget: without awaiting it, the confirmation appeared while the + grid was still showing the previous answer, and the new plugin turned up a moment later — which + reads as "nothing happened" and sends somebody looking for a refresh button. + */ + await queryClient.invalidateQueries({ queryKey: PLUGINS_KEY }); + // A clone that failed discovery still left something on disk worth showing, so this runs either way. invalidate(); - return { ok: res.ok, appName: body.appName, error: body.error }; + return { ok: res.ok, appName: body.appName, installed: body.installed, error: body.error }; }, - [invalidate], + [invalidate, queryClient], ); // Update-all is a PLAIN POST, not the streaming door the single verbs use. Twelve interleaved step