mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 08:37:49 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user