diff --git a/site/package.json b/site/package.json index a427734cb8..dfda9ff020 100644 --- a/site/package.json +++ b/site/package.json @@ -22,6 +22,7 @@ "@fortawesome/free-brands-svg-icons": "^6.7.2", "@fortawesome/free-solid-svg-icons": "^6.7.2", "@fortawesome/react-fontawesome": "^0.2.6", + "@rivet-gg/cloud": "https://pkg.pr.new/rivet-dev/cloud/@rivet-gg/cloud@bf2ebb2", "@giscus/react": "^3.1.0", "@headlessui/react": "^2.2.7", "@heroicons/react": "^2.2.0", diff --git a/site/public/llms-full.txt b/site/public/llms-full.txt index 317ea63fe0..11ab7f393b 100644 --- a/site/public/llms-full.txt +++ b/site/public/llms-full.txt @@ -4148,6 +4148,71 @@ const = registry.start(); ``` The Memory driver requires no configuration options. +## Code Autofill Example + +# Code Autofill Example + +This page demonstrates the code autofill feature with template variables. + +## Basic Example + +The following code snippet uses autofill to populate your project and namespace information. Click on any blue highlighted value to change your selection: + +```ts } +const client = createClient(}", + endpoint: "}" +}); + +console.log("Connected to project: }"); +console.log("Using namespace: }"); +``` + +## Using Default Values + +You can specify default values that will be shown before the user selects a project. Format: `}` + +```ts } +const client = createClient(}", + endpoint: "}" +}); + +const config = }", + namespace: "}", +}; +``` + +## Multiple Template Variables + +You can use various template variables in your code: + +```ts } +const config = }", + + // Project info + project: }", + name: "}", + }, + + // Namespace info + namespace: }", + name: "}", + token: "}", + }, + + // Connection + engineUrl: "}", +}; +``` + +## Regular Code Block (No Autofill) + +This code block doesn't use autofill: + +```ts +// This is a regular code block without autofill +const greeting = "Hello, World!"; +console.log(greeting); +``` ## Architecture # Architecture diff --git a/site/public/llms.txt b/site/public/llms.txt index 41197f1b2a..216778ac87 100644 --- a/site/public/llms.txt +++ b/site/public/llms.txt @@ -19,6 +19,7 @@ https://rivet.gg/blog/2025-09-12-performance-lifecycle-updates https://rivet.gg/blog/2025-09-14-weekly-updates https://rivet.gg/blog/2025-09-21-weekly-updates https://rivet.gg/blog/2025-09-24-vbare-simple-schema-evolution-with-maximum-performance +https://rivet.gg/blog/2025-09-28-weekly-updates https://rivet.gg/blog/2025-1-12-rivet-inspector https://rivet.gg/blog/2025-10-01-railway-selfhost https://rivet.gg/blog/godot-multiplayer-compared-to-unity @@ -40,6 +41,7 @@ https://rivet.gg/changelog/2025-09-12-performance-lifecycle-updates https://rivet.gg/changelog/2025-09-14-weekly-updates https://rivet.gg/changelog/2025-09-21-weekly-updates https://rivet.gg/changelog/2025-09-24-vbare-simple-schema-evolution-with-maximum-performance +https://rivet.gg/changelog/2025-09-28-weekly-updates https://rivet.gg/changelog/2025-1-12-rivet-inspector https://rivet.gg/changelog/2025-10-01-railway-selfhost https://rivet.gg/changelog/godot-multiplayer-compared-to-unity @@ -77,6 +79,7 @@ https://rivet.gg/docs/clients/rust https://rivet.gg/docs/drivers/build-your-own https://rivet.gg/docs/drivers/file-system https://rivet.gg/docs/drivers/memory +https://rivet.gg/docs/examples/autofill-example https://rivet.gg/docs/general/architecture https://rivet.gg/docs/general/cors https://rivet.gg/docs/general/docs-for-llms diff --git a/site/src/components/Comments.tsx b/site/src/components/Comments.tsx index b63c502aea..d1e012b915 100644 --- a/site/src/components/Comments.tsx +++ b/site/src/components/Comments.tsx @@ -12,7 +12,7 @@ export function Comments({ className }: CommentsProps) {
diff --git a/site/src/components/mdx.jsx b/site/src/components/mdx.jsx index b4d277da3e..6520e5bdec 100644 --- a/site/src/components/mdx.jsx +++ b/site/src/components/mdx.jsx @@ -1,43 +1,44 @@ -import { Heading } from "@/components/Heading"; -import NextImage from "next/image"; -import Link from "next/link"; -import { SchemaPreview as Schema } from "@/components/SchemaPreview"; +import { Heading } from '@/components/Heading'; +import NextImage from 'next/image'; +import Link from 'next/link'; +import { SchemaPreview as Schema } from '@/components/SchemaPreview'; export const a = Link; -export const Image = (props) => ; +export const Image = props => ; export const h2 = function H2(props) { - return ; + return ; }; export const h3 = function H3(props) { - return ; + return ; }; export const table = function Table(props) { - return ( -
- - - ); + return ( +
+
+ + ); }; export const SchemaPreview = ({ schema }) => { - return ( -
- -
- ); + return ( +
+ +
+ ); }; export const Lead = ({ children }) => { - return

{children}

; + return

{children}

; }; -export * from "@rivet-gg/components/mdx"; -export { Resource } from "@/components/Resources"; -export { Summary } from "@/components/Summary"; -export { Accordion, AccordionGroup } from "@/components/Accordion"; -export { Frame } from "@/components/Frame"; -export { Card, CardGroup } from "@/components/Card"; +export * from '@rivet-gg/components/mdx'; +export { Resource } from '@/components/Resources'; +export { Summary } from '@/components/Summary'; +export { Accordion, AccordionGroup } from '@/components/Accordion'; +export { Frame } from '@/components/Frame'; +export { Card, CardGroup } from '@/components/Card'; +export { pre, code } from '@/components/v2/Code'; diff --git a/site/src/components/v2/AutofillCodeBlock.tsx b/site/src/components/v2/AutofillCodeBlock.tsx new file mode 100644 index 0000000000..94be2f0846 --- /dev/null +++ b/site/src/components/v2/AutofillCodeBlock.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { type ReactElement, useEffect, useMemo, useRef } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { TemplateVariable } from "@/components/v2/TemplateVariable"; +import { useAutofillStore } from "@/stores/autofill-store"; + +interface AutofillCodeBlockProps { + code: string; + children: ReactElement; +} + +export function AutofillCodeBlock({ code, children }: AutofillCodeBlockProps) { + const codeRef = useRef(null); + const rootsRef = useRef([]); + const { getTemplateVariables } = useAutofillStore(); + + // Calculate processed code for copy functionality + const processedCode = useMemo(() => { + const variables = getTemplateVariables(); + let result = code; + + for (const [key, value] of Object.entries(variables)) { + // Match both simple and default value patterns + const simpleRegex = new RegExp(`\\{\\{${key}\\}\\}`, "g"); + const defaultRegex = new RegExp(`\\{\\{${key}:[^}]+\\}\\}`, "g"); + + result = result.replace(simpleRegex, value); + result = result.replace(defaultRegex, value); + } + + return result; + }, [code, getTemplateVariables]); + + // Replace template variables in the DOM after render (only once) + useEffect(() => { + if (!codeRef.current) return; + + const codeElement = codeRef.current.querySelector(".code"); + if (!codeElement) return; + + // Check if already processed + if (codeElement.hasAttribute("data-autofill-processed")) return; + + // Mark as processed to prevent re-processing + codeElement.setAttribute("data-autofill-processed", "true"); + + // Find all spans with data-template-var attribute (marked by Shiki transformer) + const templateVarSpans = codeElement.querySelectorAll( + "[data-template-var]", + ); + + templateVarSpans.forEach((span) => { + const variable = span.getAttribute("data-template-var"); + const defaultValue = span.getAttribute("data-template-default"); + + if (!variable) return; + + // Create a wrapper span for the React component + const wrapper = document.createElement("span"); + wrapper.className = "template-variable-wrapper inline-block"; + + // Mount React component + const root = createRoot(wrapper); + rootsRef.current.push(root); + root.render( + , + ); + + // Replace the original span with our wrapper + span.parentNode?.replaceChild(wrapper, span); + }); + + // Cleanup on unmount + return () => { + rootsRef.current.forEach((root) => { + try { + root.unmount(); + } catch { + // Ignore unmount errors + } + }); + rootsRef.current = []; + }; + }, []); + + return ( +
+ {children} +
+ ); +} diff --git a/site/src/components/v2/AutofillFooter.tsx b/site/src/components/v2/AutofillFooter.tsx new file mode 100644 index 0000000000..ac1589a677 --- /dev/null +++ b/site/src/components/v2/AutofillFooter.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { useAutofillStore } from "@/stores/autofill-store"; +import { useMemo } from "react"; + +export function AutofillFooter() { + const { projects, namespaces, selectedProjectId, selectedNamespaceId } = + useAutofillStore(); + + const selectedProject = useMemo( + () => projects.find((p) => p.id === selectedProjectId), + [projects, selectedProjectId], + ); + + const selectedNamespace = useMemo( + () => namespaces.find((n) => n.id === selectedNamespaceId), + [namespaces, selectedNamespaceId], + ); + + if (!selectedProject || !selectedNamespace) { + return null; + } + + return ( +
+ Using: + + {selectedProject.organizationSlug} / {selectedProject.slug} /{" "} + {selectedNamespace.slug} + +
+ ); +} diff --git a/site/src/components/v2/Code.tsx b/site/src/components/v2/Code.tsx index 680343a1cf..81a4d268c0 100644 --- a/site/src/components/v2/Code.tsx +++ b/site/src/components/v2/Code.tsx @@ -1,18 +1,20 @@ -import { CopyCodeTrigger } from "@/components/v2/CopyCodeButton"; import { Badge, Button, + cn, ScrollArea, Tabs, TabsContent, TabsList, TabsTrigger, WithTooltip, - cn, } from "@rivet-gg/components"; -import { Icon, faCopy, faFile } from "@rivet-gg/icons"; +import { faCopy, faFile, Icon } from "@rivet-gg/icons"; import escapeHTML from "escape-html"; -import { Children, type ReactElement, cloneElement } from "react"; +import { Children, cloneElement, type ReactElement } from "react"; +import { AutofillCodeBlock } from "@/components/v2/AutofillCodeBlock"; +import { AutofillFooter } from "@/components/v2/AutofillFooter"; +import { CopyCodeTrigger } from "@/components/v2/CopyCodeButton"; const languageNames = { csharp: "C#", @@ -93,6 +95,8 @@ interface PreProps { language: keyof typeof languageNames; isInGroup?: boolean; children?: ReactElement; + autofill?: boolean; + code?: string; } export const pre = ({ children, @@ -100,8 +104,10 @@ export const pre = ({ language, title, isInGroup, + autofill, + code, }: PreProps) => { - return ( + const codeBlock = (
@@ -112,7 +118,7 @@ export const pre = ({
-
+
{file ? ( <> @@ -123,6 +129,7 @@ export const pre = ({ {title || languageNames[language]} )} + {autofill && }
); + + // Wrap with autofill component if enabled + if (autofill && code) { + return {codeBlock}; + } + + return codeBlock; }; export { pre as Code }; diff --git a/site/src/components/v2/CopyCodeButton.tsx b/site/src/components/v2/CopyCodeButton.tsx index 2e978913ca..a4768ff373 100644 --- a/site/src/components/v2/CopyCodeButton.tsx +++ b/site/src/components/v2/CopyCodeButton.tsx @@ -3,10 +3,21 @@ import { Slot, toast } from "@rivet-gg/components"; export function CopyCodeTrigger({ children }) { const handleClick = (event) => { - const code = - event.currentTarget.parentNode.parentNode.querySelector( - ".code", - ).innerText; + // Check if this is an autofill code block with processed code + const autofillContainer = event.currentTarget.closest('[data-autofill-code]'); + let code: string; + + if (autofillContainer) { + // Use the autofilled code from the data attribute + code = autofillContainer.getAttribute('data-autofill-code') || ''; + } else { + // Use the original behavior - get code from innerText + code = + event.currentTarget.parentNode.parentNode.querySelector( + ".code", + ).innerText; + } + navigator.clipboard.writeText(code); toast.success("Copied to clipboard"); }; diff --git a/site/src/components/v2/TemplateVariable.tsx b/site/src/components/v2/TemplateVariable.tsx new file mode 100644 index 0000000000..58efa0e027 --- /dev/null +++ b/site/src/components/v2/TemplateVariable.tsx @@ -0,0 +1,207 @@ +"use client"; + +import { + Button, + Popover, + PopoverContent, + PopoverTrigger, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@rivet-gg/components"; +import { faRightToBracket, Icon } from "@rivet-gg/icons"; +import { useEffect, useMemo, useState } from "react"; +import { useAutofillStore } from "@/stores/autofill-store"; + +interface TemplateVariableProps { + variable: string; + defaultValue?: string; +} + +const CLOUD_LOGIN_URL = + process.env.NEXT_PUBLIC_CLOUD_URL || "https://cloud.rivet.gg"; + +export function TemplateVariable({ variable, defaultValue }: TemplateVariableProps) { + const [open, setOpen] = useState(false); + const { + user, + projects, + namespaces, + selectedProjectId, + selectedNamespaceId, + isLoading, + isLoadingNamespaces, + initialize, + selectProject, + selectNamespace, + getTemplateVariables, + } = useAutofillStore(); + + useEffect(() => { + initialize(); + }, [initialize]); + + const selectedProject = useMemo( + () => projects.find((p) => p.id === selectedProjectId), + [projects, selectedProjectId], + ); + + const selectedNamespace = useMemo( + () => namespaces.find((n) => n.id === selectedNamespaceId), + [namespaces, selectedNamespaceId], + ); + + const value = useMemo(() => { + const variables = getTemplateVariables(); + // Use actual value if available, fallback to default, then template placeholder + return variables[variable] || defaultValue || `{{${variable}}}`; + }, [variable, defaultValue, getTemplateVariables]); + + // Not authenticated + if (!isLoading && !user) { + return ( + + + + + +
+
+

Sign in required

+

+ Connect your Rivet account to autofill this + value +

+
+ +
+
+
+ ); + } + + // Loading + if (isLoading) { + return ( + {defaultValue || `{{${variable}}}`} + ); + } + + // No projects + if (projects.length === 0) { + return ( + + + + + +
+

No projects found

+

+ Create a project on Rivet Cloud to use autofill +

+
+
+
+ ); + } + + // Has value - show it with click to change + return ( + + + + + +
+
+

Configure autofill

+

+ Select your project and namespace +

+
+ + {/* Project Selector */} +
+ + +
+ + {/* Namespace Selector */} +
+ + +
+
+
+
+ ); +} diff --git a/site/src/content/docs/examples/autofill-example.mdx b/site/src/content/docs/examples/autofill-example.mdx new file mode 100644 index 0000000000..bd952ebd0a --- /dev/null +++ b/site/src/content/docs/examples/autofill-example.mdx @@ -0,0 +1,75 @@ +# Code Autofill Example + +This page demonstrates the code autofill feature with template variables. + +## Basic Example + +The following code snippet uses autofill to populate your project and namespace information. Click on any blue highlighted value to change your selection: + +```ts {{"autofill": true}} +import { createClient } from "rivetkit"; + +const client = createClient({ + token: "{{namespace.token}}", + endpoint: "{{engine.url}}" +}); + +console.log("Connected to project: {{project.slug}}"); +console.log("Using namespace: {{namespace.slug}}"); +``` + +## Using Default Values + +You can specify default values that will be shown before the user selects a project. Format: `{{variable.name:"default-value"}}` + +```ts {{"autofill": true}} +import { createClient } from "rivetkit"; + +const client = createClient({ + // These will show default values until user selects a project + token: "{{namespace.token:'YOUR_TOKEN_HERE'}}", + endpoint: "{{engine.url:'https://engine.rivet.gg'}}" +}); + +const config = { + project: "{{project.slug:'my-project'}}", + namespace: "{{namespace.slug:'production'}}", +}; +``` + +## Multiple Template Variables + +You can use various template variables in your code: + +```ts {{"autofill": true}} +const config = { + // Organization info + organization: "{{organization.slug}}", + + // Project info + project: { + slug: "{{project.slug}}", + name: "{{project.name}}", + }, + + // Namespace info + namespace: { + slug: "{{namespace.slug}}", + name: "{{namespace.name}}", + token: "{{namespace.token}}", + }, + + // Connection + engineUrl: "{{engine.url}}", +}; +``` + +## Regular Code Block (No Autofill) + +This code block doesn't use autofill: + +```ts +// This is a regular code block without autofill +const greeting = "Hello, World!"; +console.log(greeting); +``` diff --git a/site/src/lib/cloud-api/client.ts b/site/src/lib/cloud-api/client.ts new file mode 100644 index 0000000000..60056d4d8e --- /dev/null +++ b/site/src/lib/cloud-api/client.ts @@ -0,0 +1,64 @@ +// Cloud API client for fetching autofill data +import type { + CloudBootstrapResponse, + CloudNamespace, + CloudNamespaceToken, +} from "./types"; + +const CLOUD_API_URL = + process.env.NEXT_PUBLIC_CLOUD_API_URL || "https://cloud.rivet.gg"; + +class CloudApiClient { + private baseUrl: string; + + constructor(baseUrl: string = CLOUD_API_URL) { + this.baseUrl = baseUrl; + } + + async bootstrap(): Promise { + const response = await fetch(`${this.baseUrl}/cloud/bootstrap`, { + credentials: "include", // Include cookies for auth + }); + + if (!response.ok) { + throw new Error(`Failed to fetch bootstrap data: ${response.statusText}`); + } + + return response.json(); + } + + async getNamespaces(projectId: string): Promise { + const response = await fetch( + `${this.baseUrl}/cloud/projects/${projectId}/namespaces`, + { + credentials: "include", + }, + ); + + if (!response.ok) { + throw new Error(`Failed to fetch namespaces: ${response.statusText}`); + } + + return response.json(); + } + + async getNamespaceToken( + projectId: string, + namespaceId: string, + ): Promise { + const response = await fetch( + `${this.baseUrl}/cloud/projects/${projectId}/namespaces/${namespaceId}/token`, + { + credentials: "include", + }, + ); + + if (!response.ok) { + throw new Error(`Failed to fetch namespace token: ${response.statusText}`); + } + + return response.json(); + } +} + +export const cloudApi = new CloudApiClient(); diff --git a/site/src/lib/cloud-api/index.ts b/site/src/lib/cloud-api/index.ts new file mode 100644 index 0000000000..d2ec2302c2 --- /dev/null +++ b/site/src/lib/cloud-api/index.ts @@ -0,0 +1,2 @@ +export * from "./client"; +export * from "./types"; diff --git a/site/src/lib/cloud-api/types.ts b/site/src/lib/cloud-api/types.ts new file mode 100644 index 0000000000..725eda8347 --- /dev/null +++ b/site/src/lib/cloud-api/types.ts @@ -0,0 +1,31 @@ +// Cloud API types for autofill functionality + +export interface CloudBootstrapResponse { + user: CloudUser | null; + projects: CloudProject[]; +} + +export interface CloudUser { + id: string; + username: string; +} + +export interface CloudProject { + id: string; + slug: string; + name: string; + organizationId: string; + organizationSlug: string; +} + +export interface CloudNamespace { + id: string; + slug: string; + name: string; + projectId: string; +} + +export interface CloudNamespaceToken { + token: string; + engineUrl: string; +} diff --git a/site/src/mdx/rehype.ts b/site/src/mdx/rehype.ts index a8f1b77cbb..62397b63e1 100644 --- a/site/src/mdx/rehype.ts +++ b/site/src/mdx/rehype.ts @@ -7,6 +7,7 @@ import rehypeMdxTitle from "rehype-mdx-title"; import * as shiki from "shiki"; import { visit } from "unist-util-visit"; import theme from "../lib/textmate-code-theme"; +import { transformerTemplateVariables } from "./transformers"; function rehypeParseCodeBlocks() { return (tree) => { @@ -30,6 +31,12 @@ function rehypeParseCodeBlocks() { annotations = { title: annotations.trim() }; } + // Autofill is handled client-side in AutofillCodeBlock.tsx + // Just pass through the autofill flag + if (annotations.autofill) { + parentNode.properties.autofill = true; + } + for (const key in annotations) { parentNode.properties[key] = annotations[key]; } @@ -69,7 +76,7 @@ function rehypeShiki() { ], }); - visit(tree, "element", (node) => { + visit(tree, "element", (node, _index, parentNode) => { if ( node.tagName === "pre" && node.children[0]?.tagName === "code" @@ -80,10 +87,20 @@ function rehypeShiki() { node.properties.code = textNode.value; if (node.properties.language) { + const transformers = [transformerNotationFocus()]; + + // Add template variable transformer for autofill blocks + if ( + node.properties?.autofill || + parentNode.properties?.autofill + ) { + transformers.push(transformerTemplateVariables()); + } + textNode.value = highlighter.codeToHtml(textNode.value, { lang: node.properties.language, theme: theme.name, - transformers: [transformerNotationFocus()], + transformers, }); } } diff --git a/site/src/mdx/transformers.ts b/site/src/mdx/transformers.ts new file mode 100644 index 0000000000..fd7ad8c42c --- /dev/null +++ b/site/src/mdx/transformers.ts @@ -0,0 +1,134 @@ +import type { ShikiTransformer, ThemedToken } from "shiki"; + +interface TemplateVariable { + variable: string; + defaultValue?: string; +} + +/** + * Parses a template variable string and returns info about it + * Supports: {{variable.name}} or {{variable.name:"default-value"}} + */ +function parseTemplateVariable(fullMatch: string): TemplateVariable | null { + // Remove {{ and }} + const content = fullMatch.slice(2, -2).trim(); + const colonIndex = content.indexOf(":"); + + if (colonIndex > -1) { + const variable = content.substring(0, colonIndex).trim(); + const defaultPart = content.substring(colonIndex + 1).trim(); + // Remove quotes from default value + const defaultValue = defaultPart.replace(/^["']|["']$/g, ""); + return { variable, defaultValue }; + } + + return { variable: content }; +} + +/** + * Shiki transformer that detects template variables in code and splits them + * into separate tokens with data attributes so they can be made interactive. + * + * Template variables are in the format: + * - {{variable.name}} - basic variable + * - {{variable.name:"default-value"}} - variable with default value + */ +export function transformerTemplateVariables(): ShikiTransformer { + console.log("Using template variable transformer"); + return { + name: "template-variables", + tokens(tokens) { + console.log("Processing tokens with template variable transformer"); + const newLines: ThemedToken[][] = []; + + for (const line of tokens) { + const newTokens: ThemedToken[] = []; + + for (const token of line) { + const splitTokens = splitTokenForTemplateVariables(token); + newTokens.push(...splitTokens); + } + + newLines.push(newTokens); + } + + return newLines; + }, + }; +} + +/** + * Splits a single token that may contain template variables into multiple tokens + */ +function splitTokenForTemplateVariables(token: ThemedToken): ThemedToken[] { + const templateVarRegex = /\{\{[^}]+\}\}/g; + const matches = Array.from(token.content.matchAll(templateVarRegex)); + + if (matches.length === 0) { + // No template variables, return original token + return [token]; + } + + console.log( + "Found template variable matches:", + matches.map((m) => m[0]), + ); + + const resultTokens: ThemedToken[] = []; + let lastIndex = 0; + + for (const match of matches) { + if (match.index === undefined) continue; + const matchIndex = match.index; + + // Add text before the match as a regular token + if (matchIndex > lastIndex) { + resultTokens.push({ + ...token, + content: token.content.substring(lastIndex, matchIndex), + offset: token.offset + lastIndex, + }); + } + + // Parse the template variable + const parsed = parseTemplateVariable(match[0]); + + if (parsed) { + // Create a new token for the template variable with special attributes + resultTokens.push({ + ...token, + content: parsed.defaultValue || match[0], + offset: token.offset + matchIndex, + htmlAttrs: { + ...(token.htmlAttrs || {}), + "data-template-var": parsed.variable, + ...(parsed.defaultValue + ? { + "data-template-default": parsed.defaultValue, + } + : {}), + }, + }); + } else { + // If parsing failed, keep original content + resultTokens.push({ + ...token, + content: match[0], + offset: token.offset + matchIndex, + }); + } + + lastIndex = matchIndex + match[0].length; + } + + // Add remaining text after last match + if (lastIndex < token.content.length) { + resultTokens.push({ + ...token, + content: token.content.substring(lastIndex), + offset: token.offset + lastIndex, + }); + } + + return resultTokens; +} diff --git a/site/src/stores/autofill-store.ts b/site/src/stores/autofill-store.ts new file mode 100644 index 0000000000..dad39e9813 --- /dev/null +++ b/site/src/stores/autofill-store.ts @@ -0,0 +1,151 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; +import type { + CloudBootstrapResponse, + CloudNamespace, + CloudNamespaceToken, + CloudProject, +} from "@/lib/cloud-api/types"; +import { cloudApi } from "@/lib/cloud-api/client"; + +interface AutofillState { + // Data + user: CloudBootstrapResponse["user"] | null; + projects: CloudProject[]; + namespaces: CloudNamespace[]; + selectedProjectId: string | null; + selectedNamespaceId: string | null; + tokenData: CloudNamespaceToken | null; + + // Loading states + isLoading: boolean; + isLoadingNamespaces: boolean; + isLoadingToken: boolean; + + // Actions + initialize: () => Promise; + selectProject: (projectId: string) => Promise; + selectNamespace: (namespaceId: string) => Promise; + getTemplateVariables: () => Record; +} + +export const useAutofillStore = create()( + persist( + (set, get) => ({ + // Initial state + user: null, + projects: [], + namespaces: [], + selectedProjectId: null, + selectedNamespaceId: null, + tokenData: null, + isLoading: false, + isLoadingNamespaces: false, + isLoadingToken: false, + + // Initialize: fetch bootstrap data + initialize: async () => { + set({ isLoading: true }); + try { + const data = await cloudApi.bootstrap(); + set({ + user: data.user, + projects: data.projects, + isLoading: false, + }); + + // Auto-select first project if available and no selection exists + const state = get(); + if ( + data.projects.length > 0 && + !state.selectedProjectId + ) { + await get().selectProject(data.projects[0].id); + } + } catch (error) { + console.error("Failed to initialize autofill:", error); + set({ isLoading: false }); + } + }, + + // Select a project and load its namespaces + selectProject: async (projectId: string) => { + set({ + selectedProjectId: projectId, + isLoadingNamespaces: true, + selectedNamespaceId: null, + tokenData: null, + }); + + try { + const namespaces = await cloudApi.getNamespaces(projectId); + set({ + namespaces, + isLoadingNamespaces: false, + }); + + // Auto-select first namespace + if (namespaces.length > 0) { + await get().selectNamespace(namespaces[0].id); + } + } catch (error) { + console.error("Failed to load namespaces:", error); + set({ isLoadingNamespaces: false }); + } + }, + + // Select a namespace and fetch its token + selectNamespace: async (namespaceId: string) => { + const { selectedProjectId } = get(); + if (!selectedProjectId) return; + + set({ + selectedNamespaceId: namespaceId, + isLoadingToken: true, + }); + + try { + const tokenData = await cloudApi.getNamespaceToken( + selectedProjectId, + namespaceId, + ); + set({ + tokenData, + isLoadingToken: false, + }); + } catch (error) { + console.error("Failed to load token:", error); + set({ isLoadingToken: false }); + } + }, + + // Get all template variables for replacement + getTemplateVariables: () => { + const state = get(); + const project = state.projects.find( + (p) => p.id === state.selectedProjectId, + ); + const namespace = state.namespaces.find( + (n) => n.id === state.selectedNamespaceId, + ); + + return { + "project.slug": project?.slug || "", + "project.name": project?.name || "", + "organization.slug": project?.organizationSlug || "", + "namespace.slug": namespace?.slug || "", + "namespace.name": namespace?.name || "", + "namespace.token": state.tokenData?.token || "", + "engine.url": state.tokenData?.engineUrl || "", + }; + }, + }), + { + name: "rivet-autofill-storage", + partialize: (state) => ({ + selectedProjectId: state.selectedProjectId, + selectedNamespaceId: state.selectedNamespaceId, + }), + }, + ), +);