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 }) + } +})