Compare commits

..

1 Commits

Author SHA1 Message Date
renovate[bot]
c6332f252b chore: bump up nestjs 2026-02-08 07:58:28 +00:00
693 changed files with 8955 additions and 27312 deletions

View File

@@ -222,7 +222,7 @@
},
"SMTP.sender": {
"type": "string",
"description": "Sender of all the emails (e.g. \"AFFiNE Self Hosted &lt;noreply@example.com&gt;\")\n@default \"AFFiNE Self Hosted <noreply@example.com>\"\n@environment `MAILER_SENDER`",
"description": "Sender of all the emails (e.g. \"AFFiNE Self Hosted <noreply@example.com>\")\n@default \"AFFiNE Self Hosted <noreply@example.com>\"\n@environment `MAILER_SENDER`",
"default": "AFFiNE Self Hosted <noreply@example.com>"
},
"SMTP.ignoreTLS": {
@@ -262,7 +262,7 @@
},
"fallbackSMTP.sender": {
"type": "string",
"description": "Sender of all the emails (e.g. \"AFFiNE Self Hosted &lt;noreply@example.com&gt;\")\n@default \"\"",
"description": "Sender of all the emails (e.g. \"AFFiNE Self Hosted <noreply@example.com>\")\n@default \"\"",
"default": ""
},
"fallbackSMTP.ignoreTLS": {

View File

@@ -3,6 +3,6 @@ contact_links:
- name: Something else?
url: https://github.com/toeverything/AFFiNE/discussions
about: Feel free to ask and answer questions over in GitHub Discussions
- name: AFFiNE Community Support (Discord)
url: https://affine.pro/redirect/discord
- name: AFFiNE Community Support
url: https://community.affine.pro
about: AFFiNE Community - a place to ask, learn and engage with others

View File

@@ -1,20 +0,0 @@
self-hosted-runner:
# Labels of self-hosted runner in array of strings.
labels:
- win-signer
# Configuration variables in array of strings defined in your repository or
# organization. `null` means disabling configuration variables check.
# Empty array means no configuration variable is allowed.
config-variables: null
# Configuration for file paths. The keys are glob patterns to match to file
# paths relative to the repository root. The values are the configurations for
# the file paths. Note that the path separator is always '/'.
# The following configurations are available.
#
# "ignore" is an array of regular expression patterns. Matched error messages
# are ignored. This is similar to the "-ignore" command line option.
paths:
# .github/workflows/**/*.yml:
# ignore: []

View File

@@ -25,30 +25,30 @@ const buildType = BUILD_TYPE || 'canary';
const isProduction = buildType === 'stable';
const isBeta = buildType === 'beta';
const isCanary = buildType === 'canary';
const isInternal = buildType === 'internal';
const isSpotEnabled = isBeta || isCanary;
const replicaConfig = {
stable: {
front: Number(process.env.PRODUCTION_FRONT_REPLICA) || 2,
graphql: Number(process.env.PRODUCTION_GRAPHQL_REPLICA) || 2,
doc: Number(process.env.PRODUCTION_DOC_REPLICA) || 2,
},
beta: {
front: Number(process.env.BETA_FRONT_REPLICA) || 1,
graphql: Number(process.env.BETA_GRAPHQL_REPLICA) || 1,
doc: Number(process.env.BETA_DOC_REPLICA) || 1,
},
canary: { front: 1, graphql: 1 },
canary: { front: 1, graphql: 1, doc: 1 },
};
const cpuConfig = {
beta: { front: '1', graphql: '1' },
canary: { front: '500m', graphql: '1' },
beta: { front: '1', graphql: '1', doc: '1' },
canary: { front: '500m', graphql: '1', doc: '500m' },
};
const memoryConfig = {
beta: { front: '2Gi', graphql: '1Gi' },
canary: { front: '512Mi', graphql: '512Mi' },
beta: { front: '1Gi', graphql: '1Gi', doc: '1Gi' },
canary: { front: '512Mi', graphql: '512Mi', doc: '512Mi' },
};
const createHelmCommand = ({ isDryRun }) => {
@@ -72,12 +72,10 @@ const createHelmCommand = ({ isDryRun }) => {
`--set-string global.indexer.endpoint="${AFFINE_INDEXER_SEARCH_ENDPOINT}"`,
`--set-string global.indexer.apiKey="${AFFINE_INDEXER_SEARCH_API_KEY}"`,
];
const cloudSqlNodeSelector = isBeta
? `{ \\"iam.gke.io/gke-metadata-server-enabled\\": \\"true\\", \\"cloud.google.com/gke-spot\\": \\"true\\" }`
: `{ \\"iam.gke.io/gke-metadata-server-enabled\\": \\"true\\" }`;
const serviceAnnotations = [
`--set-json front.serviceAccount.annotations="{ \\"iam.gke.io/gcp-service-account\\": \\"${APP_IAM_ACCOUNT}\\" }"`,
`--set-json graphql.serviceAccount.annotations="{ \\"iam.gke.io/gcp-service-account\\": \\"${APP_IAM_ACCOUNT}\\" }"`,
`--set-json doc.serviceAccount.annotations="{ \\"iam.gke.io/gcp-service-account\\": \\"${APP_IAM_ACCOUNT}\\" }"`,
].concat(
isProduction || isBeta || isInternal
? [
@@ -86,17 +84,10 @@ const createHelmCommand = ({ isDryRun }) => {
`--set-json front.services.renderer.annotations="{ \\"cloud.google.com/neg\\": \\"{\\\\\\"ingress\\\\\\": true}\\" }"`,
`--set-json graphql.service.annotations="{ \\"cloud.google.com/neg\\": \\"{\\\\\\"ingress\\\\\\": true}\\" }"`,
`--set-json cloud-sql-proxy.serviceAccount.annotations="{ \\"iam.gke.io/gcp-service-account\\": \\"${CLOUD_SQL_IAM_ACCOUNT}\\" }"`,
`--set-json cloud-sql-proxy.nodeSelector="${cloudSqlNodeSelector}"`,
`--set-json cloud-sql-proxy.nodeSelector="{ \\"iam.gke.io/gke-metadata-server-enabled\\": \\"true\\" }"`,
]
: []
);
const spotNodeSelector = `{ \\"cloud.google.com/gke-spot\\": \\"true\\" }`;
const spotScheduling = isSpotEnabled
? [
`--set-json front.nodeSelector="${spotNodeSelector}"`,
`--set-json graphql.nodeSelector="${spotNodeSelector}"`,
]
: [];
const cpu = cpuConfig[buildType];
const memory = memoryConfig[buildType];
@@ -105,12 +96,14 @@ const createHelmCommand = ({ isDryRun }) => {
resources = resources.concat([
`--set front.resources.requests.cpu="${cpu.front}"`,
`--set graphql.resources.requests.cpu="${cpu.graphql}"`,
`--set doc.resources.requests.cpu="${cpu.doc}"`,
]);
}
if (memory) {
resources = resources.concat([
`--set front.resources.requests.memory="${memory.front}"`,
`--set graphql.resources.requests.memory="${memory.graphql}"`,
`--set doc.resources.requests.memory="${memory.doc}"`,
]);
}
@@ -149,8 +142,10 @@ const createHelmCommand = ({ isDryRun }) => {
`--set graphql.replicaCount=${replica.graphql}`,
`--set-string graphql.image.tag="${imageTag}"`,
`--set-string graphql.app.host="${primaryHost}"`,
`--set-string doc.image.tag="${imageTag}"`,
`--set-string doc.app.host="${primaryHost}"`,
`--set doc.replicaCount=${replica.doc}`,
...serviceAnnotations,
...spotScheduling,
...resources,
`--timeout 10m`,
flag,

View File

@@ -7,6 +7,7 @@ inputs:
ios-app-version:
description: 'iOS App Store Version (Optional, use App version if empty)'
required: false
type: string
runs:
using: 'composite'
steps:

View File

@@ -3,4 +3,4 @@ name: affine
description: AFFiNE cloud chart
type: application
version: 0.0.0
appVersion: "0.26.3"
appVersion: "0.26.1"

View File

@@ -3,7 +3,7 @@ name: doc
description: AFFiNE doc server
type: application
version: 0.0.0
appVersion: "0.26.3"
appVersion: "0.26.1"
dependencies:
- name: gcloud-sql-proxy
version: 0.0.0

View File

@@ -0,0 +1,16 @@
1. Get the application URL by running these commands:
{{- if contains "NodePort" .Values.service.type }}
export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "doc.fullname" . }})
export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
echo http://$NODE_IP:$NODE_PORT
{{- else if contains "LoadBalancer" .Values.service.type }}
NOTE: It may take a few minutes for the LoadBalancer IP to be available.
You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "doc.fullname" . }}'
export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "doc.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}")
echo http://$SERVICE_IP:{{ .Values.service.port }}
{{- else if contains "ClusterIP" .Values.service.type }}
export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "doc.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}")
export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}")
echo "Visit http://127.0.0.1:8080 to use your application"
kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT
{{- end }}

View File

