More UI changes to the playground (#2683)

Changes:
## Get rid of emotion and use pure css modules

## Better UI for errors  and warnings
Errors and warnings are now an expandable banner at the bottom of the
UI, it is completely resizable in the same way as the left and right
pane are.

<img width="363" alt="image"
src="https://github.com/microsoft/typespec/assets/1031227/3c126340-10e4-4dca-9ba2-a76fa9393119">

<img width="336" alt="image"
src="https://github.com/microsoft/typespec/assets/1031227/0cef33df-df5e-4017-8d42-d7a8fbcb053f">

<img width="730" alt="image"
src="https://github.com/microsoft/typespec/assets/1031227/baab69fe-5ea5-497d-8377-110a809e467a">

## Output tabs align with the command bar and use fluentui tabs(Makes
the tabs accessible)

<img width="627" alt="image"
src="https://github.com/microsoft/typespec/assets/1031227/90f9ac54-3c02-48f1-a7ed-f85b57e360fd">
This commit is contained in:
Timothee Guerin 2023-11-22 08:43:13 -08:00 коммит произвёл GitHub
Родитель dbd0149b15
Коммит fb7de9c832
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
42 изменённых файлов: 768 добавлений и 444 удалений

Просмотреть файл

@ -0,0 +1,10 @@
{
"changes": [
{
"packageName": "@typespec/playground",
"comment": "Move errors and warnings to a dedicated expandable banner at the bottom of the playground.",
"type": "none"
}
],
"packageName": "@typespec/playground"
}

6
common/config/rush/pnpm-lock.yaml сгенерированный
Просмотреть файл

@ -849,9 +849,6 @@ importers:
../../packages/playground:
dependencies:
'@emotion/react':
specifier: ^11.11.1
version: 11.11.1(@types/react@18.2.34)(react@18.2.0)
'@fluentui/react-components':
specifier: ~9.32.1
version: 9.32.3(@types/react-dom@18.2.14)(@types/react@18.2.34)(react-dom@18.2.0)(react@18.2.0)(scheduler@0.20.2)
@ -1003,9 +1000,6 @@ importers:
../../packages/playground-website:
dependencies:
'@emotion/react':
specifier: ^11.11.1
version: 11.11.1(@types/react@18.2.34)(react@18.2.0)
'@typespec/compiler':
specifier: workspace:~0.50.0
version: link:../compiler

Просмотреть файл

@ -61,7 +61,6 @@
"@typespec/openapi": "workspace:~0.50.0",
"@typespec/protobuf": "workspace:~0.50.0",
"@typespec/html-program-viewer": "workspace:~0.50.0",
"@emotion/react": "^11.11.1",
"react-dom": "~18.2.0",
"react": "~18.2.0",
"es-module-shims": "~1.8.0"

Просмотреть файл

