Compare commits

...

51 Commits

Author SHA1 Message Date
himself65
0d07ff2390 v0.7.0-canary.8 2023-06-02 13:02:48 +08:00
xiaodong zuo
42bab6990e fix: update version bump-blocksuite (#2658) 2023-06-02 11:16:31 +08:00
fourdim
89a566a645 fix: pdf export in client and hide png export (#2604) 2023-06-01 13:26:57 +00:00
LongYinan
7af5bd3894 v0.7.0-canary.7 2023-06-01 19:11:12 +08:00
Peng Xiao
a57c27679d chore: bump blocksuite (#2652) 2023-06-01 10:55:10 +00:00
JimmFly
68a72b2dfc chore: update whats new link (#2651) 2023-06-01 17:59:30 +08:00
LongYinan
602f795133 build: prevent tsconfig includes sources outside (#2643) 2023-06-01 17:08:14 +08:00
himself65
5df89a925b v0.7.0-canary.6 2023-06-01 15:34:08 +07:00
Whitewater
23126e1ff6 fix: show table head when no item in page list (#2642) 2023-06-01 16:31:51 +08:00
xiaodong zuo
e1f715f837 chore: update blocksuite to 0.0.0-20230601062752-68dbf1a4-nightly (#2641) 2023-06-01 16:25:54 +08:00
himself65
fbcaed40e7 fix: block hub not working in the editor 2023-06-01 14:32:41 +08:00
JimmFly
88757ce488 chore: update all page style (#2638) 2023-06-01 12:38:14 +08:00
Simon He
fc9462eee9 perf: getEnvironment() -> env (#2636) 2023-06-01 03:23:38 +00:00
Qi
e1314730be feat: support get dynamic page meta data (#2632) 2023-06-01 11:03:16 +08:00
Peng Xiao
36978dbed6 fix: plugin bootstrap (#2631)
Co-authored-by: himself65 <himself65@outlook.com>
2023-06-01 01:14:37 +08:00
Whitewater
53d1991211 chore: update page group naming (#2628) 2023-05-31 16:41:25 +00:00
LongYinan
1ea445ab15 build: perform TypeCheck for all packages (#2573)
Co-authored-by: himself65 <himself65@outlook.com>
Co-authored-by: Peng Xiao <pengxiao@outlook.com>
2023-05-31 12:49:56 +00:00
Himself65
78410f531a chore: bump version (#2627) 2023-05-31 18:16:18 +08:00
himself65
96c0321696 v0.7.0-canary.5 2023-05-31 17:34:21 +08:00
himself65
0895f1fb30 ci: enable bookmark block in canary 2023-05-31 17:33:11 +08:00
himself65
b6188f4b11 docs: update logo in README.md 2023-05-31 17:23:01 +08:00
JimmFly
2ed1a7b219 chore: update filter style (#2625) 2023-05-31 17:20:18 +08:00
Himself65
9bee6bd5cc docs: update logo (#2626) 2023-05-31 17:16:50 +08:00
himself65
198f30c86d docs: update README.md 2023-05-31 17:12:27 +08:00
Himself65
454f1887cf feat: add @affine/bookmark-block plugin (#2618) 2023-05-31 17:08:03 +08:00
himself65
4e1e4e9435 v0.7.0-canary.4 2023-05-31 16:47:16 +08:00
3720
f7768563e1 fix: wrong use of dayjs (#2624) 2023-05-31 16:46:36 +08:00
Himself65
6aa0e71b84 chore: bump blocksuite to 0.0.0-20230531080915-ca9c55a2-nightly (#2622) 2023-05-31 16:32:35 +08:00
himself65
f1b3a10969 test: fix mouse click down timeout 2023-05-31 16:22:43 +08:00
Whitewater
90e70ed986 fix: drag delay (#2621) 2023-05-31 16:21:50 +08:00
xiaodong zuo
094a479c2a fix: remove the feature of exporting pdf/png (#2619)
Co-authored-by: himself65 <himself65@outlook.com>
2023-05-31 16:20:42 +08:00
Whitewater
20f1d487c8 feat: add page preview (#2620) 2023-05-31 08:18:48 +00:00
himself65
4c9bda1406 v0.7.0-canary.3 2023-05-31 15:41:52 +08:00
JimmFly
d5debc0bf5 chore: update filter style (#2617)
Co-authored-by: Himself65 <himself65@outlook.com>
2023-05-31 15:41:16 +08:00
Peng Xiao
f5aee7c360 fix: unify sidebar switch (#2616) 2023-05-31 07:06:13 +00:00
Himself65
248cd9a8ab chore: prohibit import package itself (#2612)
Co-authored-by: Whitewater <me@waterwater.moe>
2023-05-31 15:00:50 +08:00
Himself65
06abb702f5 refactor: remove deprecated atoms (#2615) 2023-05-31 14:54:59 +08:00
Himself65
ee289706ec refactor: move affine utils into @affine/workspace (#2611) 2023-05-31 13:13:59 +08:00
Himself65
6cbf310a5a chore: bump blocksuite to 0.0.0-20230531040027-44cd9d8e-nightly (#2610) 2023-05-31 13:10:41 +08:00
Whitewater
855fd8a73a feat: page list supports preview (#2606) 2023-05-31 04:24:55 +00:00
Himself65
8dbd354659 fix: logic after delete all workspaces (#2587)
Co-authored-by: JimmFly <yangjinfei001@gmail.com>
2023-05-31 12:24:14 +08:00
Himself65
1c7ae04f4f feat: update filter button (#2609) 2023-05-31 11:26:20 +08:00
Whitewater
0bb6e362bf feat: add page mode filter (#2601)
Co-authored-by: himself65 <himself65@outlook.com>
2023-05-31 11:15:23 +08:00
Peng Xiao
617350fc7d fix: optimize DB pull (#2589) 2023-05-31 11:09:18 +08:00
Hyden Liu
2713340532 fix(web): header div props error (#2607) 2023-05-31 10:34:42 +08:00
Whitewater
31d552ab7e fix: update breakpoint in all page (#2602) 2023-05-30 18:27:42 +08:00
Himself65
e11326f05f feat: add hook useBlockSuitePagePreview (#2603) 2023-05-30 18:26:13 +08:00
Himself65
6648fe4dcc feat: init @affine/copilot (#2511) 2023-05-30 18:02:49 +08:00
Peng Xiao
f669164674 fix: popover may not be closable (#2598) 2023-05-30 17:29:00 +08:00
JimmFly
c6d8904ca2 fix: quick search result missing title (#2594) 2023-05-30 16:45:00 +08:00
3720
8c5a1e2de3 test: add some tests for page filter (#2593) 2023-05-30 16:39:14 +08:00
268 changed files with 6156 additions and 3298 deletions

View File

@@ -5,3 +5,4 @@ out
storybook-static
affine-out
_next
lib

View File

@@ -1,3 +1,43 @@
const createPattern = packageName => [
{
group: ['**/dist', '**/dist/**'],
message: 'Do not import from dist',
allowTypeImports: false,
},
{
group: ['**/src', '**/src/**'],
message: 'Do not import from src',
allowTypeImports: false,
},
{
group: [`@affine/${packageName}`],
message: 'Do not import package itself',
allowTypeImports: false,
},
{
group: [`@toeverything/${packageName}`],
message: 'Do not import package itself',
allowTypeImports: false,
},
];
const allPackages = [
'cli',
'component',
'debug',
'env',
'graphql',
'hooks',
'i18n',
'jotai',
'native',
'plugin-infra',
'templates',
'theme',
'workspace',
'y-indexeddb',
];
/**
* @type {import('eslint').Linter.Config}
*/
@@ -96,6 +136,17 @@ const config = {
'@typescript-eslint/no-var-requires': 0,
},
},
...allPackages.map(pkg => ({
files: [`packages/${pkg}/src/**/*.ts`, `packages/${pkg}/src/**/*.tsx`],
rules: {
'@typescript-eslint/no-restricted-imports': [
'error',
{
patterns: createPattern(pkg),
},
],
},
})),
],
};

6
.github/labeler.yml vendored
View File

@@ -8,11 +8,17 @@ test:
- '**/tests/**/*'
- '**/__tests__/**/*'
plugin:copilot:
- 'plugins/copilot/**/*'
mod:dev:
- 'scripts/**/*'
- 'packages/cli/**/*'
- 'packages/debug/**/*'
mod:plugin-infra:
- 'packages/plugin-infra/**/*'
mod:workspace: 'packages/workspace/**/*'
mod:i18n: 'packages/i18n/**/*'

View File

@@ -35,7 +35,10 @@ jobs:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: ./.github/actions/setup-node
- run: |
- name: Run checks
run: |
yarn i18n-codegen gen
yarn typecheck
yarn lint --max-warnings=0
yarn circular
@@ -84,7 +87,9 @@ jobs:
NEXT_PUBLIC_FIREBASE_APP_ID: ${{ secrets.NEXT_PUBLIC_FIREBASE_APP_ID }}
NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID: ${{ secrets.NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID }}
API_SERVER_PROFILE: local
ENABLE_DEBUG_PAGE: true
ENABLE_DEBUG_PAGE: 1
ENABLE_PLUGIN: true
ENABLE_ALL_PAGE_FILTER: true
ENABLE_LEGACY_PROVIDER: true
COVERAGE: true
@@ -106,7 +111,9 @@ jobs:
NEXT_PUBLIC_FIREBASE_APP_ID: ${{ secrets.NEXT_PUBLIC_FIREBASE_APP_ID }}
NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID: ${{ secrets.NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID }}
API_SERVER_PROFILE: affine
ENABLE_DEBUG_PAGE: true
ENABLE_DEBUG_PAGE: 1
ENABLE_PLUGIN: true
ENABLE_ALL_PAGE_FILTER: true
ENABLE_LEGACY_PROVIDER: false
COVERAGE: true

View File

@@ -68,7 +68,7 @@ jobs:
ENABLE_TEST_PROPERTIES: false
ENABLE_IMAGE_PREVIEW_MODAL: false
RELEASE_VERSION: ${{ github.event.inputs.version }}
ENABLE_BOOKMARK_OPERATION: false
ENABLE_BOOKMARK_OPERATION: true
- name: Upload Artifact (web-static)
uses: actions/upload-artifact@v3

2
.gitignore vendored
View File

@@ -70,3 +70,5 @@ next-env.d.ts
# Rust
target
*.node
tsconfig.node.tsbuildinfo
lib

View File

@@ -1 +1,4 @@
pnpm-lock.yaml
target
lib
test-results

View File

@@ -24,7 +24,7 @@ See https://github.com/all-?/all-contributors/issues/361#issuecomment-637166066
<!-- ALL-CONTRIBUTORS-BADGE:END -->
[![AFFiNE Web](<https://img.shields.io/badge/-Try%20It%20Online%20%E2%86%92-rgb(84,56,255)?style=flat-square&logoColor=white&logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAACXBIWXMAAADAAAAAwAEwd99eAAABjElEQVRYhe1W0U3DMBB9RfyTDeoNyAYNG2QDOgJsECYgGxA26AZ4hIxgJqCZ4PjIGV+tUxK7raqiPsmKdXe5e3fOs7IiIlwSdxetfiNw7QRKAD0Ax/ssrI5QgQOw5v03AJOTJHcCL1x84LVmWzJyJlBg7P4BwCvb3pmIAbBPykZEqaulEU7YHNva1HypxUsKqIS9EvbynASs0n3ss+ciUIsuO8VvhL9emjdFBa3YO8XvALwpsZNYSqBB0PwUWgRZNksSL5GhlN0ngGd+dkpsD6AG8IGlslxwTh2fa09EBc3Dir32rRysuQlUAL54/wTAcpePPAXHPsOTGXhSEv69rAlYpZOt6DSO29J4D/TRRLJk6AvtaZSY9PkCFYVLqI9i/NF5YkkECgrXa6P4fVEn4iolrhNxRQqBZu7FqMNdZiMqAUPj2KdGZyicu1dHzlGqBHxn2sdTR53bmeJ+ebJd7LtXhGH4uQEwd0ttAPzMxGi5/6BdxTuMej41Bs59gGP+CU+Cq/4tvxH4HwR+Ab3Uqr/VGbqEAAAAAElFTkSuQmCC>)](https://app.affine.pro)
[![AFFiNE Web](<https://img.shields.io/badge/-Try%20It%20Online%20%E2%86%92-rgb(84,56,255)?style=flat-square&logoColor=white&logo=affine>)](https://app.affine.pro)
[![AFFiNE macOS M1/M2 Chip](https://img.shields.io/badge/-macOS_M_Chip%20%E2%86%92-black?style=flat-square&logo=apple&logoColor=white)](https://affine.pro/download)
[![AFFiNE macOS x64](https://img.shields.io/badge/-macOS_x86%20%E2%86%92-black?style=flat-square&logo=apple&logoColor=white)](https://affine.pro/download)
[![AFFiNE Window x64](https://img.shields.io/badge/-Windows%20%E2%86%92-blue?style=flat-square&logo=windows&logoColor=white)](https://affine.pro/download)
@@ -45,7 +45,7 @@ See https://github.com/all-?/all-contributors/issues/361#issuecomment-637166066
---
<div align="center">
<a href="http://affine.pro"><img src="https://img.shields.io/badge/-AFFiNE-06449d?style=social&logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEsAAABLCAMAAAAPkIrYAAAAP1BMVEU8b9w8b9w+b947cNw7b9w6b908b909b9w8b9w7b9w8b9w7cN08b9w7b908b9w7b9w8b907cNw8b9w8b91HcEx3NJCJAAAAFXRSTlP/3QWSgA+lHPlu6Di4XtIrxk/xRADGudUoAAAB9UlEQVR42tWYwbKjIBREG0GJKkRj/v9bZ1ZvRC99rzib11tTB9qqnKoW3/+X38vy7ifzQ1b/wk/8Q1bCv3y6Z6wFh2x2llIRGB6xRhzz6p+wVhRJD1gRZZYHrADYSyqsjFPGZtYbuFESesUysZXlcMnYyJpxTW5keQh5N7G6CUJCE2uHFNfEGiBmbmB1H4jxDawNcqbuPmtAJTtj6RZ0lpIwiR5jNmgfNtHHwLXPWfFYcS2NMdxkjac/dNaNCJPo3yf9pFuseHbDrBsRFguGs8te8Q4rXzTjVSPCIHp3FePKWbzi30xE+4zlBMmoJaGLfpLUmAmLiN4Xyibahy76WZRQMLJ2WX27on2oFvQVac8yi4p+J2forA0V8W1c++AVS1f1H6p9KKLHxk9RWKmsyB+VLC76gV65DLjokdg5KmsEMXsiDwXWSmTc9ezSoKJHoi9zUVihbMHfQOSsXB7Mrz1S1huKPde69sEsiKgNt8hYTjiWlAyENeu7IFe1D15RSEBN+yCiXw17K1RZm/w7UtJVWYN8f1ZyLlkVb2bT4vIVVrINH1dqX2YttkHmIWsfVWs646wcRFYis6fIVGpfYq1kjpGSW8kSRD+xYSmXRM0Ang9eSZioVdy/5pWaLqzIRyIpuVxYozvGf1m67I7pf/s3UXv+AP61NI2Y+BbSAAAAAElFTkSuQmCC" height=25></a>
<a href="http://affine.pro"><img src="https://img.shields.io/badge/-AFFiNE-06449d?style=social&logo=affine" height=25></a>
&nbsp;
<a href="https://community.affine.pro"><img src="https://img.shields.io/badge/-Community-424549?style=social&logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAXNJREFUWEftlitLRUEURtdVEVExWUx2qxgNVouoXYtNDP4Tw20WtftAsItZrHaTYBJREZ98MAc248wcZxi4CGfSeezHmm/23kyPAa/egPPTAXQK/FsFBP7ldVDRZoqcgO9I+2bHy3ZIJBfTCPCZM1tqAxwBmzUBrNQNbEx+5b0B5oEN4NCBrAMnMaiUAuPAs3HU82TLEZwBqwGbaJ4UgKQ8CFR6SoEl4LIWwCJwZQCegKkWBWLHVKSActvdzgG3DqitDf3/VQBskBDALrDnAKXUo3ueAF5KinAf2DKOmnzD7l214bdbA6hC1XHZNQa8hSBC0hwDa57xDHDvvvWB7ciOZoE79+8CWPbsBGc769eFxJdWIKcuyIdRoG3W7AAC1dJkHDIOo8B78+4rEBo8r4AkLFk6Jk3HaeDBBTgHVmIAfpJUz+cAFXVBreQCvQYW/lqEjV1NAMUMqpAaxQMHyDnjYtuS+0BxstwaqJooFqxToFPgB5FuPCEB6XK2AAAAAElFTkSuQmCC" height=25></a>
&nbsp;
@@ -74,7 +74,7 @@ Before we tell you how to get started with AFFiNE, we'd like to shamelessly plug
⚠️ Please note that AFFiNE is still under active development and is not yet ready for production use. ⚠️
[![affine.pro](https://img.shields.io/static/v1?label=Try%20it%20Online&logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAhpJREFUWEdjZEACtnl3MxgY/0YzMjAaMzAwcCLLUYH9/T/D/7MM/5mXHp6kPANmHiOI4Zx9Xfg3C+tKBob/zlSwiAgjGPey/vkdvneq5luwA+zy7+yhn+Vwv+89NFHFhREU7IyM/6YT4WyqK/n/nymT0Tb/1mFGBkYbqptOhIH/Gf4fYbTLv/2NBgmOCOvBSr6DHPCfWNW0UEe2A2x1uRlakiXBbtpx6jND+7KXZLmPbAdURokzeJjxwi31rrzH8OX7P5IdQbYDtnUoMXBzMMEt7Fj2imH7qU/0cQBy8MNsPHL5K0P13Of0cQB68MNsJScaSI4CHk4mhq3tSnCf3n36k0FZmh3Mn7L+DcPqgx9ICgWSHeBpxsdQESUGtgRk+eqDH+H8O09/MiR3P6atA1qTJRlsdLnhPgYlPOQQCW96wPDi3R+iHUFSCKAHP8wydEeREg0kOQA9+JOgwR1qL8CQEygC9jWp0UCSA+aVysIT3JqDHxgmr38DtlRCiIVhZZ0CPNhB6QDkEGIA0Q4gZAkuxxFyBNEOQA7ml+/+MIQ1PUAxG1kelAhB6YMYQLQDCPmQUAjhcgxRDiDWcEKOxOYIohyQGyjCEGIvANaPLfhhBiNHA6hmBBXNhABRDgCV/aBQAAFQpYMrn4PUgNTCACiXEMoNRDmAkC8okR8UDhjYRumAN8sHvGMCSkAD2jUDOWDAO6ewbDQQ3XMAy/oxKownQR0AAAAASUVORK5CYII=&message=%E2%86%92&style=for-the-badge)](https://app.affine.pro) No installation or registration required! Head over to our website and try it out now.
[![affine.pro](https://img.shields.io/static/v1?label=Try%20it%20Online&logo=affine&message=%E2%86%92&style=for-the-badge)](https://app.affine.pro) No installation or registration required! Head over to our website and try it out now.
[![community.affine.pro](https://img.shields.io/static/v1?label=Join%20the%20community&message=%E2%86%92&style=for-the-badge)](https://community.affine.pro) Our wonderful community, where you can meet and engage with the team, developers and other like-minded enthusiastic user of AFFiNE.
@@ -120,6 +120,15 @@ If you have questions, you are welcome to contact us. One of the best places to
| [@toeverything/y-indexeddb](packages/y-indexeddb) | IndexedDB database adapter for Yjs | [![](https://img.shields.io/npm/dm/@toeverything/y-indexeddb?style=flat-square&color=eee)](https://www.npmjs.com/package/@toeverything/y-indexeddb) |
| [@toeverything/theme](packages/theme) | AFFiNE theme | [![](https://img.shields.io/npm/dm/@toeverything/theme?style=flat-square&color=eee)](https://www.npmjs.com/package/@toeverything/theme) |
## Plugins
> Plugins are a way to extend the functionality of AFFiNE.
| Name | |
| ------------------------------------------------ | ----------------------------------------- |
| [@affine/bookmark-block](plugins/bookmark-block) | A block for bookmarking a website |
| [@affine/copilot](plugins/copilot) | AI Copilot that help you document writing |
## Thanks
We would also like to give thanks to open-source projects that make AFFiNE possible:

View File

@@ -1,7 +0,0 @@
/* eslint-disable @typescript-eslint/consistent-type-imports */
// This file contains the main process events
// It will guide preload and main process on the correct event types and payloads
export type MainIPCHandlerMap = typeof import('./main/src/exposed').handlers;
export type MainIPCEventMap = typeof import('./main/src/exposed').events;

View File

@@ -6,7 +6,7 @@ import { v4 } from 'uuid';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import * as Y from 'yjs';
import type { MainIPCHandlerMap } from '../../../constraints';
import type { MainIPCHandlerMap } from '../exposed';
const registeredHandlers = new Map<
string,
@@ -176,7 +176,7 @@ describe('ensureSQLiteDB', () => {
expect(fileExists).toBe(true);
registeredHandlers.get('before-quit')?.forEach(fn => fn());
await delay(100);
expect(workspaceDB.db?.open).toBe(false);
expect(workspaceDB.db).toBe(null);
});
});
@@ -197,7 +197,7 @@ describe('workspace handlers', () => {
const list = await dispatch('workspace', 'list');
expect(list.map(([id]) => id)).toEqual([ids[0]]);
// deleted db should be closed
expect(dbs[1].db?.open).toBe(false);
expect(dbs[1].db).toBe(null);
});
});
@@ -436,6 +436,8 @@ describe('dialog handlers', () => {
});
test('moveDBFile (valid)', async () => {
const sendStub = vi.fn();
browserWindow.webContents.send = sendStub;
const newPath = path.join(SESSION_DATA_PATH, 'xxx');
const showOpenDialog = vi.fn(() => {
return { filePaths: [newPath] };
@@ -444,13 +446,19 @@ describe('dialog handlers', () => {
const id = v4();
const { ensureSQLiteDB } = await import('../db/ensure-db');
await ensureSQLiteDB(id);
const db = await ensureSQLiteDB(id);
const res = await dispatch('dialog', 'moveDBFile', id);
expect(showOpenDialog).toBeCalled();
assert(res.filePath);
expect(path.dirname(res.filePath)).toBe(newPath);
expect(res.filePath.endsWith('.affine')).toBe(true);
// should also send workspace meta change event
expect(sendStub).toBeCalledWith('workspace:onMetaChange', {
workspaceId: id,
meta: { id, secondaryDBPath: res.filePath, mainDBPath: db.path },
});
electronModule.dialog = {};
browserWindow.webContents.send = () => {};
});
test('moveDBFile (canceled)', async () => {
@@ -469,3 +477,18 @@ describe('dialog handlers', () => {
electronModule.dialog = {};
});
});
describe('applicationMenu', () => {
// test some basic IPC events
test('applicationMenu event', async () => {
const { applicationMenuSubjects } = await import('../application-menu');
const sendStub = vi.fn();
browserWindow.webContents.send = sendStub;
applicationMenuSubjects.newPageAction.next();
expect(sendStub).toHaveBeenCalledWith(
'applicationMenu:onNewPageAction',
undefined
);
browserWindow.webContents.send = () => {};
});
});

View File

@@ -1,8 +1,8 @@
import { app, Menu } from 'electron';
import { isMacOS } from '../../../utils';
import { revealLogFile } from '../logger';
import { checkForUpdatesAndNotify } from '../updater';
import { isMacOS } from '../utils';
import { applicationMenuSubjects } from './subject';
// Unique id for menuitems

View File

@@ -0,0 +1 @@
tmp

View File

@@ -0,0 +1,147 @@
import path from 'node:path';
import fs from 'fs-extra';
import { v4 } from 'uuid';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
const tmpDir = path.join(__dirname, 'tmp');
const registeredHandlers = new Map<
string,
((...args: any[]) => Promise<any>)[]
>();
const SESSION_DATA_PATH = path.join(tmpDir, 'affine-test');
const DOCUMENTS_PATH = path.join(tmpDir, 'affine-test-documents');
const electronModule = {
app: {
getPath: (name: string) => {
if (name === 'sessionData') {
return SESSION_DATA_PATH;
} else if (name === 'documents') {
return DOCUMENTS_PATH;
}
throw new Error('not implemented');
},
name: 'affine-test',
on: (name: string, callback: (...args: any[]) => any) => {
const handlers = registeredHandlers.get(name) || [];
handlers.push(callback);
registeredHandlers.set(name, handlers);
},
addEventListener: (...args: any[]) => {
// @ts-ignore
electronModule.app.on(...args);
},
removeEventListener: () => {},
},
shell: {} as Partial<Electron.Shell>,
dialog: {} as Partial<Electron.Dialog>,
};
const runHandler = (key: string) => {
registeredHandlers.get(key)?.forEach(handler => handler());
};
// dynamically import handlers so that we can inject local variables to mocks
vi.doMock('electron', () => {
return electronModule;
});
const constructorStub = vi.fn();
const destroyStub = vi.fn();
vi.doMock('../secondary-db', () => {
return {
SecondaryWorkspaceSQLiteDB: class {
constructor(...args: any[]) {
constructorStub(...args);
}
destroy = destroyStub;
},
};
});
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
});
afterEach(async () => {
runHandler('before-quit');
await fs.remove(tmpDir);
vi.useRealTimers();
});
test('can get a valid WorkspaceSQLiteDB', async () => {
const { ensureSQLiteDB } = await import('../ensure-db');
const workspaceId = v4();
const db0 = await ensureSQLiteDB(workspaceId);
expect(db0).toBeDefined();
expect(db0.workspaceId).toBe(workspaceId);
const db1 = await ensureSQLiteDB(v4());
expect(db1).not.toBe(db0);
expect(db1.workspaceId).not.toBe(db0.workspaceId);
// ensure that the db is cached
expect(await ensureSQLiteDB(workspaceId)).toBe(db0);
});
test('db should be destroyed when app quits', async () => {
const { ensureSQLiteDB } = await import('../ensure-db');
const workspaceId = v4();
const db0 = await ensureSQLiteDB(workspaceId);
const db1 = await ensureSQLiteDB(v4());
expect(db0.db).not.toBeNull();
expect(db1.db).not.toBeNull();
runHandler('before-quit');
expect(db0.db).toBeNull();
expect(db1.db).toBeNull();
});
test('if db has a secondary db path, we should also poll that', async () => {
const { ensureSQLiteDB } = await import('../ensure-db');
const { appContext } = await import('../../context');
const { storeWorkspaceMeta } = await import('../../workspace');
const workspaceId = v4();
await storeWorkspaceMeta(appContext, workspaceId, {
secondaryDBPath: path.join(tmpDir, 'secondary.db'),
});
const db = await ensureSQLiteDB(workspaceId);
await vi.advanceTimersByTimeAsync(1500);
// not sure why but we still need to wait with real timer
await new Promise(resolve => setTimeout(resolve, 100));
expect(constructorStub).toBeCalledTimes(1);
expect(constructorStub).toBeCalledWith(path.join(tmpDir, 'secondary.db'), db);
// if secondary meta is changed
await storeWorkspaceMeta(appContext, workspaceId, {
secondaryDBPath: path.join(tmpDir, 'secondary2.db'),
});
await vi.advanceTimersByTimeAsync(1500);
expect(constructorStub).toBeCalledTimes(2);
expect(destroyStub).toBeCalledTimes(1);
// if secondary meta is changed (but another workspace)
await storeWorkspaceMeta(appContext, v4(), {
secondaryDBPath: path.join(tmpDir, 'secondary3.db'),
});
await vi.advanceTimersByTimeAsync(1500);
expect(constructorStub).toBeCalledTimes(2);
expect(destroyStub).toBeCalledTimes(1);
// if primary is destroyed, secondary should also be destroyed
db.destroy();
await new Promise(resolve => setTimeout(resolve, 100));
expect(destroyStub).toBeCalledTimes(2);
});

View File

@@ -0,0 +1,101 @@
import path from 'node:path';
import fs from 'fs-extra';
import { v4 } from 'uuid';
import { afterEach, expect, test, vi } from 'vitest';
import * as Y from 'yjs';
import type { AppContext } from '../../context';
import { dbSubjects } from '../subjects';
const tmpDir = path.join(__dirname, 'tmp');
const testAppContext: AppContext = {
appDataPath: path.join(tmpDir, 'test-data'),
appName: 'test',
};
afterEach(async () => {
if (process.platform !== 'win32') {
// hmmm ....
await fs.remove(tmpDir);
}
});
function getTestUpdates() {
const testYDoc = new Y.Doc();
const yText = testYDoc.getText('test');
yText.insert(0, 'hello');
const updates = Y.encodeStateAsUpdate(testYDoc);
return updates;
}
test('can create new db file if not exists', async () => {
const { openWorkspaceDatabase } = await import('../workspace-db-adapter');
const workspaceId = v4();
const db = await openWorkspaceDatabase(testAppContext, workspaceId);
const dbPath = path.join(
testAppContext.appDataPath,
`workspaces/${workspaceId}`,
`storage.db`
);
expect(await fs.exists(dbPath)).toBe(true);
db.destroy();
});
test('on applyUpdate (from self), will not trigger update', async () => {
const { openWorkspaceDatabase } = await import('../workspace-db-adapter');
const workspaceId = v4();
const onUpdate = vi.fn();
const db = await openWorkspaceDatabase(testAppContext, workspaceId);
db.update$.subscribe(onUpdate);
db.applyUpdate(getTestUpdates(), 'self');
expect(onUpdate).not.toHaveBeenCalled();
db.destroy();
});
test('on applyUpdate (from renderer), will trigger update', async () => {
const { openWorkspaceDatabase } = await import('../workspace-db-adapter');
const workspaceId = v4();
const onUpdate = vi.fn();
const onExternalUpdate = vi.fn();
const db = await openWorkspaceDatabase(testAppContext, workspaceId);
db.update$.subscribe(onUpdate);
const sub = dbSubjects.externalUpdate.subscribe(onExternalUpdate);
db.applyUpdate(getTestUpdates(), 'renderer');
expect(onUpdate).toHaveBeenCalled(); // not yet updated
sub.unsubscribe();
db.destroy();
});
test('on applyUpdate (from external), will trigger update & send external update event', async () => {
const { openWorkspaceDatabase } = await import('../workspace-db-adapter');
const workspaceId = v4();
const onUpdate = vi.fn();
const onExternalUpdate = vi.fn();
const db = await openWorkspaceDatabase(testAppContext, workspaceId);
db.update$.subscribe(onUpdate);
const sub = dbSubjects.externalUpdate.subscribe(onExternalUpdate);
db.applyUpdate(getTestUpdates(), 'external');
expect(onUpdate).toHaveBeenCalled();
expect(onExternalUpdate).toHaveBeenCalled();
sub.unsubscribe();
db.destroy();
});
test('on destroy, check if resources have been released', async () => {
const { openWorkspaceDatabase } = await import('../workspace-db-adapter');
const workspaceId = v4();
const db = await openWorkspaceDatabase(testAppContext, workspaceId);
const updateSub = {
complete: vi.fn(),
next: vi.fn(),
};
db.update$ = updateSub as any;
db.destroy();
expect(db.db).toBe(null);
expect(updateSub.complete).toHaveBeenCalled();
});

View File

@@ -44,9 +44,9 @@ export abstract class BaseSQLiteAdapter {
}
// todo: what if SQLite DB wrapper later is not sync?
connect() {
connect(): Database | undefined {
if (this.db) {
return;
return this.db;
}
logger.log(`[SQLiteAdapter][${this.role}] open db`, this.path);
const db = (this.db = sqlite(this.path));

View File

@@ -5,6 +5,7 @@ import {
from,
fromEvent,
interval,
merge,
Observable,
} from 'rxjs';
import {
@@ -36,6 +37,7 @@ function getWorkspaceDB$(id: string) {
db$Map.set(
id,
from(openWorkspaceDatabase(appContext, id)).pipe(
shareReplay(1),
switchMap(db => {
return startPollingSecondaryDB(db).pipe(
ignoreElements(),
@@ -57,7 +59,6 @@ function getWorkspaceDB$(id: string) {
return db$Map.get(id)!;
}
// fixme: this function has issue on registering multiple times...
function startPollingSecondaryDB(db: WorkspaceSQLiteDB) {
const meta$ = getWorkspaceMeta$(db.workspaceId);
const secondaryDB$ = meta$.pipe(
@@ -65,28 +66,43 @@ function startPollingSecondaryDB(db: WorkspaceSQLiteDB) {
distinctUntilChanged(),
filter((p): p is string => !!p),
switchMap(path => {
const secondaryDB = new SecondaryWorkspaceSQLiteDB(path, db);
return new Observable<SecondaryWorkspaceSQLiteDB>(observer => {
const secondaryDB = new SecondaryWorkspaceSQLiteDB(path, db);
observer.next(secondaryDB);
return () => {
logger.info(
'[ensureSQLiteDB] close secondary db connection',
secondaryDB.path
);
secondaryDB.destroy();
};
});
})
}),
takeUntil(db.update$.pipe(last())),
shareReplay(1)
);
const firstDelayedTick$ = defer(() => {
return new Promise<number>(resolve =>
setTimeout(() => {
resolve(0);
}, 1000)
);
});
// pull every 30 seconds
const poll$ = interval(30000).pipe(
const poll$ = merge(firstDelayedTick$, interval(30000)).pipe(
switchMap(() => secondaryDB$),
tap({
next: secondaryDB => {
secondaryDB.pull();
},
}),
takeUntil(db.update$.pipe(last())),
shareReplay(1)
);
return poll$.pipe(takeUntil(db.update$.pipe(last())), shareReplay(1));
return poll$;
}
export function ensureSQLiteDB(id: string) {

View File

@@ -4,6 +4,7 @@ import * as Y from 'yjs';
import type { AppContext } from '../context';
import { logger } from '../logger';
import type { YOrigin } from '../type';
import { mergeUpdateWorker } from '../workers';
import { getWorkspaceMeta } from '../workspace';
import { BaseSQLiteAdapter } from './base-db-adapter';
import type { WorkspaceSQLiteDB } from './workspace-db-adapter';
@@ -26,6 +27,7 @@ export class SecondaryWorkspaceSQLiteDB extends BaseSQLiteAdapter {
) {
super(path);
this.setupAndListen();
logger.debug('[SecondaryWorkspaceSQLiteDB] created', this.workspaceId);
}
close() {
@@ -34,10 +36,10 @@ export class SecondaryWorkspaceSQLiteDB extends BaseSQLiteAdapter {
}
override destroy() {
this.flushUpdateQueue();
this.unsubscribers.forEach(unsub => unsub());
this.db?.close();
this.yDoc.destroy();
this.close();
}
get workspaceId() {
@@ -74,13 +76,13 @@ export class SecondaryWorkspaceSQLiteDB extends BaseSQLiteAdapter {
// wrap the fn with connect and close
// it only works for sync functions
run = (fn: () => void) => {
run = <T extends (...args: any[]) => any>(fn: T) => {
try {
if (this.runCounter === 0) {
this.connect();
}
this.runCounter++;
fn();
return fn();
} catch (err) {
logger.error(err);
} finally {
@@ -166,19 +168,24 @@ export class SecondaryWorkspaceSQLiteDB extends BaseSQLiteAdapter {
* - get blobs and put new blobs to upstream
* - disconnect
*/
pull() {
this.run(() => {
async pull() {
const start = performance.now();
const updates = this.run(() => {
// TODO: no need to get all updates, just get the latest ones (using a cursor, etc)?
const updates = this.getUpdates().map(update => update.data);
Y.transact(this.yDoc, () => {
updates.forEach(update => {
this.applyUpdate(update, 'self');
});
});
logger.debug('pull external updates', this.path, updates.length);
this.syncBlobs();
return this.getUpdates().map(update => update.data);
});
const merged = await mergeUpdateWorker(updates);
this.applyUpdate(merged, 'self');
logger.debug(
'pull external updates',
this.path,
updates.length,
(performance.now() - start).toFixed(2),
'ms'
);
}
}

View File

@@ -1,9 +1,11 @@
import type { Database } from 'better-sqlite3';
import { Subject } from 'rxjs';
import * as Y from 'yjs';
import type { AppContext } from '../context';
import { logger } from '../logger';
import type { YOrigin } from '../type';
import { mergeUpdateWorker } from '../workers';
import { getWorkspaceMeta } from '../workspace';
import { BaseSQLiteAdapter } from './base-db-adapter';
import { dbSubjects } from './subjects';
@@ -21,6 +23,7 @@ export class WorkspaceSQLiteDB extends BaseSQLiteAdapter {
override destroy() {
this.db?.close();
this.db = null;
this.yDoc.destroy();
// when db is closed, we can safely remove it from ensure-db list
@@ -31,15 +34,15 @@ export class WorkspaceSQLiteDB extends BaseSQLiteAdapter {
return this.yDoc.getMap('space:meta').get('name') as string;
};
override connect() {
async init(): Promise<Database | undefined> {
const db = super.connect();
if (!this.firstConnected) {
this.yDoc.on('update', (update: Uint8Array, origin: YOrigin) => {
if (origin !== 'self') {
if (origin === 'renderer') {
this.addUpdateToSQLite([update]);
} else if (origin === 'external') {
this.addUpdateToSQLite([update]);
}
if (origin === 'external') {
logger.debug('external update', this.workspaceId);
dbSubjects.externalUpdate.next({
workspaceId: this.workspaceId,
@@ -50,12 +53,10 @@ export class WorkspaceSQLiteDB extends BaseSQLiteAdapter {
}
const updates = this.getUpdates();
const merged = await mergeUpdateWorker(updates.map(update => update.data));
// to initialize the yDoc, we need to apply all updates from the db
Y.transact(this.yDoc, () => {
updates.forEach(update => {
this.applyUpdate(update.data, 'self');
});
});
this.applyUpdate(merged, 'self');
this.firstConnected = true;
this.update$.next();
@@ -100,6 +101,6 @@ export async function openWorkspaceDatabase(
) {
const meta = await getWorkspaceMeta(context, workspaceId);
const db = new WorkspaceSQLiteDB(meta.mainDBPath, workspaceId);
await db.connect();
await db.init();
return db;
}

View File

@@ -29,7 +29,7 @@ export async function revealDBFile(workspaceId: string) {
}
// provide a backdoor to set dialog path for testing in playwright
interface FakeDialogResult {
export interface FakeDialogResult {
canceled?: boolean;
filePath?: string;
filePaths?: string[];
@@ -63,7 +63,7 @@ const ErrorMessages = [
type ErrorMessage = (typeof ErrorMessages)[number];
interface SaveDBFileResult {
export interface SaveDBFileResult {
filePath?: string;
canceled?: boolean;
error?: ErrorMessage;
@@ -122,7 +122,7 @@ export async function saveDBFileAs(
}
}
interface SelectDBFileLocationResult {
export interface SelectDBFileLocationResult {
filePath?: string;
error?: ErrorMessage;
canceled?: boolean;
@@ -154,7 +154,7 @@ export async function selectDBFileLocation(): Promise<SelectDBFileLocationResult
}
}
interface LoadDBFileResult {
export interface LoadDBFileResult {
workspaceId?: string;
error?: ErrorMessage;
canceled?: boolean;
@@ -237,7 +237,7 @@ export async function loadDBFile(): Promise<LoadDBFileResult> {
}
}
interface MoveDBFileResult {
export interface MoveDBFileResult {
filePath?: string;
error?: ErrorMessage;
canceled?: boolean;

View File

@@ -21,9 +21,18 @@ export function registerEvents() {
// register events
for (const [namespace, namespaceEvents] of Object.entries(allEvents)) {
for (const [key, eventRegister] of Object.entries(namespaceEvents)) {
const subscription = eventRegister((...args: any) => {
const subscription = eventRegister((...args: any[]) => {
const chan = `${namespace}:${key}`;
logger.info('[ipc-event]', chan, args);
logger.info(
'[ipc-event]',
chan,
args.filter(
a =>
a !== undefined &&
typeof a !== 'function' &&
typeof a !== 'object'
)
);
getActiveWindows().forEach(win => win.webContents.send(chan, ...args));
});
app.on('before-quit', () => {

View File

@@ -5,7 +5,7 @@ import { logger } from '../logger';
import type { ErrorMessage } from './utils';
import { getFakedResult } from './utils';
interface SavePDFFileResult {
export interface SavePDFFileResult {
filePath?: string;
canceled?: boolean;
error?: ErrorMessage;

View File

@@ -30,3 +30,7 @@ export const getExposedMeta = () => {
events: eventsMeta,
};
};
export type MainIPCHandlerMap = typeof handlers;
export type MainIPCEventMap = typeof events;

View File

@@ -2,9 +2,9 @@ import { BrowserWindow, nativeTheme } from 'electron';
import electronWindowState from 'electron-window-state';
import { join } from 'path';
import { isMacOS, isWindows } from '../../utils';
import { getExposedMeta } from './exposed';
import { logger } from './logger';
import { isMacOS, isWindows } from './utils';
const IS_DEV: boolean =
process.env.NODE_ENV === 'development' && !process.env.CI;

View File

@@ -0,0 +1,49 @@
import { BrowserWindow } from 'electron';
import type { GetHTMLOptions } from './types';
async function getHTMLFromWindow(win: BrowserWindow): Promise<string> {
return win.webContents
.executeJavaScript(`document.documentElement.outerHTML;`)
.then(html => html);
}
// For normal web pages, obtaining html can be done directly,
// but for some dynamic web pages, obtaining html should wait for the complete loading of web pages. shouldReGetHTML should be used to judge whether to obtain html again
export async function getHTMLByURL(
url: string,
options: GetHTMLOptions
): Promise<string> {
return new Promise(resolve => {
const { timeout = 10000, shouldReGetHTML } = options;
const window = new BrowserWindow({
show: false,
});
let html = '';
window.loadURL(url);
const timer = setTimeout(() => {
resolve(html);
window.close();
}, timeout);
async function loopHandle() {
html = await getHTMLFromWindow(window);
if (!shouldReGetHTML) {
return html;
}
if (await shouldReGetHTML(html)) {
setTimeout(loopHandle, 1000);
} else {
window.close();
clearTimeout(timer);
resolve(html);
}
}
window.webContents.on('did-finish-load', async () => {
loopHandle();
});
});
}

View File

@@ -1,23 +1,15 @@
import type { CheerioAPI, Element } from 'cheerio';
import { load } from 'cheerio';
import got from 'got';
import type { Context, MetaData, Options, RuleSet } from './types';
export * from './types';
import { getHTMLByURL } from './get-html';
import { metaDataRules } from './rules';
import type { GetMetaDataOptions } from './types';
const defaultOptions = {
maxRedirects: 5,
ua: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36',
lang: '*',
timeout: 10000,
forceImageHttps: true,
customRules: {},
};
const runRule = function (ruleSet: RuleSet, $: CheerioAPI, context: Context) {
function runRule(ruleSet: RuleSet, $: CheerioAPI, context: Context) {
let maxScore = 0;
let value;
@@ -58,61 +50,31 @@ const runRule = function (ruleSet: RuleSet, $: CheerioAPI, context: Context) {
}
return undefined;
};
}
const getMetaData = async function (
input: string | Partial<Options>,
inputOptions: Partial<Options> = {}
async function getMetaDataByHTML(
html: string,
url: string,
options: GetMetaDataOptions
) {
let url;
if (typeof input === 'object') {
inputOptions = input;
url = input.url || '';
} else {
url = input;
}
const options = Object.assign({}, defaultOptions, inputOptions);
const { customRules = {} } = options;
const rules: Record<string, RuleSet> = { ...metaDataRules };
Object.keys(options.customRules).forEach((key: string) => {
Object.keys(customRules).forEach((key: string) => {
rules[key] = {
rules: [...metaDataRules[key].rules, ...options.customRules[key].rules],
rules: [...metaDataRules[key].rules, ...customRules[key].rules],
defaultValue:
options.customRules[key].defaultValue ||
metaDataRules[key].defaultValue,
processor:
options.customRules[key].processor || metaDataRules[key].processor,
customRules[key].defaultValue || metaDataRules[key].defaultValue,
processor: customRules[key].processor || metaDataRules[key].processor,
};
});
let html;
if (!options.html) {
const response = await got(url, {
headers: {
'User-Agent': options.ua,
'Accept-Language': options.lang,
},
timeout: options.timeout,
...(options.maxRedirects === 0
? { followRedirect: false }
: { maxRedirects: options.maxRedirects }),
});
html = response.body;
} else {
html = options.html;
}
const metadata: MetaData = {};
const context: Context = {
url,
options,
...options,
};
const $ = load(html);
// console.log('===============================');
// console.log('html');
// console.log(doc);
Object.keys(rules).forEach((key: string) => {
const ruleSet = rules[key];
@@ -120,6 +82,26 @@ const getMetaData = async function (
});
return metadata;
};
}
export { getMetaData };
export async function getMetaData(url: string, options: Options = {}) {
const { customRules, forceImageHttps, shouldReGetHTML, ...other } = options;
const html = await getHTMLByURL(url, {
...other,
shouldReGetHTML: async html => {
const meta = await getMetaDataByHTML(html, url, {
customRules,
forceImageHttps,
});
return shouldReGetHTML ? await shouldReGetHTML(meta) : false;
},
}).catch(() => {
// TODO: report error
return '';
});
return await getMetaDataByHTML(html, url, {
customRules,
forceImageHttps,
});
}

View File

@@ -591,7 +591,7 @@ export const metaDataRules: Record<string, RuleSet> = {
],
],
processor: (imageUrl: any, context) =>
context.options.forceImageHttps === true
context.forceImageHttps === true
? makeUrlSecure(makeUrlAbsolute(context.url, imageUrl))
: makeUrlAbsolute(context.url, imageUrl),
},
@@ -625,7 +625,7 @@ export const metaDataRules: Record<string, RuleSet> = {
},
defaultValue: context => makeUrlAbsolute(context.url, '/favicon.ico'),
processor: (iconUrl, context) =>
context.options.forceImageHttps === true
context.forceImageHttps === true
? makeUrlSecure(makeUrlAbsolute(context.url, iconUrl))
: makeUrlAbsolute(context.url, iconUrl),
},
@@ -654,7 +654,7 @@ export const metaDataRules: Record<string, RuleSet> = {
['meta[name="og:video"][content]', element => element.attribs['content']],
],
processor: (imageUrl: any, context) =>
context.options.forceImageHttps === true
context.forceImageHttps === true
? makeUrlSecure(makeUrlAbsolute(context.url, imageUrl))
: makeUrlAbsolute(context.url, imageUrl),
},
@@ -683,7 +683,7 @@ export const metaDataRules: Record<string, RuleSet> = {
['meta[name="og:audio"][content]', element => element.attribs['content']],
],
processor: (imageUrl: any, context) =>
context.options.forceImageHttps === true
context.forceImageHttps === true
? makeUrlSecure(makeUrlAbsolute(context.url, imageUrl))
: makeUrlAbsolute(context.url, imageUrl),
},

View File

@@ -1,6 +1,6 @@
import type { Element } from 'cheerio';
export interface MetaData {
export type MetaData = {
title?: string;
description?: string;
icon?: string;
@@ -12,29 +12,32 @@ export interface MetaData {
provider?: string;
[x: string]: string | string[] | undefined;
}
};
export type MetadataRule = [string, (el: Element) => string | null];
export interface Context {
export type Context = {
url: string;
options: Options;
}
} & GetMetaDataOptions;
export interface RuleSet {
export type RuleSet = {
rules: MetadataRule[];
defaultValue?: (context: Context) => string | string[];
scorer?: (el: Element, score: any) => any;
processor?: (input: any, context: Context) => any;
}
};
export interface Options {
maxRedirects?: number;
ua?: string;
lang?: string;
timeout?: number;
forceImageHttps?: boolean;
html?: string;
url?: string;
export type GetMetaDataOptions = {
customRules?: Record<string, RuleSet>;
}
forceImageHttps?: boolean;
};
export type GetHTMLOptions = {
timeout?: number;
shouldReGetHTML?: (currentHTML: string) => boolean | Promise<boolean>;
};
export type Options = {
shouldReGetHTML?: (metaData: MetaData) => boolean | Promise<boolean>;
} & GetMetaDataOptions &
Omit<GetHTMLOptions, 'shouldReGetHTML'>;

View File

@@ -1,7 +1,7 @@
import { app, BrowserWindow, nativeTheme, session } from 'electron';
import { app, BrowserWindow, nativeTheme } from 'electron';
import { isMacOS } from '../../../utils';
import type { NamespaceHandlers } from '../type';
import { isMacOS } from '../utils';
import { getMetaData } from './get-meta-data';
import { getGoogleOauthCode } from './google-auth';
@@ -42,7 +42,9 @@ export const uiHandlers = {
},
getBookmarkDataByLink: async (_, url: string) => {
return getMetaData(url, {
ua: session.defaultSession.getUserAgent(),
shouldReGetHTML: metaData => {
return !metaData.title && !metaData.description;
},
});
},
} satisfies NamespaceHandlers;

View File

@@ -2,8 +2,8 @@ import { app } from 'electron';
import type { AppUpdater } from 'electron-updater';
import { z } from 'zod';
import { isMacOS } from '../../../utils';
import { logger } from '../logger';
import { isMacOS } from '../utils';
import { updaterSubjects } from './event';
export const ReleaseTypeSchema = z.enum([

View File

@@ -2,7 +2,7 @@ import { BehaviorSubject, Subject } from 'rxjs';
import type { MainEventListener } from '../type';
interface UpdateMeta {
export interface UpdateMeta {
version: string;
allowAutoUpdate: boolean;
}

View File

@@ -1,3 +1,11 @@
export function getTime() {
return new Date().getTime();
}
export const isMacOS = () => {
return process.platform === 'darwin';
};
export const isWindows = () => {
return process.platform === 'win32';
};

View File

@@ -0,0 +1,35 @@
import path from 'node:path';
import { Worker } from 'node:worker_threads';
import { mergeUpdate } from './merge-update';
export function mergeUpdateWorker(updates: Uint8Array[]) {
// fallback to main thread if worker is disabled (in vitest)
if (process.env.USE_WORKER !== 'true') {
return mergeUpdate(updates);
}
return new Promise<Uint8Array>((resolve, reject) => {
// it is intended to have "./workers" in the path
const workerFile = path.join(__dirname, './workers/merge-update.worker.js');
// convert updates to SharedArrayBuffer[s]
const sharedArrayBufferUpdates = updates.map(update => {
const buffer = new SharedArrayBuffer(update.byteLength);
const view = new Uint8Array(buffer);
view.set(update);
return view;
});
const worker = new Worker(workerFile, {
workerData: sharedArrayBufferUpdates,
});
worker.on('message', resolve);
worker.on('error', reject);
worker.on('exit', code => {
if (code !== 0) {
reject(new Error(`Worker stopped with exit code ${code}`));
}
});
});
}

View File

@@ -0,0 +1,11 @@
import * as Y from 'yjs';
export function mergeUpdate(updates: Uint8Array[]) {
const yDoc = new Y.Doc();
Y.transact(yDoc, () => {
for (const update of updates) {
Y.applyUpdate(yDoc, update);
}
});
return Y.encodeStateAsUpdate(yDoc);
}

View File

@@ -0,0 +1,14 @@
import { parentPort, workerData } from 'node:worker_threads';
import { mergeUpdate } from './merge-update';
function getMergeUpdate(updates: Uint8Array[]) {
const update = mergeUpdate(updates);
const buffer = new SharedArrayBuffer(update.byteLength);
const view = new Uint8Array(buffer);
view.set(update);
return update;
}
parentPort?.postMessage(getMergeUpdate(workerData));

View File

@@ -0,0 +1 @@
tmp

View File

@@ -0,0 +1,208 @@
import path from 'node:path';
import fs from 'fs-extra';
import { v4 } from 'uuid';
import { afterEach, describe, expect, test, vi } from 'vitest';
import type { AppContext } from '../../context';
const tmpDir = path.join(__dirname, 'tmp');
const testAppContext: AppContext = {
appDataPath: path.join(tmpDir, 'test-data'),
appName: 'test',
};
vi.doMock('../../context', () => ({
appContext: testAppContext,
}));
vi.doMock('../../db/ensure-db', () => ({
ensureSQLiteDB: async () => ({
destroy: () => {},
}),
}));
afterEach(async () => {
await fs.remove(tmpDir);
});
describe('list workspaces', () => {
test('listWorkspaces (valid)', async () => {
const { listWorkspaces } = await import('../handlers');
const workspaceId = v4();
const workspacePath = path.join(
testAppContext.appDataPath,
'workspaces',
workspaceId
);
const meta = {
id: workspaceId,
};
await fs.ensureDir(workspacePath);
await fs.writeJSON(path.join(workspacePath, 'meta.json'), meta);
const workspaces = await listWorkspaces(testAppContext);
expect(workspaces).toEqual([[workspaceId, meta]]);
});
test('listWorkspaces (without meta json file)', async () => {
const { listWorkspaces } = await import('../handlers');
const workspaceId = v4();
const workspacePath = path.join(
testAppContext.appDataPath,
'workspaces',
workspaceId
);
await fs.ensureDir(workspacePath);
const workspaces = await listWorkspaces(testAppContext);
expect(workspaces).toEqual([
[
workspaceId,
// meta file will be created automatically
{ id: workspaceId, mainDBPath: path.join(workspacePath, 'storage.db') },
],
]);
});
});
describe('delete workspace', () => {
test('deleteWorkspace', async () => {
const { deleteWorkspace } = await import('../handlers');
const workspaceId = v4();
const workspacePath = path.join(
testAppContext.appDataPath,
'workspaces',
workspaceId
);
await fs.ensureDir(workspacePath);
await deleteWorkspace(testAppContext, workspaceId);
expect(await fs.pathExists(workspacePath)).toBe(false);
// removed workspace will be moved to delete-workspaces
expect(
await fs.pathExists(
path.join(testAppContext.appDataPath, 'delete-workspaces', workspaceId)
)
).toBe(true);
});
});
describe('getWorkspaceMeta', () => {
test('can get meta', async () => {
const { getWorkspaceMeta } = await import('../handlers');
const workspaceId = v4();
const workspacePath = path.join(
testAppContext.appDataPath,
'workspaces',
workspaceId
);
const meta = {
id: workspaceId,
};
await fs.ensureDir(workspacePath);
await fs.writeJSON(path.join(workspacePath, 'meta.json'), meta);
expect(await getWorkspaceMeta(testAppContext, workspaceId)).toEqual(meta);
});
test('can create meta if not exists', async () => {
const { getWorkspaceMeta } = await import('../handlers');
const workspaceId = v4();
const workspacePath = path.join(
testAppContext.appDataPath,
'workspaces',
workspaceId
);
await fs.ensureDir(workspacePath);
expect(await getWorkspaceMeta(testAppContext, workspaceId)).toEqual({
id: workspaceId,
mainDBPath: path.join(workspacePath, 'storage.db'),
});
expect(
await fs.pathExists(path.join(workspacePath, 'meta.json'))
).toBeTruthy();
});
test('can migrate meta if db file is a link', async () => {
const { getWorkspaceMeta } = await import('../handlers');
const workspaceId = v4();
const workspacePath = path.join(
testAppContext.appDataPath,
'workspaces',
workspaceId
);
await fs.ensureDir(workspacePath);
const sourcePath = path.join(tmpDir, 'source.db');
await fs.writeFile(sourcePath, 'test');
await fs.ensureSymlink(sourcePath, path.join(workspacePath, 'storage.db'));
expect(await getWorkspaceMeta(testAppContext, workspaceId)).toEqual({
id: workspaceId,
mainDBPath: path.join(workspacePath, 'storage.db'),
secondaryDBPath: sourcePath,
});
expect(
await fs.pathExists(path.join(workspacePath, 'meta.json'))
).toBeTruthy();
});
});
test('storeWorkspaceMeta', async () => {
const { storeWorkspaceMeta } = await import('../handlers');
const workspaceId = v4();
const workspacePath = path.join(
testAppContext.appDataPath,
'workspaces',
workspaceId
);
await fs.ensureDir(workspacePath);
const meta = {
id: workspaceId,
mainDBPath: path.join(workspacePath, 'storage.db'),
};
await storeWorkspaceMeta(testAppContext, workspaceId, meta);
expect(await fs.readJSON(path.join(workspacePath, 'meta.json'))).toEqual(
meta
);
await storeWorkspaceMeta(testAppContext, workspaceId, {
secondaryDBPath: path.join(tmpDir, 'test.db'),
});
expect(await fs.readJSON(path.join(workspacePath, 'meta.json'))).toEqual({
...meta,
secondaryDBPath: path.join(tmpDir, 'test.db'),
});
});
test('getWorkspaceMeta observable', async () => {
const { storeWorkspaceMeta } = await import('../handlers');
const { getWorkspaceMeta$ } = await import('../index');
const workspaceId = v4();
const workspacePath = path.join(
testAppContext.appDataPath,
'workspaces',
workspaceId
);
const metaChange = vi.fn();
const meta$ = getWorkspaceMeta$(workspaceId);
meta$.subscribe(metaChange);
await new Promise(resolve => setTimeout(resolve, 100));
expect(metaChange).toHaveBeenCalledWith({
id: workspaceId,
mainDBPath: path.join(workspacePath, 'storage.db'),
});
await storeWorkspaceMeta(testAppContext, workspaceId, {
secondaryDBPath: path.join(tmpDir, 'test.db'),
});
expect(metaChange).toHaveBeenCalledWith({
id: workspaceId,
mainDBPath: path.join(workspacePath, 'storage.db'),
secondaryDBPath: path.join(tmpDir, 'test.db'),
});
});

View File

@@ -42,7 +42,6 @@ export async function deleteWorkspace(context: AppContext, id: string) {
try {
const db = await ensureSQLiteDB(id);
db.destroy();
// TODO: should remove DB connection first
return await fs.move(basePath, movedPath, {
overwrite: true,
});

View File

@@ -1,5 +1,5 @@
import { from, merge } from 'rxjs';
import { map } from 'rxjs/operators';
import { merge } from 'rxjs';
import { filter, map } from 'rxjs/operators';
import { appContext } from '../context';
import type {
@@ -35,7 +35,10 @@ export const workspaceHandlers = {
// used internally. Get a stream of workspace id -> meta
export const getWorkspaceMeta$ = (workspaceId: string) => {
return merge(
from(getWorkspaceMeta(appContext, workspaceId)),
workspaceSubjects.meta.pipe(map(meta => meta.meta))
getWorkspaceMeta(appContext, workspaceId),
workspaceSubjects.meta.pipe(
map(meta => meta.meta),
filter(meta => meta.id === workspaceId)
)
);
};

View File

@@ -1,7 +1,6 @@
/* eslint-disable @typescript-eslint/consistent-type-imports */
interface Window {
apis: typeof import('./src/affine-apis').apis;
events: typeof import('./src/affine-apis').events;
appInfo: typeof import('./src/affine-apis').appInfo;
declare interface Window {
apis: import('./src/affine-apis').PreloadHandlers;
events: import('./src/affine-apis').MainIPCEventMap;
}

View File

@@ -1,9 +1,13 @@
/* eslint-disable @typescript-eslint/no-var-requires */
// NOTE: we will generate preload types from this file
// NOTE: we will generate preload types from this file
import { ipcRenderer } from 'electron';
import type { MainIPCEventMap, MainIPCHandlerMap } from '../../constraints';
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
import type {
MainIPCEventMap,
MainIPCHandlerMap,
} from '../../main/src/exposed';
type WithoutFirstParameter<T> = T extends (_: any, ...args: infer P) => infer R
? (...args: P) => R
@@ -15,7 +19,7 @@ type HandlersMap<N extends keyof MainIPCHandlerMap> = {
>;
};
type PreloadHandlers = {
export type PreloadHandlers = {
[N in keyof MainIPCHandlerMap]: HandlersMap<N>;
};
@@ -88,3 +92,6 @@ const appInfo = {
};
export { apis, appInfo, events };
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
export type { MainIPCEventMap } from '../../main/src/exposed';

View File

@@ -1,7 +0,0 @@
export const isMacOS = () => {
return process.platform === 'darwin';
};
export const isWindows = () => {
return process.platform === 'win32';
};

View File

@@ -1,7 +1,7 @@
{
"name": "@affine/electron",
"private": true,
"version": "0.7.0-canary.2",
"version": "0.7.0-canary.8",
"author": "affine",
"repository": {
"url": "https://github.com/toeverything/AFFiNE",
@@ -42,13 +42,13 @@
"@types/fs-extra": "^11.0.1",
"@types/uuid": "^9.0.1",
"cross-env": "7.0.3",
"electron": "24.4.0",
"electron": "25.0.0",
"electron-log": "^5.0.0-beta.24",
"electron-squirrel-startup": "1.0.0",
"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",

View File

@@ -23,6 +23,7 @@ export const config = () => {
JSON.stringify(process.env[key] ?? ''),
]),
['process.env.NODE_ENV', `"${mode}"`],
['process.env.USE_WORKER', '"true"'],
]);
if (DEV_SERVER_URL) {
@@ -31,7 +32,10 @@ export const config = () => {
return {
main: {
entryPoints: [resolve(root, './layers/main/src/index.ts')],
entryPoints: [
resolve(root, './layers/main/src/index.ts'),
resolve(root, './layers/main/src/workers/merge-update.worker.ts'),
],
outdir: resolve(root, './dist/layers/main'),
bundle: true,
target: `node${NODE_MAJOR_VERSION}`,

View File

@@ -2,7 +2,9 @@
"extends": "../../../tsconfig.json",
"compilerOptions": {
"baseUrl": ".",
"noEmit": true
"noEmit": true,
"target": "ESNext"
},
"references": [{ "path": "../../../tests/kit" }],
"include": ["**.spec.ts", "**.test.ts"]
}

View File

@@ -1,6 +1,7 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"target": "ESNext",
"module": "ESNext",
@@ -10,17 +11,22 @@
"outDir": "dist",
"moduleResolution": "node",
"resolveJsonModule": true,
"noImplicitOverride": true
"noImplicitOverride": true,
"noEmit": false
},
"include": ["**/*.ts", "**/*.tsx", "package.json"],
"exclude": ["out", "dist", "node_modules"],
"include": ["**/*.ts", "**/*.tsx"],
"exclude": ["node_modules", "out", "dist"],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "../../packages/native"
}
},
{
"path": "../../packages/env"
},
{ "path": "../../tests/kit" }
],
"ts-node": {
"esm": true,

View File

@@ -1,11 +1,13 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"composite": true,
"target": "ESNext",
"module": "ESNext",
"resolveJsonModule": true,
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true
"allowSyntheticDefaultImports": true,
"noEmit": false
},
"include": ["./scripts", "package.json"]
"include": ["./scripts"]
}

View File

@@ -1,7 +1,7 @@
{
"name": "@affine/server",
"private": true,
"version": "0.7.0-canary.2",
"version": "0.7.0-canary.8",
"description": "Affine Node.js server",
"type": "module",
"bin": {
@@ -21,14 +21,14 @@
"@nestjs/graphql": "^11.0.6",
"@nestjs/platform-express": "^9.4.2",
"@node-rs/bcrypt": "^1.7.1",
"@prisma/client": "^4.14.1",
"dotenv": "^16.0.3",
"@prisma/client": "^4.15.0",
"dotenv": "^16.1.1",
"express": "^4.18.2",
"graphql": "^16.6.0",
"graphql-type-json": "^0.3.2",
"jsonwebtoken": "^9.0.0",
"lodash-es": "^4.17.21",
"prisma": "^4.14.1",
"prisma": "^4.15.0",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.8.1"
},
@@ -39,12 +39,12 @@
"@types/lodash-es": "^4.17.7",
"@types/node": "^18.16.16",
"@types/supertest": "^2.0.12",
"c8": "^7.13.0",
"c8": "^7.14.0",
"nodemon": "^2.0.22",
"supertest": "^6.3.3",
"ts-node": "^10.9.1",
"typescript": "^5.0.4",
"vitest": "^0.31.1"
"vitest": "^0.31.2"
},
"nodemonConfig": {
"exec": "node",

View File

@@ -10,7 +10,7 @@ declare global {
}
}
export const enum ExternalAccount {
export enum ExternalAccount {
github = 'github',
google = 'google',
firebase = 'firebase',

View File

@@ -5,7 +5,8 @@
"module": "ESNext",
"resolveJsonModule": true,
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true
"allowSyntheticDefaultImports": true,
"outDir": "dist/scripts"
},
"include": ["scripts", "package.json"]
}

View File

@@ -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',

View File

@@ -1,7 +1,7 @@
{
"name": "@affine/web",
"private": true,
"version": "0.7.0-canary.2",
"version": "0.7.0-canary.8",
"scripts": {
"dev": "next dev",
"build": "next build",
@@ -11,6 +11,7 @@
"dependencies": {
"@affine-test/fixtures": "workspace:*",
"@affine/component": "workspace:*",
"@affine/copilot": "workspace:*",
"@affine/debug": "workspace:*",
"@affine/env": "workspace:*",
"@affine/graphql": "workspace:*",
@@ -18,29 +19,30 @@
"@affine/jotai": "workspace:*",
"@affine/templates": "workspace:*",
"@affine/workspace": "workspace:*",
"@blocksuite/blocks": "0.0.0-20230530061436-d0702cc0-nightly",
"@blocksuite/editor": "0.0.0-20230530061436-d0702cc0-nightly",
"@blocksuite/global": "0.0.0-20230530061436-d0702cc0-nightly",
"@blocksuite/blocks": "0.0.0-20230601122821-16196c35-nightly",
"@blocksuite/editor": "0.0.0-20230601122821-16196c35-nightly",
"@blocksuite/global": "0.0.0-20230601122821-16196c35-nightly",
"@blocksuite/icons": "^2.1.19",
"@blocksuite/lit": "0.0.0-20230530061436-d0702cc0-nightly",
"@blocksuite/store": "0.0.0-20230530061436-d0702cc0-nightly",
"@blocksuite/lit": "0.0.0-20230601122821-16196c35-nightly",
"@blocksuite/store": "0.0.0-20230601122821-16196c35-nightly",
"@dnd-kit/core": "^6.0.8",
"@dnd-kit/sortable": "^7.0.2",
"@emotion/cache": "^11.11.0",
"@emotion/react": "^11.11.0",
"@emotion/server": "^11.11.0",
"@emotion/styled": "^11.11.0",
"@mui/material": "^5.13.2",
"@mui/material": "^5.13.3",
"@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",
"jotai": "^2.1.0",
"jotai-devtools": "^0.5.3",
"lit": "^2.7.4",
"lottie-web": "^5.11.0",
"lottie-web": "^5.12.0",
"next-themes": "^0.2.1",
"react": "18.3.0-canary-16d053d59-20230506",
"react-dom": "18.3.0-canary-16d053d59-20230506",
@@ -61,14 +63,14 @@
"@swc-jotai/react-refresh": "^0.0.8",
"@types/react": "^18.2.6",
"@types/react-dom": "^18.2.4",
"@types/webpack-env": "^1.18.0",
"@types/webpack-env": "^1.18.1",
"@vanilla-extract/css": "^1.11.0",
"@vanilla-extract/next-plugin": "=2.1.2",
"dotenv": "^16.0.3",
"dotenv": "^16.1.1",
"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",

View File

@@ -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
@@ -39,6 +40,5 @@ export const buildFlags = {
process.env.ENABLE_DEBUG_PAGE ?? process.env.NODE_ENV === 'development'
),
changelogUrl:
process.env.CHANGELOG_URL ??
'https://affine.pro/blog/whats-new-affine-0518',
process.env.CHANGELOG_URL ?? 'http://affine.pro/blog/whats-new-affine-0601',
};

View File

@@ -1,13 +1,13 @@
import { Unreachable } from '@affine/env/constant';
import { affineApis } from '@affine/workspace/affine/shared';
import { rootStore } from '@affine/workspace/atom';
import { createAffineProviders } from '@affine/workspace/providers';
import type { AffineLegacyCloudWorkspace } from '@affine/workspace/type';
import { WorkspaceFlavour } from '@affine/workspace/type';
import { createEmptyBlockSuiteWorkspace } from '@affine/workspace/utils';
import { assertExists } from '@blocksuite/store';
import { workspacesAtom } from '../../atoms';
import { createAffineProviders } from '../../blocksuite';
import { affineApis } from '../../shared/apis';
type Query = (typeof QueryKey)[keyof typeof QueryKey];

View File

@@ -14,8 +14,13 @@ import {
setLoginStorage,
SignMethod,
} from '@affine/workspace/affine/login';
import { affineApis, affineAuth } from '@affine/workspace/affine/shared';
import { rootStore, rootWorkspacesMetadataAtom } from '@affine/workspace/atom';
import { createIndexedDBBackgroundProvider } from '@affine/workspace/providers';
import {
createAffineProviders,
createIndexedDBBackgroundProvider,
} from '@affine/workspace/providers';
import { createAffineDownloadProvider } from '@affine/workspace/providers';
import type { AffineLegacyCloudWorkspace } from '@affine/workspace/type';
import {
LoadPriority,
@@ -32,12 +37,9 @@ import { Suspense, useEffect } from 'react';
import { mutate } from 'swr';
import { z } from 'zod';
import { createAffineProviders } from '../../blocksuite';
import { createAffineDownloadProvider } from '../../blocksuite/providers/affine';
import { PageLoading } from '../../components/pure/loading';
import { useAffineRefreshAuthToken } from '../../hooks/affine/use-affine-refresh-auth-token';
import { BlockSuiteWorkspace } from '../../shared';
import { affineApis, affineAuth } from '../../shared/apis';
import { toast } from '../../utils';
import {
BlockSuitePageList,

View File

@@ -1,11 +1,6 @@
import { DebugLogger } from '@affine/debug';
import type { RootWorkspaceMetadata } from '@affine/workspace/atom';
import {
rootCurrentEditorAtom,
rootCurrentPageIdAtom,
rootCurrentWorkspaceIdAtom,
rootWorkspacesMetadataAtom,
} from '@affine/workspace/atom';
import { rootWorkspacesMetadataAtom } from '@affine/workspace/atom';
import { WorkspaceFlavour } from '@affine/workspace/type';
import type { Page } from '@blocksuite/store';
import { atom } from 'jotai';
@@ -17,11 +12,6 @@ import type { CreateWorkspaceMode } from '../components/affine/create-workspace-
const logger = new DebugLogger('web:atoms');
// workspace necessary atoms
/**
* @deprecated Use `rootCurrentWorkspaceIdAtom` directly instead.
*/
export const currentWorkspaceIdAtom = rootCurrentWorkspaceIdAtom;
// todo(himself65): move this to the workspace package
rootWorkspacesMetadataAtom.onMount = setAtom => {
function createFirst(): RootWorkspaceMetadata[] {
@@ -46,7 +36,11 @@ rootWorkspacesMetadataAtom.onMount = setAtom => {
const id = setTimeout(() => {
setAtom(metadata => {
if (abortController.signal.aborted) return metadata;
if (metadata.length === 0) {
if (
metadata.length === 0 &&
localStorage.getItem('is-first-open') === null
) {
localStorage.setItem('is-first-open', 'false');
const newMetadata = createFirst();
logger.info('create first workspace', newMetadata);
return newMetadata;
@@ -77,15 +71,6 @@ rootWorkspacesMetadataAtom.onMount = setAtom => {
};
};
/**
* @deprecated Use `rootCurrentPageIdAtom` directly instead.
*/
export const currentPageIdAtom = rootCurrentPageIdAtom;
/**
* @deprecated Use `rootCurrentEditorAtom` directly instead.
*/
export const currentEditorAtom = rootCurrentEditorAtom;
// modal atoms
export const openWorkspacesModalAtom = atom(false);
export const openCreateWorkspaceModalAtom = atom<CreateWorkspaceMode>(false);
@@ -139,3 +124,6 @@ export const workspaceRecentViresWriteAtom = atom<null, [string, View], View[]>(
return record[id];
}
);
export type PageModeOption = 'all' | 'page' | 'edgeless';
export const pageModeSelectAtom = atom<PageModeOption>('all');

View 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;
});
}
);

View File

@@ -1,12 +1,12 @@
import type { BlockSuiteFeatureFlags } from '@affine/env';
import { config } from '@affine/env';
import { affineApis } from '@affine/workspace/affine/shared';
import type { AffinePublicWorkspace } from '@affine/workspace/type';
import { WorkspaceFlavour } from '@affine/workspace/type';
import { createEmptyBlockSuiteWorkspace } from '@affine/workspace/utils';
import { atom } from 'jotai';
import { BlockSuiteWorkspace } from '../../shared';
import { affineApis } from '../../shared/apis';
function createPublicWorkspace(
workspaceId: string,

View File

@@ -1,29 +0,0 @@
import { config } from '@affine/env';
import {
createIndexedDBDownloadProvider,
createLocalProviders,
} from '@affine/workspace/providers';
import {
createAffineWebSocketProvider,
createBroadCastChannelProvider,
} from '@affine/workspace/providers';
import type { Provider } from '@affine/workspace/type';
import type { BlockSuiteWorkspace } from '../shared';
import { createAffineDownloadProvider } from './providers/affine';
export const createAffineProviders = (
blockSuiteWorkspace: BlockSuiteWorkspace
): Provider[] => {
return (
[
createAffineDownloadProvider(blockSuiteWorkspace),
createAffineWebSocketProvider(blockSuiteWorkspace),
config.enableBroadCastChannelProvider &&
createBroadCastChannelProvider(blockSuiteWorkspace),
createIndexedDBDownloadProvider(blockSuiteWorkspace),
] as any[]
).filter(v => Boolean(v));
};
export { createLocalProviders };

View File

@@ -1,3 +0,0 @@
import { DebugLogger } from '@affine/debug';
export const providerLogger = new DebugLogger('provider');

View File

@@ -0,0 +1,11 @@
import { config, setupGlobal } from '@affine/env/config';
setupGlobal();
if (config.enablePlugin && !environment.isServer) {
import('@affine/copilot');
}
if (!environment.isServer) {
import('@affine/bookmark-block');
}

View File

@@ -1,7 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`ProviderComposer 1`] = `
<DocumentFragment>
test1
</DocumentFragment>
`;

View File

@@ -1,26 +0,0 @@
export const SidebarSwitchIcon = () => {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M11 5.00009V19.0001M6 7.62509H8M6 10.2501H8M6 12.8751H8M6.2 19.0001H17.8C18.9201 19.0001 19.4802 19.0001 19.908 18.8094C20.2843 18.6416 20.5903 18.3739 20.782 18.0446C21 17.6702 21 17.1802 21 16.2001V7.80009C21 6.82 21 6.32995 20.782 5.95561C20.5903 5.62632 20.2843 5.35861 19.908 5.19083C19.4802 5.00009 18.9201 5.00009 17.8 5.00009H6.2C5.0799 5.00009 4.51984 5.00009 4.09202 5.19083C3.71569 5.35861 3.40973 5.62632 3.21799 5.95561C3 6.32995 3 6.82 3 7.80009V16.2001C3 17.1802 3 17.6702 3.21799 18.0446C3.40973 18.3739 3.71569 18.6416 4.09202 18.8094C4.51984 19.0001 5.07989 19.0001 6.2 19.0001Z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M11 5.00009V19.0001M6 7.62509H8M6 10.2501H8M6 12.8751H8M6.2 19.0001H17.8C18.9201 19.0001 19.4802 19.0001 19.908 18.8094C20.2843 18.6416 20.5903 18.3739 20.782 18.0446C21 17.6702 21 17.1802 21 16.2001V7.80009C21 6.82 21 6.32995 20.782 5.95561C20.5903 5.62632 20.2843 5.35861 19.908 5.19083C19.4802 5.00009 18.9201 5.00009 17.8 5.00009H6.2C5.0799 5.00009 4.51984 5.00009 4.09202 5.19083C3.71569 5.35861 3.40973 5.62632 3.21799 5.95561C3 6.32995 3 6.82 3 7.80009V16.2001C3 17.1802 3 17.6702 3.21799 18.0446C3.40973 18.3739 3.71569 18.6416 4.09202 18.8094C4.51984 19.0001 5.07989 19.0001 6.2 19.0001Z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
};

View File

@@ -1,65 +0,0 @@
import { Tooltip } from '@affine/component';
import { appSidebarOpenAtom } from '@affine/component/app-sidebar';
import { getEnvironment } from '@affine/env';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { useAtom } from 'jotai';
import { useCallback, useEffect, useState } from 'react';
import { SidebarSwitchIcon } from './icons';
import { StyledSidebarSwitch } from './style';
type SidebarSwitchProps = {
visible?: boolean;
tooltipContent?: string;
};
// fixme: the following code is not correct, SSR will fail because hydrate will not match the client side render
// in `StyledSidebarSwitch` a component
export const SidebarSwitch = ({
visible = true,
tooltipContent,
...props
}: SidebarSwitchProps) => {
const [open, setOpen] = useAtom(appSidebarOpenAtom);
const [tooltipVisible, setTooltipVisible] = useState(false);
const t = useAFFiNEI18N();
const checkIsMac = () => {
const env = getEnvironment();
return env.isBrowser && env.isMacOs;
};
const [isMac, setIsMac] = useState(false);
const collapseKeyboardShortcuts = isMac ? ' ⌘+/' : ' Ctrl+/';
useEffect(() => {
setIsMac(checkIsMac());
}, []);
tooltipContent =
tooltipContent || (open ? t['Collapse sidebar']() : t['Expand sidebar']());
return (
<Tooltip
content={tooltipContent + ' ' + collapseKeyboardShortcuts}
placement="right"
zIndex={1000}
visible={tooltipVisible}
>
<StyledSidebarSwitch
{...props}
visible={visible}
disabled={!visible}
onClick={useCallback(() => {
setOpen(open => !open);
setTooltipVisible(false);
}, [setOpen])}
onMouseEnter={useCallback(() => {
setTooltipVisible(true);
}, [])}
onMouseLeave={useCallback(() => {
setTooltipVisible(false);
}, [])}
>
<SidebarSwitchIcon />
</StyledSidebarSwitch>
</Tooltip>
);
};

View File

@@ -1,13 +0,0 @@
import { IconButton, styled } from '@affine/component';
export const StyledSidebarSwitch = styled(IconButton, {
shouldForwardProp(propName: PropertyKey) {
return propName !== 'visible';
},
})<{ visible: boolean }>(({ visible }) => {
return {
opacity: visible ? 1 : 0,
WebkitAppRegion: visible ? 'no-drag' : 'drag',
transition: 'all 0.2s ease-in-out',
};
});

View File

@@ -1,7 +1,6 @@
import { Button, toast } from '@affine/component';
import { WorkspaceAvatar } from '@affine/component/workspace-avatar';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { rootWorkspacesMetadataAtom } from '@affine/workspace/atom';
import {
ArrowRightSmallIcon,
DeleteIcon,
@@ -12,7 +11,6 @@ import {
import { useBlockSuiteWorkspaceAvatarUrl } from '@toeverything/hooks/use-block-suite-workspace-avatar-url';
import { useBlockSuiteWorkspaceName } from '@toeverything/hooks/use-block-suite-workspace-name';
import clsx from 'clsx';
import { useAtomValue } from 'jotai';
import type React from 'react';
import { useEffect, useState } from 'react';
@@ -88,7 +86,6 @@ export const GeneralPanel: React.FC<PanelProps> = ({
workspace.blockSuiteWorkspace
);
const isLastWorkspace = useAtomValue(rootWorkspacesMetadataAtom).length === 1;
return (
<>
<div data-testid="avatar-row" className={style.row}>
@@ -225,14 +222,6 @@ export const GeneralPanel: React.FC<PanelProps> = ({
<div className={style.settingItemLabelHint}>
{t['Delete Workspace Label Hint']()}
</div>
{isOwner && isLastWorkspace && (
<div
className={style.settingsCannotDelete}
data-testid="warn-cannot-delete-last-workspace"
>
{t['com.affine.workspace.cannot-delete']()}
</div>
)}
</div>
<div className={style.col}></div>
@@ -243,7 +232,6 @@ export const GeneralPanel: React.FC<PanelProps> = ({
type="warning"
data-testid="delete-workspace-button"
size="middle"
disabled={isLastWorkspace}
icon={<DeleteIcon />}
onClick={() => {
setShowDelete(true);

View File

@@ -1,21 +1,21 @@
import { Empty } from '@affine/component';
import type {
ListData,
TrashListData,
View,
} from '@affine/component/page-list';
import type { ListData, TrashListData } from '@affine/component/page-list';
import {
filterByView,
filterByFilterList,
PageList,
PageListTrashView,
} from '@affine/component/page-list';
import type { View } from '@affine/component/page-list/filter/shared-types';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { EdgelessIcon, PageIcon } from '@blocksuite/icons';
import type { PageMeta } from '@blocksuite/store';
import { useBlockSuitePageMeta } from '@toeverything/hooks/use-block-suite-page-meta';
import { getPagePreviewText } from '@toeverything/hooks/use-block-suite-page-preview';
import { useAtom } from 'jotai';
import type React from 'react';
import { useMemo } from 'react';
import { pageModeSelectAtom } from '../../../atoms';
import { useBlockSuiteMetaHelper } from '../../../hooks/affine/use-block-suite-meta-helper';
import type { BlockSuiteWorkspace } from '../../../shared';
import { toast } from '../../../utils';
@@ -80,32 +80,46 @@ export const BlockSuitePageList: React.FC<BlockSuitePageListProps> = ({
permanentlyDeletePage,
cancelPublicPage,
} = useBlockSuiteMetaHelper(blockSuiteWorkspace);
const [filterMode] = useAtom(pageModeSelectAtom);
const { createPage, createEdgeless, importFile, isPreferredEdgeless } =
usePageHelper(blockSuiteWorkspace);
const t = useAFFiNEI18N();
const list = useMemo(
() =>
pageMetas.filter(pageMeta => {
if (!filter[listType](pageMeta, pageMetas)) {
return false;
}
if (!view) {
pageMetas
.filter(pageMeta => {
if (filterMode === 'all') {
return true;
}
if (filterMode === 'edgeless') {
return isPreferredEdgeless(pageMeta.id);
}
if (filterMode === 'page') {
return !isPreferredEdgeless(pageMeta.id);
}
console.error('unknown filter mode', pageMeta, filterMode);
return true;
}
return filterByView(view, {
Favorite: !!pageMeta.favorite,
Created: pageMeta.createDate,
Updated: pageMeta.updatedDate,
});
}),
[pageMetas, listType, view]
})
.filter(pageMeta => {
if (!filter[listType](pageMeta, pageMetas)) {
return false;
}
if (!view) {
return true;
}
return filterByFilterList(view.filterList, {
'Is Favourited': !!pageMeta.favorite,
Created: pageMeta.createDate,
Updated: pageMeta.updatedDate ?? pageMeta.createDate,
});
}),
[pageMetas, filterMode, isPreferredEdgeless, listType, view]
);
if (list.length === 0) {
return <PageListEmpty listType={listType} />;
}
if (listType === 'trash') {
const pageList: TrashListData[] = list.map(pageMeta => {
const page = blockSuiteWorkspace.getPage(pageMeta.id);
const preview = page ? getPagePreviewText(page) : undefined;
return {
icon: isPreferredEdgeless(pageMeta.id) ? (
<EdgelessIcon />
@@ -114,6 +128,7 @@ export const BlockSuitePageList: React.FC<BlockSuitePageListProps> = ({
),
pageId: pageMeta.id,
title: pageMeta.title,
preview,
createDate: new Date(pageMeta.createDate),
trashDate: pageMeta.trashDate
? new Date(pageMeta.trashDate)
@@ -132,14 +147,22 @@ export const BlockSuitePageList: React.FC<BlockSuitePageListProps> = ({
},
};
});
return <PageListTrashView list={pageList} />;
return (
<PageListTrashView
list={pageList}
fallback={<PageListEmpty listType={listType} />}
/>
);
}
const pageList: ListData[] = list.map(pageMeta => {
const page = blockSuiteWorkspace.getPage(pageMeta.id);
const preview = page ? getPagePreviewText(page) : undefined;
return {
icon: isPreferredEdgeless(pageMeta.id) ? <EdgelessIcon /> : <PageIcon />,
pageId: pageMeta.id,
title: pageMeta.title,
preview,
favorite: !!pageMeta.favorite,
isPublicPage: !!pageMeta.isPublic,
createDate: new Date(pageMeta.createDate),
@@ -181,6 +204,7 @@ export const BlockSuitePageList: React.FC<BlockSuitePageListProps> = ({
onImportFile={importFile}
isPublicWorkspace={isPublic}
list={pageList}
fallback={<PageListEmpty listType={listType} />}
/>
);
};

View File

@@ -1,12 +1,11 @@
import { DownloadTips } from '@affine/component/affine-banner';
import { getEnvironment } from '@affine/env';
import { env } from '@affine/env';
import { useAtom } from 'jotai';
import { useCallback } from 'react';
import { guideDownloadClientTipAtom } from '../../../atoms/guide';
export const DownloadClientTip = () => {
const env = getEnvironment();
const [showDownloadClientTips, setShowDownloadClientTips] = useAtom(
guideDownloadClientTipAtom
);

View File

@@ -6,6 +6,7 @@ import {
setLoginStorage,
SignMethod,
} from '@affine/workspace/affine/login';
import { affineAuth } from '@affine/workspace/affine/shared';
import type { LocalWorkspace } from '@affine/workspace/type';
import { WorkspaceFlavour } from '@affine/workspace/type';
import {
@@ -20,7 +21,6 @@ import React, { useEffect, useState } from 'react';
import { useCurrentWorkspace } from '../../../../hooks/current/use-current-workspace';
import { useTransformWorkspace } from '../../../../hooks/use-transform-workspace';
import type { AffineOfficialWorkspace } from '../../../../shared';
import { affineAuth } from '../../../../shared/apis';
import { TransformWorkspaceToAffineModal } from '../../../affine/transform-workspace-to-affine-modal';
const IconWrapper = styled('div')(() => {

View File

@@ -3,22 +3,18 @@ import {
appSidebarFloatingAtom,
appSidebarOpenAtom,
} from '@affine/component/app-sidebar';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { SidebarSwitch } from '@affine/component/app-sidebar/sidebar-header';
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,
Suspense,
useEffect,
useMemo,
useState,
} from 'react';
import { forwardRef, memo, useEffect, useMemo, useState } 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';
@@ -31,12 +27,6 @@ import UserAvatar from './header-right-items/user-avatar';
import * as styles from './styles.css';
import { OSWarningMessage, shouldShowWarning } from './utils';
const SidebarSwitch = lazy(() =>
import('../../affine/sidebar-switch').then(module => ({
default: module.SidebarSwitch,
}))
);
export type BaseHeaderProps<
Workspace extends AffineOfficialWorkspace = AffineOfficialWorkspace
> = {
@@ -46,7 +36,7 @@ export type BaseHeaderProps<
leftSlot?: ReactNode;
};
export const enum HeaderRightItemName {
export enum HeaderRightItemName {
EditorOptionMenu = 'editorOptionMenu',
TrashButtonGroup = 'trashButtonGroup',
SyncUser = 'syncUser',
@@ -149,6 +139,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>
@@ -164,11 +191,10 @@ export const Header = forwardRef<
setShowGuideDownloadClientTip(shouldShowGuideDownloadClientTip);
}, [shouldShowGuideDownloadClientTip]);
const open = useAtomValue(appSidebarOpenAtom);
const t = useAFFiNEI18N();
const appSidebarFloating = useAtomValue(appSidebarFloatingAtom);
const mode = useCurrentMode();
return (
<div
className={styles.headerContainer}
@@ -176,7 +202,6 @@ export const Header = forwardRef<
data-has-warning={showWarning}
data-open={open}
data-sidebar-floating={appSidebarFloating}
{...props}
>
{showGuideDownloadClientTip ? (
<DownloadClientTip />
@@ -196,19 +221,14 @@ export const Header = forwardRef<
data-testid="editor-header-items"
data-is-edgeless={mode === 'edgeless'}
>
<Suspense>
<div style={{ display: 'flex', alignItems: 'center' }}>
<SidebarSwitch
visible={!open}
tooltipContent={t['Expand sidebar']()}
data-testid="sliderBar-arrowButton-expand"
/>
{props.leftSlot}
</div>
</Suspense>
<div style={{ display: 'flex', alignItems: 'center' }}>
{!open && <SidebarSwitch />}
{props.leftSlot}
</div>
{props.children}
<div className={styles.headerRightSide}>
<PluginHeader />
{useMemo(() => {
return Object.entries(HeaderRightItems).map(
([name, { availableWhen, Component }]) => {

View File

@@ -21,6 +21,9 @@ export const headerContainer = style({
display: 'none',
},
},
':has([data-popper-placement])': {
WebkitAppRegion: 'no-drag',
},
} as ComplexStyleRule);
export const header = style({
@@ -146,7 +149,24 @@ export const pageListTitleWrapper = style({
justifyContent: 'center',
alignItems: 'center',
});
export const allPageListTitleWrapper = style({
fontSize: 'var(--affine-font-base)',
color: 'var(--affine-text-primary-color)',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
'::after': {
content: '""',
display: 'block',
width: '100%',
height: '1px',
background: 'var(--affine-border-color)',
position: 'absolute',
bottom: 0,
left: 0,
margin: '0 1px',
},
});
export const pageListTitleIcon = style({
fontSize: '20px',
height: '1em',

View File

@@ -1,4 +1,4 @@
import { getEnvironment } from '@affine/env';
import { env } from '@affine/env';
import { Trans } from '@affine/i18n';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import type React from 'react';
@@ -7,7 +7,6 @@ import { useEffect, useState } from 'react';
const minimumChromeVersion = 102;
export const shouldShowWarning = () => {
const env = getEnvironment();
if (env.isDesktop) {
// even though desktop have compatibility issues, we don't want to show the warning
return false;
@@ -28,7 +27,6 @@ export const OSWarningMessage: React.FC = () => {
const [notChrome, setNotChrome] = useState(false);
const [notGoodVersion, setNotGoodVersion] = useState(false);
useEffect(() => {
const env = getEnvironment();
setNotChrome(env.isBrowser && !env.isChrome);
setNotGoodVersion(
env.isBrowser && env.isChrome && env.chromeVersion < minimumChromeVersion

View File

@@ -0,0 +1,18 @@
import { style } from '@vanilla-extract/css';
export const filterContainerStyle = style({
padding: '12px',
display: 'flex',
position: 'relative',
'::after': {
content: '""',
display: 'block',
width: '100%',
height: '1px',
background: 'var(--affine-border-color)',
position: 'absolute',
bottom: 0,
left: 0,
margin: '0 1px',
},
});

View File

@@ -1,18 +1,32 @@
import './page-detail-editor.css';
import { PageNotFoundError, Unreachable } from '@affine/env/constant';
import { rootCurrentEditorAtom } from '@affine/workspace/atom';
import type { EditorContainer } from '@blocksuite/editor';
import type { Page } from '@blocksuite/store';
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 type { PluginBlockSuiteAdapter } 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 { workspacePreferredModeAtom } from '../atoms';
import { contentLayoutAtom } from '../atoms/layout';
import type { AffineOfficialWorkspace } from '../shared';
import { BlockSuiteEditor as Editor } from './blocksuite/block-suite-editor';
@@ -37,6 +51,11 @@ const EditorWrapper = memo(function EditorWrapper({
onLoad,
isPublic,
}: PageDetailEditorProps) {
const affinePluginsMap = useAtomValue(affinePluginsAtom);
const plugins = useMemo(
() => Object.values(affinePluginsMap),
[affinePluginsMap]
);
const blockSuiteWorkspace = workspace.blockSuiteWorkspace;
const page = useBlockSuiteWorkspacePage(blockSuiteWorkspace, pageId);
if (!page) {
@@ -47,7 +66,7 @@ const EditorWrapper = memo(function EditorWrapper({
);
const currentMode =
useAtomValue(workspacePreferredModeAtom)[pageId] ?? 'page';
const setEditor = useSetAtom(currentEditorAtom);
const setEditor = useSetAtom(rootCurrentEditorAtom);
assertExists(meta);
return (
<Editor
@@ -75,18 +94,40 @@ const EditorWrapper = memo(function EditorWrapper({
updatedDate: Date.now(),
});
localStorage.setItem('last_page_id', page.id);
let dispose = () => {};
if (onLoad) {
return onLoad(page, editor);
dispose = onLoad(page, editor);
}
return () => {};
const uiDecorators = plugins
.map(plugin => plugin.blockSuiteAdapter.uiDecorator)
.filter((ui): ui is PluginBlockSuiteAdapter['uiDecorator'] =>
Boolean(ui)
);
const disposes = uiDecorators.map(ui => ui(editor));
return () => {
disposes.map(fn => fn());
dispose();
};
},
[onLoad, setEditor]
[plugins, onLoad, setEditor]
)}
/>
);
});
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,23 +135,66 @@ 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);
const 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]
);
return (
<>
<Head>
<title>{title}</title>
</Head>
<Mosaic
onChange={useCallback(() => {}, [])}
renderTile={id => {
if (id === 'editor') {
return <EditorWrapper {...props} />;
} else {
// @affine/copilot and other plugins will be added in the future
{layout === 'editor' ? (
<EditorWrapper {...props} />
) : (
<Mosaic
onChange={onChange}
renderTile={id => {
if (id === 'editor') {
return <EditorWrapper {...props} />;
} else {
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}
/>
)}
</>
);
};

View File

@@ -1,5 +1,5 @@
import { MuiFade, Tooltip } from '@affine/component';
import { config, getEnvironment } from '@affine/env';
import { config, env } from '@affine/env';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { CloseIcon, NewIcon, UserGuideIcon } from '@blocksuite/icons';
import { useAtom } from 'jotai';
@@ -15,7 +15,6 @@ import {
StyledIsland,
StyledTriggerWrapper,
} from './style';
const env = getEnvironment();
const ContactModal = lazy(() =>
import('@affine/component/contact-modal').then(({ ContactModal }) => ({
default: ContactModal,

View File

@@ -1,27 +1,27 @@
import { MessageCode, Messages } from '@affine/env/constant';
import { setLoginStorage, SignMethod } from '@affine/workspace/affine/login';
import type React from 'react';
import { affineAuth } from '@affine/workspace/affine/shared';
import type { FC } from 'react';
import { memo, useEffect, useState } from 'react';
import { useAffineLogOut } from '../../../hooks/affine/use-affine-log-out';
import { affineAuth } from '../../../shared/apis';
import { toast } from '../../../utils';
declare global {
interface DocumentEventMap {
'affine-error': CustomEvent<{
code: MessageCode;
code: keyof typeof Messages;
}>;
}
}
export const MessageCenter: React.FC = memo(function MessageCenter() {
export const MessageCenter: FC = memo(function MessageCenter() {
const [popup, setPopup] = useState(false);
const onLogout = useAffineLogOut();
useEffect(() => {
const listener = (
event: CustomEvent<{
code: MessageCode;
code: keyof typeof Messages;
}>
) => {
// fixme: need refactor

View File

@@ -1,5 +1,5 @@
import { Modal, ModalWrapper } from '@affine/component';
import { getEnvironment } from '@affine/env';
import { env } from '@affine/env';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { Command } from 'cmdk';
import type { NextRouter } from 'next/router';
@@ -27,7 +27,6 @@ import {
} from './style';
const isMac = () => {
const env = getEnvironment();
return env.isBrowser && env.isMacOs;
};

View File

@@ -139,7 +139,7 @@ export const Results: FC<ResultsProps> = ({
>
<StyledListItem>
{result.mode === 'edgeless' ? <EdgelessIcon /> : <PageIcon />}
<span>{result.title}</span>
<span>{result.title || UNTITLED_WORKSPACE_NAME}</span>
</StyledListItem>
</Command.Item>
);

View File

@@ -3,7 +3,7 @@ import {
MuiClickAwayListener,
MuiSlide,
} from '@affine/component';
import { getEnvironment } from '@affine/env';
import { env } from '@affine/env';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { useEffect, useState } from 'react';
@@ -27,7 +27,6 @@ type ModalProps = {
};
const checkIsMac = () => {
const env = getEnvironment();
return env.isBrowser && env.isMacOs;
};

View File

@@ -1,8 +1,11 @@
import { RadioButton, RadioButtonGroup } from '@affine/component';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { useSetAtom } from 'jotai';
import { useAtom } from 'jotai';
import type { ReactNode } from 'react';
import type React from 'react';
import { openQuickSearchModalAtom } from '../../../atoms';
import { openQuickSearchModalAtom, pageModeSelectAtom } from '../../../atoms';
import type { HeaderProps } from '../../blocksuite/workspace-header/header';
import { Header } from '../../blocksuite/workspace-header/header';
import * as styles from '../../blocksuite/workspace-header/styles.css';
@@ -34,3 +37,30 @@ export const WorkspaceTitle: React.FC<WorkspaceTitleProps> = ({
</Header>
);
};
export const WorkspaceModeFilterTab = ({ ...props }: WorkspaceTitleProps) => {
const t = useAFFiNEI18N();
const [value, setMode] = useAtom(pageModeSelectAtom);
const handleValueChange = (value: string) => {
if (value !== 'all' && value !== 'page' && value !== 'edgeless') {
throw new Error('Invalid value for page mode option');
}
setMode(value);
};
return (
<Header {...props}>
<div className={styles.allPageListTitleWrapper}>
<RadioButtonGroup
defaultValue={value}
onValueChange={handleValueChange}
>
<RadioButton value="all" style={{ textTransform: 'capitalize' }}>
{t['all']()}
</RadioButton>
<RadioButton value="page">{t['Page']()}</RadioButton>
<RadioButton value="edgeless">{t['Edgeless']()}</RadioButton>
</RadioButtonGroup>
</div>
</Header>
);
};

View File

@@ -9,19 +9,14 @@ import { config } from '@affine/env';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import type { WorkspaceHeaderProps } from '@affine/workspace/type';
import { WorkspaceFlavour, WorkspaceSubPath } from '@affine/workspace/type';
import {
DeleteTemporarilyIcon,
FolderIcon,
SettingsIcon,
ShareIcon,
} from '@blocksuite/icons';
import { SettingsIcon } from '@blocksuite/icons';
import { RESET } from 'jotai/utils';
import type { ReactElement } from 'react';
import React from 'react';
import { NIL } from 'uuid';
import { BlockSuiteEditorHeader } from './blocksuite/workspace-header';
import { WorkspaceTitle } from './pure/workspace-title';
import { filterContainerStyle } from './filter-container.css';
import { WorkspaceModeFilterTab, WorkspaceTitle } from './pure/workspace-title';
export function WorkspaceHeader({
currentWorkspace,
@@ -31,23 +26,21 @@ export function WorkspaceHeader({
const t = useAFFiNEI18N();
if ('subPath' in currentEntry) {
if (currentEntry.subPath === WorkspaceSubPath.ALL) {
const leftSlot = config.enableAllPageFilter && (
<ViewList setting={setting}></ViewList>
);
const filterContainer = config.enableAllPageFilter &&
setting.currentView.filterList.length > 0 && (
<div style={{ padding: 12, display: 'flex' }}>
<div style={{ flex: 1 }}>
<FilterList
value={setting.currentView.filterList}
onChange={filterList => {
setting.setCurrentView(view => ({
...view,
filterList,
}));
}}
/>
</div>
const leftSlot = <ViewList setting={setting}></ViewList>;
const filterContainer = setting.currentView.filterList.length > 0 && (
<div className={filterContainerStyle}>
<div style={{ flex: 1 }}>
<FilterList
value={setting.currentView.filterList}
onChange={filterList => {
setting.setCurrentView(view => ({
...view,
filterList,
}));
}}
/>
</div>
{config.enableAllPageFilter && (
<div>
{setting.currentView.id !== NIL ||
(setting.currentView.id === NIL &&
@@ -62,20 +55,17 @@ export function WorkspaceHeader({
</Button>
)}
</div>
</div>
);
)}
</div>
);
return (
<>
<WorkspaceTitle
<WorkspaceModeFilterTab
workspace={currentWorkspace}
currentPage={null}
isPublic={false}
icon={<FolderIcon />}
leftSlot={leftSlot}
>
{t['All pages']()}
</WorkspaceTitle>
/>
{filterContainer}
</>
);
@@ -92,25 +82,19 @@ export function WorkspaceHeader({
);
} else if (currentEntry.subPath === WorkspaceSubPath.SHARED) {
return (
<WorkspaceTitle
<WorkspaceModeFilterTab
workspace={currentWorkspace}
currentPage={null}
isPublic={false}
icon={<ShareIcon />}
>
{t['Shared Pages']()}
</WorkspaceTitle>
/>
);
} else if (currentEntry.subPath === WorkspaceSubPath.TRASH) {
return (
<WorkspaceTitle
<WorkspaceModeFilterTab
workspace={currentWorkspace}
currentPage={null}
isPublic={false}
icon={<DeleteTemporarilyIcon />}
>
{t['Trash']()}
</WorkspaceTitle>
/>
);
}
} else if ('pageId' in currentEntry) {

View File

@@ -23,11 +23,9 @@ import type React from 'react';
import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest';
import { workspacesAtom } from '../../atoms';
import { rootCurrentWorkspaceAtom } from '../../atoms/root';
import { BlockSuiteWorkspace } from '../../shared';
import {
currentWorkspaceAtom,
useCurrentWorkspace,
} from '../current/use-current-workspace';
import { useCurrentWorkspace } from '../current/use-current-workspace';
import { useAppHelper, useWorkspaces } from '../use-workspaces';
vi.mock(
@@ -169,7 +167,7 @@ describe('useWorkspacesHelper', () => {
wrapper: ProviderWrapper,
});
store.set(rootCurrentWorkspaceIdAtom, workspacesHook.result.current[1].id);
await store.get(currentWorkspaceAtom);
await store.get(rootCurrentWorkspaceAtom);
const currentWorkspaceHook = renderHook(() => useCurrentWorkspace(), {
wrapper: ProviderWrapper,
});
@@ -191,22 +189,20 @@ describe('useWorkspaces', () => {
const { result } = renderHook(() => useAppHelper(), {
wrapper: ProviderWrapper,
});
// next tick
await new Promise(resolve => setTimeout(resolve, 100));
{
const workspaces = await store.get(workspacesAtom);
expect(workspaces.length).toEqual(1);
expect(workspaces.length).toEqual(0);
}
await result.current.createLocalWorkspace('test');
{
const workspaces = await store.get(workspacesAtom);
expect(workspaces.length).toEqual(2);
expect(workspaces.length).toEqual(1);
}
const { result: result2 } = renderHook(() => useWorkspaces(), {
wrapper: ProviderWrapper,
});
expect(result2.current.length).toEqual(2);
const firstWorkspace = result2.current[1];
expect(result2.current.length).toEqual(1);
const firstWorkspace = result2.current[0];
expect(firstWorkspace.flavour).toBe('local');
assert(firstWorkspace.flavour === WorkspaceFlavour.LOCAL);
expect(firstWorkspace.blockSuiteWorkspace.meta.name).toBe('test');

View File

@@ -20,11 +20,9 @@ import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest';
import { LocalAdapter } from '../../adapters/local';
import { workspacesAtom } from '../../atoms';
import { rootCurrentWorkspaceAtom } from '../../atoms/root';
import { BlockSuiteWorkspace } from '../../shared';
import {
currentWorkspaceAtom,
useCurrentWorkspace,
} from '../current/use-current-workspace';
import { useCurrentWorkspace } from '../current/use-current-workspace';
import {
useRecentlyViewed,
useSyncRecentViewsWithRouter,
@@ -98,14 +96,14 @@ describe('useRecentlyViewed', () => {
providers: [],
} satisfies LocalWorkspace);
store.set(rootCurrentWorkspaceIdAtom, blockSuiteWorkspace.id);
const workspace = await store.get(currentWorkspaceAtom);
const workspace = await store.get(rootCurrentWorkspaceAtom);
expect(workspace?.id).toBe(blockSuiteWorkspace.id);
const currentHook = renderHook(() => useCurrentWorkspace(), {
wrapper: ProviderWrapper,
});
expect(currentHook.result.current[0]?.id).toEqual(workspaceId);
store.set(rootCurrentWorkspaceIdAtom, blockSuiteWorkspace.id);
await store.get(currentWorkspaceAtom);
await store.get(rootCurrentWorkspaceAtom);
const recentlyViewedHook = renderHook(() => useRecentlyViewed(), {
wrapper: ProviderWrapper,
});

View File

@@ -6,10 +6,9 @@ import {
setLoginStorage,
storageChangeSlot,
} from '@affine/workspace/affine/login';
import { affineAuth } from '@affine/workspace/affine/shared';
import useSWR from 'swr';
import { affineAuth } from '../../shared/apis';
const logger = new DebugLogger('auth-token');
const revalidate = async () => {

View File

@@ -1,9 +1,9 @@
import type { Member } from '@affine/workspace/affine/api';
import { affineApis } from '@affine/workspace/affine/shared';
import { useCallback } from 'react';
import useSWR from 'swr';
import { QueryKey } from '../../adapters/affine/fetcher';
import { affineApis } from '../../shared/apis';
export function useMembers(workspaceId: string) {
const { data, mutate } = useSWR<Member[]>(

View File

@@ -1,10 +1,10 @@
import { affineApis } from '@affine/workspace/affine/shared';
import { rootStore, rootWorkspacesMetadataAtom } from '@affine/workspace/atom';
import type { AffineLegacyCloudWorkspace } from '@affine/workspace/type';
import { useCallback } from 'react';
import useSWR from 'swr';
import { QueryKey } from '../../adapters/affine/fetcher';
import { affineApis } from '../../shared/apis';
export function useToggleWorkspacePublish(
workspace: AffineLegacyCloudWorkspace

View File

@@ -1,9 +1,10 @@
import { rootCurrentPageIdAtom } from '@affine/workspace/atom';
import { atom, useAtomValue } from 'jotai';
import { currentPageIdAtom, workspacePreferredModeAtom } from '../../atoms';
import { workspacePreferredModeAtom } from '../../atoms';
const currentModeAtom = atom<'page' | 'edgeless'>(get => {
const pageId = get(currentPageIdAtom);
const pageId = get(rootCurrentPageIdAtom);
const record = get(workspacePreferredModeAtom);
if (pageId) return record[pageId] ?? 'page';
else {

View File

@@ -1,22 +1,20 @@
import {
rootCurrentPageIdAtom,
rootCurrentWorkspaceIdAtom,
} from '@affine/workspace/atom';
import { useAtom, useAtomValue } from 'jotai';
import { useCallback } from 'react';
import { currentPageIdAtom, currentWorkspaceIdAtom } from '../../atoms';
import { rootCurrentWorkspaceAtom } from '../../atoms/root';
import type { AllWorkspace } from '../../shared';
/**
* @deprecated use `rootCurrentWorkspaceAtom` instead
*/
export const currentWorkspaceAtom = rootCurrentWorkspaceAtom;
export function useCurrentWorkspace(): [
AllWorkspace,
(id: string | null) => void
] {
const currentWorkspace = useAtomValue(rootCurrentWorkspaceAtom);
const [, setId] = useAtom(currentWorkspaceIdAtom);
const [, setPageId] = useAtom(currentPageIdAtom);
const [, setId] = useAtom(rootCurrentWorkspaceIdAtom);
const [, setPageId] = useAtom(rootCurrentPageIdAtom);
return [
currentWorkspace,
useCallback(

View File

@@ -6,13 +6,13 @@ import {
SignMethod,
storageChangeSlot,
} from '@affine/workspace/affine/login';
import { affineAuth } from '@affine/workspace/affine/shared';
import { rootCurrentWorkspaceIdAtom } from '@affine/workspace/atom';
import type { WorkspaceRegistry } from '@affine/workspace/type';
import { WorkspaceFlavour } from '@affine/workspace/type';
import { useSetAtom } from 'jotai';
import { useCallback } from 'react';
import { affineAuth } from '../../shared/apis';
import { useTransformWorkspace } from '../use-transform-workspace';
export function useOnTransformWorkspace() {

View File

@@ -2,7 +2,7 @@ import type { WorkspaceSubPath } from '@affine/workspace/type';
import type { NextRouter } from 'next/router';
import { useCallback } from 'react';
export const enum RouteLogic {
export enum RouteLogic {
REPLACE = 'replace',
PUSH = 'push',
}

View File

@@ -58,7 +58,10 @@ import { useCurrentWorkspace } from '../hooks/current/use-current-workspace';
import { useRouterHelper } from '../hooks/use-router-helper';
import { useRouterTitle } from '../hooks/use-router-title';
import { useWorkspaces } from '../hooks/use-workspaces';
import { ModalProvider } from '../providers/modal-provider';
import {
AllWorkspaceModals,
CurrentWorkspaceModals,
} from '../providers/modal-provider';
import { pathGenerator, publicPathGenerator } from '../shared';
import { toast } from '../utils';
@@ -178,7 +181,10 @@ export const CurrentWorkspaceContext = ({
return () => {
clearTimeout(id);
};
}, [push, exist]);
}, [push, exist, metadata.length]);
if (metadata.length === 0) {
return <WorkspaceFallback key="no-workspace" />;
}
if (!router.isReady) {
return <WorkspaceFallback key="router-is-loading" />;
}
@@ -266,9 +272,10 @@ export const WorkspaceLayout: FC<PropsWithChildren> =
{/* load all workspaces is costly, do not block the whole UI */}
<Suspense fallback={null}>
<AllWorkspaceContext>
<AllWorkspaceModals />
<CurrentWorkspaceContext>
{/* fixme(himself65): don't re-render whole modals */}
<ModalProvider key={currentWorkspaceId} />
<CurrentWorkspaceModals key={currentWorkspaceId} />
</CurrentWorkspaceContext>
</AllWorkspaceContext>
</Suspense>
@@ -375,7 +382,7 @@ export const WorkspaceLayoutInner: FC<PropsWithChildren> = ({ children }) => {
// Otherwise clicks would be intercepted
useSensor(MouseSensor, {
activationConstraint: {
delay: 10,
delay: 500,
tolerance: 10,
},
})

View File

@@ -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 '../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>

View File

@@ -1,3 +1,4 @@
import { WorkspaceFallback } from '@affine/component/workspace';
import { DebugLogger } from '@affine/debug';
import { WorkspaceSubPath } from '@affine/workspace/type';
import type { NextPage } from 'next';
@@ -7,6 +8,8 @@ import { Suspense, useEffect } from 'react';
import { PageLoading } from '../components/pure/loading';
import { RouteLogic, useRouterHelper } from '../hooks/use-router-helper';
import { useAppHelper, useWorkspaces } from '../hooks/use-workspaces';
import { AllWorkspaceContext } from '../layouts/workspace-layout';
import { AllWorkspaceModals } from '../providers/modal-provider';
const logger = new DebugLogger('index-page');
@@ -62,7 +65,13 @@ const IndexPageInner = () => {
}
}, [helper, jumpToPage, jumpToSubPath, router, workspaces]);
return <PageLoading key="IndexPageInfinitePageLoading" />;
return (
<Suspense fallback={<WorkspaceFallback />}>
<AllWorkspaceContext>
<AllWorkspaceModals />
</AllWorkspaceContext>
</Suspense>
);
};
const IndexPage: NextPage = () => {

View 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>
);
}

View File

@@ -1,6 +1,7 @@
import { Breadcrumbs, IconButton, ListSkeleton } from '@affine/component';
import { StyledTableContainer } from '@affine/component/page-list';
import { QueryParamError } from '@affine/env/constant';
import { rootCurrentWorkspaceIdAtom } from '@affine/workspace/atom';
import { SearchIcon } from '@blocksuite/icons';
import { useBlockSuiteWorkspaceAvatarUrl } from '@toeverything/hooks/use-block-suite-workspace-avatar-url';
import { useBlockSuiteWorkspaceName } from '@toeverything/hooks/use-block-suite-workspace-name';
@@ -9,7 +10,7 @@ import { useRouter } from 'next/router';
import type React from 'react';
import { lazy, Suspense, useCallback, useEffect } from 'react';
import { currentWorkspaceIdAtom, openQuickSearchModalAtom } from '../../atoms';
import { openQuickSearchModalAtom } from '../../atoms';
import {
publicWorkspaceAtom,
publicWorkspaceIdAtom,
@@ -96,7 +97,7 @@ const ListPage: NextPageWithLayout = () => {
const workspaceId = router.query.workspaceId;
const setWorkspaceId = useSetAtom(publicWorkspaceIdAtom);
// todo: remove this atom usage here
const setCurrentWorkspaceId = useSetAtom(currentWorkspaceIdAtom);
const setCurrentWorkspaceId = useSetAtom(rootCurrentWorkspaceIdAtom);
useEffect(() => {
if (!router.isReady) {
return;

View File

@@ -1,5 +1,4 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { rootWorkspacesMetadataAtom } from '@affine/workspace/atom';
import type { SettingPanel } from '@affine/workspace/type';
import {
settingPanel,
@@ -7,7 +6,7 @@ import {
WorkspaceSubPath,
} from '@affine/workspace/type';
import { assertExists } from '@blocksuite/store';
import { useAtom, useAtomValue } from 'jotai';
import { useAtom } from 'jotai';
import { atomWithStorage } from 'jotai/utils';
import Head from 'next/head';
import type { NextRouter } from 'next/router';
@@ -21,7 +20,6 @@ import { useOnTransformWorkspace } from '../../../hooks/root/use-on-transform-wo
import { useAppHelper } from '../../../hooks/use-workspaces';
import { WorkspaceLayout } from '../../../layouts/workspace-layout';
import type { NextPageWithLayout } from '../../../shared';
import { toast } from '../../../utils';
const settingPanelAtom = atomWithStorage<SettingPanel>(
'workspaceId',
@@ -77,7 +75,6 @@ function useTabRouterSync(
const SettingPage: NextPageWithLayout = () => {
const router = useRouter();
const workspaceIds = useAtomValue(rootWorkspacesMetadataAtom);
const [currentWorkspace] = useCurrentWorkspace();
const t = useAFFiNEI18N();
const [currentTab, setCurrentTab] = useAtom(settingPanelAtom);
@@ -102,13 +99,8 @@ const SettingPage: NextPageWithLayout = () => {
const onDeleteWorkspace = useCallback(async () => {
assertExists(currentWorkspace);
const workspaceId = currentWorkspace.id;
if (workspaceIds.length === 1 && workspaceId === workspaceIds[0].id) {
toast(t['You cannot delete the last workspace']());
throw new Error('You cannot delete the last workspace');
} else {
return await helper.deleteWorkspace(workspaceId);
}
}, [currentWorkspace, helper, t, workspaceIds]);
return helper.deleteWorkspace(workspaceId);
}, [currentWorkspace, helper]);
const onTransformWorkspace = useOnTransformWorkspace();
if (!router.isReady) {
return <PageLoading />;

View File

@@ -1,14 +1,16 @@
import { getEnvironment } from '@affine/env';
import { rootWorkspacesMetadataAtom } from '@affine/workspace/atom';
import { env } from '@affine/env';
import {
rootCurrentWorkspaceIdAtom,
rootWorkspacesMetadataAtom,
} from '@affine/workspace/atom';
import { WorkspaceSubPath } from '@affine/workspace/type';
import { arrayMove } from '@dnd-kit/sortable';
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import { useAtom, useSetAtom } from 'jotai';
import { useRouter } from 'next/router';
import type { ReactElement } from 'react';
import { lazy, Suspense, useCallback, useTransition } from 'react';
import {
currentWorkspaceIdAtom,
openCreateWorkspaceModalAtom,
openDisableCloudAlertModalAtom,
openOnboardingModalAtom,
@@ -17,7 +19,6 @@ import {
import { useAffineLogIn } from '../hooks/affine/use-affine-log-in';
import { useAffineLogOut } from '../hooks/affine/use-affine-log-out';
import { useCurrentUser } from '../hooks/current/use-current-user';
import { useCurrentWorkspace } from '../hooks/current/use-current-workspace';
import { useRouterHelper } from '../hooks/use-router-helper';
import { useWorkspaces } from '../hooks/use-workspaces';
@@ -47,14 +48,7 @@ const OnboardingModal = lazy(() =>
}))
);
export function Modals() {
const [openWorkspacesModal, setOpenWorkspacesModal] = useAtom(
openWorkspacesModalAtom
);
const [openCreateWorkspaceModal, setOpenCreateWorkspaceModal] = useAtom(
openCreateWorkspaceModalAtom
);
export function CurrentWorkspaceModals() {
const [openDisableCloudAlertModal, setOpenDisableCloudAlertModal] = useAtom(
openDisableCloudAlertModalAtom
);
@@ -62,15 +56,6 @@ export function Modals() {
openOnboardingModalAtom
);
const router = useRouter();
const { jumpToSubPath } = useRouterHelper(router);
const user = useCurrentUser();
const workspaces = useWorkspaces();
const setWorkspaces = useSetAtom(rootWorkspacesMetadataAtom);
const currentWorkspaceId = useAtomValue(currentWorkspaceIdAtom);
const [, setCurrentWorkspace] = useCurrentWorkspace();
const [transitioning, transition] = useTransition();
const env = getEnvironment();
const onCloseOnboardingModal = useCallback(() => {
setOpenOnboardingModal(false);
}, [setOpenOnboardingModal]);
@@ -92,14 +77,39 @@ export function Modals() {
/>
</Suspense>
)}
</>
);
}
export const AllWorkspaceModals = (): ReactElement => {
const [openWorkspacesModal, setOpenWorkspacesModal] = useAtom(
openWorkspacesModalAtom
);
const [isOpenCreateWorkspaceModal, setOpenCreateWorkspaceModal] = useAtom(
openCreateWorkspaceModalAtom
);
const router = useRouter();
const { jumpToSubPath } = useRouterHelper(router);
const user = useCurrentUser();
const workspaces = useWorkspaces();
const setWorkspaces = useSetAtom(rootWorkspacesMetadataAtom);
const [currentWorkspaceId, setCurrentWorkspaceId] = useAtom(
rootCurrentWorkspaceIdAtom
);
const [transitioning, transition] = useTransition();
return (
<>
<Suspense>
<WorkspaceListModal
disabled={transitioning}
user={user}
workspaces={workspaces}
currentWorkspaceId={currentWorkspaceId}
open={openWorkspacesModal || workspaces.length === 0}
open={
(openWorkspacesModal || workspaces.length === 0) &&
isOpenCreateWorkspaceModal === false
}
onClose={useCallback(() => {
setOpenWorkspacesModal(false);
}, [setOpenWorkspacesModal])}
@@ -118,18 +128,18 @@ export function Modals() {
onClickWorkspace={useCallback(
workspace => {
setOpenWorkspacesModal(false);
setCurrentWorkspace(workspace.id);
setCurrentWorkspaceId(workspace.id);
jumpToSubPath(workspace.id, WorkspaceSubPath.ALL);
},
[jumpToSubPath, setCurrentWorkspace, setOpenWorkspacesModal]
[jumpToSubPath, setCurrentWorkspaceId, setOpenWorkspacesModal]
)}
onClickWorkspaceSetting={useCallback(
workspace => {
setOpenWorkspacesModal(false);
setCurrentWorkspace(workspace.id);
setCurrentWorkspaceId(workspace.id);
jumpToSubPath(workspace.id, WorkspaceSubPath.SETTING);
},
[jumpToSubPath, setCurrentWorkspace, setOpenWorkspacesModal]
[jumpToSubPath, setCurrentWorkspaceId, setOpenWorkspacesModal]
)}
onClickLogin={useAffineLogIn()}
onClickLogout={useAffineLogOut()}
@@ -143,7 +153,7 @@ export function Modals() {
</Suspense>
<Suspense>
<CreateWorkspaceModal
mode={openCreateWorkspaceModal}
mode={isOpenCreateWorkspaceModal}
onClose={useCallback(() => {
setOpenCreateWorkspaceModal(false);
}, [setOpenCreateWorkspaceModal])}
@@ -151,12 +161,12 @@ export function Modals() {
async id => {
setOpenCreateWorkspaceModal(false);
setOpenWorkspacesModal(false);
setCurrentWorkspace(id);
setCurrentWorkspaceId(id);
return jumpToSubPath(id, WorkspaceSubPath.ALL);
},
[
jumpToSubPath,
setCurrentWorkspace,
setCurrentWorkspaceId,
setOpenCreateWorkspaceModal,
setOpenWorkspacesModal,
]
@@ -165,12 +175,4 @@ export function Modals() {
</Suspense>
</>
);
}
export const ModalProvider = (): ReactElement => {
return (
<>
<Modals />
</>
);
};

View File

@@ -2,16 +2,15 @@
* @vitest-environment node
*/
import { loginResponseSchema } from '@affine/workspace/affine/login';
import type { affineApis as API } from '@affine/workspace/affine/shared';
import { beforeAll, describe, expect, it, vi } from 'vitest';
import type { affineApis as API } from '../apis';
let affineApis: typeof API;
beforeAll(async () => {
// @ts-expect-error
globalThis.window = undefined;
affineApis = (await import('../apis')).affineApis;
affineApis = (await import('@affine/workspace/affine/shared')).affineApis;
});
describe('apis', () => {

View File

@@ -4,7 +4,6 @@ import type {
} from '@affine/workspace/type';
import type { AffinePublicWorkspace } from '@affine/workspace/type';
import type { WorkspaceRegistry } from '@affine/workspace/type';
import { WorkspaceSubPath } from '@affine/workspace/type';
import { Workspace as BlockSuiteWorkspace } from '@blocksuite/store';
import type { NextPage } from 'next';
import type { ReactElement, ReactNode } from 'react';
@@ -25,6 +24,13 @@ export type NextPageWithLayout<P = Record<string, unknown>, IP = P> = NextPage<
getLayout?: (page: ReactElement) => ReactNode;
};
export enum WorkspaceSubPath {
ALL = 'all',
SETTING = 'setting',
TRASH = 'trash',
SHARED = 'shared',
}
export const WorkspaceSubPathName = {
[WorkspaceSubPath.ALL]: 'All Pages',
[WorkspaceSubPath.SETTING]: 'Settings',

View File

@@ -1,7 +1,7 @@
import { getEnvironment } from '@affine/env';
import { env } from '@affine/env';
import createCache from '@emotion/cache';
const isBrowser = getEnvironment().isBrowser;
const isBrowser = env.isBrowser;
export default function createEmotionCache() {
let insertionPoint;

View File

@@ -1,8 +1,7 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"target": "ES2020",
"lib": ["dom", "dom.iterable", "esnext"],
"target": "ESNext",
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@@ -18,6 +17,33 @@
"incremental": true,
"experimentalDecorators": true
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "src/types/types.d.ts"],
"exclude": ["node_modules"]
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
"src/types/types.d.ts",
"../../packages/graphql",
"../electron/layers"
],
"exclude": ["node_modules"],
"references": [
{
"path": "../../packages/env"
},
{
"path": "../../packages/debug"
},
{
"path": "../../packages/component"
},
{
"path": "../../packages/i18n"
},
{
"path": "../../packages/jotai"
},
{
"path": "../../packages/hooks"
}
]
}

Some files were not shown because too many files have changed in this diff Show More