@@ -0,0 +1,63 @@
{{/*
Expand the name of the chart.
*/}}
{{- define "doc.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
If release name contains chart name it will be used as a full name.
*/}}
{{- define "doc.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}
{{/*
Create chart name and version as used by the chart label.
*/}}
{{- define "doc.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Common labels
*/}}
{{- define "doc.labels" -}}
helm.sh/chart: {{ include "doc.chart" . }}
{{ include "doc.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
monitoring: enabled
{{- end }}
{{/*
Selector labels
*/}}
{{- define "doc.selectorLabels" -}}
app.kubernetes.io/name: {{ include "doc.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{/*
Create the name of the service account to use
*/}}
{{- define "doc.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "doc.fullname" .) .Values.global.docService.name }}
{{- else }}
{{- default "default" .Values.global.docService.name }}
{{- end }}
{{- end }}

View File

@@ -0,0 +1,118 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "doc.fullname" . }}
labels:
{{- include "doc.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
{{- include "doc.selectorLabels" . | nindent 6 }}
template:
metadata:
{{- with .Values.podAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "doc.selectorLabels" . | nindent 8 }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
serviceAccountName: {{ include "doc.serviceAccountName" . }}
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
env:
- name: AFFINE_PRIVATE_KEY
valueFrom:
secretKeyRef:
name: "{{ .Values.global.secret.secretName }}"
key: key
- name: NODE_ENV
value: "{{ .Values.env }}"
- name: NODE_OPTIONS
value: "--max-old-space-size=4096"
- name: NO_COLOR
value: "1"
- name: DEPLOYMENT_TYPE
value: "{{ .Values.global.deployment.type }}"
- name: DEPLOYMENT_PLATFORM
value: "{{ .Values.global.deployment.platform }}"
- name: SERVER_FLAVOR
value: "doc"
- name: AFFINE_ENV
value: "{{ .Release.Namespace }}"
- name: DATABASE_PASSWORD
valueFrom:
secretKeyRef:
name: pg-postgresql
key: postgres-password
- name: DATABASE_URL
value: postgres://{{ .Values.global.database.user }}:$(DATABASE_PASSWORD)@{{ .Values.global.database.host }}:{{ .Values.global.database.port }}/{{ .Values.global.database.name }}
- name: REDIS_SERVER_ENABLED
value: "true"
- name: REDIS_SERVER_HOST
value: "{{ .Values.global.redis.host }}"
- name: REDIS_SERVER_PORT
value: "{{ .Values.global.redis.port }}"
- name: REDIS_SERVER_USER
value: "{{ .Values.global.redis.username }}"
- name: REDIS_SERVER_PASSWORD
valueFrom:
secretKeyRef:
name: redis
key: redis-password
- name: REDIS_SERVER_DATABASE
value: "{{ .Values.global.redis.database }}"
- name: AFFINE_INDEXER_SEARCH_PROVIDER
value: "{{ .Values.global.indexer.provider }}"
- name: AFFINE_INDEXER_SEARCH_ENDPOINT
value: "{{ .Values.global.indexer.endpoint }}"
- name: AFFINE_INDEXER_SEARCH_API_KEY
valueFrom:
secretKeyRef:
name: indexer
key: indexer-apiKey
- name: AFFINE_SERVER_PORT
value: "{{ .Values.global.docService.port }}"
- name: AFFINE_SERVER_SUB_PATH
value: "{{ .Values.app.path }}"
- name: AFFINE_SERVER_HOST
value: "{{ .Values.app.host }}"
- name: AFFINE_SERVER_HTTPS
value: "{{ .Values.app.https }}"
ports:
- name: http
containerPort: {{ .Values.global.docService.port }}
protocol: TCP
livenessProbe:
httpGet:
path: /info
port: http
initialDelaySeconds: {{ .Values.probe.initialDelaySeconds }}
timeoutSeconds: {{ .Values.probe.timeoutSeconds }}
readinessProbe:
httpGet:
path: /info
port: http
initialDelaySeconds: {{ .Values.probe.initialDelaySeconds }}
timeoutSeconds: {{ .Values.probe.timeoutSeconds }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}

View File

@@ -1,19 +1,19 @@
apiVersion: v1
kind: Service
metadata:
name: {{ .Values.global.docService.name }}
name: {{ include "doc.fullname" . }}
labels:
{{- include "front.labels" . | nindent 4 }}
{{- with .Values.services.doc.annotations }}
{{- include "doc.labels" . | nindent 4 }}
{{- with .Values.service.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
type: {{ .Values.services.doc.type }}
type: {{ .Values.service.type }}
ports:
- port: {{ .Values.global.docService.port }}
targetPort: http
protocol: TCP
name: http
selector:
{{- include "front.selectorLabels" . | nindent 4 }}
{{- include "doc.selectorLabels" . | nindent 4 }}

View File

@@ -0,0 +1,12 @@
{{- if .Values.serviceAccount.create -}}
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ include "doc.serviceAccountName" . }}
labels:
{{- include "doc.labels" . | nindent 4 }}
{{- with .Values.serviceAccount.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- end }}

View File

@@ -0,0 +1,15 @@
apiVersion: v1
kind: Pod
metadata:
name: "{{ include "doc.fullname" . }}-test-connection"
labels:
{{- include "doc.labels" . | nindent 4 }}
annotations:
"helm.sh/hook": test
spec:
containers:
- name: wget
image: busybox
command: ['wget']
args: ['{{ include "doc.fullname" . }}:{{ .Values.global.docService.port }}']
restartPolicy: Never

View File

@@ -30,12 +30,9 @@ podSecurityContext:
fsGroup: 2000
resources:
limits:
cpu: '1'
memory: 4Gi
requests:
cpu: '1'
memory: 2Gi
memory: 4Gi
probe:
initialDelaySeconds: 20

View File

@@ -3,7 +3,7 @@ name: front
description: AFFiNE front server
type: application
version: 0.0.0
appVersion: "0.26.3"
appVersion: "0.26.1"
dependencies:
- name: gcloud-sql-proxy
version: 0.0.0

View File

@@ -88,6 +88,8 @@ spec:
value: "{{ .Values.app.host }}"
- name: AFFINE_SERVER_HTTPS
value: "{{ .Values.app.https }}"
- name: DOC_SERVICE_ENDPOINT
value: "http://{{ .Values.global.docService.name }}:{{ .Values.global.docService.port }}"
ports:
- name: http
containerPort: {{ .Values.app.port }}
@@ -96,20 +98,12 @@ spec:
httpGet:
path: /info
port: http
initialDelaySeconds: {{ default .Values.probe.initialDelaySeconds .Values.probe.liveness.initialDelaySeconds }}
timeoutSeconds: {{ default .Values.probe.timeoutSeconds .Values.probe.liveness.timeoutSeconds }}
periodSeconds: {{ default .Values.probe.periodSeconds .Values.probe.liveness.periodSeconds }}
failureThreshold: {{ default .Values.probe.failureThreshold .Values.probe.liveness.failureThreshold }}
successThreshold: {{ default .Values.probe.successThreshold .Values.probe.liveness.successThreshold }}
initialDelaySeconds: {{ .Values.probe.initialDelaySeconds }}
readinessProbe:
httpGet:
path: /info
port: http
initialDelaySeconds: {{ default .Values.probe.initialDelaySeconds .Values.probe.readiness.initialDelaySeconds }}
timeoutSeconds: {{ default .Values.probe.timeoutSeconds .Values.probe.readiness.timeoutSeconds }}
periodSeconds: {{ default .Values.probe.periodSeconds .Values.probe.readiness.periodSeconds }}
failureThreshold: {{ default .Values.probe.failureThreshold .Values.probe.readiness.failureThreshold }}
successThreshold: {{ default .Values.probe.successThreshold .Values.probe.readiness.successThreshold }}
initialDelaySeconds: {{ .Values.probe.initialDelaySeconds }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
{{- with .Values.nodeSelector }}

View File

@@ -29,23 +29,12 @@ podSecurityContext:
fsGroup: 2000
resources:
limits:
cpu: '1'
memory: 4Gi
requests:
cpu: '1'
memory: 2Gi
probe:
initialDelaySeconds: 20
timeoutSeconds: 5
periodSeconds: 10
failureThreshold: 6
successThreshold: 1
liveness:
initialDelaySeconds: 60
failureThreshold: 12
readiness: {}
services:
sync:
@@ -65,9 +54,6 @@ services:
type: ClusterIP
port: 8080
annotations: {}
doc:
type: ClusterIP
annotations: {}
nodeSelector: {}
tolerations: []

View File

@@ -3,7 +3,7 @@ name: graphql
description: AFFiNE GraphQL server
type: application
version: 0.0.0
appVersion: "0.26.3"
appVersion: "0.26.1"
dependencies:
- name: gcloud-sql-proxy
version: 0.0.0

View File

@@ -27,11 +27,8 @@ podSecurityContext:
fsGroup: 2000
resources:
limits:
cpu: '1'
memory: 4Gi
requests:
cpu: '1'
cpu: '2'
memory: 2Gi
probe:

View File

@@ -47,6 +47,12 @@ graphql:
annotations:
cloud.google.com/backend-config: '{"default": "affine-api-backendconfig"}'
doc:
service:
type: ClusterIP
annotations:
cloud.google.com/backend-config: '{"default": "affine-api-backendconfig"}'
front:
services:
sync:
@@ -65,7 +71,3 @@ front:
name: affine-web
type: ClusterIP
port: 8080
doc:
type: ClusterIP
annotations:
cloud.google.com/backend-config: '{"default": "affine-api-backendconfig"}'

View File

@@ -1,10 +1,6 @@
name: 'Pull Request Labeler'
on:
pull_request_target:
types:
- opened
- reopened
- synchronize
- pull_request_target
jobs:
triage:

View File

@@ -68,26 +68,9 @@ jobs:
runs-on: ubuntu-24.04-arm
steps:
- uses: actions/checkout@v4
- name: Setup Go (for actionlint)
uses: actions/setup-go@v5
with:
go-version: 'stable'
- name: Install actionlint
shell: bash
run: |
set -euo pipefail
go install github.com/rhysd/actionlint/cmd/actionlint@v1.7.11
- name: Run actionlint
shell: bash
run: |
set -euo pipefail
"$(go env GOPATH)/bin/actionlint"
- name: Run oxlint
# oxlint is fast, so wrong code will fail quickly
run: |
set -euo pipefail
oxlint_version="$(node -e "console.log(require('./package.json').devDependencies.oxlint)")"
yarn dlx "oxlint@${oxlint_version}" --deny-warnings
run: yarn dlx $(node -e "console.log(require('./package.json').scripts['lint:ox'].replace('oxlint', 'oxlint@' + require('./package.json').devDependencies.oxlint))")
- name: Setup Node.js
uses: ./.github/actions/setup-node
with:
@@ -125,45 +108,20 @@ jobs:
run: |
yarn affine bs-docs build
git checkout packages/frontend/i18n/src/i18n-completenesses.json
if git status --porcelain | grep -q .; then
git status --porcelain | grep . && {
echo "Run 'yarn typecheck && yarn affine bs-docs build' and make sure all changes are submitted"
exit 1
else
} || {
echo "All changes are submitted"
fi
rust-test-filter:
name: Rust test filter
runs-on: ubuntu-latest
outputs:
run-rust: ${{ steps.rust-filter.outputs.rust }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: rust-filter
with:
filters: |
rust:
- '**/*.rs'
- '**/Cargo.toml'
- '**/Cargo.lock'
- '.cargo/**'
- 'rust-toolchain*'
- '.github/actions/build-rust/**'
}
lint-rust:
name: Lint Rust
if: ${{ needs.rust-test-filter.outputs.run-rust == 'true' }}
runs-on: ubuntu-latest
needs:
- rust-test-filter
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/build-rust
with:
target: x86_64-unknown-linux-gnu
package: 'affine'
no-build: 'true'
- name: fmt check
run: |
@@ -201,12 +159,12 @@ jobs:
yarn affine i18n build
yarn affine server genconfig
git checkout packages/frontend/i18n/src/i18n-completenesses.json
if git status --porcelain | grep -q .; then
git status --porcelain | grep . && {
echo "Run 'yarn affine init && yarn affine gql build && yarn affine i18n build && yarn affine server genconfig' and make sure all changes are submitted"
exit 1
else
} || {
echo "All changes are submitted"
fi
}
check-yarn-binary:
name: Check yarn binary
@@ -215,9 +173,7 @@ jobs:
- uses: actions/checkout@v4
- name: Run check
run: |
set -euo pipefail
yarn_version="$(node -e "console.log(require('./package.json').packageManager.split('@')[1])")"
yarn set version "$yarn_version"
yarn set version $(node -e "console.log(require('./package.json').packageManager.split('@')[1])")
git diff --exit-code
e2e-blocksuite-test:
@@ -232,7 +188,6 @@ jobs:
- name: Setup Node.js
uses: ./.github/actions/setup-node
with:
extra-flags: workspaces focus @affine/monorepo @affine-test/blocksuite @blocksuite/playground @blocksuite/integration-test
playwright-install: true
playwright-platform: 'chromium'
electron-install: false
@@ -255,14 +210,18 @@ jobs:
e2e-blocksuite-cross-browser-test:
name: E2E BlockSuite Cross Browser Test
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1]
browser: ['chromium', 'firefox', 'webkit']
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: ./.github/actions/setup-node
with:
extra-flags: workspaces focus @affine/monorepo @affine-test/blocksuite @blocksuite/playground @blocksuite/integration-test
playwright-install: true
playwright-platform: 'chromium,firefox,webkit'
playwright-platform: ${{ matrix.browser }}
electron-install: false
full-cache: true
@@ -270,64 +229,18 @@ jobs:
run: yarn workspace @blocksuite/playground build
- name: Run playwright tests
run: |
yarn workspace @blocksuite/integration-test test:unit
yarn workspace @affine-test/blocksuite test "cross-platform/" --forbid-only
env:
BROWSER: ${{ matrix.browser }}
run: yarn workspace @affine-test/blocksuite test "cross-platform/" --forbid-only --shard=${{ matrix.shard }}/${{ strategy.job-total }}
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results-e2e-bs-cross-browser
name: test-results-e2e-bs-cross-browser-${{ matrix.browser }}-${{ matrix.shard }}
path: ./test-results
if-no-files-found: ignore
bundler-matrix:
name: Bundler Matrix (${{ matrix.bundler }})
runs-on: ubuntu-24.04-arm
strategy:
fail-fast: false
matrix:
bundler: [webpack, rspack]
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: ./.github/actions/setup-node
with:
playwright-install: false
electron-install: false
full-cache: true
- name: Run frontend build matrix
env:
AFFINE_BUNDLER: ${{ matrix.bundler }}
run: |
set -euo pipefail
packages=(
"@affine/web"
"@affine/mobile"
"@affine/ios"
"@affine/android"
"@affine/admin"
"@affine/electron-renderer"
)
summary="test-results-bundler-${AFFINE_BUNDLER}.txt"
: > "$summary"
for pkg in "${packages[@]}"; do
start=$(date +%s)
yarn affine "$pkg" build
end=$(date +%s)
echo "${pkg},$((end-start))" >> "$summary"
done
- name: Upload bundler timing
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results-bundler-${{ matrix.bundler }}
path: ./test-results-bundler-${{ matrix.bundler }}.txt
if-no-files-found: ignore
e2e-test:
name: E2E Test
runs-on: ubuntu-24.04-arm
@@ -344,7 +257,6 @@ jobs:
- name: Setup Node.js
uses: ./.github/actions/setup-node
with:
extra-flags: workspaces focus @affine/monorepo @affine-test/affine-local @affine/web @affine/server
playwright-install: true
playwright-platform: 'chromium'
electron-install: false
@@ -376,7 +288,6 @@ jobs:
- name: Setup Node.js
uses: ./.github/actions/setup-node
with:
extra-flags: workspaces focus @affine/monorepo @affine-test/affine-mobile @affine/mobile
playwright-install: true
electron-install: false
full-cache: true
@@ -396,7 +307,7 @@ jobs:
name: Unit Test
runs-on: ubuntu-latest
needs:
- build-native-linux
- build-native
env:
DISTRIBUTION: web
strategy:
@@ -410,7 +321,6 @@ jobs:
with:
electron-install: true
playwright-install: true
playwright-platform: 'chromium,firefox,webkit'
full-cache: true
- name: Download affine.linux-x64-gnu.node
@@ -431,39 +341,7 @@ jobs:
name: affine
fail_ci_if_error: false
build-native-linux:
name: Build AFFiNE native (x86_64-unknown-linux-gnu)
runs-on: ubuntu-latest
env:
CARGO_PROFILE_RELEASE_DEBUG: '1'
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: ./.github/actions/setup-node
with:
extra-flags: workspaces focus @affine/native
electron-install: false
- name: Setup filename
id: filename
working-directory: ${{ github.workspace }}
shell: bash
run: |
PLATFORM_ARCH_ABI="$(node -e "console.log(require('@napi-rs/cli').parseTriple('x86_64-unknown-linux-gnu').platformArchABI)")"
echo "filename=affine.$PLATFORM_ARCH_ABI.node" >> "$GITHUB_OUTPUT"
- name: Build AFFiNE native
uses: ./.github/actions/build-rust
with:
target: x86_64-unknown-linux-gnu
package: '@affine/native'
- name: Upload ${{ steps.filename.outputs.filename }}
uses: actions/upload-artifact@v4
if: always()
with:
name: ${{ steps.filename.outputs.filename }}
path: ${{ github.workspace }}/packages/frontend/native/${{ steps.filename.outputs.filename }}
if-no-files-found: error
build-native-macos:
build-native:
name: Build AFFiNE native (${{ matrix.spec.target }})
runs-on: ${{ matrix.spec.os }}
env:
@@ -472,6 +350,7 @@ jobs:
fail-fast: false
matrix:
spec:
- { os: ubuntu-latest, target: x86_64-unknown-linux-gnu }
- { os: macos-latest, target: x86_64-apple-darwin }
- { os: macos-latest, target: aarch64-apple-darwin }
@@ -487,7 +366,7 @@ jobs:
working-directory: ${{ github.workspace }}
shell: bash
run: |
PLATFORM_ARCH_ABI="$(node -e "console.log(require('@napi-rs/cli').parseTriple('${{ matrix.spec.target }}').platformArchABI)")"
export PLATFORM_ARCH_ABI=$(node -e "console.log(require('@napi-rs/cli').parseTriple('${{ matrix.spec.target }}').platformArchABI)")
echo "filename=affine.$PLATFORM_ARCH_ABI.node" >> "$GITHUB_OUTPUT"
- name: Build AFFiNE native
uses: ./.github/actions/build-rust
@@ -504,7 +383,7 @@ jobs:
# Split Windows build because it's too slow
# and other ci jobs required linux native
build-native-windows:
build-windows-native:
name: Build AFFiNE native (${{ matrix.spec.target }})
runs-on: ${{ matrix.spec.os }}
env:
@@ -536,7 +415,7 @@ jobs:
working-directory: ${{ env.DEV_DRIVE_WORKSPACE }}
shell: bash
run: |
PLATFORM_ARCH_ABI="$(node -e "console.log(require('@napi-rs/cli').parseTriple('${{ matrix.spec.target }}').platformArchABI)")"
export PLATFORM_ARCH_ABI=$(node -e "console.log(require('@napi-rs/cli').parseTriple('${{ matrix.spec.target }}').platformArchABI)")
echo "filename=affine.$PLATFORM_ARCH_ABI.node" >> "$GITHUB_OUTPUT"
- name: Build AFFiNE native
uses: ./.github/actions/build-rust
@@ -584,7 +463,6 @@ jobs:
- name: Setup Node.js
uses: ./.github/actions/setup-node
with:
extra-flags: workspaces focus @affine/monorepo @affine/electron-renderer @affine/nbstore @toeverything/infra
electron-install: false
full-cache: true
- name: Build Electron renderer
@@ -605,7 +483,7 @@ jobs:
name: Native Unit Test
runs-on: ubuntu-latest
needs:
- build-native-linux
- build-native
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
@@ -666,7 +544,6 @@ jobs:
- name: Setup Node.js
uses: ./.github/actions/setup-node
with:
extra-flags: workspaces focus @affine/monorepo @affine/server
electron-install: false
full-cache: true
@@ -700,6 +577,8 @@ jobs:
runs-on: ubuntu-latest
needs:
- build-server-native
strategy:
fail-fast: false
env:
NODE_ENV: test
DATABASE_URL: postgresql://affine:affine@localhost:5432/affine
@@ -747,7 +626,6 @@ jobs:
- name: Setup Node.js
uses: ./.github/actions/setup-node
with:
extra-flags: workspaces focus @affine/monorepo @affine/server
electron-install: false
full-cache: true
@@ -764,6 +642,8 @@ jobs:
run: yarn affine @affine/server test:coverage "**/*/*elasticsearch.spec.ts" --forbid-only
env:
CARGO_TARGET_DIR: '${{ github.workspace }}/target'
CI_NODE_INDEX: ${{ matrix.node_index }}
CI_NODE_TOTAL: ${{ matrix.total_nodes }}
- name: Upload server test coverage results
uses: codecov/codecov-action@v5
@@ -810,7 +690,6 @@ jobs:
- name: Setup Node.js
uses: ./.github/actions/setup-node
with:
extra-flags: workspaces focus @affine/monorepo @affine/server
electron-install: false
full-cache: true
@@ -837,10 +716,7 @@ jobs:
miri:
name: miri code check
if: ${{ needs.rust-test-filter.outputs.run-rust == 'true' }}
runs-on: ubuntu-latest
needs:
- rust-test-filter
env:
RUST_BACKTRACE: full
CARGO_TERM_COLOR: always
@@ -865,10 +741,7 @@ jobs:
loom:
name: loom thread test
if: ${{ needs.rust-test-filter.outputs.run-rust == 'true' }}
runs-on: ubuntu-latest
needs:
- rust-test-filter
env:
RUSTFLAGS: --cfg loom
RUST_BACKTRACE: full
@@ -891,10 +764,7 @@ jobs:
fuzzing:
name: fuzzing
if: ${{ needs.rust-test-filter.outputs.run-rust == 'true' }}
runs-on: ubuntu-latest
needs:
- rust-test-filter
env:
CARGO_TERM_COLOR: always
steps:
@@ -930,10 +800,7 @@ jobs:
rust-test:
name: Run native tests
if: ${{ needs.rust-test-filter.outputs.run-rust == 'true' }}
runs-on: ubuntu-latest
needs:
- rust-test-filter
env:
CARGO_TERM_COLOR: always
steps:
@@ -941,7 +808,6 @@ jobs:
- name: Setup Rust
uses: ./.github/actions/build-rust
with:
target: x86_64-unknown-linux-gnu
package: 'affine'
no-build: 'true'
@@ -953,51 +819,11 @@ jobs:
- name: Run tests
run: cargo nextest run --workspace --exclude affine_server_native --features use-as-lib --release --no-fail-fast
copilot-test-filter:
name: Copilot test filter
runs-on: ubuntu-latest
outputs:
run-api: ${{ steps.decision.outputs.run_api }}
run-e2e: ${{ steps.decision.outputs.run_e2e }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: copilot-filter
with:
filters: |
api:
- 'packages/backend/server/src/plugins/copilot/**'
- 'packages/backend/server/tests/copilot.*'
e2e:
- 'packages/backend/server/src/plugins/copilot/**'
- 'packages/backend/server/tests/copilot.*'
- 'packages/frontend/core/src/blocksuite/ai/**'
- 'packages/frontend/core/src/modules/workspace-indexer-embedding/**'
- 'tests/affine-cloud-copilot/**'
- name: Decide test scope
id: decision
run: |
if [[ "${{ steps.copilot-filter.outputs.api }}" == "true" ]]; then
echo "run_api=true" >> "$GITHUB_OUTPUT"
else
echo "run_api=false" >> "$GITHUB_OUTPUT"
fi
if [[ "${{ steps.copilot-filter.outputs.e2e }}" == "true" ]]; then
echo "run_e2e=true" >> "$GITHUB_OUTPUT"
else
echo "run_e2e=false" >> "$GITHUB_OUTPUT"
fi
copilot-api-test:
name: Server Copilot Api Test
if: ${{ needs.copilot-test-filter.outputs.run-api == 'true' }}
runs-on: ubuntu-latest
needs:
- build-server-native
- copilot-test-filter
env:
NODE_ENV: test
DISTRIBUTION: web
@@ -1031,30 +857,53 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Check blocksuite update
id: check-blocksuite-update
env:
BASE_REF: ${{ github.base_ref }}
run: |
if node ./scripts/detect-blocksuite-update.mjs "$BASE_REF"; then
echo "skip=false" >> $GITHUB_OUTPUT
else
echo "skip=true" >> $GITHUB_OUTPUT
fi
- uses: dorny/paths-filter@v3
id: apifilter
with:
filters: |
changed:
- 'packages/backend/server/src/plugins/copilot/**'
- 'packages/backend/server/tests/copilot.*'
- name: Setup Node.js
if: ${{ steps.check-blocksuite-update.outputs.skip != 'true' || steps.apifilter.outputs.changed == 'true' }}
uses: ./.github/actions/setup-node
with:
extra-flags: workspaces focus @affine/monorepo @affine/server
electron-install: false
full-cache: true
- name: Download server-native.node
if: ${{ steps.check-blocksuite-update.outputs.skip != 'true' || steps.apifilter.outputs.changed == 'true' }}
uses: actions/download-artifact@v4
with:
name: server-native.node
path: ./packages/backend/native
- name: Prepare Server Test Environment
if: ${{ steps.check-blocksuite-update.outputs.skip != 'true' || steps.apifilter.outputs.changed == 'true' }}
env:
SERVER_CONFIG: ${{ secrets.TEST_SERVER_CONFIG }}
uses: ./.github/actions/server-test-env
- name: Run server tests
if: ${{ steps.check-blocksuite-update.outputs.skip != 'true' || steps.apifilter.outputs.changed == 'true' }}
run: yarn affine @affine/server test:copilot:coverage --forbid-only
env:
CARGO_TARGET_DIR: '${{ github.workspace }}/target'
- name: Upload server test coverage results
if: ${{ steps.check-blocksuite-update.outputs.skip != 'true' || steps.apifilter.outputs.changed == 'true' }}
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
@@ -1065,7 +914,6 @@ jobs:
copilot-e2e-test:
name: Frontend Copilot E2E Test
if: ${{ needs.copilot-test-filter.outputs.run-e2e == 'true' }}
runs-on: ubuntu-latest
env:
DISTRIBUTION: web
@@ -1080,7 +928,6 @@ jobs:
shardTotal: [5]
needs:
- build-server-native
- copilot-test-filter
services:
postgres:
image: pgvector/pgvector:pg16
@@ -1104,27 +951,52 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Check blocksuite update
id: check-blocksuite-update
env:
BASE_REF: ${{ github.base_ref }}
run: |
if node ./scripts/detect-blocksuite-update.mjs "$BASE_REF"; then
echo "skip=false" >> $GITHUB_OUTPUT
else
echo "skip=true" >> $GITHUB_OUTPUT
fi
- uses: dorny/paths-filter@v3
id: e2efilter
with:
filters: |
changed:
- 'packages/backend/server/src/plugins/copilot/**'
- 'packages/backend/server/tests/copilot.*'
- 'packages/frontend/core/src/blocksuite/ai/**'
- 'packages/frontend/core/src/modules/workspace-indexer-embedding/**'
- 'tests/affine-cloud-copilot/**'
- name: Setup Node.js
if: ${{ steps.check-blocksuite-update.outputs.skip != 'true' || steps.e2efilter.outputs.changed == 'true' }}
uses: ./.github/actions/setup-node
with:
extra-flags: workspaces focus @affine/monorepo @affine-test/affine-cloud-copilot @affine/web @affine/server
playwright-install: true
playwright-platform: 'chromium'
electron-install: false
hard-link-nm: false
- name: Download server-native.node
if: ${{ steps.check-blocksuite-update.outputs.skip != 'true' || steps.e2efilter.outputs.changed == 'true' }}
uses: actions/download-artifact@v4
with:
name: server-native.node
path: ./packages/backend/native
- name: Prepare Server Test Environment
if: ${{ steps.check-blocksuite-update.outputs.skip != 'true' || steps.e2efilter.outputs.changed == 'true' }}
env:
SERVER_CONFIG: ${{ secrets.TEST_SERVER_CONFIG }}
uses: ./.github/actions/server-test-env
- name: Run Copilot E2E Test ${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
if: ${{ steps.check-blocksuite-update.outputs.skip != 'true' || steps.e2efilter.outputs.changed == 'true' }}
uses: ./.github/actions/copilot-test
with:
script: yarn affine @affine-test/affine-cloud-copilot e2e --forbid-only --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
@@ -1134,7 +1006,7 @@ jobs:
runs-on: ubuntu-latest
needs:
- build-server-native
- build-native-linux
- build-native
env:
DISTRIBUTION: web
DATABASE_URL: postgresql://affine:affine@localhost:5432/affine
@@ -1190,10 +1062,7 @@ jobs:
- name: Setup Node.js
uses: ./.github/actions/setup-node
with:
extra-flags: workspaces focus @affine/monorepo @affine-test/affine-cloud @affine-test/affine-desktop-cloud @affine/web @affine/server @affine/electron @affine/electron-renderer @affine/nbstore @toeverything/infra
playwright-install: true
playwright-platform: 'chromium'
electron-install: ${{ matrix.tests.shard == 'desktop' && 'true' || 'false' }}
hard-link-nm: false
- name: Download server-native.node
@@ -1230,9 +1099,7 @@ jobs:
runs-on: ${{ matrix.spec.os }}
needs:
- build-electron-renderer
- build-native-linux
- build-native-macos
- build-native-windows
- build-native
strategy:
fail-fast: false
matrix:
@@ -1272,8 +1139,7 @@ jobs:
timeout-minutes: 10
with:
extra-flags: workspaces focus @affine/electron @affine/monorepo @affine-test/affine-desktop @affine/nbstore @toeverything/infra
playwright-install: ${{ matrix.spec.test && 'true' || 'false' }}
playwright-platform: 'chromium'
playwright-install: true
hard-link-nm: false
enableScripts: false
@@ -1281,7 +1147,7 @@ jobs:
id: filename
shell: bash
run: |
PLATFORM_ARCH_ABI="$(node -e "console.log(require('@napi-rs/cli').parseTriple('${{ matrix.spec.target }}').platformArchABI)")"
export PLATFORM_ARCH_ABI=$(node -e "console.log(require('@napi-rs/cli').parseTriple('${{ matrix.spec.target }}').platformArchABI)")
echo "filename=affine.$PLATFORM_ARCH_ABI.node" >> "$GITHUB_OUTPUT"
- name: Download ${{ steps.filename.outputs.filename }}
@@ -1316,6 +1182,84 @@ jobs:
if: ${{ matrix.spec.test && matrix.spec.os != 'ubuntu-latest' }}
run: yarn affine @affine-test/affine-desktop e2e
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results-e2e-${{ matrix.spec.os }}-${{ matrix.spec.arch }}
path: ./test-results
if-no-files-found: ignore
desktop-bundle-check:
name: Desktop bundle check (${{ matrix.spec.os }}, ${{ matrix.spec.platform }}, ${{ matrix.spec.arch }}, ${{ matrix.spec.target }}, ${{ matrix.spec.test }})
runs-on: ${{ matrix.spec.os }}
needs:
- build-electron-renderer
- build-native
strategy:
fail-fast: false
matrix:
spec:
- {
os: macos-latest,
platform: macos,
arch: x64,
target: x86_64-apple-darwin,
test: false,
}
- {
os: macos-latest,
platform: macos,
arch: arm64,
target: aarch64-apple-darwin,
test: true,
}
- {
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,
}
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: ./.github/actions/setup-node
timeout-minutes: 10
with:
extra-flags: workspaces focus @affine/electron @affine/monorepo @affine-test/affine-desktop @affine/nbstore @toeverything/infra
playwright-install: true
hard-link-nm: false
enableScripts: false
- name: Setup filename
id: filename
shell: bash
run: |
export PLATFORM_ARCH_ABI=$(node -e "console.log(require('@napi-rs/cli').parseTriple('${{ matrix.spec.target }}').platformArchABI)")
echo "filename=affine.$PLATFORM_ARCH_ABI.node" >> "$GITHUB_OUTPUT"
- name: Download ${{ steps.filename.outputs.filename }}
uses: actions/download-artifact@v4
with:
name: ${{ steps.filename.outputs.filename }}
path: ./packages/frontend/native
- name: Download web artifact
uses: ./.github/actions/download-web
with:
path: packages/frontend/apps/electron/resources/web-static
- name: Build Desktop Layers
run: yarn affine @affine/electron build
- name: Make bundle (macOS)
if: ${{ matrix.spec.target == 'aarch64-apple-darwin' }}
env:
@@ -1355,14 +1299,6 @@ jobs:
run: |
yarn affine @affine/electron node ./scripts/macos-arm64-output-check.ts
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results-e2e-${{ matrix.spec.os }}-${{ matrix.spec.arch }}
path: ./test-results
if-no-files-found: ignore
test-done:
needs:
- analyze
@@ -1376,9 +1312,8 @@ jobs:
- e2e-blocksuite-cross-browser-test
- e2e-mobile-test
- unit-test
- build-native-linux
- build-native-macos
- build-native-windows
- build-native
- build-windows-native
- build-server-native
- build-electron-renderer
- native-unit-test
@@ -1388,11 +1323,10 @@ jobs:
- server-test
- server-e2e-test
- rust-test
- rust-test-filter
- copilot-test-filter
- copilot-api-test
- copilot-e2e-test
- desktop-test
- desktop-bundle-check
- cloud-e2e-test
if: always()
runs-on: ubuntu-latest

View File

@@ -16,7 +16,6 @@ jobs:
check-pull-request-title:
name: Check pull request title
runs-on: ubuntu-latest
if: ${{ github.event.action != 'edited' || github.event.changes.title != null }}
steps:
- uses: actions/checkout@v4
- name: Setup Node.js

View File

@@ -174,7 +174,7 @@ jobs:
run: |
mkdir -p builds
mv packages/frontend/apps/electron/out/*/make/zip/linux/${{ inputs.arch }}/*.zip ./builds/affine-${{ env.RELEASE_VERSION }}-${{ env.BUILD_TYPE }}-linux-${{ inputs.arch }}.zip
mv packages/frontend/apps/electron/out/*/make/AppImage/${{ inputs.arch }}/*.AppImage ./builds/affine-${{ env.RELEASE_VERSION }}-${{ env.BUILD_TYPE }}-linux-${{ inputs.arch }}.appimage
mv packages/frontend/apps/electron/out/*/make/*.AppImage ./builds/affine-${{ env.RELEASE_VERSION }}-${{ env.BUILD_TYPE }}-linux-${{ inputs.arch }}.appimage
mv packages/frontend/apps/electron/out/*/make/deb/${{ inputs.arch }}/*.deb ./builds/affine-${{ env.RELEASE_VERSION }}-${{ env.BUILD_TYPE }}-linux-${{ inputs.arch }}.deb
mv packages/frontend/apps/electron/out/*/make/flatpak/*/*.flatpak ./builds/affine-${{ env.RELEASE_VERSION }}-${{ env.BUILD_TYPE }}-linux-${{ inputs.arch }}.flatpak

View File

@@ -201,44 +201,13 @@ jobs:
nmHoistingLimits: workspaces
env:
npm_config_arch: ${{ matrix.spec.arch }}
- name: Download packaged artifacts
uses: actions/download-artifact@v4
with:
name: packaged-${{ matrix.spec.platform }}-${{ matrix.spec.arch }}
path: packaged-unsigned
- name: unzip packaged artifacts
run: Expand-Archive -Path packaged-unsigned/archive.zip -DestinationPath packages/frontend/apps/electron/out
- name: Download signed packaged file diff
- name: Download and overwrite packaged artifacts
uses: actions/download-artifact@v4
with:
name: signed-packaged-${{ matrix.spec.platform }}-${{ matrix.spec.arch }}
path: signed-packaged-diff
- name: Apply signed packaged file diff
shell: pwsh
run: |
$DiffRoot = 'signed-packaged-diff/files'
$TargetRoot = 'packages/frontend/apps/electron/out'
if (!(Test-Path -LiteralPath $DiffRoot)) {
throw "Signed diff directory not found: $DiffRoot"
}
Copy-Item -Path (Join-Path $DiffRoot '*') -Destination $TargetRoot -Recurse -Force
$ManifestPath = 'signed-packaged-diff/manifest.json'
if (Test-Path -LiteralPath $ManifestPath) {
$ManifestEntries = @(Get-Content -LiteralPath $ManifestPath | ConvertFrom-Json)
foreach ($Entry in $ManifestEntries) {
$TargetPath = Join-Path $TargetRoot $Entry.path
if (!(Test-Path -LiteralPath $TargetPath -PathType Leaf)) {
throw "Applied signed file not found: $($Entry.path)"
}
$TargetHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $TargetPath).Hash
if ($TargetHash -ne $Entry.sha256) {
throw "Signed file hash mismatch: $($Entry.path)"
}
}
}
path: .
- name: unzip file
run: Expand-Archive -Path signed.zip -DestinationPath packages/frontend/apps/electron/out
- name: Make squirrel.windows installer
run: yarn affine @affine/electron make-squirrel --platform=${{ matrix.spec.platform }} --arch=${{ matrix.spec.arch }}
@@ -298,44 +267,13 @@ jobs:
arch: arm64
runs-on: ${{ matrix.spec.runner }}
steps:
- name: Download installer artifacts
uses: actions/download-artifact@v4
with:
name: installer-${{ matrix.spec.platform }}-${{ matrix.spec.arch }}
path: installer-unsigned
- name: unzip installer artifacts
run: Expand-Archive -Path installer-unsigned/archive.zip -DestinationPath packages/frontend/apps/electron/out/${{ env.BUILD_TYPE }}/make
- name: Download signed installer file diff
- name: Download and overwrite installer artifacts
uses: actions/download-artifact@v4
with:
name: signed-installer-${{ matrix.spec.platform }}-${{ matrix.spec.arch }}
path: signed-installer-diff
- name: Apply signed installer file diff
shell: pwsh
run: |
$DiffRoot = 'signed-installer-diff/files'
$TargetRoot = 'packages/frontend/apps/electron/out/${{ env.BUILD_TYPE }}/make'
if (!(Test-Path -LiteralPath $DiffRoot)) {
throw "Signed diff directory not found: $DiffRoot"
}
Copy-Item -Path (Join-Path $DiffRoot '*') -Destination $TargetRoot -Recurse -Force
$ManifestPath = 'signed-installer-diff/manifest.json'
if (Test-Path -LiteralPath $ManifestPath) {
$ManifestEntries = @(Get-Content -LiteralPath $ManifestPath | ConvertFrom-Json)
foreach ($Entry in $ManifestEntries) {
$TargetPath = Join-Path $TargetRoot $Entry.path
if (!(Test-Path -LiteralPath $TargetPath -PathType Leaf)) {
throw "Applied signed file not found: $($Entry.path)"
}
$TargetHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $TargetPath).Hash
if ($TargetHash -ne $Entry.sha256) {
throw "Signed file hash mismatch: $($Entry.path)"
}
}
}
path: .
- name: unzip file
run: Expand-Archive -Path signed.zip -DestinationPath packages/frontend/apps/electron/out/${{ env.BUILD_TYPE }}/make
- name: Save artifacts
run: |

View File

@@ -128,9 +128,9 @@ jobs:
- name: Testflight
working-directory: packages/frontend/apps/ios/App
run: |
printf '%s' "$BUILD_PROVISION_PROFILE" | base64 --decode -o "$PP_PATH"
mkdir -p "$HOME/Library/MobileDevice/Provisioning Profiles"
cp "$PP_PATH" "$HOME/Library/MobileDevice/Provisioning Profiles"
echo -n "${{ env.BUILD_PROVISION_PROFILE }}" | base64 --decode -o $PP_PATH
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
cp $PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles
fastlane beta
env:
BUILD_TARGET: distribution
@@ -160,9 +160,7 @@ jobs:
- name: Load Google Service file
env:
DATA: ${{ secrets.FIREBASE_ANDROID_GOOGLE_SERVICE_JSON }}
run: |
set -euo pipefail
printf '%s' "$DATA" | base64 -di > packages/frontend/apps/android/App/app/google-services.json
run: echo $DATA | base64 -di > packages/frontend/apps/android/App/app/google-services.json
- name: Setup Node.js
uses: ./.github/actions/setup-node
timeout-minutes: 10

View File

@@ -148,7 +148,7 @@ jobs:
name: Wait for approval
with:
secret: ${{ secrets.GITHUB_TOKEN }}
approvers: darkskygit
approvers: darkskygit,pengx17,L-Sun,EYHN
minimum-approvals: 1
fail-on-denial: true
issue-title: Please confirm to release docker image

View File

@@ -30,43 +30,13 @@ jobs:
run: |
cd ${{ env.ARCHIVE_DIR }}/out
signtool sign /tr http://timestamp.globalsign.com/tsa/r6advanced1 /td sha256 /fd sha256 /a ${{ inputs.files }}
- name: collect signed file diff
shell: powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File {0}
- name: zip file
shell: cmd
run: |
$OutDir = Join-Path '${{ env.ARCHIVE_DIR }}' 'out'
$DiffDir = Join-Path '${{ env.ARCHIVE_DIR }}' 'signed-diff'
$FilesDir = Join-Path $DiffDir 'files'
New-Item -ItemType Directory -Path $FilesDir -Force | Out-Null
$SignedFiles = [regex]::Matches('${{ inputs.files }}', '"([^"]+)"') | ForEach-Object { $_.Groups[1].Value }
if ($SignedFiles.Count -eq 0) {
throw 'No files to sign were provided.'
}
$Manifest = @()
foreach ($RelativePath in $SignedFiles) {
$SourcePath = Join-Path $OutDir $RelativePath
if (!(Test-Path -LiteralPath $SourcePath -PathType Leaf)) {
throw "Signed file not found: $RelativePath"
}
$TargetPath = Join-Path $FilesDir $RelativePath
$TargetDir = Split-Path -Parent $TargetPath
if ($TargetDir) {
New-Item -ItemType Directory -Path $TargetDir -Force | Out-Null
}
Copy-Item -LiteralPath $SourcePath -Destination $TargetPath -Force
$Manifest += [PSCustomObject]@{
path = $RelativePath
sha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $TargetPath).Hash
}
}
$Manifest | ConvertTo-Json -Depth 4 | Out-File -FilePath (Join-Path $DiffDir 'manifest.json') -Encoding utf8
Write-Host "Collected $($SignedFiles.Count) signed files."
cd ${{ env.ARCHIVE_DIR }}
7za a signed.zip .\out\*
- name: upload
uses: actions/upload-artifact@v4
with:
name: signed-${{ inputs.artifact-name }}
path: ${{ env.ARCHIVE_DIR }}/signed-diff
path: ${{ env.ARCHIVE_DIR }}/signed.zip

View File

@@ -17,7 +17,7 @@
"explorer.fileNesting.enabled": true,
"explorer.fileNesting.patterns": {
"*.js": "${capture}.js.map, ${capture}.min.js, ${capture}.d.ts, ${capture}.d.ts.map",
"package.json": ".browserslist*, .circleci*, .codecov, .commitlint*, .cz-config.js, .czrc, .dlint.json, .dprint.json, .editorconfig, .eslint*, eslint.*, .firebase*, .flowconfig, .github*, .gitlab*, .gitpod*, .huskyrc*, .jslint*, .lighthouserc.*, .lintstagedrc*, .markdownlint*, .mocha*, .node-version, .nodemon*, .npm*, .nvmrc, .pm2*, .pnp.*, .pnpm*, .prettier*, .releaserc*, .sentry*, .stackblitz*, .styleci*, .stylelint*, .tazerc*, .textlint*, .tool-versions, .travis*, .versionrc*, .vscode*, .watchman*, .xo-config*, .yamllint*, .yarnrc*, Procfile, api-extractor.json, apollo.config.*, appveyor*, ava.config.*, azure-pipelines*, bower.json, build.config.*, commitlint*, dangerfile*, dlint.json, dprint.json, firebase.json, grunt*, gulp*, histoire.config.*, jasmine.*, jenkins*, jest.config.*, jsconfig.*, karma*, lerna*, lighthouserc.*, lint-staged*, nest-cli.*, netlify*, nodemon*, nx.*, package-lock.json, package.nls*.json, phpcs.xml, playwright.config.*, pm2.*, pnpm*, prettier*, pullapprove*, puppeteer.config.*, pyrightconfig.json, release-tasks.sh, renovate*, rollup.config.*, stylelint*, tsconfig.*, tsdoc.*, tslint*, tsup.config.*, turbo*, typedoc*, unlighthouse*, vercel*, vetur.config.*, vitest.*, webpack*, workspace.json, xo.config.*, yarn*, babel.*, .babelrc, project.json, .oxlintrc.json, oxlint.json, nyc.config.*",
"package.json": ".browserslist*, .circleci*, .codecov, .commitlint*, .cz-config.js, .czrc, .dlint.json, .dprint.json, .editorconfig, .eslint*, eslint.*, .firebase*, .flowconfig, .github*, .gitlab*, .gitpod*, .huskyrc*, .jslint*, .lighthouserc.*, .lintstagedrc*, .markdownlint*, .mocha*, .node-version, .nodemon*, .npm*, .nvmrc, .pm2*, .pnp.*, .pnpm*, .prettier*, .releaserc*, .sentry*, .stackblitz*, .styleci*, .stylelint*, .tazerc*, .textlint*, .tool-versions, .travis*, .versionrc*, .vscode*, .watchman*, .xo-config*, .yamllint*, .yarnrc*, Procfile, api-extractor.json, apollo.config.*, appveyor*, ava.config.*, azure-pipelines*, bower.json, build.config.*, commitlint*, dangerfile*, dlint.json, dprint.json, firebase.json, grunt*, gulp*, histoire.config.*, jasmine.*, jenkins*, jest.config.*, jsconfig.*, karma*, lerna*, lighthouserc.*, lint-staged*, nest-cli.*, netlify*, nodemon*, nx.*, package-lock.json, package.nls*.json, phpcs.xml, playwright.config.*, pm2.*, pnpm*, prettier*, pullapprove*, puppeteer.config.*, pyrightconfig.json, release-tasks.sh, renovate*, rollup.config.*, stylelint*, tsconfig.*, tsdoc.*, tslint*, tsup.config.*, turbo*, typedoc*, unlighthouse*, vercel*, vetur.config.*, vitest.*, webpack*, workspace.json, xo.config.*, yarn*, babel.*, .babelrc, project.json, oxlint.json, nyc.config.*",
"Cargo.toml": "Cargo.lock, rust-toolchain*, rustfmt.toml, .taplo.toml",
"README.md": "LICENSE*, CHANGELOG.md, CODE_OF_CONDUCT.md, CONTRIBUTING.md, SECURITY.md, README.*",
".gitignore": ".gitattributes, .dockerignore, .eslintignore, .prettierignore, .stylelintignore, .tslintignore, .yarnignore"

11
Cargo.lock generated
View File

@@ -111,12 +111,10 @@ dependencies = [
"base64-simd",
"chrono",
"homedir",
"lru",
"objc2",
"objc2-foundation",
"sqlx",
"thiserror 2.0.17",
"tokio",
"uniffi",
]
@@ -2574,15 +2572,6 @@ dependencies = [
"weezl",
]
[[package]]
name = "lru"
version = "0.16.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593"
dependencies = [
"hashbrown 0.16.1",
]
[[package]]
name = "mac"
version = "0.1.1"

View File

@@ -46,7 +46,6 @@ resolver = "3"
libc = "0.2"
log = "0.4"
loom = { version = "0.7", features = ["checkpoint"] }
lru = "0.16"
memory-indexer = "0.3.0"
mimalloc = "0.1"
mp4parse = "0.17"

View File

@@ -90,10 +90,10 @@ Thanks for checking us out, we appreciate your interest and sincerely hope that
## Contributing
| Bug Reports | Feature Requests | Questions/Discussions | AFFiNE Community |
| --------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | ----------------------------------------------------------------- |
| [Create a bug report](https://github.com/toeverything/AFFiNE/issues/new?assignees=&labels=bug%2Cproduct-review&template=BUG-REPORT.yml&title=TITLE) | [Submit a feature request](https://github.com/toeverything/AFFiNE/issues/new?assignees=&labels=feat%2Cproduct-review&template=FEATURE-REQUEST.yml&title=TITLE) | [Check GitHub Discussion](https://github.com/toeverything/AFFiNE/discussions) | [Visit the AFFiNE's Discord](https://affine.pro/redirect/discord) |
| Something isn't working as expected | An idea for a new feature, or improvements | Discuss and ask questions | A place to ask, learn and engage with others |
| Bug Reports | Feature Requests | Questions/Discussions | AFFiNE Community |
| --------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | ---------------------------------------------------------- |
| [Create a bug report](https://github.com/toeverything/AFFiNE/issues/new?assignees=&labels=bug%2Cproduct-review&template=BUG-REPORT.yml&title=TITLE) | [Submit a feature request](https://github.com/toeverything/AFFiNE/issues/new?assignees=&labels=feat%2Cproduct-review&template=FEATURE-REQUEST.yml&title=TITLE) | [Check GitHub Discussion](https://github.com/toeverything/AFFiNE/discussions) | [Visit the AFFiNE Community](https://community.affine.pro) |
| Something isn't working as expected | An idea for a new feature, or improvements | Discuss and ask questions | A place to ask, learn and engage with others |
Calling all developers, testers, tech writers and more! Contributions of all types are more than welcome, you can read more in [docs/types-of-contributions.md](docs/types-of-contributions.md). If you are interested in contributing code, read our [docs/CONTRIBUTING.md](docs/CONTRIBUTING.md) and feel free to check out our GitHub issues to get stuck in to show us what youre made of.
@@ -101,9 +101,11 @@ Calling all developers, testers, tech writers and more! Contributions of all typ
For **bug reports**, **feature requests** and other **suggestions** you can also [create a new issue](https://github.com/toeverything/AFFiNE/issues/new/choose) and choose the most appropriate template for your feedback.
For **translation** and **language support** you can visit our [Discord](https://affine.pro/redirect/discord).
For **translation** and **language support** you can visit our [i18n General Space](https://community.affine.pro/c/i18n-general).
If you have questions, you are welcome to contact us. One of the best places to get more info and learn more is in the [Discord](https://affine.pro/redirect/discord) where you can engage with other like-minded individuals.
Looking for **other ways to contribute** and wondering where to start? Check out the [AFFiNE Ambassador program](https://community.affine.pro/c/start-here/affine-ambassador), we work closely with passionate community members and provide them with a wide range of support and resources.
If you have questions, you are welcome to contact us. One of the best places to get more info and learn more is in the [AFFiNE Community](https://community.affine.pro) where you can engage with other like-minded individuals.
## Templates
@@ -180,16 +182,20 @@ Begin with Docker to deploy your own feature-rich, unrestricted version of AFFiN
[![Run on ClawCloud](https://raw.githubusercontent.com/ClawCloud/Run-Template/refs/heads/main/Run-on-ClawCloud.svg)](https://template.run.claw.cloud/?openapp=system-fastdeploy%3FtemplateName%3Daffine)
## Hiring
Some amazing companies, including AFFiNE, are looking for developers! Are you interested in joining AFFiNE or its partners? Check out our [Discord channel](https://affine.pro/redirect/discord) for some of the latest jobs available.
## Feature Request
For feature requests, please see [discussions](https://github.com/toeverything/AFFiNE/discussions/categories/ideas).
For feature requests, please see [community.affine.pro](https://community.affine.pro/c/feature-requests/).
## Building
### Codespaces
From the GitHub repo main page, click the green "Code" button and select "Create codespace on master". This will open a new Codespace with the (supposedly auto-forked
AFFiNE repo cloned, built, and ready to go).
AFFiNE repo cloned, built, and ready to go.
### Local

View File

@@ -296,7 +296,7 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.26.3",
"version": "0.26.1",
"devDependencies": {
"@vanilla-extract/vite-plugin": "^5.0.0",
"msw": "^2.12.4",

View File

@@ -2101,157 +2101,6 @@ describe('html to snapshot', () => {
expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot);
});
test('paragraph with br should split into multiple blocks', async () => {
const html = template(`<p>aaa<br>bbb<br>ccc</p>`);
const blockSnapshot: BlockSnapshot = {
type: 'block',
id: 'matchesReplaceMap[0]',
flavour: 'affine:note',
props: {
xywh: '[0,0,800,95]',
background: DefaultTheme.noteBackgrounColor,
index: 'a0',
hidden: false,
displayMode: NoteDisplayMode.DocAndEdgeless,
},
children: [
{
type: 'block',
id: 'matchesReplaceMap[1]',
flavour: 'affine:paragraph',
props: {
type: 'text',
text: {
'$blocksuite:internal:text$': true,
delta: [{ insert: 'aaa' }],
},
},
children: [],
},
{
type: 'block',
id: 'matchesReplaceMap[2]',
flavour: 'affine:paragraph',
props: {
type: 'text',
text: {
'$blocksuite:internal:text$': true,
delta: [{ insert: 'bbb' }],
},
},
children: [],
},
{
type: 'block',
id: 'matchesReplaceMap[3]',
flavour: 'affine:paragraph',
props: {
type: 'text',
text: {
'$blocksuite:internal:text$': true,
delta: [{ insert: 'ccc' }],
},
},
children: [],
},
],
};
const htmlAdapter = new HtmlAdapter(createJob(), provider);
const rawBlockSnapshot = await htmlAdapter.toBlockSnapshot({
file: html,
});
expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot);
});
test('paragraph with br should keep inline styles in each split line', async () => {
const html = template(
`<p><strong>aaa</strong><br><a href="https://www.google.com/">bbb</a><br><em>ccc</em></p>`
);
const blockSnapshot: BlockSnapshot = {
type: 'block',
id: 'matchesReplaceMap[0]',
flavour: 'affine:note',
props: {
xywh: '[0,0,800,95]',
background: DefaultTheme.noteBackgrounColor,
index: 'a0',
hidden: false,
displayMode: NoteDisplayMode.DocAndEdgeless,
},
children: [
{
type: 'block',
id: 'matchesReplaceMap[1]',
flavour: 'affine:paragraph',
props: {
type: 'text',
text: {
'$blocksuite:internal:text$': true,
delta: [
{
insert: 'aaa',
attributes: {
bold: true,
},
},
],
},
},
children: [],
},
{
type: 'block',
id: 'matchesReplaceMap[2]',
flavour: 'affine:paragraph',
props: {
type: 'text',
text: {
'$blocksuite:internal:text$': true,
delta: [
{
insert: 'bbb',
attributes: {
link: 'https://www.google.com/',
},
},
],
},
},
children: [],
},
{
type: 'block',
id: 'matchesReplaceMap[3]',
flavour: 'affine:paragraph',
props: {
type: 'text',
text: {
'$blocksuite:internal:text$': true,
delta: [
{
insert: 'ccc',
attributes: {
italic: true,
},
},
],
},
},
children: [],
},
],
};
const htmlAdapter = new HtmlAdapter(createJob(), provider);
const rawBlockSnapshot = await htmlAdapter.toBlockSnapshot({
file: html,
});
expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot);
});
test('nested list', async () => {
const html = template(`<ul><li>111<ul><li>222</li></ul></li></ul>`);

View File

@@ -1,95 +0,0 @@
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import { describe, expect, test } from 'vitest';
import { insertUrlTextSegments } from '../../../../blocks/database/src/properties/paste-url.js';
type InsertCall = {
range: {
index: number;
length: number;
};
text: string;
attributes?: AffineTextAttributes;
};
describe('insertUrlTextSegments', () => {
test('should replace selected text on first insert and append remaining segments', () => {
const insertCalls: InsertCall[] = [];
const selectionCalls: Array<{ index: number; length: number } | null> = [];
const inlineEditor = {
insertText: (
range: { index: number; length: number },
text: string,
attributes?: AffineTextAttributes
) => {
insertCalls.push({ range, text, attributes });
},
setInlineRange: (range: { index: number; length: number } | null) => {
selectionCalls.push(range);
},
};
const inlineRange = { index: 4, length: 6 };
const segments = [
{ text: 'hi - ' },
{ text: 'https://google.com', link: 'https://google.com' },
];
insertUrlTextSegments(inlineEditor, inlineRange, segments);
expect(insertCalls).toEqual([
{
range: { index: 4, length: 6 },
text: 'hi - ',
},
{
range: { index: 9, length: 0 },
text: 'https://google.com',
attributes: {
link: 'https://google.com',
},
},
]);
expect(selectionCalls).toEqual([{ index: 27, length: 0 }]);
});
test('should keep insertion range length zero when there is no selected text', () => {
const insertCalls: InsertCall[] = [];
const selectionCalls: Array<{ index: number; length: number } | null> = [];
const inlineEditor = {
insertText: (
range: { index: number; length: number },
text: string,
attributes?: AffineTextAttributes
) => {
insertCalls.push({ range, text, attributes });
},
setInlineRange: (range: { index: number; length: number } | null) => {
selectionCalls.push(range);
},
};
const inlineRange = { index: 2, length: 0 };
const segments = [
{ text: 'prefix ' },
{ text: 'https://a.com', link: 'https://a.com' },
];
insertUrlTextSegments(inlineEditor, inlineRange, segments);
expect(insertCalls).toEqual([
{
range: { index: 2, length: 0 },
text: 'prefix ',
},
{
range: { index: 9, length: 0 },
text: 'https://a.com',
attributes: {
link: 'https://a.com',
},
},
]);
expect(selectionCalls).toEqual([{ index: 22, length: 0 }]);
});
});

View File

@@ -26,6 +26,7 @@
"@toeverything/theme": "^1.1.23",
"file-type": "^21.0.0",
"lit": "^3.2.0",
"minimatch": "^10.1.1",
"rxjs": "^7.8.2",
"zod": "^3.25.76"
},
@@ -40,5 +41,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.26.3"
"version": "0.26.1"
}

View File

@@ -26,6 +26,7 @@
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.23",
"lit": "^3.2.0",
"minimatch": "^10.1.1",
"rxjs": "^7.8.2",
"yjs": "^13.6.27",
"zod": "^3.25.76"
@@ -44,5 +45,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.26.3"
"version": "0.26.1"
}

View File

@@ -30,6 +30,7 @@
"@types/mdast": "^4.0.4",
"emoji-mart": "^5.6.0",
"lit": "^3.2.0",
"minimatch": "^10.1.1",
"rxjs": "^7.8.2",
"zod": "^3.25.76"
},
@@ -44,5 +45,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.26.3"
"version": "0.26.1"
}

View File

@@ -216,13 +216,9 @@ export class CalloutBlockComponent extends CaptionedBlockComponent<CalloutBlockM
override renderBlock() {
const icon = this.model.props.icon$.value;
const backgroundColorName = this.model.props.backgroundColorName$.value;
const normalizedBackgroundName =
backgroundColorName === 'default' || backgroundColorName === ''
? 'grey'
: backgroundColorName;
const backgroundColor = (
cssVarV2.block.callout.background as Record<string, string>
)[normalizedBackgroundName ?? 'grey'];
)[backgroundColorName ?? ''];
const iconContent = getIcon(icon);

View File

@@ -68,14 +68,14 @@ const backgroundColorAction = {
${repeat(colors, color => {
const isDefault = color === 'default';
const value = isDefault
? cssVarV2.block.callout.background.grey
? null
: `var(--affine-text-highlight-${color})`;
const displayName = `${color} Background`;
return html`
<editor-menu-action
data-testid="background-${color}"
@click=${() => updateBackground(isDefault ? 'grey' : color)}
@click=${() => updateBackground(color)}
>
<affine-text-duotone-icon
style=${styleMap({

View File

@@ -31,6 +31,7 @@
"@toeverything/theme": "^1.1.23",
"@types/mdast": "^4.0.4",
"lit": "^3.2.0",
"minimatch": "^10.1.1",
"rxjs": "^7.8.2",
"shiki": "^3.19.0",
"zod": "^3.25.76"
@@ -47,5 +48,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.26.3"
"version": "0.26.1"
}

View File

@@ -27,16 +27,6 @@ export const codeBlockStyles = css`
${scrollbarStyle('.affine-code-block-container rich-text')}
/* In Chromium 121+, non-auto scrollbar-width/color override ::-webkit-scrollbar styles. */
@supports not selector(::-webkit-scrollbar) {
.affine-code-block-container rich-text {
scrollbar-width: thin;
scrollbar-color: ${unsafeCSSVarV2('icon/secondary', '#b1b1b1')}
transparent;
scrollbar-gutter: stable both-edges;
}
}
.affine-code-block-container .inline-editor {
font-family: var(--affine-font-code-family);
font-variant-ligatures: none;

View File

@@ -27,6 +27,7 @@
"@toeverything/theme": "^1.1.23",
"@types/mdast": "^4.0.4",
"lit": "^3.2.0",
"minimatch": "^10.1.1",
"rxjs": "^7.8.2",
"zod": "^3.25.76"
},
@@ -41,5 +42,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.26.3"
"version": "0.26.1"
}

View File

@@ -32,6 +32,7 @@
"@types/mdast": "^4.0.4",
"date-fns": "^4.0.0",
"lit": "^3.2.0",
"minimatch": "^10.1.1",
"rxjs": "^7.8.2",
"yjs": "^13.6.27",
"zod": "^3.25.76"
@@ -47,5 +48,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.26.3"
"version": "0.26.1"
}

View File

@@ -135,10 +135,14 @@ export class DatabaseBlockDataSource extends DataSourceBase {
override featureFlags$: ReadonlySignal<DatabaseFlags> = computed(() => {
const featureFlagService = this.doc.get(FeatureFlagService);
const enableNumberFormat = featureFlagService.getFlag(
'enable_database_number_formatting'
);
const enableTableVirtualScroll = featureFlagService.getFlag(
'enable_table_virtual_scroll'
);
return {
enable_number_formatting: enableNumberFormat ?? false,
enable_table_virtual_scroll: enableTableVirtualScroll ?? false,
};
});

View File

@@ -1,56 +0,0 @@
import type {
AffineInlineEditor,
AffineTextAttributes,
} from '@blocksuite/affine-shared/types';
import {
splitTextByUrl,
type UrlTextSegment,
} from '@blocksuite/affine-shared/utils';
import type { InlineRange } from '@blocksuite/std/inline';
type UrlPasteInlineEditor = Pick<
AffineInlineEditor,
'insertText' | 'setInlineRange'
>;
export function analyzeTextForUrlPaste(text: string) {
const segments = splitTextByUrl(text);
const firstSegment = segments[0];
const singleUrl =
segments.length === 1 && firstSegment?.link && firstSegment.text === text
? firstSegment.link
: undefined;
return {
segments,
singleUrl,
};
}
export function insertUrlTextSegments(
inlineEditor: UrlPasteInlineEditor,
inlineRange: InlineRange,
segments: UrlTextSegment[]
) {
let index = inlineRange.index;
let replacedSelection = false;
segments.forEach(segment => {
if (!segment.text) return;
const attributes: AffineTextAttributes | undefined = segment.link
? { link: segment.link }
: undefined;
inlineEditor.insertText(
{
index,
length: replacedSelection ? 0 : inlineRange.length,
},
segment.text,
attributes
);
replacedSelection = true;
index += segment.text.length;
});
inlineEditor.setInlineRange({
index,
length: 0,
});
}

View File

@@ -8,7 +8,10 @@ import type {
AffineInlineEditor,
AffineTextAttributes,
} from '@blocksuite/affine-shared/types';
import { getViewportElement } from '@blocksuite/affine-shared/utils';
import {
getViewportElement,
isValidUrl,
} from '@blocksuite/affine-shared/utils';
import {
BaseCellRenderer,
createFromBaseCellRenderer,
@@ -23,7 +26,6 @@ import { html } from 'lit/static-html.js';
import { EditorHostKey } from '../../context/host-context.js';
import type { DatabaseBlockComponent } from '../../database-block.js';
import { analyzeTextForUrlPaste, insertUrlTextSegments } from '../paste-url.js';
import {
richTextCellStyle,
richTextContainerStyle,
@@ -269,13 +271,10 @@ export class RichTextCell extends BaseCellRenderer<Text, string> {
?.getData('text/plain')
?.replace(/\r?\n|\r/g, '\n');
if (!text) return;
const { segments, singleUrl } = analyzeTextForUrlPaste(text);
if (singleUrl) {
if (isValidUrl(text)) {
const std = this.std;
const result = std
?.getOptional(ParseDocUrlProvider)
?.parseDocUrl(singleUrl);
const result = std?.getOptional(ParseDocUrlProvider)?.parseDocUrl(text);
if (result) {
const text = ' ';
inlineEditor.insertText(inlineRange, text, {
@@ -301,10 +300,22 @@ export class RichTextCell extends BaseCellRenderer<Text, string> {
segment: 'database',
parentFlavour: 'affine:database',
});
return;
} else {
inlineEditor.insertText(inlineRange, text, {
link: text,
});
inlineEditor.setInlineRange({
index: inlineRange.index + text.length,
length: 0,
});
}
} else {
inlineEditor.insertText(inlineRange, text);
inlineEditor.setInlineRange({
index: inlineRange.index + text.length,
length: 0,
});
}
insertUrlTextSegments(inlineEditor, inlineRange, segments);
};
override connectedCallback() {

View File

@@ -4,7 +4,10 @@ import {
ParseDocUrlProvider,
TelemetryProvider,
} from '@blocksuite/affine-shared/services';
import { getViewportElement } from '@blocksuite/affine-shared/utils';
import {
getViewportElement,
isValidUrl,
} from '@blocksuite/affine-shared/utils';
import { BaseCellRenderer } from '@blocksuite/data-view';
import { IS_MAC } from '@blocksuite/global/env';
import { LinkedPageIcon } from '@blocksuite/icons/lit';
@@ -17,7 +20,6 @@ import { html } from 'lit/static-html.js';
import { EditorHostKey } from '../../context/host-context.js';
import type { DatabaseBlockComponent } from '../../database-block.js';
import { getSingleDocIdFromText } from '../../utils/title-doc.js';
import { analyzeTextForUrlPaste, insertUrlTextSegments } from '../paste-url.js';
import {
headerAreaIconStyle,
titleCellStyle,
@@ -93,9 +95,7 @@ export class HeaderAreaTextCell extends BaseCellRenderer<Text, string> {
private readonly _onPaste = (e: ClipboardEvent) => {
const inlineEditor = this.inlineEditor;
const inlineRange = inlineEditor?.getInlineRange();
if (!inlineEditor || !inlineRange) return;
e.preventDefault();
e.stopPropagation();
if (!inlineRange) return;
if (e.clipboardData) {
try {
const getDeltas = (snapshot: BlockSnapshot): DeltaInsert[] => {
@@ -121,15 +121,14 @@ export class HeaderAreaTextCell extends BaseCellRenderer<Text, string> {
?.getData('text/plain')
?.replace(/\r?\n|\r/g, '\n');
if (!text) return;
const { segments, singleUrl } = analyzeTextForUrlPaste(text);
if (singleUrl) {
e.preventDefault();
e.stopPropagation();
if (isValidUrl(text)) {
const std = this.std;
const result = std
?.getOptional(ParseDocUrlProvider)
?.parseDocUrl(singleUrl);
const result = std?.getOptional(ParseDocUrlProvider)?.parseDocUrl(text);
if (result) {
const text = ' ';
inlineEditor.insertText(inlineRange, text, {
inlineEditor?.insertText(inlineRange, text, {
reference: {
type: 'LinkedPage',
pageId: result.docId,
@@ -140,7 +139,7 @@ export class HeaderAreaTextCell extends BaseCellRenderer<Text, string> {
},
},
});
inlineEditor.setInlineRange({
inlineEditor?.setInlineRange({
index: inlineRange.index + text.length,
length: 0,
});
@@ -152,10 +151,22 @@ export class HeaderAreaTextCell extends BaseCellRenderer<Text, string> {
segment: 'database',
parentFlavour: 'affine:database',
});
return;
} else {
inlineEditor?.insertText(inlineRange, text, {
link: text,
});
inlineEditor?.setInlineRange({
index: inlineRange.index + text.length,
length: 0,
});
}
} else {
inlineEditor?.insertText(inlineRange, text);
inlineEditor?.setInlineRange({
index: inlineRange.index + text.length,
length: 0,
});
}
insertUrlTextSegments(inlineEditor, inlineRange, segments);
};
insertDelta = (delta: DeltaInsert) => {
@@ -229,8 +240,7 @@ export class HeaderAreaTextCell extends BaseCellRenderer<Text, string> {
this.disposables.addFromEvent(
this.richText.value,
'paste',
this._onPaste,
true
this._onPaste
);
const inlineEditor = this.inlineEditor;
if (inlineEditor) {

View File

@@ -24,6 +24,7 @@
"@toeverything/theme": "^1.1.23",
"@types/mdast": "^4.0.4",
"lit": "^3.2.0",
"minimatch": "^10.1.1",
"rxjs": "^7.8.2",
"zod": "^3.25.76"
},
@@ -38,5 +39,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.26.3"
"version": "0.26.1"
}

View File

@@ -28,6 +28,7 @@
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.23",
"lit": "^3.2.0",
"minimatch": "^10.1.1",
"rxjs": "^7.8.2",
"zod": "^3.25.76"
},
@@ -42,5 +43,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.26.3"
"version": "0.26.1"
}

View File

@@ -30,6 +30,7 @@
"@types/lodash-es": "^4.17.12",
"lit": "^3.2.0",
"lodash-es": "^4.17.23",
"minimatch": "^10.1.1",
"rxjs": "^7.8.2",
"yjs": "^13.6.27",
"zod": "^3.25.76"
@@ -48,5 +49,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.26.3"
"version": "0.26.1"
}

View File

@@ -30,6 +30,7 @@
"@types/lodash-es": "^4.17.12",
"lit": "^3.2.0",
"lodash-es": "^4.17.23",
"minimatch": "^10.1.1",
"rxjs": "^7.8.2",
"yjs": "^13.6.27",
"zod": "^3.25.76"
@@ -48,5 +49,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.26.3"
"version": "0.26.1"
}

View File

@@ -28,6 +28,7 @@
"@toeverything/theme": "^1.1.23",
"@types/mdast": "^4.0.4",
"lit": "^3.2.0",
"minimatch": "^10.1.1",
"rxjs": "^7.8.2",
"yjs": "^13.6.27",
"zod": "^3.25.76"
@@ -43,5 +44,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.26.3"
"version": "0.26.1"
}

View File

@@ -28,6 +28,7 @@
"@toeverything/theme": "^1.1.23",
"file-type": "^21.0.0",
"lit": "^3.2.0",
"minimatch": "^10.1.1",
"rxjs": "^7.8.2",
"zod": "^3.25.76"
},
@@ -43,5 +44,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.26.3"
"version": "0.26.1"
}

View File

@@ -26,11 +26,6 @@ import {
@Peekable()
export class ImageEdgelessBlockComponent extends GfxBlockComponent<ImageBlockModel> {
private static readonly LOD_MIN_IMAGE_BYTES = 1024 * 1024;
private static readonly LOD_MIN_IMAGE_PIXELS = 1920 * 1080;
private static readonly LOD_MAX_ZOOM = 0.4;
private static readonly LOD_THUMBNAIL_MAX_EDGE = 256;
static override styles = css`
affine-edgeless-image {
position: relative;
@@ -68,11 +63,6 @@ export class ImageEdgelessBlockComponent extends GfxBlockComponent<ImageBlockMod
width: 100%;
height: 100%;
}
affine-edgeless-image .resizable-img {
position: relative;
overflow: hidden;
}
`;
resourceController = new ResourceController(
@@ -80,12 +70,6 @@ export class ImageEdgelessBlockComponent extends GfxBlockComponent<ImageBlockMod
'Image'
);
private _lodThumbnailUrl: string | null = null;
private _lodSourceUrl: string | null = null;
private _lodGeneratingSourceUrl: string | null = null;
private _lodGenerationToken = 0;
private _lastShouldUseLod = false;
get blobUrl() {
return this.resourceController.blobUrl$.value;
}
@@ -112,134 +96,6 @@ export class ImageEdgelessBlockComponent extends GfxBlockComponent<ImageBlockMod
});
}
private _isLargeImage() {
const { width = 0, height = 0, size = 0 } = this.model.props;
const pixels = width * height;
return (
size >= ImageEdgelessBlockComponent.LOD_MIN_IMAGE_BYTES ||
pixels >= ImageEdgelessBlockComponent.LOD_MIN_IMAGE_PIXELS
);
}
private _shouldUseLod(blobUrl: string | null, zoom = this.gfx.viewport.zoom) {
return (
Boolean(blobUrl) &&
this._isLargeImage() &&
zoom <= ImageEdgelessBlockComponent.LOD_MAX_ZOOM
);
}
private _revokeLodThumbnail() {
if (!this._lodThumbnailUrl) {
return;
}
URL.revokeObjectURL(this._lodThumbnailUrl);
this._lodThumbnailUrl = null;
}
private _resetLodSource(blobUrl: string | null) {
if (this._lodSourceUrl === blobUrl) {
return;
}
this._lodGenerationToken += 1;
this._lodGeneratingSourceUrl = null;
this._lodSourceUrl = blobUrl;
this._revokeLodThumbnail();
}
private _createImageElement(src: string) {
return new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
image.decoding = 'async';
image.onload = () => resolve(image);
image.onerror = () => reject(new Error('Failed to load image'));
image.src = src;
});
}
private _createThumbnailBlob(image: HTMLImageElement) {
const maxEdge = ImageEdgelessBlockComponent.LOD_THUMBNAIL_MAX_EDGE;
const longestEdge = Math.max(image.naturalWidth, image.naturalHeight);
const scale = longestEdge > maxEdge ? maxEdge / longestEdge : 1;
const targetWidth = Math.max(1, Math.round(image.naturalWidth * scale));
const targetHeight = Math.max(1, Math.round(image.naturalHeight * scale));
const canvas = document.createElement('canvas');
canvas.width = targetWidth;
canvas.height = targetHeight;
const ctx = canvas.getContext('2d');
if (!ctx) {
return Promise.resolve<Blob | null>(null);
}
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'low';
ctx.drawImage(image, 0, 0, targetWidth, targetHeight);
return new Promise<Blob | null>(resolve => {
canvas.toBlob(resolve);
});
}
private _ensureLodThumbnail(blobUrl: string) {
if (
this._lodThumbnailUrl ||
this._lodGeneratingSourceUrl === blobUrl ||
!this._shouldUseLod(blobUrl)
) {
return;
}
const token = ++this._lodGenerationToken;
this._lodGeneratingSourceUrl = blobUrl;
void this._createImageElement(blobUrl)
.then(image => this._createThumbnailBlob(image))
.then(blob => {
if (!blob || token !== this._lodGenerationToken || !this.isConnected) {
return;
}
const thumbnailUrl = URL.createObjectURL(blob);
if (token !== this._lodGenerationToken || !this.isConnected) {
URL.revokeObjectURL(thumbnailUrl);
return;
}
this._revokeLodThumbnail();
this._lodThumbnailUrl = thumbnailUrl;
if (this._shouldUseLod(this.blobUrl)) {
this.requestUpdate();
}
})
.catch(err => {
if (token !== this._lodGenerationToken || !this.isConnected) {
return;
}
console.error(err);
})
.finally(() => {
if (token === this._lodGenerationToken) {
this._lodGeneratingSourceUrl = null;
}
});
}
private _updateLodFromViewport(zoom: number) {
const shouldUseLod = this._shouldUseLod(this.blobUrl, zoom);
if (shouldUseLod === this._lastShouldUseLod) {
return;
}
this._lastShouldUseLod = shouldUseLod;
if (shouldUseLod && this.blobUrl) {
this._ensureLodThumbnail(this.blobUrl);
}
this.requestUpdate();
}
override connectedCallback() {
super.connectedCallback();
@@ -252,32 +108,14 @@ export class ImageEdgelessBlockComponent extends GfxBlockComponent<ImageBlockMod
this.disposables.add(
this.model.props.sourceId$.subscribe(() => {
this._resetLodSource(null);
this.refreshData();
})
);
this.disposables.add(
this.gfx.viewport.viewportUpdated.subscribe(({ zoom }) => {
this._updateLodFromViewport(zoom);
})
);
this._lastShouldUseLod = this._shouldUseLod(this.blobUrl);
}
override disconnectedCallback() {
this._lodGenerationToken += 1;
this._lodGeneratingSourceUrl = null;
this._lodSourceUrl = null;
this._revokeLodThumbnail();
super.disconnectedCallback();
}
override renderGfxBlock() {
const blobUrl = this.blobUrl;
const { rotate = 0, size = 0, caption = 'Image' } = this.model.props;
this._resetLodSource(blobUrl);
const containerStyleMap = styleMap({
display: 'flex',
@@ -300,13 +138,6 @@ export class ImageEdgelessBlockComponent extends GfxBlockComponent<ImageBlockMod
});
const { loading, icon, description, error, needUpload } = resovledState;
const shouldUseLod = this._shouldUseLod(blobUrl);
if (shouldUseLod && blobUrl) {
this._ensureLodThumbnail(blobUrl);
}
this._lastShouldUseLod = shouldUseLod;
const imageUrl =
shouldUseLod && this._lodThumbnailUrl ? this._lodThumbnailUrl : blobUrl;
return html`
<div class="affine-image-container" style=${containerStyleMap}>
@@ -318,7 +149,7 @@ export class ImageEdgelessBlockComponent extends GfxBlockComponent<ImageBlockMod
class="drag-target"
draggable="false"
loading="lazy"
src=${imageUrl ?? ''}
src=${blobUrl}
alt=${caption}
@error=${this._handleError}
/>

View File

@@ -30,6 +30,7 @@
"@types/mdast": "^4.0.4",
"katex": "^0.16.27",
"lit": "^3.2.0",
"minimatch": "^10.1.1",
"remark-math": "^6.0.0",
"rxjs": "^7.8.2",
"zod": "^3.25.76"
@@ -45,5 +46,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.26.3"
"version": "0.26.1"
}

View File

@@ -27,6 +27,7 @@
"@toeverything/theme": "^1.1.23",
"@types/mdast": "^4.0.4",
"lit": "^3.2.0",
"minimatch": "^10.1.1",
"rxjs": "^7.8.2",
"zod": "^3.25.76"
},
@@ -45,5 +46,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.26.3"
"version": "0.26.1"
}

View File

@@ -33,6 +33,7 @@
"@vanilla-extract/css": "^1.17.0",
"lit": "^3.2.0",
"lodash-es": "^4.17.23",
"minimatch": "^10.1.1",
"rxjs": "^7.8.2",
"zod": "^3.25.76"
},
@@ -48,5 +49,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.26.3"
"version": "0.26.1"
}

View File

@@ -26,6 +26,7 @@
"@toeverything/theme": "^1.1.23",
"@types/mdast": "^4.0.4",
"lit": "^3.2.0",
"minimatch": "^10.1.1",
"rxjs": "^7.8.2",
"zod": "^3.25.76"
},
@@ -41,5 +42,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.26.3"
"version": "0.26.1"
}

View File

@@ -37,126 +37,6 @@ const tagsInAncestor = (o: NodeProps<HtmlAST>, tagNames: Array<string>) => {
return false;
};
const splitDeltaByNewline = (delta: DeltaInsert[]) => {
const lines: DeltaInsert[][] = [[]];
const pending = [...delta];
while (pending.length > 0) {
const op = pending.shift();
if (!op) continue;
const insert = op.insert;
if (typeof insert !== 'string') {
lines[lines.length - 1].push(op);
continue;
}
if (!insert.includes('\n')) {
if (insert.length === 0) {
continue;
}
lines[lines.length - 1].push(op);
continue;
}
const splitIndex = insert.indexOf('\n');
const linePart = insert.slice(0, splitIndex);
const remainPart = insert.slice(splitIndex + 1);
if (linePart.length > 0) {
lines[lines.length - 1].push({ ...op, insert: linePart });
}
lines.push([]);
if (remainPart) {
pending.unshift({ ...op, insert: remainPart });
}
}
return lines;
};
const hasBlockElementDescendant = (node: HtmlAST): boolean => {
if (!HastUtils.isElement(node)) {
return false;
}
return node.children.some(child => {
if (!HastUtils.isElement(child)) {
return false;
}
return (
(HastUtils.isTagBlock(child.tagName) && child.tagName !== 'br') ||
hasBlockElementDescendant(child)
);
});
};
const getParagraphDeltas = (
node: HtmlAST,
delta: DeltaInsert[]
): DeltaInsert[][] => {
if (!HastUtils.isElement(node)) return [delta];
if (hasBlockElementDescendant(node)) return [delta];
const hasBr = !!HastUtils.querySelector(node, 'br');
if (!hasBr) return [delta];
const hasNewline = delta.some(
op => typeof op.insert === 'string' && op.insert.includes('\n')
);
if (!hasNewline) return [delta];
return splitDeltaByNewline(delta);
};
const openParagraphBlocks = (
deltas: DeltaInsert[][],
type: string,
// AST walker context from html adapter transform pipeline.
walkerContext: any
) => {
for (const delta of deltas) {
walkerContext
.openNode(
{
type: 'block',
id: nanoid(),
flavour: 'affine:paragraph',
props: { type, text: { '$blocksuite:internal:text$': true, delta } },
children: [],
},
'children'
)
.closeNode();
}
};
const MULTI_PARAGRAPH_EMITTED_NODES_CONTEXT_KEY =
'affine:paragraph:multi-emitted-nodes';
const markMultiParagraphEmitted = (walkerContext: any, node: HtmlAST) => {
const emittedNodes =
(walkerContext.getGlobalContext(
MULTI_PARAGRAPH_EMITTED_NODES_CONTEXT_KEY
) as WeakSet<object> | undefined) ?? new WeakSet<object>();
emittedNodes.add(node as object);
walkerContext.setGlobalContext(
MULTI_PARAGRAPH_EMITTED_NODES_CONTEXT_KEY,
emittedNodes
);
};
const consumeMultiParagraphEmittedMark = (
walkerContext: any,
node: HtmlAST
) => {
const emittedNodes = walkerContext.getGlobalContext(
MULTI_PARAGRAPH_EMITTED_NODES_CONTEXT_KEY
) as WeakSet<object> | undefined;
if (!emittedNodes) {
return false;
}
return emittedNodes.delete(node as object);
};
export const paragraphBlockHtmlAdapterMatcher: BlockHtmlAdapterMatcher = {
flavour: ParagraphBlockSchema.model.flavour,
toMatch: o =>
@@ -208,37 +88,41 @@ export const paragraphBlockHtmlAdapterMatcher: BlockHtmlAdapterMatcher = {
!tagsInAncestor(o, ['p', 'li']) &&
HastUtils.isParagraphLike(o.node)
) {
const delta = deltaConverter.astToDelta(o.node);
const deltas = getParagraphDeltas(o.node, delta);
openParagraphBlocks(deltas, 'text', walkerContext);
walkerContext
.openNode(
{
type: 'block',
id: nanoid(),
flavour: 'affine:paragraph',
props: {
type: 'text',
text: {
'$blocksuite:internal:text$': true,
delta: deltaConverter.astToDelta(o.node),
},
},
children: [],
},
'children'
)
.closeNode();
walkerContext.skipAllChildren();
}
break;
}
case 'p': {
const type = walkerContext.getGlobalContext('hast:blockquote')
? 'quote'
: 'text';
const delta = deltaConverter.astToDelta(o.node);
const deltas = getParagraphDeltas(o.node, delta);
if (deltas.length > 1) {
openParagraphBlocks(deltas, type, walkerContext);
markMultiParagraphEmitted(walkerContext, o.node);
walkerContext.skipAllChildren();
break;
}
walkerContext.openNode(
{
type: 'block',
id: nanoid(),
flavour: 'affine:paragraph',
props: {
type,
type: walkerContext.getGlobalContext('hast:blockquote')
? 'quote'
: 'text',
text: {
'$blocksuite:internal:text$': true,
delta,
delta: deltaConverter.astToDelta(o.node),
},
},
children: [],
@@ -308,9 +192,6 @@ export const paragraphBlockHtmlAdapterMatcher: BlockHtmlAdapterMatcher = {
break;
}
case 'p': {
if (consumeMultiParagraphEmittedMark(walkerContext, o.node)) {
break;
}
if (
o.next?.type === 'element' &&
o.next.tagName === 'div' &&

View File

@@ -50,6 +50,7 @@
"html2canvas": "^1.4.1",
"lit": "^3.2.0",
"lodash-es": "^4.17.23",
"minimatch": "^10.1.1",
"rxjs": "^7.8.2",
"yjs": "^13.6.27",
"zod": "^3.25.76"
@@ -66,5 +67,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.26.3"
"version": "0.26.1"
}

View File

@@ -86,7 +86,6 @@ export class PageClipboard extends ReadOnlyClipboard {
if (this.std.store.readonly) return;
this.std.store.captureSync();
let hasPasteTarget = false;
this.std.command
.chain()
.try<{}>(cmd => [
@@ -145,39 +144,18 @@ export class PageClipboard extends ReadOnlyClipboard {
if (!ctx.parentBlock) {
return;
}
hasPasteTarget = true;
this.std.clipboard
.paste(
e,
this.std.store,
ctx.parentBlock.model.id,
ctx.blockIndex !== undefined ? ctx.blockIndex + 1 : 1
ctx.blockIndex ? ctx.blockIndex + 1 : 1
)
.catch(console.error);
return next();
})
.run();
if (hasPasteTarget) return;
// If no valid selection target exists (for example, stale block selection
// right after cut), create/focus the default paragraph and paste after it.
const firstParagraphId = document
.querySelector('affine-page-root')
?.focusFirstParagraph?.()?.id;
const parentModel = firstParagraphId
? this.std.store.getParent(firstParagraphId)
: null;
const paragraphIndex =
firstParagraphId && parentModel
? parentModel.children.findIndex(child => child.id === firstParagraphId)
: -1;
const insertIndex = paragraphIndex >= 0 ? paragraphIndex + 1 : undefined;
this.std.clipboard
.paste(e, this.std.store, parentModel?.id, insertIndex)
.catch(console.error);
};
override mounted() {

View File

@@ -33,11 +33,7 @@ import {
ReleaseFromGroupIcon,
UnlockIcon,
} from '@blocksuite/icons/lit';
import {
batchAddChildren,
batchRemoveChildren,
type GfxModel,
} from '@blocksuite/std/gfx';
import type { GfxModel } from '@blocksuite/std/gfx';
import { html } from 'lit';
import { renderAlignmentMenu } from './alignment';
@@ -65,13 +61,14 @@ export const builtinMiscToolbarConfig = {
const group = firstModel.group;
batchRemoveChildren(group, [firstModel]);
// oxlint-disable-next-line unicorn/prefer-dom-node-remove
group.removeChild(firstModel);
firstModel.index = ctx.gfx.layer.generateIndex();
const parent = group.group;
if (parent && parent instanceof GroupElementModel) {
batchAddChildren(parent, [firstModel]);
parent.addChild(firstModel);
}
},
},
@@ -258,12 +255,9 @@ export const builtinMiscToolbarConfig = {
// release other elements from their groups and group with top element
otherElements.forEach(element => {
if (element.group) {
batchRemoveChildren(element.group, [element]);
}
if (topElement.group) {
batchAddChildren(topElement.group, [element]);
}
// oxlint-disable-next-line unicorn/prefer-dom-node-remove
element.group?.removeChild(element);
topElement.group?.addChild(element);
});
if (otherElements.length === 0) {

View File

@@ -45,5 +45,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.26.3"
"version": "0.26.1"
}

View File

@@ -46,5 +46,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.26.3"
"version": "0.26.1"
}

View File

@@ -40,146 +40,10 @@ export const SurfaceBlockSchemaExtension =
export class SurfaceBlockModel extends BaseSurfaceModel {
private readonly _disposables: DisposableGroup = new DisposableGroup();
private readonly _connectorIdsByEndpoint = new Map<string, Set<string>>();
private readonly _connectorIndexDisposables = new DisposableGroup();
private readonly _connectorEndpoints = new Map<
string,
{ sourceId: string | null; targetId: string | null }
>();
private _addConnectorEndpoint(endpointId: string, connectorId: string) {
const connectorIds = this._connectorIdsByEndpoint.get(endpointId);
if (connectorIds) {
connectorIds.add(connectorId);
return;
}
this._connectorIdsByEndpoint.set(endpointId, new Set([connectorId]));
}
private _isConnectorModel(model: unknown): model is ConnectorElementModel {
return (
!!model &&
typeof model === 'object' &&
'type' in model &&
(model as { type?: string }).type === 'connector'
);
}
private _removeConnectorEndpoint(endpointId: string, connectorId: string) {
const connectorIds = this._connectorIdsByEndpoint.get(endpointId);
if (!connectorIds) {
return;
}
connectorIds.delete(connectorId);
if (connectorIds.size === 0) {
this._connectorIdsByEndpoint.delete(endpointId);
}
}
private _removeConnectorFromIndex(connectorId: string) {
const endpoints = this._connectorEndpoints.get(connectorId);
if (!endpoints) {
return;
}
if (endpoints.sourceId) {
this._removeConnectorEndpoint(endpoints.sourceId, connectorId);
}
if (endpoints.targetId) {
this._removeConnectorEndpoint(endpoints.targetId, connectorId);
}
this._connectorEndpoints.delete(connectorId);
}
private _rebuildConnectorIndex() {
this._connectorIdsByEndpoint.clear();
this._connectorEndpoints.clear();
this.getElementsByType('connector').forEach(connector => {
this._setConnectorEndpoints(connector as ConnectorElementModel);
});
}
private _setConnectorEndpoints(connector: ConnectorElementModel) {
const sourceId = connector.source?.id ?? null;
const targetId = connector.target?.id ?? null;
const previousEndpoints = this._connectorEndpoints.get(connector.id);
if (
previousEndpoints?.sourceId === sourceId &&
previousEndpoints?.targetId === targetId
) {
return;
}
if (previousEndpoints?.sourceId) {
this._removeConnectorEndpoint(previousEndpoints.sourceId, connector.id);
}
if (previousEndpoints?.targetId) {
this._removeConnectorEndpoint(previousEndpoints.targetId, connector.id);
}
if (sourceId) {
this._addConnectorEndpoint(sourceId, connector.id);
}
if (targetId) {
this._addConnectorEndpoint(targetId, connector.id);
}
this._connectorEndpoints.set(connector.id, {
sourceId,
targetId,
});
}
override _init() {
this._extendElement(elementsCtorMap);
super._init();
this._rebuildConnectorIndex();
this._connectorIndexDisposables.add(
this.elementAdded.subscribe(({ id }) => {
const model = this.getElementById(id);
if (this._isConnectorModel(model)) {
this._setConnectorEndpoints(model);
}
})
);
this._connectorIndexDisposables.add(
this.elementUpdated.subscribe(({ id, props }) => {
if (!props['source'] && !props['target']) {
return;
}
const model = this.getElementById(id);
if (this._isConnectorModel(model)) {
this._setConnectorEndpoints(model);
}
})
);
this._connectorIndexDisposables.add(
this.elementRemoved.subscribe(({ id, type }) => {
if (type === 'connector') {
this._removeConnectorFromIndex(id);
}
})
);
this.deleted.subscribe(() => {
this._connectorIndexDisposables.dispose();
this._connectorIdsByEndpoint.clear();
this._connectorEndpoints.clear();
});
this.store.provider
.getAll(surfaceMiddlewareIdentifier)
.forEach(({ middleware }) => {
@@ -188,31 +52,13 @@ export class SurfaceBlockModel extends BaseSurfaceModel {
}
getConnectors(id: string) {
const connectorIds = this._connectorIdsByEndpoint.get(id);
const connectors = this.getElementsByType(
'connector'
) as unknown[] as ConnectorElementModel[];
if (!connectorIds?.size) {
return [];
}
const staleConnectorIds: string[] = [];
const connectors: ConnectorElementModel[] = [];
connectorIds.forEach(connectorId => {
const model = this.getElementById(connectorId);
if (!this._isConnectorModel(model)) {
staleConnectorIds.push(connectorId);
return;
}
connectors.push(model);
});
staleConnectorIds.forEach(connectorId => {
this._removeConnectorFromIndex(connectorId);
});
return connectors;
return connectors.filter(
connector => connector.source?.id === id || connector.target?.id === id
);
}
override getElementsByType<K extends keyof SurfaceElementModelMap>(

View File

@@ -42,5 +42,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.26.3"
"version": "0.26.1"
}

View File

@@ -82,5 +82,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.26.3"
"version": "0.26.1"
}

View File

@@ -48,5 +48,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.26.3"
"version": "0.26.1"
}

View File

@@ -3,11 +3,8 @@ import { describe, expect, it, vi } from 'vitest';
import type { GroupBy } from '../core/common/types.js';
import type { DataSource } from '../core/data-source/base.js';
import { DetailSelection } from '../core/detail/selection.js';
import type { FilterGroup } from '../core/filter/types.js';
import { groupByMatchers } from '../core/group-by/define.js';
import { t } from '../core/logical/type-presets.js';
import type { DataViewCellLifeCycle } from '../core/property/index.js';
import { checkboxPropertyModelConfig } from '../property-presets/checkbox/define.js';
import { multiSelectPropertyModelConfig } from '../property-presets/multi-select/define.js';
import { selectPropertyModelConfig } from '../property-presets/select/define.js';
@@ -18,10 +15,7 @@ import {
pickKanbanGroupColumn,
resolveKanbanGroupBy,
} from '../view-presets/kanban/group-by-utils.js';
import {
KanbanSingleView,
materializeKanbanColumns,
} from '../view-presets/kanban/kanban-view-manager.js';
import { materializeKanbanColumns } from '../view-presets/kanban/kanban-view-manager.js';
import type { KanbanCard } from '../view-presets/kanban/pc/card.js';
import { KanbanDragController } from '../view-presets/kanban/pc/controller/drag.js';
import type { KanbanGroup } from '../view-presets/kanban/pc/group.js';
@@ -274,73 +268,6 @@ describe('kanban', () => {
});
});
describe('filtering', () => {
const sharedFilter: FilterGroup = {
type: 'group',
op: 'and',
conditions: [
{
type: 'filter',
left: {
type: 'ref',
name: 'status',
},
function: 'is',
args: [{ type: 'literal', value: 'Done' }],
},
],
};
const sharedTitleProperty = {
id: 'title',
cellGetOrCreate: () => ({
jsonValue$: {
value: 'Task 1',
},
}),
};
it('evaluates filters with hidden columns', () => {
const statusProperty = {
id: 'status',
cellGetOrCreate: () => ({
jsonValue$: {
value: 'Done',
},
}),
};
const view = {
filter$: { value: sharedFilter },
// Simulate status being hidden in current view.
properties$: { value: [sharedTitleProperty] },
propertiesRaw$: { value: [sharedTitleProperty, statusProperty] },
} as unknown as KanbanSingleView;
expect(KanbanSingleView.prototype.isShow.call(view, 'row-1')).toBe(true);
});
it('returns false when hidden filtered column does not match', () => {
const statusProperty = {
id: 'status',
cellGetOrCreate: () => ({
jsonValue$: {
value: 'In Progress',
},
}),
};
const view = {
filter$: { value: sharedFilter },
// Simulate status being hidden in current view.
properties$: { value: [sharedTitleProperty] },
propertiesRaw$: { value: [sharedTitleProperty, statusProperty] },
} as unknown as KanbanSingleView;
expect(KanbanSingleView.prototype.isShow.call(view, 'row-1')).toBe(false);
});
});
describe('drag indicator', () => {
it('shows drop preview when insert position exists', () => {
const controller = createDragController();
@@ -529,60 +456,4 @@ describe('kanban', () => {
expect(next?.hideEmpty).toBe(true);
});
});
describe('detail selection', () => {
it('should avoid recursive selection update when exiting select edit mode', () => {
vi.stubGlobal('requestAnimationFrame', ((cb: FrameRequestCallback) => {
cb(0);
return 0;
}) as typeof requestAnimationFrame);
try {
let selection: DetailSelection;
let beforeExitCalls = 0;
const cell = {
beforeEnterEditMode: () => true,
beforeExitEditingMode: () => {
beforeExitCalls += 1;
selection.selection = {
propertyId: 'status',
isEditing: false,
};
},
afterEnterEditingMode: () => {},
focusCell: () => true,
blurCell: () => true,
forceUpdate: () => {},
} satisfies DataViewCellLifeCycle;
const field = {
isFocus$: signal(false),
isEditing$: signal(false),
cell,
focus: () => {},
blur: () => {},
};
const detail = {
querySelector: () => field,
};
selection = new DetailSelection(detail);
selection.selection = {
propertyId: 'status',
isEditing: true,
};
selection.selection = {
propertyId: 'status',
isEditing: false,
};
expect(beforeExitCalls).toBe(1);
expect(field.isEditing$.value).toBe(false);
} finally {
vi.unstubAllGlobals();
}
});
});
});

View File

@@ -0,0 +1,36 @@
import { describe, expect, test } from 'vitest';
import { mobileEffects } from '../view-presets/table/mobile/effect.js';
import type { MobileTableGroup } from '../view-presets/table/mobile/group.js';
import { pcEffects } from '../view-presets/table/pc/effect.js';
import type { TableGroup } from '../view-presets/table/pc/group.js';
/** @vitest-environment happy-dom */
describe('TableGroup', () => {
test('toggle collapse on pc', () => {
pcEffects();
const group = document.createElement(
'affine-data-view-table-group'
) as TableGroup;
expect(group.collapsed$.value).toBe(false);
(group as any)._toggleCollapse();
expect(group.collapsed$.value).toBe(true);
(group as any)._toggleCollapse();
expect(group.collapsed$.value).toBe(false);
});
test('toggle collapse on mobile', () => {
mobileEffects();
const group = document.createElement(
'mobile-table-group'
) as MobileTableGroup;
expect(group.collapsed$.value).toBe(false);
(group as any)._toggleCollapse();
expect(group.collapsed$.value).toBe(true);
(group as any)._toggleCollapse();
expect(group.collapsed$.value).toBe(false);
});
});

View File

@@ -1,247 +0,0 @@
import { describe, expect, test } from 'vitest';
import type { FilterGroup } from '../core/filter/types.js';
import { numberFormats } from '../property-presets/number/utils/formats.js';
import {
formatNumber,
NumberFormatSchema,
parseNumber,
} from '../property-presets/number/utils/formatter.js';
import { DEFAULT_COLUMN_WIDTH } from '../view-presets/table/consts.js';
import { mobileEffects } from '../view-presets/table/mobile/effect.js';
import type { MobileTableGroup } from '../view-presets/table/mobile/group.js';
import { pcEffects } from '../view-presets/table/pc/effect.js';
import type { TableGroup } from '../view-presets/table/pc/group.js';
import {
materializeTableColumns,
TableSingleView,
} from '../view-presets/table/table-view-manager.js';
/** @vitest-environment happy-dom */
describe('TableGroup', () => {
test('toggle collapse on pc', () => {
pcEffects();
const group = document.createElement(
'affine-data-view-table-group'
) as TableGroup;
expect(group.collapsed$.value).toBe(false);
(group as any)._toggleCollapse();
expect(group.collapsed$.value).toBe(true);
(group as any)._toggleCollapse();
expect(group.collapsed$.value).toBe(false);
});
test('toggle collapse on mobile', () => {
mobileEffects();
const group = document.createElement(
'mobile-table-group'
) as MobileTableGroup;
expect(group.collapsed$.value).toBe(false);
(group as any)._toggleCollapse();
expect(group.collapsed$.value).toBe(true);
(group as any)._toggleCollapse();
expect(group.collapsed$.value).toBe(false);
});
});
describe('table column materialization', () => {
test('appends missing properties while preserving existing order and state', () => {
const columns = [
{ id: 'status', width: 240, hide: true },
{ id: 'title', width: 320 },
];
const next = materializeTableColumns(columns, ['title', 'status', 'date']);
expect(next).toEqual([
{ id: 'status', width: 240, hide: true },
{ id: 'title', width: 320 },
{ id: 'date', width: DEFAULT_COLUMN_WIDTH },
]);
});
test('drops stale columns that no longer exist in data source', () => {
const columns = [
{ id: 'title', width: 320 },
{ id: 'removed', width: 200, hide: true },
];
const next = materializeTableColumns(columns, ['title']);
expect(next).toEqual([{ id: 'title', width: 320 }]);
});
test('returns original reference when columns are already materialized', () => {
const columns = [
{ id: 'title', width: 320 },
{ id: 'status', width: 240, hide: true },
];
const next = materializeTableColumns(columns, ['title', 'status']);
expect(next).toBe(columns);
});
test('supports type-aware default width when materializing missing columns', () => {
const next = materializeTableColumns([], ['title', 'status'], id =>
id === 'title' ? 260 : DEFAULT_COLUMN_WIDTH
);
expect(next).toEqual([
{ id: 'title', width: 260 },
{ id: 'status', width: DEFAULT_COLUMN_WIDTH },
]);
});
});
describe('table filtering', () => {
test('evaluates filters with hidden columns', () => {
const filter: FilterGroup = {
type: 'group',
op: 'and',
conditions: [
{
type: 'filter',
left: {
type: 'ref',
name: 'status',
},
function: 'is',
args: [{ type: 'literal', value: 'Done' }],
},
],
};
const titleProperty = {
id: 'title',
cellGetOrCreate: () => ({
jsonValue$: {
value: 'Task 1',
},
}),
};
const statusProperty = {
id: 'status',
cellGetOrCreate: () => ({
jsonValue$: {
value: 'Done',
},
}),
};
const view = {
filter$: { value: filter },
// Simulate status being hidden in current view.
properties$: { value: [titleProperty] },
propertiesRaw$: { value: [titleProperty, statusProperty] },
} as unknown as TableSingleView;
expect(TableSingleView.prototype.isShow.call(view, 'row-1')).toBe(true);
});
test('returns false when hidden filtered column does not match', () => {
const filter: FilterGroup = {
type: 'group',
op: 'and',
conditions: [
{
type: 'filter',
left: {
type: 'ref',
name: 'status',
},
function: 'is',
args: [{ type: 'literal', value: 'Done' }],
},
],
};
const titleProperty = {
id: 'title',
cellGetOrCreate: () => ({
jsonValue$: {
value: 'Task 1',
},
}),
};
const statusProperty = {
id: 'status',
cellGetOrCreate: () => ({
jsonValue$: {
value: 'In Progress',
},
}),
};
const view = {
filter$: { value: filter },
// Simulate status being hidden in current view.
properties$: { value: [titleProperty] },
propertiesRaw$: { value: [titleProperty, statusProperty] },
} as unknown as TableSingleView;
expect(TableSingleView.prototype.isShow.call(view, 'row-1')).toBe(false);
});
});
describe('number formatter', () => {
test('number format menu should expose all schema formats', () => {
const menuFormats = numberFormats.map(format => format.type);
const schemaFormats = NumberFormatSchema.options;
expect(new Set(menuFormats)).toEqual(new Set(schemaFormats));
expect(menuFormats).toHaveLength(schemaFormats.length);
});
test('formats grouped decimal numbers with Intl grouping rules', () => {
const value = 11451.4;
const decimals = 1;
const expected = new Intl.NumberFormat(navigator.language, {
style: 'decimal',
useGrouping: true,
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}).format(value);
expect(formatNumber(value, 'numberWithCommas', decimals)).toBe(expected);
});
test('formats percent values with Intl percent rules', () => {
const value = 0.1234;
const decimals = 2;
const expected = new Intl.NumberFormat(navigator.language, {
style: 'percent',
useGrouping: false,
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}).format(value);
expect(formatNumber(value, 'percent', decimals)).toBe(expected);
});
test('formats currency values with Intl currency rules', () => {
const value = 11451.4;
const expected = new Intl.NumberFormat(navigator.language, {
style: 'currency',
currency: 'USD',
currencyDisplay: 'symbol',
}).format(value);
expect(formatNumber(value, 'currencyUSD')).toBe(expected);
});
test('parses grouped number string pasted from clipboard', () => {
expect(parseNumber('11,451.4')).toBe(11451.4);
});
test('keeps regular decimal parsing', () => {
expect(parseNumber('123.45')).toBe(123.45);
});
test('supports comma as decimal separator in locale-specific input', () => {
expect(parseNumber('11451,4', ',')).toBe(11451.4);
});
});

View File

@@ -22,6 +22,7 @@ import { html } from 'lit/static-html.js';
import { dataViewCommonStyle } from './common/css-variable.js';
import type { DataSource } from './data-source/index.js';
import type { DataViewSelection } from './types.js';
import { cacheComputed } from './utils/cache.js';
import { renderUniLit } from './utils/uni-component/index.js';
import type { DataViewUILogicBase } from './view/data-view-base.js';
import type { SingleView } from './view-manager/single-view.js';
@@ -74,38 +75,12 @@ export class DataViewRootUILogic {
return new (logic(view))(this, view);
}
private readonly _viewsCache = new Map<
string,
{ mode: string; logic: DataViewUILogicBase }
>();
private readonly views$ = computed(() => {
const viewDataList = this.dataSource.viewDataList$.value;
const validIds = new Set(viewDataList.map(viewData => viewData.id));
for (const cachedId of this._viewsCache.keys()) {
if (!validIds.has(cachedId)) {
this._viewsCache.delete(cachedId);
}
}
return viewDataList.map(viewData => {
const cached = this._viewsCache.get(viewData.id);
if (cached && cached.mode === viewData.mode) {
return cached.logic;
}
const logic = this.createDataViewUILogic(viewData.id);
this._viewsCache.set(viewData.id, {
mode: viewData.mode,
logic,
});
return logic;
});
});
private readonly views$ = cacheComputed(this.viewManager.views$, viewId =>
this.createDataViewUILogic(viewId)
);
private readonly viewsMap$ = computed(() => {
return Object.fromEntries(
this.views$.value.map(logic => [logic.view.id, logic])
this.views$.list.value.map(logic => [logic.view.id, logic])
);
});
private readonly _uiRef = signal<DataViewRootUI>();

View File

@@ -1,6 +1,7 @@
import type { KanbanCardSelection } from '../../view-presets';
import type { KanbanCard } from '../../view-presets/kanban/pc/card.js';
import { KanbanCell } from '../../view-presets/kanban/pc/cell.js';
import type { RecordDetail } from './detail.js';
import { RecordField } from './field.js';
type DetailViewSelection = {
@@ -8,39 +9,16 @@ type DetailViewSelection = {
isEditing: boolean;
};
type DetailSelectionHost = {
querySelector: (selector: string) => unknown;
};
const isSameDetailSelection = (
current?: DetailViewSelection,
next?: DetailViewSelection
) => {
if (!current && !next) {
return true;
}
if (!current || !next) {
return false;
}
return (
current.propertyId === next.propertyId &&
current.isEditing === next.isEditing
);
};
export class DetailSelection {
_selection?: DetailViewSelection;
onSelect = (selection?: DetailViewSelection) => {
if (isSameDetailSelection(this._selection, selection)) {
return;
}
const old = this._selection;
this._selection = selection;
if (old) {
this.blur(old);
}
if (selection && isSameDetailSelection(this._selection, selection)) {
this._selection = selection;
if (selection) {
this.focus(selection);
}
};
@@ -71,7 +49,7 @@ export class DetailSelection {
}
}
constructor(private readonly viewEle: DetailSelectionHost) {}
constructor(private readonly viewEle: RecordDetail) {}
blur(selection: DetailViewSelection) {
const container = this.getFocusCellContainer(selection);
@@ -133,10 +111,8 @@ export class DetailSelection {
}
focusFirstCell() {
const firstField = this.viewEle.querySelector(
'affine-data-view-record-field'
) as RecordField | undefined;
const firstId = firstField?.column.id;
const firstId = this.viewEle.querySelector('affine-data-view-record-field')
?.column.id;
if (firstId) {
this.selection = {
propertyId: firstId,
@@ -168,12 +144,11 @@ export class DetailSelection {
getSelectCard(selection: KanbanCardSelection) {
const { groupKey, cardId } = selection.cards[0];
const group = this.viewEle.querySelector(
`affine-data-view-kanban-group[data-key="${groupKey}"]`
) as HTMLElement | undefined;
return group?.querySelector(
`affine-data-view-kanban-card[data-card-id="${cardId}"]`
) as KanbanCard | undefined;
return this.viewEle
.querySelector(`affine-data-view-kanban-group[data-key="${groupKey}"]`)
?.querySelector(
`affine-data-view-kanban-card[data-card-id="${cardId}"]`
) as KanbanCard | undefined;
}
}

View File

@@ -12,5 +12,6 @@ export type PropertyDataUpdater<
> = (data: Data) => Partial<Data>;
export interface DatabaseFlags {
enable_number_formatting: boolean;
enable_table_virtual_scroll: boolean;
}

View File

@@ -67,7 +67,7 @@ export const autoScrollOnBoundary = (
};
const cancelBoxListen = effect(() => {
void box.value;
box.value;
startUpdate();
});

View File

@@ -24,11 +24,17 @@ export class NumberCell extends BaseCellRenderer<
private accessor _inputEle!: HTMLInputElement;
private _getFormattedString(value: number | undefined = this.value) {
const enableNewFormatting =
this.view.featureFlags$.value.enable_number_formatting;
const decimals = this.property.data$.value.decimal ?? 0;
const formatMode = (this.property.data$.value.format ??
'number') as NumberFormat;
return value != undefined ? formatNumber(value, formatMode, decimals) : '';
return value != undefined
? enableNewFormatting
? formatNumber(value, formatMode, decimals)
: value.toString()
: '';
}
private readonly _keydown = (e: KeyboardEvent) => {
@@ -52,7 +58,9 @@ export class NumberCell extends BaseCellRenderer<
return;
}
const value = parseNumber(str);
const enableNewFormatting =
this.view.featureFlags$.value.enable_number_formatting;
const value = enableNewFormatting ? parseNumber(str) : parseFloat(str);
if (isNaN(value)) {
if (this._inputEle) {
this._inputEle.value = this.value

View File

@@ -3,7 +3,6 @@ import zod from 'zod';
import { t } from '../../core/logical/type-presets.js';
import { propertyType } from '../../core/property/property-config.js';
import { NumberPropertySchema } from './types.js';
import { parseNumber } from './utils/formatter.js';
export const numberPropertyType = propertyType('number');
export const numberPropertyModelConfig = numberPropertyType.modelConfig({
@@ -22,7 +21,7 @@ export const numberPropertyModelConfig = numberPropertyType.modelConfig({
default: () => null,
toString: ({ value }) => value?.toString() ?? '',
fromString: ({ value }) => {
const num = value ? parseNumber(value) : NaN;
const num = value ? Number(value) : NaN;
return { value: isNaN(num) ? null : num };
},
toJson: ({ value }) => value ?? null,

View File

@@ -349,7 +349,7 @@ export class KanbanSingleView extends SingleViewBase<KanbanViewData> {
isShow(rowId: string): boolean {
if (this.filter$.value?.conditions.length) {
const rowMap = Object.fromEntries(
this.propertiesRaw$.value.map(column => [
this.properties$.value.map(column => [
column.id,
column.cellGetOrCreate(rowId).jsonValue$.value,
])

View File

@@ -64,6 +64,9 @@ export class MobileTableColumnHeader extends SignalWatcher(
};
private popMenu(ele?: HTMLElement) {
const enableNumberFormatting =
this.tableViewManager.featureFlags$.value.enable_number_formatting;
popMenu(popupTargetFromElement(ele ?? this), {
options: {
title: {
@@ -73,36 +76,41 @@ export class MobileTableColumnHeader extends SignalWatcher(
inputConfig(this.column),
typeConfig(this.column),
// Number format begin
menu.subMenu({
name: 'Number Format',
hide: () =>
!this.column.dataUpdate || this.column.type$.value !== 'number',
options: {
title: {
text: 'Number Format',
},
items: [
numberFormatConfig(this.column),
...numberFormats.map(format => {
const data = this.column.data$.value;
return menu.action({
isSelected: data.format === format.type,
prefix: html`<span
style="font-size: var(--affine-font-base); scale: 1.2;"
>${format.symbol}</span
>`,
name: format.label,
select: () => {
if (data.format === format.type) return;
this.column.dataUpdate(() => ({
format: format.type,
}));
...(enableNumberFormatting
? [
menu.subMenu({
name: 'Number Format',
hide: () =>
!this.column.dataUpdate ||
this.column.type$.value !== 'number',
options: {
title: {
text: 'Number Format',
},
});
items: [
numberFormatConfig(this.column),
...numberFormats.map(format => {
const data = this.column.data$.value;
return menu.action({
isSelected: data.format === format.type,
prefix: html`<span
style="font-size: var(--affine-font-base); scale: 1.2;"
>${format.symbol}</span
>`,
name: format.label,
select: () => {
if (data.format === format.type) return;
this.column.dataUpdate(() => ({
format: format.type,
}));
},
});
}),
],
},
}),
],
},
}),
]
: []),
// Number format end
menu.group({
items: [

View File

@@ -205,39 +205,47 @@ export class DatabaseHeaderColumn extends SignalWatcher(
}
private popMenu(ele?: HTMLElement) {
const enableNumberFormatting =
this.tableViewManager.featureFlags$.value.enable_number_formatting;
popMenu(popupTargetFromElement(ele ?? this), {
options: {
items: [
inputConfig(this.column),
typeConfig(this.column),
// Number format begin
menu.subMenu({
name: 'Number Format',
hide: () =>
!this.column.dataUpdate || this.column.type$.value !== 'number',
options: {
items: [
numberFormatConfig(this.column),
...numberFormats.map(format => {
const data = this.column.data$.value;
return menu.action({
isSelected: data.format === format.type,
prefix: html`<span
style="font-size: var(--affine-font-base); scale: 1.2;"
>${format.symbol}</span
>`,
name: format.label,
select: () => {
if (data.format === format.type) return;
this.column.dataUpdate(() => ({
format: format.type,
}));
},
});
...(enableNumberFormatting
? [
menu.subMenu({
name: 'Number Format',
hide: () =>
!this.column.dataUpdate ||
this.column.type$.value !== 'number',
options: {
items: [
numberFormatConfig(this.column),
...numberFormats.map(format => {
const data = this.column.data$.value;
return menu.action({
isSelected: data.format === format.type,
prefix: html`<span
style="font-size: var(--affine-font-base); scale: 1.2;"
>${format.symbol}</span
>`,
name: format.label,
select: () => {
if (data.format === format.type) return;
this.column.dataUpdate(() => ({
format: format.type,
}));
},
});
}),
],
},
}),
],
},
}),
]
: []),
// Number format end
menu.group({
items: [

View File

@@ -54,9 +54,7 @@ export class DatabaseCellContainer extends SignalWatcher(
const selectionView = this.selectionView;
if (selectionView) {
const selection = selectionView.selection;
const shouldEnterEditMode =
editing && this.cell?.beforeEnterEditMode() !== false;
if (selection && this.isSelected(selection) && shouldEnterEditMode) {
if (selection && this.isSelected(selection) && editing) {
selectionView.selection = TableViewAreaSelection.create({
groupKey: this.groupKey,
focus: {

View File

@@ -24,12 +24,12 @@ import {
DataViewUIBase,
DataViewUILogicBase,
} from '../../../core/view/data-view-base.js';
import { LEFT_TOOL_BAR_WIDTH } from '../consts.js';
import {
type TableSingleView,
TableViewRowSelection,
type TableViewSelectionWithType,
} from '../selection.js';
import type { TableSingleView } from '../table-view-manager.js';
} from '../../index.js';
import { LEFT_TOOL_BAR_WIDTH } from '../consts.js';
import { TableClipboardController } from './controller/clipboard.js';
import { TableDragController } from './controller/drag.js';
import { TableHotkeysController } from './controller/hotkeys.js';

View File

@@ -57,9 +57,7 @@ export class TableViewCellContainer extends SignalWatcher(
const selectionView = this.selectionController;
if (selectionView) {
const selection = selectionView.selection;
const shouldEnterEditMode =
editing && this.cell?.beforeEnterEditMode() !== false;
if (selection && this.isSelected(selection) && shouldEnterEditMode) {
if (selection && this.isSelected(selection) && editing) {
selectionView.selection = TableViewAreaSelection.create({
groupKey: this.groupKey,
focus: {

View File

@@ -205,39 +205,47 @@ export class DatabaseHeaderColumn extends SignalWatcher(
}
private popMenu(ele?: HTMLElement) {
const enableNumberFormatting =
this.tableViewManager.featureFlags$.value.enable_number_formatting;
popMenu(popupTargetFromElement(ele ?? this), {
options: {
items: [
inputConfig(this.column),
typeConfig(this.column),
// Number format begin
menu.subMenu({
name: 'Number Format',
hide: () =>
!this.column.dataUpdate || this.column.type$.value !== 'number',
options: {
items: [
numberFormatConfig(this.column),
...numberFormats.map(format => {
const data = this.column.data$.value;
return menu.action({
isSelected: data.format === format.type,
prefix: html`<span
style="font-size: var(--affine-font-base); scale: 1.2;"
>${format.symbol}</span
>`,
name: format.label,
select: () => {
if (data.format === format.type) return;
this.column.dataUpdate(() => ({
format: format.type,
}));
},
});
...(enableNumberFormatting
? [
menu.subMenu({
name: 'Number Format',
hide: () =>
!this.column.dataUpdate ||
this.column.type$.value !== 'number',
options: {
items: [
numberFormatConfig(this.column),
...numberFormats.map(format => {
const data = this.column.data$.value;
return menu.action({
isSelected: data.format === format.type,
prefix: html`<span
style="font-size: var(--affine-font-base); scale: 1.2;"
>${format.symbol}</span
>`,
name: format.label,
select: () => {
if (data.format === format.type) return;
this.column.dataUpdate(() => ({
format: format.type,
}));
},
});
}),
],
},
}),
],
},
}),
]
: []),
// Number format end
menu.group({
items: [

View File

@@ -26,52 +26,6 @@ import type { ViewManager } from '../../core/view-manager/view-manager.js';
import { DEFAULT_COLUMN_MIN_WIDTH, DEFAULT_COLUMN_WIDTH } from './consts.js';
import type { TableViewData } from './define.js';
export const materializeColumnsByPropertyIds = (
columns: TableColumnData[],
propertyIds: string[],
getDefaultWidth: (id: string) => number = () => DEFAULT_COLUMN_WIDTH
) => {
const needShow = new Set(propertyIds);
const orderedColumns: TableColumnData[] = [];
for (const column of columns) {
if (needShow.has(column.id)) {
orderedColumns.push(column);
needShow.delete(column.id);
}
}
for (const id of needShow) {
orderedColumns.push({ id, width: getDefaultWidth(id), hide: undefined });
}
return orderedColumns;
};
export const materializeTableColumns = (
columns: TableColumnData[],
propertyIds: string[],
getDefaultWidth?: (id: string) => number
) => {
const nextColumns = materializeColumnsByPropertyIds(
columns,
propertyIds,
getDefaultWidth
);
const unchanged =
columns.length === nextColumns.length &&
columns.every((column, index) => {
const nextColumn = nextColumns[index];
return (
nextColumn != null &&
column.id === nextColumn.id &&
column.hide === nextColumn.hide
);
});
return unchanged ? columns : nextColumns;
};
export class TableSingleView extends SingleViewBase<TableViewData> {
propertiesRaw$ = computed(() => {
const needShow = new Set(this.dataSource.properties$.value);
@@ -266,10 +220,14 @@ export class TableSingleView extends SingleViewBase<TableViewData> {
return this.data$.value?.mode ?? 'table';
}
constructor(viewManager: ViewManager, viewId: string) {
super(viewManager, viewId);
}
isShow(rowId: string): boolean {
if (this.filter$.value?.conditions.length) {
const rowMap = Object.fromEntries(
this.propertiesRaw$.value.map(column => [
this.properties$.value.map(column => [
column.id,
column.cellGetOrCreate(rowId).jsonValue$.value,
])
@@ -332,33 +290,6 @@ export class TableSingleView extends SingleViewBase<TableViewData> {
});
}
);
private materializeColumns() {
const data = this.data$.value;
if (!data) {
return;
}
const nextColumns = materializeTableColumns(
data.columns,
this.dataSource.properties$.value,
id => this.propertyGetOrCreate(id).width$.value
);
if (nextColumns === data.columns) {
return;
}
this.dataUpdate(() => ({ columns: nextColumns }));
}
constructor(viewManager: ViewManager, viewId: string) {
super(viewManager, viewId);
// Materialize view columns on view activation so newly added properties
// can participate in hide/order operations in table.
queueMicrotask(() => {
this.materializeColumns();
});
}
}
type TableColumnData = TableViewData['columns'][number];

View File

@@ -337,7 +337,6 @@ export const popViewOptions = (
const reopen = () => {
popViewOptions(target, dataViewLogic);
};
let handler: ReturnType<typeof popMenu>;
const items: MenuConfig[] = [];
items.push(
menu.input({
@@ -351,9 +350,16 @@ export const popViewOptions = (
items.push(
menu.group({
items: [
menu => {
const viewTypeItems = menu.renderItems(
view.manager.viewMetas.map<MenuConfig>(meta => {
menu.action({
name: 'Layout',
postfix: html` <div
style="font-size: 14px;text-transform: capitalize;"
>
${view.type}
</div>
${ArrowRightSmallIcon()}`,
select: () => {
const viewTypes = view.manager.viewMetas.map<MenuConfig>(meta => {
return menu => {
if (!menu.search(meta.model.defaultName)) {
return;
@@ -373,10 +379,10 @@ export const popViewOptions = (
? 'var(--affine-text-emphasis-color)'
: 'var(--affine-text-secondary-color)',
});
const buttonData: MenuButtonData = {
const data: MenuButtonData = {
content: () => html`
<div
style="width:100%;display: flex;flex-direction: column;align-items: center;justify-content: center;padding: 6px 16px;white-space: nowrap"
style="color:var(--affine-text-emphasis-color);width:100%;display: flex;flex-direction: column;align-items: center;justify-content: center;padding: 6px 16px;white-space: nowrap"
>
<div style="${iconStyle}">
${renderUniLit(meta.renderer.icon)}
@@ -386,7 +392,7 @@ export const popViewOptions = (
`,
select: () => {
const id = view.manager.currentViewId$.value;
if (!id || meta.type === view.type) {
if (!id) {
return;
}
view.manager.viewChangeType(id, meta.type);
@@ -397,35 +403,55 @@ export const popViewOptions = (
const containerStyle = styleMap({
flex: '1',
});
return html`<affine-menu-button
return html` <affine-menu-button
style="${containerStyle}"
.data="${buttonData}"
.data="${data}"
.menu="${menu}"
></affine-menu-button>`;
};
})
);
if (!viewTypeItems.length) {
return html``;
}
return html`
<div style="display:flex;align-items:center;gap:8px;padding:0 2px;">
<div
style="display:flex;align-items:center;color:var(--affine-icon-color);"
>
${LayoutIcon()}
</div>
<div
style="font-size:14px;line-height:22px;color:var(--affine-text-secondary-color);"
>
Layout
</div>
</div>
<div style="display:flex;gap:8px;margin-top:8px;">
${viewTypeItems}
</div>
`;
},
});
const subHandler = popMenu(target, {
options: {
title: {
onBack: reopen,
text: 'Layout',
},
items: [
menu => {
const result = menu.renderItems(viewTypes);
if (result.length) {
return html` <div style="display: flex">${result}</div>`;
}
return html``;
},
// menu.toggleSwitch({
// name: 'Show block icon',
// on: true,
// onChange: value => {
// console.log(value);
// },
// }),
// menu.toggleSwitch({
// name: 'Show Vertical lines',
// on: true,
// onChange: value => {
// console.log(value);
// },
// }),
],
},
middleware: [
autoPlacement({
allowedPlacements: ['bottom-start', 'top-start'],
}),
offset({ mainAxis: 15, crossAxis: -162 }),
shift({ crossAxis: true }),
],
});
subHandler.menu.menuElement.style.minHeight = '550px';
},
prefix: LayoutIcon(),
}),
],
})
);
@@ -460,6 +486,7 @@ export const popViewOptions = (
],
})
);
let handler: ReturnType<typeof popMenu>;
handler = popMenu(target, {
options: {
title: {

View File

@@ -26,5 +26,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.26.3"
"version": "0.26.1"
}

View File

@@ -60,9 +60,10 @@ export class BaseExtensionProvider<
* @param context - The context object containing scope and registration function
* @param option - Optional configuration options for the provider
*/
setup(_context: Context<Scope>, option?: Options) {
setup(context: Context<Scope>, option?: Options) {
if (option) {
this.schema.parse(option);
}
context;
}
}

View File

@@ -42,5 +42,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.26.3"
"version": "0.26.1"
}

View File

@@ -35,5 +35,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.26.3"
"version": "0.26.1"
}

View File

@@ -26,6 +26,7 @@
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.23",
"lit": "^3.2.0",
"minimatch": "^10.1.1",
"rxjs": "^7.8.2",
"zod": "^3.25.76"
},
@@ -39,5 +40,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.26.3"
"version": "0.26.1"
}

