add storybooks for easier local dev on vscode UI (#50772)

Running `cd client/cody && pnpm run storybook` will open storybooks for
the VS Code extension. There is a light shim in place to support
rendering the VS Code extension UI with roughly the right theme colors.

### Storybook for `<App />` (VS Code main webview UI)


![image](https://user-images.githubusercontent.com/1976/232655933-1886568f-c305-42d1-91a0-5555768e92f4.png)

![image](https://user-images.githubusercontent.com/1976/232655957-8e27e258-2214-4d81-a900-2b9f76e84f8c.png)


### Storybook for login component showing invalid state


![image](https://user-images.githubusercontent.com/1976/232655884-6632dfc8-859e-4e0e-ad1b-2fa3320154f4.png)


## Test plan

n/a; dev only

---------

Co-authored-by: Naman Kumar <naman@outlook.in>
This commit is contained in:
Quinn Slack 2023-04-18 19:49:44 -07:00 committed by GitHub
parent 0b32522c2d
commit 838a029b99
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 206 additions and 66 deletions

View File

@ -41,7 +41,8 @@
"release": "ts-node ./scripts/release.ts",
"watch": "concurrently \"pnpm watch:esbuild\" \"pnpm watch:webview\"",
"watch:esbuild": "pnpm esbuild --sourcemap --watch",
"watch:webview": "vite build --mode development --watch"
"watch:webview": "vite build --mode development --watch",
"storybook": "STORIES_GLOB='client/cody/webviews/**/*.story.tsx' pnpm --filter @sourcegraph/storybook run start"
},
"main": "./dist/extension.js",
"activationEvents": [

View File

@ -0,0 +1,35 @@
import { ComponentMeta, ComponentStoryObj } from '@storybook/react'
import { App } from './App'
import { VSCodeStoryDecorator } from './storybook/VSCodeStoryDecorator'
import { VSCodeWrapper } from './utils/VSCodeApi'
const meta: ComponentMeta<typeof App> = {
title: 'cody/App',
component: App,
decorators: [VSCodeStoryDecorator],
parameters: {
component: App,
chromatic: {
enableDarkMode: true,
disableSnapshot: false,
},
},
}
export default meta
export const Simple: ComponentStoryObj<typeof App> = {
render: () => <App vscodeAPI={dummyVSCodeAPI} />,
}
const dummyVSCodeAPI: VSCodeWrapper = {
onMessage: cb => {
// Send initial message so that the component is fully rendered.
cb({ type: 'config', config: { debug: true, hasAccessToken: true, serverEndpoint: 'https://example.com' } })
return () => {}
},
postMessage: () => {},
}

View File

@ -15,9 +15,9 @@ import { NavBar, View } from './NavBar'
import { Recipes } from './Recipes'
import { Settings } from './Settings'
import { UserHistory } from './UserHistory'
import { vscodeAPI } from './utils/VSCodeApi'
import type { VSCodeWrapper } from './utils/VSCodeApi'
export function App(): React.ReactElement {
export const App: React.FunctionComponent<{ vscodeAPI: VSCodeWrapper }> = ({ vscodeAPI }) => {
const [config, setConfig] = useState<Pick<Configuration, 'debug' | 'serverEndpoint'> | null>(null)
const [debugLog, setDebugLog] = useState(['No data yet'])
const [view, setView] = useState<View | undefined>()
@ -74,20 +74,23 @@ export function App(): React.ReactElement {
vscodeAPI.postMessage({ command: 'initialized' })
// The dependencies array is empty to execute the callback only on component mount.
}, [debugLog])
}, [debugLog, vscodeAPI])
const onLogin = useCallback((token: string, endpoint: string) => {
if (!token || !endpoint) {
return
}
setIsValidLogin(undefined)
vscodeAPI.postMessage({ command: 'settings', serverEndpoint: endpoint, accessToken: token })
}, [])
const onLogin = useCallback(
(token: string, endpoint: string) => {
if (!token || !endpoint) {
return
}
setIsValidLogin(undefined)
vscodeAPI.postMessage({ command: 'settings', serverEndpoint: endpoint, accessToken: token })
},
[vscodeAPI]
)
const onLogout = useCallback(() => {
vscodeAPI.postMessage({ command: 'removeToken' })
setView('login')
}, [setView])
}, [vscodeAPI])
const onResetClick = useCallback(() => {
setView('chat')
@ -96,7 +99,7 @@ export function App(): React.ReactElement {
setMessageInProgress(null)
setTranscript([])
vscodeAPI.postMessage({ command: 'reset' })
}, [setView, setMessageInProgress, setTranscript, setDebugLog])
}, [vscodeAPI])
if (!view) {
return <LoadingPage />
@ -123,9 +126,10 @@ export function App(): React.ReactElement {
userHistory={userHistory}
setUserHistory={setUserHistory}
setInputHistory={setInputHistory}
vscodeAPI={vscodeAPI}
/>
)}
{view === 'recipes' && <Recipes />}
{view === 'recipes' && <Recipes vscodeAPI={vscodeAPI} />}
{view === 'settings' && (
<Settings setView={setView} onLogout={onLogout} serverEndpoint={config?.serverEndpoint} />
)}
@ -138,6 +142,7 @@ export function App(): React.ReactElement {
setFormInput={setFormInput}
inputHistory={inputHistory}
setInputHistory={setInputHistory}
vscodeAPI={vscodeAPI}
/>
)}
</div>

