diff --git a/src/renderer/src/components/plugins/catalog/PluginInUseAction.tsx b/src/renderer/src/components/plugins/catalog/PluginInUseAction.tsx
index 3c69a07b0a0..0bc253113a5 100644
--- a/src/renderer/src/components/plugins/catalog/PluginInUseAction.tsx
+++ b/src/renderer/src/components/plugins/catalog/PluginInUseAction.tsx
@@ -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')}
diff --git a/src/renderer/src/components/plugins/list/PluginManager.tsx b/src/renderer/src/components/plugins/list/PluginManager.tsx
index b05db6c0156..a829ded96f9 100644
--- a/src/renderer/src/components/plugins/list/PluginManager.tsx
+++ b/src/renderer/src/components/plugins/list/PluginManager.tsx
@@ -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()
diff --git a/src/renderer/src/components/plugins/page/PluginAddsSection.tsx b/src/renderer/src/components/plugins/page/PluginAddsSection.tsx
index 7a2ac524bd4..7ff6fea7a8a 100644
--- a/src/renderer/src/components/plugins/page/PluginAddsSection.tsx
+++ b/src/renderer/src/components/plugins/page/PluginAddsSection.tsx
@@ -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 ? : null
}
+ case 'iconThemes': {
+ const theme = iconThemes.find(
+ (entry) => entry.id === id && entry.pluginKey === plugin.pluginKey
+ )
+ return theme ? (
+
+ ) : 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.
diff --git a/src/renderer/src/components/plugins/page/PluginContributionActionLanguage.tsx b/src/renderer/src/components/plugins/page/PluginContributionActionLanguage.tsx
index e49289f916d..1816a1462cb 100644
--- a/src/renderer/src/components/plugins/page/PluginContributionActionLanguage.tsx
+++ b/src/renderer/src/components/plugins/page/PluginContributionActionLanguage.tsx
@@ -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 {
+ 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 {
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',
diff --git a/src/renderer/src/components/plugins/page/PluginContributionActionPanel.tsx b/src/renderer/src/components/plugins/page/PluginContributionActionPanel.tsx
index 5cee39dead5..95ea5a0090c 100644
--- a/src/renderer/src/components/plugins/page/PluginContributionActionPanel.tsx
+++ b/src/renderer/src/components/plugins/page/PluginContributionActionPanel.tsx
@@ -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): 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')}
diff --git a/src/renderer/src/components/plugins/page/PluginContributionActionSyntax.tsx b/src/renderer/src/components/plugins/page/PluginContributionActionSyntax.tsx
index e5ccea1c0ac..cef070ebef4 100644
--- a/src/renderer/src/components/plugins/page/PluginContributionActionSyntax.tsx
+++ b/src/renderer/src/components/plugins/page/PluginContributionActionSyntax.tsx
@@ -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 (
+
+ )
+ }
const extension = extensions[0]
const filename = filenames[0]
if (!extension && !filename) {
diff --git a/src/renderer/src/components/plugins/state/plugin-try-action.test.ts b/src/renderer/src/components/plugins/state/plugin-try-action.test.ts
new file mode 100644
index 00000000000..e1fd1e06bcc
--- /dev/null
+++ b/src/renderer/src/components/plugins/state/plugin-try-action.test.ts
@@ -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) =>
+ 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()
+ })
+})
diff --git a/src/renderer/src/components/plugins/state/plugin-try-action.ts b/src/renderer/src/components/plugins/state/plugin-try-action.ts
new file mode 100644
index 00000000000..c25ff50da16
--- /dev/null
+++ b/src/renderer/src/components/plugins/state/plugin-try-action.ts
@@ -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> = {
+ '.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 { } }:\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): 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 {
+ 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
+): (() => 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
+}
diff --git a/src/renderer/src/components/plugins/state/use-plugin-install-action.test.tsx b/src/renderer/src/components/plugins/state/use-plugin-install-action.test.tsx
index d16e5fe8346..dc299c6af87 100644
--- a/src/renderer/src/components/plugins/state/use-plugin-install-action.test.tsx
+++ b/src/renderer/src/components/plugins/state/use-plugin-install-action.test.tsx
@@ -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 | 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())
- 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 = {
diff --git a/src/renderer/src/components/plugins/state/use-plugin-install-action.ts b/src/renderer/src/components/plugins/state/use-plugin-install-action.ts
index ac3ae958237..1b2238cc8db 100644
--- a/src/renderer/src/components/plugins/state/use-plugin-install-action.ts
+++ b/src/renderer/src/components/plugins/state/use-plugin-install-action.ts
@@ -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
/** Reloads the list; opens review on the page for any plugin still waiting for it. */
onInstalled: (pluginKey: string) => Promise
- 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(
diff --git a/src/renderer/src/components/use-worktree-jump-palette-selection-lifecycle.ts b/src/renderer/src/components/use-worktree-jump-palette-selection-lifecycle.ts
index df331b35f0e..713bea4322c 100644
--- a/src/renderer/src/components/use-worktree-jump-palette-selection-lifecycle.ts
+++ b/src/renderer/src/components/use-worktree-jump-palette-selection-lifecycle.ts
@@ -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
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index eed4f68e58c..285a94932b4 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -13259,6 +13259,9 @@
},
"PluginContributionActionNewFile": {
"create": "Create"
+ },
+ "pluginTryAction": {
+ "incompleteLanguage": "This language pack is too incomplete to switch to."
}
},
"right": {
diff --git a/src/renderer/src/store/slices/editor/actions/untitled-file-creation.ts b/src/renderer/src/store/slices/editor/actions/untitled-file-creation.ts
index 94604fdec95..f8756a030d8 100644
--- a/src/renderer/src/store/slices/editor/actions/untitled-file-creation.ts
+++ b/src/renderer/src/store/slices/editor/actions/untitled-file-creation.ts
@@ -76,20 +76,49 @@ export async function createUntitledFileInActiveWorkspace(
}
}
+/** Creates `untitled` at the active workspace root, seeded with `readContent`. */
+function openSeededUntitledFile(
+ get: EditorGet,
+ extension: string,
+ readContent: () => Promise,
+ groupId?: string
+): Promise {
+ 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 {
+): Pick {
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)
}
}
}
diff --git a/src/renderer/src/store/slices/editor/types/editor-files-slice.ts b/src/renderer/src/store/slices/editor/types/editor-files-slice.ts
index 6be422c680f..0a696f54374 100644
--- a/src/renderer/src/store/slices/editor/types/editor-files-slice.ts
+++ b/src/renderer/src/store/slices/editor/types/editor-files-slice.ts
@@ -90,6 +90,8 @@ export type EditorFilesSlice = {
newFile: { pluginKey: string; id: string; title: string; extension: string },
groupId?: string
) => Promise
+ /** Creates an untitled file of `extension` holding `content` at the active workspace root. */
+ openSampleFileInActiveWorkspace: (sample: { extension: string; content: string }) => Promise
// Why: sequences openFile/setMarkdownViewMode/reveal around an async Monaco remount. See docs/markdown-internal-link-opening-design.md.
activateMarkdownLink: (
rawHref: string | undefined,
diff --git a/tests/e2e/helpers/plugin-gleam-catalog-fixture.ts b/tests/e2e/helpers/plugin-gleam-catalog-fixture.ts
index 73b080b760b..40e35509f59 100644
--- a/tests/e2e/helpers/plugin-gleam-catalog-fixture.ts
+++ b/tests/e2e/helpers/plugin-gleam-catalog-fixture.ts
@@ -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'), 'drawing\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)
+ }
+ ]
}
]
})
diff --git a/tests/e2e/plugin-try-it.spec.ts b/tests/e2e/plugin-try-it.spec.ts
new file mode 100644
index 00000000000..668c7ca671e
--- /dev/null
+++ b/tests/e2e/plugin-try-it.spec.ts
@@ -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 {
+ 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> | 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 })
+ }
+})