View File

@@ -28,6 +28,7 @@
"@types/lodash-es": "^4.17.12",
"lit": "^3.2.0",
"lodash-es": "^4.17.23",
"minimatch": "^10.1.1",
"rxjs": "^7.8.2",
"zod": "^3.25.76"
},
@@ -41,5 +42,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.26.3"
"version": "0.26.1"
}

View File

@@ -27,6 +27,7 @@
"@toeverything/theme": "^1.1.23",
"@vanilla-extract/css": "^1.17.0",
"lit": "^3.2.0",
"minimatch": "^10.1.1",
"rxjs": "^7.8.2",
"zod": "^3.25.76"
},
@@ -40,5 +41,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.26.3"
"version": "0.26.1"
}

View File

@@ -27,6 +27,7 @@
"@types/lodash-es": "^4.17.12",
"lit": "^3.2.0",
"lodash-es": "^4.17.23",
"minimatch": "^10.1.1",
"rxjs": "^7.8.2",
"yjs": "^13.6.27",
"zod": "^3.25.76"
@@ -42,5 +43,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.26.3"
"version": "0.26.1"
}

View File

@@ -28,6 +28,7 @@
"@types/lodash-es": "^4.17.12",
"lit": "^3.2.0",
"lodash-es": "^4.17.23",
"minimatch": "^10.1.1",
"rxjs": "^7.8.2",
"yjs": "^13.6.27",
"zod": "^3.25.76"
@@ -43,5 +44,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.26.3"
"version": "0.26.1"
}

