feat: full screen onboarding with a plugin picker and per step versions

This commit is contained in:
LukeGus committed 2026-10-06 17:44:25 -05:00
1 parent 1e74ce4827
commit b9dd3488a5
59 files changed
+5413 -643

No files matched your search

+52
View File
@@ -45,6 +45,11 @@ import {
} from "@/shell/action-registry";
import { pluginApiFor, pluginFetch, pluginWsUrl } from "@/lib/plugin-transport";
import { registerPluginComponent } from "./component-registry";
import {
onboardingSteps,
sectionPosition,
} from "@/onboarding/onboarding-registry";
import { pluginStepKey } from "@/types/onboarding";
import type { LucideIcon } from "lucide-react";
import {
registerLoginMethod,
@@ -70,6 +75,8 @@ import { pluginKey } from "@/lib/plugin-i18n";
import { hasPermission } from "@/hooks/use-permissions";
import i18n from "@/i18n/i18n";
const ONBOARDING_ID = /^[a-z0-9][a-z0-9-]{0,63}$/;
export interface PluginAppHandle {
app: TermixApp;
/** Runs every disposer, newest first, isolating failures. */
@@ -122,6 +129,12 @@ export function createPluginApp(
fn: F | undefined,
): F | undefined => scopeHostCallback(fn, pluginId);
const requireStepId = (id: string, what: string) => {
if (typeof id !== "string" || !ONBOARDING_ID.test(id)) {
throw new Error(`${pluginId}: ${what} id "${id}" is not valid`);
}
};
const requireDeclared = (kind: ViewKind, id: string, what: string) => {
if (!manifestDeclares(contributes, kind, id)) {
const field =
@@ -463,6 +476,45 @@ export function createPluginApp(
);
},
registerOnboardingStep(step) {
requireStepId(step.id, "onboarding step");
const version = step.version ?? 1;
if (!Number.isInteger(version) || version < 1) {
throw new Error(
`${pluginId}: onboarding step "${step.id}" needs a whole version of 1 or more`,
);
}
const when = guardCallback(pluginId, step.when, false);
return track(
onboardingSteps.register({
id: pluginStepKey(pluginId, step.id),
pluginId,
version,
titleKey: key(step.titleKey),
descriptionKey: step.descriptionKey
? key(step.descriptionKey)
: undefined,
icon: step.icon
? (withIconBoundary(pluginId, step.icon as never) as never)
: undefined,
Component: scoped(step.component),
audience: step.audience ?? "all",
permission: step.permission
? resolvePluginPermission(pluginId, step.permission)
: undefined,
position: sectionPosition(step.section, step.order),
isRelevant: when
? (ctx) =>
when({
isAdmin: ctx.isAdmin,
isDesktop: ctx.isDesktop,
mode: ctx.mode,
}) === true
: undefined,
}),
);
},
invokeAction: (id, ...args) => invokeAction(id, ...args),
registerComponent(id, component) {
+33
View File
@@ -73,6 +73,7 @@ import {
import { PluginViewPlaceholder } from "./PluginViewPlaceholder";
import { resolveLayoutTabTarget } from "@/shell/shell-layout";
import { getExtension, listExtensions } from "./extension-registry";
import { onboardingSteps } from "@/onboarding/onboarding-registry";
import type { WorkspaceTabSnapshot } from "@/types/ui-types";
function recordingShell(calls: ShellCall[]): TabShellCallbacks {
@@ -201,6 +202,8 @@ export async function renderPlugin(
const mine = <T extends { pluginId?: string }>(items: T[]) =>
items.filter((item) => item.pluginId === pluginId);
const unprefixed = (items: { id: string; pluginId?: string }[]) =>
mine(items).map((item) => item.id.slice(pluginId.length + 1));
const tabRecord = (type: string, host?: Host): Tab => ({
id: `${type}-test`,
@@ -257,6 +260,7 @@ export async function renderPlugin(
mine(listKeybindingActions()).map((action) => action.id),
keybindingDefaults: () =>
mine(listKeybindingDefaults()).map((binding) => binding.id),
onboardingSteps: () => unprefixed(onboardingSteps.list()),
},
async loadPaletteGroup(id) {
@@ -332,6 +336,35 @@ export async function renderPlugin(
return wrap(<Component {...props} />);
},
renderOnboardingStep(id, props = {}) {
const step =
onboardingSteps.get(`${pluginId}:${id}`) ??
missing("an onboarding step", id);
const Component = step.Component;
let canContinue = true;
let beforeNext: (() => boolean | Promise<boolean>) | null = null;
const element = wrap(
<Component
mode="full"
isAdmin
isDesktop={false}
shellReady={false}
setCanContinue={(ok) => {
canContinue = ok;
}}
setBeforeNext={(fn) => {
beforeNext = fn;
}}
{...props}
/>,
);
return {
element,
canContinue: () => canContinue,
next: async () => (beforeNext ? await beforeNext() : true),
};
},
renderDashboardCard(id) {
const card = getRegisteredDashboardCard(id) ?? missing("a card", id);
const Component = card.component;