diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2bc5e0661f8..3f47f43c86b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -310,7 +310,7 @@ jobs: flyctl launch --no-deploy --copy-config --name "$APP_NAME" --image-label latest -o personal popd fi - flyctl deploy . --config ./apps/wing-console/console/app/preview/fly.toml --app "$APP_NAME" --image-label latest --vm-memory 512 --strategy immediate + flyctl deploy . --config ./apps/wing-console/console/app/preview/fly.toml --app "$APP_NAME" --image-label latest --vm-memory 1024 --strategy immediate flyctl scale count 1 --yes --app "$APP_NAME" echo "deploytime=$(TZ=UTC date +'%Y-%m-%d %H:%M')" >> $GITHUB_OUTPUT diff --git a/.github/workflows/periodic-azure-clean.yml b/.github/workflows/periodic-azure-clean.yml new file mode 100644 index 00000000000..3ac3e90dbaf --- /dev/null +++ b/.github/workflows/periodic-azure-clean.yml @@ -0,0 +1,37 @@ +name: Periodic Azure cleanup + +on: + schedule: + - cron: "0 0 * * 1" # Every Saturday at midnight UTC, I assume the main build won't be triggered right before this one + workflow_dispatch: {} + +env: + MANUAL: ${{ github.event_name == 'workflow_dispatch' }} + +jobs: + azure-cleanup: + runs-on: ubuntu-latest + steps: + - name: test if is maintainer + uses: tspascoal/get-user-teams-membership@v3 + id: testUserGroup + if: ${{ env.MANUAL == 'true' }} + with: + username: ${{ github.actor }} + team: "maintainers" + GITHUB_TOKEN: ${{ secrets.GH_GROUPS_READ_TOKEN }} + - name: cancel run if not allowed + if: ${{ env.MANUAL == 'true' && steps.testUserGroup.outputs.isTeamMember == 'false' }} + run: | + echo "User ${{github.actor}} is not allowed to dispatch this action." + exit 1 + + - name: Configure azure credentials + uses: azure/login@v2 + with: + creds: ${{ secrets.AZURE_CREDENTIALS }} + - name: Remove all resources + run: | + for rg in $(az group list --query "[].name" -o tsv); do + az group delete --name $rg --yes --no-wait + done diff --git a/Cargo.lock b/Cargo.lock index fe1a15e1e2e..72b67505c2b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1657,7 +1657,7 @@ dependencies = [ [[package]] name = "wingcli" -version = "0.59.24" +version = "0.74.53" dependencies = [ "anstyle", "camino", diff --git a/apps/wing-console/console/app/demo/main.w b/apps/wing-console/console/app/demo/main.w index 035874567ca..b0209cc3ddc 100644 --- a/apps/wing-console/console/app/demo/main.w +++ b/apps/wing-console/console/app/demo/main.w @@ -43,6 +43,7 @@ class myBucket { } let myB = new myBucket() as "MyUIComponentBucket"; + let putfucn = new cloud.Function(inflight () => { myB.put("test", "Test"); }) as "PutFileInCustomBucket"; @@ -106,6 +107,7 @@ let table = new ex.Table( let rateSchedule = new cloud.Schedule(cloud.ScheduleProps{ rate: 5m }) as "Rate Schedule"; +nodeof(rateSchedule).expanded = true; rateSchedule.onTick(inflight () => { log("Rate schedule ticked!"); @@ -165,10 +167,12 @@ test "Add fixtures" { class WidgetService { data: cloud.Bucket; counter: cloud.Counter; + bucket: myBucket; new() { this.data = new cloud.Bucket(); this.counter = new cloud.Counter(); + this.bucket = new myBucket() as "MyInternalBucket"; // a field displays a labeled value, with optional refreshing new ui.Field( diff --git a/apps/wing-console/console/app/test/describe.ts b/apps/wing-console/console/app/test/describe.ts index 5d1e7b70cea..a9e258b04ca 100644 --- a/apps/wing-console/console/app/test/describe.ts +++ b/apps/wing-console/console/app/test/describe.ts @@ -26,13 +26,19 @@ import { createConsoleApp } from "../dist/index.js"; * `describe(wingfile, callback)`. Any tests added in * this callback will belong to the group. */ -export const describe = (wingfile: string, callback: () => void) => { +export const describe = ( + wingfile: string, + callback: () => void, + options?: { + requireSignIn?: boolean; + }, +) => { let server: { port: number; close: () => void } | undefined; test.beforeEach(async ({ page }) => { server = await createConsoleApp({ wingfile: path.resolve(__dirname, wingfile), - requireSignIn: false, + requireSignIn: options?.requireSignIn ?? false, }); await page.goto(`http://localhost:${server.port}/`); diff --git a/apps/wing-console/console/app/test/login/login.test.ts b/apps/wing-console/console/app/test/login/login.test.ts new file mode 100644 index 00000000000..e0ccde0af0e --- /dev/null +++ b/apps/wing-console/console/app/test/login/login.test.ts @@ -0,0 +1,40 @@ +import { expect, test } from "@playwright/test"; + +import { describe } from "../describe.js"; + +describe( + `${__dirname}/main.w`, + () => { + test("Sign in modal is visible when required", async ({ page }) => { + const signinModal = page.getByTestId("signin-modal"); + await expect(signinModal).toBeVisible(); + }); + }, + { requireSignIn: true }, +); + +describe( + `${__dirname}/main.w`, + () => { + test("GitHub button is clickable", async ({ page }) => { + const githubLoginButton = page.getByTestId("signin-github-button"); + await expect(githubLoginButton).toBeVisible(); + await expect(githubLoginButton).toBeEnabled(); + await githubLoginButton.click(); + }); + }, + { requireSignIn: true }, +); + +describe( + `${__dirname}/main.w`, + () => { + test("Google button is clickable", async ({ page }) => { + const googleSignInButton = page.getByTestId("signin-google-button"); + await expect(googleSignInButton).toBeVisible(); + await expect(googleSignInButton).toBeEnabled(); + await googleSignInButton.click(); + }); + }, + { requireSignIn: true }, +); diff --git a/apps/wing-console/console/app/test/login/main.w b/apps/wing-console/console/app/test/login/main.w new file mode 100644 index 00000000000..6758e443e08 --- /dev/null +++ b/apps/wing-console/console/app/test/login/main.w @@ -0,0 +1,2 @@ +bring cloud; + diff --git a/apps/wing-console/console/design-system/src/headless/tree-item.tsx b/apps/wing-console/console/design-system/src/headless/tree-item.tsx index e89df902931..e30b4891ead 100644 --- a/apps/wing-console/console/design-system/src/headless/tree-item.tsx +++ b/apps/wing-console/console/design-system/src/headless/tree-item.tsx @@ -121,6 +121,12 @@ export const TreeItem = ({ }); const canBeExpanded = !!children; + useEffect(() => { + if (selected) { + ref.current?.scrollIntoView(); + } + }, [selected, ref]); + return (
  • >; } export interface ListboxProps { label?: string; - icon?: React.ForwardRefExoticComponent>; + renderLabel?: (selected?: string[]) => JSX.Element; + icon?: ForwardRefExoticComponent>; className?: string; items: ListboxItem[]; defaultSelection?: string[]; @@ -23,10 +30,14 @@ export interface ListboxProps { selected?: string[]; onChange?: (selected: string[]) => void; disabled?: boolean; + defaultLabel?: string; + showSearch?: boolean; + notFoundLabel?: string; } export const Listbox = ({ label, + renderLabel, icon, className, items, @@ -35,6 +46,9 @@ export const Listbox = ({ selected, onChange, disabled = false, + defaultLabel = "Default", + showSearch = false, + notFoundLabel = "No results found", }: ListboxProps) => { const { theme } = useTheme(); @@ -57,6 +71,17 @@ export const Listbox = ({ return () => root.remove(); }, [root]); + const [search, setSearch] = useState(""); + + const filteredItems = useMemo(() => { + if (search === "") { + return items; + } + return items.filter((item) => + item.label.toLocaleLowerCase().includes(search.toLocaleLowerCase()), + ); + }, [search, items]); + return ( - {label && {label}} + {renderLabel && ( + {renderLabel(selected)} + )} + {!renderLabel && label && ( + {label} + )} - {defaultSelection && ( - <> - {/* TODO: Fix a11y */} - {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions */} -
  • onChange?.(defaultSelection)} - > - - Default - -
  • + {showSearch && ( +
    +
    +
    +
    + setSearch(event.target.value)} + /> +
    +
    + )} -
    - + +
    + + )} + + {filteredItems.length === 0 && search !== "" && ( +
    + {notFoundLabel}
    - - )} + )} - {items.map((item, index) => ( - - classNames( - "relative cursor-default select-none py-2 pl-10 pr-4", - active && theme.bgInputHover, - ) - } - value={item.value} - > - ( + + classNames( + "relative cursor-default select-none py-2 pl-10 pr-4", + active && theme.bgInputHover, + ) + } + value={item.value} > - {item.label} - - {selected?.includes(item.value) ? ( - - - ))} + {selected?.includes(item.value) && ( + + + )} + + ))} +
    , diff --git a/apps/wing-console/console/design-system/src/notification.tsx b/apps/wing-console/console/design-system/src/notification.tsx index 30ff987cb9c..0d0676ab42b 100644 --- a/apps/wing-console/console/design-system/src/notification.tsx +++ b/apps/wing-console/console/design-system/src/notification.tsx @@ -70,7 +70,7 @@ function NotificationsContainer() { {/* Global notification live region, render this permanently at the end of the document */}
    {/* Notification panel, dynamically insert this into the live region when it needs to be displayed */} diff --git a/apps/wing-console/console/design-system/src/resource-icon.tsx b/apps/wing-console/console/design-system/src/resource-icon.tsx index 84f4bf426cf..62a6aa65b0e 100644 --- a/apps/wing-console/console/design-system/src/resource-icon.tsx +++ b/apps/wing-console/console/design-system/src/resource-icon.tsx @@ -16,6 +16,7 @@ export interface ResourceIconProps extends IconProps { forceDarken?: boolean; solid?: boolean; color?: Colors | string; + icon?: string; } export interface IconComponent extends FunctionComponent {} @@ -32,6 +33,7 @@ export const ResourceIcon = ({ const Component = getResourceIconComponent(resourceType, { solid, resourceId: resourcePath, + icon: props.icon, }); const colors = getResourceIconColors({ resourceType, diff --git a/apps/wing-console/console/design-system/src/row-input.tsx b/apps/wing-console/console/design-system/src/row-input.tsx index ae2d8f22e82..c121b293c9d 100644 --- a/apps/wing-console/console/design-system/src/row-input.tsx +++ b/apps/wing-console/console/design-system/src/row-input.tsx @@ -68,7 +68,7 @@ export const RowInput = memo( type === "checkbox" && [ theme.focusInput, theme.bg4, - "w-4 h-4 dark:ring-offset-gray-800", + "w-4 h-4 dark:ring-offset-slate-800", ], type === "date" && !value && diff --git a/apps/wing-console/console/design-system/src/utils/icon-utils.ts b/apps/wing-console/console/design-system/src/utils/icon-utils.ts index c5d5ab472f4..121a7b49a54 100644 --- a/apps/wing-console/console/design-system/src/utils/icon-utils.ts +++ b/apps/wing-console/console/design-system/src/utils/icon-utils.ts @@ -1,32 +1,6 @@ -import { - ArchiveBoxIcon, - BeakerIcon, - BoltIcon, - CalculatorIcon, - ClockIcon, - CloudIcon, - CubeIcon, - GlobeAltIcon, - MegaphoneIcon, - QueueListIcon, - TableCellsIcon, - KeyIcon, -} from "@heroicons/react/24/outline"; -import { - ArchiveBoxIcon as SolidArchiveBoxIcon, - BeakerIcon as SolidBeakerIcon, - BoltIcon as SolidBoltIcon, - CalculatorIcon as SolidCalculatorIcon, - ClockIcon as SolidClockIcon, - CloudIcon as SolidCloudIcon, - GlobeAltIcon as SolidGlobeAltIcon, - MegaphoneIcon as SolidMegaphoneIcon, - QueueListIcon as SolidQueueListIcon, - TableCellsIcon as SolidTableCellsIcon, - KeyIcon as SolidKeyIcon, -} from "@heroicons/react/24/solid"; +import * as OutlineHeroIcons from "@heroicons/react/24/outline"; +import * as SolidHeroIcons from "@heroicons/react/24/solid"; -import { ReactIcon } from "../icons/react-icon.js"; import { RedisIcon } from "../icons/redis-icon.js"; import type { Colors } from "./colors.js"; @@ -38,52 +12,78 @@ const matchTest = (path: string) => { return isTest.test(path); }; +const getHeroIconName = (heroiconId: string): string => { + const parts = heroiconId.split("-"); + const resourceName = parts + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(""); + return `${resourceName}Icon`; +}; + export const getResourceIconComponent = ( resourceType: string | undefined, - { solid = true, resourceId }: { solid?: boolean; resourceId?: string } = {}, + { + solid = true, + resourceId, + icon, + }: { solid?: boolean; resourceId?: string; icon?: string } = {}, ) => { + const iconSet = solid ? SolidHeroIcons : OutlineHeroIcons; + if (resourceId && matchTest(resourceId)) { - return solid ? SolidBeakerIcon : BeakerIcon; + return iconSet.BeakerIcon; } + if (icon) { + // icon is a heroicon id string, e.g. "academic-cap" so we need to convert it to the actual component + // @ts-ignore + let iconComponent = iconSet[getHeroIconName(icon)]; + if (iconComponent) { + return iconComponent; + } + } + switch (resourceType) { case "@winglang/sdk.cloud.Bucket": { - return solid ? SolidArchiveBoxIcon : ArchiveBoxIcon; + return iconSet.ArchiveBoxIcon; } case "@winglang/sdk.cloud.Function": { - return solid ? SolidBoltIcon : BoltIcon; + return iconSet.BoltIcon; } case "@winglang/sdk.cloud.Queue": { - return solid ? SolidQueueListIcon : QueueListIcon; + return iconSet.QueueListIcon; } case "@winglang/sdk.cloud.Website": { - return solid ? SolidGlobeAltIcon : GlobeAltIcon; + return iconSet.GlobeAltIcon; } case "@winglang/sdk.cloud.Counter": { - return solid ? SolidCalculatorIcon : CalculatorIcon; + return iconSet.CalculatorIcon; } case "@winglang/sdk.cloud.Topic": { - return solid ? SolidMegaphoneIcon : MegaphoneIcon; + return iconSet.MegaphoneIcon; } case "@winglang/sdk.cloud.Api": { - return solid ? SolidCloudIcon : CloudIcon; + return iconSet.CloudIcon; } case "@winglang/sdk.ex.Table": { - return solid ? SolidTableCellsIcon : TableCellsIcon; + return iconSet.TableCellsIcon; } case "@winglang/sdk.cloud.Schedule": { - return solid ? SolidClockIcon : ClockIcon; + return iconSet.ClockIcon; } case "@winglang/sdk.ex.Redis": { return RedisIcon; } case "@winglang/sdk.std.Test": { - return solid ? SolidBeakerIcon : BeakerIcon; + return iconSet.BeakerIcon; } case "@winglang/sdk.cloud.Secret": { - return solid ? SolidKeyIcon : KeyIcon; + return iconSet.KeyIcon; + } + case "@winglang/sdk.cloud.Endpoint": { + return iconSet.LinkIcon; } default: { - return CubeIcon; + return iconSet.CubeIcon; } } }; @@ -159,6 +159,21 @@ export const getResourceIconColors = (options: { forceDarken?: boolean; color?: Colors | string; }) => { + let color: Colors = + options.color && Object.keys(colors).includes(options.color) + ? (options.color as Colors) + : "slate"; + + let defaultColor = [ + colors[color].default, + options.darkenOnGroupHover && colors[color].groupHover, + options.forceDarken && colors[color].forceDarken, + ]; + + if (options.color) { + return defaultColor; + } + switch (options.resourceType) { case "@winglang/sdk.cloud.Bucket": { return [ @@ -231,15 +246,7 @@ export const getResourceIconColors = (options: { ]; } default: { - let color: Colors = - options.color && Object.keys(colors).includes(options.color) - ? (options.color as Colors) - : "slate"; - return [ - colors[color].default, - options.darkenOnGroupHover && colors[color].groupHover, - options.forceDarken && colors[color].forceDarken, - ]; + return defaultColor; } } }; diff --git a/apps/wing-console/console/server/src/expressServer.ts b/apps/wing-console/console/server/src/expressServer.ts index 376035a4050..d303034cbea 100644 --- a/apps/wing-console/console/server/src/expressServer.ts +++ b/apps/wing-console/console/server/src/expressServer.ts @@ -25,6 +25,7 @@ import type { LogInterface } from "./utils/LogInterface.js"; export interface CreateExpressServerOptions { simulatorInstance(): Promise; + restartSimulator(): Promise; testSimulatorInstance(): Promise; consoleLogger: ConsoleLogger; errorMessage(): string | undefined; @@ -55,6 +56,7 @@ export interface CreateExpressServerOptions { export const createExpressServer = async ({ simulatorInstance, + restartSimulator, testSimulatorInstance, consoleLogger, errorMessage, @@ -87,6 +89,9 @@ export const createExpressServer = async ({ async simulator() { return await simulatorInstance(); }, + async restartSimulator() { + return await restartSimulator(); + }, async testSimulator() { return await testSimulatorInstance(); }, diff --git a/apps/wing-console/console/server/src/index.ts b/apps/wing-console/console/server/src/index.ts index d18ec761f96..4c48c4bebb9 100644 --- a/apps/wing-console/console/server/src/index.ts +++ b/apps/wing-console/console/server/src/index.ts @@ -204,6 +204,9 @@ export const createConsoleServer = async ({ }); simulator.on("started", () => { appState = "success"; + + // Clear tests when simulator is restarted + testsStateManager().setTests([]); invalidateQuery(undefined); isStarting = false; }); @@ -282,6 +285,9 @@ export const createConsoleServer = async ({ simulatorInstance() { return simulator.instance(); }, + restartSimulator() { + return simulator.reload(); + }, errorMessage() { return lastErrorMessage; }, diff --git a/apps/wing-console/console/server/src/router/app.ts b/apps/wing-console/console/server/src/router/app.ts index a1bf01fadb4..0b1da9c121f 100644 --- a/apps/wing-console/console/server/src/router/app.ts +++ b/apps/wing-console/console/server/src/router/app.ts @@ -14,7 +14,7 @@ import type { import { buildConstructTreeNodeMap } from "../utils/constructTreeNodeMap.js"; import type { FileLink } from "../utils/createRouter.js"; import { createProcedure, createRouter } from "../utils/createRouter.js"; -import type { IFunctionClient, Simulator } from "../wingsdk.js"; +import type { Simulator } from "../wingsdk.js"; const isTest = /(\/test$|\/test:([^/\\])+$)/; const isTestHandler = /(\/test$|\/test:.*\/Handler$)/; @@ -53,6 +53,30 @@ export const createAppRouter = () => { config: ctx.layoutConfig, }; }), + "app.reset": createProcedure.mutation(async ({ ctx }) => { + ctx.logger.verbose("Resetting simulator...", "console", { + messageType: "info", + }); + await ctx.restartSimulator(); + ctx.logger.verbose("Simulator reset.", "console", { + messageType: "info", + }); + }), + "app.logsFilters": createProcedure.query(async ({ ctx }) => { + const simulator = await ctx.simulator(); + + const resources = simulator.listResources().map((resourceId) => { + const config = simulator.tryGetResourceConfig(resourceId); + return { + id: resourceId, + type: config?.type, + }; + }); + + return { + resources, + }; + }), "app.logs": createProcedure .input( z.object({ @@ -65,20 +89,60 @@ export const createAppRouter = () => { }), timestamp: z.number(), text: z.string(), + resourceIds: z.array(z.string()), + resourceTypes: z.array(z.string()), }), }), ) .query(async ({ ctx, input }) => { - return ctx.logger.messages.filter( - (entry) => - input.filters.level[entry.level] && - entry.timestamp && - entry.timestamp >= input.filters.timestamp && - (!input.filters.text || - `${entry.message}${entry.ctx?.sourcePath}` - .toLowerCase() - .includes(input.filters.text.toLowerCase())), - ); + const filters = input.filters; + const lowerCaseText = filters.text?.toLowerCase(); + let noVerboseLogsCount = 0; + + const filteredLogs = ctx.logger.messages.filter((entry) => { + // Filter by timestamp + if (entry.timestamp && entry.timestamp < filters.timestamp) { + return false; + } + if (entry.level !== "verbose") { + noVerboseLogsCount++; + } + // Filter by level + if (!filters.level[entry.level]) { + return false; + } + // Filter by resourceIds + if ( + filters.resourceIds.length > 0 && + (!entry.ctx?.sourcePath || + !filters.resourceIds.includes(entry.ctx.sourcePath)) + ) { + return false; + } + // Filter by resourceTypes + if ( + filters.resourceTypes.length > 0 && + (!entry.ctx?.sourceType || + !filters.resourceTypes.includes(entry.ctx.sourceType)) + ) { + return false; + } + // Filter by text + if ( + lowerCaseText && + !`${entry.message}${entry.ctx?.sourcePath}` + .toLowerCase() + .includes(lowerCaseText) + ) { + return false; + } + return true; + }); + + return { + logs: filteredLogs, + hiddenLogs: noVerboseLogsCount - filteredLogs.length, + }; }), "app.error": createProcedure.query(({ ctx }) => { return ctx.errorMessage(); @@ -283,18 +347,21 @@ export const createAppRouter = () => { .input( z.object({ edgeId: z.string(), - showTests: z.boolean().optional(), }), ) .query(async ({ ctx, input }) => { - const { edgeId, showTests } = input; + const { edgeId } = input; const simulator = await ctx.simulator(); const { tree } = simulator.tree().rawData(); const nodeMap = buildConstructTreeNodeMap(shakeTree(tree)); - const sourcePath = edgeId.split("->")[0]?.trim(); - const targetPath = edgeId.split("->")[1]?.trim(); + let [, sourcePath, _sourceInflight, , targetPath, targetInflight] = + edgeId.match(/^(.+?)#(.*?)#(.*?)#(.+?)#(.*?)#(.*?)$/i) ?? []; + + targetPath = targetPath?.startsWith("#") + ? targetPath.slice(1) + : targetPath; const sourceNode = nodeMap.get(sourcePath); if (!sourceNode) { @@ -312,51 +379,26 @@ export const createAppRouter = () => { }); } - const connections = simulator.connections(); - - const inflights = sourceNode.display?.hidden - ? [] - : connections - ?.filter(({ source, target, name }) => { - if (!isFoundInPath(sourceNode, nodeMap, source, true)) { - return false; - } - - if (!isFoundInPath(targetNode, nodeMap, target, true)) { - return false; - } - - if (name === "$inflight_init()") { - return false; - } - - if ( - !showTests && - (matchTest(sourceNode.path) || matchTest(targetNode.path)) - ) { - return false; - } - - return true; - }) - .map((connection) => { - return { - name: connection.name, - }; - }) ?? []; - return { source: { id: sourceNode.id, path: sourceNode.path, type: getResourceType(sourceNode, simulator), + display: sourceNode.display, }, target: { id: targetNode?.id ?? "", path: targetNode?.path ?? "", type: (targetNode && getResourceType(targetNode, simulator)) ?? "", + display: targetNode.display, }, - inflights, + inflights: targetInflight + ? [ + { + name: targetInflight, + }, + ] + : [], }; }), "app.invalidateQuery": createProcedure.subscription(({ ctx }) => { @@ -375,42 +417,17 @@ export const createAppRouter = () => { }; }); }), - "app.map": createProcedure - .input( - z - .object({ - showTests: z.boolean().optional(), - }) - .optional(), - ) - .query(async ({ ctx, input }) => { - const simulator = await ctx.simulator(); + "app.map": createProcedure.query(async ({ ctx }) => { + const simulator = await ctx.simulator(); - const { tree } = simulator.tree().rawData(); - const connections = simulator.connections(); - const shakedTree = shakeTree(tree); - const nodeMap = buildConstructTreeNodeMap(shakedTree); - const nodes = [ - createMapNodeFromConstructTreeNode( - shakedTree, - simulator, - input?.showTests, - ), - ]; - const edges = uniqby( - createMapEdgesFromConnectionData( - nodeMap, - connections, - input?.showTests, - ), - (edge) => edge.id, - ); + const { tree } = simulator.tree().rawData(); + const connections = simulator.connections(); - return { - nodes, - edges, - }; - }), + return { + tree, + connections, + }; + }), "app.state": createProcedure.query(async ({ ctx }) => { return ctx.appState(); }), @@ -591,45 +608,6 @@ export interface MapEdge { target: string; } -function createMapEdgesFromConnectionData( - nodeMap: ConstructTreeNodeMap, - connections: NodeConnection[], - showTests = false, -): MapEdge[] { - return [ - ...connections - .filter((connection) => { - return connectionsBasicFilter(connection, nodeMap, showTests); - }) - ?.map((connection: NodeConnection) => { - const source = getVisualNodePath(connection.source, nodeMap); - const target = getVisualNodePath(connection.target, nodeMap); - return { - id: `${source} -> ${target}`, - source, - target, - }; - }) - ?.filter(({ source, target }) => { - // Remove redundant connections to a parent resource if there's already a connection to a child resource. - if ( - connections.some((connection) => { - if ( - connection.source === source && - connection.target.startsWith(`${target}/`) - ) { - return true; - } - }) - ) { - return false; - } - - return true; - }), - ].flat(); -} - function getResourceType( node: Node | ConstructTreeNode, simulator: Simulator, diff --git a/apps/wing-console/console/server/src/utils/constructTreeNodeMap.ts b/apps/wing-console/console/server/src/utils/constructTreeNodeMap.ts index f54e324cf2c..e1a620f4d1d 100644 --- a/apps/wing-console/console/server/src/utils/constructTreeNodeMap.ts +++ b/apps/wing-console/console/server/src/utils/constructTreeNodeMap.ts @@ -6,6 +6,7 @@ export interface NodeDisplay { sourceModule?: string; hidden?: boolean; color?: string; + icon?: string; } export interface NodeConnection { diff --git a/apps/wing-console/console/server/src/utils/createRouter.ts b/apps/wing-console/console/server/src/utils/createRouter.ts index 3ac5813cd53..7cce8677d2d 100644 --- a/apps/wing-console/console/server/src/utils/createRouter.ts +++ b/apps/wing-console/console/server/src/utils/createRouter.ts @@ -48,7 +48,6 @@ export interface LayoutConfig { }; errorScreen?: { position?: "default" | "bottom"; - displayTitle?: boolean; displayLinks?: boolean; }; panels?: { @@ -87,6 +86,7 @@ export interface RouterMeta { export interface RouterContext { simulator(): Promise; + restartSimulator(): Promise; testSimulator(): Promise; appDetails(): Promise<{ wingVersion: string | undefined; diff --git a/apps/wing-console/console/server/src/utils/format-wing-error.ts b/apps/wing-console/console/server/src/utils/format-wing-error.ts index 6df311bd2fe..28c6da3a03f 100644 --- a/apps/wing-console/console/server/src/utils/format-wing-error.ts +++ b/apps/wing-console/console/server/src/utils/format-wing-error.ts @@ -77,6 +77,7 @@ export const formatWingError = async (error: unknown, entryPoint?: string) => { output.push( await prettyPrintError(error.causedBy, { sourceEntrypoint: resolve(entryPoint ?? "."), + resetCache: true, }), ); @@ -107,7 +108,7 @@ export const formatWingError = async (error: unknown, entryPoint?: string) => { }; export const formatTraceError = async (error: string): Promise => { - let output = await prettyPrintError(error); + let output = await prettyPrintError(error, { resetCache: true }); // Remove ANSI color codes const regex = diff --git a/apps/wing-console/console/server/src/utils/simulator.ts b/apps/wing-console/console/server/src/utils/simulator.ts index 4d1d145722f..f958a7bea46 100644 --- a/apps/wing-console/console/server/src/utils/simulator.ts +++ b/apps/wing-console/console/server/src/utils/simulator.ts @@ -17,6 +17,7 @@ export interface Simulator { instance(statedir?: string): Promise; start(simfile: string): Promise; stop(): Promise; + reload(): Promise; on( event: T, listener: (event: SimulatorEvents[T]) => void | Promise, @@ -84,6 +85,16 @@ export const createSimulator = (props?: CreateSimulatorProps): Simulator => { } }; + const reload = async () => { + if (instance) { + await events.emit("starting", { instance }); + await instance.reload(true); + await events.emit("started"); + } else { + throw new Error("Simulator not started"); + } + }; + return { async instance() { return ( @@ -96,6 +107,9 @@ export const createSimulator = (props?: CreateSimulatorProps): Simulator => { async stop() { await instance?.stop(); }, + async reload() { + await reload(); + }, on(event, listener) { events.on(event, listener); }, diff --git a/apps/wing-console/console/ui/src/App.tsx b/apps/wing-console/console/ui/src/App.tsx index 7f7a326f918..b919b8972df 100644 --- a/apps/wing-console/console/ui/src/App.tsx +++ b/apps/wing-console/console/ui/src/App.tsx @@ -6,10 +6,11 @@ import { } from "@wingconsole/design-system"; import type { Trace } from "@wingconsole/server"; -import type { LayoutType } from "./layout/layout-provider.js"; -import { LayoutProvider } from "./layout/layout-provider.js"; -import { trpc } from "./services/trpc.js"; -import { TestsContextProvider } from "./tests-context.js"; +import type { LayoutType } from "./features/layout/layout-provider.js"; +import { LayoutProvider } from "./features/layout/layout-provider.js"; +import { SelectionContextProvider } from "./features/selection-context/selection-context.js"; +import { TestsContextProvider } from "./features/tests-pane/tests-context.js"; +import { trpc } from "./trpc.js"; export interface AppProps { layout?: LayoutType; @@ -47,14 +48,16 @@ export const App = ({ layout, theme, color, onTrace }: AppProps) => { - + + + diff --git a/apps/wing-console/console/ui/src/Console.tsx b/apps/wing-console/console/ui/src/Console.tsx index 872532b542a..f50f5460d62 100644 --- a/apps/wing-console/console/ui/src/Console.tsx +++ b/apps/wing-console/console/ui/src/Console.tsx @@ -6,9 +6,9 @@ import { useEffect, useMemo, useState } from "react"; import { App } from "./App.js"; import { AppContext } from "./AppContext.js"; -import { LayoutType } from "./layout/layout-provider.js"; -import { trpc } from "./services/trpc.js"; -import { WebSocketProvider } from "./services/use-websocket.js"; +import { LayoutType } from "./features/layout/layout-provider.js"; +import { WebSocketProvider } from "./features/websocket-state/use-websocket.js"; +import { trpc } from "./trpc.js"; export const Console = ({ trpcUrl, diff --git a/apps/wing-console/console/ui/src/features/auto-updater.tsx b/apps/wing-console/console/ui/src/features/auto-updater.tsx deleted file mode 100644 index dd1b7f61d70..00000000000 --- a/apps/wing-console/console/ui/src/features/auto-updater.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { ArrowPathIcon } from "@heroicons/react/24/outline"; -import { - ProgressBar, - ToolbarButton, - useTheme, -} from "@wingconsole/design-system"; -import classNames from "classnames"; -import { useEffect, useState } from "react"; - -import { trpc } from "../services/trpc.js"; - -export const AutoUpdater = () => { - const { theme } = useTheme(); - - const enabled = trpc["updater.enabled"].useQuery(); - const { data: currentStatus } = trpc["updater.currentStatus"].useQuery( - undefined, - { - enabled: enabled.data?.enabled, - }, - ); - const checkForUpdates = trpc["updater.checkForUpdates"].useMutation(); - const quitAndInstall = trpc["updater.quitAndInstall"].useMutation(); - - const [nextVersion, setNextVersion] = useState(""); - - useEffect(() => { - if (enabled?.data?.enabled) { - checkForUpdates.mutate(); - } - }, [checkForUpdates, enabled?.data?.enabled]); - - useEffect(() => { - if (currentStatus?.status?.version) { - setNextVersion(currentStatus?.status.version); - } - }, [currentStatus?.status?.version]); - - const getText = () => { - switch (currentStatus?.status?.status) { - case "checking-for-update": - case "update-available": { - return "Checking for updates..."; - } - case "download-progress": { - return "Downloading V" + nextVersion + ":"; - } - case "error": { - return "Failed to update Wing Console"; - } - default: { - return ""; - } - } - }; - - // updater is not available - if (!enabled.data?.enabled) { - return <>; - } - - return ( -
    - - {getText()} - {currentStatus?.status?.status === "update-downloaded" && ( - { - quitAndInstall.mutate(); - }} - > - Restart to update - - - )} - - {currentStatus?.status?.progress && - currentStatus?.status?.status === "download-progress" ? ( -
    - -
    - ) : undefined} -
    - ); -}; diff --git a/apps/wing-console/console/ui/src/ui/blue-screen-of-death.tsx b/apps/wing-console/console/ui/src/features/blue-screen-of-death/blue-screen-of-death.tsx similarity index 53% rename from apps/wing-console/console/ui/src/ui/blue-screen-of-death.tsx rename to apps/wing-console/console/ui/src/features/blue-screen-of-death/blue-screen-of-death.tsx index 979a96a295f..45d6adb1738 100644 --- a/apps/wing-console/console/ui/src/ui/blue-screen-of-death.tsx +++ b/apps/wing-console/console/ui/src/features/blue-screen-of-death/blue-screen-of-death.tsx @@ -1,24 +1,26 @@ +import { useNotifications } from "@wingconsole/design-system"; import classNames from "classnames"; -import { memo, useEffect, useState } from "react"; +import { memo, useCallback, useEffect, useMemo, useState } from "react"; -import { - OpenFileInEditorButton, - createHtmlLink, -} from "../shared/use-file-link.js"; +import { trpc } from "../../trpc.js"; +import { OpenFileInEditorButton, createHtmlLink } from "../../use-file-link.js"; export const BlueScreenOfDeath = memo( - ({ - title, - error = "", - displayLinks = true, - displayWingTitle = true, - }: { - title: string; - error: string; - displayLinks?: boolean; - displayWingTitle?: boolean; - }) => { + ({ displayLinks = true }: { displayLinks?: boolean }) => { + const errorQuery = trpc["app.error"].useQuery(); + + const error = useMemo(() => { + return errorQuery.data ?? ""; + }, [errorQuery.data]); + const [formattedPathsError, setFormattedPathsError] = useState(""); + const { showNotification } = useNotifications(); + + const copyError = useCallback(() => { + navigator.clipboard.writeText(error); + showNotification("Error copied to clipboard", { type: "success" }); + }, [error, showNotification]); + useEffect(() => { if (!displayLinks) { setFormattedPathsError(error); @@ -36,20 +38,24 @@ export const BlueScreenOfDeath = memo( return (
    - {displayWingTitle && ( -
    - Wing -
    - )} - +
    + +
    -
    {title}
    + {displayLinks && ( -
    +
    Click on any error reference to navigate to your IDE{" "} _
    diff --git a/apps/wing-console/console/ui/src/features/console-logs-filters.tsx b/apps/wing-console/console/ui/src/features/console-logs-filters.tsx deleted file mode 100644 index 4f2a7d480ff..00000000000 --- a/apps/wing-console/console/ui/src/features/console-logs-filters.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import { MagnifyingGlassIcon, NoSymbolIcon } from "@heroicons/react/24/outline"; -import { Button, Input, Listbox } from "@wingconsole/design-system"; -import type { LogLevel } from "@wingconsole/server"; -import debounce from "lodash.debounce"; -import { memo, useCallback, useEffect, useState } from "react"; - -const logLevels = ["verbose", "info", "warn", "error"] as const; - -const logLevelNames = { - verbose: "Verbose", - info: "Info", - warn: "Warnings", - error: "Errors", -} as const; - -export interface ConsoleLogsFiltersProps { - selectedLogTypeFilters: LogLevel[]; - setSelectedLogTypeFilters: (types: LogLevel[]) => void; - clearLogs: () => void; - isLoading: boolean; - onSearch: (search: string) => void; -} - -export const ConsoleLogsFilters = memo( - ({ - selectedLogTypeFilters, - setSelectedLogTypeFilters, - clearLogs, - isLoading, - onSearch, - }: ConsoleLogsFiltersProps) => { - const [searchText, setSearchText] = useState(""); - - // eslint-disable-next-line react-hooks/exhaustive-deps - const debouncedOnSearch = useCallback(debounce(onSearch, 300), [onSearch]); - useEffect(() => { - debouncedOnSearch(searchText); - }, [debouncedOnSearch, searchText]); - - const [defaultSelection, setDefaultSelection] = useState(); - const [combinationName, setCombinationName] = useState(); - - useEffect(() => { - if (selectedLogTypeFilters.length === 4) { - setCombinationName("All levels"); - } else if ( - selectedLogTypeFilters.length === 3 && - selectedLogTypeFilters.includes("verbose") === false - ) { - setCombinationName("Default levels"); - } else if (selectedLogTypeFilters.length === 0) { - setCombinationName("Hide all"); - } else if ( - selectedLogTypeFilters.length === 1 && - selectedLogTypeFilters[0] - ) { - setCombinationName(`${logLevelNames[selectedLogTypeFilters[0]]} only`); - } else { - setCombinationName("Custom levels"); - } - }, [selectedLogTypeFilters]); - - useEffect(() => { - setDefaultSelection(selectedLogTypeFilters); - }, [selectedLogTypeFilters]); - - return ( -
    -
    - ); - }, -); diff --git a/apps/wing-console/console/ui/src/shared/endpoint-item.ts b/apps/wing-console/console/ui/src/features/endpoints-pane/endpoint-item.ts similarity index 100% rename from apps/wing-console/console/ui/src/shared/endpoint-item.ts rename to apps/wing-console/console/ui/src/features/endpoints-pane/endpoint-item.ts diff --git a/apps/wing-console/console/ui/src/ui/endpoint-tree.tsx b/apps/wing-console/console/ui/src/features/endpoints-pane/endpoint-tree.tsx similarity index 96% rename from apps/wing-console/console/ui/src/ui/endpoint-tree.tsx rename to apps/wing-console/console/ui/src/features/endpoints-pane/endpoint-tree.tsx index 4346dcbc443..41c26ae0c76 100644 --- a/apps/wing-console/console/ui/src/ui/endpoint-tree.tsx +++ b/apps/wing-console/console/ui/src/features/endpoints-pane/endpoint-tree.tsx @@ -13,8 +13,7 @@ import { } from "@wingconsole/design-system"; import classNames from "classnames"; -import type { EndpointItem } from "../shared/endpoint-item.js"; - +import type { EndpointItem } from "./endpoint-item.js"; import { NoEndpoints } from "./no-endpoints.js"; export interface EndpointTreeProps { @@ -91,7 +90,7 @@ export const EndpointTree = ({ ? "cursor-not-allowed" : "cursor-pointer", )} - title="Open a tunnel for this enpoint" + title="Open a tunnel for this endpoint" onClick={() => { exposeEndpoint(endpoint.id); }} diff --git a/apps/wing-console/console/ui/src/features/endpoints-tree-view.tsx b/apps/wing-console/console/ui/src/features/endpoints-pane/endpoints-tree-view.tsx similarity index 69% rename from apps/wing-console/console/ui/src/features/endpoints-tree-view.tsx rename to apps/wing-console/console/ui/src/features/endpoints-pane/endpoints-tree-view.tsx index f2b4dcda17d..4b635b0f700 100644 --- a/apps/wing-console/console/ui/src/features/endpoints-tree-view.tsx +++ b/apps/wing-console/console/ui/src/features/endpoints-pane/endpoints-tree-view.tsx @@ -1,7 +1,8 @@ import { memo } from "react"; -import { useEndpoints } from "../services/use-endpoints.js"; -import { EndpointTree } from "../ui/endpoint-tree.js"; +import { useEndpoints } from "../inspector-pane/resource-panes/use-endpoints.js"; + +import { EndpointTree } from "./endpoint-tree.js"; export const EndpointsTreeView = memo(() => { const { endpointList, exposeEndpoint, hideEndpoint } = useEndpoints(); diff --git a/apps/wing-console/console/ui/src/ui/no-endpoints.tsx b/apps/wing-console/console/ui/src/features/endpoints-pane/no-endpoints.tsx similarity index 100% rename from apps/wing-console/console/ui/src/ui/no-endpoints.tsx rename to apps/wing-console/console/ui/src/features/endpoints-pane/no-endpoints.tsx diff --git a/apps/wing-console/console/ui/src/features/explorer-pane/elk-flow/assert.ts b/apps/wing-console/console/ui/src/features/explorer-pane/elk-flow/assert.ts new file mode 100644 index 00000000000..893d04210eb --- /dev/null +++ b/apps/wing-console/console/ui/src/features/explorer-pane/elk-flow/assert.ts @@ -0,0 +1,5 @@ +export function assert(value: unknown, message?: string): asserts value { + if (!value) { + throw new Error(message ?? "Assertion failed"); + } +} diff --git a/apps/wing-console/console/ui/src/features/explorer-pane/elk-flow/graph-generator.tsx b/apps/wing-console/console/ui/src/features/explorer-pane/elk-flow/graph-generator.tsx new file mode 100644 index 00000000000..46e8e8b020e --- /dev/null +++ b/apps/wing-console/console/ui/src/features/explorer-pane/elk-flow/graph-generator.tsx @@ -0,0 +1,241 @@ +import type { ElkExtendedEdge, ElkNode, ElkPort } from "elkjs"; +import ELK from "elkjs/lib/elk.bundled.js"; +import { + type FunctionComponent, + type PropsWithChildren, + createContext, + useContext, + useEffect, + useState, + useRef, + memo, + createElement, + type RefObject, +} from "react"; + +import { assert } from "./assert.js"; +import { + NodeChildrenContext, + type NodeChildrenProps, +} from "./node-children.js"; +import { NodeContext, type NodeProps } from "./node.js"; +import { PortContext, type PortProps } from "./port.js"; +import type { ElkOptions, IntrinsicElements } from "./types.js"; + +const ElkNodeContext = createContext({ + id: "", +}); + +const NodeRefContext = createContext | undefined>(undefined); + +const NodeComponent = ({ + elk, + as, + children, + ...props +}: NodeProps) => { + const [node] = useState(() => ({ + ...elk, + })); + + const parent = useContext(ElkNodeContext); + useEffect(() => { + parent.children = + parent.children?.filter((child) => child.id !== elk.id) ?? []; + parent.children.push(node); + return () => { + parent.children = + parent.children?.filter((child) => child.id !== elk.id) ?? []; + }; + }); + + const ref = useRef(null); + useEffect(() => { + const rect = ref.current?.getBoundingClientRect(); + node.layoutOptions = { + ...node.layoutOptions, + "elk.nodeSize.constraints": "MINIMUM_SIZE", + "elk.nodeSize.minimum": `[${rect?.width ?? 0}, ${rect?.height ?? 0}]`, + }; + }); + + return createElement( + as ?? "div", + { ...props, style: { ...props.style, position: "relative" }, ref }, + + {children} + , + ); +}; + +const NodeChildrenComponent = ({ + as, + children, + ...props +}: NodeChildrenProps) => { + const node = useContext(ElkNodeContext); + + const nodeRef = useContext(NodeRefContext); + const ref = useRef(null); + useEffect(() => { + const nodeRect = nodeRef?.current?.getBoundingClientRect(); + assert(nodeRect); + const childrenRect = ref.current?.getBoundingClientRect(); + assert(childrenRect); + const padding = { + top: childrenRect.top - nodeRect.top, + left: childrenRect.left - nodeRect.left, + bottom: nodeRect.bottom - childrenRect.bottom, + right: nodeRect.right - childrenRect.right, + }; + node.layoutOptions = { + ...node.layoutOptions, + "elk.padding": `[top=${padding.top},left=${padding.left},bottom=${padding.bottom},right=${padding.right}]`, + }; + }); + + return createElement(as ?? "div", { ...props, ref }, children); +}; + +const PortComponent = ({ + elk, + as, + children, + ...props +}: PortProps) => { + const [port] = useState(() => ({ + ...elk, + })); + + const parent = useContext(ElkNodeContext); + useEffect(() => { + parent.ports = parent.ports?.filter((child) => child.id !== elk.id) ?? []; + parent.ports.push(port); + return () => { + parent.ports = parent.ports?.filter((child) => child.id !== elk.id) ?? []; + }; + }); + + const ref = useRef(null); + useEffect(() => { + const rect = ref.current?.getBoundingClientRect(); + port.layoutOptions = { + ...port.layoutOptions, + "elk.nodeSize.constraints": "MINIMUM_SIZE", + "elk.nodeSize.minimum": `[${rect?.width ?? 0}, ${rect?.height ?? 0}]`, + }; + }); + + return createElement( + as ?? "div", + { ...props, style: { ...props.style, position: "absolute" }, ref }, +
    +
    {children}
    +
    , + ); +}; + +export interface GraphGeneratorProps { + elk: ElkOptions; + edges?: ElkExtendedEdge[]; + onGraph?: (graph: ElkNode) => void; +} + +export const GraphGenerator: FunctionComponent< + PropsWithChildren +> = memo(({ onGraph, ...props }) => { + const [root] = useState(() => ({ + ...props.elk, + })); + + const [jsonGraph, setJsonGraph] = useState(); + useEffect(() => { + const nodeMap = new Map(); + const processNode = (node: ElkNode) => { + nodeMap.set(node.id, node); + for (const port of node.ports ?? []) { + nodeMap.set(port.id, port); + } + for (const child of node.children ?? []) { + processNode(child); + } + }; + processNode(root); + const edges = (props.edges ?? []).filter((edge) => { + return ( + edge.sources.every((source) => { + const exists = nodeMap.has(source); + if (!exists) { + console.warn(`Edge source ${source} does not exist in node map`); + } + return exists; + }) && + edge.targets.every((target) => { + const exists = nodeMap.has(target); + if (!exists) { + console.warn(`Edge target ${target} does not exist in node map`); + } + return exists; + }) + ); + }); + + setJsonGraph(() => { + // Remove $H properties from JSON and sort children by id. + const processNode = (node: ElkNode) => { + delete (node as any).$H; + const children = node.children ?? []; + node.children = children.sort((a, b) => a.id.localeCompare(b.id)); + for (const child of node.children) { + processNode(child); + } + }; + + processNode(root); + + return JSON.stringify({ ...root, edges }); + }); + }, [root, props.edges]); + + useEffect(() => { + if (!jsonGraph) { + return; + } + + let abort = false; + + const elk = new ELK(); + + void elk + .layout(JSON.parse(jsonGraph)) + .then((graph) => { + if (abort) { + return; + } + + onGraph?.(graph); + }) + .catch((error) => console.error("elk layout error:", error)); + return () => { + abort = true; + }; + }, [jsonGraph, onGraph]); + + return ( + + + + + {props.children} + + + + + ); +}); diff --git a/apps/wing-console/console/ui/src/features/explorer-pane/elk-flow/graph-renderer.tsx b/apps/wing-console/console/ui/src/features/explorer-pane/elk-flow/graph-renderer.tsx new file mode 100644 index 00000000000..1822773e1d5 --- /dev/null +++ b/apps/wing-console/console/ui/src/features/explorer-pane/elk-flow/graph-renderer.tsx @@ -0,0 +1,222 @@ +import type { ElkNode } from "elkjs"; +import { + type FunctionComponent, + type PropsWithChildren, + createContext, + useContext, + memo, + createElement, + useMemo, + useEffect, + useRef, + useState, +} from "react"; +import { createPortal } from "react-dom"; + +import { assert } from "./assert.js"; +import { + NodeChildrenContext, + type NodeChildrenProps, +} from "./node-children.js"; +import { NodeContext, type NodeProps } from "./node.js"; +import { PortContext, type PortProps } from "./port.js"; +import type { IntrinsicElements, EdgeComponent } from "./types.js"; + +const DepthContext = createContext(0); + +const ElkNodeContext = createContext({ + id: "", +}); + +const OriginsContext = createContext>( + new Map(), +); + +// eslint-disable-next-line unicorn/no-null +const PortalContext = createContext(null); + +const NodeComponent = ({ + elk, + as, + children, + ...props +}: NodeProps) => { + const portal = useContext(PortalContext); + assert(portal); + + const depth = useContext(DepthContext); + + const parent = useContext(ElkNodeContext); + const node = useMemo( + () => parent.children?.find((child) => child.id === elk.id), + [elk.id, parent.children], + ); + + const origins = useContext(OriginsContext); + const origin = origins.get(elk.id); + + return createPortal( + createElement( + as ?? "div", + { + ...props, + style: { + ...props.style, + position: "absolute", + top: `${origin?.y ?? 0}px`, + left: `${origin?.x ?? 0}px`, + width: `${node?.width ?? 0}px`, + height: `${node?.height ?? 0}px`, + zIndex: depth, + }, + }, + node ? ( + + + {children} + + + ) : ( + <> + ), + ), + portal, + ); +}; + +const NodeChildrenComponent = ({ + as, + children, + ...props +}: NodeChildrenProps) => { + return createElement(as ?? "div", props, children); +}; + +const PortComponent = ({ + elk, + as, + children, + ...props +}: PortProps) => { + const node = useContext(ElkNodeContext); + const port = useMemo( + () => node.ports?.find((port) => port.id === elk.id), + [elk.id, node.ports], + ); + assert(port); + + return createElement( + as ?? "div", + { + ...props, + style: { + ...props.style, + position: "absolute", + top: `${port.y ?? 0}px`, + left: `${port.x ?? 0}px`, + }, + }, +
    +
    {children}
    +
    , + ); +}; + +const DefaultEdge: EdgeComponent = memo( + ({ edge, offsetX = 0, offsetY = 0 }) => { + const d = useMemo(() => { + return edge.sections + ?.map((section) => { + const points = + [...(section.bendPoints ?? []), section.endPoint] + ?.map((point) => `L${point.x},${point.y}`) + .join(" ") ?? ""; + + return `M${section.startPoint.x},${section.startPoint.y} ${points}`; + }) + .join(" "); + }, [edge.sections]); + + return ( + + + + ); + }, +); + +export interface GraphRendererProps { + graph: ElkNode; + edgeComponent?: EdgeComponent; +} + +export const GraphRenderer: FunctionComponent< + PropsWithChildren +> = memo((props) => { + const Edge = props.edgeComponent ?? DefaultEdge; + + const origins = useMemo(() => { + const origins = new Map(); + const mapNode = (node: ElkNode, offsetX: number, offsetY: number) => { + const x = offsetX + (node.x ?? 0); + const y = offsetY + (node.y ?? 0); + origins.set(node.id, { x, y }); + for (const child of Object.values(node.children ?? {})) { + mapNode(child, x, y); + } + }; + mapNode(props.graph, 0, 0); + return origins; + }, [props.graph]); + + const portalTarget = useRef(null); + const [portal, setPortal] = useState(); + useEffect(() => { + setPortal(portalTarget.current ?? undefined); + }, []); + + return ( +
    + {props.graph.edges?.map((edge) => ( + + ))} + +
    + + {portal && ( + + + + + + + {props.children} + + + + + + + )} +
    + ); +}); diff --git a/apps/wing-console/console/ui/src/features/explorer-pane/elk-flow/graph.tsx b/apps/wing-console/console/ui/src/features/explorer-pane/elk-flow/graph.tsx new file mode 100644 index 00000000000..2120c8fef70 --- /dev/null +++ b/apps/wing-console/console/ui/src/features/explorer-pane/elk-flow/graph.tsx @@ -0,0 +1,88 @@ +import type { ElkExtendedEdge, ElkNode } from "elkjs"; +import { + memo, + useEffect, + useMemo, + useRef, + useState, + type DetailedHTMLProps, + type FunctionComponent, + type HTMLAttributes, + type PropsWithChildren, +} from "react"; +import { createPortal } from "react-dom"; + +import { MapBackground } from "../map-background.js"; +import type { ZoomPaneRef } from "../zoom-pane.js"; +import { ZoomPane } from "../zoom-pane.js"; + +import { GraphGenerator } from "./graph-generator.js"; +import { GraphRenderer } from "./graph-renderer.js"; +import type { EdgeComponent, ElkOptions } from "./types.js"; + +export interface GraphProps + extends DetailedHTMLProps, HTMLDivElement> { + elk: ElkOptions; + edges?: ElkExtendedEdge[]; + edgeComponent?: EdgeComponent; +} + +export const Graph: FunctionComponent> = memo( + (props) => { + const { elk, edges, edgeComponent, ...divProps } = props; + + const [graph, setGraph] = useState(); + + const zoomPaneRef = useRef(null); + + const mapSize = useMemo(() => { + if (!graph) { + return; + } + + return { + width: graph.width!, + height: graph.height!, + }; + }, [graph]); + + useEffect(() => { + zoomPaneRef.current?.zoomToFit(); + }, [graph]); + + const mapBackgroundRef = useRef(null); + + return ( + <> + {createPortal( +
    + + {props.children} + +
    , + document.body, + )} + +
    + + + {mapBackgroundRef.current && + createPortal(, mapBackgroundRef.current)} + +
    + {graph && ( + + {props.children} + + )} +
    +
    + + ); + }, +); diff --git a/apps/wing-console/console/ui/src/features/explorer-pane/elk-flow/node-children.tsx b/apps/wing-console/console/ui/src/features/explorer-pane/elk-flow/node-children.tsx new file mode 100644 index 00000000000..522e23ce852 --- /dev/null +++ b/apps/wing-console/console/ui/src/features/explorer-pane/elk-flow/node-children.tsx @@ -0,0 +1,34 @@ +import { + type FunctionComponent, + useContext, + createContext, + type ReactNode, + createElement, +} from "react"; + +import type { IntrinsicElements } from "./types.js"; + +export type NodeChildrenProps = { + as?: K; + children?: ReactNode; +} & IntrinsicElements[K]; + +const DefaultComponent = ({ + as, + children, + ...props +}: NodeChildrenProps) => { + return createElement(as ?? "div", props, children); +}; + +export const NodeChildrenContext = + // eslint-disable-next-line @typescript-eslint/no-explicit-any + createContext>>(DefaultComponent); + +export const NodeChildren = ({ + children, + ...props +}: NodeChildrenProps) => { + const Component = useContext(NodeChildrenContext); + return createElement(Component, props, children); +}; diff --git a/apps/wing-console/console/ui/src/features/explorer-pane/elk-flow/node.tsx b/apps/wing-console/console/ui/src/features/explorer-pane/elk-flow/node.tsx new file mode 100644 index 00000000000..990a7266e1d --- /dev/null +++ b/apps/wing-console/console/ui/src/features/explorer-pane/elk-flow/node.tsx @@ -0,0 +1,35 @@ +import { + type FunctionComponent, + useContext, + createContext, + type ReactNode, + createElement, +} from "react"; + +import type { ElkOptions, IntrinsicElements } from "./types.js"; + +export type NodeProps = { + elk: ElkOptions; + as?: K; + children?: ReactNode; +} & IntrinsicElements[K]; + +const DefaultComponent = ({ + as, + children, + ...props +}: NodeProps) => { + return createElement(as ?? "div", props, children); +}; + +export const NodeContext = + // eslint-disable-next-line @typescript-eslint/no-explicit-any + createContext>>(DefaultComponent); + +export const Node = ({ + children, + ...props +}: NodeProps) => { + const Component = useContext(NodeContext); + return createElement(Component, props, children); +}; diff --git a/apps/wing-console/console/ui/src/features/explorer-pane/elk-flow/port.tsx b/apps/wing-console/console/ui/src/features/explorer-pane/elk-flow/port.tsx new file mode 100644 index 00000000000..202799e391b --- /dev/null +++ b/apps/wing-console/console/ui/src/features/explorer-pane/elk-flow/port.tsx @@ -0,0 +1,35 @@ +import { + useContext, + createContext, + type ReactNode, + createElement, + type FunctionComponent, +} from "react"; + +import type { ElkOptions, IntrinsicElements } from "./types.js"; + +export type PortProps = { + elk: ElkOptions; + as?: K; + children?: ReactNode; +} & IntrinsicElements[K]; + +const DefaultComponent = ({ + as, + children, + ...props +}: PortProps) => { + return createElement(as ?? "div", props, children); +}; + +export const PortContext = + // eslint-disable-next-line @typescript-eslint/no-explicit-any + createContext>>(DefaultComponent); + +export const Port = ({ + children, + ...props +}: PortProps) => { + const Component = useContext(PortContext); + return createElement(Component, props, children); +}; diff --git a/apps/wing-console/console/ui/src/features/explorer-pane/elk-flow/types.ts b/apps/wing-console/console/ui/src/features/explorer-pane/elk-flow/types.ts new file mode 100644 index 00000000000..a2c13a7c1c1 --- /dev/null +++ b/apps/wing-console/console/ui/src/features/explorer-pane/elk-flow/types.ts @@ -0,0 +1,22 @@ +import type { ElkExtendedEdge, ElkNode } from "elkjs"; +import type { FunctionComponent } from "react"; + +// export type IntrinsicElements = JSX.IntrinsicElements; +export interface IntrinsicElements { + div: JSX.IntrinsicElements["div"]; +} + +export interface ElkOptions { + id: string; + layoutOptions?: Record; +} + +export interface EdgeComponentProps { + edge: ElkExtendedEdge; + offsetX?: number; + offsetY?: number; + graphWidth: number; + graphHeight: number; +} + +export type EdgeComponent = FunctionComponent; diff --git a/apps/wing-console/console/ui/src/features/explorer-pane/explorer.tsx b/apps/wing-console/console/ui/src/features/explorer-pane/explorer.tsx new file mode 100644 index 00000000000..a1addffbbb4 --- /dev/null +++ b/apps/wing-console/console/ui/src/features/explorer-pane/explorer.tsx @@ -0,0 +1,34 @@ +import { memo, useCallback, useState } from "react"; + +import { useSelectionContext } from "../selection-context/selection-context.js"; + +import { MapView } from "./map-view.js"; + +export const Explorer = memo(() => { + const { + selectedItems, + setSelectedItems, + expand, + collapse, + expandedItems, + selectedEdgeId, + setSelectedEdgeId, + } = useSelectionContext(); + + const setSelectedItemSingle = useCallback( + (nodeId: string | undefined) => setSelectedItems(nodeId ? [nodeId] : []), + [setSelectedItems], + ); + + return ( + + ); +}); diff --git a/apps/wing-console/console/ui/src/features/explorer-pane/map-background.tsx b/apps/wing-console/console/ui/src/features/explorer-pane/map-background.tsx new file mode 100644 index 00000000000..cc6f0c01bd5 --- /dev/null +++ b/apps/wing-console/console/ui/src/features/explorer-pane/map-background.tsx @@ -0,0 +1,49 @@ +import type { FunctionComponent } from "react"; +import { useId } from "react"; + +import { useZoomPane } from "./zoom-pane.js"; + +export interface MapBackgroundProps { + hideDots?: boolean; +} + +export const MapBackground: FunctionComponent = ({ + hideDots, +}) => { + const { viewTransform } = useZoomPane(); + const patternSize = 12 * viewTransform.z; + const dotSize = 1 * viewTransform.z; + const id = useId(); + return ( + // Reference: https://github.com/xyflow/xyflow/blob/13897512d3c57e72c2e27b14ffa129412289d948/packages/react/src/additional-components/Background/Background.tsx#L52-L86. + + {!hideDots && ( + <> + + + + + + )} + + ); +}; diff --git a/apps/wing-console/console/ui/src/ui/map-controls.stories.tsx b/apps/wing-console/console/ui/src/features/explorer-pane/map-controls.stories.tsx similarity index 100% rename from apps/wing-console/console/ui/src/ui/map-controls.stories.tsx rename to apps/wing-console/console/ui/src/features/explorer-pane/map-controls.stories.tsx diff --git a/apps/wing-console/console/ui/src/ui/map-controls.tsx b/apps/wing-console/console/ui/src/features/explorer-pane/map-controls.tsx similarity index 82% rename from apps/wing-console/console/ui/src/ui/map-controls.tsx rename to apps/wing-console/console/ui/src/features/explorer-pane/map-controls.tsx index 7c724ac9e08..c7c30fdb77e 100644 --- a/apps/wing-console/console/ui/src/ui/map-controls.tsx +++ b/apps/wing-console/console/ui/src/features/explorer-pane/map-controls.tsx @@ -23,15 +23,15 @@ export const MapControls = ({
    - + - + - +
    diff --git a/apps/wing-console/console/ui/src/features/explorer-pane/map-view.tsx b/apps/wing-console/console/ui/src/features/explorer-pane/map-view.tsx new file mode 100644 index 00000000000..f2550b41fb7 --- /dev/null +++ b/apps/wing-console/console/ui/src/features/explorer-pane/map-view.tsx @@ -0,0 +1,768 @@ +import { ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/24/outline"; +import { + ResourceIcon, + SpinnerLoader, + useTheme, +} from "@wingconsole/design-system"; +import type { ConstructTreeNode } from "@winglang/sdk/lib/core/index.js"; +import clsx from "classnames"; +import { type ElkPoint, type LayoutOptions } from "elkjs"; +import type { FunctionComponent, PropsWithChildren } from "react"; +import { memo, useCallback, useMemo } from "react"; +import { useKeyPressEvent } from "react-use"; + +import { assert } from "./elk-flow/assert.js"; +import { Graph } from "./elk-flow/graph.js"; +import { NodeChildren } from "./elk-flow/node-children.js"; +import { Node } from "./elk-flow/node.js"; +import { Port } from "./elk-flow/port.js"; +import type { EdgeComponent, EdgeComponentProps } from "./elk-flow/types.js"; +import { useMap } from "./use-map.js"; + +const Z_INDICES = { + EDGE: "z-[1000]", + EDGE_HIGHLIGHTED: "z-[1001]", + EDGE_SELECTED: "z-[1002]", + EDGE_HOVERED: "hover:z-[1003]", +}; + +const SPACING_BASE_VALUE = 32; +const PORT_ANCHOR = 0; +const EDGE_ROUNDED_RADIUS = 10; +// For more configuration options, refer to: https://eclipse.dev/elk/reference/options.html +const baseLayoutOptions: LayoutOptions = { + "elk.alignment": "CENTER", + "elk.hierarchyHandling": "INCLUDE_CHILDREN", + "elk.algorithm": "org.eclipse.elk.layered", + "elk.layered.spacing.baseValue": `${SPACING_BASE_VALUE}`, // See https://eclipse.dev/elk/reference/options/org-eclipse-elk-layered-spacing-baseValue.html. +}; +interface WrapperProps { + name: string; + fqn: string; + highlight?: boolean; + onClick?: () => void; + color?: string; + icon?: string; + collapsed?: boolean; + onCollapse?: (value: boolean) => void; +} + +const Wrapper: FunctionComponent> = memo( + ({ + name, + fqn, + highlight, + onClick, + collapsed = false, + onCollapse = (value: boolean) => {}, + children, + color, + icon, + }) => { + /* eslint-disable jsx-a11y/no-static-element-interactions */ + /* eslint-disable jsx-a11y/click-events-have-key-events */ + return ( +
    { + event.stopPropagation(); + onClick?.(); + }} + > +
    + + + + {name} + +
    +
    { + onCollapse(!collapsed); + }} + > + {collapsed && ( + + )} + {!collapsed && ( + + )} +
    +
    +
    + {!collapsed && children} +
    + ); + }, +); + +interface ContainerNodeProps { + id: string; + name: string; + pseudoContainer?: boolean; + resourceType?: string; + highlight?: boolean; + onClick?: () => void; + collapsed?: boolean; + onCollapse?: (value: boolean) => void; + color?: string; + icon?: string; +} + +const ContainerNode: FunctionComponent> = + memo((props) => { + return ( + +
    + +
    + +
    {props.children}
    +
    +
    +
    +
    +
    + ); + }); + +interface ConstructNodeProps { + id: string; + name: string; + fqn: string; + inflights: { + id: string; + name: string; + sourceOccupied?: boolean; + targetOccupied?: boolean; + }[]; + highlight?: boolean; + hasChildNodes?: boolean; + onSelectedNodeIdChange: (id: string | undefined) => void; + color?: string; + onCollapse: (value: boolean) => void; + collapsed: boolean; + icon?: string; +} + +const ConstructNode: FunctionComponent> = + memo( + ({ + id, + name, + onSelectedNodeIdChange, + highlight, + fqn, + inflights, + children, + hasChildNodes, + color, + onCollapse, + collapsed, + icon, + }) => { + const select = useCallback( + () => onSelectedNodeIdChange(id), + [onSelectedNodeIdChange, id], + ); + + const renderedNode = ( + + {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */} +
    { + event.stopPropagation(); + select(); + }} + > + + + + + {!hasChildNodes && ( + // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions +
    0 && + "border-b border-slate-200 dark:border-slate-800", + )} + > + + + + {name} + + {collapsed && ( +
    { + if (collapsed) { + onCollapse(false); + } + }} + > + +
    + )} +
    + )} + +
    + +
    + {inflights.map((inflight) => ( + +
    +
    + {inflight.name} +
    +
    + + + + +
    + ))} +
    +
    +
    +
    +
    + ); + + if (hasChildNodes) { + return ( + + + {inflights.length > 0 && renderedNode} + + {children} + + + + + + + ); + } + + return renderedNode; + }, + ); + +/** + * Returns the middle point between two points with a given radius. + */ +const midPoint = (pt1: ElkPoint, pt2: ElkPoint, radius: number) => { + const distance = Math.sqrt((pt2.x - pt1.x) ** 2 + (pt2.y - pt1.y) ** 2); + const radiusCapped = Math.min(radius, distance / 2); + const diffX = (pt2.x - pt1.x) / distance; + const diffY = (pt2.y - pt1.y) / distance; + return { x: pt2.x - radiusCapped * diffX, y: pt2.y - radiusCapped * diffY }; +}; + +const RoundedEdge: FunctionComponent< + EdgeComponentProps & { + selected?: boolean; + highlighted?: boolean; + onClick?: () => void; + } +> = memo( + ({ + edge, + offsetX = 0, + offsetY = 0, + graphWidth, + graphHeight, + selected, + highlighted, + onClick, + }) => { + const points = useMemo( + () => + edge.sections?.flatMap((section) => [ + section.startPoint, + ...(section.bendPoints ?? []), + section.endPoint, + ]) ?? [], + [edge.sections], + ); + + const additionalPoints = useMemo(() => { + const additionalPoints: ElkPoint[] = []; + { + const startPoint = points[0]; + assert(startPoint); + additionalPoints.push(startPoint); + } + for (let index = 0; index < points.length - 2; index++) { + const [start, middle, end] = points.slice(index, index + 3); + assert(start && middle && end); + additionalPoints.push( + midPoint(start, middle, EDGE_ROUNDED_RADIUS), + middle, + midPoint(end, middle, EDGE_ROUNDED_RADIUS), + ); + } + { + const lastPoint = points.at(-1); + assert(lastPoint); + additionalPoints.push(lastPoint); + } + return additionalPoints; + }, [points]); + + const d = useMemo(() => { + if (additionalPoints.length === 0) { + return ""; + } + + if (additionalPoints.length === 2) { + const [start, end] = additionalPoints; + assert(start); + assert(end); + return `M${start.x},${start.y} L${end.x},${end.y}`; + } + + let path = ""; + for ( + let index = 0; + index < additionalPoints.length - 1; + index = index + 3 + ) { + const [start, c1, c2, c3] = additionalPoints.slice(index, index + 4); + if (!start) { + return path; + } + if (c1 && c2 && c3) { + path = `${path} M${start.x},${start.y} L${c1.x},${c1.y} Q${c2.x},${c2.y} ${c3.x},${c3.y}`; + } else if (c1) { + path = `${path} L${c1.x},${c1.y}`; + } + } + return path; + }, [additionalPoints]); + + const lastPoint = additionalPoints.at(-1); + + return ( + + { + event.stopPropagation(); + onClick?.(); + }} + > + + + {edge.id} (from {edge.sources.join(",")} to{" "} + {edge.targets.join(",")}) + + + + + + + + ); + }, +); + +const getNodePathFromEdge = (edge: string) => { + const [, path] = edge.match(/^(.+?)#/i) ?? []; + return path; +}; + +export interface MapViewV2Props { + selectedNodeId: string | undefined; + onSelectedNodeIdChange: (id: string | undefined) => void; + selectedEdgeId?: string; + onSelectedEdgeIdChange?: (id: string | undefined) => void; + onExpand: (path: string) => void; + onCollapse: (path: string) => void; + expandedItems: string[]; +} + +export const MapView = memo( + ({ + selectedNodeId, + onSelectedNodeIdChange, + selectedEdgeId, + onSelectedEdgeIdChange, + onExpand, + onCollapse, + expandedItems, + }: MapViewV2Props) => { + const { nodeInfo, isNodeHidden, rootNodes, edges } = useMap({ + expandedItems, + }); + + const RenderEdge = useCallback( + (props) => { + return ( + getNodePathFromEdge(path) === selectedNodeId, + ) || + props.edge.targets.some( + (path) => getNodePathFromEdge(path) === selectedNodeId, + ) + } + onClick={() => onSelectedEdgeIdChange?.(props.edge.id)} + /> + ); + }, + [selectedEdgeId, selectedNodeId, onSelectedEdgeIdChange], + ); + + const RenderNode = useCallback< + FunctionComponent<{ + constructTreeNode: ConstructTreeNode; + selectedNodeId: string | undefined; + onSelectedNodeIdChange: (id: string | undefined) => void; + }> + >( + (props) => { + const node = props.constructTreeNode; + if (isNodeHidden(node.path)) { + return <>; + } + + const info = nodeInfo?.get(node.path); + if (!info) { + return <>; + } + + const childNodes = Object.values(node.children ?? {}).filter( + (node) => !isNodeHidden(node.path), + ); + + const fqn = node.constructInfo?.fqn; + + const cloudResourceType = fqn?.split(".").at(-1); + + const name = + node.display?.title === cloudResourceType + ? node.id + : node.display?.title ?? node.id; + + const children = Object.values(node.children ?? {}); + const canBeExpanded = + !!node.children && children.some((child) => !child.display?.hidden); + const collapsed = canBeExpanded && !expandedItems.includes(node.path); + + return ( + 0} + collapsed={collapsed} + onCollapse={(collapse) => { + if (collapse) { + onCollapse(node.path); + } else { + onExpand(node.path); + } + }} + > + {childNodes.map((child) => ( + + ))} + + ); + }, + [isNodeHidden, nodeInfo, onCollapse, onExpand, expandedItems], + ); + + const { theme } = useTheme(); + + const unselectedNode = useCallback(() => { + onSelectedNodeIdChange?.("root"); + }, [onSelectedNodeIdChange]); + + useKeyPressEvent("Escape", unselectedNode); + + return ( + // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions +
    +
    + {!rootNodes && ( +
    +
    + +
    +
    + )} +
    + {rootNodes && ( + + {rootNodes.map((node) => ( + + ))} + + )} +
    +
    +
    + ); + }, +); diff --git a/apps/wing-console/console/ui/src/features/explorer-pane/use-map.bridge-connections.test.ts b/apps/wing-console/console/ui/src/features/explorer-pane/use-map.bridge-connections.test.ts new file mode 100644 index 00000000000..62b14b9708b --- /dev/null +++ b/apps/wing-console/console/ui/src/features/explorer-pane/use-map.bridge-connections.test.ts @@ -0,0 +1,427 @@ +/* eslint-disable unicorn/consistent-function-scoping */ +import { expect, test } from "vitest"; + +import type { + Connection, + GetNodeId, + IsNodeHidden, + GetConnectionId, +} from "./use-map.bridge-connections.js"; +import { bridgeConnections } from "./use-map.bridge-connections.js"; + +const isNodeHidden: IsNodeHidden = (path: string) => + path.startsWith("h"); +const getNodeId: GetNodeId = (path: string) => path; +const getConnectionId: GetConnectionId = (connection) => + `${connection.source}#${connection.target}`; +const resolveNode = (path: string) => { + const parts = path.split("/"); + for (const [index, part] of Object.entries(parts)) { + if (isNodeHidden(part)) { + return parts.slice(0, Number(index)).join("/"); + } + } + return path; +}; + +test("happy path", () => { + const connections: Connection[] = [ + { + source: "1", + target: "2", + }, + ]; + + expect( + bridgeConnections({ + connections, + isNodeHidden, + getNodeId, + getConnectionId, + resolveNode, + }), + ).toEqual([ + { + source: "1", + target: "2", + }, + ]); +}); + +test("creates one-level bridges", () => { + const connections: Connection[] = [ + { + source: "1", + target: "h2", + }, + { + source: "h2", + target: "3", + }, + ]; + + expect( + bridgeConnections({ + connections, + isNodeHidden, + getNodeId, + getConnectionId, + resolveNode, + }), + ).toEqual([ + { + source: "1", + target: "3", + }, + ]); +}); + +test("creates multi-level bridges", () => { + const connections: Connection[] = [ + { + source: "1", + target: "h2", + }, + { + source: "h2", + target: "h3", + }, + { + source: "h3", + target: "4", + }, + ]; + + expect( + bridgeConnections({ + connections, + isNodeHidden, + getNodeId, + getConnectionId, + resolveNode, + }), + ).toEqual([ + { + source: "1", + target: "4", + }, + ]); +}); + +test("creates graph bridges", () => { + const connections: Connection[] = [ + { + source: "1", + target: "h1", + }, + { + source: "2", + target: "h1", + }, + { + source: "h1", + target: "h2", + }, + { + source: "3", + target: "h2", + }, + { + source: "h2", + target: "h3", + }, + { + source: "h3", + target: "4", + }, + { + source: "h3", + target: "5", + }, + { + source: "h2", + target: "6", + }, + ]; + + expect( + bridgeConnections({ + connections, + isNodeHidden, + getNodeId, + getConnectionId, + resolveNode, + }), + ).toEqual([ + { + source: "1", + target: "4", + }, + { + source: "1", + target: "5", + }, + { + source: "1", + target: "6", + }, + { + source: "2", + target: "4", + }, + { + source: "2", + target: "5", + }, + { + source: "2", + target: "6", + }, + { + source: "3", + target: "4", + }, + { + source: "3", + target: "5", + }, + { + source: "3", + target: "6", + }, + ]); +}); + +test("handles cyclic graph correctly", () => { + const connections: Connection[] = [ + { + source: "1", + target: "2", + }, + { + source: "2", + target: "3", + }, + { + source: "3", + target: "1", + }, + ]; + + expect( + bridgeConnections({ + connections, + isNodeHidden, + getNodeId, + getConnectionId, + resolveNode, + }), + ).toEqual([ + { + source: "1", + target: "2", + }, + { + source: "2", + target: "3", + }, + { + source: "3", + target: "1", + }, + ]); +}); + +test("handles cyclic graph with hidden nodes correctly", () => { + const connections: Connection[] = [ + { + source: "1", + target: "h2", + }, + { + source: "h2", + target: "3", + }, + { + source: "3", + target: "1", + }, + ]; + + expect( + bridgeConnections({ + connections, + isNodeHidden, + getNodeId, + getConnectionId, + resolveNode, + }), + ).toEqual([ + { + source: "1", + target: "3", + }, + { + source: "3", + target: "1", + }, + ]); +}); + +test("handles hidden leafs correctly", () => { + const connections: Connection[] = [ + { + source: "h1", + target: "2", + }, + { + source: "2", + target: "h3", + }, + ]; + + expect( + bridgeConnections({ + connections, + isNodeHidden, + getNodeId, + getConnectionId, + resolveNode, + }), + ).toEqual([]); +}); + +test("handles hidden leafs correctly", () => { + const connections: Connection[] = [ + { + source: "h1", + target: "2", + }, + { + source: "2", + target: "h3", + }, + { + source: "h3", + target: "4", + }, + ]; + + expect( + bridgeConnections({ + connections, + isNodeHidden, + getNodeId, + getConnectionId, + resolveNode, + }), + ).toEqual([ + { + source: "2", + target: "4", + }, + ]); +}); + +test("handles hidden leafs correctly", () => { + const connections: Connection<{ path: string }>[] = [ + { + source: { path: "h1" }, + target: { path: "2" }, + }, + { + source: { path: "2" }, + target: { path: "h3" }, + }, + { + source: { path: "h3" }, + target: { path: "4" }, + }, + ]; + + expect( + bridgeConnections({ + connections, + isNodeHidden(node) { + return node.path.startsWith("h"); + }, + getNodeId(node) { + return node.path; + }, + getConnectionId(connection) { + return `${connection.source.path}#${connection.target.path}`; + }, + resolveNode(node) { + const path = resolveNode(node.path); + if (!path) { + return; + } + + return { + path, + }; + }, + }), + ).toEqual([ + { + source: { path: "2" }, + target: { path: "4" }, + }, + ]); +}); + +test("upcasts connections", () => { + const connections: Connection[] = [ + { + source: "1", + target: "2/h3", + }, + ]; + + expect( + bridgeConnections({ + connections, + isNodeHidden, + getNodeId, + getConnectionId, + resolveNode, + }), + ).toEqual([ + { + source: "1", + target: "2", + }, + ]); +}); + +test("upcasts connections", () => { + const connections: Connection[] = [ + { + source: "1", + target: "2/h4/5/h6", + }, + { + source: "2/h3", + target: "3", + }, + ]; + + expect( + bridgeConnections({ + connections, + isNodeHidden, + getNodeId, + getConnectionId, + resolveNode, + }), + ).toEqual([ + { + source: "1", + target: "2", + }, + { + source: "2", + target: "3", + }, + ]); +}); diff --git a/apps/wing-console/console/ui/src/features/explorer-pane/use-map.bridge-connections.ts b/apps/wing-console/console/ui/src/features/explorer-pane/use-map.bridge-connections.ts new file mode 100644 index 00000000000..ebcfcfd1448 --- /dev/null +++ b/apps/wing-console/console/ui/src/features/explorer-pane/use-map.bridge-connections.ts @@ -0,0 +1,112 @@ +import uniqby from "lodash.uniqby"; + +export type Connection = { + source: T; + target: T; +}; + +export type GetConnectionId = (connection: Connection) => string; +export type IsNodeHidden = (node: T) => boolean; +export type GetNodeId = (node: T) => string; +export type ResolveNode = (node: T) => T | undefined; + +const resolveConnections = ( + node: T, + type: "source" | "target", + allConnections: Connection[], + isNodeHidden: IsNodeHidden, + getNodeId: GetNodeId, + resolveNode: ResolveNode, +): T[] => { + const resolvedNode = resolveNode(node); + if (resolvedNode) { + return [resolvedNode]; + } + + const connections = allConnections.filter( + (c) => getNodeId(c[type]) === getNodeId(node), + ); + const invertedType = type === "source" ? "target" : "source"; + const nodes = uniqby( + connections.map((connection) => connection[invertedType]), + (node) => getNodeId(node), + ); + + return uniqby( + nodes.flatMap((node) => { + return resolveConnections( + node, + type, + allConnections, + isNodeHidden, + getNodeId, + resolveNode, + ); + }), + (node) => getNodeId(node), + ); +}; + +export interface BridgeConnectionsOptions { + /** + * A list of connections to bridge. + */ + connections: Connection[]; + + /** + * Returns whether a node is hidden. + */ + isNodeHidden: IsNodeHidden; + + /** + * Returns a unique identifier for a node. + */ + getNodeId: GetNodeId; + + /** + * Returns a unique identifier for a connection. + */ + getConnectionId: GetConnectionId; + + /** + * Allows resolving a node to a different one. + * + * This is useful when a node is hidden and should be replaced by another one (e.g. a parent node). + */ + resolveNode: ResolveNode; +} + +export const bridgeConnections = (options: BridgeConnectionsOptions) => { + return uniqby( + options.connections.flatMap((connection) => { + const sources = resolveConnections( + connection.source, + "target", + options.connections, + options.isNodeHidden, + options.getNodeId, + options.resolveNode, + ); + const targets = resolveConnections( + connection.target, + "source", + options.connections, + options.isNodeHidden, + options.getNodeId, + options.resolveNode, + ); + return uniqby( + sources.flatMap((source) => { + return targets.map((target) => { + return { + source, + target, + }; + }); + }), + options.getConnectionId, + ); + }), + options.getConnectionId, + ); +}; diff --git a/apps/wing-console/console/ui/src/features/explorer-pane/use-map.ts b/apps/wing-console/console/ui/src/features/explorer-pane/use-map.ts new file mode 100644 index 00000000000..4361945cb85 --- /dev/null +++ b/apps/wing-console/console/ui/src/features/explorer-pane/use-map.ts @@ -0,0 +1,399 @@ +import type { ConstructTreeNode } from "@winglang/sdk/lib/core/tree.js"; +import type { ConnectionData } from "@winglang/sdk/lib/simulator/index.js"; +import type { ElkExtendedEdge } from "elkjs"; +import uniqBy from "lodash.uniqby"; +import { useCallback, useEffect, useMemo } from "react"; + +import { trpc } from "../../trpc.js"; + +import { bridgeConnections } from "./use-map.bridge-connections.js"; + +export type NodeInflight = { + id: string; + name: string; + sourceOccupied?: boolean; + targetOccupied?: boolean; +}; + +export type NodeV2 = + | { + type: "container"; + children: string[]; + } + | { + type: "autoId"; + } + | { + type: "function"; + } + | { + type: "scheduler"; + } + | { + type: "endpoint"; + } + | { + type: "construct"; + inflights: NodeInflight[]; + }; + +const getNodeType = ( + node: ConstructTreeNode, + hasInflightConnections: boolean, +): NodeV2["type"] => { + if (node.constructInfo?.fqn === "@winglang/sdk.cloud.Function") { + return "function"; + } + if (node.constructInfo?.fqn === "@winglang/sdk.std.AutoIdResource") { + return "autoId"; + } + if (node.constructInfo?.fqn === "@winglang/sdk.cloud.Schedule") { + return "scheduler"; + } + if (node.constructInfo?.fqn === "@winglang/sdk.cloud.Endpoint") { + return "endpoint"; + } + + const hasVisibleChildren = Object.values(node.children ?? {}).some( + (child) => !child.display?.hidden, + ); + + if ( + node.constructInfo?.fqn === "@winglang/sdk.cloud.Api" || + hasInflightConnections || + !hasVisibleChildren + ) { + return "construct"; + } + + return "container"; +}; + +const getNodeInflights = ( + node: ConstructTreeNode, + connections: { + source: { id: string; operation: string | undefined }; + target: { id: string; operation: string | undefined }; + }[], +): NodeInflight[] => { + const inflights = new Array(); + for (const connection of connections.filter( + (connection) => connection.target.id === node.path, + )) { + const targetOp = connection.target.operation; + if (targetOp) { + inflights.push(targetOp); + } + } + for (const connection of connections.filter( + (connection) => connection.source.id === node.path, + )) { + const sourceOp = connection.source.operation; + if (sourceOp) { + inflights.push(sourceOp); + } + } + return uniqBy(inflights, (inflight) => inflight).map((connection) => ({ + id: `${node.path}#${connection}`, + name: connection, + sourceOccupied: connections.some( + (otherConnection) => + otherConnection.source.id === node.path && + otherConnection.source.operation === connection, + ), + targetOccupied: connections.some( + (otherConnection) => + otherConnection.target.id === node.path && + otherConnection.target.operation === connection, + ), + })); +}; + +export interface UseMapOptions { + expandedItems: string[]; +} + +export const useMap = ({ expandedItems }: UseMapOptions) => { + const query = trpc["app.map"].useQuery(); + const { tree: rawTree, connections: rawConnections } = query.data ?? {}; + + const nodeFqns = useMemo(() => { + if (!rawTree) { + return; + } + + const nodeTypes = new Map(); + const processNode = (node: ConstructTreeNode) => { + nodeTypes.set(node.path, node.constructInfo?.fqn); + for (const child of Object.values(node.children ?? {})) { + processNode(child); + } + }; + processNode(rawTree); + return nodeTypes; + }, [rawTree]); + + const nodeTypes = useMemo(() => { + if (!rawTree) { + return; + } + + const nodeTypes = new Map(); + const processNode = (node: ConstructTreeNode) => { + nodeTypes.set( + node.path, + getNodeType( + node, + rawConnections?.some( + (connection) => + connection.source === node.path || + connection.target === node.path, + ) ?? false, + ), + ); + for (const child of Object.values(node.children ?? {})) { + processNode(child); + } + }; + processNode(rawTree); + return nodeTypes; + }, [rawTree, rawConnections]); + + const hiddenMap = useMemo(() => { + const hiddenMap = new Map(); + const traverse = (node: ConstructTreeNode, forceHidden?: boolean) => { + const hidden = forceHidden || node.display?.hidden || false; + + hiddenMap.set(node.path, hidden); + + const children = Object.values(node.children ?? {}); + const canBeExpanded = + !!node.children && children.some((child) => !child.display?.hidden); + const collapsed = canBeExpanded && !expandedItems.includes(node.path); + + for (const child of children) { + traverse(child, hidden || collapsed); + } + }; + const pseudoRoot = rawTree?.children?.["Default"]; + for (const child of Object.values(pseudoRoot?.children ?? {})) { + traverse(child!); + } + return hiddenMap; + }, [rawTree, expandedItems]); + + const isNodeHidden = useCallback( + (path: string) => { + const nodePath = path.match(/^(.+?)#/)?.[1] ?? path; + return hiddenMap.get(nodePath) === true; + }, + [hiddenMap], + ); + + const resolveNodePath = useCallback( + (path: string) => { + const parts = path.split("/"); + for (const [index, part] of Object.entries(parts)) { + const path = parts.slice(0, Number(index) + 1).join("/"); + if (isNodeHidden(path)) { + return parts.slice(0, Number(index)).join("/"); + } + } + return path; + }, + [isNodeHidden], + ); + + const resolveNode = useCallback( + (path: string) => { + const nodePath = path.match(/^(.+?)#/)?.[1] ?? path; + return resolveNodePath(nodePath); + }, + [resolveNodePath], + ); + + const rootNodes = useMemo(() => { + if (!rawTree) { + return; + } + + const children = rawTree?.children?.["Default"]?.children; + return children ? (Object.values(children) as ConstructTreeNode[]) : []; + }, [rawTree]); + + const connections = useMemo(() => { + if (!rawConnections || !nodeFqns) { + return; + } + + return bridgeConnections({ + connections: + rawConnections + .map((connection) => { + // Convert invokeAsync to invoke, since they + // are the same to the map view. + return { + ...connection, + sourceOp: + connection.sourceOp === "invokeAsync" + ? "invoke" + : connection.sourceOp, + targetOp: + connection.targetOp === "invokeAsync" + ? "invoke" + : connection.targetOp, + }; + }) + .filter((connection) => { + return connection.source !== connection.target; + }) + .map((connection) => { + return { + source: { + id: connection.source, + nodeFqn: nodeFqns.get(connection.source), + operation: connection.sourceOp, + }, + target: { + id: connection.target, + nodeFqn: nodeFqns.get(connection.target), + operation: connection.targetOp, + }, + }; + }) ?? [], + isNodeHidden: (node) => isNodeHidden(node.id), + getNodeId: (node) => node.id, + getConnectionId: (connection) => + `${connection.source.id}#${connection.source.operation}##${connection.target.id}#${connection.target.operation}`, + resolveNode: (node) => { + const path = resolveNode(node.id); + return { + id: path, + nodeFqn: nodeFqns.get(path), + // Make the operation anonymous if the node is hidden. + operation: path === node.id ? node.operation : undefined, + }; + }, + }) + .filter((connection) => { + // Filter connections that go to parents. + return !connection.source.id.startsWith(`${connection.target.id}/`); + }) + .filter((connection) => { + // Filter connections that go to themselves. + return connection.source.id !== connection.target.id; + }); + }, [rawConnections, nodeFqns, isNodeHidden, resolveNode]); + + const getConnectionId = useCallback( + ( + nodePath: string, + nodeFqn: string | undefined, + operation: string | undefined, + type: "source" | "target", + ) => { + if (isNodeHidden(nodePath)) { + return nodePath; + } + + if (nodeFqn === "@winglang/sdk.cloud.Function") { + return `${nodePath}##${type}`; + } + + if (operation) { + return `${nodePath}#${operation}#${type}`; + } + + return `${nodePath}##${type}`; + }, + [isNodeHidden], + ); + + const edges = useMemo(() => { + return ( + connections?.map((connection) => { + const source = getConnectionId( + connection.source.id, + connection.source.nodeFqn, + connection.source.operation, + "source", + ); + const target = getConnectionId( + connection.target.id, + connection.target.nodeFqn, + connection.target.operation, + "target", + ); + return { + id: `${source}#${target}`, + sources: [source], + targets: [target], + }; + }) ?? [] + ); + }, [connections, getConnectionId]); + + const nodeInfo = useMemo(() => { + if (!rawTree || !rawConnections) { + return; + } + + const nodeMap = new Map(); + const processNode = (node: ConstructTreeNode) => { + const nodeType = getNodeType( + node, + connections?.some( + (connection) => + connection.source.id === node.path || + connection.target.id === node.path, + ) ?? false, + ); + const inflights = getNodeInflights(node, connections ?? []); + switch (nodeType) { + case "container": { + nodeMap.set(node.path, { + type: nodeType, + children: Object.values(node.children ?? {}).map((child) => { + return child.path; + }), + }); + break; + } + case "autoId": { + nodeMap.set(node.path, { + type: nodeType, + }); + break; + } + case "function": { + nodeMap.set(node.path, { + type: nodeType, + }); + break; + } + default: { + nodeMap.set(node.path, { + type: "construct", + inflights, + }); + } + } + for (const child of Object.values(node.children ?? {})) { + processNode(child); + } + }; + processNode(rawTree); + return nodeMap; + }, [rawTree, rawConnections, connections]); + + return { + rawTree, + rawConnections, + nodeInfo, + nodeTypes, + rootNodes, + connections, + isNodeHidden, + edges, + }; +}; diff --git a/apps/wing-console/console/ui/src/ui/zoom-pane.tsx b/apps/wing-console/console/ui/src/features/explorer-pane/zoom-pane.tsx similarity index 98% rename from apps/wing-console/console/ui/src/ui/zoom-pane.tsx rename to apps/wing-console/console/ui/src/features/explorer-pane/zoom-pane.tsx index a3001cb1b62..07abaa1185b 100644 --- a/apps/wing-console/console/ui/src/ui/zoom-pane.tsx +++ b/apps/wing-console/console/ui/src/features/explorer-pane/zoom-pane.tsx @@ -62,7 +62,7 @@ const boundingBoxOverlap = (a: BoundingBox, b: BoundingBox) => { ); }; const MIN_ZOOM_LEVEL = 0.125; -const MAX_ZOOM_LEVEL = 1; +const MAX_ZOOM_LEVEL = 1.5; const ZOOM_SENSITIVITY = 1.35; const MOVE_SENSITIVITY = 1.5; const WHEEL_SENSITIVITY = 0.01; @@ -146,7 +146,10 @@ export const ZoomPane = forwardRef((props, ref) => { } }); }, []); - useEvent("wheel", onWheel as (event: Event) => void, containerRef.current); + useEvent("wheel", onWheel as (event: Event) => void, containerRef.current, { + // Use passive: false to prevent the default behavior of scrolling the page. + passive: false, + }); const [isDragging, setDragging] = useState(false); @@ -392,8 +395,7 @@ export const ZoomPane = forwardRef((props, ref) => {
    -
    -
    +
    { return items.map((item) => { @@ -45,6 +47,7 @@ const createTreeMenuItemFromExplorerTreeItem = ( resourcePath={item.label} className="w-4 h-4" color={item.display?.color} + icon={item.display?.icon} /> ) : undefined, children: item.childItems?.map((item) => @@ -53,44 +56,29 @@ const createTreeMenuItemFromExplorerTreeItem = ( }; }; -export interface ExplorerProps { +export interface HierarchyProps { loading?: boolean; - items: TreeMenuItem[] | undefined; - selectedItemId: string | undefined; - expandedItems: string[]; "data-testid"?: string; - onSelectedItemsChange: (ids: string[]) => void; - onExpandedItemsChange: (ids: string[]) => void; - onExpandAll(): void; - onCollapseAll(): void; } -export const Explorer = memo((props: ExplorerProps) => { - const { - selectedItemId, - onSelectedItemsChange, - onExpandAll, - onCollapseAll, - expandedItems, - onExpandedItemsChange, - items, - } = props; +export const Hierarchy = memo((props: HierarchyProps) => { const { theme } = useTheme(); - const selectedItems = useMemo( - () => (selectedItemId ? [selectedItemId] : []), - [selectedItemId], - ); + const hierarchy = useHierarchy(); + const selectionContext = useSelectionContext(); return (
    - + - + @@ -106,14 +94,16 @@ export const Explorer = memo((props: ExplorerProps) => { )} >
    - {(!items || items.length === 0) && } + {hierarchy.items && hierarchy.items.length === 0 && ( + + )} - {items && renderTreeItems(items)} + {hierarchy.items && renderTreeItems(hierarchy.items)}
    diff --git a/apps/wing-console/console/ui/src/ui/no-resources.tsx b/apps/wing-console/console/ui/src/features/hierarchy-pane/no-resources.tsx similarity index 100% rename from apps/wing-console/console/ui/src/ui/no-resources.tsx rename to apps/wing-console/console/ui/src/features/hierarchy-pane/no-resources.tsx diff --git a/apps/wing-console/console/ui/src/services/use-explorer.tsx b/apps/wing-console/console/ui/src/features/hierarchy-pane/use-hierarchy.tsx similarity index 53% rename from apps/wing-console/console/ui/src/services/use-explorer.tsx rename to apps/wing-console/console/ui/src/features/hierarchy-pane/use-hierarchy.tsx index 5b7c1d9ed3b..70da3a18ad5 100644 --- a/apps/wing-console/console/ui/src/services/use-explorer.tsx +++ b/apps/wing-console/console/ui/src/features/hierarchy-pane/use-hierarchy.tsx @@ -1,11 +1,19 @@ import { ResourceIcon } from "@wingconsole/design-system"; import type { ExplorerItem } from "@wingconsole/server"; import { useCallback, useEffect, useState } from "react"; +import type { ReactNode } from "react"; -import type { TreeMenuItem } from "../ui/use-tree-menu-items.js"; -import { useTreeMenuItems } from "../ui/use-tree-menu-items.js"; +import { trpc } from "../../trpc.js"; +import { useSelectionContext } from "../selection-context/selection-context.js"; -import { trpc } from "./trpc.js"; +export interface TreeMenuItem { + id: string; + icon?: React.ReactNode; + label: string; + secondaryLabel?: string | ReactNode | ((item: TreeMenuItem) => ReactNode); + children?: TreeMenuItem[]; + expanded?: boolean; +} const createTreeMenuItemFromExplorerTreeItem = ( item: ExplorerItem, @@ -19,28 +27,25 @@ const createTreeMenuItemFromExplorerTreeItem = ( resourcePath={item.id} className="w-4 h-4" color={item.display?.color} + icon={item.display?.icon} /> ) : undefined, + expanded: item.display?.expanded, children: item.childItems?.map((item) => createTreeMenuItemFromExplorerTreeItem(item), ), }; }; -export const useExplorer = () => { - const { - items, - setItems, - selectedItems, - setSelectedItems, - expandedItems, - setExpandedItems, - expand, - expandAll, - collapseAll, - } = useTreeMenuItems({ - selectedItemIds: ["root"], - }); +export const useHierarchy = () => { + const [items, setItems] = useState(); + + const { setSelectedItems, setExpandedItems, setAvailableItems } = + useSelectionContext(); + + useEffect(() => { + setAvailableItems(items ?? []); + }, [items, setAvailableItems]); const tree = trpc["app.explorerTree"].useQuery(); @@ -48,14 +53,6 @@ export const useExplorer = () => { const nodeIds = trpc["app.nodeIds"].useQuery(); - const onSelectedItemsChange = useCallback( - (selectedItems: string[]) => { - setSelectedItems(selectedItems); - setSelectedNode(selectedItems[0] ?? ""); - }, - [setSelectedNode, setSelectedItems], - ); - useEffect(() => { if (!tree.data) { return; @@ -83,17 +80,26 @@ export const useExplorer = () => { }, [selectedNode, setSelectedItems]); useEffect(() => { - expandAll(); - }, [items, expandAll]); + const getExpandedNodes = (items: TreeMenuItem[]): string[] => { + let expandedNodes: string[] = []; + for (const item of items) { + if (item.expanded === true) { + expandedNodes.push(item.id); + } + if (item.children && item.children.length > 0) { + expandedNodes = [ + ...expandedNodes, + ...getExpandedNodes(item.children), + ]; + } + } + return expandedNodes; + }; + + setExpandedItems(getExpandedNodes(items ?? [])); + }, [items, setExpandedItems]); return { items, - selectedItems, - setSelectedItems: onSelectedItemsChange, - expandedItems, - setExpandedItems, - expand, - expandAll, - collapseAll, }; }; diff --git a/apps/wing-console/console/ui/src/features/inspector-pane/inspector.tsx b/apps/wing-console/console/ui/src/features/inspector-pane/inspector.tsx new file mode 100644 index 00000000000..881b15f1037 --- /dev/null +++ b/apps/wing-console/console/ui/src/features/inspector-pane/inspector.tsx @@ -0,0 +1,64 @@ +import { memo, useCallback, useContext } from "react"; + +import { trpc } from "../../trpc.js"; +import { useSelectionContext } from "../selection-context/selection-context.js"; +import { TestsContext } from "../tests-pane/tests-context.js"; + +import { EdgeMetadata } from "./resource-panes/edge-metadata.js"; +import { ResourceMetadata } from "./resource-panes/resource-metadata.js"; + +export const Inspector = memo(() => { + const { expand, setSelectedItems, selectedItems, selectedEdgeId } = + useSelectionContext(); + + const { showTests } = useContext(TestsContext); + + const metadata = trpc["app.nodeMetadata"].useQuery( + { + path: selectedItems[0], + showTests, + }, + { + enabled: selectedItems.length > 0, + }, + ); + + const edgeMetadata = trpc["app.edgeMetadata"].useQuery( + { + edgeId: selectedEdgeId || "", + }, + { + enabled: !!selectedEdgeId, + }, + ); + + const onConnectionNodeClick = useCallback( + (path: string) => { + expand(path); + setSelectedItems([path]); + }, + [expand, setSelectedItems], + ); + + return ( + <> + {metadata.data && ( + + )} + + {selectedEdgeId && edgeMetadata.data && ( + + )} + + ); +}); diff --git a/apps/wing-console/console/ui/src/features/api-interaction-view.tsx b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/api-interaction-view.tsx similarity index 82% rename from apps/wing-console/console/ui/src/features/api-interaction-view.tsx rename to apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/api-interaction-view.tsx index 43a625335f0..9b8171f1bb5 100644 --- a/apps/wing-console/console/ui/src/features/api-interaction-view.tsx +++ b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/api-interaction-view.tsx @@ -2,11 +2,12 @@ import type { OpenApiSpec } from "@wingconsole/server/src/wingsdk"; import { createPersistentState } from "@wingconsole/use-persistent-state"; import { memo, useCallback, useContext, useState } from "react"; -import { AppContext } from "../AppContext.js"; -import { trpc } from "../services/trpc.js"; -import { useApi } from "../services/use-api.js"; -import type { ApiResponse } from "../shared/api.js"; -import { ApiInteraction } from "../ui/api-interaction.js"; +import { AppContext } from "../../../AppContext.js"; +import { trpc } from "../../../trpc.js"; + +import { ApiInteraction } from "./api-interaction.js"; +import type { ApiResponse } from "./api.js"; +import { useApi } from "./use-api.js"; export interface ApiViewProps { resourcePath: string; diff --git a/apps/wing-console/console/ui/src/ui/api-interaction.tsx b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/api-interaction.tsx similarity index 99% rename from apps/wing-console/console/ui/src/ui/api-interaction.tsx rename to apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/api-interaction.tsx index d8fb4f87ba8..fb46bc59066 100644 --- a/apps/wing-console/console/ui/src/ui/api-interaction.tsx +++ b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/api-interaction.tsx @@ -15,8 +15,11 @@ import { createPersistentState } from "@wingconsole/use-persistent-state"; import classNames from "classnames"; import { memo, useCallback, useEffect, useId, useState } from "react"; -import type { AppMode } from "../AppContext.js"; -import type { ApiRequest, ApiResponse, ApiRoute } from "../shared/api.js"; +import type { AppMode } from "../../../AppContext.js"; + +import { ApiResponseBodyPanel } from "./api-response-body-panel.js"; +import { ApiResponseHeadersPanel } from "./api-response-headers-panel.js"; +import type { ApiRequest, ApiResponse, ApiRoute } from "./api.js"; import { getParametersFromOpenApi, getHeaderValues, @@ -24,10 +27,7 @@ import { HTTP_HEADERS, HTTP_METHODS, getRequestBodyFromOpenApi, -} from "../shared/api.js"; - -import { ApiResponseBodyPanel } from "./api-response-body-panel.js"; -import { ApiResponseHeadersPanel } from "./api-response-headers-panel.js"; +} from "./api.js"; export interface ApiInteractionProps { resourceId: string; diff --git a/apps/wing-console/console/ui/src/ui/api-response-body-panel.tsx b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/api-response-body-panel.tsx similarity index 96% rename from apps/wing-console/console/ui/src/ui/api-response-body-panel.tsx rename to apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/api-response-body-panel.tsx index 81464ee6719..0a3bc2b2404 100644 --- a/apps/wing-console/console/ui/src/ui/api-response-body-panel.tsx +++ b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/api-response-body-panel.tsx @@ -1,7 +1,7 @@ import { useTheme, JsonResponseInput } from "@wingconsole/design-system"; import classNames from "classnames"; -import type { ApiResponse } from "../shared/api.js"; +import type { ApiResponse } from "./api.js"; const getResponseColor: (status: number) => string = (status) => { if (status >= 200 && status < 300) { diff --git a/apps/wing-console/console/ui/src/ui/api-response-headers-panel.tsx b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/api-response-headers-panel.tsx similarity index 100% rename from apps/wing-console/console/ui/src/ui/api-response-headers-panel.tsx rename to apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/api-response-headers-panel.tsx diff --git a/apps/wing-console/console/ui/src/shared/api.ts b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/api.ts similarity index 100% rename from apps/wing-console/console/ui/src/shared/api.ts rename to apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/api.ts diff --git a/apps/wing-console/console/ui/src/features/bucket-interaction-view.tsx b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/bucket-interaction-view.tsx similarity index 81% rename from apps/wing-console/console/ui/src/features/bucket-interaction-view.tsx rename to apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/bucket-interaction-view.tsx index a72a80084f6..0bf11329cc6 100644 --- a/apps/wing-console/console/ui/src/features/bucket-interaction-view.tsx +++ b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/bucket-interaction-view.tsx @@ -1,9 +1,7 @@ -import type { TreeEntry } from "@wingconsole/design-system"; -import { memo, useCallback, useMemo, useState } from "react"; - -import { useBucket } from "../services/use-bucket.js"; +import { memo, useState } from "react"; import { FileBrowserView } from "./file-browser-view.js"; +import { useBucket } from "./use-bucket.js"; export interface BucketViewProps { resourcePath: string; diff --git a/apps/wing-console/console/ui/src/ui/bucket-metadata.tsx b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/bucket-metadata.tsx similarity index 100% rename from apps/wing-console/console/ui/src/ui/bucket-metadata.tsx rename to apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/bucket-metadata.tsx diff --git a/apps/wing-console/console/ui/src/features/counter-interaction-view.tsx b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/counter-interaction-view.tsx similarity index 80% rename from apps/wing-console/console/ui/src/features/counter-interaction-view.tsx rename to apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/counter-interaction-view.tsx index b7ee205e5af..acf656a69e4 100644 --- a/apps/wing-console/console/ui/src/features/counter-interaction-view.tsx +++ b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/counter-interaction-view.tsx @@ -1,7 +1,7 @@ import { memo } from "react"; -import { useCounter } from "../services/use-counter.js"; -import { CounterInteraction } from "../ui/counter-interaction.js"; +import { CounterInteraction } from "./counter-interaction.js"; +import { useCounter } from "./use-counter.js"; export interface CounterInteractionViewProps { resourcePath: string; diff --git a/apps/wing-console/console/ui/src/ui/counter-interaction.tsx b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/counter-interaction.tsx similarity index 100% rename from apps/wing-console/console/ui/src/ui/counter-interaction.tsx rename to apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/counter-interaction.tsx diff --git a/apps/wing-console/console/ui/src/ui/counter-metadata.tsx b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/counter-metadata.tsx similarity index 100% rename from apps/wing-console/console/ui/src/ui/counter-metadata.tsx rename to apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/counter-metadata.tsx diff --git a/apps/wing-console/console/ui/src/ui/custom-resource-file-browser.tsx b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/custom-resource-file-browser.tsx similarity index 93% rename from apps/wing-console/console/ui/src/ui/custom-resource-file-browser.tsx rename to apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/custom-resource-file-browser.tsx index d68ac88fb2a..8937b7e3c3a 100644 --- a/apps/wing-console/console/ui/src/ui/custom-resource-file-browser.tsx +++ b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/custom-resource-file-browser.tsx @@ -1,9 +1,10 @@ import { useCallback, useEffect, useMemo, useState } from "react"; -import { FileBrowserView } from "../features/file-browser-view.js"; -import { trpc } from "../services/trpc.js"; -import { useDownloadFile } from "../shared/use-download-file.js"; -import { useUploadFile } from "../shared/use-upload-file.js"; +import { trpc } from "../../../trpc.js"; +import { useDownloadFile } from "../../../use-download-file.js"; +import { useUploadFile } from "../../../use-upload-file.js"; + +import { FileBrowserView } from "./file-browser-view.js"; export interface CustomResourceFileBrowserProps { label: string; diff --git a/apps/wing-console/console/ui/src/ui/custom-resource-http-client.tsx b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/custom-resource-http-client.tsx similarity index 90% rename from apps/wing-console/console/ui/src/ui/custom-resource-http-client.tsx rename to apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/custom-resource-http-client.tsx index 3a6778b6a56..9ae0dbacb4e 100644 --- a/apps/wing-console/console/ui/src/ui/custom-resource-http-client.tsx +++ b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/custom-resource-http-client.tsx @@ -2,12 +2,12 @@ import { Attribute } from "@wingconsole/design-system"; import { createPersistentState } from "@wingconsole/use-persistent-state"; import { useContext } from "react"; -import { AppContext } from "../AppContext.js"; -import { trpc } from "../services/trpc.js"; -import { useApi } from "../services/use-api.js"; -import type { ApiResponse } from "../shared/api.js"; +import { AppContext } from "../../../AppContext.js"; +import { trpc } from "../../../trpc.js"; import { ApiInteraction } from "./api-interaction.js"; +import type { ApiResponse } from "./api.js"; +import { useApi } from "./use-api.js"; export interface CustomResourceHttpClientItemProps { label: string; diff --git a/apps/wing-console/console/ui/src/ui/custom-resource-item.tsx b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/custom-resource-item.tsx similarity index 100% rename from apps/wing-console/console/ui/src/ui/custom-resource-item.tsx rename to apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/custom-resource-item.tsx diff --git a/apps/wing-console/console/ui/src/ui/custom-resource-ui-button.tsx b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/custom-resource-ui-button.tsx similarity index 95% rename from apps/wing-console/console/ui/src/ui/custom-resource-ui-button.tsx rename to apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/custom-resource-ui-button.tsx index 3bdda793141..04ff1707092 100644 --- a/apps/wing-console/console/ui/src/ui/custom-resource-ui-button.tsx +++ b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/custom-resource-ui-button.tsx @@ -2,7 +2,7 @@ import { useTheme, Button } from "@wingconsole/design-system"; import classNames from "classnames"; import { useCallback, useId } from "react"; -import { trpc } from "../services/trpc.js"; +import { trpc } from "../../../trpc.js"; export interface CustomResourceUiButtomItemProps { label: string; diff --git a/apps/wing-console/console/ui/src/ui/custom-resource-ui-field.tsx b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/custom-resource-ui-field.tsx similarity index 94% rename from apps/wing-console/console/ui/src/ui/custom-resource-ui-field.tsx rename to apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/custom-resource-ui-field.tsx index 29114c8f899..211217894e6 100644 --- a/apps/wing-console/console/ui/src/ui/custom-resource-ui-field.tsx +++ b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/custom-resource-ui-field.tsx @@ -1,7 +1,7 @@ import { Attribute } from "@wingconsole/design-system"; import { Link } from "@wingconsole/design-system"; -import { trpc } from "../services/trpc.js"; +import { trpc } from "../../../trpc.js"; export interface CustomResourceUiFieldItemProps { label: string; diff --git a/apps/wing-console/console/ui/src/ui/edge-metadata.tsx b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/edge-metadata.tsx similarity index 96% rename from apps/wing-console/console/ui/src/ui/edge-metadata.tsx rename to apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/edge-metadata.tsx index eea537edac3..c64b3cdf881 100644 --- a/apps/wing-console/console/ui/src/ui/edge-metadata.tsx +++ b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/edge-metadata.tsx @@ -110,6 +110,7 @@ export const EdgeMetadata = ({ resourceType={source.type} resourcePath={source.path} color={source.display?.color} + icon={source.display?.icon} />
    {source.id}
    @@ -134,6 +135,7 @@ export const EdgeMetadata = ({ resourceType={target.type} resourcePath={target.path} color={target.display?.color} + icon={target.display?.icon} />
    {target.id}
    @@ -142,7 +144,7 @@ export const EdgeMetadata = ({
    - +
    file.id === currentFile)) { setCurrentFile(undefined); } - }, [fileEntries]); + }, [fileEntries, currentFile]); useEffect(() => { onCurrentFileChange(currentFile); diff --git a/apps/wing-console/console/ui/src/ui/file-browser.tsx b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/file-browser.tsx similarity index 98% rename from apps/wing-console/console/ui/src/ui/file-browser.tsx rename to apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/file-browser.tsx index 9574024cf47..2d4bc72596d 100644 --- a/apps/wing-console/console/ui/src/ui/file-browser.tsx +++ b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/file-browser.tsx @@ -18,7 +18,7 @@ import classNames from "classnames"; import type { FormEvent } from "react"; import { useCallback, useContext, useMemo, useRef, useState } from "react"; -import { LayoutContext, LayoutType } from "../layout/layout-provider.js"; +import { LayoutContext, LayoutType } from "../../layout/layout-provider.js"; export interface FileBrowserProps { selectedFiles: string[]; diff --git a/apps/wing-console/console/ui/src/features/function-interaction-view.tsx b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/function-interaction-view.tsx similarity index 78% rename from apps/wing-console/console/ui/src/features/function-interaction-view.tsx rename to apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/function-interaction-view.tsx index 2bba13d4ab6..b73910027f6 100644 --- a/apps/wing-console/console/ui/src/features/function-interaction-view.tsx +++ b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/function-interaction-view.tsx @@ -1,7 +1,7 @@ import { memo } from "react"; -import { useFunction } from "../services/use-function.js"; -import { FunctionInteraction } from "../ui/function-interaction.js"; +import { FunctionInteraction } from "./function-interaction.js"; +import { useFunction } from "./use-function.js"; export interface FunctionViewProps { resourcePath: string; diff --git a/apps/wing-console/console/ui/src/ui/function-interaction.tsx b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/function-interaction.tsx similarity index 100% rename from apps/wing-console/console/ui/src/ui/function-interaction.tsx rename to apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/function-interaction.tsx diff --git a/apps/wing-console/console/ui/src/ui/function-metadata.tsx b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/function-metadata.tsx similarity index 100% rename from apps/wing-console/console/ui/src/ui/function-metadata.tsx rename to apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/function-metadata.tsx diff --git a/apps/wing-console/console/ui/src/features/queue-interaction-view.tsx b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/queue-interaction-view.tsx similarity index 76% rename from apps/wing-console/console/ui/src/features/queue-interaction-view.tsx rename to apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/queue-interaction-view.tsx index e10ef18f9de..3185a394c1e 100644 --- a/apps/wing-console/console/ui/src/features/queue-interaction-view.tsx +++ b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/queue-interaction-view.tsx @@ -1,7 +1,7 @@ import { memo } from "react"; -import { useQueue } from "../services/use-queue.js"; -import { QueueInteraction } from "../ui/queue-interaction.js"; +import { QueueInteraction } from "./queue-interaction.js"; +import { useQueue } from "./use-queue.js"; export interface QueueViewProps { resourcePath: string; diff --git a/apps/wing-console/console/ui/src/ui/queue-interaction.tsx b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/queue-interaction.tsx similarity index 100% rename from apps/wing-console/console/ui/src/ui/queue-interaction.tsx rename to apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/queue-interaction.tsx diff --git a/apps/wing-console/console/ui/src/features/queue-metadata-view.tsx b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/queue-metadata-view.tsx similarity index 80% rename from apps/wing-console/console/ui/src/features/queue-metadata-view.tsx rename to apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/queue-metadata-view.tsx index ff16e303d18..c47736b5891 100644 --- a/apps/wing-console/console/ui/src/features/queue-metadata-view.tsx +++ b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/queue-metadata-view.tsx @@ -1,9 +1,9 @@ import { useNotifications } from "@wingconsole/design-system"; import { memo, useCallback } from "react"; -import { useQueue } from "../services/use-queue.js"; -import { QueueMetadata } from "../ui/queue-metadata.js"; -import type { MetadataNode } from "../ui/resource-metadata.js"; +import { QueueMetadata } from "./queue-metadata.js"; +import type { MetadataNode } from "./resource-metadata.js"; +import { useQueue } from "./use-queue.js"; export interface QueueMetadataProps { node: MetadataNode; diff --git a/apps/wing-console/console/ui/src/ui/queue-metadata.tsx b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/queue-metadata.tsx similarity index 100% rename from apps/wing-console/console/ui/src/ui/queue-metadata.tsx rename to apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/queue-metadata.tsx diff --git a/apps/wing-console/console/ui/src/features/redis-interaction-view.tsx b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/redis-interaction-view.tsx similarity index 94% rename from apps/wing-console/console/ui/src/features/redis-interaction-view.tsx rename to apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/redis-interaction-view.tsx index 6d6565dea87..778de91a833 100644 --- a/apps/wing-console/console/ui/src/features/redis-interaction-view.tsx +++ b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/redis-interaction-view.tsx @@ -1,9 +1,9 @@ import { createPersistentState } from "@wingconsole/use-persistent-state"; import { memo, useCallback } from "react"; -import { useRedis } from "../services/use-redis.js"; -import { useTerminalHistory } from "../shared/use-terminal-history.js"; -import { RedisInteraction } from "../ui/redis-interaction.js"; +import { RedisInteraction } from "./redis-interaction.js"; +import { useRedis } from "./use-redis.js"; +import { useTerminalHistory } from "./use-terminal-history.js"; export interface RedisViewProps { resourcePath: string; @@ -67,7 +67,6 @@ export const RedisInteractionView = memo(({ resourcePath }: RedisViewProps) => { }, [ execCommand, - open, updateCommandHistory, updateTerminalHistory, clearTerminalHistory, diff --git a/apps/wing-console/console/ui/src/ui/redis-interaction.tsx b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/redis-interaction.tsx similarity index 100% rename from apps/wing-console/console/ui/src/ui/redis-interaction.tsx rename to apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/redis-interaction.tsx diff --git a/apps/wing-console/console/ui/src/features/resource-interaction-view.tsx b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/resource-interaction-view.tsx similarity index 100% rename from apps/wing-console/console/ui/src/features/resource-interaction-view.tsx rename to apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/resource-interaction-view.tsx diff --git a/apps/wing-console/console/ui/src/ui/resource-metadata.tsx b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/resource-metadata.tsx similarity index 94% rename from apps/wing-console/console/ui/src/ui/resource-metadata.tsx rename to apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/resource-metadata.tsx index 35e79ee3214..48fe84d1e8d 100644 --- a/apps/wing-console/console/ui/src/ui/resource-metadata.tsx +++ b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/resource-metadata.tsx @@ -1,10 +1,5 @@ +import { CubeIcon } from "@heroicons/react/20/solid"; import { - CubeIcon, - ArrowLeftOnRectangleIcon, - ArrowRightOnRectangleIcon, -} from "@heroicons/react/20/solid"; -import { - ArrowPathRoundedSquareIcon, CubeTransparentIcon, CursorArrowRaysIcon, } from "@heroicons/react/24/outline"; @@ -22,14 +17,14 @@ import type { NodeDisplay } from "@wingconsole/server"; import classNames from "classnames"; import { memo, useCallback, useMemo, useState } from "react"; -import { QueueMetadataView } from "../features/queue-metadata-view.js"; -import { ResourceInteractionView } from "../features/resource-interaction-view.js"; -import { trpc } from "../services/trpc.js"; +import { trpc } from "../../../trpc.js"; import { BucketMetadata } from "./bucket-metadata.js"; import { CounterMetadata } from "./counter-metadata.js"; import { CustomResourceUiItem } from "./custom-resource-item.js"; import { FunctionMetadata } from "./function-metadata.js"; +import { QueueMetadataView } from "./queue-metadata-view.js"; +import { ResourceInteractionView } from "./resource-interaction-view.js"; import { ScheduleMetadata } from "./schedule-metadata.js"; interface AttributeGroup { @@ -75,20 +70,25 @@ export interface MetadataProps { } export const ResourceMetadata = memo( - ({ node, inbound, outbound, onConnectionNodeClick }: MetadataProps) => { + ({ node, inbound, outbound }: MetadataProps) => { const { theme } = useTheme(); const [openInspectorSections, setOpenInspectorSections] = useState(() => [ "resourceUI", "interact", "interact-actions", ]); - const { resourceGroup, connectionsGroups } = useMemo(() => { + + const icon = useMemo(() => { + return getResourceIconComponent(node.type, { + resourceId: node.id, + icon: node.display?.icon, + }); + }, [node]); + + const { resourceGroup } = useMemo(() => { const connectionsGroupsArray: ConnectionsGroup[] = []; let resourceGroup: AttributeGroup | undefined; if (node.props) { - const icon = getResourceIconComponent(node.type, { - resourceId: node.id, - }); switch (node.type) { case "@winglang/sdk.cloud.Function": { resourceGroup = { @@ -181,6 +181,7 @@ export const ResourceMetadata = memo( resourcePath={relationship.path} className="w-4 h-4" color={relationship.display?.color} + icon={relationship.display?.icon} /> ), })), @@ -199,6 +200,7 @@ export const ResourceMetadata = memo( resourcePath={relationship.path} className="w-4 h-4" color={relationship.display?.color} + icon={relationship.display?.icon} /> ), })), @@ -208,7 +210,7 @@ export const ResourceMetadata = memo( resourceGroup, connectionsGroups: connectionsGroupsArray, }; - }, [node, inbound, outbound]); + }, [node, inbound, outbound, icon]); const nodeLabel = useMemo(() => { const cloudResourceTypeName = node.type.split(".").at(-1) || ""; @@ -247,6 +249,7 @@ export const ResourceMetadata = memo( resourceType={node.type} resourcePath={node.path} color={node.display?.color} + icon={node.display?.icon} />
    @@ -259,7 +262,7 @@ export const ResourceMetadata = memo(
    {resourceUI.data && resourceUI.data.length > 0 && ( toggleInspectorSection("resourceUI")} @@ -384,7 +387,8 @@ export const ResourceMetadata = memo(
    - {connectionsGroups && connectionsGroups.length > 0 && ( + {/* Need to fix the relationships data. */} + {/* {connectionsGroups && connectionsGroups.length > 0 && ( - )} + )} */}
    diff --git a/apps/wing-console/console/ui/src/features/schedule-interaction-view.tsx b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/schedule-interaction-view.tsx similarity index 100% rename from apps/wing-console/console/ui/src/features/schedule-interaction-view.tsx rename to apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/schedule-interaction-view.tsx diff --git a/apps/wing-console/console/ui/src/ui/schedule-metadata.tsx b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/schedule-metadata.tsx similarity index 86% rename from apps/wing-console/console/ui/src/ui/schedule-metadata.tsx rename to apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/schedule-metadata.tsx index 15a0366c086..22db9b538aa 100644 --- a/apps/wing-console/console/ui/src/ui/schedule-metadata.tsx +++ b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/schedule-metadata.tsx @@ -1,7 +1,7 @@ import { useTheme, Attribute } from "@wingconsole/design-system"; import classNames from "classnames"; -import type { MetadataNode } from "./resource-metadata.js"; +import type { MetadataNode } from "../by-feature/inspector/resource-metadata.js"; export interface ScheduleMetadataProps { node: MetadataNode; diff --git a/apps/wing-console/console/ui/src/features/table-interaction-view.tsx b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/table-interaction-view.tsx similarity index 94% rename from apps/wing-console/console/ui/src/features/table-interaction-view.tsx rename to apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/table-interaction-view.tsx index 8761b6c6851..72ec8eb3f8d 100644 --- a/apps/wing-console/console/ui/src/features/table-interaction-view.tsx +++ b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/table-interaction-view.tsx @@ -1,9 +1,9 @@ import { useNotifications, Attribute } from "@wingconsole/design-system"; import { memo, useCallback, useEffect, useState } from "react"; -import { useTable } from "../services/use-table.js"; -import type { Row, RowData } from "../ui/table-interaction.js"; -import { TableInteraction } from "../ui/table-interaction.js"; +import type { Row, RowData } from "./table-interaction.js"; +import { TableInteraction } from "./table-interaction.js"; +import { useTable } from "./use-table.js"; export interface TableInteractionViewProps { resourcePath: string; diff --git a/apps/wing-console/console/ui/src/ui/table-interaction.stories.tsx b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/table-interaction.stories.tsx similarity index 100% rename from apps/wing-console/console/ui/src/ui/table-interaction.stories.tsx rename to apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/table-interaction.stories.tsx diff --git a/apps/wing-console/console/ui/src/ui/table-interaction.tsx b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/table-interaction.tsx similarity index 100% rename from apps/wing-console/console/ui/src/ui/table-interaction.tsx rename to apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/table-interaction.tsx diff --git a/apps/wing-console/console/ui/src/shared/terminal.ts b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/terminal.ts similarity index 100% rename from apps/wing-console/console/ui/src/shared/terminal.ts rename to apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/terminal.ts diff --git a/apps/wing-console/console/ui/src/features/topic-interaction-view.tsx b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/topic-interaction-view.tsx similarity index 86% rename from apps/wing-console/console/ui/src/features/topic-interaction-view.tsx rename to apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/topic-interaction-view.tsx index 73708bde83d..7e862ddd2b2 100644 --- a/apps/wing-console/console/ui/src/features/topic-interaction-view.tsx +++ b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/topic-interaction-view.tsx @@ -1,8 +1,8 @@ import { useNotifications } from "@wingconsole/design-system"; import { memo, useCallback } from "react"; -import { useTopic } from "../services/use-topic.js"; -import { TopicInteraction } from "../ui/topic-interaction.js"; +import { TopicInteraction } from "./topic-interaction.js"; +import { useTopic } from "./use-topic.js"; export interface TopicViewProps { resourcePath: string; diff --git a/apps/wing-console/console/ui/src/ui/topic-interaction.tsx b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/topic-interaction.tsx similarity index 100% rename from apps/wing-console/console/ui/src/ui/topic-interaction.tsx rename to apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/topic-interaction.tsx diff --git a/apps/wing-console/console/ui/src/features/unsupported-interaction-view.tsx b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/unsupported-interaction-view.tsx similarity index 79% rename from apps/wing-console/console/ui/src/features/unsupported-interaction-view.tsx rename to apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/unsupported-interaction-view.tsx index c4138f81bcf..348e535f2d0 100644 --- a/apps/wing-console/console/ui/src/features/unsupported-interaction-view.tsx +++ b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/unsupported-interaction-view.tsx @@ -1,6 +1,6 @@ import { memo } from "react"; -import { UnsupportedInteraction } from "../ui/unsupported-interaction.js"; +import { UnsupportedInteraction } from "./unsupported-interaction.js"; export interface UnsupportedInteractionViewProps { resourceType: string; diff --git a/apps/wing-console/console/ui/src/ui/unsupported-interaction.tsx b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/unsupported-interaction.tsx similarity index 100% rename from apps/wing-console/console/ui/src/ui/unsupported-interaction.tsx rename to apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/unsupported-interaction.tsx diff --git a/apps/wing-console/console/ui/src/services/use-api.ts b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/use-api.ts similarity index 90% rename from apps/wing-console/console/ui/src/services/use-api.ts rename to apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/use-api.ts index 7e70526a5c8..e8526ea276b 100644 --- a/apps/wing-console/console/ui/src/services/use-api.ts +++ b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/use-api.ts @@ -1,8 +1,8 @@ import { useCallback, useEffect, useMemo } from "react"; -import type { ApiRequest } from "../shared/api.js"; +import { trpc } from "../../../trpc.js"; -import { trpc } from "./trpc.js"; +import type { ApiRequest } from "./api.js"; export interface UseApiOptions { onFetchDataUpdate: (data: any) => void; diff --git a/apps/wing-console/console/ui/src/services/use-bucket.ts b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/use-bucket.ts similarity index 95% rename from apps/wing-console/console/ui/src/services/use-bucket.ts rename to apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/use-bucket.ts index 86a3c2b0c7b..4707b40976b 100644 --- a/apps/wing-console/console/ui/src/services/use-bucket.ts +++ b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/use-bucket.ts @@ -1,9 +1,8 @@ import { useCallback, useEffect, useMemo, useState } from "react"; -import { useDownloadFile } from "../shared/use-download-file.js"; -import { useUploadFile } from "../shared/use-upload-file.js"; - -import { trpc } from "./trpc.js"; +import { trpc } from "../../../trpc.js"; +import { useDownloadFile } from "../../../use-download-file.js"; +import { useUploadFile } from "../../../use-upload-file.js"; export interface UseBucketOptions { resourcePath: string; diff --git a/apps/wing-console/console/ui/src/services/use-counter.ts b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/use-counter.ts similarity index 96% rename from apps/wing-console/console/ui/src/services/use-counter.ts rename to apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/use-counter.ts index f19139df0ad..34fe3a177fd 100644 --- a/apps/wing-console/console/ui/src/services/use-counter.ts +++ b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/use-counter.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useState } from "react"; -import { trpc } from "./trpc.js"; +import { trpc } from "../../../trpc.js"; export interface UseCounterOptions { resourcePath: string; diff --git a/apps/wing-console/console/ui/src/services/use-endpoints.ts b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/use-endpoints.ts similarity index 90% rename from apps/wing-console/console/ui/src/services/use-endpoints.ts rename to apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/use-endpoints.ts index cd61979b2ba..cd0a5749eaf 100644 --- a/apps/wing-console/console/ui/src/services/use-endpoints.ts +++ b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/use-endpoints.ts @@ -1,8 +1,7 @@ import { useEffect, useState, useCallback } from "react"; -import type { EndpointItem } from "../shared/endpoint-item.js"; - -import { trpc } from "./trpc.js"; +import { trpc } from "../../../trpc.js"; +import type { EndpointItem } from "../../endpoints-pane/endpoint-item.js"; export const useEndpoints = () => { const [endpointList, setEndpointList] = useState([]); diff --git a/apps/wing-console/console/ui/src/services/use-function.ts b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/use-function.ts similarity index 94% rename from apps/wing-console/console/ui/src/services/use-function.ts rename to apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/use-function.ts index 8a006c814de..b4210760277 100644 --- a/apps/wing-console/console/ui/src/services/use-function.ts +++ b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/use-function.ts @@ -1,6 +1,6 @@ import { useMemo, useState } from "react"; -import { trpc } from "./trpc.js"; +import { trpc } from "../../../trpc.js"; export interface UseFunctionOptions { resourcePath: string; } diff --git a/apps/wing-console/console/ui/src/services/use-queue.ts b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/use-queue.ts similarity index 96% rename from apps/wing-console/console/ui/src/services/use-queue.ts rename to apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/use-queue.ts index e2775e776eb..fc278f82dfd 100644 --- a/apps/wing-console/console/ui/src/services/use-queue.ts +++ b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/use-queue.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from "react"; -import { trpc } from "./trpc.js"; +import { trpc } from "../../../trpc.js"; export interface UseQueueOptions { resourcePath: string; diff --git a/apps/wing-console/console/ui/src/services/use-redis.ts b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/use-redis.ts similarity index 94% rename from apps/wing-console/console/ui/src/services/use-redis.ts rename to apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/use-redis.ts index 74b6fdca7c5..d788ba29ef8 100644 --- a/apps/wing-console/console/ui/src/services/use-redis.ts +++ b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/use-redis.ts @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState } from "react"; -import { trpc } from "./trpc.js"; +import { trpc } from "../../../trpc.js"; export interface UseRedisOptions { resourcePath: string; diff --git a/apps/wing-console/console/ui/src/services/use-table.tsx b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/use-table.tsx similarity index 97% rename from apps/wing-console/console/ui/src/services/use-table.tsx rename to apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/use-table.tsx index 2f46c0734e9..aed43ac274d 100644 --- a/apps/wing-console/console/ui/src/services/use-table.tsx +++ b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/use-table.tsx @@ -1,6 +1,6 @@ import { useCallback, useMemo } from "react"; -import { trpc } from "./trpc.js"; +import { trpc } from "../../../trpc.js"; export interface UseTableOptions { resourcePath: string; diff --git a/apps/wing-console/console/ui/src/shared/use-terminal-history.ts b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/use-terminal-history.ts similarity index 100% rename from apps/wing-console/console/ui/src/shared/use-terminal-history.ts rename to apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/use-terminal-history.ts diff --git a/apps/wing-console/console/ui/src/services/use-topic.ts b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/use-topic.ts similarity index 89% rename from apps/wing-console/console/ui/src/services/use-topic.ts rename to apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/use-topic.ts index 78c78c0c1fa..1f8dfea8728 100644 --- a/apps/wing-console/console/ui/src/services/use-topic.ts +++ b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/use-topic.ts @@ -1,4 +1,4 @@ -import { trpc } from "./trpc.js"; +import { trpc } from "../../../trpc.js"; export interface UseTopicOptions { resourcePath: string; diff --git a/apps/wing-console/console/ui/src/services/use-website.ts b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/use-website.ts similarity index 90% rename from apps/wing-console/console/ui/src/services/use-website.ts rename to apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/use-website.ts index 43bc2ece78f..6a9941ea0fc 100644 --- a/apps/wing-console/console/ui/src/services/use-website.ts +++ b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/use-website.ts @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; -import { trpc } from "./trpc.js"; +import { trpc } from "../../../trpc.js"; export interface UseWebsiteOptions { resourcePath: string; diff --git a/apps/wing-console/console/ui/src/features/website-interaction-view.tsx b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/website-interaction-view.tsx similarity index 69% rename from apps/wing-console/console/ui/src/features/website-interaction-view.tsx rename to apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/website-interaction-view.tsx index 64b77b6b0ae..7249bc3ad35 100644 --- a/apps/wing-console/console/ui/src/features/website-interaction-view.tsx +++ b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/website-interaction-view.tsx @@ -1,8 +1,9 @@ import { memo, useContext } from "react"; -import { AppContext } from "../AppContext.js"; -import { useWebsite } from "../services/use-website.js"; -import { WebsiteInteraction } from "../ui/website-interaction.js"; +import { AppContext } from "../../../AppContext.js"; + +import { useWebsite } from "./use-website.js"; +import { WebsiteInteraction } from "./website-interaction.js"; export interface WebsiteInteractionViewProps { resourcePath: string; diff --git a/apps/wing-console/console/ui/src/ui/website-interaction.tsx b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/website-interaction.tsx similarity index 92% rename from apps/wing-console/console/ui/src/ui/website-interaction.tsx rename to apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/website-interaction.tsx index 3a0bfebcd07..27b6416f901 100644 --- a/apps/wing-console/console/ui/src/ui/website-interaction.tsx +++ b/apps/wing-console/console/ui/src/features/inspector-pane/resource-panes/website-interaction.tsx @@ -1,6 +1,6 @@ import { Attribute } from "@wingconsole/design-system"; -import type { AppMode } from "../AppContext.js"; +import type { AppMode } from "../../../AppContext.js"; export interface WebsiteInteractionProps { url: string; diff --git a/apps/wing-console/console/ui/src/features/layout/default-layout.tsx b/apps/wing-console/console/ui/src/features/layout/default-layout.tsx new file mode 100644 index 00000000000..ad75a3e5088 --- /dev/null +++ b/apps/wing-console/console/ui/src/features/layout/default-layout.tsx @@ -0,0 +1,379 @@ +import { + SpinnerLoader, + LeftResizableWidget, + RightResizableWidget, + TopResizableWidget, + USE_EXTERNAL_THEME_COLOR, + useTheme, +} from "@wingconsole/design-system"; +import type { State, LayoutConfig, LayoutComponent } from "@wingconsole/server"; +import { useLoading } from "@wingconsole/use-loading"; +import { PersistentStateProvider } from "@wingconsole/use-persistent-state"; +import classNames from "classnames"; +import { useCallback, useEffect, useMemo } from "react"; + +import { BlueScreenOfDeath } from "../blue-screen-of-death/blue-screen-of-death.js"; +import { EndpointsTreeView } from "../endpoints-pane/endpoints-tree-view.js"; +import { Explorer } from "../explorer-pane/explorer.js"; +import { Hierarchy } from "../hierarchy-pane/hierarchy.js"; +import { Inspector } from "../inspector-pane/inspector.js"; +import { LogsWidget } from "../logs-pane/logs.js"; +import { useSelectionContext } from "../selection-context/selection-context.js"; +import { SignInModal } from "../sign-in/sign-in.js"; +import { StatusBar } from "../status-bar/status-bar.js"; +import { TestsTreeView } from "../tests-pane/tests-tree-view.js"; +import { WebSocketState } from "../websocket-state/websocket-state.js"; + +import { useLayout } from "./use-layout.js"; + +export interface LayoutProps { + cloudAppState: State; + wingVersion: string | undefined; + layoutConfig?: LayoutConfig; +} + +const defaultLayoutConfig: LayoutConfig = { + leftPanel: { + components: [ + { + type: "explorer", + }, + { + type: "endpoints", + }, + { + type: "tests", + }, + ], + }, + bottomPanel: { + components: [ + { + type: "logs", + }, + ], + }, + statusBar: { + hide: false, + showThemeToggle: true, + }, + errorScreen: { + position: "default", + displayLinks: true, + }, + panels: { + rounded: false, + }, +}; + +export const DefaultLayout = ({ + cloudAppState, + wingVersion, + layoutConfig, +}: LayoutProps) => { + const { theme } = useTheme(); + + const { loading, showTests, onResourceClick, title } = useLayout({ + cloudAppState, + }); + + const { selectedItems, setSelectedItems } = useSelectionContext(); + + useEffect(() => { + document.title = title; + }, [title]); + + const { loading: deferredLoading, setLoading: setDeferredLoading } = + useLoading({ + delay: 800, + duration: 100, + }); + useEffect(() => { + setDeferredLoading(loading); + }, [loading, setDeferredLoading]); + + const layout: LayoutConfig = useMemo(() => { + return { + ...defaultLayoutConfig, + ...layoutConfig, + }; + }, [layoutConfig]); + + const selectedItemId = useMemo(() => selectedItems.at(0), [selectedItems]); + + const onTestsSelectedItemsChange = useCallback( + (items: string[]) => { + if (!showTests) { + return; + } + setSelectedItems(items); + }, + [showTests, setSelectedItems], + ); + + const renderLayoutComponent = useCallback( + (component: LayoutComponent) => { + switch (component.type) { + case "explorer": { + return ( +
    + +
    + ); + } + case "tests": { + return ( + + ); + } + case "logs": { + return ( +
    + +
    + ); + } + case "endpoints": { + return ; + } + } + }, + [ + loading, + selectedItemId, + onTestsSelectedItemsChange, + showTests, + theme.bg3, + onResourceClick, + ], + ); + + return ( + <> + + + +
    +
    + + {cloudAppState === "error" && + layout.errorScreen?.position === "default" && ( +
    + +
    + )} + +
    + {/* Middle panels */} + {cloudAppState !== "error" && ( + <> + {loading && ( +
    + )} + +
    + {!layout.leftPanel?.hide && + layout.leftPanel?.components?.length && ( + + {layout.leftPanel?.components.map( + (component: LayoutComponent, index: number) => { + const panelComponent = ( +
    0 && "h-full", + )} + > + {renderLayoutComponent(component)} +
    + ); + + if (index > 0) { + return ( + + {panelComponent} + + ); + } + return ( +
    + {panelComponent} +
    + ); + }, + )} +
    + )} + +
    +
    +
    + +
    + {!layout.rightPanel?.hide && ( + +
    + +
    +
    + )} +
    +
    +
    + + )} + + {/* Bottom panel */} + {!layout.bottomPanel?.hide && ( + + {layout.bottomPanel?.components?.map( + (component: LayoutComponent, index: number) => { + const panelComponent = ( +
    + {renderLayoutComponent(component)} +
    + ); + + if ( + layout.bottomPanel?.components?.length && + layout.bottomPanel.components.length > 1 && + index !== layout.bottomPanel.components.length - 1 + ) { + return ( + + {panelComponent} + + ); + } + return panelComponent; + }, + )} +
    + )} +
    + + {cloudAppState === "error" && + layout.errorScreen?.position === "bottom" && ( + <> +
    + +
    + + + +
    + + )} + + {/* Footer */} + {!layout.statusBar?.hide && ( +
    + +
    + )} + +
    +
    + + ); +}; diff --git a/apps/wing-console/console/ui/src/layout/layout-provider.tsx b/apps/wing-console/console/ui/src/features/layout/layout-provider.tsx similarity index 98% rename from apps/wing-console/console/ui/src/layout/layout-provider.tsx rename to apps/wing-console/console/ui/src/features/layout/layout-provider.tsx index 54e507309fc..f37c2be73ea 100644 --- a/apps/wing-console/console/ui/src/layout/layout-provider.tsx +++ b/apps/wing-console/console/ui/src/features/layout/layout-provider.tsx @@ -64,7 +64,6 @@ export function LayoutProvider({ }, errorScreen: { position: "bottom", - displayTitle: false, displayLinks: false, }, statusBar: { diff --git a/apps/wing-console/console/ui/src/features/layout/use-layout.ts b/apps/wing-console/console/ui/src/features/layout/use-layout.ts new file mode 100644 index 00000000000..d1b6b9733df --- /dev/null +++ b/apps/wing-console/console/ui/src/features/layout/use-layout.ts @@ -0,0 +1,54 @@ +import type { State } from "@wingconsole/server"; +import { useLoading } from "@wingconsole/use-loading"; +import { useEffect, useContext, useMemo, useCallback } from "react"; + +import { trpc } from "../../trpc.js"; +import { useSelectionContext } from "../selection-context/selection-context.js"; +import { TestsContext } from "../tests-pane/tests-context.js"; + +export interface UseLayoutProps { + cloudAppState: State; +} + +export const useLayout = ({ cloudAppState }: UseLayoutProps) => { + const { setSelectedItems } = useSelectionContext(); + + const { showTests } = useContext(TestsContext); + + const wingfileQuery = trpc["app.wingfile"].useQuery(); + const wingfile = useMemo(() => { + return wingfileQuery.data; + }, [wingfileQuery.data]); + const title = useMemo(() => { + if (!wingfile) { + return "Wing Console"; + } + return `${wingfile} - Wing Console`; + }, [wingfile]); + + const { loading, setLoading } = useLoading({ + duration: 400, + }); + + useEffect(() => { + setLoading( + cloudAppState === "loadingSimulator" || cloudAppState === "compiling", + ), + [cloudAppState]; + }); + + const onResourceClick = useCallback( + (path: string) => { + setSelectedItems([path]); + }, + [setSelectedItems], + ); + + return { + loading, + showTests, + onResourceClick, + title, + wingfile, + }; +}; diff --git a/apps/wing-console/console/ui/src/features/logs-pane/console-logs-filters.tsx b/apps/wing-console/console/ui/src/features/logs-pane/console-logs-filters.tsx new file mode 100644 index 00000000000..d6d25a6abaa --- /dev/null +++ b/apps/wing-console/console/ui/src/features/logs-pane/console-logs-filters.tsx @@ -0,0 +1,329 @@ +import { + ExclamationTriangleIcon, + MagnifyingGlassIcon, + NoSymbolIcon, +} from "@heroicons/react/24/outline"; +import { + Button, + getResourceIconComponent, + Input, + Listbox, + useTheme, +} from "@wingconsole/design-system"; +import type { LogLevel } from "@wingconsole/server"; +import classNames from "classnames"; +import debounce from "lodash.debounce"; +import uniqby from "lodash.uniqby"; +import { memo, useCallback, useEffect, useMemo, useState } from "react"; + +export const LOG_LEVELS: LogLevel[] = ["verbose", "info", "warn", "error"]; + +const logLevelNames = { + verbose: "Verbose", + info: "Info", + warn: "Warnings", + error: "Errors", +} as const; + +interface Resource { + id: string; + type?: string; +} + +export interface ConsoleLogsFiltersProps { + selectedLogTypeFilters: LogLevel[]; + setSelectedLogTypeFilters: (types: LogLevel[]) => void; + clearLogs: () => void; + onSearch: (search: string) => void; + resources?: Resource[]; + selectedResourceIds: string[]; + setSelectedResourceIds: (ids: string[]) => void; + selectedResourceTypes: string[]; + setSelectedResourceTypes: (types: string[]) => void; + onResetFilters: () => void; + shownLogs: number; + hiddenLogs: number; +} + +const getResourceIdLabel = (id: string) => id; + +const getResourceTypeLabel = (type?: string) => + type?.replaceAll("@winglang/", "") ?? ""; + +export const ConsoleLogsFilters = memo( + ({ + selectedLogTypeFilters, + setSelectedLogTypeFilters, + clearLogs, + onSearch, + resources, + selectedResourceIds, + setSelectedResourceIds, + selectedResourceTypes, + setSelectedResourceTypes, + onResetFilters, + shownLogs, + hiddenLogs, + }: ConsoleLogsFiltersProps) => { + const { theme } = useTheme(); + + const [searchText, setSearchText] = useState(""); + + // eslint-disable-next-line react-hooks/exhaustive-deps + const debouncedOnSearch = useCallback(debounce(onSearch, 300), [onSearch]); + useEffect(() => { + debouncedOnSearch(searchText); + }, [debouncedOnSearch, searchText]); + + const [defaultLogTypeSelection] = useState(selectedLogTypeFilters.sort()); + const resetFiltersDisabled = useMemo(() => { + return ( + selectedLogTypeFilters === defaultLogTypeSelection && + selectedResourceIds.length === 0 && + selectedResourceTypes.length === 0 + ); + }, [ + defaultLogTypeSelection, + selectedLogTypeFilters, + selectedResourceIds, + selectedResourceTypes, + ]); + + const renderResourceIdsLabel = useCallback( + (selected?: string[]) => { + if (!selected || selected.length === 0) { + return All resources; + } + + const type = selected[0] as string; + const Icon = getResourceIconComponent(type); + return ( + + + {getResourceIdLabel(type)} + {selected.length > 1 && ( + + {" "} + and {selected.length - 1} more + + )} + + ); + }, + [theme.text2], + ); + + const renderResourceTypesLabel = useCallback((selected?: string[]) => { + if (!selected || selected.length === 0) { + return All types; + } + + const type = selected[0] as string; + const Icon = getResourceIconComponent(selected[0]); + return ( + + + {getResourceTypeLabel(type)} + {selected.length > 1 && ( + and {selected.length - 1} more + )} + + ); + }, []); + + const resourceIdItems = useMemo(() => { + if (!resources) { + return []; + } + let filteredResources = resources; + + // filter resources by selected types + if (selectedResourceTypes.length > 0) { + filteredResources = resources.filter((resource) => { + return ( + selectedResourceIds.includes(resource.id) || + selectedResourceTypes.includes(resource.type ?? "") + ); + }); + } + + return filteredResources.map((resource) => ({ + label: getResourceIdLabel(resource.id), + value: resource.id, + icon: getResourceIconComponent(resource.type), + })); + }, [resources, selectedResourceTypes, selectedResourceIds]); + + const resourceTypeItems = useMemo(() => { + if (!resources) { + return []; + } + const resourceTypes = uniqby( + resources + .sort((a, b) => a.type?.localeCompare(b.type ?? "") ?? 0) + .filter((resource) => resource.type !== undefined), + (resource) => resource.type, + ); + + return resourceTypes.map((resource) => ({ + label: getResourceTypeLabel(resource.type), + value: resource.type ?? "", + icon: getResourceIconComponent(resource.type), + })); + }, [resources]); + + const logTypeLabel = useMemo(() => { + if (selectedLogTypeFilters.length === resourceTypeItems.length) { + return "All levels"; + } else if ( + selectedLogTypeFilters.sort().toString() === + defaultLogTypeSelection.sort().toString() + ) { + return "Default levels"; + } else if (selectedLogTypeFilters.length === 0) { + return "Hide all"; + } else if ( + selectedLogTypeFilters.length === 1 && + selectedLogTypeFilters[0] + ) { + return `${logLevelNames[selectedLogTypeFilters[0]]} only`; + } else { + return "Custom levels"; + } + }, [resourceTypeItems, selectedLogTypeFilters, defaultLogTypeSelection]); + + const showIncompatibleResourceTypeWarning = useMemo(() => { + if (!resources || selectedResourceTypes.length === 0) { + return false; + } + return selectedResourceIds.some((id) => { + const resource = resources?.find((r) => r.id === id); + return resource && !selectedResourceTypes.includes(resource.type ?? ""); + }); + }, [resources, selectedResourceIds, selectedResourceTypes]); + + const showAllLogsHiddenWarning = useMemo(() => { + return shownLogs === 0 && hiddenLogs > 0; + }, [shownLogs, hiddenLogs]); + + return ( +
    +
    + +
    + + {(showIncompatibleResourceTypeWarning || showAllLogsHiddenWarning) && ( +
    +
    + {showIncompatibleResourceTypeWarning && ( + + + The selected resource Ids and resource type filters are + incompatible. + + )} + {!showIncompatibleResourceTypeWarning && + showAllLogsHiddenWarning && ( + <> + + All logs entries are hidden by the current filters. + + + ({hiddenLogs} hidden{" "} + {hiddenLogs > 1 ? "entries" : "entry"}) + + + )} +
    + + +
    + )} +
    + ); + }, +); diff --git a/apps/wing-console/console/ui/src/features/console-logs.tsx b/apps/wing-console/console/ui/src/features/logs-pane/console-logs.tsx similarity index 90% rename from apps/wing-console/console/ui/src/features/console-logs.tsx rename to apps/wing-console/console/ui/src/features/logs-pane/console-logs.tsx index cce032aa5ea..3c6be8f0e5c 100644 --- a/apps/wing-console/console/ui/src/features/console-logs.tsx +++ b/apps/wing-console/console/ui/src/features/logs-pane/console-logs.tsx @@ -10,8 +10,6 @@ import Linkify from "linkify-react"; import throttle from "lodash.throttle"; import { Fragment, memo, useEffect, useMemo, useRef, useState } from "react"; -import { OpenFileInEditorButton } from "../shared/use-file-link.js"; - const dateTimeFormat = new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit", @@ -30,6 +28,14 @@ function logText(log: LogEntry, expanded: boolean) { return expanded ? log.message : log.message?.split("\n")?.[0]; } +function nodePathBaseName(nodePath: string) { + return nodePath.split("/").at(-1); +} + +function nodePathParentName(nodePath: string) { + return nodePath.split("/").slice(2, -1).join("/"); +} + const LogEntryRow = memo( ({ log, showIcons = true, onRowClick, onResourceClick }: LogEntryProps) => { const { theme } = useTheme(); @@ -67,6 +73,16 @@ const LogEntryRow = memo( [expanded], ); + const resourceName = useMemo( + () => log.ctx?.label || nodePathBaseName(log.ctx?.sourcePath ?? ""), + [log.ctx?.label, log.ctx?.sourcePath], + ); + + const parentName = useMemo( + () => nodePathParentName(log.ctx?.sourcePath ?? ""), + [log.ctx?.sourcePath], + ); + return ( {/*TODO: Fix a11y*/} @@ -169,7 +185,10 @@ const LogEntryRow = memo(
    {onResourceClick && ( -
    +
    + {parentName && ( + {parentName}/ + )} {log.ctx?.sourceType && ( - {log.ctx?.label || log.ctx?.sourcePath} + {resourceName}
    )} @@ -202,6 +221,7 @@ export interface ConsoleLogsProps { onRowClick?: (log: LogEntry) => void; onResourceClick?: (log: LogEntry) => void; showIcons?: boolean; + hiddenLogs: number; } export const ConsoleLogs = memo( @@ -210,6 +230,7 @@ export const ConsoleLogs = memo( onRowClick, onResourceClick, showIcons = true, + hiddenLogs, }: ConsoleLogsProps) => { const { theme } = useTheme(); @@ -227,7 +248,7 @@ export const ConsoleLogs = memo( showIcons={showIcons} /> ))} - {logs.length === 0 && ( + {logs.length === 0 && hiddenLogs === 0 && (
    No logs
    diff --git a/apps/wing-console/console/ui/src/widgets/logs.tsx b/apps/wing-console/console/ui/src/features/logs-pane/logs.tsx similarity index 68% rename from apps/wing-console/console/ui/src/widgets/logs.tsx rename to apps/wing-console/console/ui/src/features/logs-pane/logs.tsx index d1feff7584f..48bd981062c 100644 --- a/apps/wing-console/console/ui/src/widgets/logs.tsx +++ b/apps/wing-console/console/ui/src/features/logs-pane/logs.tsx @@ -7,9 +7,10 @@ import type { LogEntry, LogLevel } from "@wingconsole/server"; import classNames from "classnames"; import { useState, useRef, useEffect, useCallback, memo } from "react"; -import { ConsoleLogsFilters } from "../features/console-logs-filters.js"; -import { ConsoleLogs } from "../features/console-logs.js"; -import { trpc } from "../services/trpc.js"; +import { trpc } from "../../trpc.js"; + +import { ConsoleLogsFilters } from "./console-logs-filters.js"; +import { ConsoleLogs } from "./console-logs.js"; const DEFAULT_LOG_LEVELS: LogLevel[] = ["info", "warn", "error"]; @@ -25,8 +26,15 @@ export const LogsWidget = memo(({ onResourceClick }: LogsWidgetProps) => { ); const [searchText, setSearchText] = useState(""); + const [selectedResourceIds, setSelectedResourceIds] = useState([]); + const [selectedResourceTypes, setSelectedResourceTypes] = useState( + [], + ); + const [logsTimeFilter, setLogsTimeFilter] = useState(0); + const filters = trpc["app.logsFilters"].useQuery(); + const logs = trpc["app.logs"].useQuery( { filters: { @@ -38,6 +46,8 @@ export const LogsWidget = memo(({ onResourceClick }: LogsWidgetProps) => { }, text: searchText, timestamp: logsTimeFilter, + resourceIds: selectedResourceIds, + resourceTypes: selectedResourceTypes, }, }, { @@ -80,15 +90,30 @@ export const LogsWidget = memo(({ onResourceClick }: LogsWidgetProps) => { const clearLogs = useCallback(() => setLogsTimeFilter(Date.now()), []); + const resetFilters = useCallback(() => { + setSelectedLogTypeFilters(DEFAULT_LOG_LEVELS); + setSelectedResourceIds([]); + setSelectedResourceTypes([]); + setSearchText(""); + }, []); + return (
    +
    { )} onScrolledToBottomChange={setScrolledToBottom} > - +
    diff --git a/apps/wing-console/console/ui/src/features/map-view.tsx b/apps/wing-console/console/ui/src/features/map-view.tsx deleted file mode 100644 index 05ca19533bc..00000000000 --- a/apps/wing-console/console/ui/src/features/map-view.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import { - useTheme, - ResourceIcon, - SpinnerLoader, -} from "@wingconsole/design-system"; -import type { MapNode } from "@wingconsole/server"; -import classNames from "classnames"; -import { memo } from "react"; - -import { useMap } from "../services/use-map.js"; -import { ContainerNode } from "../ui/elk-map-nodes.js"; -import { ElkMap } from "../ui/elk-map.js"; - -export interface MapViewProps { - selectedNodeId?: string; - showTests?: boolean; - onSelectedNodeIdChange?: (id: string | undefined) => void; - selectedEdgeId?: string; - onSelectedEdgeIdChange?: (id: string | undefined) => void; -} - -const Node = memo( - ({ - node, - depth, - selected, - fade, - }: { - node: MapNode; - depth: number; - selected: boolean; - fade: boolean; - }) => { - return ( -
    - 0} - selected={selected} - resourceType={node.data?.type} - fade={fade} - icon={(props) => ( - - )} - depth={depth} - /> -
    - ); - }, -); - -export const MapView = memo( - ({ - showTests, - selectedNodeId, - onSelectedNodeIdChange, - selectedEdgeId, - onSelectedEdgeIdChange, - }: MapViewProps) => { - const { mapData } = useMap({ showTests: showTests ?? false }); - const { theme } = useTheme(); - - return ( -
    -
    - {!mapData && ( -
    -
    - -
    -
    - )} - -
    - -
    -
    -
    - ); - }, -); diff --git a/apps/wing-console/console/ui/src/features/selection-context/selection-context.tsx b/apps/wing-console/console/ui/src/features/selection-context/selection-context.tsx new file mode 100644 index 00000000000..c50436ceaab --- /dev/null +++ b/apps/wing-console/console/ui/src/features/selection-context/selection-context.tsx @@ -0,0 +1,131 @@ +import type { FunctionComponent, PropsWithChildren } from "react"; +import { + createContext, + useCallback, + useContext, + useEffect, + useState, +} from "react"; + +export interface SelectionItem { + id: string; + children?: SelectionItem[]; +} + +const Context = createContext({ + setAvailableItems(items: SelectionItem[]) {}, + selectedItems: new Array(), + setSelectedItems(items: string[]) {}, + expandedItems: new Array(), + setExpandedItems(items: string[]) {}, + toggle(itemId: string) {}, + expandAll() {}, + collapseAll() {}, + expand(itemId: string) {}, + collapse(itemId: string) {}, + selectedEdgeId: undefined as string | undefined, + setSelectedEdgeId(edgeId: string | undefined) {}, +}); + +export const SelectionContextProvider: FunctionComponent = ( + props, +) => { + const [availableItems, setAvailableItems] = useState( + () => new Array(), + ); + + const [selectedItems, setSelectedItems] = useState(() => ["root"]); + const [expandedItems, setExpandedItems] = useState(() => new Array()); + + const toggle = useCallback((itemId: string) => { + setExpandedItems(([...openedMenuItems]) => { + const index = openedMenuItems.indexOf(itemId); + if (index !== -1) { + openedMenuItems.splice(index, 1); + return openedMenuItems; + } + + openedMenuItems.push(itemId); + return openedMenuItems; + }); + }, []); + + const expandAll = useCallback(() => { + const itemIds = []; + const stack = [...availableItems]; + while (stack.length > 0) { + const item = stack.pop(); + if (!item) { + continue; + } + + itemIds.push(item.id); + + if (item.children) { + stack.push(...item.children); + } + } + + setExpandedItems(itemIds); + }, [availableItems]); + + const collapseAll = useCallback(() => { + setExpandedItems([]); + }, []); + + const expand = useCallback((itemId: string) => { + setExpandedItems(([...openedMenuItems]) => { + const index = openedMenuItems.indexOf(itemId); + if (index !== -1) { + return openedMenuItems; + } + + openedMenuItems.push(itemId); + return openedMenuItems; + }); + }, []); + + const collapse = useCallback((itemId: string) => { + setExpandedItems(([...openedMenuItems]) => { + const index = openedMenuItems.indexOf(itemId); + if (index === -1) { + return openedMenuItems; + } + + openedMenuItems.splice(index, 1); + return openedMenuItems; + }); + }, []); + + const [selectedEdgeId, setSelectedEdgeId] = useState(); + + return ( + { + setSelectedEdgeId(undefined); + setSelectedItems(selectedItems); + }, []), + expandedItems, + setExpandedItems, + toggle, + expandAll, + collapseAll, + expand, + collapse, + selectedEdgeId, + setSelectedEdgeId: useCallback((edgeId) => { + setSelectedItems([]); + setSelectedEdgeId(edgeId); + }, []), + }} + {...props} + /> + ); +}; + +export const useSelectionContext = () => { + return useContext(Context); +}; diff --git a/apps/wing-console/console/ui/src/layout/github-icon.tsx b/apps/wing-console/console/ui/src/features/sign-in/github-icon.tsx similarity index 100% rename from apps/wing-console/console/ui/src/layout/github-icon.tsx rename to apps/wing-console/console/ui/src/features/sign-in/github-icon.tsx diff --git a/apps/wing-console/console/ui/src/layout/google-icon.tsx b/apps/wing-console/console/ui/src/features/sign-in/google-icon.tsx similarity index 100% rename from apps/wing-console/console/ui/src/layout/google-icon.tsx rename to apps/wing-console/console/ui/src/features/sign-in/google-icon.tsx diff --git a/apps/wing-console/console/ui/src/layout/sign-in.tsx b/apps/wing-console/console/ui/src/features/sign-in/sign-in.tsx similarity index 95% rename from apps/wing-console/console/ui/src/layout/sign-in.tsx rename to apps/wing-console/console/ui/src/features/sign-in/sign-in.tsx index 0995a59e2cf..1a588cd1226 100644 --- a/apps/wing-console/console/ui/src/layout/sign-in.tsx +++ b/apps/wing-console/console/ui/src/features/sign-in/sign-in.tsx @@ -9,8 +9,8 @@ import classNames from "classnames"; import { useCallback, useContext, useEffect, useMemo, useState } from "react"; import { useSearchParam } from "react-use"; -import { AppContext } from "../AppContext.js"; -import { trpc } from "../services/trpc.js"; +import { AppContext } from "../../AppContext.js"; +import { trpc } from "../../trpc.js"; import { GithubIcon } from "./github-icon.js"; import { GoogleIcon } from "./google-icon.js"; @@ -133,7 +133,10 @@ export const SignInModal = (props: SignInModalProps) => { return ( -
    +

    { setGithubIsLoading(true); void signInWithGithub(); }} + dataTestid="signin-github-button" > {githubIsLoading ? ( @@ -171,6 +175,7 @@ export const SignInModal = (props: SignInModalProps) => { setGoogleIsLoading(true); void signInWithGoogle(); }} + dataTestid="signin-google-button" > {googleIsLoading ? ( diff --git a/apps/wing-console/console/ui/src/features/status-bar/discord-button.tsx b/apps/wing-console/console/ui/src/features/status-bar/discord-button.tsx new file mode 100644 index 00000000000..58069b8ef0c --- /dev/null +++ b/apps/wing-console/console/ui/src/features/status-bar/discord-button.tsx @@ -0,0 +1,30 @@ +import { useTheme } from "@wingconsole/design-system"; +import classNames from "classnames"; + +import { DiscordIcon } from "./discord-icon.js"; + +const WING_DISCORD_URL = "https://t.winglang.io/discord"; + +const openDiscordLink = () => { + open(WING_DISCORD_URL, "_blank"); +}; + +export const DiscordButton = () => { + const { theme } = useTheme(); + + return ( + + ); +}; diff --git a/apps/wing-console/console/ui/src/features/status-bar/discord-icon.tsx b/apps/wing-console/console/ui/src/features/status-bar/discord-icon.tsx new file mode 100644 index 00000000000..e6fc7914852 --- /dev/null +++ b/apps/wing-console/console/ui/src/features/status-bar/discord-icon.tsx @@ -0,0 +1,28 @@ +import { forwardRef } from "react"; + +type DiscordIconProps = React.PropsWithoutRef> & { + title?: string; + titleId?: string; +} & React.RefAttributes; + +export const DiscordIcon = forwardRef( + ({ title, titleId, ...props }, svgRef) => { + return ( + + {title && {title}} + + + + ); + }, +); diff --git a/apps/wing-console/console/ui/src/features/status-bar/reset-button.tsx b/apps/wing-console/console/ui/src/features/status-bar/reset-button.tsx new file mode 100644 index 00000000000..38738a78689 --- /dev/null +++ b/apps/wing-console/console/ui/src/features/status-bar/reset-button.tsx @@ -0,0 +1,59 @@ +import { ArrowPathIcon } from "@heroicons/react/24/outline"; +import { Button, Modal, useTheme } from "@wingconsole/design-system"; +import classNames from "classnames"; +import { useCallback, useState } from "react"; + +import { trpc } from "../../trpc.js"; + +export const ResetButton = ({ disabled }: { disabled?: boolean }) => { + const { theme } = useTheme(); + + const resetMutation = trpc["app.reset"].useMutation(); + const [showRestartModal, setShowRestartModal] = useState(false); + + const restart = useCallback(async () => { + setShowRestartModal(false); + await resetMutation.mutateAsync(); + }, [resetMutation]); + + return ( + <> + + + +
    +

    + Reset Application +

    +

    + Are you sure you want to reset all state and restart the + application? +

    +
    + {" "} + +
    +
    +
    + + ); +}; diff --git a/apps/wing-console/console/ui/src/layout/status-bar.tsx b/apps/wing-console/console/ui/src/features/status-bar/status-bar.tsx similarity index 73% rename from apps/wing-console/console/ui/src/layout/status-bar.tsx rename to apps/wing-console/console/ui/src/features/status-bar/status-bar.tsx index 6624783b36f..369ba1dcfbd 100644 --- a/apps/wing-console/console/ui/src/layout/status-bar.tsx +++ b/apps/wing-console/console/ui/src/features/status-bar/status-bar.tsx @@ -2,8 +2,8 @@ import { useTheme, Loader } from "@wingconsole/design-system"; import type { State } from "@wingconsole/server"; import classNames from "classnames"; -import { AutoUpdater } from "../features/auto-updater.js"; - +import { DiscordButton } from "./discord-button.js"; +import { ResetButton } from "./reset-button.js"; import { ThemeToggle } from "./theme-toggle.js"; export interface StatusBarProps { @@ -28,27 +28,29 @@ export const StatusBar = ({ success: "success", error: "error", }; + return (
    {/*left side*/} -
    -
    +
    + +
    {wingVersion && ( - <> +
    Wing v{wingVersion} - +
    )}
    -
    +
    Status:
    + {/*center*/} +
    {/*right side*/} -
    - +
    + {showThemeToggle && }
    diff --git a/apps/wing-console/console/ui/src/features/status-bar/theme-toggle.tsx b/apps/wing-console/console/ui/src/features/status-bar/theme-toggle.tsx new file mode 100644 index 00000000000..6a0bb956094 --- /dev/null +++ b/apps/wing-console/console/ui/src/features/status-bar/theme-toggle.tsx @@ -0,0 +1,83 @@ +import { + MoonIcon as MoonIconOutline, + SunIcon as SunIconOutline, +} from "@heroicons/react/24/outline"; +import { + MoonIcon as MoonIconSolid, + SunIcon as SunIconSolid, +} from "@heroicons/react/24/solid"; +import type { Mode } from "@wingconsole/design-system"; +import { useTheme } from "@wingconsole/design-system"; +import classNames from "classnames"; +import { useCallback, useMemo } from "react"; + +const AutoIcon = ({ currentTheme }: { currentTheme?: Mode }) => { + return ( +
    + + + + + {currentTheme === "light" ? ( + + ) : ( + + )} +
    + ); +}; + +export const ThemeToggle = () => { + const { theme, setThemeMode, mode, mediaTheme } = useTheme(); + + const currentTheme = useMemo(() => { + if (mode === "auto") { + return mediaTheme; + } + return mode; + }, [mode, mediaTheme]); + + const toggleThemeMode = useCallback(() => { + const newMode = + // eslint-disable-next-line unicorn/no-nested-ternary + mode === "light" ? "auto" : mode === "auto" ? "dark" : "light"; + setThemeMode?.(newMode); + }, [setThemeMode, mode]); + + return ( + + ); +}; diff --git a/apps/wing-console/console/ui/src/ui/no-tests.tsx b/apps/wing-console/console/ui/src/features/tests-pane/no-tests.tsx similarity index 100% rename from apps/wing-console/console/ui/src/ui/no-tests.tsx rename to apps/wing-console/console/ui/src/features/tests-pane/no-tests.tsx diff --git a/apps/wing-console/console/ui/src/shared/test-item.ts b/apps/wing-console/console/ui/src/features/tests-pane/test-item.ts similarity index 100% rename from apps/wing-console/console/ui/src/shared/test-item.ts rename to apps/wing-console/console/ui/src/features/tests-pane/test-item.ts diff --git a/apps/wing-console/console/ui/src/ui/test-tree.tsx b/apps/wing-console/console/ui/src/features/tests-pane/test-tree.tsx similarity index 100% rename from apps/wing-console/console/ui/src/ui/test-tree.tsx rename to apps/wing-console/console/ui/src/features/tests-pane/test-tree.tsx diff --git a/apps/wing-console/console/ui/src/tests-context.tsx b/apps/wing-console/console/ui/src/features/tests-pane/tests-context.tsx similarity index 100% rename from apps/wing-console/console/ui/src/tests-context.tsx rename to apps/wing-console/console/ui/src/features/tests-pane/tests-context.tsx diff --git a/apps/wing-console/console/ui/src/features/tests-tree-view.tsx b/apps/wing-console/console/ui/src/features/tests-pane/tests-tree-view.tsx similarity index 84% rename from apps/wing-console/console/ui/src/features/tests-tree-view.tsx rename to apps/wing-console/console/ui/src/features/tests-pane/tests-tree-view.tsx index e20e38b4ce2..6749562532e 100644 --- a/apps/wing-console/console/ui/src/features/tests-tree-view.tsx +++ b/apps/wing-console/console/ui/src/features/tests-pane/tests-tree-view.tsx @@ -1,7 +1,7 @@ import { memo } from "react"; -import { useTests } from "../services/use-tests.js"; -import { TestTree } from "../ui/test-tree.js"; +import { TestTree } from "./test-tree.js"; +import { useTests } from "./use-tests.js"; export interface TestsTreeViewProps { onSelectedItemsChange?: (items: string[]) => void; diff --git a/apps/wing-console/console/ui/src/services/use-tests.ts b/apps/wing-console/console/ui/src/features/tests-pane/use-tests.ts similarity index 86% rename from apps/wing-console/console/ui/src/services/use-tests.ts rename to apps/wing-console/console/ui/src/features/tests-pane/use-tests.ts index 1785a7febed..17d4365fde4 100644 --- a/apps/wing-console/console/ui/src/services/use-tests.ts +++ b/apps/wing-console/console/ui/src/features/tests-pane/use-tests.ts @@ -1,11 +1,9 @@ -import type { inferRouterOutputs } from "@trpc/server"; -import type { Router } from "@wingconsole/server"; import { useContext, useEffect, useState } from "react"; -import type { TestItem, TestStatus } from "../shared/test-item.js"; -import { TestsContext } from "../tests-context.js"; +import { trpc } from "../../trpc.js"; -import { trpc } from "./trpc.js"; +import type { TestItem, TestStatus } from "./test-item.js"; +import { TestsContext } from "./tests-context.js"; export const useTests = () => { const [testList, setTestList] = useState([]); diff --git a/apps/wing-console/console/ui/src/services/use-websocket.tsx b/apps/wing-console/console/ui/src/features/websocket-state/use-websocket.tsx similarity index 98% rename from apps/wing-console/console/ui/src/services/use-websocket.tsx rename to apps/wing-console/console/ui/src/features/websocket-state/use-websocket.tsx index 3a8827f5a7b..5daf54961df 100644 --- a/apps/wing-console/console/ui/src/services/use-websocket.tsx +++ b/apps/wing-console/console/ui/src/features/websocket-state/use-websocket.tsx @@ -37,6 +37,7 @@ export const useWebSocketState = () => { webSocket.readyState === WebSocket.OPEN ? "open" : "closed", ); }; + onStateChange(); webSocket.addEventListener("close", onStateChange); webSocket.addEventListener("open", onStateChange); return () => { diff --git a/apps/wing-console/console/ui/src/layout/websocket-state.tsx b/apps/wing-console/console/ui/src/features/websocket-state/websocket-state.tsx similarity index 85% rename from apps/wing-console/console/ui/src/layout/websocket-state.tsx rename to apps/wing-console/console/ui/src/features/websocket-state/websocket-state.tsx index 395d39b63b5..315aed6899e 100644 --- a/apps/wing-console/console/ui/src/layout/websocket-state.tsx +++ b/apps/wing-console/console/ui/src/features/websocket-state/websocket-state.tsx @@ -2,7 +2,7 @@ import { ExclamationTriangleIcon } from "@heroicons/react/24/outline"; import { Modal } from "@wingconsole/design-system"; import { useEffect, useState } from "react"; -import { useWebSocketState } from "../services/use-websocket.js"; +import { useWebSocketState } from "./use-websocket.js"; export const WebSocketState = () => { const { webSocketState } = useWebSocketState(); @@ -28,16 +28,16 @@ export const WebSocketState = () => { />

    -

    +

    Connection Lost

    -

    +

    The connection to the server was lost.

    -

    +

    Please, try restarting the Wing Console.

    diff --git a/apps/wing-console/console/ui/src/index.ts b/apps/wing-console/console/ui/src/index.ts index cdaee3c6d5e..100ea78f520 100644 --- a/apps/wing-console/console/ui/src/index.ts +++ b/apps/wing-console/console/ui/src/index.ts @@ -1,3 +1,5 @@ export * from "./Console.js"; export type { RouterContext } from "@wingconsole/server"; + +export type * from "@trpc/server"; diff --git a/apps/wing-console/console/ui/src/layout/default-layout.tsx b/apps/wing-console/console/ui/src/layout/default-layout.tsx deleted file mode 100644 index 950f48f0d66..00000000000 --- a/apps/wing-console/console/ui/src/layout/default-layout.tsx +++ /dev/null @@ -1,453 +0,0 @@ -import { - SpinnerLoader, - LeftResizableWidget, - RightResizableWidget, - TopResizableWidget, - USE_EXTERNAL_THEME_COLOR, -} from "@wingconsole/design-system"; -import type { State, LayoutConfig, LayoutComponent } from "@wingconsole/server"; -import { useLoading } from "@wingconsole/use-loading"; -import { PersistentStateProvider } from "@wingconsole/use-persistent-state"; -import classNames from "classnames"; -import { useCallback, useEffect, useMemo } from "react"; - -import { EndpointsTreeView } from "../features/endpoints-tree-view.js"; -import { MapView } from "../features/map-view.js"; -import { TestsTreeView } from "../features/tests-tree-view.js"; -import { BlueScreenOfDeath } from "../ui/blue-screen-of-death.js"; -import { EdgeMetadata } from "../ui/edge-metadata.js"; -import { Explorer } from "../ui/explorer.js"; -import { ResourceMetadata } from "../ui/resource-metadata.js"; -import { LogsWidget } from "../widgets/logs.js"; - -import { SignInModal } from "./sign-in.js"; -import { StatusBar } from "./status-bar.js"; -import { useLayout } from "./use-layout.js"; -import { WebSocketState } from "./websocket-state.js"; - -export interface LayoutProps { - cloudAppState: State; - wingVersion: string | undefined; - layoutConfig?: LayoutConfig; -} - -const defaultLayoutConfig: LayoutConfig = { - leftPanel: { - components: [ - { - type: "explorer", - }, - { - type: "endpoints", - }, - { - type: "tests", - }, - ], - }, - bottomPanel: { - components: [ - { - type: "logs", - }, - ], - }, - statusBar: { - hide: false, - showThemeToggle: true, - }, - errorScreen: { - position: "default", - displayTitle: true, - displayLinks: true, - }, - panels: { - rounded: false, - }, -}; - -export const DefaultLayout = ({ - cloudAppState, - wingVersion, - layoutConfig, -}: LayoutProps) => { - const { - items, - selectedItems, - setSelectedItems, - expandedItems, - setExpandedItems, - expand, - expandAll, - collapseAll, - theme, - errorMessage, - loading, - metadata, - selectedEdgeId, - setSelectedEdgeId, - edgeMetadata, - showTests, - onResourceClick, - title, - } = useLayout({ - cloudAppState, - }); - - useEffect(() => { - document.title = title; - }, [title]); - - const { loading: deferredLoading, setLoading: setDeferredLoading } = - useLoading({ - delay: 800, - duration: 100, - }); - useEffect(() => { - setDeferredLoading(loading); - }, [loading, setDeferredLoading]); - - const layout: LayoutConfig = useMemo(() => { - return { - ...defaultLayoutConfig, - ...layoutConfig, - }; - }, [layoutConfig]); - - const selectedItemId = useMemo(() => selectedItems.at(0), [selectedItems]); - - const onTestsSelectedItemsChange = useCallback( - (items: string[]) => { - if (!showTests) { - return; - } - setSelectedItems(items); - }, - [showTests, setSelectedItems], - ); - - const renderLayoutComponent = useCallback( - (component: LayoutComponent) => { - switch (component.type) { - case "explorer": { - return ( -
    - -
    - ); - } - case "tests": { - return ( - - ); - } - case "logs": { - return ( -
    - -
    - ); - } - case "endpoints": { - return ; - } - } - }, - [ - loading, - items, - selectedItemId, - setSelectedItems, - expandedItems, - setExpandedItems, - expandAll, - collapseAll, - onTestsSelectedItemsChange, - showTests, - theme.bg3, - onResourceClick, - ], - ); - - const onConnectionNodeClick = useCallback( - (path: string) => { - expand(path); - setSelectedItems([path]); - }, - [expand, setSelectedItems], - ); - - return ( - <> - - - -
    -
    - - {cloudAppState === "error" && - layout.errorScreen?.position === "default" && ( -
    - -
    - )} - - {cloudAppState !== "error" && ( - <> - {loading && ( -
    - )} - -
    - {!layout.leftPanel?.hide && - layout.leftPanel?.components?.length && ( - - {layout.leftPanel?.components.map( - (component: LayoutComponent, index: number) => { - const panelComponent = ( -
    0 && "h-full", - )} - > - {renderLayoutComponent(component)} -
    - ); - - if (index > 0) { - return ( - - {panelComponent} - - ); - } - return ( -
    - {panelComponent} -
    - ); - }, - )} -
    - )} - -
    -
    -
    - - setSelectedItems(nodeId ? [nodeId] : []) - } - selectedEdgeId={selectedEdgeId} - onSelectedEdgeIdChange={setSelectedEdgeId} - /> -
    - {!layout.rightPanel?.hide && ( - -
    -
    -
    - -
    -
    - - {metadata.data && ( - - )} - - {selectedEdgeId && edgeMetadata.data && ( - - )} -
    -
    - )} -
    -
    -
    - - )} - {!layout.bottomPanel?.hide && ( - - {layout.bottomPanel?.components?.map( - (component: LayoutComponent, index: number) => { - const panelComponent = ( -
    - {renderLayoutComponent(component)} -
    - ); - - if ( - layout.bottomPanel?.components?.length && - layout.bottomPanel.components.length > 1 && - index !== layout.bottomPanel.components.length - 1 - ) { - return ( - - {panelComponent} - - ); - } - return panelComponent; - }, - )} -
    - )} - - {cloudAppState === "error" && - layout.errorScreen?.position === "bottom" && ( - <> -
    - -
    - - - -
    - - )} - - {!layout.statusBar?.hide && ( -
    - -
    - )} - -
    -
    - - ); -}; diff --git a/apps/wing-console/console/ui/src/layout/header.tsx b/apps/wing-console/console/ui/src/layout/header.tsx deleted file mode 100644 index bf9e64b01b2..00000000000 --- a/apps/wing-console/console/ui/src/layout/header.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { useTheme } from "@wingconsole/design-system"; -import classNames from "classnames"; - -import { ThemeToggle } from "./theme-toggle.js"; - -export interface HeaderProps { - title: string; - showThemeToggle?: boolean; -} - -export const Header = ({ title, showThemeToggle = true }: HeaderProps) => { - const { theme } = useTheme(); - - return ( -
    -
    -
    -
    {title}
    -
    -
    - {showThemeToggle && } -
    -
    - ); -}; diff --git a/apps/wing-console/console/ui/src/layout/theme-toggle.tsx b/apps/wing-console/console/ui/src/layout/theme-toggle.tsx deleted file mode 100644 index beecb9a227e..00000000000 --- a/apps/wing-console/console/ui/src/layout/theme-toggle.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { MoonIcon, SunIcon } from "@heroicons/react/24/outline"; -import { useTheme } from "@wingconsole/design-system"; -import classNames from "classnames"; -import { useCallback, useMemo } from "react"; - -export const ThemeToggle = () => { - const { theme, setThemeMode, mode, mediaTheme } = useTheme(); - - const currentTheme = useMemo(() => { - if (mode === "auto") { - return mediaTheme; - } - return mode; - }, [mode, mediaTheme]); - - const toggleThemeMode = useCallback(() => { - const newMode = - // eslint-disable-next-line unicorn/no-nested-ternary - mode === "light" ? "auto" : mode === "auto" ? "dark" : "light"; - setThemeMode?.(newMode); - }, [setThemeMode, mode]); - - return ( - - ); -}; diff --git a/apps/wing-console/console/ui/src/layout/use-layout.tsx b/apps/wing-console/console/ui/src/layout/use-layout.tsx deleted file mode 100644 index 7b2c0995622..00000000000 --- a/apps/wing-console/console/ui/src/layout/use-layout.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import { useTheme } from "@wingconsole/design-system"; -import type { State } from "@wingconsole/server"; -import { useLoading } from "@wingconsole/use-loading"; -import { useEffect, useState, useContext, useMemo, useCallback } from "react"; - -import { trpc } from "../services/trpc.js"; -import { useExplorer } from "../services/use-explorer.js"; -import { TestsContext } from "../tests-context.js"; - -export interface UseLayoutProps { - cloudAppState: State; -} - -export const useLayout = ({ cloudAppState }: UseLayoutProps) => { - const { theme } = useTheme(); - - const { - items, - selectedItems, - setSelectedItems, - expandedItems, - setExpandedItems, - expand, - expandAll, - collapseAll, - } = useExplorer(); - - const errorMessage = trpc["app.error"].useQuery(); - const { showTests } = useContext(TestsContext); - - const [selectedEdgeId, setSelectedEdgeId] = useState(); - - const onSelectedItemsChange = useCallback( - (items: string[]) => { - setSelectedEdgeId(undefined); - setSelectedItems(items); - }, - [setSelectedItems], - ); - - const onSelectedEdgeIdChange = useCallback( - (edgeId: string | undefined) => { - onSelectedItemsChange([]); - setSelectedEdgeId(edgeId); - }, - [onSelectedItemsChange], - ); - - const wingfile = trpc["app.wingfile"].useQuery(); - const title = useMemo(() => { - if (!wingfile.data) { - return "Wing Console"; - } - return `${wingfile.data} - Wing Console`; - }, [wingfile.data]); - - const metadata = trpc["app.nodeMetadata"].useQuery( - { - path: selectedItems[0], - showTests, - }, - { - enabled: selectedItems.length > 0, - }, - ); - - const edgeMetadata = trpc["app.edgeMetadata"].useQuery( - { - edgeId: selectedEdgeId || "", - showTests, - }, - { - enabled: !!selectedEdgeId, - }, - ); - - const { loading, setLoading } = useLoading({ - duration: 400, - }); - - useEffect(() => { - setLoading( - cloudAppState === "loadingSimulator" || cloudAppState === "compiling", - ), - [cloudAppState, items.length]; - }); - - const onResourceClick = useCallback( - (path: string) => { - setSelectedItems([path]); - }, - [setSelectedItems], - ); - - return { - items, - selectedItems, - setSelectedItems: onSelectedItemsChange, - expandedItems, - setExpandedItems, - expand, - expandAll, - collapseAll, - theme, - errorMessage, - loading, - metadata, - selectedEdgeId, - setSelectedEdgeId: onSelectedEdgeIdChange, - edgeMetadata, - showTests, - onResourceClick, - title, - wingfile, - }; -}; diff --git a/apps/wing-console/console/ui/src/services/use-map.ts b/apps/wing-console/console/ui/src/services/use-map.ts deleted file mode 100644 index 8d3d4c1bf4c..00000000000 --- a/apps/wing-console/console/ui/src/services/use-map.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { useEffect, useState } from "react"; - -import { trpc } from "./trpc.js"; - -export interface UseMapOptions { - showTests: boolean; -} -export const useMap = ({ showTests }: UseMapOptions) => { - const map = trpc["app.map"].useQuery({ - showTests: showTests, - }); - - const [mapData, setMapData] = useState(map.data); - - useEffect(() => { - setMapData(map.data); - }, [map.data]); - - return { - mapData, - }; -}; diff --git a/apps/wing-console/console/ui/src/shared/Edge.ts b/apps/wing-console/console/ui/src/shared/Edge.ts deleted file mode 100644 index 5447cfdb117..00000000000 --- a/apps/wing-console/console/ui/src/shared/Edge.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface Edge { - id: string; - source: string; - target: string; -} diff --git a/apps/wing-console/console/ui/src/shared/Node.ts b/apps/wing-console/console/ui/src/shared/Node.ts deleted file mode 100644 index 09e82115bab..00000000000 --- a/apps/wing-console/console/ui/src/shared/Node.ts +++ /dev/null @@ -1,5 +0,0 @@ -export type Node = { - id: string; - children?: Node[]; - data?: T; -}; diff --git a/apps/wing-console/console/ui/src/services/trpc.ts b/apps/wing-console/console/ui/src/trpc.ts similarity index 100% rename from apps/wing-console/console/ui/src/services/trpc.ts rename to apps/wing-console/console/ui/src/trpc.ts diff --git a/apps/wing-console/console/ui/src/ui/edge-item.tsx b/apps/wing-console/console/ui/src/ui/edge-item.tsx deleted file mode 100644 index 8a0362a616a..00000000000 --- a/apps/wing-console/console/ui/src/ui/edge-item.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import classNames from "classnames"; -import type { ElkExtendedEdge } from "elkjs/lib/elk.bundled.js"; -import { motion } from "framer-motion"; -import { memo, useMemo, useState } from "react"; - -export const EdgeItem = memo( - ({ - edge, - offset = { x: 0, y: 0 }, - markerStart, - markerEnd, - highlighted, - fade, - transitionDuration, - selected, - onMouseEnter, - onMouseLeave, - onClick, - }: { - edge: ElkExtendedEdge; - offset?: { x: number; y: number }; - markerStart?: string; - markerEnd?: string; - highlighted?: boolean; - fade?: boolean; - transitionDuration?: number; - selected?: boolean; - onMouseEnter?: () => void; - onMouseLeave?: () => void; - onClick?: (id: string) => void; - }) => { - const d = useMemo(() => { - return edge.sections - ?.map((section) => { - const points = - [...(section.bendPoints ?? []), section.endPoint] - ?.map((point) => `L${point.x},${point.y}`) - .join(" ") ?? ""; - - return `M${section.startPoint.x},${section.startPoint.y} ${points}`; - }) - .join(" "); - }, [edge.sections]); - - return ( - - - onClick?.(edge.id)} - className="opacity-0 pointer-events-auto" - style={{ translateX: offset.x, translateY: offset.y }} - markerStart={`url(#${markerStart})`} - markerEnd={`url(#${markerEnd})`} - d={d} - strokeWidth="8" - /> - - ); - }, -); diff --git a/apps/wing-console/console/ui/src/ui/elk-map-nodes.tsx b/apps/wing-console/console/ui/src/ui/elk-map-nodes.tsx deleted file mode 100644 index 0668219502f..00000000000 --- a/apps/wing-console/console/ui/src/ui/elk-map-nodes.tsx +++ /dev/null @@ -1,230 +0,0 @@ -import type { IconComponent } from "@wingconsole/design-system"; -import { useTheme } from "@wingconsole/design-system"; -import type { Colors } from "@wingconsole/design-system/src/utils/colors"; -import type { BaseResourceSchema, NodeDisplay } from "@wingconsole/server"; -import classNames from "classnames"; -import type { PropsWithChildren } from "react"; -import { memo, useMemo } from "react"; - -const colorSet: Record = { - orange: "bg-orange-500 dark:bg-orange-600", - sky: "bg-sky-500 dark:bg-sky-600", - emerald: "bg-emerald-500 dark:bg-emerald-600", - lime: "bg-lime-500 dark:bg-lime-600", - pink: "bg-pink-500 dark:bg-pink-600", - amber: "bg-amber-500 dark:bg-amber-600", - cyan: "bg-cyan-500 dark:bg-cyan-600", - purple: "bg-purple-500 dark:bg-purple-600", - red: "bg-red-700 dark:bg-red-600", - violet: "bg-violet-500 dark:bg-violet-600", - slate: "bg-slate-400 dark:bg-slate-600", -}; - -const getResourceBackgroudColor = ( - resourceType: BaseResourceSchema["type"] | undefined, - color: Colors = "slate", -) => { - switch (resourceType) { - case "@winglang/sdk.cloud.Bucket": { - return colorSet.orange; - } - case "@winglang/sdk.cloud.Function": { - return colorSet.sky; - } - case "@winglang/sdk.cloud.Queue": { - return colorSet.emerald; - } - case "@winglang/sdk.cloud.Counter": { - return colorSet.lime; - } - case "@winglang/sdk.cloud.Topic": { - return colorSet.pink; - } - case "@winglang/sdk.cloud.Api": { - return colorSet.amber; - } - case "@winglang/sdk.ex.Table": { - return colorSet.cyan; - } - case "@winglang/sdk.cloud.Schedule": { - return colorSet.purple; - } - case "@winglang/sdk.ex.Redis": { - return colorSet.red; - } - case "@winglang/sdk.cloud.Website": { - return colorSet.violet; - } - case "@winglang/sdk.ex.ReactApp": { - return colorSet.sky; - } - default: { - return colorSet[color] ?? colorSet.slate; - } - } -}; - -export interface ContainerNodeProps { - nodeId: string; - name: string | undefined; - display?: NodeDisplay; - icon?: IconComponent; - open?: boolean; - hideBottomBar?: boolean; - selected?: boolean; - fade?: boolean; - resourceType: BaseResourceSchema["type"] | undefined; - depth: number; - onClick?: () => void; - onMouseEnter?: () => void; -} - -export const ContainerNode = memo( - ({ - open, - icon: Icon, - hideBottomBar, - selected, - fade, - onClick, - onMouseEnter, - resourceType, - depth, - display, - ...props - }: PropsWithChildren) => { - const { theme } = useTheme(); - const bgColor = useMemo( - () => getResourceBackgroudColor(resourceType, display?.color as Colors), - [resourceType, display?.color], - ); - - const compilerNamed = useMemo(() => { - // sdk cloud resource type has a fixed convention: @winglang/sdk.cloud.* - const cloudResourceType = resourceType - ? resourceType.split(".").at(-1) - : ""; - return !!display?.title && display?.title !== cloudResourceType; - }, [display, resourceType]); - - return ( - // TODO: Fix a11y - // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -
    -
    - {Icon && ( -
    -
    - -
    -
    - )} -
    -
    -
    - {compilerNamed ? display?.title : props.name} -
    -
    -
    -
    - - {open && ( -
    -
    -
    - )} - -
    -
    - ); - }, -); diff --git a/apps/wing-console/console/ui/src/ui/elk-map.stories.tsx b/apps/wing-console/console/ui/src/ui/elk-map.stories.tsx deleted file mode 100644 index 777e238c40b..00000000000 --- a/apps/wing-console/console/ui/src/ui/elk-map.stories.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; - -import { ElkMap } from "./elk-map.js"; - -const meta = { - title: "UI/MapView/ElkMap", - component: ElkMap, - tags: ["autodocs"], -} satisfies Meta; - -export default meta; - -type Story = StoryObj; - -export const Default: Story = { - args: { - nodes: [ - { - id: "1", - data: { - className: "w-4 h-4 bg-red-500", - }, - }, - { - id: "2", - data: { - className: "w-12 h-4 bg-blue-500", - }, - }, - ], - // @ts-ignore - node: (props) =>
    , - }, -}; diff --git a/apps/wing-console/console/ui/src/ui/elk-map.tsx b/apps/wing-console/console/ui/src/ui/elk-map.tsx deleted file mode 100644 index ab64a3a808c..00000000000 --- a/apps/wing-console/console/ui/src/ui/elk-map.tsx +++ /dev/null @@ -1,646 +0,0 @@ -import classNames from "classnames"; -import type { - ElkExtendedEdge, - ElkNode, - LayoutOptions, -} from "elkjs/lib/elk.bundled.js"; -import ELK from "elkjs/lib/elk.bundled.js"; -import { AnimatePresence } from "framer-motion"; -import type { FC } from "react"; -import { - Fragment, - memo, - useCallback, - useEffect, - useId, - useMemo, - useRef, - useState, -} from "react"; -import { createPortal } from "react-dom"; -import { useKeyPressEvent } from "react-use"; - -import type { Edge } from "../shared/Edge.js"; -import type { Node } from "../shared/Node.js"; - -import { EdgeItem } from "./edge-item.js"; -import { useNodeStaticData } from "./use-node-static-data.js"; -import type { ZoomPaneRef } from "./zoom-pane.js"; -import { ZoomPane, useZoomPane } from "./zoom-pane.js"; - -const durationClass = "duration-500"; - -// For more configuration options, refer to: https://eclipse.dev/elk/reference/options.html -const layoutOptions: LayoutOptions = { - "elk.hierarchyHandling": "INCLUDE_CHILDREN", - "elk.direction": "RIGHT", - "elk.alignment": "CENTER", - "elk.algorithm": "org.eclipse.elk.layered", - "elk.layered.layering.strategy": "MIN_WIDTH", - "elk.layered.nodePlacement.strategy": "NETWORK_SIMPLEX", - "elk.layered.crossingMinimization.strategy": "LAYER_SWEEP", - "elk.layered.spacing.baseValue": "0", - "elk.spacing.edgeEdge": "128", - "elk.spacing.edgeNode": "32", - "elk.spacing.nodeNode": "48", - "elk.layered.spacing.edgeEdgeBetweenLayers": "16", - "elk.layered.spacing.nodeNodeBetweenLayers": "64", - "elk.layered.spacing.edgeNodeBetweenLayers": "16", - "elk.padding": "[top=68,left=20,bottom=20,right=20]", -}; - -export type NodeItemProps = { - node: Node; - depth: number; - selected: boolean; - fade: boolean; -}; - -type Sizes = Record; - -interface InvisibleNodeSizeCalculatorProps { - nodes: Node[]; - node: FC>; - onSizesChange(sizes: Sizes): void; -} - -const InvisibleNodeSizeCalculator = memo( - ({ - nodes, - node: NodeItem, - onSizesChange, - }: InvisibleNodeSizeCalculatorProps) => { - const refs = useRef>({}); - - const [sizes, setSizes] = useState(); - useEffect(() => { - setSizes(() => { - const sizes: Sizes = {}; - for (const [nodeId, element] of Object.entries(refs.current)) { - if (!element) { - continue; - } - const rect = element.getBoundingClientRect(); - sizes[nodeId] = { - width: rect.width, - height: rect.height, - }; - } - return sizes; - }); - }, [nodes]); - useEffect(() => { - if (sizes) { - onSizesChange(sizes); - } - }, [sizes, onSizesChange]); - - const renderElkPre = useCallback( - (node: ElkNode, depth = 0) => { - return ( - -
    -
    (refs.current[node.id] = element)} - > - -
    -
    - - {node.children?.map((node) => renderElkPre(node, depth + 1))} -
    - ); - }, - [NodeItem], - ); - - return ( -
    - {nodes.map((node) => renderElkPre(node))} -
    - ); - }, -); - -export interface ElkMapProps { - nodes: Node[]; - edges?: Edge[]; - node: FC>; - selectedNodeId?: string | undefined; - onSelectedNodeIdChange?: (id: string | undefined) => void; - selectedEdgeId?: string; - onSelectedEdgeIdChange?: (id: string) => void; -} - -interface EdgesContainerProps { - width: number; - height: number; - edges: ElkExtendedEdge[]; - offsets: Map< - string, - { - x: number; - y: number; - } - >; - selectedEdgeId?: string; - onClick?: (id: string) => void; - isHighlighted(nodeId: string): boolean; - selectedNodeId?: string; - highlighted?: string; -} - -const EdgesContainer = memo( - ({ - width, - height, - edges, - offsets, - selectedEdgeId, - onClick, - isHighlighted, - selectedNodeId, - highlighted, - }: EdgesContainerProps) => { - // const higlight = useCallback(() => { - // setHighlighted(edge.sources[0] ?? edge.targets[0]); - // }); - return ( - - - - - - - - - - - - - - - - - - - - {edges.map((edge) => { - const isNodeHighlighted = - isHighlighted(edge.sources[0]!) || isHighlighted(edge.targets[0]!); - const isEdgeHighlighted = - edge.sources[0] === selectedNodeId || - edge.targets[0] === selectedNodeId; - const visible = highlighted || isNodeHighlighted; - const selected = edge.id === selectedEdgeId; - - return ( - - ); - })} - - ); - }, -); - -interface GraphProps { - graph: ElkNode; - node: FC>; - nodeList: NodeData[]; - offsets: Map; - selectedNodeId?: string; - onSelectedNodeIdChange?(id: string): void; - isHighlighted(nodeId: string): boolean; - hasHighlightedEdge(node: NodeData): boolean; - onSelectedEdgeIdChange?(id: string): void; -} - -const Graph = memo( - ({ - graph, - node, - nodeList, - offsets, - selectedNodeId, - onSelectedNodeIdChange, - isHighlighted, - hasHighlightedEdge, - onSelectedEdgeIdChange, - }: GraphProps) => { - return ( -
    - - - - - - {offsets && graph.edges && ( - - )} - -
    - ); - }, -); - -type NodeData = { - id: string; - width: number; - height: number; - offset: { x: number; y: number }; - depth: number; - edges: Edge[]; - data: Node; -}; - -interface NodesContainerProps { - nodeList: NodeData[]; - node: FC>; - selectedNodeId: string | undefined; - onSelectedNodeIdChange?(id: string): void; - isHighlighted(nodeId: string): boolean; - hasHighlightedEdge(node: NodeData): boolean; -} - -const NodesContainer = memo( - ({ - nodeList, - node: NodeItem, - selectedNodeId, - onSelectedNodeIdChange, - isHighlighted, - hasHighlightedEdge, - }: NodesContainerProps) => { - return ( - <> - {nodeList.map((node) => ( - // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -
    { - // Stop the event from propagating to the background node. - event.stopPropagation(); - onSelectedNodeIdChange?.(node.id); - }} - > - -
    - ))} - - ); - }, -); - -const MapBackground = (props: {}) => { - const { viewTransform } = useZoomPane(); - const patternSize = 12 * viewTransform.z; - const dotSize = 1 * viewTransform.z; - const id = useId(); - return ( - // Reference: https://github.com/xyflow/xyflow/blob/13897512d3c57e72c2e27b14ffa129412289d948/packages/react/src/additional-components/Background/Background.tsx#L52-L86. - - - - - - - ); -}; - -const nodeExists = (nodes: Node[], id: string): boolean => { - let current = nodes; - - let node: Node | undefined; - do { - node = current.find( - (node) => node.id === id || id.startsWith(`${node.id}/`), - ); - if (node?.id === id) { - return true; - } - current = node?.children ?? []; - } while (node); - - return false; -}; - -export const ElkMap = ({ - nodes, - edges, - node, - selectedNodeId, - onSelectedNodeIdChange, - selectedEdgeId, - onSelectedEdgeIdChange, -}: ElkMapProps) => { - const { nodeRecord } = useNodeStaticData({ - nodes, - }); - - const [minimumSizes, setMinimumSizes] = useState(); - - const [offsets, setOffsets] = - useState>(); - const [graph, setGraph] = useState(); - useEffect(() => { - if (!minimumSizes || Object.keys(minimumSizes).length === 0) return; - - const elk = new ELK(); - const toElkNode = (node: Node): ElkNode => { - const size = minimumSizes?.[node.id]; - return { - id: node.id, - width: size?.width, - height: size?.height, - layoutOptions: { - ...layoutOptions, - "elk.nodeSize.constraints": "MINIMUM_SIZE", - "elk.nodeSize.minimum": `[${size?.width}, ${size?.height}]`, - }, - children: node.children?.map((node) => toElkNode(node)), - }; - }; - - let abort = false; - void elk - .layout({ - id: "root", - layoutOptions: { - ...layoutOptions, - "elk.padding": "[top=10,left=10,bottom=10,right=10]", - }, - children: nodes.map((node) => toElkNode(node)), - edges: edges - ?.filter( - (edge) => - nodeExists(nodes, edge.source) && nodeExists(nodes, edge.target), - ) - ?.map((edge) => ({ - id: edge.id, - sources: [edge.source], - targets: [edge.target], - })), - }) - .then((graph) => { - if (abort) { - return; - } - - const offsets = new Map(); - const visit = (node: ElkNode, offsetX = 0, offsetY = 0) => { - const x = offsetX + (node.x ?? 0); - const y = offsetY + (node.y ?? 0); - offsets.set(node.id, { x, y }); - for (const child of node.children ?? []) { - visit(child, x, y); - } - }; - visit(graph); - setOffsets(offsets); - setGraph(graph); - }); - - return () => { - abort = true; - }; - }, [nodes, edges, minimumSizes]); - - const highlighted = selectedNodeId; - - const isHighlighted = useCallback( - (nodeId: string) => { - if (!highlighted || highlighted === "root") return true; - return highlighted === nodeId; - }, - [highlighted], - ); - - const [nodeList, setNodeList] = useState([]); - useEffect(() => { - setNodeList(() => { - if (!graph) { - return []; - } - if (!nodeRecord) { - return []; - } - if (!offsets) { - return []; - } - - const nodeList: NodeData[] = []; - const traverse = (node: ElkNode, depth = 0) => { - const offset = offsets?.get(node.id) ?? { x: 0, y: 0 }; - const data = nodeRecord[node.id]; - if (data) { - nodeList.push({ - id: node.id, - width: node.width ?? 0, - height: node.height ?? 0, - offset, - depth, - edges: - edges?.filter( - (edge) => edge.source === node.id || edge.target === node.id, - ) ?? [], - data, - }); - } - for (const child of node.children ?? []) { - traverse(child, depth + 1); - } - }; - - for (const node of graph.children ?? []) { - traverse(node); - } - - // Remove root node. - return nodeList.slice(1); - }); - }, [graph, nodeRecord, offsets, edges, setNodeList]); - - const hasHighlightedEdge = useCallback( - (node: NodeData) => { - return node.edges.some( - (edge) => - (edge.source === node.id && edge.target === highlighted) || - (edge.source === highlighted && edge.target === node.id), - ); - }, - [highlighted], - ); - - const mapSize = useMemo(() => { - if (!graph) { - return; - } - - return { - width: graph.width!, - height: graph.height!, - }; - }, [graph]); - const zoomPaneRef = useRef(null); - - useEffect(() => { - zoomPaneRef.current?.zoomToFit(); - }, [offsets]); - - const mapBackgroundRef = useRef(null); - - useKeyPressEvent( - "Escape", - useCallback(() => { - onSelectedNodeIdChange?.(undefined); - }, [onSelectedNodeIdChange]), - ); - - return ( - <> - - -
    - - onSelectedNodeIdChange?.(undefined)} - > - {mapBackgroundRef.current && - createPortal(, mapBackgroundRef.current)} - {graph && ( - - )} - - - ); -}; diff --git a/apps/wing-console/console/ui/src/ui/use-node-static-data.tsx b/apps/wing-console/console/ui/src/ui/use-node-static-data.tsx deleted file mode 100644 index aefcd68788b..00000000000 --- a/apps/wing-console/console/ui/src/ui/use-node-static-data.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { useEffect, useState } from "react"; - -import type { Node } from "../shared/Node.js"; - -export interface NodeStaticDataOptions { - /** - * The list of nodes. - */ - nodes: Node[]; -} - -/** - * Generate data such as sizes, port positions and more for each node. - */ -export const useNodeStaticData = ({ nodes }: NodeStaticDataOptions) => { - const [nodeRecord, setNodeRecord] = useState>>(); - useEffect(() => { - setNodeRecord(() => { - const nodeRecord: Record = {}; - const visit = (node: Node) => { - nodeRecord[node.id] = node; - for (const child of node.children ?? []) { - visit(child); - } - }; - for (const node of nodes) { - visit(node); - } - return nodeRecord; - }); - }, [nodes]); - - return { - nodeRecord, - }; -}; diff --git a/apps/wing-console/console/ui/src/ui/use-tree-menu-items.tsx b/apps/wing-console/console/ui/src/ui/use-tree-menu-items.tsx deleted file mode 100644 index 4dfefd92d16..00000000000 --- a/apps/wing-console/console/ui/src/ui/use-tree-menu-items.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import type { ReactNode } from "react"; -import { useCallback, useState } from "react"; - -export interface TreeMenuItem { - id: string; - icon?: React.ReactNode; - label: string; - secondaryLabel?: string | ReactNode | ((item: TreeMenuItem) => ReactNode); - children?: TreeMenuItem[]; -} - -export function useTreeMenuItems(options?: { - treeMenuItems?: TreeMenuItem[]; - selectedItemIds?: string[]; - openMenuItemIds?: string[]; -}) { - const [items, setItems] = useState(options?.treeMenuItems ?? []); - - const [selectedItems, setSelectedItems] = useState( - () => options?.selectedItemIds ?? [], - ); - const [expandedItems, setExpandedItems] = useState( - options?.openMenuItemIds ?? [], - ); - const toggle = useCallback((itemId: string) => { - setExpandedItems(([...openedMenuItems]) => { - const index = openedMenuItems.indexOf(itemId); - if (index !== -1) { - openedMenuItems.splice(index, 1); - return openedMenuItems; - } - - openedMenuItems.push(itemId); - return openedMenuItems; - }); - }, []); - - const expandAll = useCallback(() => { - const itemIds = []; - const stack = [...items]; - while (stack.length > 0) { - const item = stack.pop(); - if (!item) { - continue; - } - - itemIds.push(item.id); - - if (item.children) { - stack.push(...item.children); - } - } - - setExpandedItems(itemIds); - }, [items]); - - const collapseAll = useCallback(() => { - setExpandedItems([]); - }, []); - - const expand = useCallback((itemId: string) => { - setExpandedItems(([...openedMenuItems]) => { - const index = openedMenuItems.indexOf(itemId); - if (index !== -1) { - return openedMenuItems; - } - - openedMenuItems.push(itemId); - return openedMenuItems; - }); - }, []); - - return { - items, - setItems, - selectedItems, - setSelectedItems, - expandedItems, - setExpandedItems, - toggle, - expandAll, - collapseAll, - expand, - }; -} diff --git a/apps/wing-console/console/ui/src/ui/zoom-pane.stories.tsx b/apps/wing-console/console/ui/src/ui/zoom-pane.stories.tsx deleted file mode 100644 index 4e338c4f1bb..00000000000 --- a/apps/wing-console/console/ui/src/ui/zoom-pane.stories.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import type { ComponentStory, ComponentMeta } from "@storybook/react"; - -import { ZoomPane, ZoomPaneProvider } from "./zoom-pane.js"; - -export default { - title: "UI/MapView/ZoomPane", - component: ZoomPane, - parameters: { - docs: { - description: { - component: "A zoomable pane.", - }, - }, - }, -} satisfies ComponentMeta; - -const Template: ComponentStory = (props) => ( -
    - - -
    -
    -
    -
    -
    -
    -); - -export const Default = Template.bind({}); -Default.args = {}; diff --git a/apps/wing-console/console/ui/src/shared/use-download-file.ts b/apps/wing-console/console/ui/src/use-download-file.ts similarity index 100% rename from apps/wing-console/console/ui/src/shared/use-download-file.ts rename to apps/wing-console/console/ui/src/use-download-file.ts diff --git a/apps/wing-console/console/ui/src/shared/use-file-link.tsx b/apps/wing-console/console/ui/src/use-file-link.tsx similarity index 97% rename from apps/wing-console/console/ui/src/shared/use-file-link.tsx rename to apps/wing-console/console/ui/src/use-file-link.tsx index de483835f31..9087ca8cd17 100644 --- a/apps/wing-console/console/ui/src/shared/use-file-link.tsx +++ b/apps/wing-console/console/ui/src/use-file-link.tsx @@ -1,7 +1,7 @@ import classNames from "classnames"; import type { PropsWithChildren } from "react"; -import { trpc } from "../services/trpc.js"; +import { trpc } from "./trpc.js"; export const createHtmlLink = ( error: string, diff --git a/apps/wing-console/console/ui/src/services/use-open-external.ts b/apps/wing-console/console/ui/src/use-open-external.ts similarity index 100% rename from apps/wing-console/console/ui/src/services/use-open-external.ts rename to apps/wing-console/console/ui/src/use-open-external.ts diff --git a/apps/wing-console/console/ui/src/shared/use-upload-file.ts b/apps/wing-console/console/ui/src/use-upload-file.ts similarity index 100% rename from apps/wing-console/console/ui/src/shared/use-upload-file.ts rename to apps/wing-console/console/ui/src/use-upload-file.ts diff --git a/apps/wing-console/console/ui/vite.config.ts b/apps/wing-console/console/ui/vite.config.ts index de3630bea3c..751332ad111 100644 --- a/apps/wing-console/console/ui/vite.config.ts +++ b/apps/wing-console/console/ui/vite.config.ts @@ -6,8 +6,4 @@ import { defineConfig } from "vite"; export default defineConfig({ plugins: [react()], - test: { - environment: "happy-dom", - setupFiles: ["./test/setup.ts"], - }, }); diff --git a/apps/wing/project-templates/wing/slack/main.w b/apps/wing/project-templates/wing/slack/main.w new file mode 100644 index 00000000000..1e7b20825fb --- /dev/null +++ b/apps/wing/project-templates/wing/slack/main.w @@ -0,0 +1,43 @@ +bring cloud; +bring slack; + +/** + * In this example, we will create a slack application that gives us updates about + * a inbox bucket, sending slack messages when a new file is added. Additionally, the + * app will allow us to mention it with the text "list inbox" to get a list of all the + * files in the inbox bucket. + */ + +/// Since the slack bot will require a token as a secret please be sure +/// to run `wing secrets` before trying this out. For help understanding how to create +/// a slack bot token see: https://github.com/winglang/winglibs/blob/main/slack/README.md +let botToken = new cloud.Secret(name: "slack-bot-token"); +let slackBot = new slack.App(token: botToken); +let inbox = new cloud.Bucket() as "file-process-inbox"; + +/// When a file is created this event will post an update to slack (be sure to change it to a real slack channel name) +inbox.onCreate(inflight (key) => { + let channel = slackBot.channel("INBOX_PROCESSING_CHANNEL"); + channel.post("New file: {key} was just uploaded to inbox!"); +}); + + +/// When our slack bot is mentioned, this event handler checks for "list inbox" in the +/// events text, and then responds with a list of all files in the inbox +slackBot.onEvent("app_mention", inflight(ctx, event) => { + let eventText = event["event"]["text"].asStr(); + log(eventText); + if eventText.contains("list inbox") { + let files = inbox.list(); + let message = new slack.Message(); + message.addSection({ + fields: [ + { + type: slack.FieldType.mrkdwn, + text: "*Current Inbox:*\n-{files.join("\n-")}" + } + ] + }); + ctx.channel.postMessage(message); + } +}); \ No newline at end of file diff --git a/apps/wing/project-templates/wing/slack/package.json b/apps/wing/project-templates/wing/slack/package.json new file mode 100644 index 00000000000..5feb8c4a279 --- /dev/null +++ b/apps/wing/project-templates/wing/slack/package.json @@ -0,0 +1,10 @@ +{ + "name": "my-slack-app", + "version": "0.0.0", + "description": "A simple Slack app", + "author": "Your Name", + "license": "MIT", + "dependencies": { + "@winglibs/slack": "0.1.0" + } +} \ No newline at end of file diff --git a/apps/wing/src/commands/lsp.ts b/apps/wing/src/commands/lsp.ts index 9a4edd1a025..56056a7ace2 100644 --- a/apps/wing/src/commands/lsp.ts +++ b/apps/wing/src/commands/lsp.ts @@ -100,7 +100,7 @@ export async function lsp() { hoverProvider: true, documentSymbolProvider: true, definitionProvider: true, - renameProvider: true, + renameProvider: { prepareProvider: true }, }, }; return result; @@ -219,6 +219,9 @@ export async function lsp() { connection.onRenameRequest(async (params) => { return callWing("wingc_on_rename", params); }); + connection.onPrepareRename(async (params) => { + return callWing("wingc_on_prepare_rename", params); + }); connection.onHover(async (params) => { return callWing("wingc_on_hover", params); }); diff --git a/apps/wingcli-v2/Cargo.toml b/apps/wingcli-v2/Cargo.toml index f412fc7628a..f700753167b 100644 --- a/apps/wingcli-v2/Cargo.toml +++ b/apps/wingcli-v2/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "wingcli" -version = "0.59.24" +version = "0.74.53" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/apps/wingcli-v2/package.json b/apps/wingcli-v2/package.json index 4b7870aa202..859aa219160 100644 --- a/apps/wingcli-v2/package.json +++ b/apps/wingcli-v2/package.json @@ -6,7 +6,8 @@ }, "dependencies": { "@winglang/wingii": "workspace:^", - "@winglang/wingc": "workspace:^" + "@winglang/wingc": "workspace:^", + "@winglang/sdk": "workspace:^" }, "volta": { "extends": "../../package.json" diff --git a/apps/wingcli-v2/src/main.rs b/apps/wingcli-v2/src/main.rs index 2e2f21457ad..3107bff85c3 100644 --- a/apps/wingcli-v2/src/main.rs +++ b/apps/wingcli-v2/src/main.rs @@ -84,19 +84,18 @@ fn command_build(source_file: Utf8PathBuf, target: Option) -> Result<(), print_compiling(source_file.as_str()); let sdk_root = WING_CACHE_DIR.join("node_modules").join("@winglang").join("sdk"); - if !sdk_root.exists() { + + // Skip installing the SDK here if we're in a unit test since tests may run in parallel + // TODO: check if the SDK is up to date + if !sdk_root.exists() && !cfg!(test) { install_sdk()?; - } else { - // TODO: check that the SDK version matches the CLI version - if cfg!(test) { - // For now, always reinstall the SDK in tests - install_sdk()?; - } } tracing::info!("Using SDK at {}", sdk_root); // Special pragma used by wingc to find the SDK types - std::env::set_var("WINGSDK_MANIFEST_ROOT", &sdk_root); + if !cfg!(test) { + std::env::set_var("WINGSDK_MANIFEST_ROOT", &sdk_root); + } let result = compile(&project_dir, &source_file, None, &work_dir); @@ -120,16 +119,17 @@ fn install_sdk() -> Result<(), Box> { std::fs::create_dir_all(WING_CACHE_DIR.as_str())?; let mut install_command = std::process::Command::new("npm"); install_command.arg("install").arg("esbuild"); // TODO: should this not be an optional dependency? - if cfg!(test) { - install_command.arg(format!("file:{}/../../libs/wingsdk", env!("CARGO_MANIFEST_DIR"))); - } else { + + // No need to install the latest verison of SDK from npm in tests + if !cfg!(test) { install_command.arg(format!("@winglang/sdk@{}", env!("CARGO_PKG_VERSION"))); } - install_command.current_dir(WING_CACHE_DIR.as_str()); install_command.stdout(std::process::Stdio::piped()); install_command.stderr(std::process::Stdio::piped()); + tracing::info!("Running command: {:?}", install_command); + let output = install_command.output()?; if !output.status.success() { let stdout = String::from_utf8_lossy(&output.stdout); @@ -137,6 +137,7 @@ fn install_sdk() -> Result<(), Box> { let error_message = format!("Failed to install SDK. stdout: {}. stderr: {}", stdout, stderr); return Err(error_message.into()); } + Ok(()) } @@ -146,7 +147,19 @@ fn run_javascript_node(source_file: &Utf8Path, target_dir: &Utf8Path, target: Ta let mut command = std::process::Command::new("node"); command.arg(target_dir.join(".wing").join("preflight.cjs")); - command.env("NODE_PATH", WING_CACHE_DIR.join("node_modules").as_str()); + + let mut node_path = WING_CACHE_DIR.join("node_modules").to_string(); + + // For tests, add the local version of the SDK to the NODE_PATH + if cfg!(test) { + node_path = format!( + "{}:{}", + Utf8Path::new(env!("CARGO_MANIFEST_DIR")).join("node_modules"), + node_path + ); + } + + command.env("NODE_PATH", node_path); command.env("WING_PLATFORMS", target.to_string()); command.env("WING_SOURCE_DIR", source_dir); command.env("WING_SYNTH_DIR", target_dir); @@ -205,6 +218,7 @@ mod test { fn initialize() { INIT.call_once(|| { + initialize_logger(); install_sdk().expect("Failed to install SDK"); }); } @@ -213,13 +227,13 @@ mod test { fn test_compile_sim() { initialize(); let res = command_build("../../examples/tests/valid/hello.test.w".into(), Some(Target::Sim)); - assert!(res.is_ok()); + res.expect("Failed to compile to sim"); } #[test] fn test_compile_tfaws() { initialize(); let res = command_build("../../examples/tests/valid/hello.test.w".into(), Some(Target::TfAws)); - assert!(res.is_ok()); + res.expect("Failed to compile to tf-aws"); } } diff --git a/docs/contributing/999-rfcs/2023-06-12-language-spec.md b/docs/contributing/999-rfcs/2023-06-12-language-spec.md index 94ee6ec770f..4e36c5c59e7 100644 --- a/docs/contributing/999-rfcs/2023-06-12-language-spec.md +++ b/docs/contributing/999-rfcs/2023-06-12-language-spec.md @@ -121,8 +121,8 @@ Almost all types can be implicitly resolved by the compiler except for "any". > `Promise` is only available to JSII imported modules. > ```TS -> let z = {1, 2, 3}; // immutable set, Set is inferred -> let zm = MutSet{}; // mutable set +> let z = Set[1, 2, 3]; // immutable set +> let zm = MutSet[]; // mutable set > let y = {"a" => 1, "b" => 2}; // immutable map, Map is inferred > let ym = MutMap{}; // mutable map > let x = [1, 2, 3]; // immutable array, Array is inferred diff --git a/docs/docs/01-start-here/02-installation.md b/docs/docs/01-start-here/02-getting-started.md similarity index 80% rename from docs/docs/01-start-here/02-installation.md rename to docs/docs/01-start-here/02-getting-started.md index ff5aea5fb3d..e9bc0eca29c 100644 --- a/docs/docs/01-start-here/02-installation.md +++ b/docs/docs/01-start-here/02-getting-started.md @@ -1,10 +1,27 @@ --- -id: installation -title: Installation -keywords: [Wing installation, installation, Wing toolchain] +id: getting-started +title: Getting Started +keywords: [Getting started, Wing installation, installation, Wing toolchain] slug: / --- +## Welcome + +Welcome, it's great to see you here! + +As you prepare to start taking flight with Wing 😉, there are a few things you need to do to get set up. +This guide will walk you through the steps to setup Wing on your machine, create your first project, run it in the Wing Simulator and deploy it to AWS. + +:::info + +Wing is still in active development, and we would love to hear what you think! Please ping us on [Wing Slack](https://t.winglang.io/slack), share what you want to build +and let us know if you encounter any issues. There's also a cute channel with music recommendations 🎶 + +::: + +> Did you know that you can also take Wing for a spin without installing anything? +> Check out the [Wing Playground](https://www.winglang.io/play/). + ## Prerequisite * [Node.js](https://nodejs.org/en/) v20 or later @@ -51,7 +68,7 @@ You can use the CLI to bootstrap a new project: Use the `new` command and then m wing new empty ``` -```js +```js example bring cloud; // define a queue, a bucket and a counter diff --git a/docs/docs/01-start-here/05-aws.md b/docs/docs/01-start-here/05-aws.md index 3b2581a913b..e4a7c689091 100644 --- a/docs/docs/01-start-here/05-aws.md +++ b/docs/docs/01-start-here/05-aws.md @@ -23,7 +23,7 @@ resources, using Terraform as the provisioning engine. :::info Under Construction -:construction: We plan to also support [Azure](https://github.com/winglang/wing/issues?q=is:issue+is:open+sort:updated-desc+label:azure) and [Google Cloud](https://github.com/winglang/wing/issues?q=is:issue+is:open+sort:updated-desc+label:gcp) as platforms out of +:construction: We plan to also support [Azure](https://github.com/winglang/wing/issues?q=is:issue+is:open+sort:updated-desc+label:%22☁️%20azure%22) and [Google Cloud](https://github.com/winglang/wing/issues?q=is:issue+is:open+sort:updated-desc+label:%22☁️%20gcp%22) as platforms out of the box. In addition, we are planning support for other provisioning engines such as AWS CloudFormation and Kubernetes. diff --git a/docs/docs/02-concepts/00-cloud-oriented-programming.md b/docs/docs/02-concepts/00-cloud-oriented-programming.md index f9c90bc6298..12ce581b98d 100644 --- a/docs/docs/02-concepts/00-cloud-oriented-programming.md +++ b/docs/docs/02-concepts/00-cloud-oriented-programming.md @@ -15,14 +15,14 @@ cloud without having to worry about the underlying infrastructure. It's best explained through an example: -```js +```js example bring cloud; let queue = new cloud.Queue(timeout: 2m); let bucket = new cloud.Bucket(); let counter = new cloud.Counter(initial: 100); -queue.setConsumer(inflight (body: str): str => { +queue.setConsumer(inflight (body: str) => { let next = counter.inc(); let key = "myfile-{next}.txt"; bucket.put(key, body); diff --git a/docs/docs/02-concepts/01-preflight-and-inflight.md b/docs/docs/02-concepts/01-preflight-and-inflight.md index ea7c3ceca36..73f019797ea 100644 --- a/docs/docs/02-concepts/01-preflight-and-inflight.md +++ b/docs/docs/02-concepts/01-preflight-and-inflight.md @@ -28,7 +28,7 @@ Your preflight code runs once, at compile time, and defines your application's i For example, this code snippet defines a storage bucket using a class from the standard library: -```js playground +```js playground example bring cloud; let bucket = new cloud.Bucket(); @@ -42,7 +42,7 @@ Preflight code can be also used to configure services or set up more complex eve In this code snippet, we've specified the bucket's contents will be publicly accessible, and it will be pre-populated with a file during the app's deployment (not while the app is running). -```js playground +```js playground example bring cloud; let bucket = new cloud.Bucket(public: true); @@ -52,7 +52,7 @@ bucket.addObject("file1.txt", "Hello world!"); There are a few global functions with specific behaviors in preflight. For example, adding a `log()` statement to your preflight code will result in Wing printing a message to the console after compilation. -```js +```js example // hello.w log("7 * 6 = {7 * 6}"); ``` @@ -84,7 +84,7 @@ Let's walk through some examples. Inflight code is always contained inside a block that starts with the word `inflight`. -```js +```js example let greeting = inflight () => { log("Hello from the cloud!"); }; @@ -93,7 +93,7 @@ let greeting = inflight () => { Inflight code can call other inflight functions and methods. For example, `cloud.Bucket` has an inflight method named `list()` that can be called inside inflight contexts: -```js playground +```js playground example bring cloud; let bucket = new cloud.Bucket(); @@ -110,7 +110,7 @@ Even though `bucket` is defined in preflight, it's okay to use its inflight meth For an inflight function to actually get executed, it must be provided to an API that expects inflight code. For example, we can provide it to a `cloud.Function`: -```js playground +```js playground example bring cloud; let func = new cloud.Function(inflight () => { @@ -133,7 +133,7 @@ firstObject(); // error: Cannot call into inflight phase while preflight Likewise, inflight code cannot call preflight code, because preflight code has the capability to modify your application's infrastructure configuration, which is disallowed after deployment. For example, since `addObject` is a preflight method, it cannot be called in inflight: -```js playground +```js playground example{valid: false} bring cloud; let bucket = new cloud.Bucket(); @@ -147,7 +147,7 @@ Instead, to insert an object into the bucket at runtime you would have to use an Since a class's initializer is just a special kind of preflight function, it also isn't possible to initialize regular classes during preflight: -```js playground +```js playground example{valid: false} bring cloud; inflight () => { @@ -163,7 +163,7 @@ A preflight class (the default kind of class) can contain both preflight and inf Here's a class that models a queue that can replay its messages. A `cloud.Bucket` stores the history of messages, and a `cloud.Counter` helps with sequencing each new message as it's added to the queue. -```js playground +```js playground example bring cloud; class ReplayableQueue { @@ -202,7 +202,7 @@ Inflight classes are safe to create in inflight contexts. For example, this inflight class can be created in an inflight contexts, and its methods can be called in inflight contexts: -```js playground +```js playground example inflight () => { class Person { name: str; @@ -213,7 +213,7 @@ inflight () => { this.age = age; } - inflight greet() { + pub inflight greet() { log("Hello, {this.name}!"); } } @@ -230,7 +230,7 @@ While inflight code can't call preflight code, it's perfectly ok to reference da For example, the `cloud.Api` class has a preflight field named `url`. Since the URL is a string, it can be directly referenced inflight: -```js +```js example bring cloud; bring http; @@ -254,7 +254,7 @@ new cloud.Function(checkEndpoint); However, mutation to preflight data is not allowed. This mean means that variables from preflight cannot be reassigned to, and mutable collections like `MutArray` and `MutMap` cannot be modified (they're turned into their immutable counterparts, `Array` and `Map`, respectively when accessed inflight). -```js playground +```js playground example{valid: false} let var count = 3; let names = MutArray["John", "Jane", "Joe"]; @@ -271,7 +271,7 @@ inflight () => { Preflight objects referenced inflight are called "lifted" objects: -```js playground +```js playground example let preflight_str = "hello from preflight"; inflight () => { log(preflight_str); // `preflight_str` is "lifted" into inflight. @@ -281,7 +281,7 @@ inflight () => { During the lifting process the compiler tries to figure out in what way the lifted objects are being used. This is how Winglang generats least privilage permissions. Consider the case of lifting a [`cloud.Bucket`](../04-standard-library/cloud/bucket.md) object: -```js playground +```js playground example bring cloud; let bucket = new cloud.Bucket(); new cloud.Function(inflight () => { @@ -294,7 +294,7 @@ In this example the compiler generates the correct _write_ access permissions fo #### Explicit lift qualification In some cases the compiler can't figure out (yet) the lift qualifications, and therefore will report an error: -```js playground +```js playground example{valid: false} bring cloud; let main_bucket = new cloud.Bucket() as "main"; let secondary_bucket = new cloud.Bucket() as "backup"; diff --git a/docs/docs/02-concepts/02-application-tree.md b/docs/docs/02-concepts/02-application-tree.md index d1536113e34..02318fda115 100644 --- a/docs/docs/02-concepts/02-application-tree.md +++ b/docs/docs/02-concepts/02-application-tree.md @@ -3,12 +3,16 @@ id: application-tree title: Application tree --- +## Instance names + Instances of preflight classes in Wing are identified by a unique name. The name is used to identify the resource in the Wing Console, and is used to determine the logical name assigned to the resource in the infrastructure provisioning engine (such as Terraform or CloudFormation), and the physical name of the resource in the target cloud provider (such as AWS, Azure, or GCP). The default name of a resource is the name of the class. The name can be overridden using the `as` syntax: -```js +```js example +bring cloud; + let bucket1 = new cloud.Bucket(); // default name is "cloud.Bucket" let bucket2 = new cloud.Bucket() as "my-bucket"; ``` @@ -16,7 +20,9 @@ let bucket2 = new cloud.Bucket() as "my-bucket"; The name of a resource needs to be unique within the scope it is defined. New classes introduce new scopes, so the same name can be used for different resources in different classes. -```js +```js example +bring cloud; + class Group1 { new() { new cloud.Bucket() as "Store"; @@ -34,6 +40,126 @@ new Group1(); new Group2(); ``` +## Instance scope + +Instances of preflight classes define the application's construct tree. This way you can think about the generated cloud infrastructure as a logical tree where each node is some logical piece of infrastructure which may contain children nodes which are also part of your application's infrastructure (composition). + +In Wing this tree structure is automatically generated. Any class instantiated at the top level (global scope) is a child of the "root" node of of the tree. +Any class instantiated inside another class (in its constructor or one of its other preflight methods) will be placed as a child of that other class. + +```js example +class ThumbnailBucket { + //... +} + +class ImageStorage { + new() { + new ThumbnailBucket(); // This ThumbnailBucket will be a child of a ImageStorage instance in the construct tree + } +} + +new ImageStorage(); // This ImageStorage will be a child of the root in the construct tree +new ThumbnailBucket(); // This Counter will be a child of of the root in the construct tree + +// Here's a tree view of the generated infrastructure: +// +// root +// /\ +// ImageStorage ThumbnailBucket +// / +// ThumbnailBucket +``` + +As mentioned in the [previous section](#instance-names) each instance must have a unique name within its scope. +And the name will be automatically generated based on the class name. +So the tree shown above also shows the correct names for each infrastructure piece of our application. +You may query information about the construct tree using the `nodeof(someInstance)` intrinsic function: +```js example +bring cloud; + +let b = new cloud.Bucket(); +log(nodeof(b).path); // Will log something like "/root/Bucket" +``` + +### Controlling instance scope + +You may define an explicit scope for an instance instead of using Wing's default of placing it inside the instance where it was created using the `in` keyword: + +```js example +class ThumbnailBucket { + //... +} + +class ImageStorage { + new() { + new ThumbnailBucket(); // This ThumbnailBucket will be a child of a ImageStorage instance in the construct tree + } +} + +let imageStorage = new ImageStorage(); // This goes in root +let defaultThumbnails = new ThumbnailBucket() as "defaultThumbs" in imageStorage; // This is explicitly named "defaultThumbs" and explicitly placed inside imageStorage + +// Here's a tree view of the generated infrastructure: +// +// root +// / +// ImageStorage +// / \ +// ThumbnailBucket defaultsThumbs +``` + +### Instances created inside static methods + +Preflight classes instantiated inside static methods, or instantiated inside constructors before `this` is available will use the +scope of the caller by default: + +```js example +bring cloud; + +class Factory { + pub static make() { + new cloud.Bucket(); // We're in a static, so we don't know where to place this bucket + } +} + +class MyBucket { + new() { + Factory.make(); // Bucket will be placed inside `this` instance of `MyBucket` + } +} +new MyBucket(); + +// tree: +// root +// / +// MyBucket +// / +// Bucket +``` + +Similarly, consider this case where we instantiate a class inside a parameter in a `super()` constructor call before the +the class is creates and its scope becomes valid: + +```js example +bring cloud; + +class Base { + new(b: cloud.Bucket) {} +} +class Derived extends Base { + new() { + super(new cloud.Bucket()); // Bucket create before `Derived` so scope defaults to caller + } +} +new Derived(); + +// tree: +// root +// /\ +// Bucket Derived +``` + +## Interop Classes in Wing are an extension of the [Construct Programming Model] and as such any [AWS Constructs] can be natively used in Wing applications. [Construct Programming Model]: https://docs.aws.amazon.com/cdk/v2/guide/constructs.html diff --git a/docs/docs/02-concepts/03-platforms.md b/docs/docs/02-concepts/03-platforms.md index aa5ab0f2144..f916a0dff63 100644 --- a/docs/docs/02-concepts/03-platforms.md +++ b/docs/docs/02-concepts/03-platforms.md @@ -123,7 +123,7 @@ There might be times when you need to write code that is specific to a particula With the Wing `util` library, you can access environment variables. The `WING_TARGET` environment variable contains the current platform target as it's value, which you can use to conditionally run target-specific code. See the example below: -```js playground +```js playground example bring cloud; bring util; diff --git a/docs/docs/02-concepts/04-tests.md b/docs/docs/02-concepts/04-tests.md index 8f82720da56..7fb091a6be0 100644 --- a/docs/docs/02-concepts/04-tests.md +++ b/docs/docs/02-concepts/04-tests.md @@ -11,7 +11,7 @@ Winglang incorporates a lightweight testing framework, which is built around the You can create a test by adding the following code structure to any Winglang file (.w): -```ts wing +```ts wing example test "" { // test code } @@ -21,7 +21,7 @@ If a test throws an exception (typically using the `assert` function), it's cons Here's an example: -```ts playground +```ts playground example // example.w bring math; @@ -50,7 +50,7 @@ Duration 0m0.54s Every Winglang test is executed in complete isolation. Take a look at the following code: -```ts playground +```ts playground example bring cloud; let b = new cloud.Bucket(); @@ -73,7 +73,7 @@ In the first test (`bucket list should include created file`), a file is created Consider the following example: -```ts playground +```ts playground example bring cloud; bring util; @@ -140,7 +140,7 @@ Wing Console provides a straightforward method to run either a single test or al Consider the following code: -```ts playground +```ts playground example{valid: false} // example.w bring cloud; diff --git a/docs/docs/04-standard-library/cloud/api.md b/docs/docs/04-standard-library/cloud/api.md index 0890de10a03..5127273d45e 100644 --- a/docs/docs/04-standard-library/cloud/api.md +++ b/docs/docs/04-standard-library/cloud/api.md @@ -29,7 +29,7 @@ When a client invokes a route, the corresponding event handler function executes The following example shows a complete REST API implementation using `cloud.Api`, `ex.Table` & `cloud.Counter` -```ts playground +```ts playground example bring cloud; bring ex; @@ -558,6 +558,84 @@ bring cloud; let ApiConnectOptions = cloud.ApiConnectOptions{ ... }; ``` +#### Properties + +| **Name** | **Type** | **Description** | +| --- | --- | --- | +| concurrency | num | The maximum concurrent invocations that can run at one time. | +| env | MutMap<str> | Environment variables to pass to the function. | +| logRetentionDays | num | Specifies the number of days that function logs will be kept. | +| memory | num | The amount of memory to allocate to the function, in MB. | +| timeout | duration | The maximum amount of time the function can run. | + +--- + +##### `concurrency`Optional + +```wing +concurrency: num; +``` + +- *Type:* num +- *Default:* platform specific limits (100 on the simulator) + +The maximum concurrent invocations that can run at one time. + +--- + +##### `env`Optional + +```wing +env: MutMap; +``` + +- *Type:* MutMap<str> +- *Default:* No environment variables. + +Environment variables to pass to the function. + +--- + +##### `logRetentionDays`Optional + +```wing +logRetentionDays: num; +``` + +- *Type:* num +- *Default:* 30 + +Specifies the number of days that function logs will be kept. + +Setting negative value means logs will not expire. + +--- + +##### `memory`Optional + +```wing +memory: num; +``` + +- *Type:* num +- *Default:* 1024 + +The amount of memory to allocate to the function, in MB. + +--- + +##### `timeout`Optional + +```wing +timeout: duration; +``` + +- *Type:* duration +- *Default:* 1m + +The maximum amount of time the function can run. + +--- ### ApiCorsOptions @@ -702,6 +780,175 @@ bring cloud; let ApiDeleteOptions = cloud.ApiDeleteOptions{ ... }; ``` +#### Properties + +| **Name** | **Type** | **Description** | +| --- | --- | --- | +| concurrency | num | The maximum concurrent invocations that can run at one time. | +| env | MutMap<str> | Environment variables to pass to the function. | +| logRetentionDays | num | Specifies the number of days that function logs will be kept. | +| memory | num | The amount of memory to allocate to the function, in MB. | +| timeout | duration | The maximum amount of time the function can run. | + +--- + +##### `concurrency`Optional + +```wing +concurrency: num; +``` + +- *Type:* num +- *Default:* platform specific limits (100 on the simulator) + +The maximum concurrent invocations that can run at one time. + +--- + +##### `env`Optional + +```wing +env: MutMap; +``` + +- *Type:* MutMap<str> +- *Default:* No environment variables. + +Environment variables to pass to the function. + +--- + +##### `logRetentionDays`Optional + +```wing +logRetentionDays: num; +``` + +- *Type:* num +- *Default:* 30 + +Specifies the number of days that function logs will be kept. + +Setting negative value means logs will not expire. + +--- + +##### `memory`Optional + +```wing +memory: num; +``` + +- *Type:* num +- *Default:* 1024 + +The amount of memory to allocate to the function, in MB. + +--- + +##### `timeout`Optional + +```wing +timeout: duration; +``` + +- *Type:* duration +- *Default:* 1m + +The maximum amount of time the function can run. + +--- + +### ApiEndpointOptions + +Base options for Api endpoints. + +#### Initializer + +```wing +bring cloud; + +let ApiEndpointOptions = cloud.ApiEndpointOptions{ ... }; +``` + +#### Properties + +| **Name** | **Type** | **Description** | +| --- | --- | --- | +| concurrency | num | The maximum concurrent invocations that can run at one time. | +| env | MutMap<str> | Environment variables to pass to the function. | +| logRetentionDays | num | Specifies the number of days that function logs will be kept. | +| memory | num | The amount of memory to allocate to the function, in MB. | +| timeout | duration | The maximum amount of time the function can run. | + +--- + +##### `concurrency`Optional + +```wing +concurrency: num; +``` + +- *Type:* num +- *Default:* platform specific limits (100 on the simulator) + +The maximum concurrent invocations that can run at one time. + +--- + +##### `env`Optional + +```wing +env: MutMap; +``` + +- *Type:* MutMap<str> +- *Default:* No environment variables. + +Environment variables to pass to the function. + +--- + +##### `logRetentionDays`Optional + +```wing +logRetentionDays: num; +``` + +- *Type:* num +- *Default:* 30 + +Specifies the number of days that function logs will be kept. + +Setting negative value means logs will not expire. + +--- + +##### `memory`Optional + +```wing +memory: num; +``` + +- *Type:* num +- *Default:* 1024 + +The amount of memory to allocate to the function, in MB. + +--- + +##### `timeout`Optional + +```wing +timeout: duration; +``` + +- *Type:* duration +- *Default:* 1m + +The maximum amount of time the function can run. + +--- ### ApiGetOptions @@ -715,6 +962,84 @@ bring cloud; let ApiGetOptions = cloud.ApiGetOptions{ ... }; ``` +#### Properties + +| **Name** | **Type** | **Description** | +| --- | --- | --- | +| concurrency | num | The maximum concurrent invocations that can run at one time. | +| env | MutMap<str> | Environment variables to pass to the function. | +| logRetentionDays | num | Specifies the number of days that function logs will be kept. | +| memory | num | The amount of memory to allocate to the function, in MB. | +| timeout | duration | The maximum amount of time the function can run. | + +--- + +##### `concurrency`Optional + +```wing +concurrency: num; +``` + +- *Type:* num +- *Default:* platform specific limits (100 on the simulator) + +The maximum concurrent invocations that can run at one time. + +--- + +##### `env`Optional + +```wing +env: MutMap; +``` + +- *Type:* MutMap<str> +- *Default:* No environment variables. + +Environment variables to pass to the function. + +--- + +##### `logRetentionDays`Optional + +```wing +logRetentionDays: num; +``` + +- *Type:* num +- *Default:* 30 + +Specifies the number of days that function logs will be kept. + +Setting negative value means logs will not expire. + +--- + +##### `memory`Optional + +```wing +memory: num; +``` + +- *Type:* num +- *Default:* 1024 + +The amount of memory to allocate to the function, in MB. + +--- + +##### `timeout`Optional + +```wing +timeout: duration; +``` + +- *Type:* duration +- *Default:* 1m + +The maximum amount of time the function can run. + +--- ### ApiHeadOptions @@ -728,6 +1053,84 @@ bring cloud; let ApiHeadOptions = cloud.ApiHeadOptions{ ... }; ``` +#### Properties + +| **Name** | **Type** | **Description** | +| --- | --- | --- | +| concurrency | num | The maximum concurrent invocations that can run at one time. | +| env | MutMap<str> | Environment variables to pass to the function. | +| logRetentionDays | num | Specifies the number of days that function logs will be kept. | +| memory | num | The amount of memory to allocate to the function, in MB. | +| timeout | duration | The maximum amount of time the function can run. | + +--- + +##### `concurrency`Optional + +```wing +concurrency: num; +``` + +- *Type:* num +- *Default:* platform specific limits (100 on the simulator) + +The maximum concurrent invocations that can run at one time. + +--- + +##### `env`Optional + +```wing +env: MutMap; +``` + +- *Type:* MutMap<str> +- *Default:* No environment variables. + +Environment variables to pass to the function. + +--- + +##### `logRetentionDays`Optional + +```wing +logRetentionDays: num; +``` + +- *Type:* num +- *Default:* 30 + +Specifies the number of days that function logs will be kept. + +Setting negative value means logs will not expire. + +--- + +##### `memory`Optional + +```wing +memory: num; +``` + +- *Type:* num +- *Default:* 1024 + +The amount of memory to allocate to the function, in MB. + +--- + +##### `timeout`Optional + +```wing +timeout: duration; +``` + +- *Type:* duration +- *Default:* 1m + +The maximum amount of time the function can run. + +--- ### ApiOptionsOptions @@ -741,6 +1144,84 @@ bring cloud; let ApiOptionsOptions = cloud.ApiOptionsOptions{ ... }; ``` +#### Properties + +| **Name** | **Type** | **Description** | +| --- | --- | --- | +| concurrency | num | The maximum concurrent invocations that can run at one time. | +| env | MutMap<str> | Environment variables to pass to the function. | +| logRetentionDays | num | Specifies the number of days that function logs will be kept. | +| memory | num | The amount of memory to allocate to the function, in MB. | +| timeout | duration | The maximum amount of time the function can run. | + +--- + +##### `concurrency`Optional + +```wing +concurrency: num; +``` + +- *Type:* num +- *Default:* platform specific limits (100 on the simulator) + +The maximum concurrent invocations that can run at one time. + +--- + +##### `env`Optional + +```wing +env: MutMap; +``` + +- *Type:* MutMap<str> +- *Default:* No environment variables. + +Environment variables to pass to the function. + +--- + +##### `logRetentionDays`Optional + +```wing +logRetentionDays: num; +``` + +- *Type:* num +- *Default:* 30 + +Specifies the number of days that function logs will be kept. + +Setting negative value means logs will not expire. + +--- + +##### `memory`Optional + +```wing +memory: num; +``` + +- *Type:* num +- *Default:* 1024 + +The amount of memory to allocate to the function, in MB. + +--- + +##### `timeout`Optional + +```wing +timeout: duration; +``` + +- *Type:* duration +- *Default:* 1m + +The maximum amount of time the function can run. + +--- ### ApiPatchOptions @@ -754,6 +1235,84 @@ bring cloud; let ApiPatchOptions = cloud.ApiPatchOptions{ ... }; ``` +#### Properties + +| **Name** | **Type** | **Description** | +| --- | --- | --- | +| concurrency | num | The maximum concurrent invocations that can run at one time. | +| env | MutMap<str> | Environment variables to pass to the function. | +| logRetentionDays | num | Specifies the number of days that function logs will be kept. | +| memory | num | The amount of memory to allocate to the function, in MB. | +| timeout | duration | The maximum amount of time the function can run. | + +--- + +##### `concurrency`Optional + +```wing +concurrency: num; +``` + +- *Type:* num +- *Default:* platform specific limits (100 on the simulator) + +The maximum concurrent invocations that can run at one time. + +--- + +##### `env`Optional + +```wing +env: MutMap; +``` + +- *Type:* MutMap<str> +- *Default:* No environment variables. + +Environment variables to pass to the function. + +--- + +##### `logRetentionDays`Optional + +```wing +logRetentionDays: num; +``` + +- *Type:* num +- *Default:* 30 + +Specifies the number of days that function logs will be kept. + +Setting negative value means logs will not expire. + +--- + +##### `memory`Optional + +```wing +memory: num; +``` + +- *Type:* num +- *Default:* 1024 + +The amount of memory to allocate to the function, in MB. + +--- + +##### `timeout`Optional + +```wing +timeout: duration; +``` + +- *Type:* duration +- *Default:* 1m + +The maximum amount of time the function can run. + +--- ### ApiPostOptions @@ -767,6 +1326,84 @@ bring cloud; let ApiPostOptions = cloud.ApiPostOptions{ ... }; ``` +#### Properties + +| **Name** | **Type** | **Description** | +| --- | --- | --- | +| concurrency | num | The maximum concurrent invocations that can run at one time. | +| env | MutMap<str> | Environment variables to pass to the function. | +| logRetentionDays | num | Specifies the number of days that function logs will be kept. | +| memory | num | The amount of memory to allocate to the function, in MB. | +| timeout | duration | The maximum amount of time the function can run. | + +--- + +##### `concurrency`Optional + +```wing +concurrency: num; +``` + +- *Type:* num +- *Default:* platform specific limits (100 on the simulator) + +The maximum concurrent invocations that can run at one time. + +--- + +##### `env`Optional + +```wing +env: MutMap; +``` + +- *Type:* MutMap<str> +- *Default:* No environment variables. + +Environment variables to pass to the function. + +--- + +##### `logRetentionDays`Optional + +```wing +logRetentionDays: num; +``` + +- *Type:* num +- *Default:* 30 + +Specifies the number of days that function logs will be kept. + +Setting negative value means logs will not expire. + +--- + +##### `memory`Optional + +```wing +memory: num; +``` + +- *Type:* num +- *Default:* 1024 + +The amount of memory to allocate to the function, in MB. + +--- + +##### `timeout`Optional + +```wing +timeout: duration; +``` + +- *Type:* duration +- *Default:* 1m + +The maximum amount of time the function can run. + +--- ### ApiProps @@ -847,6 +1484,84 @@ bring cloud; let ApiPutOptions = cloud.ApiPutOptions{ ... }; ``` +#### Properties + +| **Name** | **Type** | **Description** | +| --- | --- | --- | +| concurrency | num | The maximum concurrent invocations that can run at one time. | +| env | MutMap<str> | Environment variables to pass to the function. | +| logRetentionDays | num | Specifies the number of days that function logs will be kept. | +| memory | num | The amount of memory to allocate to the function, in MB. | +| timeout | duration | The maximum amount of time the function can run. | + +--- + +##### `concurrency`Optional + +```wing +concurrency: num; +``` + +- *Type:* num +- *Default:* platform specific limits (100 on the simulator) + +The maximum concurrent invocations that can run at one time. + +--- + +##### `env`Optional + +```wing +env: MutMap; +``` + +- *Type:* MutMap<str> +- *Default:* No environment variables. + +Environment variables to pass to the function. + +--- + +##### `logRetentionDays`Optional + +```wing +logRetentionDays: num; +``` + +- *Type:* num +- *Default:* 30 + +Specifies the number of days that function logs will be kept. + +Setting negative value means logs will not expire. + +--- + +##### `memory`Optional + +```wing +memory: num; +``` + +- *Type:* num +- *Default:* 1024 + +The amount of memory to allocate to the function, in MB. + +--- + +##### `timeout`Optional + +```wing +timeout: duration; +``` + +- *Type:* duration +- *Default:* 1m + +The maximum amount of time the function can run. + +--- ### ApiRequest diff --git a/docs/docs/04-standard-library/cloud/bucket.md b/docs/docs/04-standard-library/cloud/bucket.md index 1bd87a94960..8844346e31e 100644 --- a/docs/docs/04-standard-library/cloud/bucket.md +++ b/docs/docs/04-standard-library/cloud/bucket.md @@ -26,7 +26,7 @@ Unlike other kinds of storage like file storage, data is not stored in a hierarc ### Defining a bucket -```js +```js example bring cloud; let bucket = new cloud.Bucket( @@ -38,7 +38,7 @@ let bucket = new cloud.Bucket( If you have static data that you want to upload to the bucket each time your app is deployed, you can call the preflight method `addObject`: -```js +```js example bring cloud; let bucket = new cloud.Bucket(); @@ -48,7 +48,7 @@ bucket.addObject("my-file.txt", "Hello, world!"); ### Using a bucket inflight -```js playground +```js playground example bring cloud; let bucket = new cloud.Bucket(); @@ -80,7 +80,7 @@ Use the `onEvent` method for responding to any event. Each method creates a new `cloud.Function` resource which will be triggered by the given event type. -```js playground +```js playground example bring cloud; let store = new cloud.Bucket(); diff --git a/docs/docs/04-standard-library/cloud/counter.md b/docs/docs/04-standard-library/cloud/counter.md index e587a2d78e4..2d5aecf564f 100644 --- a/docs/docs/04-standard-library/cloud/counter.md +++ b/docs/docs/04-standard-library/cloud/counter.md @@ -20,7 +20,7 @@ The `cloud.Counter` resource represents a stateful container for one or more num ### Defining a counter -```js +```js example bring cloud; let counter = new cloud.Counter( @@ -30,7 +30,7 @@ let counter = new cloud.Counter( ### Using a counter inflight -```js playground +```js playground example bring cloud; let counter = new cloud.Counter(); @@ -51,7 +51,7 @@ new cloud.Function(counterFunc); ### Using keys to manage multiple counter values -```js playground +```js playground example bring cloud; let counter = new cloud.Counter(initial: 100); diff --git a/docs/docs/04-standard-library/cloud/endpoint.md b/docs/docs/04-standard-library/cloud/endpoint.md index 7e11f592e76..c9a3abcc1fb 100644 --- a/docs/docs/04-standard-library/cloud/endpoint.md +++ b/docs/docs/04-standard-library/cloud/endpoint.md @@ -19,7 +19,7 @@ The `cloud.Endpoint` represents a publicly accessible endpoint and outputs it as ## Usage -```ts playground +```ts playground example bring cloud; let endpoint = new cloud.Endpoint("https://example.com"); @@ -54,7 +54,7 @@ represents a publicly accessible endpoint and outputs it as part of the compilat #### Initializers -```wing +```wing example bring cloud; new cloud.Endpoint("https://example.com"); diff --git a/docs/docs/04-standard-library/cloud/function.md b/docs/docs/04-standard-library/cloud/function.md index 5165c72ae8e..16af5f6bd26 100644 --- a/docs/docs/04-standard-library/cloud/function.md +++ b/docs/docs/04-standard-library/cloud/function.md @@ -29,7 +29,7 @@ A function can be invoked in two ways: * **invoke()** - Executes the function with a payload and waits for the result. * **invokeAsync()** - Kicks off the execution of the function with a payload and returns immediately while the function is running. -```ts playground +```ts playground example bring cloud; bring util; @@ -61,7 +61,7 @@ It is possible to leverage this behavior to cache objects across function execut The following example reads the `bigdata.json` file once and reuses it every time `query()` is called. -```ts playground +```ts playground example bring cloud; let big = new cloud.Bucket(); @@ -98,6 +98,8 @@ The sim implementation of `cloud.Function` runs the inflight code as a JavaScrip By default, a maximum of 10 workers can be processing requests sent to a `cloud.Function` concurrently, but this number can be adjusted with the `concurrency` property: ```ts playground +bring cloud; + new cloud.Function(inflight () => { // ... code that shouldn't run concurrently ... }, concurrency: 1); @@ -109,7 +111,7 @@ The AWS implementation of `cloud.Function` uses [AWS Lambda](https://aws.amazon. To add extra IAM permissions to the function, you can use the `aws.Function` class as shown below. -```ts playground +```ts playground example bring aws; bring cloud; @@ -129,7 +131,7 @@ if let lambdaFn = aws.Function.from(f) { To access the AWS Lambda context object, you can use the `aws.Function` class as shown below. -```ts playground +```ts playground example bring aws; bring cloud; diff --git a/docs/docs/04-standard-library/cloud/on-deploy.md b/docs/docs/04-standard-library/cloud/on-deploy.md index 10729023e07..5d41d3694bf 100644 --- a/docs/docs/04-standard-library/cloud/on-deploy.md +++ b/docs/docs/04-standard-library/cloud/on-deploy.md @@ -20,7 +20,7 @@ The `cloud.OnDeploy` resource runs a block of inflight code each time the applic ## Usage -```ts playground +```ts playground example bring cloud; let bucket = new cloud.Bucket(); @@ -35,7 +35,7 @@ let setup = new cloud.OnDeploy(inflight () => { To specify that the `cloud.OnDeploy` resource should be run before or after another resource is created or updated, use the `executeBefore` or `executeAfter` properties: -```ts playground +```ts playground example bring cloud; let counter = new cloud.Counter(); diff --git a/docs/docs/04-standard-library/cloud/queue.md b/docs/docs/04-standard-library/cloud/queue.md index 978bc328718..1b5bcbc21b7 100644 --- a/docs/docs/04-standard-library/cloud/queue.md +++ b/docs/docs/04-standard-library/cloud/queue.md @@ -26,7 +26,7 @@ Queues by default are not FIFO (first in, first out) - so the order of messages ### Setting a Queue Consumer -```ts playground +```ts playground example bring cloud; let q = new cloud.Queue(); @@ -45,7 +45,7 @@ new cloud.Function(inflight () => { Pushing messages, popping them, and purging. -```ts playground +```ts playground example bring cloud; let q = new cloud.Queue(); @@ -54,8 +54,8 @@ new cloud.Function(inflight () => { q.push("message a"); q.push("message b", "message c", "message d"); log("approxSize is ${q.approxSize()}"); - log("popping message ${q.pop()}"); - log("popping message ${q.pop()}"); + log("popping message ${q.pop()!}"); + log("popping message ${q.pop()!}"); log("approxSize is ${q.approxSize()}"); q.purge(); log("approxSize is ${q.approxSize()}"); @@ -66,7 +66,7 @@ new cloud.Function(inflight () => { Creating a queue and adding a dead-letter queue with the maximum number of attempts configured -```ts playground +```ts playground example bring cloud; let dlq = new cloud.Queue() as "dead-letter queue"; @@ -88,7 +88,8 @@ If you would like to reference an existing queue from within your application yo The following example defines a reference to an Amazon SQS queue with a specific ARN and sends a message to the queue from the function: -```js +```js example +bring cloud; bring aws; let outbox = new aws.QueueRef("arn:aws:sqs:us-east-1:111111111111:Outbox"); diff --git a/docs/docs/04-standard-library/cloud/schedule.md b/docs/docs/04-standard-library/cloud/schedule.md index 9f0a3864870..7da90e055c4 100644 --- a/docs/docs/04-standard-library/cloud/schedule.md +++ b/docs/docs/04-standard-library/cloud/schedule.md @@ -23,10 +23,10 @@ The timezone used in cron expressions is always UTC. ### From cron -```ts playground +```ts playground example bring cloud; -let schedule = new cloud.Schedule(cron: "* * * * ?"); +let schedule = new cloud.Schedule(cron: "* * * * *"); schedule.onTick(inflight () => { log("schedule: triggered"); @@ -35,7 +35,7 @@ schedule.onTick(inflight () => { ### From rate -```ts playground +```ts playground example bring cloud; let schedule = new cloud.Schedule(rate: 1m); diff --git a/docs/docs/04-standard-library/cloud/secret.md b/docs/docs/04-standard-library/cloud/secret.md index 8bf2da0326f..73dafd421eb 100644 --- a/docs/docs/04-standard-library/cloud/secret.md +++ b/docs/docs/04-standard-library/cloud/secret.md @@ -19,11 +19,13 @@ The `cloud.Secret` class represents a secret value (like an API key, certificate Secrets are encrypted at rest and in transit, and are only decrypted when they are used in a task. Storing a secret allows you to use the value in different compute tasks while only having to rotate or revoke it in one place. +You can use the [`wing secrets`](https://www.winglang.io/docs/tools/cli#store-secrets-wing-secrets) command to store secrets in the target platform. + ## Usage ### Defining a secret -```js +```js example bring cloud; let secret = new cloud.Secret( @@ -35,7 +37,7 @@ Before deploying your application, you will be expected to store the secret valu ### Retrieving secret values -```js +```js example bring cloud; let secret = new cloud.Secret( diff --git a/docs/docs/04-standard-library/cloud/topic.md b/docs/docs/04-standard-library/cloud/topic.md index 56de75be3ee..11253eba690 100644 --- a/docs/docs/04-standard-library/cloud/topic.md +++ b/docs/docs/04-standard-library/cloud/topic.md @@ -22,7 +22,7 @@ Topics are a staple of event-driven architectures, especially those that rely on ### Creating a topic -```js +```js example bring cloud; let topic = new cloud.Topic(); @@ -30,7 +30,7 @@ let topic = new cloud.Topic(); ### Subscribing to a topic -```js +```js example bring cloud; let topic = new cloud.Topic(); @@ -42,11 +42,11 @@ topic.onMessage(inflight (message: str) => { ### Subscribing a Queue to a Topic -```js +```js example bring cloud; let queue = new cloud.Queue(); -queue.setConsumer(inflight (message str) => { +queue.setConsumer(inflight (message: str) => { log("Topic published message: {message}"); }); @@ -58,7 +58,7 @@ topic.subscribeQueue(queue); The inflight method `publish` sends messages to all of the topic's subscribers. -```js +```js example bring cloud; let topic = new cloud.Topic(); @@ -76,7 +76,7 @@ inflight () => { Here is an example of combining the preflight and inflight apis for a topic and creating an adorable simple pub-sub application. -```js +```js example bring cloud; // First we create a topic diff --git a/docs/docs/04-standard-library/sim/api-reference.md b/docs/docs/04-standard-library/sim/api-reference.md index ff5a0b1a75b..ce9323d766e 100644 --- a/docs/docs/04-standard-library/sim/api-reference.md +++ b/docs/docs/04-standard-library/sim/api-reference.md @@ -726,7 +726,9 @@ let ContainerProps = sim.ContainerProps{ ... }; | name | str | A name for the container. | | args | MutArray<str> | Container arguments. | | containerPort | num | Internal container port to expose. | +| entrypoint | str | Container entrypoint. | | env | MutMap<str> | Environment variables to set in the container. | +| network | str | Docker network to use for the container - such as 'host', 'bridge', etc. | | sourceHash | str | An explicit source hash that represents the container source. | | sourcePattern | str | A glob of local files to consider as input sources for the container, relative to the build context directory. | | volumes | MutArray<str> | Volume mount points. | @@ -783,6 +785,19 @@ Internal container port to expose. --- +##### `entrypoint`Optional + +```wing +entrypoint: str; +``` + +- *Type:* str +- *Default:* default image entrypoint + +Container entrypoint. + +--- + ##### `env`Optional ```wing @@ -796,6 +811,28 @@ Environment variables to set in the container. --- +##### `network`Optional + +```wing +network: str; +``` + +- *Type:* str +- *Default:* default docker network + +Docker network to use for the container - such as 'host', 'bridge', etc. + +> [https://docs.docker.com/network.](https://docs.docker.com/network.) + +--- + +*Example* + +```wing +'host' +``` + + ##### `sourceHash`Optional ```wing diff --git a/docs/docs/04-standard-library/sim/container.md b/docs/docs/04-standard-library/sim/container.md index d3915ba26e4..086e22c58ab 100644 --- a/docs/docs/04-standard-library/sim/container.md +++ b/docs/docs/04-standard-library/sim/container.md @@ -235,7 +235,9 @@ let ContainerProps = sim.ContainerProps{ ... }; | name | str | A name for the container. | | args | MutArray<str> | Container arguments. | | containerPort | num | Internal container port to expose. | +| entrypoint | str | Container entrypoint. | | env | MutMap<str> | Environment variables to set in the container. | +| network | str | Docker network to use for the container - such as 'host', 'bridge', etc. | | sourceHash | str | An explicit source hash that represents the container source. | | sourcePattern | str | A glob of local files to consider as input sources for the container, relative to the build context directory. | | volumes | MutArray<str> | Volume mount points. | @@ -292,6 +294,19 @@ Internal container port to expose. --- +##### `entrypoint`Optional + +```wing +entrypoint: str; +``` + +- *Type:* str +- *Default:* default image entrypoint + +Container entrypoint. + +--- + ##### `env`Optional ```wing @@ -305,6 +320,28 @@ Environment variables to set in the container. --- +##### `network`Optional + +```wing +network: str; +``` + +- *Type:* str +- *Default:* default docker network + +Docker network to use for the container - such as 'host', 'bridge', etc. + +> [https://docs.docker.com/network.](https://docs.docker.com/network.) + +--- + +*Example* + +```wing +'host' +``` + + ##### `sourceHash`Optional ```wing diff --git a/docs/docs/04-standard-library/std/node.md b/docs/docs/04-standard-library/std/node.md index a05920ea044..f6c4f60df8a 100644 --- a/docs/docs/04-standard-library/std/node.md +++ b/docs/docs/04-standard-library/std/node.md @@ -272,7 +272,9 @@ Invokes the `validate()` method on all validations added through | color | str | The color of the construct for display purposes. | | defaultChild | constructs.IConstruct | Returns the child construct that has the id `Default` or `Resource"`. | | description | str | Description of the construct for display purposes. | +| expanded | bool | Whether the node is expanded or collapsed by default in the UI. | | hidden | bool | Whether the construct should be hidden by default in tree visualizations. | +| icon | str | The icon of the construct for display purposes. | | sourceModule | str | The source file or library where the construct was defined. | | title | str | Title of the construct for display purposes. | @@ -499,6 +501,21 @@ Description of the construct for display purposes. --- +##### `expanded`Optional + +```wing +expanded: bool; +``` + +- *Type:* bool +- *Default:* false + +Whether the node is expanded or collapsed by default in the UI. + +By default, nodes are collapsed. Set this to `true` if you want the node to be expanded by default. + +--- + ##### `hidden`Optional ```wing @@ -511,6 +528,23 @@ Whether the construct should be hidden by default in tree visualizations. --- +##### `icon`Optional + +```wing +icon: str; +``` + +- *Type:* str + +The icon of the construct for display purposes. + +Supported icons are from Heroicons: +- https://heroicons.com/ +e.g. +- "academic-cap" + +--- + ##### `sourceModule`Optional ```wing @@ -553,8 +587,10 @@ let AddConnectionProps = AddConnectionProps{ ... }; | **Name** | **Type** | **Description** | | --- | --- | --- | | name | str | A name for the connection. | -| source | constructs.IConstruct | The source of the connection. | | target | constructs.IConstruct | The target of the connection. | +| source | constructs.IConstruct | The source of the connection. | +| sourceOp | str | An operation that the source construct supports. | +| targetOp | str | An operation that the target construct supports. | --- @@ -570,27 +606,54 @@ A name for the connection. --- -##### `source`Required +##### `target`Required + +```wing +target: IConstruct; +``` + +- *Type:* constructs.IConstruct + +The target of the connection. + +--- + +##### `source`Optional ```wing source: IConstruct; ``` - *Type:* constructs.IConstruct +- *Default:* this The source of the connection. --- -##### `target`Required +##### `sourceOp`Optional ```wing -target: IConstruct; +sourceOp: str; ``` -- *Type:* constructs.IConstruct +- *Type:* str +- *Default:* no operation -The target of the connection. +An operation that the source construct supports. + +--- + +##### `targetOp`Optional + +```wing +targetOp: str; +``` + +- *Type:* str +- *Default:* no operation + +An operation that the target construct supports. --- diff --git a/docs/docs/06-tools/01-cli.md b/docs/docs/06-tools/01-cli.md index 415d631e82b..c7ff114d0e9 100644 --- a/docs/docs/06-tools/01-cli.md +++ b/docs/docs/06-tools/01-cli.md @@ -33,7 +33,7 @@ Usage: $ wing new