Compare commits

...

85 Commits

Author SHA1 Message Date
LongYinan
b48a7814a6 v0.5.4-beta.2 2023-05-18 17:36:37 +08:00
Peng Xiao
8d34de3e9e fix: adjust some styles (#2438) 2023-05-18 17:32:56 +08:00
JimmFly
f435377757 chore: adjust delete description style (#2437) 2023-05-18 17:31:04 +08:00
JimmFly
9a81896563 fix: create workspace card responsive (#2435) 2023-05-18 17:23:52 +08:00
himself65
f2f5128783 v0.5.4-beta.1 2023-05-18 00:08:56 -07:00
ShortCipher5
1363094ce6 chore: update pre-load content (#2432) 2023-05-18 00:08:56 -07:00
Peng Xiao
75c54f0af5 feat: fav page references (#2422)
Co-authored-by: Himself65 <himself65@outlook.com>
2023-05-17 23:23:05 -07:00
himself65
ec142a7189 fix: open non-trash page when open (#2431) 2023-05-17 23:23:04 -07:00
Himself65
6f859967a9 chore: bump blocksuite to 0.0.0-20230518051344-45970a96-nightly (#2430) 2023-05-17 22:32:06 -07:00
ShortCipher5
bcee63175c chore: update pre-loading page (#2429) 2023-05-17 22:31:56 -07:00
JimmFly
f62ca1822d chore: adjust copywriting for onboarding (#2428) 2023-05-17 22:31:52 -07:00
himself65
684bbafbcf fix: version check 2023-05-17 17:36:59 -07:00
Himself65
6cd0053b0c refactor: remove unused code (#2425) 2023-05-17 17:30:37 -07:00
Peng Xiao
ccd3fb4925 fix: configurable changelog url (#2418) 2023-05-17 17:30:37 -07:00
Himself65
d5c3d1b86a fix: sidebar fallback ui position (#2424) 2023-05-17 17:30:37 -07:00
himself65
31e1575b5d chore: bump version (#2423) 2023-05-17 17:30:36 -07:00
Horus
403479996d fix: add workflow to check release version match with package.json (#2420) 2023-05-17 17:28:42 -07:00
Peng Xiao
19f7f591ce chore: bump blocksuite to 0.0.0-20230517102216-36bda4ab-nightly (#2411) 2023-05-17 10:11:22 -07:00
LongYinan
76289838d2 build: missing build native step in nightly build 2023-05-17 09:45:41 -07:00
JimmFly
bb65262217 chore: update translation 2023-05-17 18:24:40 +08:00
LongYinan
877b87aae0 build: fix electron release build process (#2408) 2023-05-17 18:03:10 +08:00
JimmFly
0c5c1a5511 chore: update preloading page (#2409) 2023-05-17 18:03:10 +08:00
Peng Xiao
edda79c448 feat: update button enhancements (#2401) 2023-05-17 17:33:19 +08:00
Peng Xiao
a4111f5550 chore: disable image modal by default (#2400) 2023-05-17 14:26:16 +08:00
Himself65
e099734cc7 fix: infinite reloading (#2405) 2023-05-17 14:26:16 +08:00
Himself65
26f3380c1a fix: hydration error (#2404) 2023-05-17 14:26:16 +08:00
LongYinan
4874adbf3f feat(electron): use affine native (#2329) 2023-05-17 14:26:16 +08:00
Whitewater
943e6c59e3 fix: unexpected undefined class in popup (#2394) 2023-05-17 14:26:15 +08:00
Peng Xiao
c0d6b8c458 fix: some style updates (#2396) 2023-05-17 14:26:15 +08:00
Whitewater
26f5461f9a chore: disable confused storybook backgrounds addon (#2395) 2023-05-17 14:26:15 +08:00
JimmFly
66303e5fd6 fix: text overflows in the header option menu (#2393) 2023-05-17 14:26:15 +08:00
JimmFly
337fe18d4c chore: add responsive styles for workspace card (#2390) 2023-05-17 14:26:15 +08:00
xiaodong zuo
cbcf8140e4 Update jobs.md
Added a job posting for a full-time or internship engineer.
2023-05-17 14:26:15 +08:00
DiamondThree
a998dc808a docs: update jobs.md (#2389) 2023-05-17 14:26:14 +08:00
m1911star
23f51a7ecc fix: fix app updater not working for internal release 2023-05-17 14:16:32 +08:00
Whitewater
ab8cdb4222 feat: supports sort all page (#2356) 2023-05-17 14:16:32 +08:00
JimmFly
5c6655ab0e chore: remove favorite page (#2372) 2023-05-17 14:16:32 +08:00
JimmFly
9c6e687113 chore: remove quick search tips (#2375) 2023-05-17 14:16:32 +08:00
JimmFly
25cf2e9ba0 chore: add animation for tour modal (#2365) 2023-05-17 14:16:32 +08:00
himself65
31bea47545 ci: use samver 2023-05-17 14:16:32 +08:00
Himself65
a34e2eb57d feat(electron): track router history (#2336)
Co-authored-by: Peng Xiao <pengxiao@outlook.com>
2023-05-17 14:16:31 +08:00
himself65
8527c5bfac build: add app bundle id for internal 2023-05-17 14:16:31 +08:00
Peng Xiao
599bf92c08 fix: some style updates (#2348) 2023-05-17 14:16:31 +08:00
Doma
e8f70c6e45 feat(electron): app menu item and hotkey for creating new page (#2267)
Co-authored-by: Peng Xiao <pengxiao@outlook.com>
2023-05-17 14:16:31 +08:00
himself65
c01f2d5eea chore: update blocksuite to 0.0.0-20230514141009-705c0fac-nightly 2023-05-17 14:16:29 +08:00
Ikko Eltociear Ashimine
581726ecc5 fix: typo in AFFiNE-Docs.md (#2355) 2023-05-17 14:15:37 +08:00
Himself65
b15eae11cf chore: update blocksuite to 0.0.0-20230512192655-e61e272b-nightly (#2352) 2023-05-17 14:14:40 +08:00
LongYinan
1aef8862ad chore(server): remove bcrypt to avoid node-gyp usage (#2349) 2023-05-17 14:14:40 +08:00
Himself65
5fcaf7eef9 chore: bump version (#2331) 2023-05-17 14:14:39 +08:00
himself65
fac93b0328 chore: update icons 2023-05-17 14:14:39 +08:00
Himself65
54b8b36618 fix: correct router logic (#2342) 2023-05-17 14:14:39 +08:00
Peng Xiao
683343ad82 feat: new sidebar (app shell) styles (#2303) 2023-05-17 14:14:39 +08:00
himself65
add5deae0f ci: collect test coverage on electron (#2335) 2023-05-17 14:14:39 +08:00
Himself65
ec66b229fe fix: remove useEffect on router sync with atoms (#2241) 2023-05-17 14:14:38 +08:00
Himself65
5008958e84 refactor: rename WorkspacePlugin to WorkspaceAdapter (#2330) 2023-05-17 14:14:38 +08:00
Himself65
5516c215cd fix: delay setAom on rootWorkspacesMetadataAtom (#2271) 2023-05-17 14:14:38 +08:00
Peng Xiao
7c90417b2b fix: updater issue 2023-05-17 14:14:38 +08:00
LongYinan
1922c07c00 fix(electron): close db before move db file 2023-05-17 14:14:38 +08:00
LongYinan
c61c1e10a0 chore(native): license 2023-05-17 14:14:37 +08:00
LongYinan
df93a870af ci: rust build config 2023-05-17 14:14:37 +08:00
LongYinan
6ab51b6d54 feat(native): NotifyEvent types 2023-05-17 14:14:37 +08:00
LongYinan
f25b75c0d8 feat(native): provide FSWatcher 2023-05-17 14:14:37 +08:00
LongYinan
93521f434f refactor(native): rename folder name 2023-05-17 14:14:36 +08:00
Peng Xiao
20fb801ecd fix: should not show open folder if it is not moved (#2299) 2023-05-11 14:44:32 +08:00
Himself65
9902892615 feat(component): improve fallback skeleton (#2323) 2023-05-11 00:36:24 -05:00
JimmFly
f8e184a6c0 fix: delete modal on confirm does not close (#2322) 2023-05-11 00:36:21 -05:00
JimmFly
66e1b5c537 chore: update AFFiNE Cloud prompt (#2321) 2023-05-11 00:36:19 -05:00
himself65
37512bc18f ci: fix set version scripts 2023-05-10 23:00:49 -05:00
himself65
5ba4fb8d7c build: replace version 2023-05-10 22:24:45 -05:00
Himself65
5f28afa5fe chore: bump version (#2310) 2023-05-10 21:45:30 -05:00
Himself65
270c00f021 build(electron): add internal release channel (#2309) 2023-05-10 21:45:27 -05:00
himself65
e69831636a fix(electron): remove unused code 2023-05-10 21:45:22 -05:00
JimmFly
df60392c31 refactor: tour modal (#2297) 2023-05-10 21:45:19 -05:00
himself65
58fa9d1fb8 v0.5.4-canary.31 2023-05-10 21:45:15 -05:00
Himself65
b4981abe4f fix(component): toast too many times when switch page mode (#2296) 2023-05-10 00:54:38 -05:00
Peng Xiao
4c230843ed fix: try to fix updater not working (#2294)
Co-authored-by: Himself65 <himself65@outlook.com>
2023-05-10 00:54:35 -05:00
Himself65
c76bc34c6f feat: enhance root div styles (#2295) 2023-05-10 00:54:32 -05:00
himself65
8bbb9ca304 ci: remove master branch build 2023-05-09 23:23:15 -05:00
himself65
d9dbe64d9b ci: add nightly-build.yml 2023-05-09 23:04:47 -05:00
Himself65
d389e2bc43 feat(component): add skeleton in page detail (#2292) 2023-05-09 23:04:45 -05:00
Peng Xiao
64f4e634e8 fix: theme not being persisted issue (#2283) 2023-05-09 22:05:56 -05:00
Chi Zhang
cf6341d00b docs: update README.md (#2291) 2023-05-09 21:59:14 -05:00
himself65
aad711c115 ci: disable fall-test in desktop-test 2023-05-09 21:59:11 -05:00
himself65
f787d19696 ci: build staging and release branches 2023-05-09 20:27:55 -05:00
Himself65
a0a22f417a chore: bump version (#2287) 2023-05-09 20:20:40 -05:00
286 changed files with 8810 additions and 4607 deletions

View File

@@ -17,7 +17,7 @@
"hooks",
"i18n",
"jotai",
"octobase-node",
"native",
"templates",
"y-indexeddb",
"debug",

49
.github/actions/build-rust/action.yml vendored Normal file
View File

@@ -0,0 +1,49 @@
name: 'AFFiNE Rust build'
description: 'Rust build setup, including cache configuration'
inputs:
target:
description: 'Cargo target'
required: true
runs:
using: 'composite'
steps:
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
toolchain: stable
targets: ${{ inputs.target }}
- name: Cache cargo
uses: actions/cache@v3
with:
path: |
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
.cargo-cache
target/${{ inputs.target }}
key: stable-${{ inputs.target }}-cargo-cache
- name: Build
if: ${{ inputs.target != 'x86_64-unknown-linux-gnu' && inputs.target != 'aarch64-unknown-linux-gnu' }}
shell: bash
run: yarn workspace @affine/native build --target ${{ inputs.target }}
env:
CARGO_BUILD_INCREMENTAL: 'false'
- name: Build
if: ${{ inputs.target == 'x86_64-unknown-linux-gnu' }}
uses: addnab/docker-run-action@v3
with:
image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-debian
options: --user 0:0 -e CARGO_BUILD_INCREMENTAL=false -v ${{ github.workspace }}/.cargo-cache/git/db:/usr/local/cargo/git/db -v ${{ github.workspace }}/.cargo/registry/cache:/usr/local/cargo/registry/cache -v ${{ github.workspace }}/.cargo/registry/index:/usr/local/cargo/registry/index -v ${{ github.workspace }}:/build -w /build
run: yarn workspace @affine/native build --target ${{ inputs.target }}
- name: Build
if: ${{ inputs.target == 'aarch64-unknown-linux-gnu' }}
uses: addnab/docker-run-action@v3
with:
image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-debian-aarch64
options: --user 0:0 -e CARGO_BUILD_INCREMENTAL=false -v ${{ github.workspace }}/.cargo-cache/git/db:/usr/local/cargo/git/db -v ${{ github.workspace }}/.cargo/registry/cache:/usr/local/cargo/registry/cache -v ${{ github.workspace }}/.cargo/registry/index:/usr/local/cargo/registry/index -v ${{ github.workspace }}:/build -w /build
run: yarn workspace @affine/native build --target ${{ inputs.target }}

View File

@@ -4,9 +4,26 @@ on:
push:
branches:
- master
- v[0-9]+.[0-9]+.x-staging
- v[0-9]+.[0-9]+.x
paths-ignore:
- README.md
- .github/**
- '!.github/workflows/build.yml'
pull_request:
branches:
- master
- v[0-9]+.[0-9]+.x-staging
- v[0-9]+.[0-9]+.x
paths-ignore:
- README.md
- .github/**
- '!.github/workflows/build.yml'
env:
DEBUG: napi:*
APP_NAME: affine
MACOSX_DEPLOYMENT_TARGET: '10.13'
jobs:
lint:
@@ -37,23 +54,6 @@ jobs:
path: ./packages/component/storybook-static
if-no-files-found: error
build-electron:
name: Build @affine/electron
runs-on: ubuntu-latest
environment: development
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: ./.github/actions/setup-node
- name: Build Electron
working-directory: apps/electron
run: yarn build-layers
- name: Upload Ubuntu desktop artifact
uses: actions/upload-artifact@v3
with:
name: affine-ubuntu
path: ./apps/electron/dist
build:
name: Build @affine/web
runs-on: ubuntu-latest
@@ -261,7 +261,7 @@ jobs:
- name: Upload test results
if: ${{ failure() }}
uses: actions/upload-artifact@v2
uses: actions/upload-artifact@v3
with:
name: test-results-e2e-${{ matrix.shard }}
path: ./test-results
@@ -269,28 +269,63 @@ jobs:
dekstop-test:
name: Desktop Test
runs-on: ubuntu-latest
runs-on: ${{ matrix.spec.os }}
environment: development
strategy:
fail-fast: false
# all combinations: macos-latest x64, macos-latest arm64, windows-latest x64, ubuntu-latest x64
matrix:
spec:
- { os: macos-latest, platform: macos, arch: x64 }
- { os: macos-latest, platform: macos, arch: arm64 }
- { os: ubuntu-latest, platform: linux, arch: x64 }
- { os: windows-latest, platform: windows, arch: x64 }
needs: [build, build-electron]
- {
os: macos-latest,
platform: macos,
arch: x64,
target: x86_64-apple-darwin,
test: true,
}
- {
os: macos-latest,
platform: macos,
arch: arm64,
target: aarch64-apple-darwin,
test: false,
}
- {
os: ubuntu-latest,
platform: linux,
arch: x64,
target: x86_64-unknown-linux-gnu,
test: true,
}
- {
os: windows-latest,
platform: windows,
arch: x64,
target: x86_64-pc-windows-msvc,
test: true,
}
needs: [build]
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: ./.github/actions/setup-node
with:
playwright-install: true
- name: Download Ubuntu desktop artifact
uses: actions/download-artifact@v3
- name: Build AFFiNE native
uses: ./.github/actions/build-rust
with:
name: affine-ubuntu
path: ./apps/electron/dist
target: ${{ matrix.spec.target }}
- name: Run unit tests
if: ${{ matrix.spec.test }}
shell: bash
run: |
rm -rf apps/electron/node_modules/better-sqlite3/build
yarn --cwd apps/electron/node_modules/better-sqlite3 run install
yarn test:unit
env:
NATIVE_TEST: 'true'
- name: Build layers
run: yarn workspace @affine/electron build-layers
- name: Download static resource artifact
uses: actions/download-artifact@v3
@@ -299,18 +334,47 @@ jobs:
path: ./apps/electron/resources/web-static
- name: Rebuild Electron dependences
run: yarn rebuild:for-electron
working-directory: apps/electron
shell: bash
run: |
rm -rf apps/electron/node_modules/better-sqlite3/build
yarn workspace @affine/electron rebuild:for-electron --arch=${{ matrix.spec.arch }}
- name: Run desktop tests
run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- yarn test
if: ${{ matrix.spec.test && matrix.spec.os == 'ubuntu-latest' }}
run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- yarn workspace @affine/electron test
env:
COVERAGE: true
- name: Run desktop tests
if: ${{ matrix.spec.test && matrix.spec.os != 'ubuntu-latest' }}
run: yarn workspace @affine/electron test
env:
COVERAGE: true
- name: Collect code coverage report
if: ${{ matrix.spec.test }}
run: yarn exec nyc report -t .nyc_output --report-dir .coverage --reporter=lcov
- name: Upload e2e test coverage results
if: ${{ matrix.spec.test }}
uses: codecov/codecov-action@v3
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./.coverage/lcov.info
flags: e2etest-${{ matrix.spec.os }}-${{ matrix.spec.arch }}
name: affine
fail_ci_if_error: true
- name: Run desktop tests
if: ${{ matrix.spec.test && matrix.spec.os != 'ubuntu-latest' }}
run: yarn test
working-directory: apps/electron
- name: Upload test results
if: ${{ failure() }}
uses: actions/upload-artifact@v2
uses: actions/upload-artifact@v3
with:
name: test-results-e2e-${{ matrix.shard }}
name: test-results-e2e-${{ matrix.spec.os }}-${{ matrix.spec.arch }}
path: ./test-results
if-no-files-found: ignore

232
.github/workflows/nightly-build.yml vendored Normal file
View File

@@ -0,0 +1,232 @@
name: Build Canary Desktop App on Staging Branch
on:
push:
branches:
- v[0-9]+.[0-9]+.x-staging
paths-ignore:
- README.md
- .github/**
- '!.github/workflows/nightly-build.yml'
permissions:
actions: write
contents: write
security-events: write
concurrency:
# The concurrency group contains the workflow name and the branch name for
# pull requests or the commit hash for any other events.
group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.head_ref || github.sha }}
cancel-in-progress: true
env:
BUILD_TYPE: internal
jobs:
set-build-version:
runs-on: ubuntu-latest
environment: production
outputs:
version: 0.0.0-${{ steps.version.outputs.version }}
steps:
- uses: actions/checkout@v3
- uses: toeverything/set-build-version@latest
- id: version
run: echo ::set-output name=version::${{ env.BUILD_VERSION }}
before-make:
runs-on: ubuntu-latest
environment: production
needs:
- set-build-version
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: ./.github/actions/setup-node
- name: Replace Version
run: ./scripts/set-version.sh ${{ needs.set-build-version.outputs.version }}
- name: generate-assets
working-directory: apps/electron
run: yarn generate-assets
env:
NEXT_PUBLIC_FIREBASE_API_KEY: ${{ secrets.NEXT_PUBLIC_FIREBASE_API_KEY }}
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN: ${{ secrets.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN }}
NEXT_PUBLIC_FIREBASE_PROJECT_ID: ${{ secrets.NEXT_PUBLIC_FIREBASE_PROJECT_ID }}
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET: ${{ secrets.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET }}
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID: ${{ secrets.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID }}
NEXT_PUBLIC_FIREBASE_APP_ID: ${{ secrets.NEXT_PUBLIC_FIREBASE_APP_ID }}
NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID: ${{ secrets.NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID }}
AFFINE_GOOGLE_CLIENT_ID: ${{ secrets.AFFINE_GOOGLE_CLIENT_ID }}
AFFINE_GOOGLE_CLIENT_SECRET: ${{ secrets.AFFINE_GOOGLE_CLIENT_SECRET }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
NEXT_PUBLIC_SENTRY_DSN: ${{ secrets.NEXT_PUBLIC_SENTRY_DSN }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
API_SERVER_PROFILE: prod
ENABLE_TEST_PROPERTIES: false
ENABLE_IMAGE_PREVIEW_MODAL: false
RELEASE_VERSION: ${{ needs.set-build-version.outputs.version }}
- name: Upload Artifact (web-static)
uses: actions/upload-artifact@v3
with:
name: before-make-web-static
path: apps/electron/resources/web-static
make-distribution:
environment: production
strategy:
# all combinations: macos-latest x64, macos-latest arm64, windows-latest x64, ubuntu-latest x64
matrix:
spec:
- {
os: macos-latest,
platform: darwin,
arch: x64,
target: x86_64-apple-darwin,
}
- {
os: macos-latest,
platform: darwin,
arch: arm64,
target: aarch64-apple-darwin,
}
- {
os: ubuntu-latest,
platform: linux,
arch: x64,
target: x86_64-unknown-linux-gnu,
}
- {
os: windows-latest,
platform: win32,
arch: x64,
target: x86_64-pc-windows-msvc,
}
runs-on: ${{ matrix.spec.os }}
needs:
- before-make
- set-build-version
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
SKIP_GENERATE_ASSETS: 1
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: ./.github/actions/setup-node
- name: Build AFFiNE native
uses: ./.github/actions/build-rust
with:
target: ${{ matrix.spec.target }}
- name: Replace Version
run: ./scripts/set-version.sh ${{ needs.set-build-version.outputs.version }}
- uses: actions/download-artifact@v3
with:
name: before-make-web-static
path: apps/electron/resources/web-static
- name: Rebuild Electron dependences
shell: bash
run: |
rm -rf apps/electron/node_modules/better-sqlite3/build
yarn workspace @affine/electron rebuild:for-electron --arch=${{ matrix.spec.arch }}
- name: Build layers
run: yarn workspace @affine/electron build-layers
- name: Signing By Apple Developer ID
if: ${{ matrix.spec.platform == 'darwin' }}
uses: apple-actions/import-codesign-certs@v2
with:
p12-file-base64: ${{ secrets.CERTIFICATES_P12 }}
p12-password: ${{ secrets.CERTIFICATES_P12_PASSWORD }}
- name: make
run: yarn workspace @affine/electron make --platform=${{ matrix.spec.platform }} --arch=${{ matrix.spec.arch }}
- name: Save artifacts (mac)
if: ${{ matrix.spec.platform == 'darwin' }}
run: |
mkdir -p builds
mv apps/electron/out/*/make/*.dmg ./builds/affine-${{ env.BUILD_TYPE }}-macos-${{ matrix.spec.arch }}.dmg
mv apps/electron/out/*/make/zip/darwin/${{ matrix.spec.arch }}/*.zip ./builds/affine-${{ env.BUILD_TYPE }}-macos-${{ matrix.spec.arch }}.zip
- name: Save artifacts (windows)
if: ${{ matrix.spec.platform == 'win32' }}
run: |
mkdir -p builds
mv apps/electron/out/*/make/zip/win32/x64/AFFiNE*-win32-x64-*.zip ./builds/affine-${{ env.BUILD_TYPE }}-windows-x64.zip
mv apps/electron/out/*/make/squirrel.windows/x64/*.exe ./builds/affine-${{ env.BUILD_TYPE }}-windows-x64.exe
mv apps/electron/out/*/make/squirrel.windows/x64/*.msi ./builds/affine-${{ env.BUILD_TYPE }}-windows-x64.msi
mv apps/electron/out/*/make/squirrel.windows/x64/*.nupkg ./builds/affine-${{ env.BUILD_TYPE }}-windows-x64.nupkg
- name: Save artifacts (linux)
if: ${{ matrix.spec.platform == 'linux' }}
run: |
mkdir -p builds
mv apps/electron/out/*/make/zip/linux/x64/*.zip ./builds/affine-${{ env.BUILD_TYPE }}-linux-x64.zip
- name: Upload Artifact
uses: actions/upload-artifact@v3
with:
name: affine-${{ matrix.spec.platform }}-${{ matrix.spec.arch }}-builds
path: builds
release:
needs:
- make-distribution
- set-build-version
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Download Artifacts (macos-x64)
uses: actions/download-artifact@v3
with:
name: affine-darwin-x64-builds
path: ./
- name: Download Artifacts (macos-arm64)
uses: actions/download-artifact@v3
with:
name: affine-darwin-arm64-builds
path: ./
- name: Download Artifacts (windows-x64)
uses: actions/download-artifact@v3
with:
name: affine-win32-x64-builds
path: ./
- name: Download Artifacts (linux-x64)
uses: actions/download-artifact@v3
with:
name: affine-linux-x64-builds
path: ./
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: 18
- name: Generate Release yml
run: |
cp ./apps/electron/scripts/generate-yml.js .
node generate-yml.js
env:
RELEASE_VERSION: ${{ needs.set-build-version.outputs.version }}
- name: Create Release Draft
uses: softprops/action-gh-release@v1
env:
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
with:
repository: 'toeverything/AFFiNE-Releases'
name: ${{ needs.set-build-version.outputs.version }}
tag_name: ${{ needs.set-build-version.outputs.version }}
prerelease: true
files: |
./VERSION
./*.zip
./*.dmg
./*.exe
./*.nupkg
./RELEASES
./*.AppImage
./*.apk
./*.yml

View File

@@ -36,6 +36,9 @@ concurrency:
env:
BUILD_TYPE: ${{ github.event.inputs.build-type }}
DEBUG: napi:*
APP_NAME: affine
MACOSX_DEPLOYMENT_TARGET: '10.13'
jobs:
before-make:
@@ -46,8 +49,7 @@ jobs:
- name: Setup Node.js
uses: ./.github/actions/setup-node
- name: generate-assets
working-directory: apps/electron
run: yarn generate-assets
run: yarn workspace @affine/electron generate-assets
env:
NEXT_PUBLIC_FIREBASE_API_KEY: ${{ secrets.NEXT_PUBLIC_FIREBASE_API_KEY }}
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN: ${{ secrets.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN }}
@@ -64,6 +66,8 @@ jobs:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
API_SERVER_PROFILE: prod
ENABLE_TEST_PROPERTIES: false
ENABLE_IMAGE_PREVIEW_MODAL: false
RELEASE_VERSION: ${{ github.event.inputs.version }}
- name: Upload Artifact (web-static)
uses: actions/upload-artifact@v3
@@ -71,28 +75,36 @@ jobs:
name: before-make-web-static
path: apps/electron/resources/web-static
- name: Upload Artifact (electron dist)
uses: actions/upload-artifact@v3
with:
name: before-make-electron-dist
path: apps/electron/dist
- name: Upload YML Build Script
uses: actions/upload-artifact@v3
with:
name: release-yml-build-script
path: apps/electron/scripts/generate-yml.js
make-distribution:
environment: ${{ github.ref_name == 'master' && 'production' || 'development' }}
strategy:
# all combinations: macos-latest x64, macos-latest arm64, windows-latest x64, ubuntu-latest x64
matrix:
spec:
- { os: macos-latest, platform: macos, arch: x64 }
- { os: macos-latest, platform: macos, arch: arm64 }
- { os: ubuntu-latest, platform: linux, arch: x64 }
- { os: windows-latest, platform: windows, arch: x64 }
- {
os: macos-latest,
platform: darwin,
arch: x64,
target: x86_64-apple-darwin,
}
- {
os: macos-latest,
platform: darwin,
arch: arm64,
target: aarch64-apple-darwin,
}
- {
os: ubuntu-latest,
platform: linux,
arch: x64,
target: x86_64-unknown-linux-gnu,
}
- {
os: windows-latest,
platform: win32,
arch: x64,
target: x86_64-pc-windows-msvc,
}
runs-on: ${{ matrix.spec.os }}
needs: before-make
env:
@@ -104,34 +116,42 @@ jobs:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: ./.github/actions/setup-node
- name: Build AFFiNE native
uses: ./.github/actions/build-rust
with:
target: ${{ matrix.spec.target }}
- uses: actions/download-artifact@v3
with:
name: before-make-web-static
path: apps/electron/resources/web-static
- uses: actions/download-artifact@v3
with:
name: before-make-electron-dist
path: apps/electron/dist
- name: Rebuild Electron dependences
shell: bash
run: |
rm -rf apps/electron/node_modules/better-sqlite3/build
yarn workspace @affine/electron rebuild:for-electron --arch=${{ matrix.spec.arch }}
- name: Build layers
run: yarn workspace @affine/electron build-layers
- name: Signing By Apple Developer ID
if: ${{ matrix.spec.platform == 'macos' }}
if: ${{ matrix.spec.platform == 'darwin' }}
uses: apple-actions/import-codesign-certs@v2
with:
p12-file-base64: ${{ secrets.CERTIFICATES_P12 }}
p12-password: ${{ secrets.CERTIFICATES_P12_PASSWORD }}
- name: make
run: yarn make-${{ matrix.spec.platform }}-${{ matrix.spec.arch }}
working-directory: apps/electron
run: yarn workspace @affine/electron make --platform=${{ matrix.spec.platform }} --arch=${{ matrix.spec.arch }}
- name: Save artifacts (mac)
if: ${{ matrix.spec.platform == 'macos' }}
if: ${{ matrix.spec.platform == 'darwin' }}
run: |
mkdir -p builds
mv apps/electron/out/*/make/*.dmg ./builds/affine-${{ env.BUILD_TYPE }}-macos-${{ matrix.spec.arch }}.dmg
mv apps/electron/out/*/make/zip/darwin/${{ matrix.spec.arch }}/*.zip ./builds/affine-${{ env.BUILD_TYPE }}-macos-${{ matrix.spec.arch }}.zip
- name: Save artifacts (windows)
if: ${{ matrix.spec.platform == 'windows' }}
if: ${{ matrix.spec.platform == 'win32' }}
run: |
mkdir -p builds
mv apps/electron/out/*/make/zip/win32/x64/AFFiNE*-win32-x64-*.zip ./builds/affine-${{ env.BUILD_TYPE }}-windows-x64.zip
@@ -156,37 +176,36 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Download Artifacts (macos-x64)
uses: actions/download-artifact@v3
with:
name: affine-macos-x64-builds
name: affine-darwin-x64-builds
path: ./
- name: Download Artifacts (macos-arm64)
uses: actions/download-artifact@v3
with:
name: affine-macos-arm64-builds
name: affine-darwin-arm64-builds
path: ./
- name: Download Artifacts (windows-x64)
uses: actions/download-artifact@v3
with:
name: affine-windows-x64-builds
name: affine-win32-x64-builds
path: ./
- name: Download Artifacts (linux-x64)
uses: actions/download-artifact@v3
with:
name: affine-linux-x64-builds
path: ./
- name: Download Artifacts
uses: actions/download-artifact@v3
with:
name: release-yml-build-script
path: ./
- uses: actions/setup-node@v3
with:
node-version: 18
- name: Generate Release yml
run: |
RELEASE_VERSION=${{ github.event.inputs.version }} node generate-yml.js
cp ./apps/electron/scripts/generate-yml.js .
node generate-yml.js
env:
RELEASE_VERSION: ${{ github.event.inputs.version }}
- name: Create Release Draft
uses: softprops/action-gh-release@v1
env:

4
.gitignore vendored
View File

@@ -66,3 +66,7 @@ i18n-generated.ts
# Cache
.eslintcache
next-env.d.ts
# Rust
target
*.node

9
.taplo.toml Normal file
View File

@@ -0,0 +1,9 @@
exclude = ["node_modules/**/*.toml"]
[[rule]]
keys = ["dependencies", "*-dependencies"]
[rule.formatting]
align_entries = true
indent_tables = true
reorder_keys = true

View File

@@ -26,7 +26,6 @@
"[toml]": {
"editor.defaultFormatter": "tamasfe.even-better-toml"
},
"rust-analyzer.linkedProjects": ["packages/octobase-node/Cargo.toml"],
"[typescriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
@@ -38,5 +37,6 @@
"apps/electron/layers/**/*.spec.ts",
"tests/unit/**/*.spec.ts",
"tests/unit/**/*.spec.tsx"
]
],
"deepscan.enable": true
}

800
Cargo.lock generated Normal file
View File

@@ -0,0 +1,800 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
[[package]]
name = "affine_native"
version = "0.0.0"
dependencies = [
"anyhow",
"napi",
"napi-build",
"napi-derive",
"notify",
"once_cell",
"parking_lot",
"serde",
"serde_json",
"tokio",
"uuid",
]
[[package]]
name = "aho-corasick"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67fc08ce920c31afb70f013dcce1bfc3a3195de6a228474e45e1f145b36f8d04"
dependencies = [
"memchr",
]
[[package]]
name = "anyhow"
version = "1.0.71"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c7d0618f0e0b7e8ff11427422b64564d5fb0be1940354bfe2e0529b18a9d9b8"
[[package]]
name = "autocfg"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa"
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24a6904aef64d73cf10ab17ebace7befb918b82164785cb89907993be7f83813"
[[package]]
name = "bytes"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89b2fd2a0dcf38d7971e2194b6b6eebab45ae01067456a7fd93d5547a61b70be"
[[package]]
name = "cfg-if"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "convert_case"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca"
dependencies = [
"unicode-segmentation",
]
[[package]]
name = "crossbeam-channel"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a33c2bf77f2df06183c3aa30d1e96c0695a313d4f9c453cc3762a6db39f99200"
dependencies = [
"cfg-if",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c063cd8cc95f5c377ed0d4b49a4b21f632396ff690e8470c29b3359b346984b"
dependencies = [
"cfg-if",
]
[[package]]
name = "ctor"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd4056f63fce3b82d852c3da92b08ea59959890813a7f4ce9c0ff85b10cf301b"
dependencies = [
"quote",
"syn 2.0.15",
]
[[package]]
name = "filetime"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5cbc844cecaee9d4443931972e1289c8ff485cb4cc2767cb03ca139ed6885153"
dependencies = [
"cfg-if",
"libc",
"redox_syscall",
"windows-sys 0.48.0",
]
[[package]]
name = "fsevent-sys"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2"
dependencies = [
"libc",
]
[[package]]
name = "getrandom"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c85e1d9ab2eadba7e5040d4e09cbd6d072b76a557ad64e797c2cb9d4da21d7e4"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]]
name = "hermit-abi"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee512640fe35acbfb4bb779db6f0d80704c2cacfa2e39b601ef3e3f47d1ae4c7"
dependencies = [
"libc",
]
[[package]]
name = "inotify"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff"
dependencies = [
"bitflags 1.3.2",
"inotify-sys",
"libc",
]
[[package]]
name = "inotify-sys"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb"
dependencies = [
"libc",
]
[[package]]
name = "itoa"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "453ad9f582a441959e5f0d088b02ce04cfe8d51a8eaf077f12ac6d3e94164ca6"
[[package]]
name = "kqueue"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c8fc60ba15bf51257aa9807a48a61013db043fcf3a78cb0d916e8e396dcad98"
dependencies = [
"kqueue-sys",
"libc",
]
[[package]]
name = "kqueue-sys"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8367585489f01bc55dd27404dcf56b95e6da061a256a666ab23be9ba96a2e587"
dependencies = [
"bitflags 1.3.2",
"libc",
]
[[package]]
name = "libc"
version = "0.2.144"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b00cc1c228a6782d0f076e7b232802e0c5689d41bb5df366f2a6b6621cfdfe1"
[[package]]
name = "libloading"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f"
dependencies = [
"cfg-if",
"winapi",
]
[[package]]
name = "lock_api"
version = "0.4.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "435011366fe56583b16cf956f9df0095b405b82d76425bc8981c0e22e60ec4df"
dependencies = [
"autocfg",
"scopeguard",
]
[[package]]
name = "log"
version = "0.4.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "abb12e687cfb44aa40f41fc3978ef76448f9b6038cad6aef4259d3c095a2382e"
dependencies = [
"cfg-if",
]
[[package]]
name = "memchr"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d"
[[package]]
name = "mio"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b9d9a46eff5b4ff64b45a9e316a6d1e0bc719ef429cbec4dc630684212bfdf9"
dependencies = [
"libc",
"log",
"wasi",
"windows-sys 0.45.0",
]
[[package]]
name = "napi"
version = "2.12.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49ac8112fe5998579b22e29903c7b277fc7f91c7860c0236f35792caf8156e18"
dependencies = [
"anyhow",
"bitflags 2.2.1",
"ctor",
"napi-derive",
"napi-sys",
"once_cell",
"serde",
"serde_json",
"tokio",
]
[[package]]
name = "napi-build"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "882a73d9ef23e8dc2ebbffb6a6ae2ef467c0f18ac10711e4cc59c5485d41df0e"
[[package]]
name = "napi-derive"
version = "2.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c47e0f395207c062e680a158f0624ec456c1dfb3c96a8cb888e0401506d50ae9"
dependencies = [
"cfg-if",
"convert_case",
"napi-derive-backend",
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
name = "napi-derive-backend"
version = "1.0.51"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a83afae5b4ba6f98ed6e33a52da343fdeb66474f1162a38cde5a3d46eb054e7"
dependencies = [
"convert_case",
"once_cell",
"proc-macro2",
"quote",
"regex",
"semver",
"syn 1.0.109",
]
[[package]]
name = "napi-sys"
version = "2.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "166b5ef52a3ab5575047a9fe8d4a030cdd0f63c96f071cd6907674453b07bae3"
dependencies = [
"libloading",
]
[[package]]
name = "notify"
version = "5.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "58ea850aa68a06e48fdb069c0ec44d0d64c8dbffa49bf3b6f7f0a901fdea1ba9"
dependencies = [
"bitflags 1.3.2",
"crossbeam-channel",
"filetime",
"fsevent-sys",
"inotify",
"kqueue",
"libc",
"mio",
"serde",
"walkdir",
"windows-sys 0.42.0",
]
[[package]]
name = "num_cpus"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fac9e2da13b5eb447a6ce3d392f23a29d8694bff781bf03a16cd9ac8697593b"
dependencies = [
"hermit-abi",
"libc",
]
[[package]]
name = "once_cell"
version = "1.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7e5500299e16ebb147ae15a00a942af264cf3688f47923b8fc2cd5858f23ad3"
[[package]]
name = "parking_lot"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f"
dependencies = [
"lock_api",
"parking_lot_core",
]
[[package]]
name = "parking_lot_core"
version = "0.9.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9069cbb9f99e3a5083476ccb29ceb1de18b9118cafa53e90c9551235de2b9521"
dependencies = [
"cfg-if",
"libc",
"redox_syscall",
"smallvec",
"windows-sys 0.45.0",
]
[[package]]
name = "pin-project-lite"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0a7ae3ac2f1173085d398531c705756c94a4c56843785df85a60c1a0afac116"
[[package]]
name = "ppv-lite86"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de"
[[package]]
name = "proc-macro2"
version = "1.0.56"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b63bdb0cd06f1f4dedf69b254734f9b45af66e4a031e42a7480257d9898b435"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f4f29d145265ec1c483c7c654450edde0bfe043d3938d6972630663356d9500"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rand"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom",
]
[[package]]
name = "redox_syscall"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a"
dependencies = [
"bitflags 1.3.2",
]
[[package]]
name = "regex"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af83e617f331cc6ae2da5443c602dfa5af81e517212d9d611a5b3ba1777b5370"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5996294f19bd3aae0453a862ad728f60e6600695733dd5df01da90c54363a3c"
[[package]]
name = "ryu"
version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f91339c0467de62360649f8d3e185ca8de4224ff281f66000de5eb2a77a79041"
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]]
name = "scopeguard"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd"
[[package]]
name = "semver"
version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bebd363326d05ec3e2f532ab7660680f3b02130d780c299bca73469d521bc0ed"
[[package]]
name = "serde"
version = "1.0.162"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71b2f6e1ab5c2b98c05f0f35b236b22e8df7ead6ffbf51d7808da7f8817e7ab6"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.162"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2a0814352fd64b58489904a44ea8d90cb1a91dcb6b4f5ebabc32c8318e93cb6"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.15",
]
[[package]]
name = "serde_json"
version = "1.0.96"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "057d394a50403bcac12672b2b18fb387ab6d289d957dab67dd201875391e52f1"
dependencies = [
"itoa",
"ryu",
"serde",
]
[[package]]
name = "signal-hook-registry"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8229b473baa5980ac72ef434c4415e70c4b5e71b423043adb4ba059f89c99a1"
dependencies = [
"libc",
]
[[package]]
name = "smallvec"
version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a507befe795404456341dfab10cef66ead4c041f62b8b11bbb92bffe5d0953e0"
[[package]]
name = "socket2"
version = "0.4.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64a4a911eed85daf18834cfaa86a79b7d266ff93ff5ba14005426219480ed662"
dependencies = [
"libc",
"winapi",
]
[[package]]
name = "syn"
version = "1.0.109"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "2.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a34fcf3e8b60f57e6a14301a2e916d323af98b0ea63c599441eec8558660c822"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "tokio"
version = "1.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3c786bf8134e5a3a166db9b29ab8f48134739014a3eca7bc6bfa95d673b136f"
dependencies = [
"autocfg",
"bytes",
"libc",
"mio",
"num_cpus",
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
"socket2",
"tokio-macros",
"windows-sys 0.48.0",
]
[[package]]
name = "tokio-macros"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "630bdcf245f78637c13ec01ffae6187cca34625e8c63150d424b59e55af2675e"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.15",
]
[[package]]
name = "unicode-ident"
version = "1.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5464a87b239f13a63a501f2701565754bae92d243d4bb7eb12f6d57d2269bf4"
[[package]]
name = "unicode-segmentation"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1dd624098567895118886609431a7c3b8f516e41d30e0643f03d94592a147e36"
[[package]]
name = "uuid"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4dad5567ad0cf5b760e5665964bec1b47dfd077ba8a2544b513f3556d3d239a2"
dependencies = [
"getrandom",
"rand",
"serde",
]
[[package]]
name = "walkdir"
version = "2.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36df944cda56c7d8d8b7496af378e6b16de9284591917d307c9b4d313c44e698"
dependencies = [
"same-file",
"winapi-util",
]
[[package]]
name = "wasi"
version = "0.11.0+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-util"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178"
dependencies = [
"winapi",
]
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows-sys"
version = "0.42.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a3e1820f08b8513f676f7ab6c1f99ff312fb97b553d30ff4dd86f9f15728aa7"
dependencies = [
"windows_aarch64_gnullvm 0.42.2",
"windows_aarch64_msvc 0.42.2",
"windows_i686_gnu 0.42.2",
"windows_i686_msvc 0.42.2",
"windows_x86_64_gnu 0.42.2",
"windows_x86_64_gnullvm 0.42.2",
"windows_x86_64_msvc 0.42.2",
]
[[package]]
name = "windows-sys"
version = "0.45.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
dependencies = [
"windows-targets 0.42.2",
]
[[package]]
name = "windows-sys"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
dependencies = [
"windows-targets 0.48.0",
]
[[package]]
name = "windows-targets"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
dependencies = [
"windows_aarch64_gnullvm 0.42.2",
"windows_aarch64_msvc 0.42.2",
"windows_i686_gnu 0.42.2",
"windows_i686_msvc 0.42.2",
"windows_x86_64_gnu 0.42.2",
"windows_x86_64_gnullvm 0.42.2",
"windows_x86_64_msvc 0.42.2",
]
[[package]]
name = "windows-targets"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b1eb6f0cd7c80c79759c929114ef071b87354ce476d9d94271031c0497adfd5"
dependencies = [
"windows_aarch64_gnullvm 0.48.0",
"windows_aarch64_msvc 0.48.0",
"windows_i686_gnu 0.48.0",
"windows_i686_msvc 0.48.0",
"windows_x86_64_gnu 0.48.0",
"windows_x86_64_gnullvm 0.48.0",
"windows_x86_64_msvc 0.48.0",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91ae572e1b79dba883e0d315474df7305d12f569b400fcf90581b06062f7e1bc"
[[package]]
name = "windows_aarch64_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
[[package]]
name = "windows_aarch64_msvc"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2ef27e0d7bdfcfc7b868b317c1d32c641a6fe4629c171b8928c7b08d98d7cf3"
[[package]]
name = "windows_i686_gnu"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
[[package]]
name = "windows_i686_gnu"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "622a1962a7db830d6fd0a69683c80a18fda201879f0f447f065a3b7467daa241"
[[package]]
name = "windows_i686_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
[[package]]
name = "windows_i686_msvc"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4542c6e364ce21bf45d69fdd2a8e455fa38d316158cfd43b3ac1c5b1b19f8e00"
[[package]]
name = "windows_x86_64_gnu"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
[[package]]
name = "windows_x86_64_gnu"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca2b8a661f7628cbd23440e50b05d705db3686f894fc9580820623656af974b1"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7896dbc1f41e08872e9d5e8f8baa8fdd2677f29468c4e156210174edc7f7b953"
[[package]]
name = "windows_x86_64_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
[[package]]
name = "windows_x86_64_msvc"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a515f5799fe4961cb532f983ce2b23082366b898e52ffbce459c86f67c8378a"

8
Cargo.toml Normal file
View File

@@ -0,0 +1,8 @@
[workspace]
members = ["./packages/native"]
[profile.release]
lto = true
codegen-units = 1
opt-level = 3
strip = "symbols"

View File

@@ -140,7 +140,7 @@ Thanks a lot to the community for providing such powerful and simple libraries,
We would like to express our gratitude to all the individuals who have already contributed to AFFiNE! If you have any AFFiNE-related project, documentation, tool or template, please feel free to contribute it by submitting a pull request to our curated list on GitHub: [awesome-affine](https://github.com/toeverything/awesome-affine).
<a href="https://github.com/toeverything/affine/graphs/contributors">
<img src="https://user-images.githubusercontent.com/5910926/233382206-312428ca-094a-4579-ae06-213961ed7eab.svg" />
<img src="https://user-images.githubusercontent.com/5910926/237263745-36bb975d-83d6-4a7c-a152-d9ad020e5023.png" />
</a>
## Self-Host

View File

@@ -1,11 +1,16 @@
/* eslint-disable @typescript-eslint/no-var-requires */
const { z } = require('zod');
const {
utils: { fromBuildIdentifier },
} = require('@electron-forge/core');
const path = require('node:path');
const buildType = (process.env.BUILD_TYPE || 'stable').trim().toLowerCase();
const ReleaseTypeSchema = z.enum(['stable', 'beta', 'canary', 'internal']);
const envBuildType = (process.env.BUILD_TYPE || 'canary').trim().toLowerCase();
const buildType = ReleaseTypeSchema.parse(envBuildType);
const stableBuild = buildType === 'stable';
const productName = !stableBuild ? `AFFiNE-${buildType}` : 'AFFiNE';
const icoPath = !stableBuild
@@ -28,6 +33,7 @@ module.exports = {
packagerConfig: {
name: productName,
appBundleId: fromBuildIdentifier({
internal: 'pro.affine.internal',
canary: 'pro.affine.canary',
beta: 'pro.affine.beta',
stable: 'pro.affine.app',

View File

@@ -0,0 +1,142 @@
import { app, Menu } from 'electron';
import { isMacOS } from '../../utils';
import { subjects } from './events';
import { checkForUpdatesAndNotify } from './handlers/updater';
import { revealLogFile } from './logger';
// Unique id for menuitems
const MENUITEM_NEW_PAGE = 'affine:new-page';
export function createApplicationMenu() {
const isMac = isMacOS();
// Electron menu cannot be modified
// You have to copy the complete default menu template event if you want to add a single custom item
// See https://www.electronjs.org/docs/latest/api/menu#examples
const template = [
// { role: 'appMenu' }
...(isMac
? [
{
label: app.name,
submenu: [
{ role: 'about' },
{ type: 'separator' },
{ role: 'services' },
{ type: 'separator' },
{ role: 'hide' },
{ role: 'hideOthers' },
{ role: 'unhide' },
{ type: 'separator' },
{ role: 'quit' },
],
},
]
: []),
// { role: 'fileMenu' }
{
label: 'File',
submenu: [
{
id: MENUITEM_NEW_PAGE,
label: 'New Page',
accelerator: isMac ? 'Cmd+N' : 'Ctrl+N',
click: () => {
subjects.applicationMenu.newPageAction.next();
},
},
{ type: 'separator' },
isMac ? { role: 'close' } : { role: 'quit' },
],
},
// { role: 'editMenu' }
{
label: 'Edit',
submenu: [
{ role: 'undo' },
{ role: 'redo' },
{ type: 'separator' },
{ role: 'cut' },
{ role: 'copy' },
{ role: 'paste' },
...(isMac
? [
{ role: 'pasteAndMatchStyle' },
{ role: 'delete' },
{ role: 'selectAll' },
{ type: 'separator' },
{
label: 'Speech',
submenu: [{ role: 'startSpeaking' }, { role: 'stopSpeaking' }],
},
]
: [{ role: 'delete' }, { type: 'separator' }, { role: 'selectAll' }]),
],
},
// { role: 'viewMenu' }
{
label: 'View',
submenu: [
{ role: 'reload' },
{ role: 'forceReload' },
{ role: 'toggleDevTools' },
{ type: 'separator' },
{ role: 'resetZoom' },
{ role: 'zoomIn' },
{ role: 'zoomOut' },
{ type: 'separator' },
{ role: 'togglefullscreen' },
],
},
// { role: 'windowMenu' }
{
label: 'Window',
submenu: [
{ role: 'minimize' },
{ role: 'zoom' },
...(isMac
? [
{ type: 'separator' },
{ role: 'front' },
{ type: 'separator' },
{ role: 'window' },
]
: [{ role: 'close' }]),
],
},
{
role: 'help',
submenu: [
{
label: 'Learn More',
click: async () => {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { shell } = require('electron');
await shell.openExternal('https://affine.pro/');
},
},
{
label: 'Open logs folder',
click: async () => {
revealLogFile();
},
},
{
label: 'Check for Updates',
click: async () => {
await checkForUpdatesAndNotify(true);
},
},
],
},
];
// @ts-ignore The snippet is copied from Electron official docs.
// It's working as expected. No idea why it contains type errors.
// Just ignore for now.
const menu = Menu.buildFromTemplate(template);
Menu.setApplicationMenu(menu);
return menu;
}

View File

@@ -0,0 +1,22 @@
import { Subject } from 'rxjs';
import type { MainEventListener } from './type';
export const applicationMenuSubjects = {
newPageAction: new Subject<void>(),
};
/**
* Events triggered by application menu
*/
export const applicationMenuEvents = {
/**
* File -> New Page
*/
onNewPageAction: (fn: () => void) => {
const sub = applicationMenuSubjects.newPageAction.subscribe(fn);
return () => {
sub.unsubscribe();
};
},
} satisfies Record<string, MainEventListener>;

View File

@@ -2,25 +2,37 @@ import { Subject } from 'rxjs';
import type { MainEventListener } from './type';
interface DBFilePathMeta {
workspaceId: string;
path: string;
realPath: string;
}
export const dbSubjects = {
// emit workspace ids
dbFileMissing: new Subject<string>(),
// emit workspace ids
dbFileUpdate: new Subject<string>(),
dbFilePathChange: new Subject<DBFilePathMeta>(),
};
export const dbEvents = {
onDbFileMissing: (fn: (workspaceId: string) => void) => {
onDBFileMissing: (fn: (workspaceId: string) => void) => {
const sub = dbSubjects.dbFileMissing.subscribe(fn);
return () => {
sub.unsubscribe();
};
},
onDbFileUpdate: (fn: (workspaceId: string) => void) => {
onDBFileUpdate: (fn: (workspaceId: string) => void) => {
const sub = dbSubjects.dbFileUpdate.subscribe(fn);
return () => {
sub.unsubscribe();
};
},
onDBFilePathChange: (fn: (meta: DBFilePathMeta) => void) => {
const sub = dbSubjects.dbFilePathChange.subscribe(fn);
return () => {
sub.unsubscribe();
};
},
} satisfies Record<string, MainEventListener>;

View File

@@ -1,7 +1,9 @@
export * from './register';
import { applicationMenuSubjects } from './application-menu';
import { dbSubjects } from './db';
export const subjects = {
db: dbSubjects,
applicationMenu: applicationMenuSubjects,
};

View File

@@ -1,12 +1,14 @@
import { app, BrowserWindow } from 'electron';
import { logger } from '../logger';
import { applicationMenuEvents } from './application-menu';
import { dbEvents } from './db';
import { updaterEvents } from './updater';
export const allEvents = {
db: dbEvents,
updater: updaterEvents,
applicationMenu: applicationMenuEvents,
};
function getActiveWindows() {

View File

@@ -1,19 +1,34 @@
import { Subject } from 'rxjs';
import { BehaviorSubject, Subject } from 'rxjs';
import type { MainEventListener } from './type';
interface UpdateMeta {
version: string;
allowAutoUpdate: boolean;
}
export const updaterSubjects = {
// means it is ready for restart and install the new version
clientUpdateReady: new Subject<UpdateMeta>(),
updateAvailable: new Subject<UpdateMeta>(),
updateReady: new Subject<UpdateMeta>(),
downloadProgress: new BehaviorSubject<number>(0),
};
export const updaterEvents = {
onClientUpdateReady: (fn: (versionMeta: UpdateMeta) => void) => {
const sub = updaterSubjects.clientUpdateReady.subscribe(fn);
onUpdateAvailable: (fn: (versionMeta: UpdateMeta) => void) => {
const sub = updaterSubjects.updateAvailable.subscribe(fn);
return () => {
sub.unsubscribe();
};
},
onUpdateReady: (fn: (versionMeta: UpdateMeta) => void) => {
const sub = updaterSubjects.updateReady.subscribe(fn);
return () => {
sub.unsubscribe();
};
},
onDownloadProgress: (fn: (progress: number) => void) => {
const sub = updaterSubjects.downloadProgress.subscribe(fn);
return () => {
sub.unsubscribe();
};

View File

@@ -2,6 +2,8 @@ import assert from 'node:assert';
import path from 'node:path';
import fs from 'fs-extra';
import type { Subscription } from 'rxjs';
import { v4 } from 'uuid';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import * as Y from 'yjs';
@@ -61,6 +63,9 @@ const ipcMain = {
handlers.push(callback);
registeredHandlers.set(key, handlers);
},
setMaxListeners: (_n: number) => {
// noop
},
};
const nativeTheme = {
@@ -96,6 +101,11 @@ const electronModule = {
handlers.push(callback);
registeredHandlers.set(name, handlers);
},
addEventListener: (...args: any[]) => {
// @ts-ignore
electronModule.app.on(...args);
},
removeEventListener: () => {},
},
BrowserWindow: {
getAllWindows: () => {
@@ -113,6 +123,8 @@ vi.doMock('electron', () => {
return electronModule;
});
let connectableSubscription: Subscription;
beforeEach(async () => {
const { registerHandlers } = await import('../register');
registerHandlers();
@@ -120,20 +132,24 @@ beforeEach(async () => {
// should also register events
const { registerEvents } = await import('../../events');
registerEvents();
await fs.mkdirp(SESSION_DATA_PATH);
const { database$ } = await import('../db/ensure-db');
connectableSubscription = database$.connect();
});
afterEach(async () => {
const { cleanupSQLiteDBs } = await import('../db/ensure-db');
await cleanupSQLiteDBs();
await fs.remove(SESSION_DATA_PATH);
// reset registered handlers
registeredHandlers.get('before-quit')?.forEach(fn => fn());
connectableSubscription.unsubscribe();
await fs.remove(SESSION_DATA_PATH);
});
describe('ensureSQLiteDB', () => {
test('should create db file on connection if it does not exist', async () => {
const id = 'test-workspace-id';
const id = v4();
const { ensureSQLiteDB } = await import('../db/ensure-db');
const workspaceDB = await ensureSQLiteDB(id);
const file = workspaceDB.path;
@@ -143,73 +159,76 @@ describe('ensureSQLiteDB', () => {
test('when db file is removed', async () => {
// stub webContents.send
const sendStub = vi.fn();
browserWindow.webContents.send = sendStub;
const id = 'test-workspace-id';
const sendSpy = vi.spyOn(browserWindow.webContents, 'send');
const id = v4();
const { ensureSQLiteDB } = await import('../db/ensure-db');
let workspaceDB = await ensureSQLiteDB(id);
const file = workspaceDB.path;
const fileExists = await fs.pathExists(file);
expect(fileExists).toBe(true);
// Can't remove file on Windows, because the sqlite is still holding the file handle
if (process.platform === 'win32') {
return;
}
await fs.remove(file);
// wait for 1000ms for file watcher to detect file removal
// wait for 2000ms for file watcher to detect file removal
await delay(2000);
expect(sendStub).toBeCalledWith('db:onDbFileMissing', id);
expect(sendSpy).toBeCalledWith('db:onDBFileMissing', id);
// ensureSQLiteDB should recreate the db file
workspaceDB = await ensureSQLiteDB(id);
const fileExists2 = await fs.pathExists(file);
expect(fileExists2).toBe(true);
sendSpy.mockRestore();
});
test('when db file is updated', async () => {
// stub webContents.send
const sendStub = vi.fn();
browserWindow.webContents.send = sendStub;
const id = 'test-workspace-id';
const id = v4();
const { ensureSQLiteDB } = await import('../db/ensure-db');
const { dbSubjects } = await import('../../events/db');
const workspaceDB = await ensureSQLiteDB(id);
const file = workspaceDB.path;
const fileExists = await fs.pathExists(file);
expect(fileExists).toBe(true);
// wait to make sure
await delay(500);
const dbUpdateSpy = vi.spyOn(dbSubjects.dbFileUpdate, 'next');
await delay(100);
// writes some data to the db file
await fs.appendFile(file, 'random-data', { encoding: 'binary' });
// write again
await fs.appendFile(file, 'random-data', { encoding: 'binary' });
// wait for 200ms for file watcher to detect file change
// wait for 2000ms for file watcher to detect file change
await delay(2000);
expect(sendStub).toBeCalledWith('db:onDbFileUpdate', id);
// should only call once for multiple writes
expect(sendStub).toBeCalledTimes(1);
expect(dbUpdateSpy).toBeCalledWith(id);
dbUpdateSpy.mockRestore();
});
});
describe('workspace handlers', () => {
test('list all workspace ids', async () => {
const ids = ['test-workspace-id', 'test-workspace-id-2'];
const ids = [v4(), v4()];
const { ensureSQLiteDB } = await import('../db/ensure-db');
await Promise.all(ids.map(id => ensureSQLiteDB(id)));
const list = await dispatch('workspace', 'list');
expect(list.map(([id]) => id)).toEqual(ids);
expect(list.map(([id]) => id).sort()).toEqual(ids.sort());
});
test('delete workspace', async () => {
const ids = ['test-workspace-id', 'test-workspace-id-2'];
// @TODO dispatch is hanging on Windows
if (process.platform === 'win32') {
return;
}
const ids = [v4(), v4()];
const { ensureSQLiteDB } = await import('../db/ensure-db');
await Promise.all(ids.map(id => ensureSQLiteDB(id)));
await dispatch('workspace', 'delete', 'test-workspace-id-2');
await dispatch('workspace', 'delete', ids[1]);
const list = await dispatch('workspace', 'list');
expect(list.map(([id]) => id)).toEqual(['test-workspace-id']);
expect(list.map(([id]) => id)).toEqual([ids[0]]);
});
});
@@ -244,7 +263,7 @@ describe('UI handlers', () => {
describe('db handlers', () => {
test('apply doc and get doc updates', async () => {
const workspaceId = 'test-workspace-id';
const workspaceId = v4();
const bin = await dispatch('db', 'getDocAsUpdates', workspaceId);
// ? is this a good test?
expect(bin.every((byte: number) => byte === 0)).toBe(true);
@@ -264,13 +283,13 @@ describe('db handlers', () => {
});
test('get non existent blob', async () => {
const workspaceId = 'test-workspace-id';
const workspaceId = v4();
const bin = await dispatch('db', 'getBlob', workspaceId, 'non-existent-id');
expect(bin).toBeNull();
});
test('list blobs (empty)', async () => {
const workspaceId = 'test-workspace-id';
const workspaceId = v4();
const list = await dispatch('db', 'getPersistedBlobs', workspaceId);
expect(list).toEqual([]);
});
@@ -318,7 +337,7 @@ describe('dialog handlers', () => {
const mockShowItemInFolder = vi.fn();
electronModule.shell.showItemInFolder = mockShowItemInFolder;
const id = 'test-workspace-id';
const id = v4();
const { ensureSQLiteDB } = await import('../db/ensure-db');
const db = await ensureSQLiteDB(id);
@@ -334,13 +353,15 @@ describe('dialog handlers', () => {
electronModule.dialog.showSaveDialog = mockShowSaveDialog;
electronModule.shell.showItemInFolder = mockShowItemInFolder;
const id = 'test-workspace-id';
const id = v4();
const { ensureSQLiteDB } = await import('../db/ensure-db');
await ensureSQLiteDB(id);
await dispatch('dialog', 'saveDBFileAs', id);
expect(mockShowSaveDialog).toBeCalled();
expect(mockShowItemInFolder).not.toBeCalled();
electronModule.dialog = {};
electronModule.shell = {};
});
test('saveDBFileAs', async () => {
@@ -352,7 +373,7 @@ describe('dialog handlers', () => {
electronModule.dialog.showSaveDialog = mockShowSaveDialog;
electronModule.shell.showItemInFolder = mockShowItemInFolder;
const id = 'test-workspace-id';
const id = v4();
const { ensureSQLiteDB } = await import('../db/ensure-db');
await ensureSQLiteDB(id);
@@ -403,11 +424,13 @@ describe('dialog handlers', () => {
const res = await dispatch('dialog', 'loadDBFile');
expect(mockShowOpenDialog).toBeCalled();
expect(res.error).toBe('DB_FILE_INVALID');
electronModule.dialog = {};
});
test('loadDBFile', async () => {
// we use ensureSQLiteDB to create a valid db file
const id = 'test-workspace-id';
const id = v4();
const { ensureSQLiteDB } = await import('../db/ensure-db');
const db = await ensureSQLiteDB(id);
@@ -417,6 +440,11 @@ describe('dialog handlers', () => {
await fs.ensureDir(basePath);
await fs.copyFile(db.path, originDBFilePath);
// on Windows, we skip this test because we can't delete the db file
if (process.platform === 'win32') {
return;
}
// remove db
await fs.remove(db.path);
@@ -440,19 +468,19 @@ describe('dialog handlers', () => {
});
test('moveDBFile', async () => {
const newPath = path.join(SESSION_DATA_PATH, 'affine-test', 'xxx');
const newPath = path.join(SESSION_DATA_PATH, 'xxx');
const mockShowSaveDialog = vi.fn(() => {
return { filePath: newPath };
}) as any;
electronModule.dialog.showSaveDialog = mockShowSaveDialog;
const id = 'test-workspace-id';
const id = v4();
const { ensureSQLiteDB } = await import('../db/ensure-db');
await ensureSQLiteDB(id);
const res = await dispatch('dialog', 'moveDBFile', id);
expect(mockShowSaveDialog).toBeCalled();
expect(res.filePath).toBe(newPath);
electronModule.dialog = {};
});
test('moveDBFile (skipped)', async () => {
@@ -461,12 +489,13 @@ describe('dialog handlers', () => {
}) as any;
electronModule.dialog.showSaveDialog = mockShowSaveDialog;
const id = 'test-workspace-id';
const id = v4();
const { ensureSQLiteDB } = await import('../db/ensure-db');
await ensureSQLiteDB(id);
const res = await dispatch('dialog', 'moveDBFile', id);
expect(mockShowSaveDialog).toBeCalled();
expect(res.filePath).toBe(undefined);
electronModule.dialog = {};
});
});

View File

@@ -1,89 +1,160 @@
import { watch } from 'chokidar';
import type { NotifyEvent } from '@affine/native/event';
import { createFSWatcher } from '@affine/native/fs-watcher';
import { app } from 'electron';
import {
connectable,
defer,
from,
fromEvent,
identity,
lastValueFrom,
Observable,
ReplaySubject,
Subject,
} from 'rxjs';
import {
debounceTime,
exhaustMap,
filter,
groupBy,
ignoreElements,
mergeMap,
shareReplay,
startWith,
switchMap,
take,
takeUntil,
tap,
} from 'rxjs/operators';
import { appContext } from '../../context';
import { subjects } from '../../events';
import { logger } from '../../logger';
import { debounce, ts } from '../../utils';
import { ts } from '../../utils';
import type { WorkspaceSQLiteDB } from './sqlite';
import { openWorkspaceDatabase } from './sqlite';
const dbMapping = new Map<string, Promise<WorkspaceSQLiteDB>>();
const dbWatchers = new Map<string, () => void>();
const databaseInput$ = new Subject<string>();
export const databaseConnector$ = new ReplaySubject<WorkspaceSQLiteDB>();
const groupedDatabaseInput$ = databaseInput$.pipe(groupBy(identity));
export const database$ = connectable(
groupedDatabaseInput$.pipe(
mergeMap(workspaceDatabase$ =>
workspaceDatabase$.pipe(
// only open the first db with the same workspaceId, and emit it to the downstream
exhaustMap(workspaceId => {
logger.info('[ensureSQLiteDB] open db connection', workspaceId);
return from(openWorkspaceDatabase(appContext, workspaceId)).pipe(
switchMap(db => {
return startWatchingDBFile(db).pipe(
// ignore all events and only emit the db to the downstream
ignoreElements(),
startWith(db)
);
})
);
}),
shareReplay(1)
)
),
tap({
complete: () => {
logger.info('[FSWatcher] close all watchers');
createFSWatcher().close();
},
})
),
{
connector: () => databaseConnector$,
resetOnDisconnect: true,
}
);
export const databaseConnectableSubscription = database$.connect();
// 1. File delete
// 2. File move
// - on Linux, it's `type: { modify: { kind: 'rename', mode: 'from' } }`
// - on Windows, it's `type: { remove: { kind: 'any' } }`
// - on macOS, it's `type: { modify: { kind: 'rename', mode: 'any' } }`
export function isRemoveOrMoveEvent(event: NotifyEvent) {
return (
typeof event.type === 'object' &&
('remove' in event.type ||
('modify' in event.type &&
event.type.modify.kind === 'rename' &&
(event.type.modify.mode === 'from' ||
event.type.modify.mode === 'any')))
);
}
// if we removed the file, we will stop watching it
function startWatchingDBFile(db: WorkspaceSQLiteDB) {
if (dbWatchers.has(db.workspaceId)) {
return dbWatchers.get(db.workspaceId);
}
logger.info('watch db file', db.path);
const watcher = watch(db.path);
const debounceOnChange = debounce(() => {
logger.info(
'db file changed on disk',
db.workspaceId,
ts() - db.lastUpdateTime,
'ms'
const FSWatcher = createFSWatcher();
return new Observable<NotifyEvent>(subscriber => {
logger.info('[FSWatcher] start watching db file', db.workspaceId);
const subscription = FSWatcher.watch(db.path, {
recursive: false,
}).subscribe(
event => {
logger.info('[FSWatcher]', event);
subscriber.next(event);
// remove file or move file, complete the observable and close db
if (isRemoveOrMoveEvent(event)) {
subscriber.complete();
}
},
err => {
subscriber.error(err);
}
);
// reconnect db
db.reconnectDB();
subjects.db.dbFileUpdate.next(db.workspaceId);
}, 1000);
watcher.on('change', () => {
const currentTime = ts();
if (currentTime - db.lastUpdateTime > 100) {
debounceOnChange();
}
});
dbWatchers.set(db.workspaceId, () => {
watcher.close();
});
// todo: there is still a possibility that the file is deleted
// but we didn't get the event soon enough and another event tries to
// access the db
watcher.on('unlink', () => {
logger.info('db file missing', db.workspaceId);
subjects.db.dbFileMissing.next(db.workspaceId);
// cleanup
watcher.close().then(() => {
return () => {
// destroy on unsubscribe
logger.info('[FSWatcher] cleanup db file watcher', db.workspaceId);
db.destroy();
dbWatchers.delete(db.workspaceId);
dbMapping.delete(db.workspaceId);
});
});
subscription.unsubscribe();
};
}).pipe(
debounceTime(1000),
filter(event => !isRemoveOrMoveEvent(event)),
tap({
next: () => {
logger.info(
'[FSWatcher] db file changed on disk',
db.workspaceId,
ts() - db.lastUpdateTime,
'ms'
);
db.reconnectDB();
subjects.db.dbFileUpdate.next(db.workspaceId);
},
complete: () => {
// todo: there is still a possibility that the file is deleted
// but we didn't get the event soon enough and another event tries to
// access the db
logger.info('[FSWatcher] db file missing', db.workspaceId);
subjects.db.dbFileMissing.next(db.workspaceId);
db.destroy();
},
}),
takeUntil(defer(() => fromEvent(app, 'before-quit')))
);
}
export async function ensureSQLiteDB(id: string) {
let workspaceDB = dbMapping.get(id);
if (!workspaceDB) {
logger.info('[ensureSQLiteDB] open db connection', id);
workspaceDB = openWorkspaceDatabase(appContext, id);
dbMapping.set(id, workspaceDB);
startWatchingDBFile(await workspaceDB);
}
return await workspaceDB;
}
export async function disconnectSQLiteDB(id: string) {
const dbp = dbMapping.get(id);
if (dbp) {
const db = await dbp;
logger.info('close db connection', id);
db.destroy();
dbWatchers.get(id)?.();
dbWatchers.delete(id);
dbMapping.delete(id);
}
}
export async function cleanupSQLiteDBs() {
for (const [id] of dbMapping) {
logger.info('close db connection', id);
await disconnectSQLiteDB(id);
}
dbMapping.clear();
dbWatchers.clear();
export function ensureSQLiteDB(id: string) {
const deferValue = lastValueFrom(
database$.pipe(
filter(db => db.workspaceId === id && db.db.open),
take(1),
tap({
error: err => {
logger.error('[ensureSQLiteDB] error', err);
},
})
)
);
databaseInput$.next(id);
return deferValue;
}

View File

@@ -1,3 +1,5 @@
import fs from 'fs-extra';
import { appContext } from '../../context';
import type { NamespaceHandlers } from '../type';
import { ensureSQLiteDB } from './ensure-db';
@@ -30,4 +32,11 @@ export const dbHandlers = {
getDefaultStorageLocation: async () => {
return appContext.appDataPath;
},
getDBFilePath: async (_, workspaceId: string) => {
const workspaceDB = await ensureSQLiteDB(workspaceId);
return {
path: workspaceDB.path,
realPath: await fs.realpath(workspaceDB.path),
};
},
} satisfies NamespaceHandlers;

View File

@@ -6,6 +6,7 @@ import fs from 'fs-extra';
import * as Y from 'yjs';
import type { AppContext } from '../../context';
import { dbSubjects } from '../../events/db';
import { logger } from '../../logger';
import { ts } from '../../utils';
@@ -41,6 +42,7 @@ export class WorkspaceSQLiteDB {
ydoc = new Y.Doc();
firstConnect = false;
lastUpdateTime = ts();
destroyed = false;
constructor(public path: string, public workspaceId: string) {
this.db = this.reconnectDB();
@@ -57,11 +59,23 @@ export class WorkspaceSQLiteDB {
};
reconnectDB = () => {
logger.log('open db', this.workspaceId);
logger.log('[WorkspaceSQLiteDB] open db', this.workspaceId);
if (this.db) {
this.db.close();
}
fs.realpath(this.path)
.then(realPath => {
dbSubjects.dbFilePathChange.next({
workspaceId: this.workspaceId,
path: this.path,
realPath,
});
})
.catch(() => {
// skip error
});
// use cached version?
const db = (this.db = sqlite(this.path));
db.exec(schemas.join(';'));
@@ -211,8 +225,9 @@ export async function openWorkspaceDatabase(
}
export function isValidDBFile(path: string) {
let db: Database | null = null;
try {
const db = sqlite(path);
db = sqlite(path);
// check if db has two tables, one for updates and onefor blobs
const statement = db.prepare(
`SELECT name FROM sqlite_schema WHERE type='table'`
@@ -226,6 +241,7 @@ export function isValidDBFile(path: string) {
return true;
} catch (error) {
logger.error('isValidDBFile', error);
db?.close();
return false;
}
}

View File

@@ -6,7 +6,8 @@ import { nanoid } from 'nanoid';
import { appContext } from '../../context';
import { logger } from '../../logger';
import { ensureSQLiteDB } from '../db/ensure-db';
import { ensureSQLiteDB, isRemoveOrMoveEvent } from '../db/ensure-db';
import type { WorkspaceSQLiteDB } from '../db/sqlite';
import { getWorkspaceDBPath, isValidDBFile } from '../db/sqlite';
import { listWorkspaces } from '../workspace/workspace';
@@ -15,7 +16,7 @@ import { listWorkspaces } from '../workspace/workspace';
export async function revealDBFile(workspaceId: string) {
const workspaceDB = await ensureSQLiteDB(workspaceId);
shell.showItemInFolder(workspaceDB.path);
shell.showItemInFolder(await fs.realpath(workspaceDB.path));
}
// provide a backdoor to set dialog path for testing in playwright
@@ -47,6 +48,7 @@ const ErrorMessages = [
'DB_FILE_ALREADY_LOADED',
'DB_FILE_PATH_INVALID',
'DB_FILE_INVALID',
'FILE_ALREADY_EXISTS',
'UNKNOWN_ERROR',
] as const;
@@ -201,7 +203,7 @@ export async function loadDBFile(): Promise<LoadDBFileResult> {
await fs.ensureDir(path.join(appContext.appDataPath, 'workspaces'));
await fs.symlink(filePath, linkedFilePath);
await fs.symlink(filePath, linkedFilePath, 'file');
logger.info(`loadDBFile, symlink: ${filePath} -> ${linkedFilePath}`);
return { workspaceId };
@@ -231,17 +233,29 @@ export async function moveDBFile(
workspaceId: string,
dbFileLocation?: string
): Promise<MoveDBFileResult> {
let db: WorkspaceSQLiteDB | null = null;
try {
const db = await ensureSQLiteDB(workspaceId);
const { moveFile, FsWatcher } = await import('@affine/native');
db = await ensureSQLiteDB(workspaceId);
// get the real file path of db
const realpath = await fs.realpath(db.path);
const isLink = realpath !== db.path;
const watcher = FsWatcher.watch(realpath, { recursive: false });
const waitForRemove = new Promise<void>(resolve => {
const subscription = watcher.subscribe(event => {
if (isRemoveOrMoveEvent(event)) {
subscription.unsubscribe();
// resolve after FSWatcher in `database$` is fired
setImmediate(() => {
resolve();
});
}
});
});
const newFilePath =
dbFileLocation ||
dbFileLocation ??
(
getFakedResult() ||
getFakedResult() ??
(await dialog.showSaveDialog({
properties: ['showOverwriteConfirmation'],
title: 'Move Workspace Storage',
@@ -262,23 +276,39 @@ export async function moveDBFile(
};
}
db.db.close();
if (await fs.pathExists(newFilePath)) {
return {
error: 'FILE_ALREADY_EXISTS',
};
}
if (isLink) {
// remove the old link to unblock new link
await fs.unlink(db.path);
}
await fs.move(realpath, newFilePath, {
overwrite: true,
});
logger.info(`[moveDBFile] move ${realpath} -> ${newFilePath}`);
await moveFile(realpath, newFilePath);
await fs.ensureSymlink(newFilePath, db.path, 'file');
logger.info(`[moveDBFile] symlink: ${realpath} -> ${newFilePath}`);
// wait for the file move event emits to the FileWatcher in database$ in ensure-db.ts
// so that the db will be destroyed and we can call the `ensureSQLiteDB` in the next step
// or the FileWatcher will continue listen on the `realpath` and emit file change events
// then the database will reload while receiving these events; and the moved database file will be recreated while reloading database
await waitForRemove;
logger.info(`removed`);
await ensureSQLiteDB(workspaceId);
await fs.ensureSymlink(newFilePath, db.path);
logger.info(`openMoveDBFileDialog symlink: ${realpath} -> ${newFilePath}`);
db.reconnectDB();
return {
filePath: newFilePath,
};
} catch (err) {
logger.error('moveDBFile', err);
db?.destroy();
logger.error('[moveDBFile]', err);
return {
error: 'UNKNOWN_ERROR',
};

View File

@@ -18,7 +18,7 @@ export const dialogHandlers = {
saveDBFileAs: async (_, workspaceId: string) => {
return saveDBFileAs(workspaceId);
},
moveDBFile: async (_, workspaceId: string, dbFileLocation?: string) => {
moveDBFile: (_, workspaceId: string, dbFileLocation?: string) => {
return moveDBFile(workspaceId, dbFileLocation);
},
selectDBFileLocation: async () => {

View File

@@ -36,6 +36,8 @@ export const allHandlers = {
} satisfies Record<string, NamespaceHandlers>;
export const registerHandlers = () => {
// TODO: listen to namespace instead of individual event types
ipcMain.setMaxListeners(100);
for (const [namespace, namespaceHandlers] of Object.entries(allHandlers)) {
for (const [key, handler] of Object.entries(namespaceHandlers)) {
const chan = `${namespace}:${key}`;

View File

@@ -1,4 +1,4 @@
import { BrowserWindow, nativeTheme } from 'electron';
import { app, BrowserWindow, nativeTheme } from 'electron';
import { isMacOS } from '../../../../utils';
import type { NamespaceHandlers } from '../type';
@@ -17,6 +17,25 @@ export const uiHandlers = {
});
}
},
handleMinimizeApp: async () => {
const windows = BrowserWindow.getAllWindows();
windows.forEach(w => {
w.minimize();
});
},
handleMaximizeApp: async () => {
const windows = BrowserWindow.getAllWindows();
windows.forEach(w => {
if (w.isMaximized()) {
w.unmaximize();
} else {
w.maximize();
}
});
},
handleCloseApp: async () => {
app.quit();
},
getGoogleOauthCode: async () => {
return getGoogleOauthCode();
},

View File

@@ -1,9 +1,17 @@
import { app } from 'electron';
import type { NamespaceHandlers } from '../type';
import { updateClient } from './updater';
import { checkForUpdatesAndNotify, quitAndInstall } from './updater';
export const updaterHandlers = {
updateClient: async () => {
return updateClient();
currentVersion: async () => {
return app.getVersion();
},
quitAndInstall: async () => {
return quitAndInstall();
},
checkForUpdatesAndNotify: async () => {
return checkForUpdatesAndNotify(true);
},
} satisfies NamespaceHandlers;

View File

@@ -1,69 +1,100 @@
import { app } from 'electron';
import type { AppUpdater } from 'electron-updater';
import { z } from 'zod';
import { isMacOS } from '../../../../utils';
import { updaterSubjects } from '../../events/updater';
import { logger } from '../../logger';
const buildType = (process.env.BUILD_TYPE || 'canary').trim().toLowerCase();
export const ReleaseTypeSchema = z.enum([
'stable',
'beta',
'canary',
'internal',
]);
export const envBuildType = (process.env.BUILD_TYPE || 'canary')
.trim()
.toLowerCase();
export const buildType = ReleaseTypeSchema.parse(envBuildType);
const mode = process.env.NODE_ENV;
const isDev = mode === 'development';
let _autoUpdater: AppUpdater | null = null;
export const updateClient = async () => {
export const quitAndInstall = async () => {
_autoUpdater?.quitAndInstall();
};
let lastCheckTime = 0;
export const checkForUpdatesAndNotify = async (force = true) => {
if (!_autoUpdater) {
return; // ?
}
// check every 30 minutes (1800 seconds) at most
if (force || lastCheckTime + 1000 * 1800 < Date.now()) {
lastCheckTime = Date.now();
return _autoUpdater.checkForUpdatesAndNotify();
}
};
export const registerUpdater = async () => {
// require it will cause some side effects and will break generate-main-exposed-meta,
// so we wrap it in a function
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { autoUpdater } = await import('electron-updater');
const { autoUpdater } = require('electron-updater');
_autoUpdater = autoUpdater;
autoUpdater.autoDownload = false;
autoUpdater.allowPrerelease = buildType !== 'stable';
autoUpdater.autoInstallOnAppQuit = false;
autoUpdater.autoRunAppAfterInstall = true;
autoUpdater.setFeedURL({
channel: buildType,
provider: 'github',
repo: 'AFFiNE',
owner: 'toeverything',
releaseType: buildType === 'stable' ? 'release' : 'prerelease',
});
autoUpdater.autoDownload = false;
autoUpdater.allowPrerelease = buildType !== 'stable';
autoUpdater.autoInstallOnAppQuit = false;
autoUpdater.autoRunAppAfterInstall = true;
autoUpdater.setFeedURL({
channel: buildType,
provider: 'github',
repo: 'AFFiNE',
owner: 'toeverything',
releaseType: buildType === 'stable' ? 'release' : 'prerelease',
});
if (isMacOS()) {
autoUpdater.on('update-available', () => {
autoUpdater.downloadUpdate();
logger.info('Update available, downloading...');
});
autoUpdater.on('download-progress', e => {
logger.info(`Download progress: ${e.percent}`);
});
autoUpdater.on('update-downloaded', e => {
updaterSubjects.clientUpdateReady.next({
version: e.version,
});
logger.info('Update downloaded, ready to install');
});
autoUpdater.on('error', e => {
logger.error('Error while updating client', e);
});
autoUpdater.forceDevUpdateConfig = isDev;
await autoUpdater.checkForUpdatesAndNotify();
if (!_autoUpdater) {
return;
}
// TODO: support auto update on windows and linux
const allowAutoUpdate = isMacOS();
_autoUpdater.autoDownload = false;
_autoUpdater.allowPrerelease = buildType !== 'stable';
_autoUpdater.autoInstallOnAppQuit = false;
_autoUpdater.autoRunAppAfterInstall = true;
_autoUpdater.setFeedURL({
channel: buildType,
provider: 'github',
repo: buildType !== 'internal' ? 'AFFiNE' : 'AFFiNE-Releases',
owner: 'toeverything',
releaseType: buildType === 'stable' ? 'release' : 'prerelease',
});
// register events for checkForUpdatesAndNotify
_autoUpdater.on('update-available', info => {
if (allowAutoUpdate) {
_autoUpdater!.downloadUpdate();
logger.info('Update available, downloading...', info);
}
updaterSubjects.updateAvailable.next({
version: info.version,
allowAutoUpdate,
});
});
_autoUpdater.on('download-progress', e => {
logger.info(`Download progress: ${e.percent}`);
updaterSubjects.downloadProgress.next(e.percent);
});
_autoUpdater.on('update-downloaded', e => {
updaterSubjects.updateReady.next({
version: e.version,
allowAutoUpdate,
});
// I guess we can skip it?
// updaterSubjects.clientDownloadProgress.next(100);
logger.info('Update downloaded, ready to install');
});
_autoUpdater.on('error', e => {
logger.error('Error while updating client', e);
});
_autoUpdater.forceDevUpdateConfig = isDev;
app.on('activate', async () => {
await checkForUpdatesAndNotify(false);
});
};

View File

@@ -2,6 +2,7 @@ import './security-restrictions';
import { app } from 'electron';
import { createApplicationMenu } from './application-menu';
import { registerEvents } from './events';
import { registerHandlers } from './handlers';
import { registerUpdater } from './handlers/updater';
@@ -57,6 +58,7 @@ app
.then(registerHandlers)
.then(registerEvents)
.then(restoreOrCreateWindow)
.then(createApplicationMenu)
.then(registerUpdater)
.catch(e => console.error('Failed create window:', e));
/**

View File

@@ -2,7 +2,7 @@ import { BrowserWindow, nativeTheme } from 'electron';
import electronWindowState from 'electron-window-state';
import { join } from 'path';
import { isMacOS } from '../../utils';
import { isMacOS, isWindows } from '../../utils';
import { logger } from './logger';
const IS_DEV: boolean =
@@ -18,13 +18,17 @@ async function createWindow() {
});
const browserWindow = new BrowserWindow({
titleBarStyle: isMacOS() ? 'hiddenInset' : 'default',
titleBarStyle: isMacOS()
? 'hiddenInset'
: isWindows()
? 'hidden'
: 'default',
trafficLightPosition: { x: 24, y: 18 },
x: mainWindowState.x,
y: mainWindowState.y,
width: mainWindowState.width,
minWidth: 640,
transparent: isMacOS(),
minHeight: 480,
visualEffectState: 'active',
vibrancy: 'under-window',
height: mainWindowState.height,

View File

@@ -1,15 +0,0 @@
{
"compilerOptions": {
"module": "esnext",
"target": "esnext",
"sourceMap": false,
"moduleResolution": "Node",
"skipLibCheck": true,
"strict": true,
"isolatedModules": true,
"allowSyntheticDefaultImports": true,
"types": ["node"]
},
"include": ["src/**/*.ts", "../../types/**/*.d.ts", "index.ts", "../utils.ts"]
}

View File

@@ -1,7 +1,7 @@
/* 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;
apis: typeof import('./src/affine-apis').apis;
events: typeof import('./src/affine-apis').events;
appInfo: typeof import('./src/affine-apis').appInfo;
}

View File

@@ -57,6 +57,10 @@ const events: MainIPCEventMap = (() => {
const {
events: eventsMeta,
}: MainExposedMeta = require('../main/exposed-meta');
// NOTE: ui may try to listen to a lot of the same events, so we increase the limit...
ipcRenderer.setMaxListeners(100);
const all = eventsMeta.map(([namespace, eventNames]) => {
const namespaceEvents = eventNames.map(name => {
const channel = `${namespace}:${name}`;

View File

@@ -1,14 +0,0 @@
{
"compilerOptions": {
"module": "esnext",
"target": "esnext",
"sourceMap": false,
"moduleResolution": "Node",
"skipLibCheck": true,
"strict": true,
"isolatedModules": true,
"types": ["node"]
},
"include": ["src/**/*.ts", "../../types/**/*.d.ts"]
}

View File

@@ -1,3 +1,7 @@
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.5.4-canary.30",
"version": "0.5.4-beta.2",
"author": "affine",
"repository": {
"url": "https://github.com/toeverything/AFFiNE",
@@ -18,10 +18,6 @@
"generate-main-exposed-meta": "zx scripts/generate-main-exposed-meta.mjs",
"package": "electron-forge package",
"make": "electron-forge make",
"make-macos-arm64": "electron-forge make --platform=darwin --arch=arm64",
"make-macos-x64": "electron-forge make --platform=darwin --arch=x64",
"make-windows-x64": "electron-forge make --platform=win32 --arch=x64",
"make-linux-x64": "electron-forge make --platform=linux --arch=x64",
"rebuild:for-unit-test": "yarn rebuild better-sqlite3",
"rebuild:for-electron": "yarn electron-rebuild",
"test": "playwright test"
@@ -32,6 +28,7 @@
"main": "./dist/layers/main/index.js",
"devDependencies": {
"@affine-test/kit": "workspace:*",
"@affine/native": "workspace:*",
"@electron-forge/cli": "^6.1.1",
"@electron-forge/core": "^6.1.1",
"@electron-forge/core-utils": "^6.1.1",
@@ -44,16 +41,18 @@
"@electron/remote": "2.0.9",
"@types/better-sqlite3": "^7.6.4",
"@types/fs-extra": "^11.0.1",
"@types/uuid": "^9.0.1",
"cross-env": "7.0.3",
"electron": "24.2.0",
"electron-log": "^5.0.0-beta.23",
"electron": "24.3.1",
"electron-log": "^5.0.0-beta.24",
"electron-squirrel-startup": "1.0.0",
"electron-window-state": "^5.0.3",
"esbuild": "^0.17.18",
"esbuild": "^0.17.19",
"fs-extra": "^11.1.1",
"playwright": "^1.33.0",
"ts-node": "^10.9.1",
"undici": "^5.22.0",
"undici": "^5.22.1",
"uuid": "^9.0.0",
"zx": "^7.2.2"
},
"dependencies": {

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

View File

@@ -8,6 +8,11 @@ import { config } from './common.mjs';
const NODE_ENV =
process.env.NODE_ENV === 'development' ? 'development' : 'production';
if (process.platform === 'win32') {
$.shell = true;
$.prefix = '';
}
async function buildLayers() {
const common = config();
await esbuild.build(common.preload);

View File

@@ -12,16 +12,6 @@ const DEV_SERVER_URL = process.env.DEV_SERVER_URL;
/** @type 'production' | 'development'' */
const mode = (process.env.NODE_ENV = process.env.NODE_ENV || 'development');
const nativeNodeModulesPlugin = {
name: 'native-node-modules',
setup(build) {
// Mark native Node.js modules as external
build.onResolve({ filter: /\.node$/, namespace: 'file' }, args => {
return { path: args.path, external: true };
});
},
};
// List of env that will be replaced by esbuild
const ENV_MACROS = ['AFFINE_GOOGLE_CLIENT_ID', 'AFFINE_GOOGLE_CLIENT_SECRET'];
@@ -50,9 +40,13 @@ export const config = () => {
target: `node${NODE_MAJOR_VERSION}`,
platform: 'node',
external: ['electron', 'yjs', 'better-sqlite3', 'electron-updater'],
plugins: [nativeNodeModulesPlugin],
define: define,
format: 'cjs',
loader: {
'.node': 'copy',
},
assetNames: '[name]',
treeShaking: true,
},
preload: {
entryPoints: [resolve(root, './layers/preload/src/index.ts')],
@@ -61,7 +55,6 @@ export const config = () => {
target: `node${NODE_MAJOR_VERSION}`,
platform: 'node',
external: ['electron', '../main/exposed-meta'],
plugins: [nativeNodeModulesPlugin],
define: define,
},
};

View File

@@ -1,14 +1,18 @@
#!/usr/bin/env zx
import 'zx/globals';
import { createRequire } from 'node:module';
import path from 'node:path';
const require = createRequire(import.meta.url);
const repoRootDir = path.join(__dirname, '..', '..', '..');
const electronRootDir = path.join(__dirname, '..');
const publicDistDir = path.join(electronRootDir, 'resources');
const affineWebDir = path.join(repoRootDir, 'apps', 'web');
const affineWebOutDir = path.join(affineWebDir, 'out');
const publicAffineOutDir = path.join(publicDistDir, `web-static`);
const releaseVersionEnv = process.env.RELEASE_VERSION || '';
console.log('build with following dir', {
repoRootDir,
@@ -19,9 +23,16 @@ console.log('build with following dir', {
publicAffineOutDir,
});
// step 0: check version match
const electronPackageJson = require(`${electronRootDir}/package.json`);
if (releaseVersionEnv && electronPackageJson.version !== releaseVersionEnv) {
throw new Error(
`Version mismatch, expected ${releaseVersionEnv} but got ${electronPackageJson.version}`
);
}
// copy web dist files to electron dist
// step 0: clean up
// step 1: clean up
await cleanup();
echo('Clean up done');
@@ -32,9 +43,6 @@ if (process.platform === 'win32') {
cd(repoRootDir);
// step 1: build electron resources
await $`yarn workspace @affine/electron build-layers`;
// step 2: build web (nextjs) dist
if (!process.env.SKIP_WEB_BUILD) {
process.env.ENABLE_LEGACY_PROVIDER = 'false';
@@ -59,6 +67,17 @@ if (!process.env.SKIP_WEB_BUILD) {
await fs.move(affineWebOutDir, publicAffineOutDir, { overwrite: true });
}
// step 3: update app-updater.yml content with build type in resources folder
if (process.env.BUILD_TYPE === 'internal') {
const appUpdaterYml = path.join(publicDistDir, 'app-update.yml');
const appUpdaterYmlContent = await fs.readFile(appUpdaterYml, 'utf-8');
const newAppUpdaterYmlContent = appUpdaterYmlContent.replace(
'AFFiNE',
'AFFiNE-Releases'
);
await fs.writeFile(appUpdaterYml, newAppUpdaterYmlContent);
}
/// --------
/// --------
/// --------

View File

@@ -1,12 +1,8 @@
#!/usr/bin/env zx
/* eslint-disable @typescript-eslint/no-restricted-imports */
import 'zx/globals';
const mainDistDir = path.resolve(__dirname, '../dist/layers/main');
// be careful and avoid any side effects in
const { handlers, events } = await import(
path.resolve(mainDistDir, 'exposed.js')
'file://' + path.resolve(mainDistDir, 'exposed.js')
);
const handlersMeta = Object.entries(handlers).map(

View File

@@ -1,3 +1,5 @@
import { platform } from 'node:os';
import { expect } from '@playwright/test';
import { test } from './fixture';
@@ -11,8 +13,74 @@ test('new page', async ({ page, workspace }) => {
expect(flavour).toBe('local');
});
// macOS only
if (platform() === 'darwin') {
test('app sidebar router forward/back', async ({ page }) => {
await page.getByTestId('help-island').click();
await page.getByTestId('easy-guide').click();
await page.getByTestId('onboarding-modal-next-button').click();
await page.getByTestId('onboarding-modal-close-button').click();
{
// create pages
await page.waitForTimeout(500);
await page.getByTestId('new-page-button').click({
delay: 100,
});
await page.waitForSelector('v-line');
await page.focus('.affine-default-page-block-title');
await page.type('.affine-default-page-block-title', 'test1', {
delay: 100,
});
await page.waitForTimeout(500);
await page.getByTestId('new-page-button').click({
delay: 100,
});
await page.waitForSelector('v-line');
await page.focus('.affine-default-page-block-title');
await page.type('.affine-default-page-block-title', 'test2', {
delay: 100,
});
await page.waitForTimeout(500);
await page.getByTestId('new-page-button').click({
delay: 100,
});
await page.waitForSelector('v-line');
await page.focus('.affine-default-page-block-title');
await page.type('.affine-default-page-block-title', 'test3', {
delay: 100,
});
}
{
const title = (await page
.locator('.affine-default-page-block-title')
.textContent()) as string;
expect(title.trim()).toBe('test3');
}
await page.click('[data-testid="app-sidebar-arrow-button-back"]');
await page.waitForTimeout(1000);
await page.click('[data-testid="app-sidebar-arrow-button-back"]');
await page.waitForTimeout(1000);
{
const title = (await page
.locator('.affine-default-page-block-title')
.textContent()) as string;
expect(title.trim()).toBe('test1');
}
await page.click('[data-testid="app-sidebar-arrow-button-forward"]');
await page.waitForTimeout(1000);
await page.click('[data-testid="app-sidebar-arrow-button-forward"]');
await page.waitForTimeout(1000);
{
const title = (await page
.locator('.affine-default-page-block-title')
.textContent()) as string;
expect(title.trim()).toBe('test3');
}
});
}
test('app theme', async ({ page, electronApp }) => {
await page.waitForSelector('v-line');
const root = page.locator('html');
{
const themeMode = await root.evaluate(element =>
@@ -20,30 +88,25 @@ test('app theme', async ({ page, electronApp }) => {
);
expect(themeMode).toBe('light');
// check if electron theme source is set to light
const themeSource = await electronApp.evaluate(({ nativeTheme }) => {
return nativeTheme.themeSource;
const theme = await electronApp.evaluate(({ nativeTheme }) => {
return nativeTheme.shouldUseDarkColors ? 'dark' : 'light';
});
expect(themeSource).toBe('light');
expect(theme).toBe('light');
}
{
await page.getByTestId('editor-option-menu').click();
await page.getByTestId('change-theme-dark').click();
await page.waitForTimeout(50);
{
const themeMode = await root.evaluate(element =>
element.getAttribute('data-theme')
);
expect(themeMode).toBe('dark');
}
const themeSource = await electronApp.evaluate(({ nativeTheme }) => {
return nativeTheme.themeSource;
const themeMode = await root.evaluate(element =>
element.getAttribute('data-theme')
);
expect(themeMode).toBe('dark');
const theme = await electronApp.evaluate(({ nativeTheme }) => {
return nativeTheme.shouldUseDarkColors ? 'dark' : 'light';
});
expect(themeSource).toBe('dark');
expect(theme).toBe('dark');
}
});
@@ -73,7 +136,7 @@ test('affine onboarding button', async ({ page }) => {
'[data-testid=onboarding-modal-editing-video]'
);
expect(await editingVideo.isVisible()).toEqual(true);
await page.getByTestId('onboarding-modal-ok-button').click();
await page.getByTestId('onboarding-modal-close-button').click();
expect(await onboardingModal.isVisible()).toEqual(false);
});

View File

@@ -3,9 +3,14 @@
/* eslint-disable no-empty-pattern */
import crypto from 'node:crypto';
import { resolve } from 'node:path';
import { join, resolve } from 'node:path';
import { test as base } from '@affine-test/kit/playwright';
import {
enableCoverage,
istanbulTempDir,
test as base,
testResultDir,
} from '@affine-test/kit/playwright';
import fs from 'fs-extra';
import type { ElectronApplication, Page } from 'playwright';
import { _electron as electron } from 'playwright';
@@ -42,7 +47,31 @@ export const test = base.extend<{
const logFilePath = await page.evaluate(async () => {
return window.apis?.debug.logFilePath();
});
// wat for blocksuite to be loaded
await page.waitForSelector('v-line');
if (enableCoverage) {
await fs.promises.mkdir(istanbulTempDir, { recursive: true });
await page.exposeFunction(
'collectIstanbulCoverage',
(coverageJSON?: string) => {
if (coverageJSON)
fs.writeFileSync(
join(
istanbulTempDir,
`playwright_coverage_${generateUUID()}.json`
),
coverageJSON
);
}
);
}
await use(page);
if (enableCoverage) {
await page.evaluate(() =>
// @ts-expect-error
window.collectIstanbulCoverage(JSON.stringify(window.__coverage__))
);
}
await page.close();
if (logFilePath) {
const logs = await fs.readFile(logFilePath, 'utf-8');
@@ -52,16 +81,27 @@ export const test = base.extend<{
electronApp: async ({}, use) => {
// a random id to avoid conflicts between tests
const id = generateUUID();
const ext = process.platform === 'win32' ? '.cmd' : '';
const electronApp = await electron.launch({
args: [resolve(__dirname, '..'), '--app-name', 'affine-test-' + id],
executablePath: resolve(__dirname, '../node_modules/.bin/electron'),
executablePath: resolve(
__dirname,
'..',
'node_modules',
'.bin',
`electron${ext}`
),
recordVideo: {
dir: testResultDir,
},
colorScheme: 'light',
});
const sessionDataPath = await electronApp.evaluate(async ({ app }) => {
return app.getPath('sessionData');
});
await use(electronApp);
await fs.rm(sessionDataPath, { recursive: true, force: true });
// FIXME: the following does not work well on CI
// const sessionDataPath = await electronApp.evaluate(async ({ app }) => {
// return app.getPath('sessionData');
// });
// await fs.rm(sessionDataPath, { recursive: true, force: true });
},
appInfo: async ({ electronApp }, use) => {
const appInfo = await electronApp.evaluate(async ({ app }) => {

View File

@@ -1,7 +1,8 @@
import { execSync } from 'node:child_process';
import { join } from 'node:path';
export default async function () {
execSync('yarn ts-node-esm scripts/', {
cwd: path.join(__dirname, '..'),
cwd: join(__dirname, '..'),
});
}

View File

@@ -1,23 +1,24 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"target": "ESNext",
"module": "ESNext",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"moduleResolution": "bundler",
"isolatedModules": false,
"resolveJsonModule": true,
"types": ["node"],
"outDir": "dist",
"noEmit": false
"moduleResolution": "node",
"resolveJsonModule": true
},
"include": ["layers", "types", "package.json"],
"include": ["**/*.ts", "**/*.tsx", "package.json"],
"exclude": ["out", "dist", "node_modules"],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "../../packages/native"
}
],
"ts-node": {

View File

@@ -1,3 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"
provider = "postgresql"

View File

@@ -1,7 +1,7 @@
{
"name": "@affine/server",
"private": true,
"version": "0.5.4-canary.30",
"version": "0.5.4-beta.2",
"description": "Affine Node.js server",
"type": "module",
"bin": {
@@ -16,36 +16,35 @@
"dependencies": {
"@apollo/server": "^4.7.1",
"@nestjs/apollo": "^11.0.5",
"@nestjs/common": "^9.4.0",
"@nestjs/core": "^9.4.0",
"@nestjs/common": "^9.4.1",
"@nestjs/core": "^9.4.1",
"@nestjs/graphql": "^11.0.5",
"@nestjs/platform-express": "^9.4.0",
"@prisma/client": "^4.13.0",
"bcrypt": "^5.1.0",
"@nestjs/platform-express": "^9.4.1",
"@node-rs/bcrypt": "^1.7.1",
"@prisma/client": "^4.14.1",
"dotenv": "^16.0.3",
"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.13.0",
"prisma": "^4.14.1",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@nestjs/testing": "^9.4.0",
"@types/bcrypt": "^5.0.0",
"@nestjs/testing": "^9.4.1",
"@types/express": "^4.17.17",
"@types/jsonwebtoken": "^9.0.2",
"@types/lodash-es": "^4.17.7",
"@types/node": "^18.16.6",
"@types/node": "^18.16.12",
"@types/supertest": "^2.0.12",
"c8": "^7.13.0",
"nodemon": "^2.0.22",
"supertest": "^6.3.3",
"ts-node": "^10.9.1",
"typescript": "^5.0.4",
"vitest": "^0.31.0"
"vitest": "^0.31.1"
},
"nodemonConfig": {
"exec": "node",

View File

@@ -1,6 +1,6 @@
import crypto from 'node:crypto';
import { genSalt } from 'bcrypt';
import { genSalt } from '@node-rs/bcrypt';
const { privateKey, publicKey } = crypto.generateKeyPairSync('ec', {
namedCurve: 'prime256v1',

View File

@@ -169,10 +169,6 @@ export interface AFFiNEConfig {
* authentication config
*/
auth: {
/**
* Application sign key secret
*/
readonly salt: string;
/**
* Application access token expiration time
*/

View File

@@ -56,7 +56,6 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => ({
debug: true,
},
auth: {
salt: '$2b$10$x4VDo2nmlo74yB5jflNhlu',
accessTokenExpiresIn: '1h',
refreshTokenExpiresIn: '7d',
publicKey: examplePublicKey,

View File

@@ -3,8 +3,8 @@ import {
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { compare, hash } from '@node-rs/bcrypt';
import { User } from '@prisma/client';
import { compare, hash } from 'bcrypt';
import jwt from 'jsonwebtoken';
import { Config } from '../../config';
@@ -69,7 +69,7 @@ export class AuthService {
}
async register(name: string, email: string, password: string): Promise<User> {
const hashedPassword = await hash(password, this.config.auth.salt);
const hashedPassword = await hash(password);
const user = await this.prisma.user.findFirst({
where: {

View File

@@ -3,8 +3,8 @@ import { afterEach, beforeEach, describe, test } from 'node:test';
import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { hash } from '@node-rs/bcrypt';
import { PrismaClient } from '@prisma/client';
import { hash } from 'bcrypt';
import request from 'supertest';
import { AppModule } from '../app';
@@ -27,7 +27,7 @@ describe('AppModule', () => {
id: '1',
name: 'Alex Yang',
email: 'alex.yang@example.org',
password: await hash('123456', globalThis.AFFiNE.auth.salt),
password: await hash('123456'),
},
});
});

View File

@@ -1,7 +1,7 @@
{
"name": "@affine/web",
"private": true,
"version": "0.5.4-canary.30",
"version": "0.5.4-beta.2",
"scripts": {
"dev": "next dev",
"build": "next build",
@@ -19,32 +19,33 @@
"@affine/jotai": "workspace:*",
"@affine/templates": "workspace:*",
"@affine/workspace": "workspace:*",
"@blocksuite/blocks": "0.0.0-20230509052644-b8b1b6a1-nightly",
"@blocksuite/editor": "0.0.0-20230509052644-b8b1b6a1-nightly",
"@blocksuite/global": "0.0.0-20230509052644-b8b1b6a1-nightly",
"@blocksuite/icons": "^2.1.15",
"@blocksuite/store": "0.0.0-20230509052644-b8b1b6a1-nightly",
"@blocksuite/blocks": "0.0.0-20230518051344-45970a96-nightly",
"@blocksuite/editor": "0.0.0-20230518051344-45970a96-nightly",
"@blocksuite/global": "0.0.0-20230518051344-45970a96-nightly",
"@blocksuite/icons": "^2.1.16",
"@blocksuite/lit": "0.0.0-20230518051344-45970a96-nightly",
"@blocksuite/store": "0.0.0-20230518051344-45970a96-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.12.3",
"@mui/material": "^5.13.1",
"@react-hookz/web": "^23.0.0",
"@sentry/nextjs": "^7.51.2",
"@sentry/nextjs": "^7.52.1",
"@toeverything/hooks": "workspace:*",
"cmdk": "^0.2.0",
"css-spring": "^4.1.0",
"dayjs": "^1.11.7",
"graphql": "^16.6.0",
"jotai": "^2.1.0",
"jotai-devtools": "^0.5.2",
"jotai-devtools": "^0.5.3",
"lit": "^2.7.4",
"lottie-web": "^5.11.0",
"next-themes": "^0.2.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react": "18.3.0-canary-16d053d59-20230506",
"react-dom": "18.3.0-canary-16d053d59-20230506",
"react-is": "^18.2.0",
"rxjs": "^7.8.1",
"swr": "^2.1.5",
@@ -66,16 +67,16 @@
"@vanilla-extract/next-plugin": "^2.1.2",
"dotenv": "^16.0.3",
"eslint": "^8.40.0",
"eslint-config-next": "^13.4.1",
"eslint-config-next": "^13.4.2",
"eslint-plugin-unicorn": "^47.0.0",
"next": "^13.4.1",
"next": "^13.4.2",
"next-debug-local": "^0.1.5",
"next-router-mock": "^0.9.3",
"raw-loader": "^4.0.2",
"redux": "^4.2.1",
"swc-plugin-coverage-instrument": "^0.0.18",
"typescript": "^5.0.4",
"webpack": "^5.82.0"
"webpack": "^5.83.1"
},
"stableVersion": "0.0.0"
}

View File

@@ -33,4 +33,7 @@ export const buildFlags = {
enableDebugPage: Boolean(
process.env.ENABLE_DEBUG_PAGE ?? process.env.NODE_ENV === 'development'
),
changelogUrl:
process.env.CHANGELOG_URL ??
'https://affine.pro/blog/whats-new-affine-0518',
};

View File

@@ -19,7 +19,7 @@ import type { Page } from '@blocksuite/store';
import { createStore } from 'jotai';
import { describe, expect, test } from 'vitest';
import { WorkspacePlugins } from '../../plugins';
import { WorkspaceAdapters } from '../../plugins';
import { rootCurrentWorkspaceAtom } from '../root';
describe('currentWorkspace atom', () => {
@@ -45,7 +45,7 @@ describe('currentWorkspace atom', () => {
const provider = createIndexedDBDownloadProvider(workspace);
provider.sync();
await provider.whenReady;
const workspaceId = await WorkspacePlugins[
const workspaceId = await WorkspaceAdapters[
WorkspaceFlavour.LOCAL
].CRUD.create(workspace);
store.set(rootWorkspacesMetadataAtom, [

View File

@@ -0,0 +1,99 @@
import { atom, useAtom, useSetAtom } from 'jotai';
import { useRouter } from 'next/router';
import { useCallback, useEffect } from 'react';
export type History = {
stack: string[];
current: number;
skip: boolean;
};
export const MAX_HISTORY = 50;
export const historyBaseAtom = atom<History>({
stack: [],
current: 0,
skip: false,
});
// fixme(himself65): don't use hooks, use atom lifecycle instead
export function useTrackRouterHistoryEffect() {
const setBase = useSetAtom(historyBaseAtom);
const router = useRouter();
useEffect(() => {
const callback = (url: string) => {
setBase(prev => {
console.log('push', url, prev.skip, prev.stack.length, prev.current);
if (prev.skip) {
return {
stack: [...prev.stack],
current: prev.current,
skip: false,
};
} else {
if (prev.current < prev.stack.length - 1) {
const newStack = prev.stack.slice(0, prev.current);
newStack.push(url);
if (newStack.length > MAX_HISTORY) {
newStack.shift();
}
return {
stack: newStack,
current: newStack.length - 1,
skip: false,
};
} else {
const newStack = [...prev.stack, url];
if (newStack.length > MAX_HISTORY) {
newStack.shift();
}
return {
stack: newStack,
current: newStack.length - 1,
skip: false,
};
}
}
});
};
router.events.on('routeChangeComplete', callback);
return () => {
router.events.off('routeChangeComplete', callback);
};
}, [router.events, setBase]);
}
export function useHistoryAtom() {
const router = useRouter();
const [base, setBase] = useAtom(historyBaseAtom);
return [
base,
useCallback(
(forward: boolean) => {
setBase(prev => {
if (forward) {
const target = Math.min(prev.stack.length - 1, prev.current + 1);
const url = prev.stack[target];
void router.push(url);
return {
...prev,
current: target,
skip: true,
};
} else {
const target = Math.max(0, prev.current - 1);
const url = prev.stack[target];
void router.push(url);
return {
...prev,
current: target,
skip: true,
};
}
});
},
[router, setBase]
),
] as const;
}

View File

@@ -12,7 +12,7 @@ import { atom } from 'jotai';
import { atomWithStorage } from 'jotai/utils';
import type { CreateWorkspaceMode } from '../components/affine/create-workspace-modal';
import { WorkspacePlugins } from '../plugins';
import { WorkspaceAdapters } from '../plugins';
const logger = new DebugLogger('web:atoms');
@@ -25,7 +25,7 @@ export const currentWorkspaceIdAtom = rootCurrentWorkspaceIdAtom;
// todo(himself65): move this to the workspace package
rootWorkspacesMetadataAtom.onMount = setAtom => {
function createFirst(): RootWorkspaceMetadata[] {
const Plugins = Object.values(WorkspacePlugins).sort(
const Plugins = Object.values(WorkspaceAdapters).sort(
(a, b) => a.loadPriority - b.loadPriority
);
@@ -40,17 +40,24 @@ rootWorkspacesMetadataAtom.onMount = setAtom => {
}).filter((ids): ids is RootWorkspaceMetadata => !!ids);
}
setAtom(metadata => {
if (metadata.length === 0) {
const newMetadata = createFirst();
logger.info('create first workspace', newMetadata);
return newMetadata;
}
return metadata;
});
const abortController = new AbortController();
// next tick to make sure the hydration is correct
const id = setTimeout(() => {
setAtom(metadata => {
if (abortController.signal.aborted) return metadata;
if (metadata.length === 0) {
const newMetadata = createFirst();
logger.info('create first workspace', newMetadata);
return newMetadata;
}
return metadata;
});
}, 0);
if (environment.isDesktop) {
window.apis?.workspace.list().then(workspaceIDs => {
if (abortController.signal.aborted) return;
const newMetadata = workspaceIDs.map(w => ({
id: w[0],
flavour: WorkspaceFlavour.LOCAL,
@@ -63,6 +70,11 @@ rootWorkspacesMetadataAtom.onMount = setAtom => {
});
});
}
return () => {
clearTimeout(id);
abortController.abort();
};
};
/**

View File

@@ -13,7 +13,7 @@ import { WorkspaceFlavour } from '@affine/workspace/type';
import { assertExists } from '@blocksuite/store';
import { atom } from 'jotai';
import { WorkspacePlugins } from '../plugins';
import { WorkspaceAdapters } from '../plugins';
import type { AllWorkspace } from '../shared';
const logger = new DebugLogger('web:atoms:root');
@@ -22,7 +22,7 @@ const logger = new DebugLogger('web:atoms:root');
* Fetch all workspaces from the Plugin CRUD
*/
export const workspacesAtom = atom<Promise<AllWorkspace[]>>(async get => {
const flavours: string[] = Object.values(WorkspacePlugins).map(
const flavours: string[] = Object.values(WorkspaceAdapters).map(
plugin => plugin.flavour
);
const jotaiWorkspaces = get(rootWorkspacesMetadataAtom)
@@ -38,7 +38,7 @@ export const workspacesAtom = atom<Promise<AllWorkspace[]>>(async get => {
const workspaces = await Promise.all(
jotaiWorkspaces.map(workspace => {
const plugin =
WorkspacePlugins[workspace.flavour as keyof typeof WorkspacePlugins];
WorkspaceAdapters[workspace.flavour as keyof typeof WorkspaceAdapters];
assertExists(plugin);
const { CRUD } = plugin;
return CRUD.get(workspace.id).then(workspace => {
@@ -93,7 +93,7 @@ export const rootCurrentWorkspaceAtom = atom<Promise<AllWorkspace>>(
if (!targetWorkspace) {
throw new Error(`cannot find the workspace with id ${targetId}.`);
}
const workspace = await WorkspacePlugins[targetWorkspace.flavour].CRUD.get(
const workspace = await WorkspaceAdapters[targetWorkspace.flavour].CRUD.get(
targetWorkspace.id
);
if (!workspace) {

View File

@@ -30,6 +30,7 @@ export const createAffineDownloadProvider = (
new Uint8Array(hashMap.get(id) as ArrayBuffer)
);
connected = true;
callbacks.forEach(cb => cb());
return;
}
affineApis
@@ -41,6 +42,8 @@ export const createAffineDownloadProvider = (
blockSuiteWorkspace.doc,
new Uint8Array(binary)
);
connected = true;
callbacks.forEach(cb => cb());
})
.catch(e => {
providerLogger.error('downloadWorkspace', e);

View File

@@ -1,52 +1,19 @@
import type {
QueryParamError,
Unreachable,
WorkspaceNotFoundError,
} from '@affine/env/constant';
import { PageNotFoundError } from '@affine/env/constant';
import { RequestError } from '@affine/workspace/affine/api';
import type { NextRouter } from 'next/router';
import type { ErrorInfo, ReactNode } from 'react';
import type React from 'react';
import { Component } from 'react';
import type { BlockSuiteWorkspace } from '../../shared';
export type AffineErrorBoundaryProps = React.PropsWithChildren<{
router: NextRouter;
}>;
export class PageNotFoundError extends TypeError {
readonly workspace: BlockSuiteWorkspace;
readonly pageId: string;
constructor(workspace: BlockSuiteWorkspace, pageId: string) {
super();
this.workspace = workspace;
this.pageId = pageId;
}
}
export class WorkspaceNotFoundError extends TypeError {
readonly workspaceId: string;
constructor(workspaceId: string) {
super();
this.workspaceId = workspaceId;
}
}
export class QueryParamError extends TypeError {
readonly targetKey: string;
readonly query: unknown;
constructor(targetKey: string, query: unknown) {
super();
this.targetKey = targetKey;
this.query = query;
}
}
export class Unreachable extends Error {
constructor(message?: string) {
super(message);
}
}
type AffineError =
| QueryParamError
| Unreachable

View File

@@ -74,7 +74,7 @@ const NameWorkspaceContent = ({
data-testid="create-workspace-input"
onKeyDown={handleKeyDown}
placeholder={t['Set a Workspace name']()}
maxLength={15} // TODO: the max workspace name length?
maxLength={64}
minLength={0}
onChange={value => {
setWorkspaceName(value);
@@ -118,10 +118,7 @@ interface SetDBLocationContentProps {
onConfirmLocation: (dir?: string) => void;
}
const SetDBLocationContent = ({
onConfirmLocation,
}: SetDBLocationContentProps) => {
const t = useAFFiNEI18N();
const useDefaultDBLocation = () => {
const [defaultDBLocation, setDefaultDBLocation] = useState('');
useEffect(() => {
@@ -130,20 +127,40 @@ const SetDBLocationContent = ({
});
}, []);
return defaultDBLocation;
};
const SetDBLocationContent = ({
onConfirmLocation,
}: SetDBLocationContentProps) => {
const t = useAFFiNEI18N();
const defaultDBLocation = useDefaultDBLocation();
const [opening, setOpening] = useState(false);
const handleSelectDBFileLocation = async () => {
if (opening) {
return;
}
setOpening(true);
const result = await window.apis?.dialog.selectDBFileLocation();
setOpening(false);
if (result?.filePath) {
onConfirmLocation(result.filePath);
} else if (result?.error) {
toast(t[result.error]());
}
};
return (
<div className={style.content}>
<div className={style.contentTitle}>{t['Set database location']()}</div>
<p>{t['Workspace database storage description']()}</p>
<div className={style.buttonGroup}>
<Button
disabled={opening}
data-testid="create-workspace-customize-button"
type="light"
onClick={async () => {
const result = await window.apis?.dialog.selectDBFileLocation();
if (result) {
onConfirmLocation(result.filePath);
}
}}
onClick={handleSelectDBFileLocation}
>
{t['Customize']()}
</Button>

View File

@@ -7,7 +7,7 @@ export const StyledSidebarSwitch = styled(IconButton, {
})<{ visible: boolean }>(({ visible }) => {
return {
opacity: visible ? 1 : 0,
WebkitAppRegion: 'no-drag',
WebkitAppRegion: visible ? 'no-drag' : 'drag',
transition: 'all 0.2s ease-in-out',
};
});

View File

@@ -1,4 +1,6 @@
import { Empty, IconButton, Modal, ModalWrapper } from '@affine/component';
import { Trans } from '@affine/i18n';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { CloseIcon } from '@blocksuite/icons';
import type React from 'react';
@@ -20,6 +22,7 @@ interface TmpDisableAffineCloudModalProps {
export const TmpDisableAffineCloudModal: React.FC<
TmpDisableAffineCloudModalProps
> = ({ open, onClose }) => {
const t = useAFFiNEI18N();
return (
<Modal
data-testid="disable-affine-cloud-modal"
@@ -37,21 +40,25 @@ export const TmpDisableAffineCloudModal: React.FC<
</IconButton>
</Header>
<Content>
<ContentTitle>AFFiNE Cloud is upgrading now.</ContentTitle>
<ContentTitle>
{t['com.affine.cloudTempDisable.title']()}
</ContentTitle>
<StyleTips>
We are upgrading the AFFiNE Cloud service and it is temporarily
unavailable on the client side. If you wish to be notified the first
time it&apos;s available, please&nbsp;
<a
href="https://github.com/toeverything/AFFiNE/releases"
target="_blank"
style={{
color: 'var(--affine-link-color)',
}}
>
click here
</a>
.
<Trans i18nKey="com.affine.cloudTempDisable.description">
We are upgrading the AFFiNE Cloud service and it is temporarily
unavailable on the client side. If you wish to stay updated on the
progress and be notified on availability, you can fill out the
<a
href="https://6dxre9ihosp.typeform.com/to/B8IHwuyy"
target="_blank"
style={{
color: 'var(--affine-link-color)',
}}
>
AFFiNE Cloud Signup
</a>
.
</Trans>
</StyleTips>
<StyleImage>
<Empty
@@ -69,7 +76,7 @@ export const TmpDisableAffineCloudModal: React.FC<
onClose();
}}
>
Got it
{t['Got it']()}
</StyleButton>
</StyleButtonContainer>
</Content>

View File

@@ -6,8 +6,10 @@ import { globalStyle, style, styleVariants } from '@vanilla-extract/css';
export const container = style({
display: 'flex',
flexDirection: 'column',
padding: '52px 52px 0 52px',
marginTop: '52px',
padding: '0 52px 52px 52px',
height: 'calc(100vh - 52px)',
overflow: 'auto',
});
export const sidebar = style({
@@ -15,7 +17,6 @@ export const sidebar = style({
});
export const content = style({
overflow: 'auto',
flex: 1,
marginTop: '40px',
});
@@ -110,7 +111,8 @@ export const settingItemLabelHint = style({
export const row = style({
padding: '40px 0',
display: 'flex',
gap: '60px',
columnGap: '60px',
rowGap: '12px',
selectors: {
'&': {
borderBottom: '1px solid var(--affine-border-color)',
@@ -119,22 +121,22 @@ export const row = style({
paddingTop: 0,
},
},
flexWrap: 'wrap',
});
export const col = style({
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
flexShrink: 0,
selectors: {
[`${row} &:nth-child(1)`]: {
flex: 3,
flex: '3 0 200px',
},
[`${row} &:nth-child(2)`]: {
flex: 5,
flex: '5 0 240px',
},
[`${row} &:nth-child(3)`]: {
flex: 2,
flex: '2 0 200px',
alignItems: 'flex-end',
},
},
@@ -156,7 +158,10 @@ export const indicator = style({
export const tabButtonWrapper = style({
display: 'flex',
position: 'relative',
position: 'sticky',
top: '0',
background: 'var(--affine-background-primary-color)',
zIndex: 1,
});
export const storageTypeWrapper = style({
@@ -175,6 +180,10 @@ export const storageTypeWrapper = style({
'&:not(:last-child)': {
marginBottom: '12px',
},
'&[data-disabled="true"]': {
cursor: 'default',
pointerEvents: 'none',
},
},
});

View File

@@ -1,8 +1,12 @@
import { Button, IconButton, Menu, MenuItem, Wrapper } from '@affine/component';
import { config } from '@affine/env';
import { Unreachable } from '@affine/env/constant';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { PermissionType } from '@affine/workspace/affine/api';
import type { AffineWorkspace, LocalWorkspace } from '@affine/workspace/type';
import type {
AffineLegacyCloudWorkspace,
LocalWorkspace,
} from '@affine/workspace/type';
import { WorkspaceFlavour } from '@affine/workspace/type';
import {
DeleteTemporarilyIcon,
@@ -14,7 +18,6 @@ import { useCallback, useState } from 'react';
import { useMembers } from '../../../../../hooks/affine/use-members';
import { toast } from '../../../../../utils';
import { Unreachable } from '../../../affine-error-eoundary';
import { TmpDisableAffineCloudModal } from '../../../tmp-disable-affine-cloud-modal';
import { TransformWorkspaceToAffineModal } from '../../../transform-workspace-to-affine-modal';
import type { PanelProps } from '../../index';
@@ -37,7 +40,7 @@ import {
const AffineRemoteCollaborationPanel: React.FC<
Omit<PanelProps, 'workspace'> & {
workspace: AffineWorkspace;
workspace: AffineLegacyCloudWorkspace;
}
> = ({ workspace }) => {
const [isInviteModalShow, setIsInviteModalShow] = useState(false);
@@ -214,7 +217,7 @@ const LocalCollaborationPanel: React.FC<
export const CollaborationPanel: React.FC<PanelProps> = props => {
switch (props.workspace.flavour) {
case WorkspaceFlavour.AFFINE: {
const workspace = props.workspace as AffineWorkspace;
const workspace = props.workspace as AffineLegacyCloudWorkspace;
return (
<AffineRemoteCollaborationPanel {...props} workspace={workspace} />
);

View File

@@ -15,8 +15,13 @@ export const ExportPanel = () => {
disabled={!environment.isDesktop || !id}
data-testid="export-affine-backup"
onClick={async () => {
if (id && (await window.apis?.dialog.saveDBFileAs(id))) {
toast(t['Export success']());
if (id) {
const result = await window.apis?.dialog.saveDBFileAs(id);
if (result?.error) {
toast(t[result.error]());
} else if (!result?.canceled) {
toast(t['Export success']());
}
}
}}
>

View File

@@ -57,7 +57,7 @@ export const StyledButtonContent = styled('div')(() => {
export const StyledWorkspaceName = styled('span')(() => {
return {
color: '#E8178A',
fontWeight: '600',
};
});

View File

@@ -12,7 +12,7 @@ import { useBlockSuiteWorkspaceAvatarUrl } from '@toeverything/hooks/use-block-s
import { useBlockSuiteWorkspaceName } from '@toeverything/hooks/use-block-suite-workspace-name';
import clsx from 'clsx';
import type React from 'react';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useIsWorkspaceOwner } from '../../../../../hooks/affine/use-is-workspace-owner';
import { Upload } from '../../../../pure/file-upload';
@@ -23,6 +23,26 @@ import { CameraIcon } from './icons';
import { WorkspaceLeave } from './leave';
import { StyledInput } from './style';
const useDBFilePathMeta = (workspaceId: string) => {
const [meta, setMeta] = useState<{
path: string;
realPath: string;
}>();
useEffect(() => {
if (window.apis && window.events) {
window.apis.db.getDBFilePath(workspaceId).then(meta => {
setMeta(meta);
});
return window.events.db.onDBFilePathChange(meta => {
if (meta.workspaceId === workspaceId) {
setMeta(meta);
}
});
}
}, [workspaceId]);
return meta;
};
export const GeneralPanel: React.FC<PanelProps> = ({
workspace,
onDeleteWorkspace,
@@ -36,11 +56,36 @@ export const GeneralPanel: React.FC<PanelProps> = ({
const isOwner = useIsWorkspaceOwner(workspace);
const t = useAFFiNEI18N();
const dbPathMeta = useDBFilePathMeta(workspace.id);
const showOpenFolder =
environment.isDesktop && dbPathMeta?.path !== dbPathMeta?.realPath;
const handleUpdateWorkspaceName = (name: string) => {
setName(name);
toast(t['Update workspace name success']());
};
const [moveToInProgress, setMoveToInProgress] = useState<boolean>(false);
const handleMoveTo = async () => {
if (moveToInProgress) {
return;
}
try {
setMoveToInProgress(true);
const result = await window.apis?.dialog.moveDBFile(workspace.id);
if (!result?.error && !result?.canceled) {
toast(t['Move folder success']());
} else if (result?.error) {
toast(t[result.error]());
}
} catch (err) {
toast(t['UNKNOWN_ERROR']());
} finally {
setMoveToInProgress(false);
}
};
const [, update] = useBlockSuiteWorkspaceAvatarUrl(
workspace.blockSuiteWorkspace
);
@@ -88,12 +133,11 @@ export const GeneralPanel: React.FC<PanelProps> = ({
<div className={style.col}>
<StyledInput
width={284}
height={38}
value={input}
data-testid="workspace-name-input"
placeholder={t['Workspace Name']()}
maxLength={50}
maxLength={64}
minLength={0}
onChange={newName => {
setInput(newName);
@@ -129,34 +173,33 @@ export const GeneralPanel: React.FC<PanelProps> = ({
</div>
<div className={style.col}>
<div
className={style.storageTypeWrapper}
onClick={() => {
if (environment.isDesktop) {
window.apis?.dialog.revealDBFile(workspace.id);
}
}}
>
<FolderIcon color="var(--affine-primary-color)" />
<div className={style.storageTypeLabelWrapper}>
<div className={style.storageTypeLabel}>
{t['Open folder']()}
</div>
<div className={style.storageTypeLabelHint}>
{t['Open folder hint']()}
{showOpenFolder && (
<div
className={style.storageTypeWrapper}
onClick={() => {
if (environment.isDesktop) {
window.apis?.dialog.revealDBFile(workspace.id);
}
}}
>
<FolderIcon color="var(--affine-primary-color)" />
<div className={style.storageTypeLabelWrapper}>
<div className={style.storageTypeLabel}>
{t['Open folder']()}
</div>
<div className={style.storageTypeLabelHint}>
{t['Open folder hint']()}
</div>
</div>
<ArrowRightSmallIcon color="var(--affine-primary-color)" />
</div>
<ArrowRightSmallIcon color="var(--affine-primary-color)" />
</div>
)}
<div
data-testid="move-folder"
data-disabled={moveToInProgress}
className={style.storageTypeWrapper}
onClick={async () => {
if (await window.apis?.dialog.moveDBFile(workspace.id)) {
toast(t['Move folder success']());
}
}}
onClick={handleMoveTo}
>
<MoveToIcon color="var(--affine-primary-color)" />
<div className={style.storageTypeLabelWrapper}>

View File

@@ -1,13 +1,7 @@
import { displayFlex, styled } from '@affine/component';
import { Input } from '@affine/component';
export const StyledInput = styled(Input)(() => {
return {
border: '1px solid var(--affine-border-color)',
borderRadius: '8px',
fontSize: 'var(--affine-font-sm)',
};
});
export const StyledInput = Input;
export const StyledWorkspaceInfo = styled('div')(() => {
return {

View File

@@ -6,8 +6,12 @@ import {
Wrapper,
} from '@affine/component';
import { config } from '@affine/env';
import { Unreachable } from '@affine/env/constant';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import type { AffineWorkspace, LocalWorkspace } from '@affine/workspace/type';
import type {
AffineLegacyCloudWorkspace,
LocalWorkspace,
} from '@affine/workspace/type';
import { WorkspaceFlavour } from '@affine/workspace/type';
import { Box } from '@mui/material';
import type React from 'react';
@@ -16,7 +20,6 @@ import { useCallback, useEffect, useState } from 'react';
import { useToggleWorkspacePublish } from '../../../../../hooks/affine/use-toggle-workspace-publish';
import type { AffineOfficialWorkspace } from '../../../../../shared';
import { toast } from '../../../../../utils';
import { Unreachable } from '../../../affine-error-eoundary';
import { EnableAffineCloudModal } from '../../../enable-affine-cloud-modal';
import { TmpDisableAffineCloudModal } from '../../../tmp-disable-affine-cloud-modal';
import type { WorkspaceSettingDetailProps } from '../../index';
@@ -26,7 +29,7 @@ export type PublishPanelProps = WorkspaceSettingDetailProps & {
};
export type PublishPanelAffineProps = WorkspaceSettingDetailProps & {
workspace: AffineWorkspace;
workspace: AffineLegacyCloudWorkspace;
};
const PublishPanelAffine: React.FC<PublishPanelAffineProps> = ({

View File

@@ -19,7 +19,7 @@ import { pageListEmptyStyle } from './index.css';
export type BlockSuitePageListProps = {
blockSuiteWorkspace: BlockSuiteWorkspace;
listType: 'all' | 'trash' | 'favorite' | 'shared' | 'public';
listType: 'all' | 'trash' | 'shared' | 'public';
isPublic?: true;
onOpenPage: (pageId: string, newTab?: boolean) => void;
};
@@ -31,7 +31,6 @@ const filter = {
const parentMeta = allMetas.find(m => m.subpageIds?.includes(pageMeta.id));
return !parentMeta?.trash && pageMeta.trash;
},
favorite: (pageMeta: PageMeta) => pageMeta.favorite && !pageMeta.trash,
shared: (pageMeta: PageMeta) => pageMeta.isPublic && !pageMeta.trash,
};
@@ -52,9 +51,6 @@ const PageListEmpty = (props: {
if (listType === 'all') {
return t['emptyAllPages']();
}
if (listType === 'favorite') {
return t['emptyFavorite']();
}
if (listType === 'trash') {
return t['emptyTrash']();
}
@@ -102,7 +98,7 @@ export const BlockSuitePageList: React.FC<BlockSuitePageListProps> = ({
pageId: pageMeta.id,
title: pageMeta.title,
createDate: formatDate(pageMeta.createDate),
updatedDate: formatDate(pageMeta.updatedDate),
updatedDate: formatDate(pageMeta.updatedDate ?? pageMeta.createDate),
onClickPage: () => onOpenPage(pageMeta.id),
onClickRestore: () => {
restoreFromTrash(pageMeta.id);
@@ -129,7 +125,7 @@ export const BlockSuitePageList: React.FC<BlockSuitePageListProps> = ({
favorite: !!pageMeta.favorite,
isPublicPage: !!pageMeta.isPublic,
createDate: formatDate(pageMeta.createDate),
updatedDate: formatDate(pageMeta.updatedDate),
updatedDate: formatDate(pageMeta.updatedDate ?? pageMeta.createDate),
onClickPage: () => onOpenPage(pageMeta.id),
onOpenPageInNewTab: () => onOpenPage(pageMeta.id, true),
onClickRestore: () => {

View File

@@ -1,3 +1,4 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { assertExists } from '@blocksuite/store';
import { useBlockSuitePageMeta } from '@toeverything/hooks/use-block-suite-page-meta';
import { useAtomValue, useSetAtom } from 'jotai';
@@ -27,6 +28,7 @@ export const EditorModeSwitch = ({
const pageMeta = useBlockSuitePageMeta(blockSuiteWorkspace).find(
meta => meta.id === pageId
);
const t = useAFFiNEI18N();
assertExists(pageMeta);
const { trash } = pageMeta;
@@ -41,8 +43,12 @@ export const EditorModeSwitch = ({
active={currentMode === 'page'}
hide={trash && currentMode !== 'page'}
onClick={() => {
setMode(mode => ({ ...mode, [pageMeta.id]: 'page' }));
toast('Page mode');
setMode(mode => {
if (mode[pageMeta.id] !== 'page') {
toast(t['com.affine.pageMode']());
}
return { ...mode, [pageMeta.id]: 'page' };
});
}}
/>
<EdgelessSwitchItem
@@ -50,8 +56,12 @@ export const EditorModeSwitch = ({
active={currentMode === 'edgeless'}
hide={trash && currentMode !== 'edgeless'}
onClick={() => {
setMode(mode => ({ ...mode, [pageMeta.id]: 'edgeless' }));
toast('Edgeless mode');
setMode(mode => {
if (mode[pageMeta.id] !== 'edgeless') {
toast(t['com.affine.edgelessMode']());
}
return { ...mode, [pageMeta.id]: 'edgeless' };
});
}}
/>
</StyledEditorModeSwitch>

View File

@@ -24,10 +24,7 @@ import { useCurrentPageId } from '../../../../hooks/current/use-current-page-id'
import { useCurrentWorkspace } from '../../../../hooks/current/use-current-workspace';
import { toast } from '../../../../utils';
import { MenuThemeModeSwitch } from '../header-right-items/theme-mode-switch';
import {
StyledHorizontalDivider,
StyledHorizontalDividerContainer,
} from '../styles';
import * as styles from '../styles.css';
import { LanguageMenu } from './language-menu';
const CommonMenu = () => {
const content = (
@@ -43,7 +40,6 @@ const CommonMenu = () => {
return (
<FlexWrapper alignItems="center" justifyContent="center">
<Menu
width={276}
content={content}
placement="bottom"
disablePortal={true}
@@ -120,9 +116,9 @@ const PageMenu = () => {
}}
/>
)}
<StyledHorizontalDividerContainer>
<StyledHorizontalDivider />
</StyledHorizontalDividerContainer>
<div className={styles.horizontalDividerContainer}>
<div className={styles.horizontalDivider} />
</div>
</>
<div
@@ -140,7 +136,6 @@ const PageMenu = () => {
<>
<FlexWrapper alignItems="center" justifyContent="center">
<Menu
width={276}
content={EditMenu}
placement="bottom-end"
disablePortal={true}
@@ -156,6 +151,7 @@ const PageMenu = () => {
onConfirm={() => {
removeToTrash(pageMeta.id);
toast(t['Moved to Trash']());
setOpenConfirm(false);
}}
onCancel={() => {
setOpenConfirm(false);

View File

@@ -1,6 +1,10 @@
import { ShareMenu } from '@affine/component/share-menu';
import { config } from '@affine/env';
import type { AffineWorkspace, LocalWorkspace } from '@affine/workspace/type';
import { Unreachable } from '@affine/env/constant';
import type {
AffineLegacyCloudWorkspace,
LocalWorkspace,
} from '@affine/workspace/type';
import { WorkspaceFlavour } from '@affine/workspace/type';
import type { Page } from '@blocksuite/store';
import { assertEquals } from '@blocksuite/store';
@@ -12,19 +16,18 @@ import { useToggleWorkspacePublish } from '../../../../hooks/affine/use-toggle-w
import { useOnTransformWorkspace } from '../../../../hooks/root/use-on-transform-workspace';
import { useRouterHelper } from '../../../../hooks/use-router-helper';
import { WorkspaceSubPath } from '../../../../shared';
import { Unreachable } from '../../../affine/affine-error-eoundary';
import { TransformWorkspaceToAffineModal } from '../../../affine/transform-workspace-to-affine-modal';
import type { BaseHeaderProps } from '../header';
const AffineHeaderShareMenu: React.FC<BaseHeaderProps> = props => {
// todo: these hooks should be moved to the top level
const togglePublish = useToggleWorkspacePublish(
props.workspace as AffineWorkspace
props.workspace as AffineLegacyCloudWorkspace
);
const helper = useRouterHelper(useRouter());
return (
<ShareMenu
workspace={props.workspace as AffineWorkspace}
workspace={props.workspace as AffineLegacyCloudWorkspace}
currentPage={props.currentPage as Page}
onEnableAffineCloud={useCallback(async () => {
throw new Unreachable(

View File

@@ -30,10 +30,12 @@ export const StyledThemeButton = styled('button')<{
active: boolean;
}>(({ active }) => {
return {
padding: '0 8px',
height: '100%',
flex: 1,
cursor: 'pointer',
color: active ? 'var(--affine-primary-color)' : 'var(--affine-icon-color)',
whiteSpace: 'nowrap',
};
});
export const StyledVerticalDivider = styled('div')(() => {

View File

@@ -1,9 +1,13 @@
import { BrowserWarning } from '@affine/component/affine-banner';
import { appSidebarOpenAtom } from '@affine/component/app-sidebar';
import {
appSidebarFloatingAtom,
appSidebarOpenAtom,
} from '@affine/component/app-sidebar';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { WorkspaceFlavour } from '@affine/workspace/type';
import { CloseIcon, MinusIcon, RoundedRectangleIcon } from '@blocksuite/icons';
import type { Page } from '@blocksuite/store';
import { useAtom } from 'jotai';
import { useAtom, useAtomValue } from 'jotai';
import type { FC, HTMLAttributes, PropsWithChildren } from 'react';
import {
forwardRef,
@@ -24,11 +28,7 @@ import { HeaderShareMenu } from './header-right-items/share-menu';
import SyncUser from './header-right-items/sync-user';
import TrashButtonGroup from './header-right-items/trash-button-group';
import UserAvatar from './header-right-items/user-avatar';
import {
StyledHeader,
StyledHeaderContainer,
StyledHeaderRightSide,
} from './styles';
import * as styles from './styles.css';
import { OSWarningMessage, shouldShowWarning } from './utils';
const SidebarSwitch = lazy(() =>
@@ -53,6 +53,9 @@ export const enum HeaderRightItemName {
ShareMenu = 'shareMenu',
EditPage = 'editPage',
UserAvatar = 'userAvatar',
// some windows only items
WindowsAppControls = 'windowsAppControls',
}
type HeaderItem = {
@@ -67,6 +70,7 @@ type HeaderItem = {
}
) => boolean;
};
const HeaderRightItems: Record<HeaderRightItemName, HeaderItem> = {
[HeaderRightItemName.TrashButtonGroup]: {
Component: TrashButtonGroup,
@@ -104,6 +108,44 @@ const HeaderRightItems: Record<HeaderRightItemName, HeaderItem> = {
return !isPublic && !isPreview;
},
},
[HeaderRightItemName.WindowsAppControls]: {
Component: () => {
return (
<div className={styles.windowAppControlsWrapper}>
<button
data-type="minimize"
className={styles.windowAppControl}
onClick={() => {
window.apis?.ui.handleMinimizeApp();
}}
>
<MinusIcon />
</button>
<button
data-type="maximize"
className={styles.windowAppControl}
onClick={() => {
window.apis?.ui.handleMaximizeApp();
}}
>
<RoundedRectangleIcon />
</button>
<button
data-type="close"
className={styles.windowAppControl}
onClick={() => {
window.apis?.ui.handleCloseApp();
}}
>
<CloseIcon />
</button>
</div>
);
},
availableWhen: () => {
return environment.isDesktop && environment.isWindows;
},
},
};
export type HeaderProps = BaseHeaderProps;
@@ -122,15 +164,19 @@ export const Header = forwardRef<
setShowWarning(shouldShowWarning());
setShowGuideDownloadClientTip(shouldShowGuideDownloadClientTip);
}, [shouldShowGuideDownloadClientTip]);
const [open] = useAtom(appSidebarOpenAtom);
const open = useAtomValue(appSidebarOpenAtom);
const t = useAFFiNEI18N();
const appSidebarFloating = useAtomValue(appSidebarFloatingAtom);
const mode = useCurrentMode();
return (
<StyledHeaderContainer
<div
className={styles.headerContainer}
ref={ref}
hasWarning={showWarning}
data-has-warning={showWarning}
data-open={open}
data-sidebar-floating={appSidebarFloating}
{...props}
>
{showGuideDownloadClientTip ? (
@@ -145,11 +191,11 @@ export const Header = forwardRef<
/>
)}
<StyledHeader
hasWarning={showWarning}
<div
className={styles.header}
data-has-warning={showWarning}
data-testid="editor-header-items"
data-tauri-drag-region
isEdgeless={mode === 'edgeless'}
data-is-edgeless={mode === 'edgeless'}
>
<Suspense>
<SidebarSwitch
@@ -160,7 +206,7 @@ export const Header = forwardRef<
</Suspense>
{props.children}
<StyledHeaderRightSide>
<div className={styles.headerRightSide}>
{useMemo(() => {
return Object.entries(HeaderRightItems).map(
([name, { availableWhen, Component }]) => {
@@ -184,10 +230,9 @@ export const Header = forwardRef<
}
);
}, [props])}
{/*<ShareMenu />*/}
</StyledHeaderRightSide>
</StyledHeader>
</StyledHeaderContainer>
</div>
</div>
</div>
);
});

View File

@@ -1,99 +1,43 @@
import type { PopperProps } from '@affine/component';
import { QuickSearchTips } from '@affine/component';
import { getEnvironment } from '@affine/env';
import { ArrowDownSmallIcon } from '@blocksuite/icons';
import { assertExists } from '@blocksuite/store';
import { useBlockSuitePageMeta } from '@toeverything/hooks/use-block-suite-page-meta';
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import type { HTMLAttributes, PropsWithChildren } from 'react';
import { forwardRef, useCallback, useRef } from 'react';
import { useSetAtom } from 'jotai';
import type {
FC,
HTMLAttributes,
PropsWithChildren,
ReactElement,
} from 'react';
import { useRef } from 'react';
import { currentEditorAtom, openQuickSearchModalAtom } from '../../../atoms';
import { guideQuickSearchTipsAtom } from '../../../atoms/guide';
import { useElementResizeEffect } from '../../../hooks/use-workspaces';
import { openQuickSearchModalAtom } from '../../../atoms';
import { QuickSearchButton } from '../../pure/quick-search-button';
import { EditorModeSwitch } from './editor-mode-switch';
import type { BaseHeaderProps } from './header';
import { Header } from './header';
import {
StyledQuickSearchTipButton,
StyledQuickSearchTipContent,
StyledSearchArrowWrapper,
StyledSwitchWrapper,
StyledTitle,
StyledTitleContainer,
StyledTitleWrapper,
} from './styles';
import * as styles from './styles.css';
export type WorkspaceHeaderProps = BaseHeaderProps;
export const WorkspaceHeader = forwardRef<
HTMLDivElement,
export const WorkspaceHeader: FC<
PropsWithChildren<WorkspaceHeaderProps> & HTMLAttributes<HTMLDivElement>
>((props, ref) => {
> = (props): ReactElement => {
const { workspace, currentPage, children, isPublic } = props;
// fixme(himself65): remove this atom and move it to props
const setOpenQuickSearch = useSetAtom(openQuickSearchModalAtom);
const pageMeta = useBlockSuitePageMeta(workspace.blockSuiteWorkspace).find(
meta => meta.id === currentPage?.id
);
const headerRef = useRef<HTMLDivElement>(null);
assertExists(pageMeta);
const title = pageMeta.title;
const isMac = () => {
const env = getEnvironment();
return env.isBrowser && env.isMacOs;
};
const popperRef: PopperProps['popperRef'] = useRef(null);
const [showQuickSearchTips, setShowQuickSearchTips] = useAtom(
guideQuickSearchTipsAtom
);
useElementResizeEffect(
useAtomValue(currentEditorAtom),
useCallback(() => {
if (showQuickSearchTips || !popperRef.current) {
return;
}
popperRef.current.update();
}, [showQuickSearchTips])
);
const TipsContent = (
<StyledQuickSearchTipContent>
<div>
Click button
{
<span
style={{
fontSize: '24px',
verticalAlign: 'middle',
}}
>
<ArrowDownSmallIcon />
</span>
}
or use
{isMac() ? ' ⌘ + K' : ' Ctrl + K'} to activate Quick Search. Then you
can search keywords or quickly open recently viewed pages.
</div>
<StyledQuickSearchTipButton
data-testid="quick-search-got-it"
onClick={() => setShowQuickSearchTips(false)}
>
Got it
</StyledQuickSearchTipButton>
</StyledQuickSearchTipContent>
);
return (
<Header ref={ref} {...props}>
<Header ref={headerRef} {...props}>
{children}
{!isPublic && currentPage && (
<StyledTitleContainer data-tauri-drag-region>
<StyledTitleWrapper>
<StyledSwitchWrapper>
<div className={styles.titleContainer}>
<div className={styles.titleWrapper}>
<div className={styles.switchWrapper}>
<EditorModeSwitch
blockSuiteWorkspace={workspace.blockSuiteWorkspace}
pageId={currentPage.id}
@@ -101,29 +45,21 @@ export const WorkspaceHeader = forwardRef<
marginRight: '12px',
}}
/>
</StyledSwitchWrapper>
<StyledTitle>{title || 'Untitled'}</StyledTitle>
<QuickSearchTips
data-testid="quick-search-tips"
content={TipsContent}
placement="bottom"
popperRef={popperRef}
open={showQuickSearchTips}
offset={[0, -5]}
>
<StyledSearchArrowWrapper>
<QuickSearchButton
onClick={() => {
setOpenQuickSearch(true);
}}
/>
</StyledSearchArrowWrapper>
</QuickSearchTips>
</StyledTitleWrapper>
</StyledTitleContainer>
</div>
<div className={styles.title}>{title || 'Untitled'}</div>
<div className={styles.searchArrowWrapper}>
<QuickSearchButton
onClick={() => {
setOpenQuickSearch(true);
}}
/>
</div>
</div>
</div>
)}
</Header>
);
});
};
WorkspaceHeader.displayName = 'BlockSuiteEditorHeader';

View File

@@ -0,0 +1,207 @@
import type { ComplexStyleRule } from '@vanilla-extract/css';
import { style } from '@vanilla-extract/css';
export const headerContainer = style({
height: '52px',
flexShrink: 0,
position: 'sticky',
top: 0,
background: 'var(--affine-background-primary-color)',
zIndex: 'var(--affine-z-index-popover)',
selectors: {
'&[data-has-warning="true"]': {
height: '96px',
},
'&[data-sidebar-floating="false"]': {
WebkitAppRegion: 'drag',
},
},
} as ComplexStyleRule);
export const header = style({
flexShrink: 0,
height: '52px',
width: '100%',
padding: '0 20px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
background: 'var(--affine-background-primary-color)',
zIndex: 99,
position: 'relative',
selectors: {
'&[data-is-edgeless="true"]': {
borderBottom: `1px solid var(--affine-border-color)`,
},
},
});
export const titleContainer = style({
width: '100%',
height: '100%',
margin: 'auto',
position: 'absolute',
inset: 'auto auto auto 50%',
transform: 'translate(-50%, 0px)',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
alignContent: 'unset',
fontSize: 'var(--affine-font-base)',
});
export const title = style({
maxWidth: '620px',
transition: 'max-width .15s',
userSelect: 'none',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
'@media': {
'(max-width: 768px)': {
selectors: {
'&[data-open="true"]': {
WebkitAppRegion: 'no-drag',
},
},
},
},
} as ComplexStyleRule);
export const titleWrapper = style({
height: '100%',
position: 'relative',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
});
export const headerRightSide = style({
height: '100%',
display: 'flex',
alignItems: 'center',
gap: '12px',
zIndex: 1,
});
export const browserWarning = style({
backgroundColor: 'var(--affine-background-warning-color)',
color: 'var(--affine-warning-color)',
height: '36px',
fontSize: 'var(--affine-font-sm)',
display: 'none',
justifyContent: 'center',
alignItems: 'center',
selectors: {
'&[data-show="true"]': {
display: 'flex',
},
},
});
export const closeButton = style({
width: '36px',
height: '36px',
color: 'var(--affine-icon-color)',
cursor: 'pointer',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
position: 'absolute',
right: '15px',
top: 0,
});
export const switchWrapper = style({
position: 'absolute',
right: '100%',
top: 0,
bottom: 0,
margin: 'auto',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
});
export const searchArrowWrapper = style({
position: 'absolute',
left: 'calc(100% + 4px)',
top: 0,
bottom: 0,
margin: 'auto',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
});
export const pageListTitleWrapper = style({
fontSize: 'var(--affine-font-base)',
color: 'var(--affine-text-primary-color)',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
});
export const pageListTitleIcon = style({
fontSize: '20px',
height: '1em',
marginRight: '12px',
});
export const quickSearchTipButton = style({
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
marginTop: '12px',
color: '#FFFFFF',
width: '48px',
height: ' 26px',
fontSize: 'var(--affine-font-sm)',
lineHeight: '22px',
background: 'var(--affine-primary-color)',
borderRadius: '8px',
textAlign: 'center',
cursor: 'pointer',
});
export const quickSearchTipContent = style({
display: 'flex',
justifyContent: 'center',
alignItems: 'flex-end',
flexDirection: 'column',
});
export const horizontalDivider = style({
width: '100%',
borderTop: `1px solid var(--affine-border-color)`,
});
export const horizontalDividerContainer = style({
width: '100%',
padding: '14px',
});
export const windowAppControlsWrapper = style({
display: 'flex',
gap: '2px',
transform: 'translateX(8px)',
});
export const windowAppControl = style({
WebkitAppRegion: 'no-drag',
cursor: 'pointer',
display: 'inline-flex',
width: '32px',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '2px',
selectors: {
'&[data-type="close"]:hover': {
background: 'var(--affine-error-color)',
color: '#FFFFFF',
},
'&:hover': {
background: 'var(--affine-background-tertiary-color)',
},
},
} as ComplexStyleRule);

View File

@@ -1,186 +0,0 @@
import {
absoluteCenter,
displayFlex,
styled,
textEllipsis,
} from '@affine/component';
export const StyledHeaderContainer = styled('div')<{
hasWarning: boolean;
}>(({ hasWarning }) => {
return {
height: hasWarning ? '96px' : '52px',
flexShrink: 0,
position: 'sticky',
top: 0,
background: 'var(--affine-background-primary-color)',
WebkitAppRegion: 'drag',
zIndex: 'var(--affine-z-index-popover)',
'@media (max-width: 768px)': {
'&[data-open="true"]': {
WebkitAppRegion: 'no-drag',
},
},
};
});
export const StyledHeader = styled('div')<{
hasWarning: boolean;
isEdgeless: boolean;
}>(({ isEdgeless }) => {
return {
flexShrink: 0,
height: '52px',
width: '100%',
padding: '0 20px',
...displayFlex('space-between', 'center'),
background: 'var(--affine-background-primary-color)',
zIndex: 99,
position: 'relative',
borderBottom: isEdgeless ? '1px solid var(--affine-border-color)' : 'none',
};
});
export const StyledTitleContainer = styled('div')(() => ({
width: '100%',
height: '100%',
margin: 'auto',
...absoluteCenter({ horizontal: true, position: { top: 0 } }),
...displayFlex('center', 'center'),
fontSize: 'var(--affine-font-base)',
}));
export const StyledTitle = styled('div')(({ theme }) => {
return {
maxWidth: '620px',
[theme.breakpoints.down('lg')]: {
maxWidth: '480px',
},
[theme.breakpoints.down('md')]: {
maxWidth: '240px',
},
[theme.breakpoints.down('sm')]: {
maxWidth: '180px',
},
transition: 'max-width .15s',
userSelect: 'none',
...textEllipsis(1),
};
});
export const StyledTitleWrapper = styled('div')({
height: '100%',
position: 'relative',
...displayFlex('center', 'center'),
});
export const StyledHeaderRightSide = styled('div')({
height: '100%',
display: 'flex',
alignItems: 'center',
'>*:not(:last-child)': {
marginRight: '12px',
},
});
export const StyledBrowserWarning = styled('div')<{ show: boolean }>(
({ show }) => {
return {
backgroundColor: 'var(--affine-background-warning-color)',
color: 'var(--affine-background-warning-color)',
height: '36px',
fontSize: 'var(--affine-font-sm)',
display: show ? 'flex' : 'none',
justifyContent: 'center',
alignItems: 'center',
};
}
);
export const StyledCloseButton = styled('div')(() => {
return {
width: '36px',
height: '36px',
color: 'var(--affine-icon-color)',
cursor: 'pointer',
...displayFlex('center', 'center'),
position: 'absolute',
right: '15px',
top: '0',
svg: {
width: '15px',
height: '15px',
position: 'relative',
zIndex: 1,
},
};
});
export const StyledSwitchWrapper = styled('div')(() => {
return {
position: 'absolute',
right: '100%',
top: 0,
bottom: 0,
margin: 'auto',
...displayFlex('center', 'center'),
};
});
export const StyledSearchArrowWrapper = styled('div')(() => {
return {
position: 'absolute',
left: 'calc(100% + 4px)',
top: 0,
bottom: 0,
margin: 'auto',
...displayFlex('center', 'center'),
};
});
export const StyledPageListTittleWrapper = styled(StyledTitle)(() => {
return {
fontSize: 'var(--affine-font-base)',
color: 'var(--affine-text-primary-color)',
...displayFlex('center', 'center'),
'>svg': {
fontSize: '20px',
marginRight: '12px',
},
};
});
export const StyledQuickSearchTipButton = styled('div')(() => {
return {
...displayFlex('center', 'center'),
marginTop: '12px',
color: '#FFFFFF',
width: '48px',
height: ' 26px',
fontSize: 'var(--affine-font-sm)',
lineHeight: '22px',
background: 'var(--affine-primary-color)',
borderRadius: '8px',
textAlign: 'center',
cursor: 'pointer',
};
});
export const StyledQuickSearchTipContent = styled('div')(() => {
return {
...displayFlex('center', 'flex-end'),
flexDirection: 'column',
};
});
export const StyledHorizontalDivider = styled('div')(() => {
return {
width: '100%',
borderTop: `1px solid var(--affine-border-color)`,
};
});
export const StyledHorizontalDividerContainer = styled('div')(() => {
return {
width: '100%',
padding: '14px',
};
});

View File

@@ -1,3 +1,4 @@
import { PageNotFoundError } from '@affine/env/constant';
import type { EditorContainer } from '@blocksuite/editor';
import type { Page } from '@blocksuite/store';
import { assertExists } from '@blocksuite/store';
@@ -11,7 +12,6 @@ import { startTransition, useCallback } from 'react';
import { currentEditorAtom, workspacePreferredModeAtom } from '../atoms';
import type { AffineOfficialWorkspace } from '../shared';
import { PageNotFoundError } from './affine/affine-error-eoundary';
import { BlockSuiteEditor as Editor } from './blocksuite/block-suite-editor';
import { WorkspaceHeader } from './blocksuite/workspace-header';
@@ -84,6 +84,7 @@ export const PageDetailEditor: React.FC<PageDetailEditorProps> = ({
page.workspace.setPageMeta(page.id, {
updatedDate: Date.now(),
});
localStorage.setItem('last_page_id', page.id);
onLoad?.(page, editor);
},
[onLoad, setEditor]

View File

@@ -1,5 +1,5 @@
import { MuiFade, Tooltip } from '@affine/component';
import { getEnvironment } from '@affine/env';
import { config, getEnvironment } from '@affine/env';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { CloseIcon, NewIcon } from '@blocksuite/icons';
import { useAtom } from 'jotai';
@@ -74,10 +74,7 @@ export const HelpIsland = ({
<StyledIconWrapper
data-testid="right-bottom-change-log-icon"
onClick={() => {
window.open(
'https://github.com/toeverything/AFFiNE/releases',
'_blank'
);
window.open(config.changelogUrl, '_blank');
}}
>
<NewIcon />
@@ -111,7 +108,10 @@ export const HelpIsland = ({
</Tooltip>
)}
{showList.includes('guide') && (
<Tooltip content={'Easy Guide'} placement="left-end">
<Tooltip
content={t['com.affine.helpIsland.gettingStarted']()}
placement="left-end"
>
<StyledIconWrapper
data-testid="easy-guide"
onClick={() => {

View File

@@ -1,6 +1,4 @@
import { styled } from '@affine/component';
import { AffineLoading } from '@affine/component/affine-loading';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { memo, Suspense } from 'react';
export const Loading = memo(function Loading() {
@@ -18,31 +16,9 @@ export const Loading = memo(function Loading() {
);
});
// Used for the full page loading
const StyledLoadingContainer = styled('div')(() => {
return {
height: '100vh',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
color: '#6880FF',
flexDirection: 'column',
h1: {
fontSize: '2em',
marginTop: '15px',
fontWeight: '600',
},
};
});
export const PageLoading = ({ text }: { text?: string }) => {
const t = useAFFiNEI18N();
return (
<StyledLoadingContainer>
<Loading />
<h1>{text ? text : t['Loading']()}</h1>
</StyledLoadingContainer>
);
/**
* @deprecated use skeleton instead
*/
export const PageLoading = () => {
return null;
};
export default PageLoading;

View File

@@ -1,7 +1,6 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import {
DeleteTemporarilyIcon,
FavoriteIcon,
FolderIcon,
SettingsIcon,
} from '@blocksuite/icons';
@@ -24,11 +23,6 @@ export const useSwitchToConfig = (
href: pathGenerator.all(workspaceId),
icon: FolderIcon,
},
{
title: t['Favorites'](),
href: pathGenerator.favorite(workspaceId),
icon: FavoriteIcon,
},
{
title: t['Workspace Settings'](),
href: pathGenerator.setting(workspaceId),

View File

@@ -9,7 +9,10 @@ import {
import { WorkspaceList } from '@affine/component/workspace-list';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import type { AccessTokenMessage } from '@affine/workspace/affine/login';
import type { AffineWorkspace, LocalWorkspace } from '@affine/workspace/type';
import type {
AffineLegacyCloudWorkspace,
LocalWorkspace,
} from '@affine/workspace/type';
import { WorkspaceFlavour } from '@affine/workspace/type';
import { HelpIcon, ImportIcon, PlusIcon } from '@blocksuite/icons';
import type { DragEndEvent } from '@dnd-kit/core';
@@ -109,7 +112,7 @@ export const WorkspaceListModal = ({
items={
workspaces.filter(
({ flavour }) => flavour !== WorkspaceFlavour.PUBLIC
) as (AffineWorkspace | LocalWorkspace)[]
) as (AffineLegacyCloudWorkspace | LocalWorkspace)[]
}
currentWorkspaceId={currentWorkspaceId}
onClick={onClickWorkspace}
@@ -145,7 +148,7 @@ export const WorkspaceListModal = ({
{environment.isDesktop && (
<Menu
placement="auto"
trigger={['click', 'hover']}
trigger={['click']}
zIndex={1000}
content={
<StyledCreateWorkspaceCardPillContainer>

View File

@@ -62,6 +62,9 @@ export const StyledCreateWorkspaceCard = styled('div')(() => {
color: 'var(--affine-primary-color)',
},
},
'@media (max-width: 720px)': {
width: '100%',
},
};
});
export const StyledCreateWorkspaceCardPillContainer = styled('div')(() => {

View File

@@ -6,7 +6,7 @@ export const StyledSelectorContainer = styled('div')(() => {
display: 'flex',
alignItems: 'center',
padding: '0 6px',
marginBottom: '16px',
margin: '0 -6px',
borderRadius: '8px',
color: 'var(--affine-text-primary-color)',
':hover': {

View File

@@ -1,13 +1,10 @@
import { MenuItem } from '@affine/component/app-sidebar';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { StyledCollapseItem } from '../shared-styles';
export const EmptyItem = () => {
const t = useAFFiNEI18N();
return (
<StyledCollapseItem disable={true} textWrap={true}>
{t['Favorite pages for easy access']()}
</StyledCollapseItem>
<MenuItem disabled={true}>{t['Favorite pages for easy access']()}</MenuItem>
);
};

View File

@@ -1,66 +1,107 @@
import { MuiCollapse } from '@affine/component';
import { MenuLinkItem } from '@affine/component/app-sidebar';
import { EdgelessIcon, PageIcon } from '@blocksuite/icons';
import type { PageMeta, Workspace } from '@blocksuite/store';
import { useBlockSuitePageMeta } from '@toeverything/hooks/use-block-suite-page-meta';
import { useBlockSuitePageReferences } from '@toeverything/hooks/use-block-suite-page-references';
import { useAtomValue } from 'jotai';
import { useRouter } from 'next/router';
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import { workspacePreferredModeAtom } from '../../../../atoms';
import type { FavoriteListProps } from '../index';
import { StyledCollapseItem } from '../shared-styles';
import EmptyItem from './empty-item';
export const FavoriteList = ({
pageMeta,
openPage,
showList,
}: FavoriteListProps) => {
import * as styles from './styles.css';
interface FavoriteMenuItemProps {
workspace: Workspace;
pageId: string;
metaMapping: Record<string, PageMeta>;
parentIds: Set<string>;
}
function FavoriteMenuItem({
workspace,
pageId,
metaMapping,
parentIds,
}: FavoriteMenuItemProps) {
const router = useRouter();
const record = useAtomValue(workspacePreferredModeAtom);
const active = router.query.pageId === pageId;
const icon = record[pageId] === 'edgeless' ? <EdgelessIcon /> : <PageIcon />;
const references = useBlockSuitePageReferences(workspace, pageId);
const referencesToShow = useMemo(() => {
return [...new Set(references.filter(ref => !parentIds.has(ref)))];
}, [references, parentIds]);
const [collapsed, setCollapsed] = useState(true);
const collapsible = referencesToShow.length > 0;
const showReferences = collapsible ? !collapsed : referencesToShow.length > 0;
const nestedItem = parentIds.size > 0;
const untitled = !metaMapping[pageId]?.title;
return (
<div className={styles.favItemWrapper} data-nested={nestedItem}>
<MenuLinkItem
data-type="favorite-list-item"
data-testid={`favorite-list-item-${pageId}`}
active={active}
href={`/workspace/${workspace.id}/${pageId}`}
icon={icon}
collapsed={collapsible ? collapsed : undefined}
onCollapsedChange={setCollapsed}
>
<span className={styles.label} data-untitled={untitled}>
{metaMapping[pageId]?.title || 'Untitled'}
</span>
</MenuLinkItem>
{showReferences &&
referencesToShow.map(ref => {
return (
<FavoriteMenuItem
key={ref}
workspace={workspace}
pageId={ref}
metaMapping={metaMapping}
parentIds={new Set([...parentIds, pageId])}
/>
);
})}
</div>
);
}
export const FavoriteList = ({ currentWorkspace }: FavoriteListProps) => {
const metas = useBlockSuitePageMeta(currentWorkspace.blockSuiteWorkspace);
const favoriteList = useMemo(
() => pageMeta.filter(p => p.favorite && !p.trash),
[pageMeta]
() => metas.filter(p => p.favorite && !p.trash),
[metas]
);
const metaMapping = useMemo(
() =>
metas.reduce((acc, meta) => {
acc[meta.id] = meta;
return acc;
}, {} as Record<string, PageMeta>),
[metas]
);
return (
<MuiCollapse
in={showList}
style={{
maxHeight: 300,
overflowY: 'auto',
marginLeft: '16px',
}}
>
<>
{favoriteList.map((pageMeta, index) => {
const active = router.query.pageId === pageMeta.id;
return (
<div key={`${pageMeta}-${index}`}>
<StyledCollapseItem
data-testid={`favorite-list-item-${pageMeta.id}`}
active={active}
ref={ref => {
if (ref && active) {
ref.scrollIntoView({ behavior: 'smooth' });
}
}}
onClick={() => {
if (active) {
return;
}
openPage(pageMeta.id);
}}
>
{record[pageMeta.id] === 'edgeless' ? (
<EdgelessIcon />
) : (
<PageIcon />
)}
<span>{pageMeta.title || 'Untitled'}</span>
</StyledCollapseItem>
</div>
<FavoriteMenuItem
key={`${pageMeta}-${index}`}
metaMapping={metaMapping}
pageId={pageMeta.id}
// memo?
parentIds={new Set()}
workspace={currentWorkspace.blockSuiteWorkspace}
/>
);
})}
{favoriteList.length === 0 && <EmptyItem />}
</MuiCollapse>
</>
);
};

View File

@@ -1,66 +1 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { ArrowDownSmallIcon, FavoriteIcon } from '@blocksuite/icons';
import { useBlockSuitePageMeta } from '@toeverything/hooks/use-block-suite-page-meta';
import { useCallback, useState } from 'react';
import type { AllWorkspace } from '../../../../shared';
import type { WorkSpaceSliderBarProps } from '../index';
import { StyledCollapseButton, StyledListItem } from '../shared-styles';
import { StyledLink } from '../style';
import FavoriteList from './favorite-list';
export const Favorite = ({
currentPath,
paths,
currentPageId,
openPage,
currentWorkspace,
}: Pick<
WorkSpaceSliderBarProps,
'currentPath' | 'paths' | 'currentPageId' | 'openPage'
> & {
currentWorkspace: AllWorkspace;
}) => {
const currentWorkspaceId = currentWorkspace.id;
const pageMeta = useBlockSuitePageMeta(currentWorkspace.blockSuiteWorkspace);
const [showSubFavorite, setOpenSubFavorite] = useState(true);
const t = useAFFiNEI18N();
return (
<>
<StyledListItem
active={
currentPath ===
(currentWorkspaceId && paths.favorite(currentWorkspaceId))
}
>
<StyledLink
href={{
pathname: currentWorkspaceId && paths.favorite(currentWorkspaceId),
}}
>
<FavoriteIcon />
{t['Favorites']()}
</StyledLink>
<StyledCollapseButton
onClick={useCallback(() => {
setOpenSubFavorite(!showSubFavorite);
}, [showSubFavorite])}
collapse={showSubFavorite}
>
<ArrowDownSmallIcon />
</StyledCollapseButton>
</StyledListItem>
<FavoriteList
currentPageId={currentPageId}
showList={showSubFavorite}
openPage={openPage}
pageMeta={pageMeta}
/>
</>
);
};
export default Favorite;
export * from './favorite-list';

View File

@@ -0,0 +1,21 @@
import { style } from '@vanilla-extract/css';
export const label = style({
selectors: {
'&[data-untitled="true"]': {
opacity: 0.6,
},
},
});
export const favItemWrapper = style({
display: 'flex',
flexDirection: 'column',
gap: '4px',
selectors: {
'&[data-nested="true"]': {
marginLeft: '12px',
width: 'calc(100% - 12px)',
},
},
});

View File

@@ -1,13 +0,0 @@
export const Arrow = () => {
return (
<svg
width="6"
height="10"
viewBox="0 0 6 10"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M0.354 9.22997C0.201333 9.0773 0.125 8.91764 0.125 8.75097C0.125 8.5843 0.201333 8.42464 0.354 8.27197L3.625 5.00097L0.354 1.72997C0.201333 1.5773 0.125 1.41764 0.125 1.25097C0.125 1.0843 0.201333 0.924636 0.354 0.771969C0.506667 0.619302 0.666333 0.542969 0.833 0.542969C0.999667 0.542969 1.15933 0.619302 1.312 0.771969L4.979 4.43897C5.06233 4.52164 5.125 4.61164 5.167 4.70897C5.20833 4.8063 5.229 4.90364 5.229 5.00097C5.229 5.0983 5.20833 5.19564 5.167 5.29297C5.125 5.3903 5.06233 5.4803 4.979 5.56297L1.312 9.22997C1.15933 9.38264 0.999667 9.45897 0.833 9.45897C0.666333 9.45897 0.506667 9.38264 0.354 9.22997Z" />
</svg>
);
};

View File

@@ -1,12 +1,9 @@
import type { Page, PageMeta } from '@blocksuite/store';
import type { Page } from '@blocksuite/store';
import type { AllWorkspace } from '../../../shared';
export type FavoriteListProps = {
currentPageId: string | null;
openPage: (pageId: string) => void;
showList: boolean;
pageMeta: PageMeta[];
currentWorkspace: AllWorkspace;
};
export type WorkSpaceSliderBarProps = {

View File

@@ -1,31 +0,0 @@
import { IconButton } from '@affine/component';
import { ArrowLeftSmallIcon, ArrowRightSmallIcon } from '@blocksuite/icons';
import { StyledRouteNavigationWrapper } from './shared-styles';
export const RouteNavigation = () => {
if (!environment.isDesktop) {
return <></>;
}
return (
<StyledRouteNavigationWrapper>
<IconButton
size="middle"
onClick={() => {
window.history.back();
}}
>
<ArrowLeftSmallIcon />
</IconButton>
<IconButton
size="middle"
onClick={() => {
window.history.forward();
}}
style={{ marginLeft: '32px' }}
>
<ArrowRightSmallIcon />
</IconButton>
</StyledRouteNavigationWrapper>
);
};

View File

@@ -5,7 +5,7 @@ import type React from 'react';
import { openQuickSearchModalAtom } from '../../../atoms';
import type { HeaderProps } from '../../blocksuite/workspace-header/header';
import { Header } from '../../blocksuite/workspace-header/header';
import { StyledPageListTittleWrapper } from '../../blocksuite/workspace-header/styles';
import * as styles from '../../blocksuite/workspace-header/styles.css';
import { QuickSearchButton } from '../quick-search-button';
export type WorkspaceTitleProps = React.PropsWithChildren<
@@ -22,15 +22,15 @@ export const WorkspaceTitle: React.FC<WorkspaceTitleProps> = ({
const setOpenQuickSearch = useSetAtom(openQuickSearchModalAtom);
return (
<Header {...props}>
<StyledPageListTittleWrapper>
{icon}
<div className={styles.pageListTitleWrapper}>
<div className={styles.pageListTitleIcon}>{icon}</div>
{children}
<QuickSearchButton
onClick={() => {
setOpenQuickSearch(true);
}}
/>
</StyledPageListTittleWrapper>
</div>
</Header>
);
};

View File

@@ -1,7 +1,13 @@
import {
AddPageButton,
AppSidebar,
appSidebarOpenAtom,
ResizeIndicator,
AppUpdaterButton,
CategoryDivider,
MenuLinkItem,
QuickSearchInput,
SidebarContainer,
SidebarScrollableContainer,
} from '@affine/component/app-sidebar';
import { config } from '@affine/env';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
@@ -9,27 +15,18 @@ import { WorkspaceFlavour } from '@affine/workspace/type';
import {
DeleteTemporarilyIcon,
FolderIcon,
PlusIcon,
SearchIcon,
SettingsIcon,
ShareIcon,
} from '@blocksuite/icons';
import type { Page } from '@blocksuite/store';
import { useAtomValue } from 'jotai';
import type { ReactElement, UIEvent } from 'react';
import { useAtom } from 'jotai';
import type { ReactElement } from 'react';
import type React from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo } from 'react';
import { useHistoryAtom } from '../../atoms/history';
import type { AllWorkspace } from '../../shared';
import ChangeLog from '../pure/workspace-slider-bar/changeLog';
import Favorite from '../pure/workspace-slider-bar/favorite';
import { StyledListItem } from '../pure/workspace-slider-bar/shared-styles';
import {
StyledLink,
StyledNewPageButton,
StyledScrollWrapper,
StyledSliderBarInnerWrapper,
} from '../pure/workspace-slider-bar/style';
import FavoriteList from '../pure/workspace-slider-bar/favorite/favorite-list';
import { WorkspaceSelector } from '../pure/workspace-slider-bar/WorkspaceSelector';
export type RootAppSidebarProps = {
@@ -37,19 +34,37 @@ export type RootAppSidebarProps = {
onOpenQuickSearchModal: () => void;
onOpenWorkspaceListModal: () => void;
currentWorkspace: AllWorkspace | null;
currentPageId: string | null;
openPage: (pageId: string) => void;
createPage: () => Page;
currentPath: string;
paths: {
all: (workspaceId: string) => string;
favorite: (workspaceId: string) => string;
trash: (workspaceId: string) => string;
setting: (workspaceId: string) => string;
shared: (workspaceId: string) => string;
};
};
const RouteMenuLinkItem = ({
currentPath,
path,
icon,
children,
...props
}: {
currentPath: string; // todo: pass through useRouter?
path?: string | null;
icon: ReactElement;
children?: ReactElement;
} & React.HTMLAttributes<HTMLDivElement>) => {
const active = currentPath === path;
return (
<MenuLinkItem {...props} active={active} href={path ?? ''} icon={icon}>
{children}
</MenuLinkItem>
);
};
/**
* This is for the whole affine app sidebar.
* This component wraps the app sidebar in `@affine/component` with logic and data.
@@ -58,7 +73,6 @@ export type RootAppSidebarProps = {
*/
export const RootAppSidebar = ({
currentWorkspace,
currentPageId,
openPage,
createPage,
currentPath,
@@ -69,171 +83,118 @@ export const RootAppSidebar = ({
const currentWorkspaceId = currentWorkspace?.id || null;
const blockSuiteWorkspace = currentWorkspace?.blockSuiteWorkspace;
const t = useAFFiNEI18N();
const [isScrollAtTop, setIsScrollAtTop] = useState(true);
const onClickNewPage = useCallback(async () => {
const page = await createPage();
openPage(page.id);
}, [createPage, openPage]);
const sidebarOpen = useAtomValue(appSidebarOpenAtom);
// Listen to the "New Page" action from the menu
useEffect(() => {
if (environment.isDesktop) {
return window.events?.applicationMenu.onNewPageAction(onClickNewPage);
}
}, [onClickNewPage]);
const [sidebarOpen, setSidebarOpen] = useAtom(appSidebarOpenAtom);
useEffect(() => {
if (environment.isDesktop && typeof sidebarOpen === 'boolean') {
window.apis?.ui.handleSidebarVisibilityChange(sidebarOpen);
}
}, [sidebarOpen]);
const [ref, setRef] = useState<HTMLElement | null>(null);
const handleQuickSearchButtonKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onOpenQuickSearchModal();
useEffect(() => {
const keydown = (e: KeyboardEvent) => {
if ((e.key === '/' && e.metaKey) || (e.key === '/' && e.ctrlKey)) {
setSidebarOpen(!sidebarOpen);
}
},
[onOpenQuickSearchModal]
);
};
document.addEventListener('keydown', keydown, { capture: true });
return () =>
document.removeEventListener('keydown', keydown, { capture: true });
}, [sidebarOpen, setSidebarOpen]);
const [history, setHistory] = useHistoryAtom();
const router = useMemo(() => {
return {
forward: () => {
setHistory(true);
},
back: () => {
setHistory(false);
},
history,
};
}, [history, setHistory]);
return (
<>
<AppSidebar
ref={setRef}
footer={
<StyledNewPageButton
data-testid="new-page-button"
onClick={onClickNewPage}
>
<PlusIcon /> {t['New Page']()}
</StyledNewPageButton>
}
>
<StyledSliderBarInnerWrapper data-testid="sliderBar-inner">
<AppSidebar router={router}>
<SidebarContainer>
<WorkspaceSelector
currentWorkspace={currentWorkspace}
onClick={onOpenWorkspaceListModal}
/>
<ChangeLog />
<StyledListItem
<QuickSearchInput
data-testid="slider-bar-quick-search-button"
onClick={useCallback(() => {
onOpenQuickSearchModal();
}, [onOpenQuickSearchModal])}
onKeyDown={handleQuickSearchButtonKeyDown}
onClick={onOpenQuickSearchModal}
/>
<RouteMenuLinkItem
icon={<FolderIcon />}
currentPath={currentPath}
path={currentWorkspaceId && paths.all(currentWorkspaceId)}
>
<div
role="button"
tabIndex={0}
style={{
display: 'flex',
flex: 1,
alignItems: 'center',
justifyContent: 'flex-start',
}}
>
<SearchIcon />
{t['Quick search']()}
</div>
</StyledListItem>
<StyledListItem
active={
currentPath ===
(currentWorkspaceId && paths.setting(currentWorkspaceId))
}
<span data-testid="all-pages">{t['All pages']()}</span>
</RouteMenuLinkItem>
<RouteMenuLinkItem
data-testid="slider-bar-workspace-setting-button"
style={{
marginBottom: '16px',
}}
icon={<SettingsIcon />}
currentPath={currentPath}
path={currentWorkspaceId && paths.setting(currentWorkspaceId)}
>
<StyledLink
href={{
pathname:
currentWorkspaceId && paths.setting(currentWorkspaceId),
}}
>
<SettingsIcon />
<div>{t['Workspace Settings']()}</div>
</StyledLink>
</StyledListItem>
<StyledListItem
active={
currentPath ===
(currentWorkspaceId && paths.all(currentWorkspaceId))
}
>
<StyledLink
href={{
pathname: currentWorkspaceId && paths.all(currentWorkspaceId),
}}
>
<FolderIcon />
<span data-testid="all-pages">{t['All pages']()}</span>
</StyledLink>
</StyledListItem>
<StyledScrollWrapper
showTopBorder={!isScrollAtTop}
onScroll={(e: UIEvent<HTMLDivElement>) => {
(e.target as HTMLDivElement).scrollTop === 0
? setIsScrollAtTop(true)
: setIsScrollAtTop(false);
}}
>
{blockSuiteWorkspace && (
<Favorite
currentPath={currentPath}
paths={paths}
currentPageId={currentPageId}
openPage={openPage}
currentWorkspace={currentWorkspace}
/>
)}
</StyledScrollWrapper>
<div style={{ height: 16 }}></div>
<span data-testid="settings">{t['Settings']()}</span>
</RouteMenuLinkItem>
</SidebarContainer>
<SidebarScrollableContainer>
<CategoryDivider label={t['Favorites']()} />
{blockSuiteWorkspace && (
<FavoriteList currentWorkspace={currentWorkspace} />
)}
{config.enableLegacyCloud &&
(currentWorkspace?.flavour === WorkspaceFlavour.AFFINE &&
currentWorkspace.public ? (
<StyledListItem>
<StyledLink
href={{
pathname:
currentWorkspaceId && paths.setting(currentWorkspaceId),
}}
>
<ShareIcon />
<span data-testid="Published-to-web">Published to web</span>
</StyledLink>
</StyledListItem>
) : (
<StyledListItem
active={
currentPath ===
(currentWorkspaceId && paths.shared(currentWorkspaceId))
}
<RouteMenuLinkItem
icon={<ShareIcon />}
currentPath={currentPath}
path={currentWorkspaceId && paths.setting(currentWorkspaceId)}
>
<StyledLink
href={{
pathname:
currentWorkspaceId && paths.shared(currentWorkspaceId),
}}
>
<ShareIcon />
<span data-testid="shared-pages">{t['Shared Pages']()}</span>
</StyledLink>
</StyledListItem>
<span data-testid="Published-to-web">Published to web</span>
</RouteMenuLinkItem>
) : (
<RouteMenuLinkItem
icon={<ShareIcon />}
currentPath={currentPath}
path={currentWorkspaceId && paths.shared(currentWorkspaceId)}
>
<span data-testid="shared-pages">{t['Shared Pages']()}</span>
</RouteMenuLinkItem>
))}
<StyledListItem
active={
currentPath ===
(currentWorkspaceId && paths.trash(currentWorkspaceId))
}
<CategoryDivider label={t['others']()} />
<RouteMenuLinkItem
icon={<DeleteTemporarilyIcon />}
currentPath={currentPath}
path={currentWorkspaceId && paths.trash(currentWorkspaceId)}
>
<StyledLink
href={{
pathname: currentWorkspaceId && paths.trash(currentWorkspaceId),
}}
>
<DeleteTemporarilyIcon /> {t['Trash']()}
</StyledLink>
</StyledListItem>
</StyledSliderBarInnerWrapper>
<span data-testid="trash-page">{t['Trash']()}</span>
</RouteMenuLinkItem>
</SidebarScrollableContainer>
<SidebarContainer>
{environment.isDesktop && <AppUpdaterButton />}
<div />
<AddPageButton onClick={onClickNewPage} />
</SidebarContainer>
</AppSidebar>
<ResizeIndicator targetElement={ref} />
</>
);
};

View File

@@ -5,11 +5,7 @@ import 'fake-indexeddb/auto';
import assert from 'node:assert';
import {
rootCurrentWorkspaceIdAtom,
rootWorkspacesMetadataAtom,
} from '@affine/workspace/atom';
import type { LocalWorkspace } from '@affine/workspace/type';
import { rootCurrentWorkspaceIdAtom } from '@affine/workspace/atom';
import { WorkspaceFlavour } from '@affine/workspace/type';
import type { PageBlockModel } from '@blocksuite/blocks';
import { __unstableSchemas, AffineSchemas } from '@blocksuite/blocks/models';
@@ -21,23 +17,17 @@ import {
usePageMetaHelper,
} from '@toeverything/hooks/use-block-suite-page-meta';
import { createStore, Provider } from 'jotai';
import { useRouter } from 'next/router';
import routerMock from 'next-router-mock';
import { createDynamicRouteParser } from 'next-router-mock/dynamic-routes';
import type React from 'react';
import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest';
import { currentWorkspaceIdAtom, workspacesAtom } from '../../atoms';
import { LocalPlugin } from '../../plugins/local';
import { workspacesAtom } from '../../atoms';
import { BlockSuiteWorkspace, WorkspaceSubPath } from '../../shared';
import {
currentWorkspaceAtom,
useCurrentWorkspace,
} from '../current/use-current-workspace';
import {
useRecentlyViewed,
useSyncRecentViewsWithRouter,
} from '../use-recent-views';
import { useAppHelper, useWorkspaces } from '../use-workspaces';
vi.mock(
@@ -202,6 +192,8 @@ 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);
@@ -221,58 +213,3 @@ describe('useWorkspaces', () => {
expect(firstWorkspace.blockSuiteWorkspace.meta.name).toBe('test');
});
});
describe('useRecentlyViewed', () => {
test('basic', async () => {
const { ProviderWrapper, store } = await getJotaiContext();
const workspaceId = blockSuiteWorkspace.id;
const pageId = 'page0';
store.set(rootWorkspacesMetadataAtom, [
{
id: workspaceId,
flavour: WorkspaceFlavour.LOCAL,
},
]);
LocalPlugin.CRUD.get = vi.fn().mockResolvedValue({
id: workspaceId,
flavour: WorkspaceFlavour.LOCAL,
blockSuiteWorkspace,
providers: [],
} satisfies LocalWorkspace);
store.set(currentWorkspaceIdAtom, blockSuiteWorkspace.id);
const workspace = await store.get(currentWorkspaceAtom);
expect(workspace?.id).toBe(blockSuiteWorkspace.id);
const currentHook = renderHook(() => useCurrentWorkspace(), {
wrapper: ProviderWrapper,
});
expect(currentHook.result.current[0]?.id).toEqual(workspaceId);
await store.get(currentWorkspaceAtom);
const recentlyViewedHook = renderHook(() => useRecentlyViewed(), {
wrapper: ProviderWrapper,
});
expect(recentlyViewedHook.result.current).toEqual([]);
const routerHook = renderHook(() => useRouter());
await routerHook.result.current.push({
pathname: '/workspace/[workspaceId]/[pageId]',
query: {
workspaceId,
pageId,
},
});
routerHook.rerender();
const syncHook = renderHook(
router => useSyncRecentViewsWithRouter(router, blockSuiteWorkspace),
{
wrapper: ProviderWrapper,
initialProps: routerHook.result.current,
}
);
syncHook.rerender(routerHook.result.current);
expect(recentlyViewedHook.result.current).toEqual([
{
id: 'page0',
mode: 'page',
},
]);
});
});

View File

@@ -0,0 +1,139 @@
/**
* @vitest-environment happy-dom
*/
import {
rootCurrentWorkspaceIdAtom,
rootWorkspacesMetadataAtom,
} from '@affine/workspace/atom';
import type { LocalWorkspace } from '@affine/workspace/type';
import { WorkspaceFlavour } from '@affine/workspace/type';
import { __unstableSchemas, AffineSchemas } from '@blocksuite/blocks/models';
import type { Page } from '@blocksuite/store';
import { assertExists } from '@blocksuite/store';
import { renderHook } from '@testing-library/react';
import { createStore, Provider } from 'jotai/index';
import { useRouter } from 'next/router';
import routerMock from 'next-router-mock';
import { createDynamicRouteParser } from 'next-router-mock/dynamic-routes';
import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest';
import { workspacesAtom } from '../../atoms';
import { LocalPlugin } from '../../plugins/local';
import { BlockSuiteWorkspace } from '../../shared';
import { WorkspaceSubPath } from '../../shared';
import {
currentWorkspaceAtom,
useCurrentWorkspace,
} from '../current/use-current-workspace';
import {
useRecentlyViewed,
useSyncRecentViewsWithRouter,
} from '../use-recent-views';
let blockSuiteWorkspace: BlockSuiteWorkspace;
beforeAll(() => {
routerMock.useParser(
createDynamicRouteParser([
`/workspace/[workspaceId/${WorkspaceSubPath.ALL}`,
`/workspace/[workspaceId/${WorkspaceSubPath.SETTING}`,
`/workspace/[workspaceId/${WorkspaceSubPath.TRASH}`,
`/workspace/[workspaceId/${WorkspaceSubPath.FAVORITE}`,
'/workspace/[workspaceId]/[pageId]',
])
);
});
async function getJotaiContext() {
const store = createStore();
const ProviderWrapper: React.FC<React.PropsWithChildren> =
function ProviderWrapper({ children }) {
return <Provider store={store}>{children}</Provider>;
};
const workspaces = await store.get(workspacesAtom);
expect(workspaces.length).toBe(0);
return {
store,
ProviderWrapper,
initialWorkspaces: workspaces,
} as const;
}
beforeEach(async () => {
blockSuiteWorkspace = new BlockSuiteWorkspace({ id: 'test' })
.register(AffineSchemas)
.register(__unstableSchemas);
const initPage = (page: Page) => {
expect(page).not.toBeNull();
assertExists(page);
const pageBlockId = page.addBlock('affine:page', {
title: new page.Text(''),
});
const frameId = page.addBlock('affine:frame', {}, pageBlockId);
page.addBlock('affine:paragraph', {}, frameId);
};
initPage(
blockSuiteWorkspace.createPage({
id: 'page0',
})
);
initPage(blockSuiteWorkspace.createPage({ id: 'page1' }));
initPage(blockSuiteWorkspace.createPage({ id: 'page2' }));
});
describe('useRecentlyViewed', () => {
test('basic', async () => {
const { ProviderWrapper, store } = await getJotaiContext();
const workspaceId = blockSuiteWorkspace.id;
const pageId = 'page0';
store.set(rootWorkspacesMetadataAtom, [
{
id: workspaceId,
flavour: WorkspaceFlavour.LOCAL,
},
]);
LocalPlugin.CRUD.get = vi.fn().mockResolvedValue({
id: workspaceId,
flavour: WorkspaceFlavour.LOCAL,
blockSuiteWorkspace,
providers: [],
} satisfies LocalWorkspace);
store.set(rootCurrentWorkspaceIdAtom, blockSuiteWorkspace.id);
const workspace = await store.get(currentWorkspaceAtom);
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);
const recentlyViewedHook = renderHook(() => useRecentlyViewed(), {
wrapper: ProviderWrapper,
});
expect(recentlyViewedHook.result.current).toEqual([]);
const routerHook = renderHook(() => useRouter(), {
wrapper: ProviderWrapper,
});
await routerHook.result.current.push({
pathname: '/workspace/[workspaceId]/[pageId]',
query: {
workspaceId,
pageId,
},
});
routerHook.rerender();
const syncHook = renderHook(
router => useSyncRecentViewsWithRouter(router, blockSuiteWorkspace),
{
wrapper: ProviderWrapper,
initialProps: routerHook.result.current,
}
);
syncHook.rerender(routerHook.result.current);
expect(recentlyViewedHook.result.current).toEqual([
{
id: 'page0',
mode: 'page',
},
]);
});
});

View File

@@ -15,7 +15,6 @@ beforeAll(() => {
createDynamicRouteParser([
'/workspace/[workspaceId]/[pageId]',
'/workspace/[workspaceId]/all',
'/workspace/[workspaceId]/favorite',
'/workspace/[workspaceId]/trash',
'/workspace/[workspaceId]/setting',
'/workspace/[workspaceId]/shared',
@@ -54,19 +53,6 @@ describe('useRouterHelper', () => {
// routerHook.result.current.back()
// routerHook.rerender()
// expect(routerHook.result.current.pathname).toBe('/')
await hook.jumpToSubPath(
'workspace1',
WorkspaceSubPath.FAVORITE,
RouteLogic.REPLACE
);
routerHook.rerender();
expect(routerHook.result.current.pathname).toBe(
'/workspace/[workspaceId]/favorite'
);
expect(routerHook.result.current.asPath).toBe(
'/workspace/workspace1/favorite'
);
});
test('should jump to the expected page', async () => {

View File

@@ -2,12 +2,12 @@ import { WorkspaceFlavour } from '@affine/workspace/type';
import { useRouter } from 'next/router';
import { useCallback } from 'react';
import { WorkspacePlugins } from '../../plugins';
import { WorkspaceAdapters } from '../../plugins';
export function useAffineLogIn() {
const router = useRouter();
return useCallback(async () => {
await WorkspacePlugins[WorkspaceFlavour.AFFINE].Events[
await WorkspaceAdapters[WorkspaceFlavour.AFFINE].Events[
'workspace:access'
]?.();
// todo: remove reload page requirement

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