@ -14,9 +14,7 @@
"jsx": "react-jsx",
"checkJs": true,
"allowJs": true,
"jsxImportSource": "@emotion/react",
"lib": ["DOM", "ES2022"],
"types": ["@emotion/react"]
"lib": ["DOM", "ES2022"]
},
"include": [
"src/**/*.ts",

Просмотреть файл

@ -40,8 +40,8 @@
"default": "./dist/react/index.js"
},
"./react/viewers": {
"types": "./dist/src/react/viewers.d.ts",
"default": "./dist/react/viewers.js"
"types": "./dist/src/react/viewers/index.d.ts",
"default": "./dist/react/viewers/index.js"
},
"./style.css": "./dist/index.css"
},
@ -68,7 +68,6 @@
"!dist/test/**"
],
"dependencies": {
"@emotion/react": "^11.11.1",
"@fluentui/react-components": "~9.32.1",
"@fluentui/react-icons": "~2.0.217",
"@typespec/bundler": "workspace:~0.1.0-alpha.3",

Просмотреть файл

@ -13,9 +13,9 @@ const dependencies = Object.keys(packageJson.dependencies);
const external = [
...dependencies,
"swagger-ui-react/swagger-ui.css",
"@emotion/react/jsx-runtime",
"@typespec/bundler/vite",
"react-dom/client",
"react/jsx-runtime",
"vite",
"@vitejs/plugin-react",
"fs/promises",
@ -25,7 +25,7 @@ export default defineConfig([
input: {
index: "src/index.ts",
"react/index": "src/react/index.ts",
"react/viewers": "src/react/viewers.tsx",
"react/viewers/index": "src/react/viewers/index.tsx",
"tooling/index": "src/tooling/index.ts",
"vite/index": "src/vite/index.ts",
},

Просмотреть файл

@ -0,0 +1,11 @@
.bar {
border-bottom: 1px solid var(--colorNeutralStroke1);
}
.divider {
flex: 1;
}
.spacer {
width: 10px;
}

Просмотреть файл

@ -1,26 +1,12 @@
import {
Dialog,
DialogBody,
DialogSurface,
DialogTrigger,
Link,
Toolbar,
ToolbarButton,
Tooltip,
tokens,
} from "@fluentui/react-components";
import {
Broom16Filled,
Bug16Regular,
Save16Regular,
Settings24Regular,
} from "@fluentui/react-icons";
import { Link, Toolbar, ToolbarButton, Tooltip } from "@fluentui/react-components";
import { Broom16Filled, Bug16Regular, Save16Regular } from "@fluentui/react-icons";
import { CompilerOptions } from "@typespec/compiler";
import { FunctionComponent, useMemo } from "react";
import { EmitterDropdown } from "../react/emitter-dropdown.js";
import { SamplesDropdown } from "../react/samples-dropdown.js";
import { CompilerSettingsDialogButton } from "../react/settings/compiler-settings-dialog-button.js";
import { BrowserHost, PlaygroundSample } from "../types.js";
import { EmitterDropdown } from "./emitter-dropdown.js";
import { SamplesDropdown } from "./samples-dropdown.js";
import { CompilerSettings } from "./settings/compiler-settings.js";
import style from "./editor-command-bar.module.css";
export interface EditorCommandBarProps {
documentationUrl?: string;
@ -45,7 +31,7 @@ export const EditorCommandBar: FunctionComponent<EditorCommandBarProps> = ({
host,
selectedEmitter,
onSelectedEmitterChange,
compilerOptions: emitterOptions,
compilerOptions,
onCompilerOptionsChange,
samples,
selectedSampleName,
@ -70,7 +56,7 @@ export const EditorCommandBar: FunctionComponent<EditorCommandBarProps> = ({
);
return (
<div css={{ borderBottom: `1px solid ${tokens.colorNeutralStroke1}` }}>
<div className={style["bar"]}>
<Toolbar>
<Tooltip content="Save" relationship="description" withArrow>
<ToolbarButton aria-label="Save" icon={<Save16Regular />} onClick={saveCode as any} />
@ -79,35 +65,34 @@ export const EditorCommandBar: FunctionComponent<EditorCommandBarProps> = ({
<ToolbarButton aria-label="Format" icon={<Broom16Filled />} onClick={formatCode as any} />
</Tooltip>
{samples && (
<SamplesDropdown
samples={samples}
selectedSampleName={selectedSampleName}
onSelectedSampleNameChange={onSelectedSampleNameChange}
/>
<>
<SamplesDropdown
samples={samples}
selectedSampleName={selectedSampleName}
onSelectedSampleNameChange={onSelectedSampleNameChange}
/>
<div className={style["spacer"]}></div>
</>
)}
<EmitterDropdown
emitters={emitters}
onSelectedEmitterChange={onSelectedEmitterChange}
selectedEmitter={selectedEmitter}
/>
<Dialog>
<DialogTrigger>
<ToolbarButton icon={<Settings24Regular />} />
</DialogTrigger>
<DialogSurface>
<DialogBody>
<CompilerSettings
host={host}
selectedEmitter={selectedEmitter}
options={emitterOptions}
onOptionsChanged={onCompilerOptionsChange}
/>
</DialogBody>
</DialogSurface>
</Dialog>
{documentation}
<div css={{ flex: "1" }}></div>
{documentation && (
<>
<div className={style["spacer"]}></div>
{documentation}
</>
)}
<div className={style["divider"]}></div>
{bugButton}
<CompilerSettingsDialogButton
compilerOptions={compilerOptions}
onCompilerOptionsChange={onCompilerOptionsChange}
selectedEmitter={selectedEmitter}
/>
</Toolbar>
</div>
);
@ -124,9 +109,7 @@ const FileBugButton: FunctionComponent<FileBugButtonProps> = ({ onClick }) => {
aria-label="File Bug Report"
icon={<Bug16Regular />}
onClick={onClick as any}
>
File bug
</ToolbarButton>
></ToolbarButton>
</Tooltip>
);
};

Просмотреть файл

@ -1,42 +0,0 @@
import { css } from "@emotion/react";
import { tokens } from "@fluentui/react-components";
import type { Diagnostic } from "@typespec/compiler";
import { FunctionComponent } from "react";
export interface DiagnosticListProps {
readonly diagnostics: readonly Diagnostic[];
}
export const DiagnosticList: FunctionComponent<DiagnosticListProps> = ({ diagnostics }) => {
if (diagnostics.length === 0) {
return <div className="center">No errors</div>;
}
return (
<div css={{ height: "100%", overflow: "auto" }}>
{diagnostics.map((x, i) => {
return <DiagnosticItem key={i} diagnostic={x} />;
})}
</div>
);
};
export interface DiagnosticItemProps {
readonly diagnostic: Diagnostic;
}
export const DiagnosticItem: FunctionComponent<DiagnosticItemProps> = ({ diagnostic }) => {
return (
<div css={{ display: "flex" }}>
<div
css={[{ padding: "0 5px" }, diagnostic.severity === "error" ? errorColor : warningColor]}
>
{diagnostic.severity}
</div>
<div css={{ padding: "0 5px", color: tokens.colorNeutralForeground2 }}>{diagnostic.code}</div>
<div css={{ padding: "0 5px" }}>{diagnostic.message}</div>
</div>
);
};
const errorColor = css({ color: tokens.colorStatusDangerForeground1 });
const warningColor = css({ color: tokens.colorStatusWarningForeground1 });

Просмотреть файл

@ -0,0 +1,29 @@
.list {
}
.item {
cursor: pointer;
display: flex;
gap: 10px;
padding: 0 10px;
}
.item:hover {
background-color: var(--colorNeutralBackground3Hover);
}
.item--error {
color: var(--colorStatusDangerForeground1);
}
.item--warning {
color: var(--colorStatusWarningForeground1);
}
.item-code {
color: var(--colorBrandForeground2);
}
.item-loc {
color: var(--colorNeutralForeground3);
}

Просмотреть файл

@ -0,0 +1,80 @@
import { mergeClasses } from "@fluentui/react-components";
import {
getSourceLocation,
type Diagnostic,
type DiagnosticTarget,
type NoTarget,
} from "@typespec/compiler";
import { FunctionComponent, memo, useCallback } from "react";
import style from "./diagnostic-list.module.css";
export interface DiagnosticListProps {
readonly diagnostics: readonly Diagnostic[];
readonly onDiagnosticSelected?: (diagnostic: Diagnostic) => void;
}
export const DiagnosticList: FunctionComponent<DiagnosticListProps> = ({
diagnostics,
onDiagnosticSelected,
}) => {
if (diagnostics.length === 0) {
return <div className={style["list"]}>No errors</div>;
}
const handleItemSelected = useCallback(
(diagnostic: Diagnostic) => {
onDiagnosticSelected?.(diagnostic);
},
[onDiagnosticSelected]
);
return (
<div className={style["list"]}>
{diagnostics.map((x, i) => {
return <DiagnosticItem key={i} diagnostic={x} onItemSelected={handleItemSelected} />;
})}
</div>
);
};
interface DiagnosticItemProps {
readonly diagnostic: Diagnostic;
readonly onItemSelected: (diagnostic: Diagnostic) => void;
}
const DiagnosticItem: FunctionComponent<DiagnosticItemProps> = ({ diagnostic, onItemSelected }) => {
const handleClick = useCallback(() => {
onItemSelected(diagnostic);
}, [diagnostic, onItemSelected]);
return (
<div tabIndex={0} className={style["item"]} onClick={handleClick}>
<div
className={mergeClasses(
(style["item-severity"],
style[diagnostic.severity === "error" ? "item--error" : "item--warning"])
)}
>
{diagnostic.severity}
</div>
<div className={style["item-code"]}>{diagnostic.code}</div>
<div className={style["item-message"]}>{diagnostic.message}</div>
<div className={style["item-loc"]}>
<DiagnosticTargetLink target={diagnostic.target} />
</div>
</div>
);
};
const DiagnosticTargetLink = memo(({ target }: { target: DiagnosticTarget | typeof NoTarget }) => {
if (typeof target === "symbol") {
return <span></span>;
}
const location = getSourceLocation(target);
const file = location.file.path === "/test/main.tsp" ? "" : `${location.file.path}:`;
const { line, character } = location.file.getLineAndCharacterOfPosition(location.pos);
return (
<span>
{file}
{line + 1}:{character + 1}
</span>
);
});

Просмотреть файл

@ -53,7 +53,7 @@ export const Editor: FunctionComponent<EditorProps> = ({ model, options, actions
return (
<div
className="monaco-editor-container"
css={{ width: "100%", height: "100%", overflow: "hidden" }}
style={{ width: "100%", height: "100%", overflow: "hidden" }}
ref={editorContainerRef}
></div>
);

Просмотреть файл

@ -1,51 +0,0 @@
import { css } from "@emotion/react";
import { tokens } from "@fluentui/react-components";
import type { Diagnostic } from "@typespec/compiler";
import { FunctionComponent } from "react";
import { DiagnosticList } from "./diagnostic-list.js";
export interface ErrorTabProps {
readonly internalCompilerError?: any;
readonly diagnostics?: readonly Diagnostic[];
}
export const ErrorTab: FunctionComponent<ErrorTabProps> = ({
internalCompilerError,
diagnostics,
}) => {
return (
<>
{internalCompilerError && <InternalCompilerError error={internalCompilerError} />}
{diagnostics && <DiagnosticList diagnostics={diagnostics} />}
</>
);
};
export interface InternalCompilerErrorProps {
readonly error?: any;
}
export const InternalCompilerError: FunctionComponent<InternalCompilerErrorProps> = ({ error }) => {
return (
<div css={{ CenterStyles }}>
<div
css={{
border: `1px solid ${tokens.colorStatusDangerBorder1}`,
padding: "10px",
margin: "20px",
}}
>
<h3>Internal Compiler error</h3>
<div>File issue at https://github.com/microsoft/typespec</div>
<hr />
<div>{error.stack}</div>
</div>
</div>
);
};
const CenterStyles = css({
display: "flex",
height: "100%",
alignItems: "center",
justifyContent: "center",
});

Просмотреть файл

@ -0,0 +1,10 @@
.file-output {
position: relative;
height: 100%;
}
.viewer-selector {
margin: 0.5rem 1.5rem;
position: absolute;
z-index: 1;
right: 0;
}

Просмотреть файл

@ -1,7 +1,7 @@
import { css } from "@emotion/react";
import { Select, SelectOnChangeData } from "@fluentui/react-components";
import { FunctionComponent, useCallback, useMemo, useState } from "react";
import { FileOutputViewer } from "./types.js";
import { FileOutputViewer } from "../types.js";
import style from "./file-output.module.css";
export interface FileOutputProps {
filename: string;
@ -32,23 +32,18 @@ export const FileOutput: FunctionComponent<FileOutputProps> = ({ filename, conte
return viewers.find((x) => x.key === selected)?.render;
}, [selected, viewers]);
return (
<div css={{ width: "100%", height: "100%", overflow: "hidden" }}>
<Select value={selected} onChange={handleSelected} css={DropdownStyle}>
{viewers.map(({ key, label }) => (
<option key={key} value={key}>
{label}
</option>
))}
</Select>
<div className={style["file-output"]}>
<div className={style["viewer-selector"]}>
<Select value={selected} onChange={handleSelected}>
{viewers.map(({ key, label }) => (
<option key={key} value={key}>
{label}
</option>
))}
</Select>
</div>
{selectedRender && selectedRender({ filename, content })}
</div>
);
};
const DropdownStyle = css({
margin: "0.5rem 1.5rem",
position: "absolute",
"z-index": 1,
right: 0,
});

Просмотреть файл

@ -16,4 +16,4 @@ export {
createReactPlayground,
renderReactPlayground,
} from "./standalone.js";
export * from "./types.js";
export type * from "./types.js";

Просмотреть файл

@ -1,72 +0,0 @@
import { tokens } from "@fluentui/react-components";
import { FunctionComponent, ReactElement } from "react";
export interface Tab {
id: string;
name: string | ReactElement<any, any>;
align: "left" | "right";
}
export interface OutputTabsProps {
tabs: Tab[];
selected: string;
onSelect: (file: string) => void;
}
export const OutputTabs: FunctionComponent<OutputTabsProps> = ({ tabs, selected, onSelect }) => {
const [leftTabs, rightTabs] = chunk(tabs, (x) => x.align === "left");
return (
<div css={{ display: "flex", borderBottom: `1px solid ${tokens.colorNeutralStroke1}` }}>
{leftTabs.map((tab) => {
return (
<OutputTab key={tab.id} tab={tab} selected={selected === tab.id} onSelect={onSelect} />
);
})}
<span css={{ flex: 1, borderRight: `1px solid ${tokens.colorNeutralStroke1}` }}></span>
{rightTabs.map((tab) => {
return (
<OutputTab key={tab.id} tab={tab} selected={selected === tab.id} onSelect={onSelect} />
);
})}
</div>
);
};
export interface OutputTabProps {
tab: Tab;
selected: boolean;
onSelect: (id: string) => void;
}
export const OutputTab: FunctionComponent<OutputTabProps> = ({ tab, selected, onSelect }) => {
return (
<div
tabIndex={0}
css={[
{
height: "26px",
padding: "0 5px",
borderRight: `1px solid ${tokens.colorNeutralStroke1}`,
borderTop: "none",
borderBottom: "none",
textDecoration: "none",
cursor: "pointer",
},
selected ? { fontWeight: "bold", backgroundColor: tokens.colorNeutralBackground5 } : {},
]}
onClick={() => onSelect(tab.id)}
>
{tab.name}
</div>
);
};
function chunk<T>(items: T[], condition: (item: T) => boolean) {
const match = [];
const unMatch = [];
for (const item of items) {
if (condition(item)) {
match.push(item);
} else {
unMatch.push(item);
}
}
return [match, unMatch];
}

Просмотреть файл

@ -0,0 +1,19 @@
.tabs {
border-bottom: 1px solid var(--colorNeutralStroke1);
box-shadow: var(--shadow2);
}
.tab-divider {
flex: 1;
border-right: 1px solid var(--colorNeutralStroke1);
}
.tab {
height: 40px;
border-right: 1px solid var(--colorNeutralStroke1) !important;
border-radius: 0 !important;
}
.tab--selected {
background-color: var(--colorNeutralBackground5) !important;
}

Просмотреть файл

@ -0,0 +1,78 @@
import {
SelectTabData,
SelectTabEvent,
SelectTabEventHandler,
Tab,
TabList,
TabProps,
mergeClasses,
} from "@fluentui/react-components";
import { FunctionComponent, ReactElement, useCallback } from "react";
import style from "./output-tabs.module.css";
export interface OutputTab {
id: string;
name: string | ReactElement<any, any>;
align: "left" | "right";
}
export interface OutputTabsProps {
tabs: OutputTab[];
selected: string;
onSelect: (file: string) => void;
}
export const OutputTabs: FunctionComponent<OutputTabsProps> = ({ tabs, selected, onSelect }) => {
const [leftTabs, rightTabs] = chunk(tabs, (x) => x.align === "left");
const onTabSelect: SelectTabEventHandler = useCallback(
(event: SelectTabEvent, data: SelectTabData) => {
onSelect(data.value as any);
},
[onSelect]
);
return (
<TabList selectedValue={selected} onTabSelect={onTabSelect} className={style["tabs"]}>
{leftTabs.map((tab) => {
return (
<OutputTabEl
key={tab.id}
value={tab.id}
className={tab.id === selected && style["tab--selected"]}
>
{tab.name}
</OutputTabEl>
);
})}
<div className={style["tab-divider"]}></div>
{rightTabs.map((tab) => {
return (
<OutputTabEl
key={tab.id}
value={tab.id}
className={tab.id === selected && style["tab--selected"]}
>
{tab.name}
</OutputTabEl>
);
})}
</TabList>
);
};
const OutputTabEl = (props: TabProps) => {
return <Tab {...props} className={mergeClasses(style["tab"], props.className)} />;
};
function chunk<T>(items: T[], condition: (item: T) => boolean) {
const match = [];
const unMatch = [];
for (const item of items) {
if (condition(item)) {
match.push(item);
} else {
unMatch.push(item);
}
}
return [match, unMatch];
}

Просмотреть файл

@ -0,0 +1,15 @@
.output-view {
display: flex;
flex-direction: column;
height: 100%;
}
.output-content {
flex: 1;
min-height: 0;
}
.type-graph-viewer {
height: 100%;
overflow-y: auto;
}

Просмотреть файл

@ -1,14 +1,13 @@
import { css } from "@emotion/react";
import { tokens } from "@fluentui/react-components";
import { Diagnostic, Program } from "@typespec/compiler";
import { Program } from "@typespec/compiler";
import { ColorPalette, ColorProvider, TypeSpecProgramViewer } from "@typespec/html-program-viewer";
import { FunctionComponent, useCallback, useEffect, useMemo, useState } from "react";
import { ErrorTab, InternalCompilerError } from "./error-tab.js";
import { FileOutput } from "./file-output.js";
import { OutputTabs, Tab } from "./output-tabs.js";
import { PlaygroundEditorsOptions } from "./playground.js";
import { CompilationState, CompileResult, FileOutputViewer, ViewerProps } from "./types.js";
import { OutputEditor } from "./typespec-editor.js";
import { FileOutput } from "../file-output/file-output.js";
import { OutputTab, OutputTabs } from "../output-tabs/output-tabs.js";
import { PlaygroundEditorsOptions } from "../playground.js";
import { CompilationState, CompileResult, FileOutputViewer, ViewerProps } from "../types.js";
import { OutputEditor } from "../typespec-editor.js";
import style from "./output-view.module.css";
export interface OutputViewProps {
compilationState: CompilationState | undefined;
@ -25,7 +24,7 @@ export const OutputView: FunctionComponent<OutputViewProps> = ({
return <></>;
}
if ("internalCompilerError" in compilationState) {
return <InternalCompilerError error={compilationState.internalCompilerError} />;
return <></>;
}
return (
<OutputViewInternal
@ -66,41 +65,34 @@ const OutputViewInternal: FunctionComponent<{
}
const diagnostics = program.diagnostics;
const tabs: Tab[] = useMemo(() => {
const tabs: OutputTab[] = useMemo(() => {
return [
...outputFiles.map(
(x): Tab => ({
(x): OutputTab => ({
align: "left",
name: x,
id: x,
})
),
{ id: "type-graph", name: "Type Graph", align: "right" },
{
id: "errors",
name: <ErrorTabLabel diagnostics={diagnostics} />,
align: "right",
},
];
}, [outputFiles, diagnostics]);
const handleTabSelection = useCallback((tabId: string) => {
if (tabId === "type-graph") {
setViewSelection({ type: "type-graph" });
} else if (tabId === "errors") {
setViewSelection({ type: "errors" });
} else {
void loadOutputFile(tabId);
}
}, []);
return (
<>
<div className={style["output-view"]}>
<OutputTabs
tabs={tabs}
selected={viewSelection.type === "file" ? viewSelection.filename : viewSelection.type}
onSelect={handleTabSelection}
/>
<div className="output-content" css={{ width: "100%", height: "100%", overflow: "hidden" }}>
<div className={style["output-content"]}>
<OutputContent
viewSelection={viewSelection}
editorOptions={editorOptions}
@ -108,7 +100,7 @@ const OutputViewInternal: FunctionComponent<{
viewers={viewers}
/>
</div>
</>
</div>
);
};
@ -149,51 +141,23 @@ const OutputContent: FunctionComponent<OutputContentProps> = ({
viewers={resolvedViewers}
/>
);
case "errors":
return <ErrorTab diagnostics={program?.diagnostics} />;
default:
return (
<div
css={{
height: "100%",
overflow: "scroll",
}}
>
{program && <TypeGraphViewer program={program} />}
</div>
);
return program && <TypeGraphViewer program={program} />;
}
};
type ViewSelection =
| { type: "file"; filename: string; content: string }
| { type: "type-graph" }
| { type: "errors" };
const ErrorTabLabel: FunctionComponent<{
diagnostics?: readonly Diagnostic[];
}> = ({ diagnostics }) => {
const errorCount = diagnostics ? diagnostics.length : 0;
return (
<div>Errors {errorCount > 0 ? <span css={ErrorTabCountStyles}>{errorCount}</span> : ""}</div>
);
};
const ErrorTabCountStyles = css({
backgroundColor: tokens.colorStatusDangerBackground3,
color: tokens.colorNeutralForegroundOnBrand,
padding: "0 5px",
borderRadius: "20px",
});
type ViewSelection = { type: "file"; filename: string; content: string } | { type: "type-graph" };
interface TypeGraphViewerProps {
program: Program;
}
const TypeGraphViewer = ({ program }: TypeGraphViewerProps) => {
return (
<ColorProvider colors={TypeGraphColors}>
<TypeSpecProgramViewer program={program} />
</ColorProvider>
<div className={style["type-graph-viewer"]}>
<ColorProvider colors={TypeGraphColors}>
<TypeSpecProgramViewer program={program} />
</ColorProvider>
</div>
);
};

Просмотреть файл

@ -0,0 +1,7 @@
.layout {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
overflow: hidden;
}

Просмотреть файл

@ -1,4 +1,4 @@
import { CompilerOptions } from "@typespec/compiler";
import { CompilerOptions, Diagnostic } from "@typespec/compiler";
import debounce from "debounce";
import { KeyCode, KeyMod, MarkerSeverity, Uri, editor } from "monaco-editor";
import {
@ -11,14 +11,16 @@ import {
useState,
} from "react";
import { CompletionItemTag } from "vscode-languageserver";
import { getMarkerLocation } from "../services.js";
import { EditorCommandBar } from "../editor-command-bar/editor-command-bar.js";
import { getMonacoRange } from "../services.js";
import { BrowserHost, PlaygroundSample } from "../types.js";
import { PlaygroundContextProvider } from "./context/playground-context.js";
import { DefaultFooter } from "./default-footer.js";
import { EditorCommandBar } from "./editor-command-bar.js";
import { OnMountData, useMonacoModel } from "./editor.js";
import { useControllableValue } from "./hooks.js";
import { OutputView } from "./output-view.js";
import { OutputView } from "./output-view/output-view.js";
import style from "./playground.module.css";
import { ProblemPane } from "./problem-pane/index.js";
import Pane from "./split-pane/pane.js";
import { SplitPane } from "./split-pane/split-pane.js";
import { CompilationState, FileOutputViewer } from "./types.js";
@ -133,7 +135,7 @@ export const Playground: FunctionComponent<PlaygroundProps> = (props) => {
setCompilationState(state);
if ("program" in state) {
const markers: editor.IMarkerData[] = state.program.diagnostics.map((diag) => ({
...getMarkerLocation(typespecCompiler, diag.target),
...getMonacoRange(typespecCompiler, diag.target),
message: diag.message,
severity: diag.severity === "error" ? MarkerSeverity.Error : MarkerSeverity.Warning,
tags: diag.code === "deprecated" ? [CompletionItemTag.Deprecated] : undefined,
@ -226,49 +228,73 @@ export const Playground: FunctionComponent<PlaygroundProps> = (props) => {
editorRef.current = editor;
}, []);
const [verticalPaneSizes, setVerticalPaneSizes] = useState<(string | number | undefined)[]>(
verticalPaneSizesConst.collapsed
);
const toggleProblemPane = useCallback(() => {
setVerticalPaneSizes((value) => {
return value === verticalPaneSizesConst.collapsed
? verticalPaneSizesConst.expanded
: verticalPaneSizesConst.collapsed;
});
}, [setVerticalPaneSizes]);
const onVerticalPaneSizeChange = useCallback(
(sizes: number[]) => {
setVerticalPaneSizes(sizes);
},
[setVerticalPaneSizes]
);
const handleDiagnosticSelected = useCallback(
(diagnostic: Diagnostic) => {
editorRef.current?.setSelection(getMonacoRange(host.compiler, diagnostic.target));
},
[setVerticalPaneSizes]
);
return (
<PlaygroundContextProvider value={{ host }}>
<div
css={{
display: "flex",
flexDirection: "column",
width: "100%",
height: "100%",
overflow: "hidden",
fontFamily: `"Segoe UI", Tahoma, Geneva, Verdana, sans-serif`,
}}
>
<SplitPane
initialSizes={["50%", "50%"]}
css={{ gridArea: "typespeceditor", width: "100%", height: "100%", overflow: "hidden" }}
>
<div className={style["layout"]}>
<SplitPane sizes={verticalPaneSizes} onChange={onVerticalPaneSizeChange} split="horizontal">
<Pane>
<EditorCommandBar
host={host}
selectedEmitter={selectedEmitter}
onSelectedEmitterChange={onSelectedEmitterChange}
compilerOptions={compilerOptions}
onCompilerOptionsChange={onCompilerOptionsChange}
samples={props.samples}
selectedSampleName={selectedSampleName}
onSelectedSampleNameChange={onSelectedSampleNameChange}
saveCode={saveCode}
formatCode={formatCode}
newIssue={props?.links?.githubIssueUrl ? newIssue : undefined}
documentationUrl={props.links?.documentationUrl}
/>
<TypeSpecEditor
model={typespecModel}
actions={typespecEditorActions}
options={props.editorOptions}
onMount={onTypeSpecEditorMount}
/>
<SplitPane initialSizes={["50%", "50%"]}>
<Pane>
<EditorCommandBar
host={host}
selectedEmitter={selectedEmitter}
onSelectedEmitterChange={onSelectedEmitterChange}
compilerOptions={compilerOptions}
onCompilerOptionsChange={onCompilerOptionsChange}
samples={props.samples}
selectedSampleName={selectedSampleName}
onSelectedSampleNameChange={onSelectedSampleNameChange}
saveCode={saveCode}
formatCode={formatCode}
newIssue={props?.links?.githubIssueUrl ? newIssue : undefined}
documentationUrl={props.links?.documentationUrl}
/>
<TypeSpecEditor
model={typespecModel}
actions={typespecEditorActions}
options={props.editorOptions}
onMount={onTypeSpecEditorMount}
/>
</Pane>
<Pane>
<OutputView
compilationState={compilationState}
editorOptions={props.editorOptions}
viewers={props.emitterViewers?.[selectedEmitter]}
/>
</Pane>
</SplitPane>
</Pane>
<Pane>
<OutputView
<Pane minSize={30}>
<ProblemPane
collapsed={verticalPaneSizes[1] === verticalPaneSizesConst.collapsed[1]}
compilationState={compilationState}
editorOptions={props.editorOptions}
viewers={props.emitterViewers?.[selectedEmitter]}
onHeaderClick={toggleProblemPane}
onDiagnosticSelected={handleDiagnosticSelected}
/>
</Pane>
</SplitPane>
@ -278,6 +304,10 @@ export const Playground: FunctionComponent<PlaygroundProps> = (props) => {
);
};
const verticalPaneSizesConst = {
collapsed: [undefined, 30],
expanded: [undefined, 200],
};
const outputDir = "./tsp-output";
async function compile(

Просмотреть файл

@ -0,0 +1,40 @@
.header {
padding: 5px;
cursor: pointer;
display: flex;
align-items: center;
height: 20px;
justify-content: space-between;
box-shadow: var(--shadow2);
}
.header--error {
background-color: var(--colorStatusDangerBackground2);
color: var(--colorNeutralForeground1);
}
.error-icon {
color: var(--colorStatusDangerForeground1);
}
.header--warning {
background-color: var(--colorStatusWarningBackground2);
color: var(--colorNeutralForeground2);
}
.warning-icon {
color: var(--colorStatusWarningForeground1);
}
.header-content {
display: flex;
align-items: center;
gap: 10px;
}
.header-chevron {
transition: transform 0.2s ease-in-out;
}
.header-chevron--collapsed {
transform: rotate(90deg);
}

Просмотреть файл

@ -0,0 +1,80 @@
import { mergeClasses } from "@fluentui/react-components";
import { ChevronDown16Regular, ErrorCircle16Filled, Warning16Filled } from "@fluentui/react-icons";
import { MouseEventHandler, ReactNode, memo } from "react";
import { CompilationState } from "../types.js";
import style from "./header.module.css";
export interface ProblemPaneHeaderProps {
compilationState: CompilationState | undefined;
onClick?: MouseEventHandler<HTMLDivElement>;
collaped: boolean;
}
export const ProblemPaneHeader = memo(({ compilationState, ...props }: ProblemPaneHeaderProps) => {
const noProblem = (
<Container status="none" {...props}>
No problems
</Container>
);
if (compilationState === undefined) {
return noProblem;
}
if ("internalCompilerError" in compilationState) {
return (
<Container status="error" {...props}>
<ErrorCircle16Filled /> Internal Compiler Error
</Container>
);
}
const diagnostics = compilationState.program.diagnostics;
if (diagnostics.length === 0) {
return noProblem;
}
const errors = diagnostics.filter((x) => x.severity === "error");
const warnings = diagnostics.filter((x) => x.severity === "warning");
return (
<Container status={errors.length > 0 ? "error" : "warning"} {...props}>
{errors.length > 0 ? (
<>
<ErrorCircle16Filled className={style["error-icon"]} /> {errors.length} errors
</>
) : null}
{warnings.length > 0 ? (
<>
<Warning16Filled className={style["warning-icon"]} /> {warnings.length} warnings
</>
) : null}
</Container>
);
});
interface ContainerProps {
collaped: boolean;
onClick?: MouseEventHandler<HTMLDivElement>;
children?: ReactNode;
className?: string;
status: "error" | "warning" | "none";
}
const Container = ({ children, className, status, onClick, collaped }: ContainerProps) => {
return (
<div
tabIndex={onClick === undefined ? undefined : 0}
className={mergeClasses(
style["header"],
status === "error" && style["header--error"],
status === "warning" && style["header--warning"],
className
)}
onClick={onClick}
>
<div className={style["header-content"]}>{children}</div>
<ChevronDown16Regular
className={mergeClasses(
style["header-chevron"],
collaped && style["header-chevron--collapsed"]
)}
/>
</div>
);
};

Просмотреть файл

@ -0,0 +1 @@
export { ProblemPane } from "./problem-pane.js";

Просмотреть файл

@ -0,0 +1,18 @@
.problem-pane {
height: 100%;
background-color: var(--colorNeutralBackground3);
display: flex;
flex-direction: column;
max-height: 100%;
}
.problem-content {
overflow-y: auto;
}
.no-problems {
padding: 10px;
}
.internal-compiler-error {
padding: 10px;
}

Просмотреть файл

@ -0,0 +1,61 @@
import { Diagnostic } from "@typespec/compiler";
import { FunctionComponent, MouseEventHandler } from "react";
import { DiagnosticList } from "../diagnostic-list/diagnostic-list.js";
import { CompilationState } from "../types.js";
import { ProblemPaneHeader } from "./header.js";
import style from "./problem-pane.module.css";
export interface ProblemPaneProps {
readonly collapsed: boolean;
readonly compilationState: CompilationState | undefined;
readonly onHeaderClick?: MouseEventHandler<HTMLDivElement>;
readonly onDiagnosticSelected?: (diagnostic: Diagnostic) => void;
}
export const ProblemPane: FunctionComponent<ProblemPaneProps> = ({
collapsed,
compilationState,
onHeaderClick,
onDiagnosticSelected,
}) => {
return (
<div className={style["problem-pane"]}>
<ProblemPaneHeader
compilationState={compilationState}
onClick={onHeaderClick}
collaped={collapsed}
/>
<div className={style["problem-content"]}>
<ProblemPaneContent
compilationState={compilationState}
onDiagnosticSelected={onDiagnosticSelected}
/>
</div>
</div>
);
};
interface ProblemPaneContentProps {
readonly compilationState: CompilationState | undefined;
readonly onDiagnosticSelected?: (diagnostic: Diagnostic) => void;
}
const ProblemPaneContent: FunctionComponent<ProblemPaneContentProps> = ({
compilationState,
onDiagnosticSelected,
}) => {
if (compilationState === undefined) {
return <></>;
}
if ("internalCompilerError" in compilationState) {
return (
<pre className={style["internal-compiler-error"]}>
{compilationState.internalCompilerError.stack}
</pre>
);
}
const diagnostics = compilationState.program.diagnostics;
return diagnostics.length === 0 ? (
<div className={style["no-problems"]}> No problems</div>
) : (
<DiagnosticList diagnostics={diagnostics} onDiagnosticSelected={onDiagnosticSelected} />
);
};

Просмотреть файл

@ -0,0 +1,48 @@
import {
Dialog,
DialogBody,
DialogContent,
DialogSurface,
DialogTitle,
DialogTrigger,
ToolbarButton,
} from "@fluentui/react-components";
import { Settings24Regular } from "@fluentui/react-icons";
import type { CompilerOptions } from "@typespec/compiler";
import { usePlaygroundContext } from "../context/index.js";
import { CompilerSettings } from "./compiler-settings.js";
export interface CompilerSettingsDialogButtonProps {
selectedEmitter: string;
compilerOptions: CompilerOptions;
onCompilerOptionsChange: (options: CompilerOptions) => void;
}
export const CompilerSettingsDialogButton = ({
selectedEmitter,
compilerOptions,
onCompilerOptionsChange,
}: CompilerSettingsDialogButtonProps) => {
const { host } = usePlaygroundContext();
return (
<Dialog>
<DialogTrigger>
<ToolbarButton icon={<Settings24Regular />} />
</DialogTrigger>
<DialogSurface>
<DialogBody>
<DialogTitle>Settings</DialogTitle>
<DialogContent>
<CompilerSettings
host={host}
selectedEmitter={selectedEmitter}
options={compilerOptions}
onOptionsChanged={onCompilerOptionsChange}
/>
</DialogContent>
</DialogBody>
</DialogSurface>
</Dialog>
);
};

Просмотреть файл

@ -1,3 +1,4 @@
import { Divider } from "@fluentui/react-components";
import { CompilerOptions, LinterRuleSet } from "@typespec/compiler";
import { FunctionComponent, useCallback } from "react";
import { BrowserHost } from "../../types.js";
@ -5,12 +6,12 @@ import { EmitterOptions } from "../types.js";
import { EmitterOptionsForm } from "./emitter-options-form.js";
import { LinterForm } from "./linter-form.js";
export type CompilerSettingsProps = {
host: BrowserHost;
selectedEmitter: string;
options: CompilerOptions;
onOptionsChanged: (options: CompilerOptions) => void;
};
export interface CompilerSettingsProps {
readonly host: BrowserHost;
readonly selectedEmitter: string;
readonly options: CompilerOptions;
readonly onOptionsChanged: (options: CompilerOptions) => void;
}
export const CompilerSettings: FunctionComponent<CompilerSettingsProps> = ({
selectedEmitter,
@ -39,9 +40,9 @@ export const CompilerSettings: FunctionComponent<CompilerSettingsProps> = ({
[options]
);
return (
<div css={{ padding: 10 }}>
<h2>Settings</h2>
<div>
<>Emitter: {selectedEmitter}</>
<Divider style={{ marginTop: 20 }} />
<h3>Options</h3>
{library && (
<EmitterOptionsForm
@ -50,6 +51,7 @@ export const CompilerSettings: FunctionComponent<CompilerSettingsProps> = ({
optionsChanged={emitterOptionsChanged}
/>
)}
<Divider style={{ marginTop: 20 }} />
<h3>Linter</h3>
<LinterForm
libraries={host.libraries}

Просмотреть файл

@ -0,0 +1,15 @@
.form {
display: flex;
flex-direction: column;
gap: 20px;
}
.item {
display: flex;
flex-direction: column;
gap: 10px;
}
.switch :global(label) {
padding-left: 0;
}

Просмотреть файл

@ -12,12 +12,14 @@ import {
import { FunctionComponent, useCallback, useMemo } from "react";
import { PlaygroundTspLibrary } from "../../types.js";
import { EmitterOptions } from "../types.js";
import style from "./emitter-options-form.module.css";
export interface EmitterOptionsFormProps {
library: PlaygroundTspLibrary;
options: EmitterOptions;
optionsChanged: (options: EmitterOptions) => void;
readonly library: PlaygroundTspLibrary;
readonly options: EmitterOptions;
readonly optionsChanged: (options: EmitterOptions) => void;
}
export const EmitterOptionsForm: FunctionComponent<EmitterOptionsFormProps> = ({
library,
options,
@ -42,10 +44,10 @@ export const EmitterOptionsForm: FunctionComponent<EmitterOptionsFormProps> = ({
[options, optionsChanged]
);
return (
<div>
<div className={style["form"]}>
{entries.map(([key, value]) => {
return (
<div key={key} css={{ margin: "16px 0" }}>
<div key={key} className={style["form-item"]}>
<JsonSchemaPropertyInput
emitterOptions={options[library.name] ?? {}}
name={key}
@ -59,19 +61,19 @@ export const EmitterOptionsForm: FunctionComponent<EmitterOptionsFormProps> = ({
);
};
type JsonSchemaProperty = {
type: "string" | "boolean" | "number";
description?: string;
enum?: string[];
default?: any;
};
interface JsonSchemaProperty {
readonly type: "string" | "boolean" | "number";
readonly description?: string;
readonly enum?: string[];
readonly default?: any;
}
type JsonSchemaPropertyInputProps = {
emitterOptions: Record<string, unknown>;
name: string;
prop: JsonSchemaProperty;
onChange: (data: { name: string; value: unknown }) => void;
};
interface JsonSchemaPropertyInputProps {
readonly emitterOptions: Record<string, unknown>;
readonly name: string;
readonly prop: JsonSchemaProperty;
readonly onChange: (data: { name: string; value: unknown }) => void;
}
const JsonSchemaPropertyInput: FunctionComponent<JsonSchemaPropertyInputProps> = ({
emitterOptions,
@ -93,27 +95,31 @@ const JsonSchemaPropertyInput: FunctionComponent<JsonSchemaPropertyInputProps> =
switch (prop.type) {
case "boolean":
return <Switch label={prettyName} checked={value} onChange={handleChange} />;
return (
<Switch
className={style["switch"]}
label={prettyName}
labelPosition="above"
checked={value}
onChange={handleChange}
/>
);
case "string":
default:
return (
<div>
<div>
<Label htmlFor={inputId} title={name}>
{prettyName}
</Label>
</div>
<div>
{prop.enum ? (
<RadioGroup layout="horizontal" id={inputId} value={value} onChange={handleChange}>
{prop.enum.map((x) => (
<Radio key={x} value={x} label={x} />
))}
</RadioGroup>
) : (
<Input id={inputId} value={value} onChange={handleChange} />
)}
</div>
<div className={style["item"]}>
<Label htmlFor={inputId} title={name}>
{prettyName}
</Label>
{prop.enum ? (
<RadioGroup layout="horizontal" id={inputId} value={value} onChange={handleChange}>
{prop.enum.map((x) => (
<Radio key={x} value={x} label={x} />
))}
</RadioGroup>
) : (
<Input id={inputId} value={value} onChange={handleChange} />
)}
</div>
);
}

Просмотреть файл

@ -49,6 +49,24 @@
background-color: var(--colorNeutralStroke1Pressed);
}
.sash-vertical .sash-content {
width: 1px;
margin-left: 1px;
}
.sash-horizontal .sash-content {
height: 1px;
margin-top: 1px;
}
.sash-vertical .sash-content-dragging {
width: 100%;
}
.sash-horizontal .sash-content-dragging {
height: 100%;
}
.pane {
height: 100%;
position: absolute;

Просмотреть файл

@ -19,8 +19,8 @@ export interface SplitPaneProps {
children: JSX.Element[];
allowResize?: boolean;
split?: "vertical" | "horizontal";
initialSizes: (string | number)[];
sizes?: (string | number)[];
initialSizes?: (string | number)[];
sizes?: (string | number | undefined)[];
sashRender?: (index: number, active: boolean) => React.ReactNode;
onChange?: (sizes: number[]) => void;
onDragStart?: (e: MouseEvent) => void;
@ -56,7 +56,7 @@ export const SplitPane: FunctionComponent<SplitPaneProps> = ({
onDragEnd = () => null,
...others
}: SplitPaneProps) => {
const [resolvedPropSize, updateSizes] = useControllableValue<(string | number)[]>(
const [resolvedPropSize, updateSizes] = useControllableValue<(string | number | undefined)[]>(
propSizes,
defaultSizes,
onChange as any
@ -151,7 +151,9 @@ export const SplitPane: FunctionComponent<SplitPaneProps> = ({
);
const resetPosition = useCallback(() => {
updateSizes(defaultSizes);
if (defaultSizes) {
updateSizes(defaultSizes);
}
}, [defaultSizes, updateSizes]);
const dragEnd = useCallback(

Просмотреть файл

@ -1,31 +0,0 @@
import { FunctionComponent, useEffect, useState } from "react";
export interface SwaggerUIProps {
spec: string;
}
export const SwaggerUI: FunctionComponent<SwaggerUIProps> = (props) => {
const [swaggerUI, setSwaggerUILib] = useState<
{ swaggerUIComp: typeof import("swagger-ui-react").default } | undefined
>(undefined);
useEffect(() => {
void import("swagger-ui-react").then((lib) => {
setSwaggerUILib({ swaggerUIComp: lib.default as any });
});
}, [setSwaggerUILib]);
if (swaggerUI === undefined) {
return null;
}
return (
<div
css={{
width: "100%",
height: "100%",
overflow: "auto",
}}
>
<swaggerUI.swaggerUIComp spec={props.spec} />
</div>
);
};

Просмотреть файл

@ -1,6 +1,6 @@
import "swagger-ui-react/swagger-ui.css";
import { FileOutputViewer, ViewerProps } from "../types.js";
import { SwaggerUI } from "./swagger-ui.js";
import { FileOutputViewer, ViewerProps } from "./types.js";
export const SwaggerUIViewer: FileOutputViewer = {
key: "swaggerUI",

Просмотреть файл

@ -0,0 +1,5 @@
.swagger-ui-container {
width: 100%;
height: 100%;
overflow: auto;
}

Просмотреть файл

@ -0,0 +1,18 @@
import { FunctionComponent, Suspense, lazy } from "react";
import style from "./swagger-ui.module.css";
export interface SwaggerUIProps {
readonly spec: string;
}
const LazySwaggerUI = lazy(() => import("swagger-ui-react") as any);
export const SwaggerUI: FunctionComponent<SwaggerUIProps> = (props) => {
return (
<Suspense fallback={<div />}>
<div className={style["swagger-ui-container"]}>
<LazySwaggerUI spec={props.spec} />
</div>
</Suspense>
);
};

Просмотреть файл

@ -5,7 +5,6 @@ import {
TypeSpecLanguageConfiguration,
} from "@typespec/compiler";
import * as monaco from "monaco-editor";
import { editor } from "monaco-editor";
import * as lsp from "vscode-languageserver";
import { DocumentHighlightKind, FormattingOptions } from "vscode-languageserver";
import { TextDocument } from "vscode-languageserver-textdocument";
@ -360,10 +359,10 @@ export async function registerMonacoLanguage(host: BrowserHost) {
});
}
export function getMarkerLocation(
export function getMonacoRange(
typespecCompiler: typeof import("@typespec/compiler"),
target: DiagnosticTarget | typeof NoTarget
): Pick<editor.IMarkerData, "startLineNumber" | "startColumn" | "endLineNumber" | "endColumn"> {
): monaco.IRange {
const loc = typespecCompiler.getSourceLocation(target);
if (loc === undefined || loc.file.path !== "/test/main.tsp") {
return {

Просмотреть файл

@ -28,12 +28,7 @@ export function definePlaygroundViteConfig(config: PlaygroundUserConfig): UserCo
exclude: ["swagger-ui"],
},
plugins: [
react({
jsxImportSource: "@emotion/react",
babel: {
plugins: ["@emotion/babel-plugin"],
},
}),
react({}),
playgroundManifestPlugin(config),
!config.skipBundleLibraries
? typespecBundlePlugin({

Просмотреть файл

@ -11,9 +11,7 @@
"rootDir": ".",
"skipLibCheck": true,
"jsx": "react-jsx",
"jsxImportSource": "@emotion/react",
"lib": ["DOM"],
"types": ["@emotion/react"]
"lib": ["DOM"]
},
"include": [
"rollup.config.ts",

Просмотреть файл

@ -21,14 +21,7 @@ const config = defineConfig({
optimizeDeps: {
exclude: ["swagger-ui"],
},
plugins: [
react({
jsxImportSource: "@emotion/react",
babel: {
plugins: ["@emotion/babel-plugin"],
},
}),
],
plugins: [react({})],
server: {
fs: {
strict: false,