View File

@ -9,7 +9,7 @@ import { Chat as ChatUI, ChatUISubmitButtonProps, ChatUITextAreaProps } from '@s
import { SubmitSvg } from '@sourcegraph/cody-ui/src/utils/icons'
import { FileLink } from './FileLink'
import { vscodeAPI } from './utils/VSCodeApi'
import { VSCodeWrapper } from './utils/VSCodeApi'
import styles from './Chat.module.css'
@ -21,6 +21,7 @@ interface ChatboxProps {
setFormInput: (input: string) => void
inputHistory: string[]
setInputHistory: (history: string[]) => void
vscodeAPI: VSCodeWrapper
}
export const Chat: React.FunctionComponent<React.PropsWithChildren<ChatboxProps>> = ({
@ -31,10 +32,14 @@ export const Chat: React.FunctionComponent<React.PropsWithChildren<ChatboxProps>
setFormInput,
inputHistory,
setInputHistory,
vscodeAPI,
}) => {
const onSubmit = useCallback((text: string) => {
vscodeAPI.postMessage({ command: 'submit', text })
}, [])
const onSubmit = useCallback(
(text: string) => {
vscodeAPI.postMessage({ command: 'submit', text })
},
[vscodeAPI]
)
return (
<ChatUI

View File

@ -1,4 +1,4 @@
import './Debug.css'
import styles from './Debug.module.css'
interface DebugProps {
debugLog: string[]
@ -7,9 +7,10 @@ interface DebugProps {
export const Debug: React.FunctionComponent<React.PropsWithChildren<DebugProps>> = ({ debugLog }) => (
<div className="inner-container">
<div className="non-transcript-container">
<div className="debug-container" data-tab-target="debug">
<div className={styles.debugContainer} data-tab-target="debug">
{debugLog?.map((log, i) => (
<div key={`log-${i}`} className="debug-message">
// eslint-disable-next-line react/no-array-index-key
<div key={i} className={styles.debugMessage}>
{log}
</div>
))}

View File

@ -2,7 +2,7 @@ import React from 'react'
import { FileLinkProps } from '@sourcegraph/cody-ui/src/chat/ContextFiles'
import { vscodeAPI } from './utils/VSCodeApi'
import { getVSCodeAPI } from './utils/VSCodeApi'
import styles from './FileLink.module.css'
@ -11,7 +11,7 @@ export const FileLink: React.FunctionComponent<FileLinkProps> = ({ path }) => (
className={styles.linkButton}
type="button"
onClick={() => {
vscodeAPI.postMessage({ command: 'openFile', filePath: path })
getVSCodeAPI().postMessage({ command: 'openFile', filePath: path })
}}
>
{path}

View File

@ -1,20 +1,20 @@
import { VSCodeTag } from '@vscode/webview-ui-toolkit/react'
import './Header.css'
import { CodyColoredSvg } from '@sourcegraph/cody-ui/src/utils/icons'
import styles from './Header.module.css'
export const Header: React.FunctionComponent = () => (
<div className="header-container">
<div className="header-container-left">
<div className="header-logo">
<div className={styles.headerContainer}>
<div className={styles.headerContainerLeft}>
<div className={styles.headerLogo}>
<CodyColoredSvg />
</div>
<div className="header-title">
<span className="header-cody">Cody</span>
<VSCodeTag className="tag-beta">experimental</VSCodeTag>
<div className={styles.headerTitle}>
<span className={styles.headerCody}>Cody</span>
<VSCodeTag className={styles.tagBeta}>experimental</VSCodeTag>
</div>
</div>
<div className="header-container-right" />
<div className={styles.headerContainerRight} />
</div>
)

View File

@ -0,0 +1,27 @@
import { ComponentMeta, ComponentStoryObj } from '@storybook/react'
import { Login } from './Login'
import { VSCodeStoryDecorator } from './storybook/VSCodeStoryDecorator'
const meta: ComponentMeta<typeof Login> = {
title: 'cody/Login',
component: Login,
decorators: [VSCodeStoryDecorator],
parameters: {
component: Login,
chromatic: {
enableDarkMode: true,
disableSnapshot: false,
},
},
}
export default meta
export const Simple: ComponentStoryObj<typeof Login> = {
render: () => <Login onLogin={() => {}} isValidLogin={true} />,
}
export const InvalidLogin: ComponentStoryObj<typeof Login> = {
render: () => <Login onLogin={() => {}} isValidLogin={false} />,
}

View File

@ -14,7 +14,7 @@
.tab-btn {
cursor: pointer;
user-select: none;
background-color: var(--vscode-tab-inactiveBackground);
background-color: transparent;
color: var(--vscode-tab-inactiveForeground);
border: none;
}

View File

@ -1,4 +1,6 @@
import './NavBar.css'
import React from 'react'
import styles from './NavBar.module.css'
export type View = 'chat' | 'recipes' | 'login' | 'settings' | 'debug' | 'history'
@ -27,16 +29,16 @@ export const NavBar: React.FunctionComponent<React.PropsWithChildren<NavBarProps
onResetClick,
showResetButton,
}) => (
<div className="tab-menu-container">
<div className="tab-menu-group">
<div className={styles.tabMenuContainer}>
<div className={styles.tabMenuGroup}>
{navBarItems.map(({ title, tab }) => (
<button key={title} onClick={() => setView(tab)} className="tab-btn" type="button">
<p className={view === tab ? 'tab-btn-selected' : ''}>{title}</p>
<button key={title} onClick={() => setView(tab)} className={styles.tabBtn} type="button">
<p className={view === tab ? styles.tabBtnSelected : ''}>{title}</p>
</button>
))}
{devMode && (
<button onClick={() => setView('debug')} className="tab-btn" type="button">
<p className={view === 'debug' ? 'tab-btn-selected' : ''}>Debug</p>
<button onClick={() => setView('debug')} className={styles.tabBtn} type="button">
<p className={view === 'debug' ? styles.tabBtnSelected : ''}>Debug</p>
</button>
)}
</div>
@ -44,7 +46,7 @@ export const NavBar: React.FunctionComponent<React.PropsWithChildren<NavBarProps
{showResetButton && (
<button
onClick={() => onResetClick()}
className="tab-btn"
className={styles.tabBtn}
type="button"
title="Start a new conversation"
>

View File

@ -1,8 +1,8 @@
import { VSCodeButton } from '@vscode/webview-ui-toolkit/react'
import { vscodeAPI } from './utils/VSCodeApi'
import { VSCodeWrapper } from './utils/VSCodeApi'
import './Recipes.css'
import styles from './Recipes.module.css'
export const recipesList = {
'explain-code-detailed': 'Explain selected code (detailed)',
@ -16,7 +16,7 @@ export const recipesList = {
fixup: 'Fixup code from inline instructions',
}
export function Recipes(): React.ReactElement {
export const Recipes: React.FunctionComponent<{ vscodeAPI: VSCodeWrapper }> = ({ vscodeAPI }) => {
const onRecipeClick = (recipeID: string): void => {
vscodeAPI.postMessage({ command: 'executeRecipe', recipe: recipeID })
}
@ -24,11 +24,11 @@ export function Recipes(): React.ReactElement {
return (
<div className="inner-container">
<div className="non-transcript-container">
<div className="recipes">
<div className={styles.recipes}>
{Object.entries(recipesList).map(([key, value]) => (
<VSCodeButton
key={key}
className="recipe-button"
className={styles.recipeButton}
type="button"
onClick={() => onRecipeClick(key)}
>

View File

@ -11,7 +11,7 @@ import { ChatHistory, ChatMessage } from '@sourcegraph/cody-shared/src/chat/tran
import { ContextFiles } from '@sourcegraph/cody-ui/src/chat/ContextFiles'
import { FileLink } from './FileLink'
import { vscodeAPI } from './utils/VSCodeApi'
import { VSCodeWrapper } from './utils/VSCodeApi'
import styles from './Chat.module.css'
@ -19,12 +19,14 @@ interface HistoryProps {
userHistory: ChatHistory | null
setUserHistory: (history: ChatHistory | null) => void
setInputHistory: (inputHistory: string[] | []) => void
vscodeAPI: VSCodeWrapper
}
export const UserHistory: React.FunctionComponent<React.PropsWithChildren<HistoryProps>> = ({
userHistory,
setUserHistory,
setInputHistory,
vscodeAPI,
}) => {
const [chatHistory, setChatHistory] = useState('')
@ -35,7 +37,7 @@ export const UserHistory: React.FunctionComponent<React.PropsWithChildren<Histor
setUserHistory(null)
setInputHistory([])
}
}, [setInputHistory, setUserHistory, userHistory])
}, [setInputHistory, setUserHistory, userHistory, vscodeAPI])
return (
<div className={styles.innerContainer}>

View File

@ -6,8 +6,10 @@ import { App } from './App'
import './index.css'
import { getVSCodeAPI } from './utils/VSCodeApi'
ReactDOM.createRoot(document.querySelector('#root') as HTMLElement).render(
<React.StrictMode>
<App />
<App vscodeAPI={getVSCodeAPI()} />
</React.StrictMode>
)

View File

@ -0,0 +1,34 @@
/**
* CSS styles for storybooks to define variables and otherwise resemble the VS Code.
*/
.container {
max-width: 600px;
margin: 2rem auto;
border: solid 1px #ffffff33;
font-size: var(--vscode-editor-font-size);
color: var(--vscode-editor-foreground);
}
.container a {
color: var(--vscode-link-color);
}
/**
* Define any --vscode-* CSS variables needed. These differ per theme and don't need to be
* an exact match for any particular VS Code theme. They just need to be good enough.
*/
:root {
--vscode-editor-foreground: var(--foreground);
--vscode-editor-background: #151c28;
--vscode-inputValidation-errorBorder: #ff0000;
--vscode-inputValidation-errorBackground: #ff000033;
--vscode-editor-font-size: 13px;
--vscode-link-color: #7777ff;
--vscode-tab-inactiveBackground: #00000033;
--vscode-tab-inactiveForeground: #ffffff77;
--vscode-tab-activeForeground: #ffffff;
--vscode-sideBarSectionHeader-border: #ffffff33;
--vscode-sideBarTitle-foreground: #ffffffcc;
}

View File

@ -0,0 +1,8 @@
import { DecoratorFn } from '@storybook/react'
import styles from './VSCodeStoryDecorator.module.css'
/**
* A decorator for storybooks that makes them look like they're running in VS Code.
*/
export const VSCodeStoryDecorator: DecoratorFn = story => <div className={styles.container}>{story()}</div>

View File

@ -8,20 +8,26 @@ interface VSCodeApi {
postMessage: (message: unknown) => void
}
class VSCodeWrapper {
private readonly vscodeApi: VSCodeApi = acquireVsCodeApi()
public postMessage(message: WebviewMessage): void {
this.vscodeApi.postMessage(message)
}
public onMessage(callback: (message: ExtensionMessage) => void): () => void {
const listener = (event: MessageEvent<ExtensionMessage>): void => {
callback(event.data)
}
window.addEventListener('message', listener)
return () => window.removeEventListener('message', listener)
}
export interface VSCodeWrapper {
postMessage(message: WebviewMessage): void
onMessage(callback: (message: ExtensionMessage) => void): () => void
}
export const vscodeAPI: VSCodeWrapper = new VSCodeWrapper()
let api: VSCodeWrapper
export function getVSCodeAPI(): VSCodeWrapper {
if (!api) {
const vsCodeApi = acquireVsCodeApi()
api = {
postMessage: message => vsCodeApi.postMessage(message),
onMessage: callback => {
const listener = (event: MessageEvent<ExtensionMessage>): void => {
callback(event.data)
}
window.addEventListener('message', listener)
return () => window.removeEventListener('message', listener)
},
}
}
return api
}

View File

@ -43,9 +43,21 @@ const getStoriesGlob = (): string[] => {
// Due to an issue with constant recompiling (https://github.com/storybookjs/storybook/issues/14342)
// we need to make the globs more specific (`(web|shared..)` also doesn't work). Once the above issue
// is fixed, this can be removed and watched for `client/**/*.story.tsx` again.
const directoriesWithStories = ['branded', 'browser', 'jetbrains/webview', 'shared', 'web', 'wildcard', 'cody-ui']
const directoriesWithStories = [
'branded',
'browser',
'jetbrains/webview',
'shared',
'web',
'wildcard',
'cody-ui',
'cody',
]
const storiesGlobs = directoriesWithStories.map(packageDirectory =>
path.resolve(ROOT_PATH, `client/${packageDirectory}/src/**/*.story.tsx`)
path.resolve(
ROOT_PATH,
`client/${packageDirectory}/${packageDirectory === 'cody' ? 'webviews' : 'src'}/**/*.story.tsx`
)
)
return [...storiesGlobs]