plugins: make Try it do the thing instead of opening the plugin page (#35846)

* plugins: make Try it do the thing instead of opening the plugin page

* plugins: address review on Try it (row's icon theme, language switch result, pending plugins)
This commit is contained in:
Neil
2026-10-29 01:54:23 -07:00
committed by GitHub
parent 5d1c688ca7
commit b28f54c5f3
16 changed files with 545 additions and 98 deletions
@@ -47,7 +47,7 @@ export function PluginInUseAction({
value0: name
}
)}
onClick={() => (pack ? switchToPluginLanguage(pack) : void applyPluginThemesNow(pluginKey))}
onClick={() => void (pack ? switchToPluginLanguage(pack) : applyPluginThemesNow(pluginKey))}
>
{translate('auto.components.settings.PluginInUseAction.use', 'Use')}
</Button>
@@ -30,11 +30,6 @@ import { PluginListToolbar } from './PluginListToolbar'
import { PluginNoMatches, PluginPageMissing } from './PluginListEmptyStates'
import { sortByAttention } from '../presentation/plugin-status-presentation'
import { usePluginAutoUpdatesStore } from '@/store/plugin-auto-updates'
import { useAppStore } from '@/store'
import { usePluginPanelsStore } from '@/store/plugin-panels'
import { collectActivePluginNewFiles } from '@/store/plugin-new-files'
import { openPluginNewFileFromSettings } from '../page/PluginContributionActionNewFile'
import { pluginPanelUnavailableReason } from '../page/plugin-contribution-action-availability'
import { PluginUpdateAllDialog } from '../dialogs/PluginUpdateAllDialog'
import {
matchesPluginFilter,
@@ -121,22 +116,9 @@ export function PluginManager({
onShowLogs(pluginKey)
}
}
// Why a new file: an editor plugin is tried by drawing in it, and its page is already open.
const tryPlugin = (pluginKey: string): void => {
const installed = usePluginPanelsStore
.getState()
.plugins.filter((plugin) => plugin.pluginKey === pluginKey)
const newFile = collectActivePluginNewFiles(installed)[0]
if (newFile && !pluginPanelUnavailableReason(useAppStore.getState())) {
openPluginNewFileFromSettings(newFile)
return
}
openEntry(pluginKey)
}
const install = usePluginInstallAction({
fetchPreview: catalog.fetchPreview,
onInstalled,
onTryPlugin: tryPlugin
onInstalled
})
const prefetch = usePluginHoverPrefetch(catalog.fetchPreview)
const orcaVersion = useOrcaVersion()
@@ -23,8 +23,11 @@ import { useAppStore } from '@/store'
import { getShortcutPlatform } from '@/lib/shortcut-platform'
import { usePluginLanguagePackStore } from '@/store/plugin-language-packs'
import type { ActivePluginCommand } from '@/store/plugin-panels'
import { usePluginIconThemes } from '@/store/plugin-icon-themes'
import { usePluginSyntaxes } from '@/store/plugin-syntax-packs'
import { ShortcutKeyCombo } from '../../ShortcutKeyCombo'
import { Button } from '../../ui/button'
import { revealPluginIconTheme } from '../state/plugin-try-action'
import { DetailRow, DetailSection } from './PluginDetailSections'
import { PluginContributionActionCommand } from './PluginContributionActionCommand'
import { PluginContributionEventRow, usePluginWorkerEvents } from './PluginContributionActionEvents'
@@ -192,6 +195,7 @@ function useContributionRowActions(
): (row: PluginContribution) => React.ReactNode {
const syntaxes = usePluginSyntaxes()
const languagePacks = usePluginLanguagePackStore((state) => state.entries)
const iconThemes = usePluginIconThemes()
if (!isPluginContributionLive(plugin)) {
return () => null
}
@@ -223,9 +227,26 @@ function useContributionRowActions(
const newFile = collectActivePluginNewFiles([plugin])[Number(id)]
return newFile ? <PluginContributionActionNewFile newFile={newFile} /> : null
}
case 'iconThemes': {
const theme = iconThemes.find(
(entry) => entry.id === id && entry.pluginKey === plugin.pluginKey
)
return theme ? (
<Button
variant="outline"
size="xs"
onClick={() =>
revealPluginIconTheme(() =>
useAppStore.getState().updateSettings({ pluginIconTheme: theme.id })
)
}
>
{translate('auto.components.settings.pluginInstallAction.tryIt', 'Try it')}
</Button>
) : null
}
case 'themes':
case 'terminalThemes':
case 'iconThemes':
case 'customEditors':
case 'vmRecipes':
// Themes have "Use now" above the list; editors open from their new files; recipes have nothing to try here.
@@ -11,27 +11,37 @@ import { languageDisplayName } from '../presentation/plugin-contributions'
// between switching and switching back. Falls back to following the system language.
let uiLanguageBeforeSwitch: UiLanguage | null = null
function applyUiLanguage(uiLanguage: UiLanguage): void {
useAppStore
function applyUiLanguage(uiLanguage: UiLanguage): Promise<boolean> {
return useAppStore
.getState()
.updateSettings({ uiLanguage })
.catch((cause: unknown) => {
toast.error(
translate(
'auto.components.settings.PluginContributionActionLanguage.failed',
'Could not switch language'
),
{ description: cause instanceof Error ? cause.message : String(cause) }
)
})
.then(
() => true,
(cause: unknown) => {
toast.error(
translate(
'auto.components.settings.PluginContributionActionLanguage.failed',
'Could not switch language'
),
{ description: cause instanceof Error ? cause.message : String(cause) }
)
return false
}
)
}
/** Switches the UI to a plugin's language pack, remembering the language to switch back to. */
export function switchToPluginLanguage(pack: PluginLanguagePackRegistration): void {
export function switchToPluginLanguage(pack: PluginLanguagePackRegistration): Promise<boolean> {
const current = useAppStore.getState().settings?.uiLanguage ?? UI_LANGUAGE_SYSTEM
// Why keep the first one: hopping between plugin packs must not lose the original.
uiLanguageBeforeSwitch ??= current
applyUiLanguage(pack.id)
return applyUiLanguage(pack.id)
}
export function switchBackFromPluginLanguage(): void {
const previous = uiLanguageBeforeSwitch ?? UI_LANGUAGE_SYSTEM
uiLanguageBeforeSwitch = null
void applyUiLanguage(previous)
}
/** Switches the UI to a plugin's language pack, with a way back; never applied unasked. */
@@ -55,11 +65,7 @@ export function PluginContributionActionLanguage({
'Switch back from {{value0}}',
{ value0: name }
)}
onClick={() => {
const previous = uiLanguageBeforeSwitch ?? UI_LANGUAGE_SYSTEM
uiLanguageBeforeSwitch = null
applyUiLanguage(previous)
}}
onClick={switchBackFromPluginLanguage}
>
{translate(
'auto.components.settings.PluginContributionActionLanguage.switchBack',
@@ -77,7 +83,7 @@ export function PluginContributionActionLanguage({
'Switch language to {{value0}}',
{ value0: name }
)}
onClick={() => switchToPluginLanguage(pack)}
onClick={() => void switchToPluginLanguage(pack)}
>
{translate(
'auto.components.settings.PluginContributionActionLanguage.switch',
@@ -5,6 +5,17 @@ import { Button } from '../../ui/button'
import { pluginPanelUnavailableReason } from './plugin-contribution-action-availability'
/** Leaves Settings for the workspace it came from and reveals the panel there. */
export function openPluginPanelFromSettings(panel: Pick<PluginHostPanel, 'tabKey'>): void {
const state = useAppStore.getState()
// Why re-check: the store may have moved on since this rendered.
if (pluginPanelUnavailableReason(state)) {
return
}
state.closeSettingsPage()
state.setRightSidebarTab(panel.tabKey)
state.setRightSidebarOpen(true)
}
export function PluginContributionActionPanel({
panel
}: {
@@ -25,16 +36,7 @@ export function PluginContributionActionPanel({
'Open panel: {{value0}}',
{ value0: panel.title }
)}
onClick={() => {
const state = useAppStore.getState()
// Why re-check: the store may have moved on since this rendered.
if (pluginPanelUnavailableReason(state)) {
return
}
state.closeSettingsPage()
state.setRightSidebarTab(panel.tabKey)
state.setRightSidebarOpen(true)
}}
onClick={() => openPluginPanelFromSettings(panel)}
>
{translate('auto.components.settings.PluginContributionActionPanel.open', 'Open panel')}
</Button>
@@ -1,6 +1,10 @@
import { translate } from '@/i18n/i18n'
import { useAppStore } from '@/store'
import { Button } from '../../ui/button'
import { openPluginSampleFromSettings, pluginSyntaxSample } from '../state/plugin-try-action'
import { pluginPanelUnavailableReason } from './plugin-contribution-action-availability'
/** There is nothing to click for a grammar: it shows the moment a matching file is open. */
/** A grammar shows the moment a matching file is open, so Try it opens a sample of one. */
export function PluginContributionSyntaxHint({
extensions,
filenames
@@ -8,6 +12,15 @@ export function PluginContributionSyntaxHint({
extensions: readonly string[]
filenames: readonly string[]
}): React.JSX.Element | null {
const noWorkspace = useAppStore((state) => pluginPanelUnavailableReason(state) !== null)
const sample = pluginSyntaxSample([{ extensions }])
if (sample && !noWorkspace) {
return (
<Button variant="outline" size="xs" onClick={() => openPluginSampleFromSettings(sample)}>
{translate('auto.components.settings.pluginInstallAction.tryIt', 'Try it')}
</Button>
)
}
const extension = extensions[0]
const filename = filenames[0]
if (!extension && !filename) {
@@ -0,0 +1,44 @@
import { describe, expect, it, vi } from 'vitest'
import { pluginManifestSchema } from '../../../../../shared/plugins/manifest/plugin-manifest'
import { pluginSyntaxSample, pluginTryAction } from './plugin-try-action'
vi.mock('@/store', () => ({ useAppStore: { getState: vi.fn() } }))
const manifest = (contributes: Record<string, unknown>) =>
pluginManifestSchema.parse({
manifestVersion: 1,
id: 'p',
publisher: 'acme',
name: 'P',
version: '1.0.0',
engines: { orca: '>=1.0.0' },
pluginApi: 1,
contributes
})
const syntax = (extensions: string[]) => ({
id: 's',
title: 'S',
scopeName: 'source.s',
path: 's.json',
extensions
})
describe('pluginTryAction', () => {
it('has a sample for a known grammar, matched ignoring case', () => {
expect(pluginSyntaxSample([{ extensions: ['.GLEAM'] }])?.content).toContain('pub fn main')
expect(pluginTryAction('acme.p', manifest({ syntaxes: [syntax(['.gleam'])] }))).toBeTypeOf(
'function'
)
})
it('offers nothing for a grammar without a sample or a theme-only plugin', () => {
expect(pluginTryAction('acme.p', manifest({ syntaxes: [syntax(['.zzz'])] }))).toBeNull()
expect(
pluginTryAction(
'acme.p',
manifest({ themes: [{ id: 'd', title: 'D', path: 'themes/d.json' }] })
)
).toBeNull()
})
})
@@ -0,0 +1,148 @@
import { toast } from 'sonner'
import type { PluginManifest } from '../../../../../shared/plugins/manifest/plugin-manifest'
import { isUsablePluginLanguagePack } from '../../../../../shared/plugins/languages/plugin-language-pack-coverage'
import { translate } from '@/i18n/i18n'
import { useAppStore } from '@/store'
import { usePluginLanguagePackStore } from '@/store/plugin-language-packs'
import { collectActivePluginNewFiles } from '@/store/plugin-new-files'
import { usePluginPanelsStore } from '@/store/plugin-panels'
import { pluginPanelUnavailableReason } from '../page/plugin-contribution-action-availability'
import {
switchBackFromPluginLanguage,
switchToPluginLanguage
} from '../page/PluginContributionActionLanguage'
import { openPluginNewFileFromSettings } from '../page/PluginContributionActionNewFile'
import { openPluginPanelFromSettings } from '../page/PluginContributionActionPanel'
import { languageDisplayName } from '../presentation/plugin-contributions'
import { PLUGIN_ACTION_TOAST_DURATION_MS } from './plugin-action-toast'
// Why built in: catalog grammars ship no sample, and an empty file shows no highlighting.
const SYNTAX_SAMPLES: Readonly<Record<string, string>> = {
'.gleam':
'import gleam/io\n\n// Greets whoever runs it.\npub fn main() -> Nil {\n let count = 3\n io.println("Hello from Gleam!")\n}\n',
'.prisma':
'// A user table.\nmodel User {\n id Int @id @default(autoincrement())\n email String @unique\n name String?\n}\n',
'.nix':
'{ pkgs ? import <nixpkgs> { } }:\n\n# A development shell.\npkgs.mkShell {\n packages = [ pkgs.nodejs ];\n shellHook = "echo ready";\n}\n',
'.gd':
'extends Node2D\n\n# Moves right every frame.\nvar speed := 120.0\n\nfunc _process(delta: float) -> void:\n\tposition.x += speed * delta\n',
'.tex':
'\\documentclass{article}\n\n% A tiny document.\n\\begin{document}\n\\section{Hello}\nInline math: $e^{i\\pi} + 1 = 0$.\n\\end{document}\n'
}
export function pluginSyntaxSample(
syntaxes: readonly { extensions: readonly string[] }[]
): { extension: string; content: string } | null {
for (const extension of syntaxes.flatMap((syntax) => syntax.extensions)) {
const content = SYNTAX_SAMPLES[extension.toLowerCase()]
if (content) {
return { extension, content }
}
}
return null
}
/** Runs `act` over the workspace Settings came from, or says why it can't. */
function fromSettingsToWorkspace(act: () => void): void {
const reason = pluginPanelUnavailableReason(useAppStore.getState())
if (reason) {
toast.info(reason)
return
}
act()
}
export function openPluginSampleFromSettings(sample: { extension: string; content: string }): void {
fromSettingsToWorkspace(() => {
const state = useAppStore.getState()
state.closeSettingsPage()
void state.openSampleFileInActiveWorkspace(sample)
})
}
/** Applies the plugin's file icons and shows them in the file tree. */
export function revealPluginIconTheme(apply: () => Promise<unknown>): void {
fromSettingsToWorkspace(() => {
void apply()
const state = useAppStore.getState()
state.closeSettingsPage()
state.showRightSidebarFiles()
})
}
const livePlugins = (pluginKey: string) =>
usePluginPanelsStore.getState().plugins.filter((plugin) => plugin.pluginKey === pluginKey)
async function tryLanguagePack(pluginKey: string): Promise<void> {
const entries = await usePluginLanguagePackStore.getState().fetchEntries()
const pack = entries.find(
(entry) => entry.pluginKey === pluginKey && isUsablePluginLanguagePack(entry)
)
if (!pack) {
toast.info(
translate(
'auto.components.settings.pluginTryAction.incompleteLanguage',
'This language pack is too incomplete to switch to.'
)
)
return
}
if (!(await switchToPluginLanguage(pack))) {
return
}
toast.success(
translate('auto.components.settings.pluginThemeApply.nowUsing', 'Now using {{value0}}', {
value0: languageDisplayName(pack.locale)
}),
{
duration: PLUGIN_ACTION_TOAST_DURATION_MS,
action: {
label: translate('auto.components.settings.pluginThemeApply.switchBack', 'Switch back'),
onClick: switchBackFromPluginLanguage
}
}
)
}
/**
* What "Try it" does right after install, or null when nothing shows the plugin off directly.
* Chosen from the manifest; the live registrations it uses land after install, so read on click.
*/
export function pluginTryAction(
pluginKey: string,
manifest: Pick<PluginManifest, 'name' | 'contributes'>
): (() => void) | null {
const { contributes } = manifest
if (contributes.newFiles.length > 0) {
return () => {
const newFile = collectActivePluginNewFiles(livePlugins(pluginKey))[0]
if (newFile) {
fromSettingsToWorkspace(() => openPluginNewFileFromSettings(newFile))
}
}
}
const sample = pluginSyntaxSample(contributes.syntaxes)
if (sample) {
return () => openPluginSampleFromSettings(sample)
}
if (contributes.languagePacks.length > 0) {
return () => void tryLanguagePack(pluginKey)
}
if (contributes.panels.length > 0) {
return () => {
const panel = livePlugins(pluginKey)[0]?.panels[0]
if (panel) {
fromSettingsToWorkspace(() => openPluginPanelFromSettings(panel))
}
}
}
if (contributes.commands.length > 0) {
return () => {
const state = useAppStore.getState()
state.closeSettingsPage()
// Why the name: the palette matches plugin commands by their plugin's name.
state.openModal('worktree-palette', { query: manifest.name })
}
}
return null
}
@@ -61,16 +61,15 @@ const codePreview: PluginMarketplaceHostInstallPreview = {
}
function setup() {
const onTryPlugin = vi.fn()
const onInstalled = vi.fn().mockResolvedValue(undefined)
const fetchPreview = vi.fn().mockResolvedValue({ ok: true, preview })
const hook: { current: ReturnType<typeof usePluginInstallAction> | null } = { current: null }
function Probe(): null {
hook.current = usePluginInstallAction({ fetchPreview, onInstalled, onTryPlugin })
hook.current = usePluginInstallAction({ fetchPreview, onInstalled })
return null
}
act(() => createRoot(document.createElement('div')).render(<Probe />))
return { hook, onInstalled, onTryPlugin, fetchPreview }
return { hook, onInstalled, fetchPreview }
}
function installResult(on: boolean) {
@@ -121,7 +120,22 @@ describe('usePluginInstallAction', () => {
})
it('keeps the Try it toast up long enough to click', async () => {
const { hook } = setup()
const { hook, fetchPreview } = setup()
const manifest = pluginManifestSchema.parse({
...preview.manifest,
contributes: {
syntaxes: [
{
id: 'gleam',
title: 'Gleam',
scopeName: 'source.gleam',
path: 'g.json',
extensions: ['.gleam']
}
]
}
})
fetchPreview.mockResolvedValue({ ok: true, preview: { ...preview, manifest } })
await act(async () => hook.current?.install(listing))
expect(toast.success).toHaveBeenCalledWith(
expect.any(String),
@@ -132,6 +146,15 @@ describe('usePluginInstallAction', () => {
)
})
it('offers no Try it when nothing shows the plugin off directly', async () => {
const { hook } = setup()
await act(async () => hook.current?.install(listing))
expect(toast.success).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ action: undefined })
)
})
it('approves exactly the fingerprint the page showed for a plugin that runs code', async () => {
const { hook } = setup()
const codeListing = {
@@ -9,6 +9,7 @@ import { translate } from '@/i18n/i18n'
import { pluginInstallErrorMessage } from '../presentation/plugin-error-presentation'
import { PLUGIN_ACTION_TOAST_DURATION_MS } from './plugin-action-toast'
import { applyPluginThemesNow } from './plugin-theme-apply'
import { pluginTryAction } from './plugin-try-action'
import type { PluginPagePreview } from './use-plugin-catalog'
import { previewMatchesListing } from './use-plugin-page-model'
@@ -51,13 +52,11 @@ const downloadFailed = (): string =>
*/
export function usePluginInstallAction({
fetchPreview,
onInstalled,
onTryPlugin
onInstalled
}: {
fetchPreview: (listing: PluginMarketplaceHostListing) => Promise<PluginPagePreview>
/** Reloads the list; opens review on the page for any plugin still waiting for it. */
onInstalled: (pluginKey: string) => Promise<void>
onTryPlugin?: (pluginKey: string) => void
}): {
phaseOf: (pluginKey: string) => PluginInstallPhase | null
install: (
@@ -159,28 +158,32 @@ export function usePluginInstallAction({
if (themed && (await applyPluginThemesNow(pluginKey).catch(() => false))) {
return
}
if (onTryPlugin) {
toast.success(
translate(
'auto.components.settings.pluginInstallAction.installed',
'{{value0}} installed',
{ value0: preview.manifest.name }
),
{
duration: PLUGIN_ACTION_TOAST_DURATION_MS,
action: {
label: translate('auto.components.settings.pluginInstallAction.tryIt', 'Try it'),
onClick: () => onTryPlugin(pluginKey)
}
}
)
}
// Why: only a plugin that is on by now has anything to try; recipes still wait for review.
const on =
execution === 'data' || (approve && preview.manifest.contributes.vmRecipes.length === 0)
const tryIt = on ? pluginTryAction(pluginKey, preview.manifest) : null
toast.success(
translate(
'auto.components.settings.pluginInstallAction.installed',
'{{value0}} installed',
{ value0: preview.manifest.name }
),
{
duration: PLUGIN_ACTION_TOAST_DURATION_MS,
action: tryIt
? {
label: translate('auto.components.settings.pluginInstallAction.tryIt', 'Try it'),
onClick: tryIt
}
: undefined
}
)
} catch (cause) {
console.warn('[plugins] catalog install failed:', cause)
setPhase(pluginKey, { kind: 'error', message: catalogInstallErrorMessage(cause) })
}
},
[dismiss, fetchPreview, onInstalled, onTryPlugin, setPhase]
[dismiss, fetchPreview, onInstalled, setPhase]
)
const phaseOf = useCallback(
@@ -104,8 +104,11 @@ export function useWorktreeJumpPaletteSelectionLifecycle({
? document.activeElement
: null
skipRestoreFocusRef.current = false
latestQueryRef.current = ''
setQuery('')
// Why: an opener may seed the search, e.g. a plugin's Try it shows its commands.
const seededQuery = appState.modalData.query
const initialQuery = typeof seededQuery === 'string' ? seededQuery : ''
latestQueryRef.current = initialQuery
setQuery(initialQuery)
setSelectedItemId('')
setExpandedSectionCaps({})
selectionMovedByUserRef.current = false
+3
View File
@@ -13259,6 +13259,9 @@
},
"PluginContributionActionNewFile": {
"create": "Create"
},
"pluginTryAction": {
"incompleteLanguage": "This language pack is too incomplete to switch to."
}
},
"right": {
@@ -76,20 +76,49 @@ export async function createUntitledFileInActiveWorkspace(
}
}
/** Creates `untitled<extension>` at the active workspace root, seeded with `readContent`. */
function openSeededUntitledFile(
get: EditorGet,
extension: string,
readContent: () => Promise<string>,
groupId?: string
): Promise<boolean> {
return createUntitledFileInActiveWorkspace(
get,
groupId,
async (context) =>
createUntitledMarkdownFile(
context.worktreePath,
context.worktreeId,
context.connectionId,
context.target,
{
newFile: { extension, content: await readContent() },
operationProvenance: context.operationProvenance,
expectedSshTargetId: context.expectedSshTargetId,
expectedSshConnectionGeneration: context.expectedSshConnectionGeneration,
expectedExecutionHostId: context.expectedExecutionHostId,
assertOperationCurrent: context.assertOperationCurrent
}
),
translate('auto.store.pluginNewFile.failed', "Couldn't create the file.")
)
}
export function createPluginNewFileActions(
_set: EditorSet,
get: EditorGet
): Pick<EditorSlice, 'openNewPluginFileInActiveWorkspace'> {
): Pick<EditorSlice, 'openNewPluginFileInActiveWorkspace' | 'openSampleFileInActiveWorkspace'> {
return {
openNewPluginFileInActiveWorkspace: async (newFile, groupId) => {
const readTemplate = window.api?.plugins?.readNewFileTemplate
if (!readTemplate) {
return
}
await createUntitledFileInActiveWorkspace(
await openSeededUntitledFile(
get,
groupId,
async (context) => {
newFile.extension,
async () => {
const content = await readTemplate({
pluginKey: newFile.pluginKey,
newFileId: newFile.id
@@ -103,23 +132,13 @@ export function createPluginNewFileActions(
)
)
}
return createUntitledMarkdownFile(
context.worktreePath,
context.worktreeId,
context.connectionId,
context.target,
{
newFile: { extension: newFile.extension, content },
operationProvenance: context.operationProvenance,
expectedSshTargetId: context.expectedSshTargetId,
expectedSshConnectionGeneration: context.expectedSshConnectionGeneration,
expectedExecutionHostId: context.expectedExecutionHostId,
assertOperationCurrent: context.assertOperationCurrent
}
)
return content
},
translate('auto.store.pluginNewFile.failed', "Couldn't create the file.")
groupId
)
},
openSampleFileInActiveWorkspace: async (sample) => {
await openSeededUntitledFile(get, sample.extension, async () => sample.content)
}
}
}
@@ -90,6 +90,8 @@ export type EditorFilesSlice = {
newFile: { pluginKey: string; id: string; title: string; extension: string },
groupId?: string
) => Promise<void>
/** Creates an untitled file of `extension` holding `content` at the active workspace root. */
openSampleFileInActiveWorkspace: (sample: { extension: string; content: string }) => Promise<void>
// Why: sequences openFile/setMarkdownViewMode/reveal around an async Monaco remount. See docs/markdown-internal-link-opening-design.md.
activateMarkdownLink: (
rawHref: string | undefined,
@@ -1,18 +1,71 @@
import { cp, mkdir, realpath, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { hashPluginTree } from '../../../src/main/plugins/plugin-content-hash'
import { pluginContributionSummary } from '../../../src/shared/plugins/contributions/plugin-contribution-summary'
import { pluginEditorFileTypes } from '../../../src/shared/plugins/contributions/plugin-file-type'
import { parsePluginManifest } from '../../../src/shared/plugins/manifest/plugin-manifest'
import { PLUGIN_MANIFEST_V2_SINCE } from '../../../src/shared/plugins/manifest/plugin-schema-field-docs'
import { commitRepository, configureFixtureGit, headCommit } from './plugin-git-fixture'
const PLUGIN = 'orca-gleam-syntax'
export const GLEAM_PLUGIN_KEY = `stablyai.${PLUGIN}`
const EDITOR = 'orca-excalidraw'
export const EXCALIDRAW_PLUGIN_KEY = `stablyai.${EDITOR}`
export const EXCALIDRAW_TEMPLATE = '{"type":"excalidraw","elements":[]}\n'
/** An official catalog listing the Gleam syntax plugin, plus a workspace with a .gleam file. */
/** A custom editor with a new-file template, as the official Excalidraw plugin ships. */
async function createEditorPlugin(dir: string) {
await mkdir(join(dir, 'editor'), { recursive: true })
await mkdir(join(dir, 'templates'), { recursive: true })
await writeFile(join(dir, 'editor', 'index.html'), '<main>drawing</main>\n')
await writeFile(join(dir, 'templates', 'empty.excalidraw'), EXCALIDRAW_TEMPLATE)
const manifest = {
manifestVersion: 2,
execution: 'sandboxed-ui',
id: EDITOR,
publisher: 'stablyai',
name: 'Excalidraw',
version: '1.0.0',
license: 'MIT',
engines: { orca: `>=${PLUGIN_MANIFEST_V2_SINCE}` },
contributes: {
customEditors: [
{
id: 'excalidraw',
title: 'Excalidraw',
extensions: ['.excalidraw'],
entry: 'editor/index.html'
}
],
newFiles: [
{
id: 'drawing',
title: 'New drawing',
extension: '.excalidraw',
template: 'templates/empty.excalidraw'
}
]
}
}
await writeFile(join(dir, 'orca-plugin.json'), JSON.stringify(manifest))
const parsed = parsePluginManifest(manifest)
if (!parsed.ok) {
throw new Error(parsed.error)
}
return parsed.manifest
}
/** An official catalog listing the Gleam syntax plugin and an Excalidraw-like editor, plus a
* workspace with a .gleam file. */
export async function createGleamCatalogFixture(root: string) {
const repositories = join(root, 'repositories')
const home = join(root, 'home')
await mkdir(home, { recursive: true })
const gitEnvironment = await configureFixtureGit(home, repositories, ['orca-plugins', PLUGIN])
const gitEnvironment = await configureFixtureGit(home, repositories, [
'orca-plugins',
PLUGIN,
EDITOR
])
const plugin = join(repositories, `${PLUGIN}.git`)
await cp(join(process.cwd(), 'examples', 'plugins', PLUGIN), plugin, { recursive: true })
@@ -23,6 +76,14 @@ export async function createGleamCatalogFixture(root: string) {
}
await commitRepository(plugin, gitEnvironment)
const editor = join(repositories, `${EDITOR}.git`)
const editorManifest = await createEditorPlugin(editor)
const editorHash = await hashPluginTree(editor)
if (!editorHash.ok) {
throw new Error(editorHash.error)
}
await commitRepository(editor, gitEnvironment)
const catalog = join(repositories, 'orca-plugins.git')
await mkdir(catalog, { recursive: true })
await writeFile(
@@ -47,6 +108,23 @@ export async function createGleamCatalogFixture(root: string) {
fileTypes: ['gleam']
}
]
},
{
id: EXCALIDRAW_PLUGIN_KEY,
source: { kind: 'git', url: `https://github.com/stablyai/${EDITOR}.git`, ref: 'v1.0.0' },
releases: [
{
version: '1.0.0',
commit: await headCommit(editor, gitEnvironment),
contentHash: editorHash.hash,
execution: 'sandboxed-ui',
minimumOrca: PLUGIN_MANIFEST_V2_SINCE,
name: 'Excalidraw',
contributes: pluginContributionSummary(editorManifest),
fileTypes: pluginEditorFileTypes(editorManifest.contributes),
editorFileTypes: pluginEditorFileTypes(editorManifest.contributes)
}
]
}
]
})
+100
View File
@@ -0,0 +1,100 @@
/**
* Invariant: the Try it on a plugin's install toast does the thing itself instead of opening
* the plugin's page — a grammar opens a highlighted sample, an editor a new file of its own.
*/
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { ElectronApplication, Page } from '@stablyai/playwright-test'
import { expect, test } from '@stablyai/playwright-test'
import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart'
import {
createGleamCatalogFixture,
EXCALIDRAW_PLUGIN_KEY,
EXCALIDRAW_TEMPLATE,
GLEAM_PLUGIN_KEY
} from './helpers/plugin-gleam-catalog-fixture'
import { editorTokenClasses } from './helpers/plugin-smoke'
async function openPluginPage(
app: ElectronApplication,
page: Page,
pluginKey: string,
name: string
) {
await app.evaluate(({ app: electronApp }, link) => {
electronApp.emit('open-url', { preventDefault: () => undefined }, link)
}, `orca://plugins/${pluginKey}?catalog=official`)
await expect(page.getByRole('article').locator('#plugin-detail-heading')).toContainText(name, {
timeout: 30_000
})
}
async function tryFromToast(page: Page, name: string): Promise<void> {
const toast = page.locator('[data-sonner-toast]').filter({ hasText: `${name} installed` })
await toast.getByRole('button', { name: 'Try it' }).click({ timeout: 30_000 })
await expect(page.locator('[data-settings-section="plugins"]')).toBeHidden()
}
// oxlint-disable-next-line no-empty-pattern -- Playwright passes fixtures before testInfo.
test('Try it opens a highlighted sample and a new drawing', async ({}, testInfo) => {
// Why: one Electron launch, a Git catalog fetch and two installs; slower than the 30s default.
test.setTimeout(180_000)
const root = await mkdtemp(join(tmpdir(), 'orca-plugin-try-it-e2e-'))
const fixture = await createGleamCatalogFixture(root)
const session = createRestartSession(testInfo, fixture.gitEnvironment)
let launched: Awaited<ReturnType<typeof session.launch>> | null = null
try {
launched = await session.launch()
const { app, page } = launched
await page.evaluate(async () => {
const settings = await window.api.settings.set({ pluginSystemEnabled: true })
window.__store?.setState({ settings })
await window.api.plugins.refreshMarketplaces?.({})
})
await expect
.poll(
() =>
page.evaluate(async () =>
(await window.api.plugins.listMarketplacePlugins()).map((entry) => entry.pluginKey)
),
{ timeout: 60_000 }
)
.toEqual(expect.arrayContaining([GLEAM_PLUGIN_KEY, EXCALIDRAW_PLUGIN_KEY]))
await attachRepoAndOpenTerminal(page, fixture.workspace)
await openPluginPage(app, page, GLEAM_PLUGIN_KEY, 'Gleam')
await page.getByRole('article').getByRole('button', { name: 'Install Gleam' }).click()
await tryFromToast(page, 'Gleam')
await expect(page.locator('[data-tab-id]').filter({ hasText: 'untitled.gleam' })).toBeVisible()
// The plugin grammar paints the sample in more than one token class.
await expect
.poll(async () => (await editorTokenClasses(page, 'pub fn main')).length, {
timeout: 15_000
})
.toBeGreaterThan(1)
await openPluginPage(app, page, EXCALIDRAW_PLUGIN_KEY, 'Excalidraw')
await page
.getByRole('article')
.getByRole('button', { name: /^Install & enable/ })
.click({ timeout: 30_000 })
await tryFromToast(page, 'Excalidraw')
await expect
.poll(() =>
readFile(join(fixture.workspace, 'untitled.excalidraw'), 'utf8').catch(() => null)
)
.toBe(EXCALIDRAW_TEMPLATE)
await expect(
page.locator('[data-tab-id]').filter({ hasText: 'untitled.excalidraw' })
).toBeVisible()
await expect(page.locator('iframe[title="Excalidraw"]')).toBeVisible({ timeout: 15_000 })
} finally {
if (launched) {
await session.close(launched.app)
}
await session.dispose()
await rm(root, { recursive: true, force: true })
}
})