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", "hooks",
"i18n", "i18n",
"jotai", "jotai",
"octobase-node", "native",
"templates", "templates",
"y-indexeddb", "y-indexeddb",
"debug", "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: push:
branches: branches:
- master - 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: pull_request:
branches: branches:
- master - 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: jobs:
lint: lint:
@@ -37,23 +54,6 @@ jobs:
path: ./packages/component/storybook-static path: ./packages/component/storybook-static
if-no-files-found: error 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: build:
name: Build @affine/web name: Build @affine/web
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -261,7 +261,7 @@ jobs:
- name: Upload test results - name: Upload test results
if: ${{ failure() }} if: ${{ failure() }}
uses: actions/upload-artifact@v2 uses: actions/upload-artifact@v3
with: with:
name: test-results-e2e-${{ matrix.shard }} name: test-results-e2e-${{ matrix.shard }}
path: ./test-results path: ./test-results
@@ -269,28 +269,63 @@ jobs:
dekstop-test: dekstop-test:
name: Desktop Test name: Desktop Test
runs-on: ubuntu-latest runs-on: ${{ matrix.spec.os }}
environment: development environment: development
strategy: strategy:
fail-fast: false
# all combinations: macos-latest x64, macos-latest arm64, windows-latest x64, ubuntu-latest x64 # all combinations: macos-latest x64, macos-latest arm64, windows-latest x64, ubuntu-latest x64
matrix: matrix:
spec: spec:
- { os: macos-latest, platform: macos, arch: x64 } - {
- { os: macos-latest, platform: macos, arch: arm64 } os: macos-latest,
- { os: ubuntu-latest, platform: linux, arch: x64 } platform: macos,
- { os: windows-latest, platform: windows, arch: x64 } arch: x64,
needs: [build, build-electron] 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: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- name: Setup Node.js - name: Setup Node.js
uses: ./.github/actions/setup-node uses: ./.github/actions/setup-node
with: with:
playwright-install: true playwright-install: true
- name: Download Ubuntu desktop artifact - name: Build AFFiNE native
uses: actions/download-artifact@v3 uses: ./.github/actions/build-rust
with: with:
name: affine-ubuntu target: ${{ matrix.spec.target }}
path: ./apps/electron/dist - 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 - name: Download static resource artifact
uses: actions/download-artifact@v3 uses: actions/download-artifact@v3
@@ -299,18 +334,47 @@ jobs:
path: ./apps/electron/resources/web-static path: ./apps/electron/resources/web-static
- name: Rebuild Electron dependences - name: Rebuild Electron dependences
run: yarn rebuild:for-electron shell: bash
working-directory: apps/electron 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 - 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 working-directory: apps/electron
- name: Upload test results - name: Upload test results
if: ${{ failure() }} if: ${{ failure() }}
uses: actions/upload-artifact@v2 uses: actions/upload-artifact@v3
with: with:
name: test-results-e2e-${{ matrix.shard }} name: test-results-e2e-${{ matrix.spec.os }}-${{ matrix.spec.arch }}
path: ./test-results path: ./test-results
if-no-files-found: ignore 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: env:
BUILD_TYPE: ${{ github.event.inputs.build-type }} BUILD_TYPE: ${{ github.event.inputs.build-type }}
DEBUG: napi:*
APP_NAME: affine
MACOSX_DEPLOYMENT_TARGET: '10.13'
jobs: jobs:
before-make: before-make:
@@ -46,8 +49,7 @@ jobs:
- name: Setup Node.js - name: Setup Node.js
uses: ./.github/actions/setup-node uses: ./.github/actions/setup-node
- name: generate-assets - name: generate-assets
working-directory: apps/electron run: yarn workspace @affine/electron generate-assets
run: yarn generate-assets
env: env:
NEXT_PUBLIC_FIREBASE_API_KEY: ${{ secrets.NEXT_PUBLIC_FIREBASE_API_KEY }} 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_AUTH_DOMAIN: ${{ secrets.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN }}
@@ -64,6 +66,8 @@ jobs:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
API_SERVER_PROFILE: prod API_SERVER_PROFILE: prod
ENABLE_TEST_PROPERTIES: false ENABLE_TEST_PROPERTIES: false
ENABLE_IMAGE_PREVIEW_MODAL: false
RELEASE_VERSION: ${{ github.event.inputs.version }}
- name: Upload Artifact (web-static) - name: Upload Artifact (web-static)
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
@@ -71,28 +75,36 @@ jobs:
name: before-make-web-static name: before-make-web-static
path: apps/electron/resources/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: make-distribution:
environment: ${{ github.ref_name == 'master' && 'production' || 'development' }} environment: ${{ github.ref_name == 'master' && 'production' || 'development' }}
strategy: strategy:
# all combinations: macos-latest x64, macos-latest arm64, windows-latest x64, ubuntu-latest x64 # all combinations: macos-latest x64, macos-latest arm64, windows-latest x64, ubuntu-latest x64
matrix: matrix:
spec: spec:
- { os: macos-latest, platform: macos, arch: x64 } - {
- { os: macos-latest, platform: macos, arch: arm64 } os: macos-latest,
- { os: ubuntu-latest, platform: linux, arch: x64 } platform: darwin,
- { os: windows-latest, platform: windows, arch: x64 } 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 }} runs-on: ${{ matrix.spec.os }}
needs: before-make needs: before-make
env: env:
@@ -104,34 +116,42 @@ jobs:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- name: Setup Node.js - name: Setup Node.js
uses: ./.github/actions/setup-node uses: ./.github/actions/setup-node
- name: Build AFFiNE native
uses: ./.github/actions/build-rust
with:
target: ${{ matrix.spec.target }}
- uses: actions/download-artifact@v3 - uses: actions/download-artifact@v3
with: with:
name: before-make-web-static name: before-make-web-static
path: apps/electron/resources/web-static path: apps/electron/resources/web-static
- uses: actions/download-artifact@v3
with: - name: Rebuild Electron dependences
name: before-make-electron-dist shell: bash
path: apps/electron/dist 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 - name: Signing By Apple Developer ID
if: ${{ matrix.spec.platform == 'macos' }} if: ${{ matrix.spec.platform == 'darwin' }}
uses: apple-actions/import-codesign-certs@v2 uses: apple-actions/import-codesign-certs@v2
with: with:
p12-file-base64: ${{ secrets.CERTIFICATES_P12 }} p12-file-base64: ${{ secrets.CERTIFICATES_P12 }}
p12-password: ${{ secrets.CERTIFICATES_P12_PASSWORD }} p12-password: ${{ secrets.CERTIFICATES_P12_PASSWORD }}
- name: make - name: make
run: yarn make-${{ matrix.spec.platform }}-${{ matrix.spec.arch }} run: yarn workspace @affine/electron make --platform=${{ matrix.spec.platform }} --arch=${{ matrix.spec.arch }}
working-directory: apps/electron
- name: Save artifacts (mac) - name: Save artifacts (mac)
if: ${{ matrix.spec.platform == 'macos' }} if: ${{ matrix.spec.platform == 'darwin' }}
run: | run: |
mkdir -p builds mkdir -p builds
mv apps/electron/out/*/make/*.dmg ./builds/affine-${{ env.BUILD_TYPE }}-macos-${{ matrix.spec.arch }}.dmg 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 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) - name: Save artifacts (windows)
if: ${{ matrix.spec.platform == 'windows' }} if: ${{ matrix.spec.platform == 'win32' }}
run: | run: |
mkdir -p builds 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/zip/win32/x64/AFFiNE*-win32-x64-*.zip ./builds/affine-${{ env.BUILD_TYPE }}-windows-x64.zip
@@ -156,37 +176,36 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3
- name: Download Artifacts (macos-x64) - name: Download Artifacts (macos-x64)
uses: actions/download-artifact@v3 uses: actions/download-artifact@v3
with: with:
name: affine-macos-x64-builds name: affine-darwin-x64-builds
path: ./ path: ./
- name: Download Artifacts (macos-arm64) - name: Download Artifacts (macos-arm64)
uses: actions/download-artifact@v3 uses: actions/download-artifact@v3
with: with:
name: affine-macos-arm64-builds name: affine-darwin-arm64-builds
path: ./ path: ./
- name: Download Artifacts (windows-x64) - name: Download Artifacts (windows-x64)
uses: actions/download-artifact@v3 uses: actions/download-artifact@v3
with: with:
name: affine-windows-x64-builds name: affine-win32-x64-builds
path: ./ path: ./
- name: Download Artifacts (linux-x64) - name: Download Artifacts (linux-x64)
uses: actions/download-artifact@v3 uses: actions/download-artifact@v3
with: with:
name: affine-linux-x64-builds name: affine-linux-x64-builds
path: ./ path: ./
- name: Download Artifacts
uses: actions/download-artifact@v3
with:
name: release-yml-build-script
path: ./
- uses: actions/setup-node@v3 - uses: actions/setup-node@v3
with: with:
node-version: 18 node-version: 18
- name: Generate Release yml - name: Generate Release yml
run: | 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 - name: Create Release Draft
uses: softprops/action-gh-release@v1 uses: softprops/action-gh-release@v1
env: env:

4
.gitignore vendored
View File

@@ -66,3 +66,7 @@ i18n-generated.ts
# Cache # Cache
.eslintcache .eslintcache
next-env.d.ts 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]": { "[toml]": {
"editor.defaultFormatter": "tamasfe.even-better-toml" "editor.defaultFormatter": "tamasfe.even-better-toml"
}, },
"rust-analyzer.linkedProjects": ["packages/octobase-node/Cargo.toml"],
"[typescriptreact]": { "[typescriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode" "editor.defaultFormatter": "esbenp.prettier-vscode"
}, },
@@ -38,5 +37,6 @@
"apps/electron/layers/**/*.spec.ts", "apps/electron/layers/**/*.spec.ts",
"tests/unit/**/*.spec.ts", "tests/unit/**/*.spec.ts",
"tests/unit/**/*.spec.tsx" "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). 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"> <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> </a>
## Self-Host ## Self-Host

View File

