mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-11 20:08:37 +00:00
feat: init @affine/copilot (#2511)
This commit is contained in:
@@ -48,7 +48,7 @@
|
||||
"electron-window-state": "^5.0.3",
|
||||
"esbuild": "^0.17.19",
|
||||
"fs-extra": "^11.1.1",
|
||||
"playwright": "^1.33.0",
|
||||
"playwright": "=1.33.0",
|
||||
"ts-node": "^10.9.1",
|
||||
"undici": "^5.22.1",
|
||||
"uuid": "^9.0.0",
|
||||
|
||||
@@ -109,8 +109,10 @@ const nextConfig = {
|
||||
'@affine/templates',
|
||||
'@affine/workspace',
|
||||
'@affine/jotai',
|
||||
'@affine/copilot',
|
||||
'@toeverything/hooks',
|
||||
'@toeverything/y-indexeddb',
|
||||
'@toeverything/plugin-infra',
|
||||
],
|
||||
publicRuntimeConfig: {
|
||||
PROJECT_NAME: process.env.npm_package_name ?? 'AFFiNE',
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"dependencies": {
|
||||
"@affine-test/fixtures": "workspace:*",
|
||||
"@affine/component": "workspace:*",
|
||||
"@affine/copilot": "workspace:*",
|
||||
"@affine/debug": "workspace:*",
|
||||
"@affine/env": "workspace:*",
|
||||
"@affine/graphql": "workspace:*",
|
||||
@@ -34,6 +35,7 @@
|
||||
"@react-hookz/web": "^23.0.1",
|
||||
"@sentry/nextjs": "^7.53.1",
|
||||
"@toeverything/hooks": "workspace:*",
|
||||
"@toeverything/plugin-infra": "workspace:*",
|
||||
"cmdk": "^0.2.0",
|
||||
"css-spring": "^4.1.0",
|
||||
"graphql": "^16.6.0",
|
||||
@@ -68,7 +70,7 @@
|
||||
"eslint": "^8.41.0",
|
||||
"eslint-config-next": "^13.4.4",
|
||||
"eslint-plugin-unicorn": "^47.0.0",
|
||||
"next": "^13.4.2",
|
||||
"next": "=13.4.2",
|
||||
"next-debug-local": "^0.1.5",
|
||||
"next-router-mock": "^0.9.3",
|
||||
"raw-loader": "^4.0.2",
|
||||
|
||||
@@ -18,6 +18,7 @@ export const blockSuiteFeatureFlags = {
|
||||
* @type {import('@affine/env').BuildFlags}
|
||||
*/
|
||||
export const buildFlags = {
|
||||
enablePlugin: process.env.ENABLE_PLUGIN === 'true',
|
||||
enableAllPageFilter:
|
||||
!!process.env.VERCEL ||
|
||||
(process.env.ENABLE_ALL_PAGE_FILTER
|
||||
|
||||
34
apps/web/src/atoms/layout.ts
Normal file
34
apps/web/src/atoms/layout.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { ExpectedLayout } from '@toeverything/plugin-infra/type';
|
||||
import { atom } from 'jotai';
|
||||
|
||||
export const contentLayoutBaseAtom = atom<ExpectedLayout>('editor');
|
||||
|
||||
type SetStateAction<Value> = Value | ((prev: Value) => Value);
|
||||
export const contentLayoutAtom = atom<
|
||||
ExpectedLayout,
|
||||
[SetStateAction<ExpectedLayout>],
|
||||
void
|
||||
>(
|
||||
get => get(contentLayoutBaseAtom),
|
||||
(get, set, layout) => {
|
||||
set(contentLayoutBaseAtom, prev => {
|
||||
let setV: (prev: ExpectedLayout) => ExpectedLayout;
|
||||
if (typeof layout !== 'function') {
|
||||
setV = () => layout;
|
||||
} else {
|
||||
setV = layout;
|
||||
}
|
||||
const nextValue = setV(prev);
|
||||
if (nextValue === 'editor') {
|
||||
return nextValue;
|
||||
}
|
||||
if (nextValue.first !== 'editor') {
|
||||
throw new Error('The first element of the layout should be editor.');
|
||||
}
|
||||
if (nextValue.splitPercentage && nextValue.splitPercentage < 70) {
|
||||
throw new Error('The split percentage should be greater than 70.');
|
||||
}
|
||||
return nextValue;
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -1,7 +0,0 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`ProviderComposer 1`] = `
|
||||
<DocumentFragment>
|
||||
test1
|
||||
</DocumentFragment>
|
||||
`;
|
||||
@@ -1,7 +0,0 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`ProviderComposer 1`] = `
|
||||
<DocumentFragment>
|
||||
test1
|
||||
</DocumentFragment>
|
||||
`;
|
||||
@@ -1,31 +0,0 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { render } from '@testing-library/react';
|
||||
import type React from 'react';
|
||||
import { createContext, useContext } from 'react';
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import { ProviderComposer } from '../provider-composer';
|
||||
|
||||
test('ProviderComposer', async () => {
|
||||
const Context = createContext('null');
|
||||
const Provider: React.FC<React.PropsWithChildren> = ({ children }) => {
|
||||
return <Context.Provider value="test1">{children}</Context.Provider>;
|
||||
};
|
||||
const ConsumerComponent = () => {
|
||||
const value = useContext(Context);
|
||||
return <>{value}</>;
|
||||
};
|
||||
const Component = () => {
|
||||
return (
|
||||
<ProviderComposer contexts={[<Provider key={1} />]}>
|
||||
<ConsumerComponent />
|
||||
</ProviderComposer>
|
||||
);
|
||||
};
|
||||
|
||||
const result = render(<Component />);
|
||||
await result.findByText('test1');
|
||||
expect(result.asFragment()).toMatchSnapshot();
|
||||
});
|
||||
@@ -7,11 +7,14 @@ import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { WorkspaceFlavour } from '@affine/workspace/type';
|
||||
import { CloseIcon, MinusIcon, RoundedRectangleIcon } from '@blocksuite/icons';
|
||||
import type { Page } from '@blocksuite/store';
|
||||
import { affinePluginsAtom } from '@toeverything/plugin-infra/manager';
|
||||
import type { PluginUIAdapter } from '@toeverything/plugin-infra/type';
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
import type { FC, HTMLAttributes, PropsWithChildren, ReactNode } from 'react';
|
||||
import {
|
||||
forwardRef,
|
||||
lazy,
|
||||
memo,
|
||||
Suspense,
|
||||
useEffect,
|
||||
useMemo,
|
||||
@@ -19,6 +22,7 @@ import {
|
||||
} from 'react';
|
||||
|
||||
import { guideDownloadClientTipAtom } from '../../../atoms/guide';
|
||||
import { contentLayoutAtom } from '../../../atoms/layout';
|
||||
import { useCurrentMode } from '../../../hooks/current/use-current-mode';
|
||||
import type { AffineOfficialWorkspace } from '../../../shared';
|
||||
import { DownloadClientTip } from './download-tips';
|
||||
@@ -149,6 +153,43 @@ const HeaderRightItems: Record<HeaderRightItemName, HeaderItem> = {
|
||||
|
||||
export type HeaderProps = BaseHeaderProps;
|
||||
|
||||
const PluginHeaderItemAdapter = memo<{
|
||||
headerItem: PluginUIAdapter['headerItem'];
|
||||
}>(function PluginHeaderItemAdapter({ headerItem }) {
|
||||
return (
|
||||
<div>
|
||||
{headerItem({
|
||||
contentLayoutAtom,
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const PluginHeader = () => {
|
||||
const affinePluginsMap = useAtomValue(affinePluginsAtom);
|
||||
const plugins = useMemo(
|
||||
() => Object.values(affinePluginsMap),
|
||||
[affinePluginsMap]
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{plugins
|
||||
.filter(plugin => plugin.uiAdapter.headerItem != null)
|
||||
.map(plugin => {
|
||||
const headerItem = plugin.uiAdapter
|
||||
.headerItem as PluginUIAdapter['headerItem'];
|
||||
return (
|
||||
<PluginHeaderItemAdapter
|
||||
key={plugin.definition.id}
|
||||
headerItem={headerItem}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Header = forwardRef<
|
||||
HTMLDivElement,
|
||||
PropsWithChildren<HeaderProps> & HTMLAttributes<HTMLDivElement>
|
||||
@@ -169,6 +210,7 @@ export const Header = forwardRef<
|
||||
const appSidebarFloating = useAtomValue(appSidebarFloatingAtom);
|
||||
|
||||
const mode = useCurrentMode();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.headerContainer}
|
||||
@@ -209,6 +251,7 @@ export const Header = forwardRef<
|
||||
|
||||
{props.children}
|
||||
<div className={styles.headerRightSide}>
|
||||
<PluginHeader />
|
||||
{useMemo(() => {
|
||||
return Object.entries(HeaderRightItems).map(
|
||||
([name, { availableWhen, Component }]) => {
|
||||
|
||||
@@ -7,12 +7,24 @@ import { assertExists } from '@blocksuite/store';
|
||||
import { useBlockSuitePageMeta } from '@toeverything/hooks/use-block-suite-page-meta';
|
||||
import { useBlockSuiteWorkspacePage } from '@toeverything/hooks/use-block-suite-workspace-page';
|
||||
import { useBlockSuiteWorkspacePageTitle } from '@toeverything/hooks/use-block-suite-workspace-page-title';
|
||||
import { useAtomValue, useSetAtom } from 'jotai';
|
||||
import { affinePluginsAtom } from '@toeverything/plugin-infra/manager';
|
||||
import type { PluginUIAdapter } from '@toeverything/plugin-infra/type';
|
||||
import type { ExpectedLayout } from '@toeverything/plugin-infra/type';
|
||||
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
|
||||
import Head from 'next/head';
|
||||
import type React from 'react';
|
||||
import { lazy, memo, startTransition, useCallback } from 'react';
|
||||
import type { FC } from 'react';
|
||||
import React, {
|
||||
lazy,
|
||||
memo,
|
||||
startTransition,
|
||||
Suspense,
|
||||
useCallback,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import type { MosaicNode } from 'react-mosaic-component';
|
||||
|
||||
import { currentEditorAtom, workspacePreferredModeAtom } from '../atoms';
|
||||
import { contentLayoutAtom } from '../atoms/layout';
|
||||
import type { AffineOfficialWorkspace } from '../shared';
|
||||
import { BlockSuiteEditor as Editor } from './blocksuite/block-suite-editor';
|
||||
|
||||
@@ -86,7 +98,19 @@ const EditorWrapper = memo(function EditorWrapper({
|
||||
);
|
||||
});
|
||||
|
||||
export const PageDetailEditor: React.FC<PageDetailEditorProps> = props => {
|
||||
const PluginContentAdapter = memo<{
|
||||
detailContent: PluginUIAdapter['detailContent'];
|
||||
}>(function PluginContentAdapter({ detailContent }) {
|
||||
return (
|
||||
<div>
|
||||
{detailContent({
|
||||
contentLayoutAtom,
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const PageDetailEditor: FC<PageDetailEditorProps> = props => {
|
||||
const { workspace, pageId } = props;
|
||||
const blockSuiteWorkspace = workspace.blockSuiteWorkspace;
|
||||
const page = useBlockSuiteWorkspacePage(blockSuiteWorkspace, pageId);
|
||||
@@ -94,22 +118,57 @@ export const PageDetailEditor: React.FC<PageDetailEditorProps> = props => {
|
||||
throw new PageNotFoundError(blockSuiteWorkspace, pageId);
|
||||
}
|
||||
const title = useBlockSuiteWorkspacePageTitle(blockSuiteWorkspace, pageId);
|
||||
const affinePluginsMap = useAtomValue(affinePluginsAtom);
|
||||
const plugins = useMemo(
|
||||
() => Object.values(affinePluginsMap),
|
||||
[affinePluginsMap]
|
||||
);
|
||||
|
||||
const [layout, setLayout] = useAtom(contentLayoutAtom);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{title}</title>
|
||||
</Head>
|
||||
<Mosaic
|
||||
onChange={useCallback(() => {}, [])}
|
||||
onChange={useCallback(
|
||||
(_: MosaicNode<string | number> | null) => {
|
||||
// type cast
|
||||
const node = _ as MosaicNode<string> | null;
|
||||
if (node) {
|
||||
if (typeof node === 'string') {
|
||||
console.error('unexpected layout');
|
||||
} else {
|
||||
if (node.splitPercentage && node.splitPercentage < 70) {
|
||||
return;
|
||||
} else if (node.first !== 'editor') {
|
||||
return;
|
||||
}
|
||||
setLayout(node as ExpectedLayout);
|
||||
}
|
||||
}
|
||||
},
|
||||
[setLayout]
|
||||
)}
|
||||
renderTile={id => {
|
||||
if (id === 'editor') {
|
||||
return <EditorWrapper {...props} />;
|
||||
} else {
|
||||
// @affine/copilot and other plugins will be added in the future
|
||||
throw new Unreachable();
|
||||
const plugin = plugins.find(plugin => plugin.definition.id === id);
|
||||
if (plugin && plugin.uiAdapter.detailContent) {
|
||||
return (
|
||||
<Suspense>
|
||||
<PluginContentAdapter
|
||||
detailContent={plugin.uiAdapter.detailContent}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
}
|
||||
throw new Unreachable();
|
||||
}}
|
||||
value="editor"
|
||||
value={layout}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import type { FC, PropsWithChildren, ReactNode } from 'react';
|
||||
import { cloneElement } from 'react';
|
||||
|
||||
export const ProviderComposer: FC<
|
||||
PropsWithChildren<{
|
||||
contexts: any;
|
||||
}>
|
||||
> = ({ contexts, children }) =>
|
||||
contexts.reduceRight(
|
||||
(kids: ReactNode, parent: any) =>
|
||||
cloneElement(parent, {
|
||||
children: kids,
|
||||
}),
|
||||
children
|
||||
);
|
||||
@@ -1,14 +1,15 @@
|
||||
import '@affine/component/theme/global.css';
|
||||
import '@affine/component/theme/theme.css';
|
||||
import 'react-mosaic-component/react-mosaic-component.css';
|
||||
// bootstrap code before everything
|
||||
import '@affine/env/bootstrap';
|
||||
|
||||
import { WorkspaceFallback } from '@affine/component/workspace';
|
||||
import { config, setupGlobal } from '@affine/env';
|
||||
import { config } from '@affine/env';
|
||||
import { createI18n, I18nextProvider } from '@affine/i18n';
|
||||
import { rootStore } from '@affine/workspace/atom';
|
||||
import type { EmotionCache } from '@emotion/cache';
|
||||
import { CacheProvider } from '@emotion/react';
|
||||
import { Provider } from 'jotai';
|
||||
import { AffinePluginContext } from '@toeverything/plugin-infra/react';
|
||||
import type { AppProps } from 'next/app';
|
||||
import Head from 'next/head';
|
||||
import { useRouter } from 'next/router';
|
||||
@@ -16,14 +17,10 @@ import type { PropsWithChildren, ReactElement } from 'react';
|
||||
import React, { lazy, Suspense, useEffect, useMemo } from 'react';
|
||||
|
||||
import { AffineErrorBoundary } from '../components/affine/affine-error-eoundary';
|
||||
import { ProviderComposer } from '../components/provider-composer';
|
||||
import { MessageCenter } from '../components/pure/message-center';
|
||||
import { ThemeProvider } from '../providers/theme-provider';
|
||||
import type { NextPageWithLayout } from '../shared';
|
||||
import createEmotionCache from '../utils/create-emotion-cache';
|
||||
|
||||
setupGlobal();
|
||||
|
||||
type AppPropsWithLayout = AppProps & {
|
||||
Component: NextPageWithLayout;
|
||||
};
|
||||
@@ -68,17 +65,7 @@ const App = function App({
|
||||
<MessageCenter />
|
||||
<AffineErrorBoundary router={useRouter()}>
|
||||
<Suspense fallback={<WorkspaceFallback key="RootPageLoading" />}>
|
||||
<ProviderComposer
|
||||
contexts={useMemo(
|
||||
() =>
|
||||
[
|
||||
<Provider key="JotaiProvider" store={rootStore} />,
|
||||
<DebugProvider key="DebugProvider" />,
|
||||
<ThemeProvider key="ThemeProvider" />,
|
||||
].filter(Boolean),
|
||||
[]
|
||||
)}
|
||||
>
|
||||
<AffinePluginContext>
|
||||
<Head>
|
||||
<title>AFFiNE</title>
|
||||
<meta
|
||||
@@ -86,8 +73,10 @@ const App = function App({
|
||||
content="initial-scale=1, width=device-width"
|
||||
/>
|
||||
</Head>
|
||||
{getLayout(<Component {...pageProps} />)}
|
||||
</ProviderComposer>
|
||||
<DebugProvider>
|
||||
{getLayout(<Component {...pageProps} />)}
|
||||
</DebugProvider>
|
||||
</AffinePluginContext>
|
||||
</Suspense>
|
||||
</AffineErrorBoundary>
|
||||
</I18nextProvider>
|
||||
|
||||
42
apps/web/src/pages/plugins.tsx
Normal file
42
apps/web/src/pages/plugins.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { AppContainer, MainContainer } from '@affine/component/workspace';
|
||||
import { config } from '@affine/env';
|
||||
import { NoSsr } from '@mui/material';
|
||||
import { affinePluginsAtom } from '@toeverything/plugin-infra/manager';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import type { ReactElement } from 'react';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
const Plugins = () => {
|
||||
const plugins = useAtomValue(affinePluginsAtom);
|
||||
return (
|
||||
<NoSsr>
|
||||
<div>
|
||||
{Object.values(plugins).map(({ definition, uiAdapter }) => {
|
||||
const Content = uiAdapter.debugContent;
|
||||
return (
|
||||
<div key={definition.id}>
|
||||
{/* todo: support i18n */}
|
||||
{definition.name.fallback}
|
||||
{Content && <Content />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</NoSsr>
|
||||
);
|
||||
};
|
||||
|
||||
export default function PluginPage(): ReactElement {
|
||||
if (!config.enablePlugin) {
|
||||
return <></>;
|
||||
}
|
||||
return (
|
||||
<AppContainer>
|
||||
<MainContainer>
|
||||
<Suspense>
|
||||
<Plugins />
|
||||
</Suspense>
|
||||
</MainContainer>
|
||||
</AppContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { ThemeProvider as NextThemeProvider, useTheme } from 'next-themes';
|
||||
import type { PropsWithChildren } from 'react';
|
||||
import { memo, useRef } from 'react';
|
||||
|
||||
const themes = ['dark', 'light'];
|
||||
|
||||
const DesktopThemeSync = memo(function DesktopThemeSync() {
|
||||
const { theme } = useTheme();
|
||||
const lastThemeRef = useRef(theme);
|
||||
const onceRef = useRef(false);
|
||||
if (lastThemeRef.current !== theme || !onceRef.current) {
|
||||
if (environment.isDesktop && theme) {
|
||||
window.apis?.ui.handleThemeChange(theme as 'dark' | 'light' | 'system');
|
||||
}
|
||||
lastThemeRef.current = theme;
|
||||
onceRef.current = true;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
export const ThemeProvider = ({ children }: PropsWithChildren) => {
|
||||
return (
|
||||
<NextThemeProvider themes={themes} enableSystem={true}>
|
||||
{children}
|
||||
<DesktopThemeSync />
|
||||
</NextThemeProvider>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user