View File

@@ -884,7 +884,7 @@ export class ConnectionOverlay extends Overlay {
private _setupThemeListener(): void {
const themeService = this.gfx.std.get(ThemeProvider);
this._themeDisposer = effect(() => {
void themeService.theme$.value;
themeService.theme$;
this._emphasisColor = this._getEmphasisColor();
});
}

View File

@@ -84,8 +84,6 @@ export const connectorWatcher: SurfaceMiddleware = (
);
return () => {
pendingFlag = false;
pendingList.clear();
disposables.forEach(d => d.unsubscribe());
};
};

View File

@@ -26,16 +26,13 @@
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.23",
"@types/lodash-es": "^4.17.12",
"fractional-indexing": "^3.2.0",
"lit": "^3.2.0",
"lodash-es": "^4.17.23",
"minimatch": "^10.1.1",
"rxjs": "^7.8.2",
"yjs": "^13.6.27",
"zod": "^3.25.76"
},
"devDependencies": {
"vitest": "^3.2.4"
},
"exports": {
".": "./src/index.ts",
"./view": "./src/view.ts",
@@ -47,5 +44,5 @@
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.26.3"
"version": "0.26.1"
}

View File

@@ -1,152 +0,0 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
vi.mock('fractional-indexing', () => ({
generateKeyBetween: vi.fn(),
generateNKeysBetween: vi.fn(),
}));
import { generateKeyBetween, generateNKeysBetween } from 'fractional-indexing';
import { ungroupCommand } from '../command/group-api.js';
type TestElement = {
id: string;
index: string;
group: TestElement | null;
childElements: TestElement[];
removeChildren?: (elements: TestElement[]) => void;
addChildren?: (elements: TestElement[]) => void;
};
const mockedGenerateNKeysBetween = vi.mocked(generateNKeysBetween);
const mockedGenerateKeyBetween = vi.mocked(generateKeyBetween);
const createElement = (
id: string,
index: string,
group: TestElement | null
): TestElement => ({
id,
index,
group,
childElements: [],
});
const createUngroupFixture = () => {
const parent = createElement('parent', 'p0', null);
const left = createElement('left', 'a0', parent);
const right = createElement('right', 'a0', parent);
const group = createElement('group', 'm0', parent);
const childA = createElement('child-a', 'c0', group);
const childB = createElement('child-b', 'c1', group);
group.childElements = [childB, childA];
parent.childElements = [left, group, right];
parent.removeChildren = vi.fn();
parent.addChildren = vi.fn();
group.removeChildren = vi.fn();
const elementOrder = new Map<TestElement, number>([
[left, 0],
[group, 1],
[right, 2],
[childA, 3],
[childB, 4],
]);
const selectionSet = vi.fn();
const gfx = {
layer: {
compare: (a: TestElement, b: TestElement) =>
(elementOrder.get(a) ?? 0) - (elementOrder.get(b) ?? 0),
},
selection: {
set: selectionSet,
},
};
const std = {
get: vi.fn(() => gfx),
store: {
transact: (callback: () => void) => callback(),
},
};
return {
childA,
childB,
group,
parent,
selectionSet,
std,
};
};
describe('ungroupCommand', () => {
beforeEach(() => {
mockedGenerateNKeysBetween.mockReset();
mockedGenerateKeyBetween.mockReset();
});
test('falls back to open-ended key generation when sibling interval is invalid', () => {
const fixture = createUngroupFixture();
mockedGenerateNKeysBetween
.mockImplementationOnce(() => {
throw new Error('interval reversed');
})
.mockReturnValueOnce(['n0', 'n1']);
const next = vi.fn();
ungroupCommand(
{
std: fixture.std,
group: fixture.group as any,
} as any,
next
);
expect(mockedGenerateNKeysBetween).toHaveBeenNthCalledWith(
1,
'a0',
'a0',
2
);
expect(mockedGenerateNKeysBetween).toHaveBeenNthCalledWith(
2,
'a0',
null,
2
);
expect(fixture.childA.index).toBe('n0');
expect(fixture.childB.index).toBe('n1');
expect(fixture.selectionSet).toHaveBeenCalledWith({
editing: false,
elements: ['child-a', 'child-b'],
});
expect(next).toHaveBeenCalledTimes(1);
});
test('falls back to key-by-key generation when all batched strategies fail', () => {
const fixture = createUngroupFixture();
mockedGenerateNKeysBetween.mockImplementation(() => {
throw new Error('invalid range');
});
let seq = 0;
mockedGenerateKeyBetween.mockImplementation(() => `k${seq++}`);
ungroupCommand(
{
std: fixture.std,
group: fixture.group as any,
} as any,
vi.fn()
);
expect(mockedGenerateNKeysBetween).toHaveBeenCalledTimes(4);
expect(mockedGenerateKeyBetween).toHaveBeenCalledTimes(2);
expect(fixture.childA.index).toBe('k0');
expect(fixture.childB.index).toBe('k1');
});
});

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