@@ -1,11 +1,16 @@
/* eslint-disable @typescript-eslint/no-var-requires */ /* eslint-disable @typescript-eslint/no-var-requires */
const { z } = require('zod');
const { const {
utils: { fromBuildIdentifier }, utils: { fromBuildIdentifier },
} = require('@electron-forge/core'); } = require('@electron-forge/core');
const path = require('node:path'); 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 stableBuild = buildType === 'stable';
const productName = !stableBuild ? `AFFiNE-${buildType}` : 'AFFiNE'; const productName = !stableBuild ? `AFFiNE-${buildType}` : 'AFFiNE';
const icoPath = !stableBuild const icoPath = !stableBuild
@@ -28,6 +33,7 @@ module.exports = {
packagerConfig: { packagerConfig: {
name: productName, name: productName,
appBundleId: fromBuildIdentifier({ appBundleId: fromBuildIdentifier({
internal: 'pro.affine.internal',
canary: 'pro.affine.canary', canary: 'pro.affine.canary',
beta: 'pro.affine.beta', beta: 'pro.affine.beta',
stable: 'pro.affine.app', 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'; import type { MainEventListener } from './type';
interface DBFilePathMeta {
workspaceId: string;
path: string;
realPath: string;
}
export const dbSubjects = { export const dbSubjects = {
// emit workspace ids // emit workspace ids
dbFileMissing: new Subject<string>(), dbFileMissing: new Subject<string>(),
// emit workspace ids // emit workspace ids
dbFileUpdate: new Subject<string>(), dbFileUpdate: new Subject<string>(),
dbFilePathChange: new Subject<DBFilePathMeta>(),
}; };
export const dbEvents = { export const dbEvents = {
onDbFileMissing: (fn: (workspaceId: string) => void) => { onDBFileMissing: (fn: (workspaceId: string) => void) => {
const sub = dbSubjects.dbFileMissing.subscribe(fn); const sub = dbSubjects.dbFileMissing.subscribe(fn);
return () => { return () => {
sub.unsubscribe(); sub.unsubscribe();
}; };
}, },
onDbFileUpdate: (fn: (workspaceId: string) => void) => { onDBFileUpdate: (fn: (workspaceId: string) => void) => {
const sub = dbSubjects.dbFileUpdate.subscribe(fn); const sub = dbSubjects.dbFileUpdate.subscribe(fn);
return () => { return () => {
sub.unsubscribe(); sub.unsubscribe();
}; };
}, },
onDBFilePathChange: (fn: (meta: DBFilePathMeta) => void) => {
const sub = dbSubjects.dbFilePathChange.subscribe(fn);
return () => {
sub.unsubscribe();
};
},
} satisfies Record<string, MainEventListener>; } satisfies Record<string, MainEventListener>;

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,8 @@ import assert from 'node:assert';
import path from 'node:path'; import path from 'node:path';
import fs from 'fs-extra'; import fs from 'fs-extra';
import type { Subscription } from 'rxjs';
import { v4 } from 'uuid';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import * as Y from 'yjs'; import * as Y from 'yjs';
@@ -61,6 +63,9 @@ const ipcMain = {
handlers.push(callback); handlers.push(callback);
registeredHandlers.set(key, handlers); registeredHandlers.set(key, handlers);
}, },
setMaxListeners: (_n: number) => {
// noop
},
}; };
const nativeTheme = { const nativeTheme = {
@@ -96,6 +101,11 @@ const electronModule = {
handlers.push(callback); handlers.push(callback);
registeredHandlers.set(name, handlers); registeredHandlers.set(name, handlers);
}, },
addEventListener: (...args: any[]) => {
// @ts-ignore
electronModule.app.on(...args);
},
removeEventListener: () => {},
}, },
BrowserWindow: { BrowserWindow: {
getAllWindows: () => { getAllWindows: () => {
@@ -113,6 +123,8 @@ vi.doMock('electron', () => {
return electronModule; return electronModule;
}); });
let connectableSubscription: Subscription;
beforeEach(async () => { beforeEach(async () => {
const { registerHandlers } = await import('../register'); const { registerHandlers } = await import('../register');
registerHandlers(); registerHandlers();
@@ -120,20 +132,24 @@ beforeEach(async () => {
// should also register events // should also register events
const { registerEvents } = await import('../../events'); const { registerEvents } = await import('../../events');
registerEvents(); registerEvents();
await fs.mkdirp(SESSION_DATA_PATH);
const { database$ } = await import('../db/ensure-db');
connectableSubscription = database$.connect();
}); });
afterEach(async () => { afterEach(async () => {
const { cleanupSQLiteDBs } = await import('../db/ensure-db');
await cleanupSQLiteDBs();
await fs.remove(SESSION_DATA_PATH);
// reset registered handlers // reset registered handlers
registeredHandlers.get('before-quit')?.forEach(fn => fn()); registeredHandlers.get('before-quit')?.forEach(fn => fn());
connectableSubscription.unsubscribe();
await fs.remove(SESSION_DATA_PATH);
}); });
describe('ensureSQLiteDB', () => { describe('ensureSQLiteDB', () => {
test('should create db file on connection if it does not exist', async () => { 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 { ensureSQLiteDB } = await import('../db/ensure-db');
const workspaceDB = await ensureSQLiteDB(id); const workspaceDB = await ensureSQLiteDB(id);
const file = workspaceDB.path; const file = workspaceDB.path;
@@ -143,73 +159,76 @@ describe('ensureSQLiteDB', () => {
test('when db file is removed', async () => { test('when db file is removed', async () => {
// stub webContents.send // stub webContents.send
const sendStub = vi.fn(); const sendSpy = vi.spyOn(browserWindow.webContents, 'send');
browserWindow.webContents.send = sendStub; const id = v4();
const id = 'test-workspace-id';
const { ensureSQLiteDB } = await import('../db/ensure-db'); const { ensureSQLiteDB } = await import('../db/ensure-db');
let workspaceDB = await ensureSQLiteDB(id); let workspaceDB = await ensureSQLiteDB(id);
const file = workspaceDB.path; const file = workspaceDB.path;
const fileExists = await fs.pathExists(file); const fileExists = await fs.pathExists(file);
expect(fileExists).toBe(true); 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); 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); await delay(2000);
expect(sendStub).toBeCalledWith('db:onDbFileMissing', id); expect(sendSpy).toBeCalledWith('db:onDBFileMissing', id);
// ensureSQLiteDB should recreate the db file // ensureSQLiteDB should recreate the db file
workspaceDB = await ensureSQLiteDB(id); workspaceDB = await ensureSQLiteDB(id);
const fileExists2 = await fs.pathExists(file); const fileExists2 = await fs.pathExists(file);
expect(fileExists2).toBe(true); expect(fileExists2).toBe(true);
sendSpy.mockRestore();
}); });
test('when db file is updated', async () => { test('when db file is updated', async () => {
// stub webContents.send const id = v4();
const sendStub = vi.fn();
browserWindow.webContents.send = sendStub;
const id = 'test-workspace-id';
const { ensureSQLiteDB } = await import('../db/ensure-db'); const { ensureSQLiteDB } = await import('../db/ensure-db');
const { dbSubjects } = await import('../../events/db');
const workspaceDB = await ensureSQLiteDB(id); const workspaceDB = await ensureSQLiteDB(id);
const file = workspaceDB.path; const file = workspaceDB.path;
const fileExists = await fs.pathExists(file); const fileExists = await fs.pathExists(file);
expect(fileExists).toBe(true); expect(fileExists).toBe(true);
const dbUpdateSpy = vi.spyOn(dbSubjects.dbFileUpdate, 'next');
// wait to make sure await delay(100);
await delay(500);
// writes some data to the db file // writes some data to the db file
await fs.appendFile(file, 'random-data', { encoding: 'binary' }); await fs.appendFile(file, 'random-data', { encoding: 'binary' });
// write again // write again
await fs.appendFile(file, 'random-data', { encoding: 'binary' }); 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); await delay(2000);
expect(sendStub).toBeCalledWith('db:onDbFileUpdate', id); expect(dbUpdateSpy).toBeCalledWith(id);
dbUpdateSpy.mockRestore();
// should only call once for multiple writes
expect(sendStub).toBeCalledTimes(1);
}); });
}); });
describe('workspace handlers', () => { describe('workspace handlers', () => {
test('list all workspace ids', async () => { 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'); const { ensureSQLiteDB } = await import('../db/ensure-db');
await Promise.all(ids.map(id => ensureSQLiteDB(id))); await Promise.all(ids.map(id => ensureSQLiteDB(id)));
const list = await dispatch('workspace', 'list'); 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 () => { 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'); const { ensureSQLiteDB } = await import('../db/ensure-db');
await Promise.all(ids.map(id => ensureSQLiteDB(id))); 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'); 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', () => { describe('db handlers', () => {
test('apply doc and get doc updates', async () => { test('apply doc and get doc updates', async () => {
const workspaceId = 'test-workspace-id'; const workspaceId = v4();
const bin = await dispatch('db', 'getDocAsUpdates', workspaceId); const bin = await dispatch('db', 'getDocAsUpdates', workspaceId);
// ? is this a good test? // ? is this a good test?
expect(bin.every((byte: number) => byte === 0)).toBe(true); expect(bin.every((byte: number) => byte === 0)).toBe(true);
@@ -264,13 +283,13 @@ describe('db handlers', () => {
}); });
test('get non existent blob', async () => { test('get non existent blob', async () => {
const workspaceId = 'test-workspace-id'; const workspaceId = v4();
const bin = await dispatch('db', 'getBlob', workspaceId, 'non-existent-id'); const bin = await dispatch('db', 'getBlob', workspaceId, 'non-existent-id');
expect(bin).toBeNull(); expect(bin).toBeNull();
}); });
test('list blobs (empty)', async () => { test('list blobs (empty)', async () => {
const workspaceId = 'test-workspace-id'; const workspaceId = v4();
const list = await dispatch('db', 'getPersistedBlobs', workspaceId); const list = await dispatch('db', 'getPersistedBlobs', workspaceId);
expect(list).toEqual([]); expect(list).toEqual([]);
}); });
@@ -318,7 +337,7 @@ describe('dialog handlers', () => {
const mockShowItemInFolder = vi.fn(); const mockShowItemInFolder = vi.fn();
electronModule.shell.showItemInFolder = mockShowItemInFolder; electronModule.shell.showItemInFolder = mockShowItemInFolder;
const id = 'test-workspace-id'; const id = v4();
const { ensureSQLiteDB } = await import('../db/ensure-db'); const { ensureSQLiteDB } = await import('../db/ensure-db');
const db = await ensureSQLiteDB(id); const db = await ensureSQLiteDB(id);
@@ -334,13 +353,15 @@ describe('dialog handlers', () => {
electronModule.dialog.showSaveDialog = mockShowSaveDialog; electronModule.dialog.showSaveDialog = mockShowSaveDialog;
electronModule.shell.showItemInFolder = mockShowItemInFolder; electronModule.shell.showItemInFolder = mockShowItemInFolder;
const id = 'test-workspace-id'; const id = v4();
const { ensureSQLiteDB } = await import('../db/ensure-db'); const { ensureSQLiteDB } = await import('../db/ensure-db');
await ensureSQLiteDB(id); await ensureSQLiteDB(id);
await dispatch('dialog', 'saveDBFileAs', id); await dispatch('dialog', 'saveDBFileAs', id);
expect(mockShowSaveDialog).toBeCalled(); expect(mockShowSaveDialog).toBeCalled();
expect(mockShowItemInFolder).not.toBeCalled(); expect(mockShowItemInFolder).not.toBeCalled();
electronModule.dialog = {};
electronModule.shell = {};
}); });
test('saveDBFileAs', async () => { test('saveDBFileAs', async () => {
@@ -352,7 +373,7 @@ describe('dialog handlers', () => {
electronModule.dialog.showSaveDialog = mockShowSaveDialog; electronModule.dialog.showSaveDialog = mockShowSaveDialog;
electronModule.shell.showItemInFolder = mockShowItemInFolder; electronModule.shell.showItemInFolder = mockShowItemInFolder;
const id = 'test-workspace-id'; const id = v4();
const { ensureSQLiteDB } = await import('../db/ensure-db'); const { ensureSQLiteDB } = await import('../db/ensure-db');
await ensureSQLiteDB(id); await ensureSQLiteDB(id);
@@ -403,11 +424,13 @@ describe('dialog handlers', () => {
const res = await dispatch('dialog', 'loadDBFile'); const res = await dispatch('dialog', 'loadDBFile');
expect(mockShowOpenDialog).toBeCalled(); expect(mockShowOpenDialog).toBeCalled();
expect(res.error).toBe('DB_FILE_INVALID'); expect(res.error).toBe('DB_FILE_INVALID');
electronModule.dialog = {};
}); });
test('loadDBFile', async () => { test('loadDBFile', async () => {
// we use ensureSQLiteDB to create a valid db file // 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 { ensureSQLiteDB } = await import('../db/ensure-db');
const db = await ensureSQLiteDB(id); const db = await ensureSQLiteDB(id);
@@ -417,6 +440,11 @@ describe('dialog handlers', () => {
await fs.ensureDir(basePath); await fs.ensureDir(basePath);
await fs.copyFile(db.path, originDBFilePath); 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 // remove db
await fs.remove(db.path); await fs.remove(db.path);
@@ -440,19 +468,19 @@ describe('dialog handlers', () => {
}); });
test('moveDBFile', async () => { 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(() => { const mockShowSaveDialog = vi.fn(() => {
return { filePath: newPath }; return { filePath: newPath };
}) as any; }) as any;
electronModule.dialog.showSaveDialog = mockShowSaveDialog; electronModule.dialog.showSaveDialog = mockShowSaveDialog;
const id = 'test-workspace-id'; const id = v4();
const { ensureSQLiteDB } = await import('../db/ensure-db'); const { ensureSQLiteDB } = await import('../db/ensure-db');
await ensureSQLiteDB(id); await ensureSQLiteDB(id);
const res = await dispatch('dialog', 'moveDBFile', id); const res = await dispatch('dialog', 'moveDBFile', id);
expect(mockShowSaveDialog).toBeCalled(); expect(mockShowSaveDialog).toBeCalled();
expect(res.filePath).toBe(newPath); expect(res.filePath).toBe(newPath);
electronModule.dialog = {};
}); });
test('moveDBFile (skipped)', async () => { test('moveDBFile (skipped)', async () => {
@@ -461,12 +489,13 @@ describe('dialog handlers', () => {
}) as any; }) as any;
electronModule.dialog.showSaveDialog = mockShowSaveDialog; electronModule.dialog.showSaveDialog = mockShowSaveDialog;
const id = 'test-workspace-id'; const id = v4();
const { ensureSQLiteDB } = await import('../db/ensure-db'); const { ensureSQLiteDB } = await import('../db/ensure-db');
await ensureSQLiteDB(id); await ensureSQLiteDB(id);
const res = await dispatch('dialog', 'moveDBFile', id); const res = await dispatch('dialog', 'moveDBFile', id);
expect(mockShowSaveDialog).toBeCalled(); expect(mockShowSaveDialog).toBeCalled();
expect(res.filePath).toBe(undefined); 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 { appContext } from '../../context';
import { subjects } from '../../events'; import { subjects } from '../../events';
import { logger } from '../../logger'; import { logger } from '../../logger';
import { debounce, ts } from '../../utils'; import { ts } from '../../utils';
import type { WorkspaceSQLiteDB } from './sqlite'; import type { WorkspaceSQLiteDB } from './sqlite';
import { openWorkspaceDatabase } from './sqlite'; import { openWorkspaceDatabase } from './sqlite';
const dbMapping = new Map<string, Promise<WorkspaceSQLiteDB>>(); const databaseInput$ = new Subject<string>();
const dbWatchers = new Map<string, () => void>(); 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 // if we removed the file, we will stop watching it
function startWatchingDBFile(db: WorkspaceSQLiteDB) { function startWatchingDBFile(db: WorkspaceSQLiteDB) {
if (dbWatchers.has(db.workspaceId)) { const FSWatcher = createFSWatcher();
return dbWatchers.get(db.workspaceId); return new Observable<NotifyEvent>(subscriber => {
} logger.info('[FSWatcher] start watching db file', db.workspaceId);
logger.info('watch db file', db.path); const subscription = FSWatcher.watch(db.path, {
const watcher = watch(db.path); recursive: false,
}).subscribe(
const debounceOnChange = debounce(() => { event => {
logger.info( logger.info('[FSWatcher]', event);
'db file changed on disk', subscriber.next(event);
db.workspaceId, // remove file or move file, complete the observable and close db
ts() - db.lastUpdateTime, if (isRemoveOrMoveEvent(event)) {
'ms' subscriber.complete();
}
},
err => {
subscriber.error(err);
}
); );
// reconnect db return () => {
db.reconnectDB(); // destroy on unsubscribe
subjects.db.dbFileUpdate.next(db.workspaceId); logger.info('[FSWatcher] cleanup db file watcher', 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(() => {
db.destroy(); db.destroy();
dbWatchers.delete(db.workspaceId); subscription.unsubscribe();
dbMapping.delete(db.workspaceId); };
}); }).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) { export function ensureSQLiteDB(id: string) {
let workspaceDB = dbMapping.get(id); const deferValue = lastValueFrom(
if (!workspaceDB) { database$.pipe(
logger.info('[ensureSQLiteDB] open db connection', id); filter(db => db.workspaceId === id && db.db.open),
workspaceDB = openWorkspaceDatabase(appContext, id); take(1),
dbMapping.set(id, workspaceDB); tap({
startWatchingDBFile(await workspaceDB); error: err => {
} logger.error('[ensureSQLiteDB] error', err);
return await workspaceDB; },
} })
)
export async function disconnectSQLiteDB(id: string) { );
const dbp = dbMapping.get(id); databaseInput$.next(id);
if (dbp) { return deferValue;
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();
} }

View File

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

View File

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

View File

@@ -6,7 +6,8 @@ import { nanoid } from 'nanoid';
import { appContext } from '../../context'; import { appContext } from '../../context';
import { logger } from '../../logger'; 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 { getWorkspaceDBPath, isValidDBFile } from '../db/sqlite';
import { listWorkspaces } from '../workspace/workspace'; import { listWorkspaces } from '../workspace/workspace';
@@ -15,7 +16,7 @@ import { listWorkspaces } from '../workspace/workspace';
export async function revealDBFile(workspaceId: string) { export async function revealDBFile(workspaceId: string) {
const workspaceDB = await ensureSQLiteDB(workspaceId); 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 // provide a backdoor to set dialog path for testing in playwright
@@ -47,6 +48,7 @@ const ErrorMessages = [
'DB_FILE_ALREADY_LOADED', 'DB_FILE_ALREADY_LOADED',
'DB_FILE_PATH_INVALID', 'DB_FILE_PATH_INVALID',
'DB_FILE_INVALID', 'DB_FILE_INVALID',
'FILE_ALREADY_EXISTS',
'UNKNOWN_ERROR', 'UNKNOWN_ERROR',
] as const; ] as const;
@@ -201,7 +203,7 @@ export async function loadDBFile(): Promise<LoadDBFileResult> {
await fs.ensureDir(path.join(appContext.appDataPath, 'workspaces')); 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}`); logger.info(`loadDBFile, symlink: ${filePath} -> ${linkedFilePath}`);
return { workspaceId }; return { workspaceId };
@@ -231,17 +233,29 @@ export async function moveDBFile(
workspaceId: string, workspaceId: string,
dbFileLocation?: string dbFileLocation?: string
): Promise<MoveDBFileResult> { ): Promise<MoveDBFileResult> {
let db: WorkspaceSQLiteDB | null = null;
try { try {
const db = await ensureSQLiteDB(workspaceId); const { moveFile, FsWatcher } = await import('@affine/native');
db = await ensureSQLiteDB(workspaceId);
// get the real file path of db // get the real file path of db
const realpath = await fs.realpath(db.path); const realpath = await fs.realpath(db.path);
const isLink = 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 = const newFilePath =
dbFileLocation || dbFileLocation ??
( (
getFakedResult() || getFakedResult() ??
(await dialog.showSaveDialog({ (await dialog.showSaveDialog({
properties: ['showOverwriteConfirmation'], properties: ['showOverwriteConfirmation'],
title: 'Move Workspace Storage', 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) { if (isLink) {
// remove the old link to unblock new link // remove the old link to unblock new link
await fs.unlink(db.path); await fs.unlink(db.path);
} }
await fs.move(realpath, newFilePath, { logger.info(`[moveDBFile] move ${realpath} -> ${newFilePath}`);
overwrite: true,
}); 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 { return {
filePath: newFilePath, filePath: newFilePath,
}; };
} catch (err) { } catch (err) {
logger.error('moveDBFile', err); db?.destroy();
logger.error('[moveDBFile]', err);
return { return {
error: 'UNKNOWN_ERROR', error: 'UNKNOWN_ERROR',
}; };

View File

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

View File

@@ -36,6 +36,8 @@ export const allHandlers = {
} satisfies Record<string, NamespaceHandlers>; } satisfies Record<string, NamespaceHandlers>;
export const registerHandlers = () => { 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 [namespace, namespaceHandlers] of Object.entries(allHandlers)) {
for (const [key, handler] of Object.entries(namespaceHandlers)) { for (const [key, handler] of Object.entries(namespaceHandlers)) {
const chan = `${namespace}:${key}`; 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 { isMacOS } from '../../../../utils';
import type { NamespaceHandlers } from '../type'; 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 () => { getGoogleOauthCode: async () => {
return getGoogleOauthCode(); return getGoogleOauthCode();
}, },

View File

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

View File

@@ -1,69 +1,100 @@
import { app } from 'electron';
import type { AppUpdater } from 'electron-updater'; import type { AppUpdater } from 'electron-updater';
import { z } from 'zod';
import { isMacOS } from '../../../../utils'; import { isMacOS } from '../../../../utils';
import { updaterSubjects } from '../../events/updater'; import { updaterSubjects } from '../../events/updater';
import { logger } from '../../logger'; 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 mode = process.env.NODE_ENV;
const isDev = mode === 'development'; const isDev = mode === 'development';
let _autoUpdater: AppUpdater | null = null; let _autoUpdater: AppUpdater | null = null;
export const updateClient = async () => { export const quitAndInstall = async () => {
_autoUpdater?.quitAndInstall(); _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 () => { export const registerUpdater = async () => {
// require it will cause some side effects and will break generate-main-exposed-meta, // require it will cause some side effects and will break generate-main-exposed-meta,
// so we wrap it in a function // so we wrap it in a function
// eslint-disable-next-line @typescript-eslint/no-var-requires // eslint-disable-next-line @typescript-eslint/no-var-requires
const { autoUpdater } = await import('electron-updater'); const { autoUpdater } = require('electron-updater');
_autoUpdater = autoUpdater; _autoUpdater = autoUpdater;
autoUpdater.autoDownload = false; if (!_autoUpdater) {
autoUpdater.allowPrerelease = buildType !== 'stable'; return;
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();
} }
// 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 { app } from 'electron';
import { createApplicationMenu } from './application-menu';
import { registerEvents } from './events'; import { registerEvents } from './events';
import { registerHandlers } from './handlers'; import { registerHandlers } from './handlers';
import { registerUpdater } from './handlers/updater'; import { registerUpdater } from './handlers/updater';
@@ -57,6 +58,7 @@ app
.then(registerHandlers) .then(registerHandlers)
.then(registerEvents) .then(registerEvents)
.then(restoreOrCreateWindow) .then(restoreOrCreateWindow)
.then(createApplicationMenu)
.then(registerUpdater) .then(registerUpdater)
.catch(e => console.error('Failed create window:', e)); .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 electronWindowState from 'electron-window-state';
import { join } from 'path'; import { join } from 'path';
import { isMacOS } from '../../utils'; import { isMacOS, isWindows } from '../../utils';
import { logger } from './logger'; import { logger } from './logger';
const IS_DEV: boolean = const IS_DEV: boolean =
@@ -18,13 +18,17 @@ async function createWindow() {
}); });
const browserWindow = new BrowserWindow({ const browserWindow = new BrowserWindow({
titleBarStyle: isMacOS() ? 'hiddenInset' : 'default', titleBarStyle: isMacOS()
? 'hiddenInset'
: isWindows()
? 'hidden'
: 'default',
trafficLightPosition: { x: 24, y: 18 }, trafficLightPosition: { x: 24, y: 18 },
x: mainWindowState.x, x: mainWindowState.x,
y: mainWindowState.y, y: mainWindowState.y,
width: mainWindowState.width, width: mainWindowState.width,
minWidth: 640, minWidth: 640,
transparent: isMacOS(), minHeight: 480,
visualEffectState: 'active', visualEffectState: 'active',
vibrancy: 'under-window', vibrancy: 'under-window',
height: mainWindowState.height, 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 */ /* eslint-disable @typescript-eslint/consistent-type-imports */
interface Window { interface Window {
apis?: typeof import('./src/affine-apis').apis; apis: typeof import('./src/affine-apis').apis;
events?: typeof import('./src/affine-apis').events; events: typeof import('./src/affine-apis').events;
appInfo?: typeof import('./src/affine-apis').appInfo; appInfo: typeof import('./src/affine-apis').appInfo;
} }

View File

@@ -57,6 +57,10 @@ const events: MainIPCEventMap = (() => {
const { const {
events: eventsMeta, events: eventsMeta,
}: MainExposedMeta = require('../main/exposed-meta'); }: 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 all = eventsMeta.map(([namespace, eventNames]) => {
const namespaceEvents = eventNames.map(name => { const namespaceEvents = eventNames.map(name => {
const channel = `${namespace}:${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 = () => { export const isMacOS = () => {
return process.platform === 'darwin'; return process.platform === 'darwin';
}; };
export const isWindows = () => {
return process.platform === 'win32';
};

View File

@@ -1,7 +1,7 @@
{ {
"name": "@affine/electron", "name": "@affine/electron",
"private": true, "private": true,
"version": "0.5.4-canary.30", "version": "0.5.4-beta.2",
"author": "affine", "author": "affine",
"repository": { "repository": {
"url": "https://github.com/toeverything/AFFiNE", "url": "https://github.com/toeverything/AFFiNE",
@@ -18,10 +18,6 @@
"generate-main-exposed-meta": "zx scripts/generate-main-exposed-meta.mjs", "generate-main-exposed-meta": "zx scripts/generate-main-exposed-meta.mjs",
"package": "electron-forge package", "package": "electron-forge package",
"make": "electron-forge make", "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-unit-test": "yarn rebuild better-sqlite3",
"rebuild:for-electron": "yarn electron-rebuild", "rebuild:for-electron": "yarn electron-rebuild",
"test": "playwright test" "test": "playwright test"
@@ -32,6 +28,7 @@
"main": "./dist/layers/main/index.js", "main": "./dist/layers/main/index.js",
"devDependencies": { "devDependencies": {
"@affine-test/kit": "workspace:*", "@affine-test/kit": "workspace:*",
"@affine/native": "workspace:*",
"@electron-forge/cli": "^6.1.1", "@electron-forge/cli": "^6.1.1",
"@electron-forge/core": "^6.1.1", "@electron-forge/core": "^6.1.1",
"@electron-forge/core-utils": "^6.1.1", "@electron-forge/core-utils": "^6.1.1",
@@ -44,16 +41,18 @@
"@electron/remote": "2.0.9", "@electron/remote": "2.0.9",
"@types/better-sqlite3": "^7.6.4", "@types/better-sqlite3": "^7.6.4",
"@types/fs-extra": "^11.0.1", "@types/fs-extra": "^11.0.1",
"@types/uuid": "^9.0.1",
"cross-env": "7.0.3", "cross-env": "7.0.3",
"electron": "24.2.0", "electron": "24.3.1",
"electron-log": "^5.0.0-beta.23", "electron-log": "^5.0.0-beta.24",
"electron-squirrel-startup": "1.0.0", "electron-squirrel-startup": "1.0.0",
"electron-window-state": "^5.0.3", "electron-window-state": "^5.0.3",
"esbuild": "^0.17.18", "esbuild": "^0.17.19",
"fs-extra": "^11.1.1", "fs-extra": "^11.1.1",
"playwright": "^1.33.0", "playwright": "^1.33.0",
"ts-node": "^10.9.1", "ts-node": "^10.9.1",
"undici": "^5.22.0", "undici": "^5.22.1",
"uuid": "^9.0.0",
"zx": "^7.2.2" "zx": "^7.2.2"
}, },
"dependencies": { "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 = const NODE_ENV =
process.env.NODE_ENV === 'development' ? 'development' : 'production'; process.env.NODE_ENV === 'development' ? 'development' : 'production';
if (process.platform === 'win32') {
$.shell = true;
$.prefix = '';
}
async function buildLayers() { async function buildLayers() {
const common = config(); const common = config();
await esbuild.build(common.preload); await esbuild.build(common.preload);

View File

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

View File

@@ -1,14 +1,18 @@
#!/usr/bin/env zx #!/usr/bin/env zx
import 'zx/globals'; import 'zx/globals';
import { createRequire } from 'node:module';
import path from 'node:path'; import path from 'node:path';
const require = createRequire(import.meta.url);
const repoRootDir = path.join(__dirname, '..', '..', '..'); const repoRootDir = path.join(__dirname, '..', '..', '..');
const electronRootDir = path.join(__dirname, '..'); const electronRootDir = path.join(__dirname, '..');
const publicDistDir = path.join(electronRootDir, 'resources'); const publicDistDir = path.join(electronRootDir, 'resources');
const affineWebDir = path.join(repoRootDir, 'apps', 'web'); const affineWebDir = path.join(repoRootDir, 'apps', 'web');
const affineWebOutDir = path.join(affineWebDir, 'out'); const affineWebOutDir = path.join(affineWebDir, 'out');
const publicAffineOutDir = path.join(publicDistDir, `web-static`); const publicAffineOutDir = path.join(publicDistDir, `web-static`);
const releaseVersionEnv = process.env.RELEASE_VERSION || '';
console.log('build with following dir', { console.log('build with following dir', {
repoRootDir, repoRootDir,
@@ -19,9 +23,16 @@ console.log('build with following dir', {
publicAffineOutDir, 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 // copy web dist files to electron dist
// step 0: clean up // step 1: clean up
await cleanup(); await cleanup();
echo('Clean up done'); echo('Clean up done');
@@ -32,9 +43,6 @@ if (process.platform === 'win32') {
cd(repoRootDir); cd(repoRootDir);
// step 1: build electron resources
await $`yarn workspace @affine/electron build-layers`;
// step 2: build web (nextjs) dist // step 2: build web (nextjs) dist
if (!process.env.SKIP_WEB_BUILD) { if (!process.env.SKIP_WEB_BUILD) {
process.env.ENABLE_LEGACY_PROVIDER = 'false'; process.env.ENABLE_LEGACY_PROVIDER = 'false';
@@ -59,6 +67,17 @@ if (!process.env.SKIP_WEB_BUILD) {
await fs.move(affineWebOutDir, publicAffineOutDir, { overwrite: true }); 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'); const mainDistDir = path.resolve(__dirname, '../dist/layers/main');
// be careful and avoid any side effects in // be careful and avoid any side effects in
const { handlers, events } = await import( const { handlers, events } = await import(
path.resolve(mainDistDir, 'exposed.js') 'file://' + path.resolve(mainDistDir, 'exposed.js')
); );
const handlersMeta = Object.entries(handlers).map( const handlersMeta = Object.entries(handlers).map(

View File

@@ -1,3 +1,5 @@
import { platform } from 'node:os';
import { expect } from '@playwright/test'; import { expect } from '@playwright/test';
import { test } from './fixture'; import { test } from './fixture';
@@ -11,8 +13,74 @@ test('new page', async ({ page, workspace }) => {
expect(flavour).toBe('local'); 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 }) => { test('app theme', async ({ page, electronApp }) => {
await page.waitForSelector('v-line');
const root = page.locator('html'); const root = page.locator('html');
{ {
const themeMode = await root.evaluate(element => const themeMode = await root.evaluate(element =>
@@ -20,30 +88,25 @@ test('app theme', async ({ page, electronApp }) => {
); );
expect(themeMode).toBe('light'); expect(themeMode).toBe('light');
// check if electron theme source is set to light const theme = await electronApp.evaluate(({ nativeTheme }) => {
const themeSource = await electronApp.evaluate(({ nativeTheme }) => { return nativeTheme.shouldUseDarkColors ? 'dark' : 'light';
return nativeTheme.themeSource;
}); });
expect(themeSource).toBe('light'); expect(theme).toBe('light');
} }
{ {
await page.getByTestId('editor-option-menu').click(); await page.getByTestId('editor-option-menu').click();
await page.getByTestId('change-theme-dark').click(); await page.getByTestId('change-theme-dark').click();
await page.waitForTimeout(50); await page.waitForTimeout(50);
{ const themeMode = await root.evaluate(element =>
const themeMode = await root.evaluate(element => element.getAttribute('data-theme')
element.getAttribute('data-theme') );
); expect(themeMode).toBe('dark');
expect(themeMode).toBe('dark'); const theme = await electronApp.evaluate(({ nativeTheme }) => {
} return nativeTheme.shouldUseDarkColors ? 'dark' : 'light';
const themeSource = await electronApp.evaluate(({ nativeTheme }) => {
return nativeTheme.themeSource;
}); });
expect(theme).toBe('dark');
expect(themeSource).toBe('dark');
} }
}); });
@@ -73,7 +136,7 @@ test('affine onboarding button', async ({ page }) => {
'[data-testid=onboarding-modal-editing-video]' '[data-testid=onboarding-modal-editing-video]'
); );
expect(await editingVideo.isVisible()).toEqual(true); 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); expect(await onboardingModal.isVisible()).toEqual(false);
}); });

View File

@@ -3,9 +3,14 @@
/* eslint-disable no-empty-pattern */ /* eslint-disable no-empty-pattern */
import crypto from 'node:crypto'; 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 fs from 'fs-extra';
import type { ElectronApplication, Page } from 'playwright'; import type { ElectronApplication, Page } from 'playwright';
import { _electron as electron } from 'playwright'; import { _electron as electron } from 'playwright';
@@ -42,7 +47,31 @@ export const test = base.extend<{
const logFilePath = await page.evaluate(async () => { const logFilePath = await page.evaluate(async () => {
return window.apis?.debug.logFilePath(); 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); await use(page);
if (enableCoverage) {
await page.evaluate(() =>
// @ts-expect-error
window.collectIstanbulCoverage(JSON.stringify(window.__coverage__))
);
}
await page.close(); await page.close();
if (logFilePath) { if (logFilePath) {
const logs = await fs.readFile(logFilePath, 'utf-8'); const logs = await fs.readFile(logFilePath, 'utf-8');
@@ -52,16 +81,27 @@ export const test = base.extend<{
electronApp: async ({}, use) => { electronApp: async ({}, use) => {
// a random id to avoid conflicts between tests // a random id to avoid conflicts between tests
const id = generateUUID(); const id = generateUUID();
const ext = process.platform === 'win32' ? '.cmd' : '';
const electronApp = await electron.launch({ const electronApp = await electron.launch({
args: [resolve(__dirname, '..'), '--app-name', 'affine-test-' + id], 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', colorScheme: 'light',
}); });
const sessionDataPath = await electronApp.evaluate(async ({ app }) => {
return app.getPath('sessionData');
});
await use(electronApp); 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) => { appInfo: async ({ electronApp }, use) => {
const appInfo = await electronApp.evaluate(async ({ app }) => { const appInfo = await electronApp.evaluate(async ({ app }) => {

View File

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

View File

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

View File

@@ -1,3 +1,3 @@
# Please do not edit this file manually # Please do not edit this file manually
# It should be added in your version-control system (i.e. Git) # 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", "name": "@affine/server",
"private": true, "private": true,
"version": "0.5.4-canary.30", "version": "0.5.4-beta.2",
"description": "Affine Node.js server", "description": "Affine Node.js server",
"type": "module", "type": "module",
"bin": { "bin": {
@@ -16,36 +16,35 @@
"dependencies": { "dependencies": {
"@apollo/server": "^4.7.1", "@apollo/server": "^4.7.1",
"@nestjs/apollo": "^11.0.5", "@nestjs/apollo": "^11.0.5",
"@nestjs/common": "^9.4.0", "@nestjs/common": "^9.4.1",
"@nestjs/core": "^9.4.0", "@nestjs/core": "^9.4.1",
"@nestjs/graphql": "^11.0.5", "@nestjs/graphql": "^11.0.5",
"@nestjs/platform-express": "^9.4.0", "@nestjs/platform-express": "^9.4.1",
"@prisma/client": "^4.13.0", "@node-rs/bcrypt": "^1.7.1",
"bcrypt": "^5.1.0", "@prisma/client": "^4.14.1",
"dotenv": "^16.0.3", "dotenv": "^16.0.3",
"express": "^4.18.2", "express": "^4.18.2",
"graphql": "^16.6.0", "graphql": "^16.6.0",
"graphql-type-json": "^0.3.2", "graphql-type-json": "^0.3.2",
"jsonwebtoken": "^9.0.0", "jsonwebtoken": "^9.0.0",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"prisma": "^4.13.0", "prisma": "^4.14.1",
"reflect-metadata": "^0.1.13", "reflect-metadata": "^0.1.13",
"rxjs": "^7.8.1" "rxjs": "^7.8.1"
}, },
"devDependencies": { "devDependencies": {
"@nestjs/testing": "^9.4.0", "@nestjs/testing": "^9.4.1",
"@types/bcrypt": "^5.0.0",
"@types/express": "^4.17.17", "@types/express": "^4.17.17",
"@types/jsonwebtoken": "^9.0.2", "@types/jsonwebtoken": "^9.0.2",
"@types/lodash-es": "^4.17.7", "@types/lodash-es": "^4.17.7",
"@types/node": "^18.16.6", "@types/node": "^18.16.12",
"@types/supertest": "^2.0.12", "@types/supertest": "^2.0.12",
"c8": "^7.13.0", "c8": "^7.13.0",
"nodemon": "^2.0.22", "nodemon": "^2.0.22",
"supertest": "^6.3.3", "supertest": "^6.3.3",
"ts-node": "^10.9.1", "ts-node": "^10.9.1",
"typescript": "^5.0.4", "typescript": "^5.0.4",
"vitest": "^0.31.0" "vitest": "^0.31.1"
}, },
"nodemonConfig": { "nodemonConfig": {
"exec": "node", "exec": "node",

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,8 +3,8 @@ import { afterEach, beforeEach, describe, test } from 'node:test';
import { INestApplication } from '@nestjs/common'; import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing'; import { Test } from '@nestjs/testing';
import { hash } from '@node-rs/bcrypt';
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
import { hash } from 'bcrypt';
import request from 'supertest'; import request from 'supertest';
import { AppModule } from '../app'; import { AppModule } from '../app';
@@ -27,7 +27,7 @@ describe('AppModule', () => {
id: '1', id: '1',
name: 'Alex Yang', name: 'Alex Yang',
email: 'alex.yang@example.org', 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", "name": "@affine/web",
"private": true, "private": true,
"version": "0.5.4-canary.30", "version": "0.5.4-beta.2",
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
@@ -19,32 +19,33 @@
"@affine/jotai": "workspace:*", "@affine/jotai": "workspace:*",
"@affine/templates": "workspace:*", "@affine/templates": "workspace:*",
"@affine/workspace": "workspace:*", "@affine/workspace": "workspace:*",
"@blocksuite/blocks": "0.0.0-20230509052644-b8b1b6a1-nightly", "@blocksuite/blocks": "0.0.0-20230518051344-45970a96-nightly",
"@blocksuite/editor": "0.0.0-20230509052644-b8b1b6a1-nightly", "@blocksuite/editor": "0.0.0-20230518051344-45970a96-nightly",
"@blocksuite/global": "0.0.0-20230509052644-b8b1b6a1-nightly", "@blocksuite/global": "0.0.0-20230518051344-45970a96-nightly",
"@blocksuite/icons": "^2.1.15", "@blocksuite/icons": "^2.1.16",
"@blocksuite/store": "0.0.0-20230509052644-b8b1b6a1-nightly", "@blocksuite/lit": "0.0.0-20230518051344-45970a96-nightly",
"@blocksuite/store": "0.0.0-20230518051344-45970a96-nightly",
"@dnd-kit/core": "^6.0.8", "@dnd-kit/core": "^6.0.8",
"@dnd-kit/sortable": "^7.0.2", "@dnd-kit/sortable": "^7.0.2",
"@emotion/cache": "^11.11.0", "@emotion/cache": "^11.11.0",
"@emotion/react": "^11.11.0", "@emotion/react": "^11.11.0",
"@emotion/server": "^11.11.0", "@emotion/server": "^11.11.0",
"@emotion/styled": "^11.11.0", "@emotion/styled": "^11.11.0",
"@mui/material": "^5.12.3", "@mui/material": "^5.13.1",
"@react-hookz/web": "^23.0.0", "@react-hookz/web": "^23.0.0",
"@sentry/nextjs": "^7.51.2", "@sentry/nextjs": "^7.52.1",
"@toeverything/hooks": "workspace:*", "@toeverything/hooks": "workspace:*",
"cmdk": "^0.2.0", "cmdk": "^0.2.0",
"css-spring": "^4.1.0", "css-spring": "^4.1.0",
"dayjs": "^1.11.7", "dayjs": "^1.11.7",
"graphql": "^16.6.0", "graphql": "^16.6.0",
"jotai": "^2.1.0", "jotai": "^2.1.0",
"jotai-devtools": "^0.5.2", "jotai-devtools": "^0.5.3",
"lit": "^2.7.4", "lit": "^2.7.4",
"lottie-web": "^5.11.0", "lottie-web": "^5.11.0",
"next-themes": "^0.2.1", "next-themes": "^0.2.1",
"react": "^18.2.0", "react": "18.3.0-canary-16d053d59-20230506",
"react-dom": "^18.2.0", "react-dom": "18.3.0-canary-16d053d59-20230506",
"react-is": "^18.2.0", "react-is": "^18.2.0",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"swr": "^2.1.5", "swr": "^2.1.5",
@@ -66,16 +67,16 @@
"@vanilla-extract/next-plugin": "^2.1.2", "@vanilla-extract/next-plugin": "^2.1.2",
"dotenv": "^16.0.3", "dotenv": "^16.0.3",
"eslint": "^8.40.0", "eslint": "^8.40.0",
"eslint-config-next": "^13.4.1", "eslint-config-next": "^13.4.2",
"eslint-plugin-unicorn": "^47.0.0", "eslint-plugin-unicorn": "^47.0.0",
"next": "^13.4.1", "next": "^13.4.2",
"next-debug-local": "^0.1.5", "next-debug-local": "^0.1.5",
"next-router-mock": "^0.9.3", "next-router-mock": "^0.9.3",
"raw-loader": "^4.0.2", "raw-loader": "^4.0.2",
"redux": "^4.2.1", "redux": "^4.2.1",
"swc-plugin-coverage-instrument": "^0.0.18", "swc-plugin-coverage-instrument": "^0.0.18",
"typescript": "^5.0.4", "typescript": "^5.0.4",
"webpack": "^5.82.0" "webpack": "^5.83.1"
}, },
"stableVersion": "0.0.0" "stableVersion": "0.0.0"
} }

View File

@@ -33,4 +33,7 @@ export const buildFlags = {
enableDebugPage: Boolean( enableDebugPage: Boolean(
process.env.ENABLE_DEBUG_PAGE ?? process.env.NODE_ENV === 'development' 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 { createStore } from 'jotai';
import { describe, expect, test } from 'vitest'; import { describe, expect, test } from 'vitest';
import { WorkspacePlugins } from '../../plugins'; import { WorkspaceAdapters } from '../../plugins';
import { rootCurrentWorkspaceAtom } from '../root'; import { rootCurrentWorkspaceAtom } from '../root';
describe('currentWorkspace atom', () => { describe('currentWorkspace atom', () => {
@@ -45,7 +45,7 @@ describe('currentWorkspace atom', () => {
const provider = createIndexedDBDownloadProvider(workspace); const provider = createIndexedDBDownloadProvider(workspace);
provider.sync(); provider.sync();
await provider.whenReady; await provider.whenReady;
const workspaceId = await WorkspacePlugins[ const workspaceId = await WorkspaceAdapters[
WorkspaceFlavour.LOCAL WorkspaceFlavour.LOCAL
].CRUD.create(workspace); ].CRUD.create(workspace);
store.set(rootWorkspacesMetadataAtom, [ 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 { atomWithStorage } from 'jotai/utils';
import type { CreateWorkspaceMode } from '../components/affine/create-workspace-modal'; import type { CreateWorkspaceMode } from '../components/affine/create-workspace-modal';
import { WorkspacePlugins } from '../plugins'; import { WorkspaceAdapters } from '../plugins';
const logger = new DebugLogger('web:atoms'); const logger = new DebugLogger('web:atoms');
@@ -25,7 +25,7 @@ export const currentWorkspaceIdAtom = rootCurrentWorkspaceIdAtom;
// todo(himself65): move this to the workspace package // todo(himself65): move this to the workspace package
rootWorkspacesMetadataAtom.onMount = setAtom => { rootWorkspacesMetadataAtom.onMount = setAtom => {
function createFirst(): RootWorkspaceMetadata[] { function createFirst(): RootWorkspaceMetadata[] {
const Plugins = Object.values(WorkspacePlugins).sort( const Plugins = Object.values(WorkspaceAdapters).sort(
(a, b) => a.loadPriority - b.loadPriority (a, b) => a.loadPriority - b.loadPriority
); );
@@ -40,17 +40,24 @@ rootWorkspacesMetadataAtom.onMount = setAtom => {
}).filter((ids): ids is RootWorkspaceMetadata => !!ids); }).filter((ids): ids is RootWorkspaceMetadata => !!ids);
} }
setAtom(metadata => { const abortController = new AbortController();
if (metadata.length === 0) {
const newMetadata = createFirst(); // next tick to make sure the hydration is correct
logger.info('create first workspace', newMetadata); const id = setTimeout(() => {
return newMetadata; setAtom(metadata => {
} if (abortController.signal.aborted) return metadata;
return metadata; if (metadata.length === 0) {
}); const newMetadata = createFirst();
logger.info('create first workspace', newMetadata);
return newMetadata;
}
return metadata;
});
}, 0);
if (environment.isDesktop) { if (environment.isDesktop) {
window.apis?.workspace.list().then(workspaceIDs => { window.apis?.workspace.list().then(workspaceIDs => {
if (abortController.signal.aborted) return;
const newMetadata = workspaceIDs.map(w => ({ const newMetadata = workspaceIDs.map(w => ({
id: w[0], id: w[0],
flavour: WorkspaceFlavour.LOCAL, 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 { assertExists } from '@blocksuite/store';
import { atom } from 'jotai'; import { atom } from 'jotai';
import { WorkspacePlugins } from '../plugins'; import { WorkspaceAdapters } from '../plugins';
import type { AllWorkspace } from '../shared'; import type { AllWorkspace } from '../shared';
const logger = new DebugLogger('web:atoms:root'); 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 * Fetch all workspaces from the Plugin CRUD
*/ */
export const workspacesAtom = atom<Promise<AllWorkspace[]>>(async get => { 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 plugin => plugin.flavour
); );
const jotaiWorkspaces = get(rootWorkspacesMetadataAtom) const jotaiWorkspaces = get(rootWorkspacesMetadataAtom)
@@ -38,7 +38,7 @@ export const workspacesAtom = atom<Promise<AllWorkspace[]>>(async get => {
const workspaces = await Promise.all( const workspaces = await Promise.all(
jotaiWorkspaces.map(workspace => { jotaiWorkspaces.map(workspace => {
const plugin = const plugin =
WorkspacePlugins[workspace.flavour as keyof typeof WorkspacePlugins]; WorkspaceAdapters[workspace.flavour as keyof typeof WorkspaceAdapters];
assertExists(plugin); assertExists(plugin);
const { CRUD } = plugin; const { CRUD } = plugin;
return CRUD.get(workspace.id).then(workspace => { return CRUD.get(workspace.id).then(workspace => {
@@ -93,7 +93,7 @@ export const rootCurrentWorkspaceAtom = atom<Promise<AllWorkspace>>(
if (!targetWorkspace) { if (!targetWorkspace) {
throw new Error(`cannot find the workspace with id ${targetId}.`); 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 targetWorkspace.id
); );
if (!workspace) { if (!workspace) {

View File

@@ -30,6 +30,7 @@ export const createAffineDownloadProvider = (
new Uint8Array(hashMap.get(id) as ArrayBuffer) new Uint8Array(hashMap.get(id) as ArrayBuffer)
); );
connected = true; connected = true;
callbacks.forEach(cb => cb());
return; return;
} }
affineApis affineApis
@@ -41,6 +42,8 @@ export const createAffineDownloadProvider = (
blockSuiteWorkspace.doc, blockSuiteWorkspace.doc,
new Uint8Array(binary) new Uint8Array(binary)
); );
connected = true;
callbacks.forEach(cb => cb());
}) })
.catch(e => { .catch(e => {
providerLogger.error('downloadWorkspace', 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 { RequestError } from '@affine/workspace/affine/api';
import type { NextRouter } from 'next/router'; import type { NextRouter } from 'next/router';
import type { ErrorInfo, ReactNode } from 'react'; import type { ErrorInfo, ReactNode } from 'react';
import type React from 'react'; import type React from 'react';
import { Component } from 'react'; import { Component } from 'react';
import type { BlockSuiteWorkspace } from '../../shared';
export type AffineErrorBoundaryProps = React.PropsWithChildren<{ export type AffineErrorBoundaryProps = React.PropsWithChildren<{
router: NextRouter; 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 = type AffineError =
| QueryParamError | QueryParamError
| Unreachable | Unreachable

View File

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

View File

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

View File

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

View File

@@ -6,8 +6,10 @@ import { globalStyle, style, styleVariants } from '@vanilla-extract/css';
export const container = style({ export const container = style({
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
padding: '52px 52px 0 52px', marginTop: '52px',
padding: '0 52px 52px 52px',
height: 'calc(100vh - 52px)', height: 'calc(100vh - 52px)',
overflow: 'auto',
}); });
export const sidebar = style({ export const sidebar = style({
@@ -15,7 +17,6 @@ export const sidebar = style({
}); });
export const content = style({ export const content = style({
overflow: 'auto',
flex: 1, flex: 1,
marginTop: '40px', marginTop: '40px',
}); });
@@ -110,7 +111,8 @@ export const settingItemLabelHint = style({
export const row = style({ export const row = style({
padding: '40px 0', padding: '40px 0',
display: 'flex', display: 'flex',
gap: '60px', columnGap: '60px',
rowGap: '12px',
selectors: { selectors: {
'&': { '&': {
borderBottom: '1px solid var(--affine-border-color)', borderBottom: '1px solid var(--affine-border-color)',
@@ -119,22 +121,22 @@ export const row = style({
paddingTop: 0, paddingTop: 0,
}, },
}, },
flexWrap: 'wrap',
}); });
export const col = style({ export const col = style({
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
alignItems: 'flex-start', alignItems: 'flex-start',
flexShrink: 0,
selectors: { selectors: {
[`${row} &:nth-child(1)`]: { [`${row} &:nth-child(1)`]: {
flex: 3, flex: '3 0 200px',
}, },
[`${row} &:nth-child(2)`]: { [`${row} &:nth-child(2)`]: {
flex: 5, flex: '5 0 240px',
}, },
[`${row} &:nth-child(3)`]: { [`${row} &:nth-child(3)`]: {
flex: 2, flex: '2 0 200px',
alignItems: 'flex-end', alignItems: 'flex-end',
}, },
}, },
@@ -156,7 +158,10 @@ export const indicator = style({
export const tabButtonWrapper = style({ export const tabButtonWrapper = style({
display: 'flex', display: 'flex',
position: 'relative', position: 'sticky',
top: '0',
background: 'var(--affine-background-primary-color)',
zIndex: 1,
}); });
export const storageTypeWrapper = style({ export const storageTypeWrapper = style({
@@ -175,6 +180,10 @@ export const storageTypeWrapper = style({
'&:not(:last-child)': { '&:not(:last-child)': {
marginBottom: '12px', 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 { Button, IconButton, Menu, MenuItem, Wrapper } from '@affine/component';
import { config } from '@affine/env'; import { config } from '@affine/env';
import { Unreachable } from '@affine/env/constant';
import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { PermissionType } from '@affine/workspace/affine/api'; 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 { WorkspaceFlavour } from '@affine/workspace/type';
import { import {
DeleteTemporarilyIcon, DeleteTemporarilyIcon,
@@ -14,7 +18,6 @@ import { useCallback, useState } from 'react';
import { useMembers } from '../../../../../hooks/affine/use-members'; import { useMembers } from '../../../../../hooks/affine/use-members';
import { toast } from '../../../../../utils'; import { toast } from '../../../../../utils';
import { Unreachable } from '../../../affine-error-eoundary';
import { TmpDisableAffineCloudModal } from '../../../tmp-disable-affine-cloud-modal'; import { TmpDisableAffineCloudModal } from '../../../tmp-disable-affine-cloud-modal';
import { TransformWorkspaceToAffineModal } from '../../../transform-workspace-to-affine-modal'; import { TransformWorkspaceToAffineModal } from '../../../transform-workspace-to-affine-modal';
import type { PanelProps } from '../../index'; import type { PanelProps } from '../../index';
@@ -37,7 +40,7 @@ import {
const AffineRemoteCollaborationPanel: React.FC< const AffineRemoteCollaborationPanel: React.FC<
Omit<PanelProps, 'workspace'> & { Omit<PanelProps, 'workspace'> & {
workspace: AffineWorkspace; workspace: AffineLegacyCloudWorkspace;
} }
> = ({ workspace }) => { > = ({ workspace }) => {
const [isInviteModalShow, setIsInviteModalShow] = useState(false); const [isInviteModalShow, setIsInviteModalShow] = useState(false);
@@ -214,7 +217,7 @@ const LocalCollaborationPanel: React.FC<
export const CollaborationPanel: React.FC<PanelProps> = props => { export const CollaborationPanel: React.FC<PanelProps> = props => {
switch (props.workspace.flavour) { switch (props.workspace.flavour) {
case WorkspaceFlavour.AFFINE: { case WorkspaceFlavour.AFFINE: {
const workspace = props.workspace as AffineWorkspace; const workspace = props.workspace as AffineLegacyCloudWorkspace;
return ( return (
<AffineRemoteCollaborationPanel {...props} workspace={workspace} /> <AffineRemoteCollaborationPanel {...props} workspace={workspace} />
); );

View File

@@ -15,8 +15,13 @@ export const ExportPanel = () => {
disabled={!environment.isDesktop || !id} disabled={!environment.isDesktop || !id}
data-testid="export-affine-backup" data-testid="export-affine-backup"
onClick={async () => { onClick={async () => {
if (id && (await window.apis?.dialog.saveDBFileAs(id))) { if (id) {
toast(t['Export success']()); 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')(() => { export const StyledWorkspaceName = styled('span')(() => {
return { 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 { useBlockSuiteWorkspaceName } from '@toeverything/hooks/use-block-suite-workspace-name';
import clsx from 'clsx'; import clsx from 'clsx';
import type React from 'react'; import type React from 'react';
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { useIsWorkspaceOwner } from '../../../../../hooks/affine/use-is-workspace-owner'; import { useIsWorkspaceOwner } from '../../../../../hooks/affine/use-is-workspace-owner';
import { Upload } from '../../../../pure/file-upload'; import { Upload } from '../../../../pure/file-upload';
@@ -23,6 +23,26 @@ import { CameraIcon } from './icons';
import { WorkspaceLeave } from './leave'; import { WorkspaceLeave } from './leave';
import { StyledInput } from './style'; 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> = ({ export const GeneralPanel: React.FC<PanelProps> = ({
workspace, workspace,
onDeleteWorkspace, onDeleteWorkspace,
@@ -36,11 +56,36 @@ export const GeneralPanel: React.FC<PanelProps> = ({
const isOwner = useIsWorkspaceOwner(workspace); const isOwner = useIsWorkspaceOwner(workspace);
const t = useAFFiNEI18N(); const t = useAFFiNEI18N();
const dbPathMeta = useDBFilePathMeta(workspace.id);
const showOpenFolder =
environment.isDesktop && dbPathMeta?.path !== dbPathMeta?.realPath;
const handleUpdateWorkspaceName = (name: string) => { const handleUpdateWorkspaceName = (name: string) => {
setName(name); setName(name);
toast(t['Update workspace name success']()); 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( const [, update] = useBlockSuiteWorkspaceAvatarUrl(
workspace.blockSuiteWorkspace workspace.blockSuiteWorkspace
); );
@@ -88,12 +133,11 @@ export const GeneralPanel: React.FC<PanelProps> = ({
<div className={style.col}> <div className={style.col}>
<StyledInput <StyledInput
width={284}
height={38} height={38}
value={input} value={input}
data-testid="workspace-name-input" data-testid="workspace-name-input"
placeholder={t['Workspace Name']()} placeholder={t['Workspace Name']()}
maxLength={50} maxLength={64}
minLength={0} minLength={0}
onChange={newName => { onChange={newName => {
setInput(newName); setInput(newName);
@@ -129,34 +173,33 @@ export const GeneralPanel: React.FC<PanelProps> = ({
</div> </div>
<div className={style.col}> <div className={style.col}>
<div {showOpenFolder && (
className={style.storageTypeWrapper} <div
onClick={() => { className={style.storageTypeWrapper}
if (environment.isDesktop) { onClick={() => {
window.apis?.dialog.revealDBFile(workspace.id); if (environment.isDesktop) {
} window.apis?.dialog.revealDBFile(workspace.id);
}} }
> }}
<FolderIcon color="var(--affine-primary-color)" /> >
<div className={style.storageTypeLabelWrapper}> <FolderIcon color="var(--affine-primary-color)" />
<div className={style.storageTypeLabel}> <div className={style.storageTypeLabelWrapper}>
{t['Open folder']()} <div className={style.storageTypeLabel}>
</div> {t['Open folder']()}
<div className={style.storageTypeLabelHint}> </div>
{t['Open folder hint']()} <div className={style.storageTypeLabelHint}>
{t['Open folder hint']()}
</div>
</div> </div>
<ArrowRightSmallIcon color="var(--affine-primary-color)" />
</div> </div>
<ArrowRightSmallIcon color="var(--affine-primary-color)" /> )}
</div>
<div <div
data-testid="move-folder" data-testid="move-folder"
data-disabled={moveToInProgress}
className={style.storageTypeWrapper} className={style.storageTypeWrapper}
onClick={async () => { onClick={handleMoveTo}
if (await window.apis?.dialog.moveDBFile(workspace.id)) {
toast(t['Move folder success']());
}
}}
> >
<MoveToIcon color="var(--affine-primary-color)" /> <MoveToIcon color="var(--affine-primary-color)" />
<div className={style.storageTypeLabelWrapper}> <div className={style.storageTypeLabelWrapper}>

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { assertExists } from '@blocksuite/store'; import { assertExists } from '@blocksuite/store';
import { useBlockSuitePageMeta } from '@toeverything/hooks/use-block-suite-page-meta'; import { useBlockSuitePageMeta } from '@toeverything/hooks/use-block-suite-page-meta';
import { useAtomValue, useSetAtom } from 'jotai'; import { useAtomValue, useSetAtom } from 'jotai';
@@ -27,6 +28,7 @@ export const EditorModeSwitch = ({
const pageMeta = useBlockSuitePageMeta(blockSuiteWorkspace).find( const pageMeta = useBlockSuitePageMeta(blockSuiteWorkspace).find(
meta => meta.id === pageId meta => meta.id === pageId
); );
const t = useAFFiNEI18N();
assertExists(pageMeta); assertExists(pageMeta);
const { trash } = pageMeta; const { trash } = pageMeta;
@@ -41,8 +43,12 @@ export const EditorModeSwitch = ({
active={currentMode === 'page'} active={currentMode === 'page'}
hide={trash && currentMode !== 'page'} hide={trash && currentMode !== 'page'}
onClick={() => { onClick={() => {
setMode(mode => ({ ...mode, [pageMeta.id]: 'page' })); setMode(mode => {
toast('Page mode'); if (mode[pageMeta.id] !== 'page') {
toast(t['com.affine.pageMode']());
}
return { ...mode, [pageMeta.id]: 'page' };
});
}} }}
/> />
<EdgelessSwitchItem <EdgelessSwitchItem
@@ -50,8 +56,12 @@ export const EditorModeSwitch = ({
active={currentMode === 'edgeless'} active={currentMode === 'edgeless'}
hide={trash && currentMode !== 'edgeless'} hide={trash && currentMode !== 'edgeless'}
onClick={() => { onClick={() => {
setMode(mode => ({ ...mode, [pageMeta.id]: 'edgeless' })); setMode(mode => {
toast('Edgeless mode'); if (mode[pageMeta.id] !== 'edgeless') {
toast(t['com.affine.edgelessMode']());
}
return { ...mode, [pageMeta.id]: 'edgeless' };
});
}} }}
/> />
</StyledEditorModeSwitch> </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 { useCurrentWorkspace } from '../../../../hooks/current/use-current-workspace';
import { toast } from '../../../../utils'; import { toast } from '../../../../utils';
import { MenuThemeModeSwitch } from '../header-right-items/theme-mode-switch'; import { MenuThemeModeSwitch } from '../header-right-items/theme-mode-switch';
import { import * as styles from '../styles.css';
StyledHorizontalDivider,
StyledHorizontalDividerContainer,
} from '../styles';
import { LanguageMenu } from './language-menu'; import { LanguageMenu } from './language-menu';
const CommonMenu = () => { const CommonMenu = () => {
const content = ( const content = (
@@ -43,7 +40,6 @@ const CommonMenu = () => {
return ( return (
<FlexWrapper alignItems="center" justifyContent="center"> <FlexWrapper alignItems="center" justifyContent="center">
<Menu <Menu
width={276}
content={content} content={content}
placement="bottom" placement="bottom"
disablePortal={true} disablePortal={true}
@@ -120,9 +116,9 @@ const PageMenu = () => {
}} }}
/> />
)} )}
<StyledHorizontalDividerContainer> <div className={styles.horizontalDividerContainer}>
<StyledHorizontalDivider /> <div className={styles.horizontalDivider} />
</StyledHorizontalDividerContainer> </div>
</> </>
<div <div
@@ -140,7 +136,6 @@ const PageMenu = () => {
<> <>
<FlexWrapper alignItems="center" justifyContent="center"> <FlexWrapper alignItems="center" justifyContent="center">
<Menu <Menu
width={276}
content={EditMenu} content={EditMenu}
placement="bottom-end" placement="bottom-end"
disablePortal={true} disablePortal={true}
@@ -156,6 +151,7 @@ const PageMenu = () => {
onConfirm={() => { onConfirm={() => {
removeToTrash(pageMeta.id); removeToTrash(pageMeta.id);
toast(t['Moved to Trash']()); toast(t['Moved to Trash']());
setOpenConfirm(false);
}} }}
onCancel={() => { onCancel={() => {
setOpenConfirm(false); setOpenConfirm(false);

View File

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

View File

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

View File

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

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 { assertExists } from '@blocksuite/store';
import { useBlockSuitePageMeta } from '@toeverything/hooks/use-block-suite-page-meta'; import { useBlockSuitePageMeta } from '@toeverything/hooks/use-block-suite-page-meta';
import { useAtom, useAtomValue, useSetAtom } from 'jotai'; import { useSetAtom } from 'jotai';
import type { HTMLAttributes, PropsWithChildren } from 'react'; import type {
import { forwardRef, useCallback, useRef } from 'react'; FC,
HTMLAttributes,
PropsWithChildren,
ReactElement,
} from 'react';
import { useRef } from 'react';
import { currentEditorAtom, openQuickSearchModalAtom } from '../../../atoms'; import { openQuickSearchModalAtom } from '../../../atoms';
import { guideQuickSearchTipsAtom } from '../../../atoms/guide';
import { useElementResizeEffect } from '../../../hooks/use-workspaces';
import { QuickSearchButton } from '../../pure/quick-search-button'; import { QuickSearchButton } from '../../pure/quick-search-button';
import { EditorModeSwitch } from './editor-mode-switch'; import { EditorModeSwitch } from './editor-mode-switch';
import type { BaseHeaderProps } from './header'; import type { BaseHeaderProps } from './header';
import { Header } from './header'; import { Header } from './header';
import { import * as styles from './styles.css';
StyledQuickSearchTipButton,
StyledQuickSearchTipContent,
StyledSearchArrowWrapper,
StyledSwitchWrapper,
StyledTitle,
StyledTitleContainer,
StyledTitleWrapper,
} from './styles';
export type WorkspaceHeaderProps = BaseHeaderProps; export type WorkspaceHeaderProps = BaseHeaderProps;
export const WorkspaceHeader = forwardRef< export const WorkspaceHeader: FC<
HTMLDivElement,
PropsWithChildren<WorkspaceHeaderProps> & HTMLAttributes<HTMLDivElement> PropsWithChildren<WorkspaceHeaderProps> & HTMLAttributes<HTMLDivElement>
>((props, ref) => { > = (props): ReactElement => {
const { workspace, currentPage, children, isPublic } = props; const { workspace, currentPage, children, isPublic } = props;
// fixme(himself65): remove this atom and move it to props // fixme(himself65): remove this atom and move it to props
const setOpenQuickSearch = useSetAtom(openQuickSearchModalAtom); const setOpenQuickSearch = useSetAtom(openQuickSearchModalAtom);
const pageMeta = useBlockSuitePageMeta(workspace.blockSuiteWorkspace).find( const pageMeta = useBlockSuitePageMeta(workspace.blockSuiteWorkspace).find(
meta => meta.id === currentPage?.id meta => meta.id === currentPage?.id
); );
const headerRef = useRef<HTMLDivElement>(null);
assertExists(pageMeta); assertExists(pageMeta);
const title = pageMeta.title; 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 ( return (
<Header ref={ref} {...props}> <Header ref={headerRef} {...props}>
{children} {children}
{!isPublic && currentPage && ( {!isPublic && currentPage && (
<StyledTitleContainer data-tauri-drag-region> <div className={styles.titleContainer}>
<StyledTitleWrapper> <div className={styles.titleWrapper}>
<StyledSwitchWrapper> <div className={styles.switchWrapper}>
<EditorModeSwitch <EditorModeSwitch
blockSuiteWorkspace={workspace.blockSuiteWorkspace} blockSuiteWorkspace={workspace.blockSuiteWorkspace}
pageId={currentPage.id} pageId={currentPage.id}
@@ -101,29 +45,21 @@ export const WorkspaceHeader = forwardRef<
marginRight: '12px', marginRight: '12px',
}} }}
/> />
</StyledSwitchWrapper> </div>
<StyledTitle>{title || 'Untitled'}</StyledTitle> <div className={styles.title}>{title || 'Untitled'}</div>
<QuickSearchTips
data-testid="quick-search-tips" <div className={styles.searchArrowWrapper}>
content={TipsContent} <QuickSearchButton
placement="bottom" onClick={() => {
popperRef={popperRef} setOpenQuickSearch(true);
open={showQuickSearchTips} }}
offset={[0, -5]} />
> </div>
<StyledSearchArrowWrapper> </div>
<QuickSearchButton </div>
onClick={() => {
setOpenQuickSearch(true);
}}
/>
</StyledSearchArrowWrapper>
</QuickSearchTips>
</StyledTitleWrapper>
</StyledTitleContainer>
)} )}
</Header> </Header>
); );
}); };
WorkspaceHeader.displayName = 'BlockSuiteEditorHeader'; 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 { EditorContainer } from '@blocksuite/editor';
import type { Page } from '@blocksuite/store'; import type { Page } from '@blocksuite/store';
import { assertExists } from '@blocksuite/store'; import { assertExists } from '@blocksuite/store';
@@ -11,7 +12,6 @@ import { startTransition, useCallback } from 'react';
import { currentEditorAtom, workspacePreferredModeAtom } from '../atoms'; import { currentEditorAtom, workspacePreferredModeAtom } from '../atoms';
import type { AffineOfficialWorkspace } from '../shared'; import type { AffineOfficialWorkspace } from '../shared';
import { PageNotFoundError } from './affine/affine-error-eoundary';
import { BlockSuiteEditor as Editor } from './blocksuite/block-suite-editor'; import { BlockSuiteEditor as Editor } from './blocksuite/block-suite-editor';
import { WorkspaceHeader } from './blocksuite/workspace-header'; import { WorkspaceHeader } from './blocksuite/workspace-header';
@@ -84,6 +84,7 @@ export const PageDetailEditor: React.FC<PageDetailEditorProps> = ({
page.workspace.setPageMeta(page.id, { page.workspace.setPageMeta(page.id, {
updatedDate: Date.now(), updatedDate: Date.now(),
}); });
localStorage.setItem('last_page_id', page.id);
onLoad?.(page, editor); onLoad?.(page, editor);
}, },
[onLoad, setEditor] [onLoad, setEditor]

View File

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

View File

@@ -1,6 +1,4 @@
import { styled } from '@affine/component';
import { AffineLoading } from '@affine/component/affine-loading'; import { AffineLoading } from '@affine/component/affine-loading';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { memo, Suspense } from 'react'; import { memo, Suspense } from 'react';
export const Loading = memo(function Loading() { 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')(() => { * @deprecated use skeleton instead
return { */
height: '100vh', export const PageLoading = () => {
display: 'flex', return null;
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>
);
}; };
export default PageLoading;

View File

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

View File

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

View File

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

View File

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

View File

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

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 { 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 { useAtomValue } from 'jotai';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useMemo } from 'react'; import { useMemo, useState } from 'react';
import { workspacePreferredModeAtom } from '../../../../atoms'; import { workspacePreferredModeAtom } from '../../../../atoms';
import type { FavoriteListProps } from '../index'; import type { FavoriteListProps } from '../index';
import { StyledCollapseItem } from '../shared-styles';
import EmptyItem from './empty-item'; import EmptyItem from './empty-item';
export const FavoriteList = ({ import * as styles from './styles.css';
pageMeta,
openPage, interface FavoriteMenuItemProps {
showList, workspace: Workspace;
}: FavoriteListProps) => { pageId: string;
metaMapping: Record<string, PageMeta>;
parentIds: Set<string>;
}
function FavoriteMenuItem({
workspace,
pageId,
metaMapping,
parentIds,
}: FavoriteMenuItemProps) {
const router = useRouter(); const router = useRouter();
const record = useAtomValue(workspacePreferredModeAtom); 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( const favoriteList = useMemo(
() => pageMeta.filter(p => p.favorite && !p.trash), () => metas.filter(p => p.favorite && !p.trash),
[pageMeta] [metas]
);
const metaMapping = useMemo(
() =>
metas.reduce((acc, meta) => {
acc[meta.id] = meta;
return acc;
}, {} as Record<string, PageMeta>),
[metas]
); );
return ( return (
<MuiCollapse <>
in={showList}
style={{
maxHeight: 300,
overflowY: 'auto',
marginLeft: '16px',
}}
>
{favoriteList.map((pageMeta, index) => { {favoriteList.map((pageMeta, index) => {
const active = router.query.pageId === pageMeta.id;
return ( return (
<div key={`${pageMeta}-${index}`}> <FavoriteMenuItem
<StyledCollapseItem key={`${pageMeta}-${index}`}
data-testid={`favorite-list-item-${pageMeta.id}`} metaMapping={metaMapping}
active={active} pageId={pageMeta.id}
ref={ref => { // memo?
if (ref && active) { parentIds={new Set()}
ref.scrollIntoView({ behavior: 'smooth' }); workspace={currentWorkspace.blockSuiteWorkspace}
} />
}}
onClick={() => {
if (active) {
return;
}
openPage(pageMeta.id);
}}
>
{record[pageMeta.id] === 'edgeless' ? (
<EdgelessIcon />
) : (
<PageIcon />
)}
<span>{pageMeta.title || 'Untitled'}</span>
</StyledCollapseItem>
</div>
); );
})} })}
{favoriteList.length === 0 && <EmptyItem />} {favoriteList.length === 0 && <EmptyItem />}
</MuiCollapse> </>
); );
}; };

View File

@@ -1,66 +1 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks'; export * from './favorite-list';
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;

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'; import type { AllWorkspace } from '../../../shared';
export type FavoriteListProps = { export type FavoriteListProps = {
currentPageId: string | null; currentWorkspace: AllWorkspace;
openPage: (pageId: string) => void;
showList: boolean;
pageMeta: PageMeta[];
}; };
export type WorkSpaceSliderBarProps = { 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 { openQuickSearchModalAtom } from '../../../atoms';
import type { HeaderProps } from '../../blocksuite/workspace-header/header'; import type { HeaderProps } from '../../blocksuite/workspace-header/header';
import { Header } 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'; import { QuickSearchButton } from '../quick-search-button';
export type WorkspaceTitleProps = React.PropsWithChildren< export type WorkspaceTitleProps = React.PropsWithChildren<
@@ -22,15 +22,15 @@ export const WorkspaceTitle: React.FC<WorkspaceTitleProps> = ({
const setOpenQuickSearch = useSetAtom(openQuickSearchModalAtom); const setOpenQuickSearch = useSetAtom(openQuickSearchModalAtom);
return ( return (
<Header {...props}> <Header {...props}>
<StyledPageListTittleWrapper> <div className={styles.pageListTitleWrapper}>
{icon} <div className={styles.pageListTitleIcon}>{icon}</div>
{children} {children}
<QuickSearchButton <QuickSearchButton
onClick={() => { onClick={() => {
setOpenQuickSearch(true); setOpenQuickSearch(true);
}} }}
/> />
</StyledPageListTittleWrapper> </div>
</Header> </Header>
); );
}; };

View File

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

View File

@@ -5,11 +5,7 @@ import 'fake-indexeddb/auto';
import assert from 'node:assert'; import assert from 'node:assert';
import { import { rootCurrentWorkspaceIdAtom } from '@affine/workspace/atom';
rootCurrentWorkspaceIdAtom,
rootWorkspacesMetadataAtom,
} from '@affine/workspace/atom';
import type { LocalWorkspace } from '@affine/workspace/type';
import { WorkspaceFlavour } from '@affine/workspace/type'; import { WorkspaceFlavour } from '@affine/workspace/type';
import type { PageBlockModel } from '@blocksuite/blocks'; import type { PageBlockModel } from '@blocksuite/blocks';
import { __unstableSchemas, AffineSchemas } from '@blocksuite/blocks/models'; import { __unstableSchemas, AffineSchemas } from '@blocksuite/blocks/models';
@@ -21,23 +17,17 @@ import {
usePageMetaHelper, usePageMetaHelper,
} from '@toeverything/hooks/use-block-suite-page-meta'; } from '@toeverything/hooks/use-block-suite-page-meta';
import { createStore, Provider } from 'jotai'; import { createStore, Provider } from 'jotai';
import { useRouter } from 'next/router';
import routerMock from 'next-router-mock'; import routerMock from 'next-router-mock';
import { createDynamicRouteParser } from 'next-router-mock/dynamic-routes'; import { createDynamicRouteParser } from 'next-router-mock/dynamic-routes';
import type React from 'react'; import type React from 'react';
import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest';
import { currentWorkspaceIdAtom, workspacesAtom } from '../../atoms'; import { workspacesAtom } from '../../atoms';
import { LocalPlugin } from '../../plugins/local';
import { BlockSuiteWorkspace, WorkspaceSubPath } from '../../shared'; import { BlockSuiteWorkspace, WorkspaceSubPath } from '../../shared';
import { import {
currentWorkspaceAtom, currentWorkspaceAtom,
useCurrentWorkspace, useCurrentWorkspace,
} from '../current/use-current-workspace'; } from '../current/use-current-workspace';
import {
useRecentlyViewed,
useSyncRecentViewsWithRouter,
} from '../use-recent-views';
import { useAppHelper, useWorkspaces } from '../use-workspaces'; import { useAppHelper, useWorkspaces } from '../use-workspaces';
vi.mock( vi.mock(
@@ -202,6 +192,8 @@ describe('useWorkspaces', () => {
const { result } = renderHook(() => useAppHelper(), { const { result } = renderHook(() => useAppHelper(), {
wrapper: ProviderWrapper, wrapper: ProviderWrapper,
}); });
// next tick
await new Promise(resolve => setTimeout(resolve, 100));
{ {
const workspaces = await store.get(workspacesAtom); const workspaces = await store.get(workspacesAtom);
expect(workspaces.length).toEqual(1); expect(workspaces.length).toEqual(1);
@@ -221,58 +213,3 @@ describe('useWorkspaces', () => {
expect(firstWorkspace.blockSuiteWorkspace.meta.name).toBe('test'); 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([ createDynamicRouteParser([
'/workspace/[workspaceId]/[pageId]', '/workspace/[workspaceId]/[pageId]',
'/workspace/[workspaceId]/all', '/workspace/[workspaceId]/all',
'/workspace/[workspaceId]/favorite',
'/workspace/[workspaceId]/trash', '/workspace/[workspaceId]/trash',
'/workspace/[workspaceId]/setting', '/workspace/[workspaceId]/setting',
'/workspace/[workspaceId]/shared', '/workspace/[workspaceId]/shared',
@@ -54,19 +53,6 @@ describe('useRouterHelper', () => {
// routerHook.result.current.back() // routerHook.result.current.back()
// routerHook.rerender() // routerHook.rerender()
// expect(routerHook.result.current.pathname).toBe('/') // 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 () => { 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 { useRouter } from 'next/router';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { WorkspacePlugins } from '../../plugins'; import { WorkspaceAdapters } from '../../plugins';
export function useAffineLogIn() { export function useAffineLogIn() {
const router = useRouter(); const router = useRouter();
return useCallback(async () => { return useCallback(async () => {
await WorkspacePlugins[WorkspaceFlavour.AFFINE].Events[ await WorkspaceAdapters[WorkspaceFlavour.AFFINE].Events[
'workspace:access' 'workspace:access'
]?.(); ]?.();
// todo: remove reload page requirement // todo: remove reload page requirement

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