logiguard fork: GPUI xdg-activation keyboard-focus serial fix
Some checks failed
Update All Top Ranking Issues / update_top_ranking_issues (push) Has been cancelled
Triage Project Sync (#84) / Sync triage project (push) Has been cancelled
release_nightly / notify_on_failure (push) Has been cancelled
release_nightly / check_style (push) Has been cancelled
release_nightly / run_tests_windows (push) Has been cancelled
release_nightly / clippy_windows (push) Has been cancelled
release_nightly / bundle_linux_aarch64 (push) Has been cancelled
release_nightly / bundle_linux_x86_64 (push) Has been cancelled
release_nightly / bundle_mac_aarch64 (push) Has been cancelled
release_nightly / bundle_mac_x86_64 (push) Has been cancelled
release_nightly / bundle_windows_aarch64 (push) Has been cancelled
release_nightly / bundle_windows_x86_64 (push) Has been cancelled
release_nightly / build_nix_linux_x86_64 (push) Has been cancelled
release_nightly / build_nix_mac_aarch64 (push) Has been cancelled
release_nightly / update_nightly_tag (push) Has been cancelled
Hotfix Review Monitor / check-hotfix-reviews (push) Has been cancelled
Stale PR Review Reminder / check-stale-prs (push) Has been cancelled
Update Weekly Top Ranking Issues / update_top_ranking_issues (push) Has been cancelled
Bump collab-staging Tag / update-collab-staging-tag (push) Has been cancelled
compliance_check / scheduled_compliance_check (push) Has been cancelled

Single-commit orphan branch: full zed-industries/zed @ 8c74db0 source tree
with a 3-file patch applied (no upstream history).

Patch (crates/gpui_linux/src/linux/wayland/):
  - serial.rs: add SerialKind::KeyboardEnter
  - client.rs: store wl_keyboard.enter serial; add latest_serial_of()
  - window.rs: activate() uses keyboard-enter serial (Mutter focus gate)

Mutter honors window activation only when the token carries the keyboard-
focus serial from wl_keyboard.enter; GPUI used a stale mouse-press serial.
See docs/tray-window-focus-wayland.md in logiguard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mohamad Khani
2026-07-14 02:22:17 +03:30
commit b72a46db68
3984 changed files with 1583326 additions and 0 deletions

19
crates/collab/.env.toml Normal file
View File

@@ -0,0 +1,19 @@
DATABASE_URL = "postgres://postgres@localhost/zed"
# DATABASE_URL = "sqlite:////root/0/zed/db.sqlite3?mode=rwc"
DATABASE_MAX_CONNECTIONS = 5
HTTP_PORT = 8080
ZED_ENVIRONMENT = "development"
ZED_CLOUD_INTERNAL_API_KEY = "internal-api-key-secret"
LIVEKIT_SERVER = "http://localhost:7880"
LIVEKIT_KEY = "devkey"
LIVEKIT_SECRET = "secret"
BLOB_STORE_ACCESS_KEY = "the-blob-store-access-key"
BLOB_STORE_SECRET_KEY = "the-blob-store-secret-key"
BLOB_STORE_BUCKET = "the-extensions-bucket"
BLOB_STORE_URL = "http://127.0.0.1:9000"
BLOB_STORE_REGION = "the-region"
ZED_CLIENT_CHECKSUM_SEED = "development-checksum-seed"
SEED_PATH = "crates/collab/seed.default.json"
# RUST_LOG=info
# LOG_JSON=true

138
crates/collab/Cargo.toml Normal file
View File

@@ -0,0 +1,138 @@
[package]
authors = ["Nathan Sobo <nathan@zed.dev>"]
default-run = "collab"
edition.workspace = true
name = "collab"
version = "0.44.0"
publish.workspace = true
license = "AGPL-3.0-or-later"
[lints]
workspace = true
[[bin]]
name = "collab"
[features]
sqlite = ["sea-orm/sqlx-sqlite", "sqlx/sqlite"]
test-support = ["sqlite"]
[lib]
test = false
[[test]]
name = "collab_tests"
required-features = ["test-support"]
path = "tests/integration/collab_tests.rs"
[dependencies]
anyhow.workspace = true
async-trait.workspace = true
async-tungstenite = { workspace = true, features = ["tokio", "tokio-rustls-manual-roots" ] }
aws-config = { version = "1.1.5" }
aws-sdk-kinesis = "1.51.0"
aws-sdk-s3 = { version = "1.15.0" }
axum = { version = "0.6", features = ["json", "headers", "ws"] }
chrono.workspace = true
clock.workspace = true
cloud_api_types.workspace = true
collections.workspace = true
dashmap.workspace = true
envy = "0.4.2"
futures.workspace = true
gpui.workspace = true
hex.workspace = true
http_client.workspace = true
livekit_api.workspace = true
log.workspace = true
nanoid.workspace = true
parking_lot.workspace = true
prometheus = "0.14"
prost.workspace = true
rand.workspace = true
reqwest = { version = "0.11", features = ["json"] }
rpc.workspace = true
# sea-orm and sea-orm-macros versions must match exactly.
sea-orm = { version = "=1.1.10", features = ["sqlx-postgres", "postgres-array", "runtime-tokio-rustls", "with-uuid"] }
sea-orm-macros = "=1.1.10"
semver.workspace = true
serde.workspace = true
serde_json.workspace = true
sha2.workspace = true
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "json", "time", "uuid", "any"] }
strum.workspace = true
telemetry_events.workspace = true
text.workspace = true
time.workspace = true
tokio = { workspace = true, features = ["full"] }
toml.workspace = true
tower = "0.4"
tower-http = { workspace = true, features = ["trace"] }
tracing.workspace = true
tracing-subscriber = { version = "0.3.18", features = ["env-filter", "json", "registry", "tracing-log"] } # workaround for https://github.com/tokio-rs/tracing/issues/2927
util.workspace = true
uuid.workspace = true
[dev-dependencies]
agent = { workspace = true, features = ["test-support"] }
async-trait.workspace = true
async-channel.workspace = true
buffer_diff.workspace = true
call = { workspace = true, features = ["test-support"] }
channel.workspace = true
client = { workspace = true, features = ["test-support"] }
collab = { workspace = true, features = ["test-support"] }
collab_ui = { workspace = true, features = ["test-support"] }
collections = { workspace = true, features = ["test-support"] }
command_palette_hooks.workspace = true
ctor.workspace = true
dap = { workspace = true, features = ["test-support"] }
dap_adapters = { workspace = true, features = ["test-support"] }
debugger_ui = { workspace = true, features = ["test-support"] }
editor = { workspace = true, features = ["test-support"] }
extension.workspace = true
file_finder.workspace = true
fs = { workspace = true, features = ["test-support"] }
git = { workspace = true, features = ["test-support"] }
git_graph = { workspace = true, features = ["test-support"] }
git_hosting_providers.workspace = true
git_ui = { workspace = true, features = ["test-support"] }
gpui = { workspace = true, features = ["test-support"] }
gpui_tokio.workspace = true
indoc.workspace = true
language = { workspace = true, features = ["test-support"] }
language_model = { workspace = true, features = ["test-support"] }
livekit_client = { workspace = true, features = ["test-support"] }
lsp = { workspace = true, features = ["test-support"] }
menu.workspace = true
multi_buffer = { workspace = true, features = ["test-support"] }
node_runtime.workspace = true
notifications = { workspace = true, features = ["test-support"] }
pretty_assertions.workspace = true
project = { workspace = true, features = ["test-support"] }
prompt_store.workspace = true
recent_projects = { workspace = true, features = ["test-support"] }
release_channel.workspace = true
remote = { workspace = true, features = ["test-support"] }
remote_server.workspace = true
rpc = { workspace = true, features = ["test-support"] }
sea-orm = { version = "=1.1.10", features = ["sqlx-sqlite"] }
serde_json.workspace = true
session = { workspace = true, features = ["test-support"] }
settings = { workspace = true, features = ["test-support"] }
smol.workspace = true
sqlx = { version = "0.8", features = ["sqlite"] }
task.workspace = true
theme_settings = { workspace = true, features = ["test-support"] }
theme.workspace = true
unindent.workspace = true
util.workspace = true
workspace = { workspace = true, features = ["test-support"] }
worktree = { workspace = true, features = ["test-support"] }
zed_actions.workspace = true
zlog.workspace = true

1
crates/collab/LICENSE-AGPL Symbolic link
View File

@@ -0,0 +1 @@
../../LICENSE-AGPL

65
crates/collab/README.md Normal file
View File

@@ -0,0 +1,65 @@
# Zed Server
This crate is what we run at https://collab.zed.dev.
It contains our back-end logic for collaboration, to which we connect from the Zed client via a websocket after authenticating via https://zed.dev, which is a separate repo running on Vercel.
# Local Development
## Database setup
Before you can run the collab server locally, you'll need to set up a zed Postgres database. Follow the steps sequentially:
1. Ensure you have postgres installed. If not, install with `brew install postgresql@15`.
2. Follow the steps on Brew's formula and verify your `$PATH` contains `/opt/homebrew/opt/postgresql@15/bin`.
3. If you hadn't done it before, create the `postgres` user with `createuser -s postgres`.
4. You are now ready to run the `bootstrap` script:
```sh
script/bootstrap
```
This script will set up the `zed` Postgres database, and populate it with some users. It requires internet access, because it fetches some users from the GitHub API.
The script will create several _admin_ users, who you'll sign in as by default when developing locally. The GitHub logins for the default users are specified in the `seed.default.json` file.
To use a different set of admin users, create `crates/collab/seed.json`.
```json
{
"admins": ["yourgithubhere"],
"channels": ["zed"]
}
```
## Testing collaborative features locally
In one terminal, run Zed's collaboration server and the livekit dev server:
```sh
foreman start
```
In a second terminal, run two or more instances of Zed.
```sh
script/zed-local -2
```
This script starts one to four instances of Zed, depending on the `-2`, `-3` or `-4` flags. Each instance will be connected to the local `collab` server, signed in as a different user from `seed.json` or `seed.default.json`.
# Deployment
We run two instances of collab:
- Staging (https://staging-collab.zed.dev)
- Production (https://collab.zed.dev)
Both of these run on the Kubernetes cluster hosted in Digital Ocean.
Deployment is triggered by pushing to the `collab-staging` (or `collab-production`) tag in GitHub. The best way to do this is:
- `./script/deploy-collab staging`
- `./script/deploy-collab production`
You can tell what is currently deployed with `./script/what-is-deployed`.

View File

@@ -0,0 +1,178 @@
---
apiVersion: v1
kind: Namespace
metadata:
name: ${ZED_KUBE_NAMESPACE}
---
kind: Service
apiVersion: v1
metadata:
namespace: ${ZED_KUBE_NAMESPACE}
name: ${ZED_SERVICE_NAME}
annotations:
service.beta.kubernetes.io/do-loadbalancer-name: "${ZED_SERVICE_NAME}-${ZED_KUBE_NAMESPACE}"
service.beta.kubernetes.io/do-loadbalancer-size-unit: "${ZED_LOAD_BALANCER_SIZE_UNIT}"
service.beta.kubernetes.io/do-loadbalancer-tls-ports: "443"
service.beta.kubernetes.io/do-loadbalancer-certificate-id: ${ZED_DO_CERTIFICATE_ID}
service.beta.kubernetes.io/do-loadbalancer-disable-lets-encrypt-dns-records: "true"
spec:
type: LoadBalancer
selector:
app: ${ZED_SERVICE_NAME}
ports:
- name: web
protocol: TCP
port: 443
targetPort: 8080
---
apiVersion: apps/v1
kind: Deployment
metadata:
namespace: ${ZED_KUBE_NAMESPACE}
name: ${ZED_SERVICE_NAME}
spec:
replicas: 1
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
selector:
matchLabels:
app: ${ZED_SERVICE_NAME}
template:
metadata:
labels:
app: ${ZED_SERVICE_NAME}
spec:
containers:
- name: ${ZED_SERVICE_NAME}
image: "${ZED_IMAGE_ID}"
args:
- serve
- ${ZED_SERVICE_NAME}
ports:
- containerPort: 8080
protocol: TCP
livenessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 5
periodSeconds: 5
timeoutSeconds: 5
readinessProbe:
httpGet:
path: /
port: 8080
initialDelaySeconds: 1
periodSeconds: 1
startupProbe:
httpGet:
path: /
port: 8080
initialDelaySeconds: 1
periodSeconds: 1
failureThreshold: 15
env:
- name: HTTP_PORT
value: "8080"
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: database
key: url
- name: DATABASE_MAX_CONNECTIONS
value: "${DATABASE_MAX_CONNECTIONS}"
- name: ZED_CLIENT_CHECKSUM_SEED
valueFrom:
secretKeyRef:
name: zed-client
key: checksum-seed
- name: ZED_CLOUD_INTERNAL_API_KEY
valueFrom:
secretKeyRef:
name: zed-cloud
key: internal-api-key
- name: LIVEKIT_SERVER
valueFrom:
secretKeyRef:
name: livekit
key: server
- name: LIVEKIT_KEY
valueFrom:
secretKeyRef:
name: livekit
key: key
- name: LIVEKIT_SECRET
valueFrom:
secretKeyRef:
name: livekit
key: secret
- name: BLOB_STORE_ACCESS_KEY
valueFrom:
secretKeyRef:
name: blob-store
key: access_key
- name: BLOB_STORE_SECRET_KEY
valueFrom:
secretKeyRef:
name: blob-store
key: secret_key
- name: BLOB_STORE_URL
valueFrom:
secretKeyRef:
name: blob-store
key: url
- name: BLOB_STORE_REGION
valueFrom:
secretKeyRef:
name: blob-store
key: region
- name: BLOB_STORE_BUCKET
valueFrom:
secretKeyRef:
name: blob-store
key: bucket
- name: KINESIS_ACCESS_KEY
valueFrom:
secretKeyRef:
name: kinesis
key: access_key
- name: KINESIS_SECRET_KEY
valueFrom:
secretKeyRef:
name: kinesis
key: secret_key
- name: KINESIS_STREAM
valueFrom:
secretKeyRef:
name: kinesis
key: stream
- name: KINESIS_REGION
valueFrom:
secretKeyRef:
name: kinesis
key: region
- name: BLOB_STORE_BUCKET
valueFrom:
secretKeyRef:
name: blob-store
key: bucket
- name: RUST_BACKTRACE
value: "1"
- name: RUST_LOG
value: ${RUST_LOG}
- name: LOG_JSON
value: "true"
- name: ZED_ENVIRONMENT
value: ${ZED_ENVIRONMENT}
securityContext:
capabilities:
# TODO - Switch to the more restrictive `PERFMON` capability.
# This capability isn't yet available in a stable version of Debian.
add: ["SYS_ADMIN"]
terminationGracePeriodSeconds: 10

View File

@@ -0,0 +1,2 @@
ZED_ENVIRONMENT=production
RUST_LOG=info

View File

@@ -0,0 +1,3 @@
ZED_ENVIRONMENT=staging
RUST_LOG=info
DATABASE_MAX_CONNECTIONS=5

View File

@@ -0,0 +1,447 @@
CREATE TABLE "users" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"github_login" VARCHAR,
"admin" BOOLEAN,
"email_address" VARCHAR(255) DEFAULT NULL,
"name" TEXT,
"connected_once" BOOLEAN NOT NULL DEFAULT false,
"created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"metrics_id" TEXT,
"github_user_id" INTEGER NOT NULL,
"accepted_tos_at" TIMESTAMP WITHOUT TIME ZONE,
"github_user_created_at" TIMESTAMP WITHOUT TIME ZONE,
"custom_llm_monthly_allowance_in_cents" INTEGER
);
CREATE UNIQUE INDEX "index_users_github_login" ON "users" ("github_login");
CREATE INDEX "index_users_on_email_address" ON "users" ("email_address");
CREATE UNIQUE INDEX "index_users_on_github_user_id" ON "users" ("github_user_id");
CREATE TABLE "contacts" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"user_id_a" INTEGER NOT NULL,
"user_id_b" INTEGER NOT NULL,
"a_to_b" BOOLEAN NOT NULL,
"should_notify" BOOLEAN NOT NULL,
"accepted" BOOLEAN NOT NULL
);
CREATE UNIQUE INDEX "index_contacts_user_ids" ON "contacts" ("user_id_a", "user_id_b");
CREATE INDEX "index_contacts_user_id_b" ON "contacts" ("user_id_b");
CREATE TABLE "rooms" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"live_kit_room" VARCHAR NOT NULL,
"environment" VARCHAR,
"channel_id" INTEGER REFERENCES channels (id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX "index_rooms_on_channel_id" ON "rooms" ("channel_id");
CREATE TABLE "projects" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"room_id" INTEGER REFERENCES rooms (id) ON DELETE CASCADE,
"host_user_id" INTEGER,
"host_connection_id" INTEGER,
"host_connection_server_id" INTEGER REFERENCES servers (id) ON DELETE CASCADE,
"unregistered" BOOLEAN NOT NULL DEFAULT FALSE,
"windows_paths" BOOLEAN NOT NULL DEFAULT FALSE,
"features" TEXT NOT NULL DEFAULT ''
);
CREATE INDEX "index_projects_on_host_connection_server_id" ON "projects" ("host_connection_server_id");
CREATE INDEX "index_projects_on_host_connection_id_and_host_connection_server_id" ON "projects" ("host_connection_id", "host_connection_server_id");
CREATE TABLE "worktrees" (
"project_id" INTEGER NOT NULL REFERENCES projects (id) ON DELETE CASCADE,
"id" INTEGER NOT NULL,
"root_name" VARCHAR NOT NULL,
"abs_path" VARCHAR NOT NULL,
"visible" BOOL NOT NULL,
"scan_id" INTEGER NOT NULL,
"is_complete" BOOL NOT NULL DEFAULT FALSE,
"completed_scan_id" INTEGER NOT NULL,
"root_repo_common_dir" VARCHAR,
PRIMARY KEY (project_id, id)
);
CREATE INDEX "index_worktrees_on_project_id" ON "worktrees" ("project_id");
CREATE TABLE "worktree_entries" (
"project_id" INTEGER NOT NULL,
"worktree_id" INTEGER NOT NULL,
"scan_id" INTEGER NOT NULL,
"id" INTEGER NOT NULL,
"is_dir" BOOL NOT NULL,
"path" VARCHAR NOT NULL,
"canonical_path" TEXT,
"inode" INTEGER NOT NULL,
"mtime_seconds" INTEGER NOT NULL,
"mtime_nanos" INTEGER NOT NULL,
"is_external" BOOL NOT NULL,
"is_ignored" BOOL NOT NULL,
"is_deleted" BOOL NOT NULL,
"is_hidden" BOOL NOT NULL,
"git_status" INTEGER,
"is_fifo" BOOL NOT NULL,
PRIMARY KEY (project_id, worktree_id, id),
FOREIGN KEY (project_id, worktree_id) REFERENCES worktrees (project_id, id) ON DELETE CASCADE
);
CREATE INDEX "index_worktree_entries_on_project_id" ON "worktree_entries" ("project_id");
CREATE INDEX "index_worktree_entries_on_project_id_and_worktree_id" ON "worktree_entries" ("project_id", "worktree_id");
CREATE TABLE "project_repositories" (
"project_id" INTEGER NOT NULL REFERENCES projects (id) ON DELETE CASCADE,
"abs_path" VARCHAR,
"id" INTEGER NOT NULL,
"entry_ids" VARCHAR,
"legacy_worktree_id" INTEGER,
"branch" VARCHAR,
"scan_id" INTEGER NOT NULL,
"is_deleted" BOOL NOT NULL,
"current_merge_conflicts" VARCHAR,
"merge_message" VARCHAR,
"branch_summary" VARCHAR,
"head_commit_details" VARCHAR,
"remote_upstream_url" VARCHAR,
"remote_origin_url" VARCHAR,
"linked_worktrees" VARCHAR,
"repository_dir_abs_path" VARCHAR,
"common_dir_abs_path" VARCHAR,
PRIMARY KEY (project_id, id)
);
CREATE INDEX "index_project_repositories_on_project_id" ON "project_repositories" ("project_id");
CREATE TABLE "project_repository_statuses" (
"project_id" INTEGER NOT NULL REFERENCES projects (id) ON DELETE CASCADE,
"repository_id" INTEGER NOT NULL,
"repo_path" VARCHAR NOT NULL,
"status" INT8 NOT NULL,
"status_kind" INT4 NOT NULL,
"first_status" INT4 NULL,
"second_status" INT4 NULL,
"lines_added" INT4 NULL,
"lines_deleted" INT4 NULL,
"scan_id" INT8 NOT NULL,
"is_deleted" BOOL NOT NULL,
PRIMARY KEY (project_id, repository_id, repo_path)
);
CREATE INDEX "index_project_repos_statuses_on_project_id" ON "project_repository_statuses" ("project_id");
CREATE INDEX "index_project_repos_statuses_on_project_id_and_repo_id" ON "project_repository_statuses" ("project_id", "repository_id");
CREATE TABLE "worktree_settings_files" (
"project_id" INTEGER NOT NULL,
"worktree_id" INTEGER NOT NULL,
"path" VARCHAR NOT NULL,
"content" TEXT,
"kind" VARCHAR,
"outside_worktree" BOOL NOT NULL DEFAULT FALSE,
PRIMARY KEY (project_id, worktree_id, path),
FOREIGN KEY (project_id, worktree_id) REFERENCES worktrees (project_id, id) ON DELETE CASCADE
);
CREATE INDEX "index_worktree_settings_files_on_project_id" ON "worktree_settings_files" ("project_id");
CREATE INDEX "index_worktree_settings_files_on_project_id_and_worktree_id" ON "worktree_settings_files" ("project_id", "worktree_id");
CREATE TABLE "worktree_diagnostic_summaries" (
"project_id" INTEGER NOT NULL,
"worktree_id" INTEGER NOT NULL,
"path" VARCHAR NOT NULL,
"language_server_id" INTEGER NOT NULL,
"error_count" INTEGER NOT NULL,
"warning_count" INTEGER NOT NULL,
PRIMARY KEY (project_id, worktree_id, path),
FOREIGN KEY (project_id, worktree_id) REFERENCES worktrees (project_id, id) ON DELETE CASCADE
);
CREATE INDEX "index_worktree_diagnostic_summaries_on_project_id" ON "worktree_diagnostic_summaries" ("project_id");
CREATE INDEX "index_worktree_diagnostic_summaries_on_project_id_and_worktree_id" ON "worktree_diagnostic_summaries" ("project_id", "worktree_id");
CREATE TABLE "language_servers" (
"id" INTEGER NOT NULL,
"project_id" INTEGER NOT NULL REFERENCES projects (id) ON DELETE CASCADE,
"name" VARCHAR NOT NULL,
"capabilities" TEXT NOT NULL,
"worktree_id" BIGINT,
PRIMARY KEY (project_id, id)
);
CREATE INDEX "index_language_servers_on_project_id" ON "language_servers" ("project_id");
CREATE TABLE "project_collaborators" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"project_id" INTEGER NOT NULL REFERENCES projects (id) ON DELETE CASCADE,
"connection_id" INTEGER NOT NULL,
"connection_server_id" INTEGER NOT NULL REFERENCES servers (id) ON DELETE CASCADE,
"user_id" INTEGER NOT NULL,
"replica_id" INTEGER NOT NULL,
"is_host" BOOLEAN NOT NULL,
"committer_name" VARCHAR,
"committer_email" VARCHAR
);
CREATE INDEX "index_project_collaborators_on_project_id" ON "project_collaborators" ("project_id");
CREATE UNIQUE INDEX "index_project_collaborators_on_project_id_and_replica_id" ON "project_collaborators" ("project_id", "replica_id");
CREATE INDEX "index_project_collaborators_on_connection_server_id" ON "project_collaborators" ("connection_server_id");
CREATE INDEX "index_project_collaborators_on_connection_id" ON "project_collaborators" ("connection_id");
CREATE UNIQUE INDEX "index_project_collaborators_on_project_id_connection_id_and_server_id" ON "project_collaborators" (
"project_id",
"connection_id",
"connection_server_id"
);
CREATE TABLE "room_participants" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"room_id" INTEGER NOT NULL REFERENCES rooms (id),
"user_id" INTEGER NOT NULL,
"answering_connection_id" INTEGER,
"answering_connection_server_id" INTEGER REFERENCES servers (id) ON DELETE CASCADE,
"answering_connection_lost" BOOLEAN NOT NULL,
"location_kind" INTEGER,
"location_project_id" INTEGER,
"initial_project_id" INTEGER,
"calling_user_id" INTEGER NOT NULL,
"calling_connection_id" INTEGER NOT NULL,
"calling_connection_server_id" INTEGER REFERENCES servers (id) ON DELETE SET NULL,
"participant_index" INTEGER,
"role" TEXT,
"in_call" BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE UNIQUE INDEX "index_room_participants_on_user_id" ON "room_participants" ("user_id");
CREATE INDEX "index_room_participants_on_room_id" ON "room_participants" ("room_id");
CREATE INDEX "index_room_participants_on_answering_connection_server_id" ON "room_participants" ("answering_connection_server_id");
CREATE INDEX "index_room_participants_on_calling_connection_server_id" ON "room_participants" ("calling_connection_server_id");
CREATE INDEX "index_room_participants_on_answering_connection_id" ON "room_participants" ("answering_connection_id");
CREATE UNIQUE INDEX "index_room_participants_on_answering_connection_id_and_answering_connection_server_id" ON "room_participants" (
"answering_connection_id",
"answering_connection_server_id"
);
CREATE TABLE "servers" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"environment" VARCHAR NOT NULL
);
CREATE TABLE "followers" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"room_id" INTEGER NOT NULL REFERENCES rooms (id) ON DELETE CASCADE,
"project_id" INTEGER NOT NULL REFERENCES projects (id) ON DELETE CASCADE,
"leader_connection_server_id" INTEGER NOT NULL REFERENCES servers (id) ON DELETE CASCADE,
"leader_connection_id" INTEGER NOT NULL,
"follower_connection_server_id" INTEGER NOT NULL REFERENCES servers (id) ON DELETE CASCADE,
"follower_connection_id" INTEGER NOT NULL
);
CREATE UNIQUE INDEX "index_followers_on_project_id_and_leader_connection_server_id_and_leader_connection_id_and_follower_connection_server_id_and_follower_connection_id" ON "followers" (
"project_id",
"leader_connection_server_id",
"leader_connection_id",
"follower_connection_server_id",
"follower_connection_id"
);
CREATE INDEX "index_followers_on_room_id" ON "followers" ("room_id");
CREATE TABLE "channels" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"name" VARCHAR NOT NULL,
"created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"visibility" VARCHAR NOT NULL,
"parent_path" TEXT NOT NULL,
"requires_zed_cla" BOOLEAN NOT NULL DEFAULT FALSE,
"channel_order" INTEGER NOT NULL DEFAULT 1
);
CREATE INDEX "index_channels_on_parent_path" ON "channels" ("parent_path");
CREATE INDEX "index_channels_on_parent_path_and_order" ON "channels" ("parent_path", "channel_order");
CREATE TABLE IF NOT EXISTS "channel_chat_participants" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"user_id" INTEGER NOT NULL,
"channel_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE,
"connection_id" INTEGER NOT NULL,
"connection_server_id" INTEGER NOT NULL REFERENCES servers (id) ON DELETE CASCADE
);
CREATE INDEX "index_channel_chat_participants_on_channel_id" ON "channel_chat_participants" ("channel_id");
CREATE TABLE "channel_members" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"channel_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE,
"user_id" INTEGER NOT NULL,
"role" VARCHAR NOT NULL,
"accepted" BOOLEAN NOT NULL DEFAULT false,
"updated_at" TIMESTAMP NOT NULL DEFAULT now
);
CREATE UNIQUE INDEX "index_channel_members_on_channel_id_and_user_id" ON "channel_members" ("channel_id", "user_id");
CREATE TABLE "buffers" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"channel_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE,
"epoch" INTEGER NOT NULL DEFAULT 0,
"latest_operation_epoch" INTEGER,
"latest_operation_replica_id" INTEGER,
"latest_operation_lamport_timestamp" INTEGER
);
CREATE INDEX "index_buffers_on_channel_id" ON "buffers" ("channel_id");
CREATE TABLE "buffer_operations" (
"buffer_id" INTEGER NOT NULL REFERENCES buffers (id) ON DELETE CASCADE,
"epoch" INTEGER NOT NULL,
"replica_id" INTEGER NOT NULL,
"lamport_timestamp" INTEGER NOT NULL,
"value" BLOB NOT NULL,
PRIMARY KEY (buffer_id, epoch, lamport_timestamp, replica_id)
);
CREATE TABLE "buffer_snapshots" (
"buffer_id" INTEGER NOT NULL REFERENCES buffers (id) ON DELETE CASCADE,
"epoch" INTEGER NOT NULL,
"text" TEXT NOT NULL,
"operation_serialization_version" INTEGER NOT NULL,
PRIMARY KEY (buffer_id, epoch)
);
CREATE TABLE "channel_buffer_collaborators" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"channel_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE,
"connection_id" INTEGER NOT NULL,
"connection_server_id" INTEGER NOT NULL REFERENCES servers (id) ON DELETE CASCADE,
"connection_lost" BOOLEAN NOT NULL DEFAULT false,
"user_id" INTEGER NOT NULL,
"replica_id" INTEGER NOT NULL
);
CREATE INDEX "index_channel_buffer_collaborators_on_channel_id" ON "channel_buffer_collaborators" ("channel_id");
CREATE UNIQUE INDEX "index_channel_buffer_collaborators_on_channel_id_and_replica_id" ON "channel_buffer_collaborators" ("channel_id", "replica_id");
CREATE INDEX "index_channel_buffer_collaborators_on_connection_server_id" ON "channel_buffer_collaborators" ("connection_server_id");
CREATE INDEX "index_channel_buffer_collaborators_on_connection_id" ON "channel_buffer_collaborators" ("connection_id");
CREATE UNIQUE INDEX "index_channel_buffer_collaborators_on_channel_id_connection_id_and_server_id" ON "channel_buffer_collaborators" (
"channel_id",
"connection_id",
"connection_server_id"
);
CREATE TABLE "observed_buffer_edits" (
"user_id" INTEGER NOT NULL,
"buffer_id" INTEGER NOT NULL REFERENCES buffers (id) ON DELETE CASCADE,
"epoch" INTEGER NOT NULL,
"lamport_timestamp" INTEGER NOT NULL,
"replica_id" INTEGER NOT NULL,
PRIMARY KEY (user_id, buffer_id)
);
CREATE UNIQUE INDEX "index_observed_buffers_user_and_buffer_id" ON "observed_buffer_edits" ("user_id", "buffer_id");
CREATE TABLE "notification_kinds" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"name" VARCHAR NOT NULL
);
CREATE UNIQUE INDEX "index_notification_kinds_on_name" ON "notification_kinds" ("name");
CREATE TABLE "notifications" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"created_at" TIMESTAMP NOT NULL default CURRENT_TIMESTAMP,
"recipient_id" INTEGER NOT NULL,
"kind" INTEGER NOT NULL REFERENCES notification_kinds (id),
"entity_id" INTEGER,
"content" TEXT,
"is_read" BOOLEAN NOT NULL DEFAULT FALSE,
"response" BOOLEAN
);
CREATE INDEX "index_notifications_on_recipient_id_is_read_kind_entity_id" ON "notifications" ("recipient_id", "is_read", "kind", "entity_id");
CREATE TABLE contributors (
user_id INTEGER,
signed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id)
);
CREATE TABLE extensions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
external_id TEXT NOT NULL,
name TEXT NOT NULL,
latest_version TEXT NOT NULL,
total_download_count INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE extension_versions (
extension_id INTEGER REFERENCES extensions (id),
version TEXT NOT NULL,
published_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
authors TEXT NOT NULL,
repository TEXT NOT NULL,
description TEXT NOT NULL,
schema_version INTEGER NOT NULL DEFAULT 0,
wasm_api_version TEXT,
download_count INTEGER NOT NULL DEFAULT 0,
provides_themes BOOLEAN NOT NULL DEFAULT FALSE,
provides_icon_themes BOOLEAN NOT NULL DEFAULT FALSE,
provides_languages BOOLEAN NOT NULL DEFAULT FALSE,
provides_grammars BOOLEAN NOT NULL DEFAULT FALSE,
provides_language_servers BOOLEAN NOT NULL DEFAULT FALSE,
provides_context_servers BOOLEAN NOT NULL DEFAULT FALSE,
provides_agent_servers BOOLEAN NOT NULL DEFAULT FALSE,
provides_slash_commands BOOLEAN NOT NULL DEFAULT FALSE,
provides_indexed_docs_providers BOOLEAN NOT NULL DEFAULT FALSE,
provides_snippets BOOLEAN NOT NULL DEFAULT FALSE,
provides_debug_adapters BOOLEAN NOT NULL DEFAULT FALSE,
PRIMARY KEY (extension_id, version)
);
CREATE UNIQUE INDEX "index_extensions_external_id" ON "extensions" ("external_id");
CREATE INDEX "index_extensions_total_download_count" ON "extensions" ("total_download_count");
CREATE TABLE IF NOT EXISTS "breakpoints" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"project_id" INTEGER NOT NULL REFERENCES projects (id) ON DELETE CASCADE,
"position" INTEGER NOT NULL,
"log_message" TEXT NULL,
"worktree_id" BIGINT NOT NULL,
"path" TEXT NOT NULL,
"kind" VARCHAR NOT NULL
);
CREATE INDEX "index_breakpoints_on_project_id" ON "breakpoints" ("project_id");
CREATE TABLE IF NOT EXISTS "shared_threads" (
"id" TEXT PRIMARY KEY NOT NULL,
"user_id" INTEGER NOT NULL,
"title" VARCHAR(512) NOT NULL,
"data" BLOB NOT NULL,
"created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX "index_shared_threads_user_id" ON "shared_threads" ("user_id");

View File

@@ -0,0 +1,850 @@
-- This file is auto-generated. Do not modify it by hand.
-- To regenerate, run `cargo xtask db dump-schema app --collab` from the Cloud repository.
CREATE EXTENSION IF NOT EXISTS pg_trgm WITH SCHEMA public;
CREATE TABLE public.breakpoints (
id integer NOT NULL,
project_id integer NOT NULL,
"position" integer NOT NULL,
log_message text,
worktree_id bigint NOT NULL,
path text NOT NULL,
kind character varying NOT NULL
);
CREATE SEQUENCE public.breakpoints_id_seq
AS integer
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER SEQUENCE public.breakpoints_id_seq OWNED BY public.breakpoints.id;
CREATE TABLE public.buffer_operations (
buffer_id integer NOT NULL,
epoch integer NOT NULL,
replica_id integer NOT NULL,
lamport_timestamp integer NOT NULL,
value bytea NOT NULL
);
CREATE TABLE public.buffer_snapshots (
buffer_id integer NOT NULL,
epoch integer NOT NULL,
text text NOT NULL,
operation_serialization_version integer NOT NULL
);
CREATE TABLE public.buffers (
id integer NOT NULL,
channel_id integer NOT NULL,
epoch integer DEFAULT 0 NOT NULL,
latest_operation_epoch integer,
latest_operation_lamport_timestamp integer,
latest_operation_replica_id integer
);
CREATE SEQUENCE public.buffers_id_seq
AS integer
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER SEQUENCE public.buffers_id_seq OWNED BY public.buffers.id;
CREATE TABLE public.channel_buffer_collaborators (
id integer NOT NULL,
channel_id integer NOT NULL,
connection_id integer NOT NULL,
connection_server_id integer NOT NULL,
connection_lost boolean DEFAULT false NOT NULL,
user_id integer NOT NULL,
replica_id integer NOT NULL
);
CREATE SEQUENCE public.channel_buffer_collaborators_id_seq
AS integer
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER SEQUENCE public.channel_buffer_collaborators_id_seq OWNED BY public.channel_buffer_collaborators.id;
CREATE TABLE public.channel_chat_participants (
id integer NOT NULL,
user_id integer NOT NULL,
channel_id integer NOT NULL,
connection_id integer NOT NULL,
connection_server_id integer NOT NULL
);
CREATE SEQUENCE public.channel_chat_participants_id_seq
AS integer
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER SEQUENCE public.channel_chat_participants_id_seq OWNED BY public.channel_chat_participants.id;
CREATE TABLE public.channel_members (
id integer NOT NULL,
channel_id integer NOT NULL,
user_id integer NOT NULL,
accepted boolean DEFAULT false NOT NULL,
updated_at timestamp without time zone DEFAULT now() NOT NULL,
role text NOT NULL
);
CREATE SEQUENCE public.channel_members_id_seq
AS integer
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER SEQUENCE public.channel_members_id_seq OWNED BY public.channel_members.id;
CREATE TABLE public.channels (
id integer NOT NULL,
name character varying NOT NULL,
created_at timestamp without time zone DEFAULT now() NOT NULL,
visibility text DEFAULT 'members'::text NOT NULL,
parent_path text NOT NULL,
requires_zed_cla boolean DEFAULT false NOT NULL,
channel_order integer DEFAULT 1 NOT NULL
);
CREATE SEQUENCE public.channels_id_seq
AS integer
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER SEQUENCE public.channels_id_seq OWNED BY public.channels.id;
CREATE TABLE public.contacts (
id integer NOT NULL,
user_id_a integer NOT NULL,
user_id_b integer NOT NULL,
a_to_b boolean NOT NULL,
should_notify boolean NOT NULL,
accepted boolean NOT NULL
);
CREATE SEQUENCE public.contacts_id_seq
AS integer
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER SEQUENCE public.contacts_id_seq OWNED BY public.contacts.id;
CREATE TABLE public.contributors (
user_id integer NOT NULL,
signed_at timestamp without time zone DEFAULT now() NOT NULL
);
CREATE TABLE public.extension_versions (
extension_id integer NOT NULL,
version text NOT NULL,
published_at timestamp without time zone DEFAULT now() NOT NULL,
authors text NOT NULL,
repository text NOT NULL,
description text NOT NULL,
download_count bigint DEFAULT 0 NOT NULL,
schema_version integer DEFAULT 0 NOT NULL,
wasm_api_version text,
provides_themes boolean DEFAULT false NOT NULL,
provides_icon_themes boolean DEFAULT false NOT NULL,
provides_languages boolean DEFAULT false NOT NULL,
provides_grammars boolean DEFAULT false NOT NULL,
provides_language_servers boolean DEFAULT false NOT NULL,
provides_context_servers boolean DEFAULT false NOT NULL,
provides_slash_commands boolean DEFAULT false NOT NULL,
provides_indexed_docs_providers boolean DEFAULT false NOT NULL,
provides_snippets boolean DEFAULT false NOT NULL,
provides_debug_adapters boolean DEFAULT false NOT NULL,
provides_agent_servers boolean DEFAULT false NOT NULL
);
CREATE TABLE public.extensions (
id integer NOT NULL,
name text NOT NULL,
external_id text NOT NULL,
latest_version text NOT NULL,
total_download_count bigint DEFAULT 0 NOT NULL
);
CREATE SEQUENCE public.extensions_id_seq
AS integer
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER SEQUENCE public.extensions_id_seq OWNED BY public.extensions.id;
CREATE TABLE public.followers (
id integer NOT NULL,
room_id integer NOT NULL,
project_id integer NOT NULL,
leader_connection_server_id integer NOT NULL,
leader_connection_id integer NOT NULL,
follower_connection_server_id integer NOT NULL,
follower_connection_id integer NOT NULL
);
CREATE SEQUENCE public.followers_id_seq
AS integer
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER SEQUENCE public.followers_id_seq OWNED BY public.followers.id;
CREATE TABLE public.language_servers (
project_id integer NOT NULL,
id bigint NOT NULL,
name character varying NOT NULL,
capabilities text NOT NULL,
worktree_id bigint
);
CREATE TABLE public.notification_kinds (
id integer NOT NULL,
name character varying NOT NULL
);
CREATE SEQUENCE public.notification_kinds_id_seq
AS integer
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER SEQUENCE public.notification_kinds_id_seq OWNED BY public.notification_kinds.id;
CREATE TABLE public.notifications (
id integer NOT NULL,
created_at timestamp without time zone DEFAULT now() NOT NULL,
recipient_id integer NOT NULL,
kind integer NOT NULL,
entity_id integer,
content text,
is_read boolean DEFAULT false NOT NULL,
response boolean
);
CREATE SEQUENCE public.notifications_id_seq
AS integer
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER SEQUENCE public.notifications_id_seq OWNED BY public.notifications.id;
CREATE TABLE public.observed_buffer_edits (
user_id integer NOT NULL,
buffer_id integer NOT NULL,
epoch integer NOT NULL,
lamport_timestamp integer NOT NULL,
replica_id integer NOT NULL
);
CREATE TABLE public.project_collaborators (
id integer NOT NULL,
project_id integer NOT NULL,
connection_id integer NOT NULL,
user_id integer NOT NULL,
replica_id integer NOT NULL,
is_host boolean NOT NULL,
connection_server_id integer NOT NULL,
committer_name character varying,
committer_email character varying
);
CREATE SEQUENCE public.project_collaborators_id_seq
AS integer
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER SEQUENCE public.project_collaborators_id_seq OWNED BY public.project_collaborators.id;
CREATE TABLE public.project_repositories (
project_id integer NOT NULL,
abs_path character varying,
id bigint NOT NULL,
legacy_worktree_id bigint,
entry_ids character varying,
branch character varying,
scan_id bigint NOT NULL,
is_deleted boolean NOT NULL,
current_merge_conflicts character varying,
branch_summary character varying,
head_commit_details character varying,
merge_message character varying,
remote_upstream_url character varying,
remote_origin_url character varying,
linked_worktrees text,
repository_dir_abs_path character varying,
common_dir_abs_path character varying
);
CREATE TABLE public.project_repository_statuses (
project_id integer NOT NULL,
repository_id bigint NOT NULL,
repo_path character varying NOT NULL,
status bigint NOT NULL,
status_kind integer NOT NULL,
first_status integer,
second_status integer,
scan_id bigint NOT NULL,
is_deleted boolean NOT NULL,
lines_added integer,
lines_deleted integer
);
CREATE TABLE public.projects (
id integer NOT NULL,
host_user_id integer,
unregistered boolean DEFAULT false NOT NULL,
room_id integer,
host_connection_id integer,
host_connection_server_id integer,
windows_paths boolean DEFAULT false,
features text DEFAULT ''::text NOT NULL
);
CREATE SEQUENCE public.projects_id_seq
AS integer
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER SEQUENCE public.projects_id_seq OWNED BY public.projects.id;
CREATE TABLE public.room_participants (
id integer NOT NULL,
room_id integer NOT NULL,
user_id integer NOT NULL,
answering_connection_id integer,
location_kind integer,
location_project_id integer,
initial_project_id integer,
calling_user_id integer NOT NULL,
calling_connection_id integer NOT NULL,
answering_connection_lost boolean DEFAULT false NOT NULL,
answering_connection_server_id integer,
calling_connection_server_id integer,
participant_index integer,
role text
);
CREATE SEQUENCE public.room_participants_id_seq
AS integer
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER SEQUENCE public.room_participants_id_seq OWNED BY public.room_participants.id;
CREATE TABLE public.rooms (
id integer NOT NULL,
live_kit_room character varying NOT NULL,
channel_id integer
);
CREATE SEQUENCE public.rooms_id_seq
AS integer
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER SEQUENCE public.rooms_id_seq OWNED BY public.rooms.id;
CREATE TABLE public.servers (
id integer NOT NULL,
environment character varying NOT NULL
);
CREATE SEQUENCE public.servers_id_seq
AS integer
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER SEQUENCE public.servers_id_seq OWNED BY public.servers.id;
CREATE TABLE public.shared_threads (
id uuid NOT NULL,
user_id integer NOT NULL,
title text NOT NULL,
data bytea NOT NULL,
created_at timestamp without time zone DEFAULT now() NOT NULL,
updated_at timestamp without time zone DEFAULT now() NOT NULL
);
CREATE TABLE public.users (
id integer NOT NULL,
github_login character varying,
admin boolean NOT NULL,
email_address character varying(255) DEFAULT NULL::character varying,
connected_once boolean DEFAULT false NOT NULL,
created_at timestamp without time zone DEFAULT now() NOT NULL,
github_user_id integer NOT NULL,
metrics_id uuid DEFAULT gen_random_uuid() NOT NULL,
accepted_tos_at timestamp without time zone,
github_user_created_at timestamp without time zone,
custom_llm_monthly_allowance_in_cents integer,
name text
);
CREATE SEQUENCE public.users_id_seq
AS integer
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER SEQUENCE public.users_id_seq OWNED BY public.users.id;
CREATE TABLE public.worktree_diagnostic_summaries (
project_id integer NOT NULL,
worktree_id bigint NOT NULL,
path character varying NOT NULL,
language_server_id bigint NOT NULL,
error_count integer NOT NULL,
warning_count integer NOT NULL
);
CREATE TABLE public.worktree_entries (
project_id integer NOT NULL,
worktree_id bigint NOT NULL,
id bigint NOT NULL,
is_dir boolean NOT NULL,
path character varying NOT NULL,
inode bigint NOT NULL,
mtime_seconds bigint NOT NULL,
mtime_nanos integer NOT NULL,
is_symlink boolean DEFAULT false NOT NULL,
is_ignored boolean NOT NULL,
scan_id bigint,
is_deleted boolean,
git_status bigint,
is_external boolean DEFAULT false NOT NULL,
is_fifo boolean DEFAULT false NOT NULL,
canonical_path text,
is_hidden boolean DEFAULT false NOT NULL
);
CREATE TABLE public.worktree_settings_files (
project_id integer NOT NULL,
worktree_id bigint NOT NULL,
path character varying NOT NULL,
content text NOT NULL,
kind character varying,
outside_worktree boolean DEFAULT false NOT NULL
);
CREATE TABLE public.worktrees (
project_id integer NOT NULL,
id bigint NOT NULL,
root_name character varying NOT NULL,
abs_path character varying NOT NULL,
visible boolean NOT NULL,
scan_id bigint NOT NULL,
is_complete boolean DEFAULT false NOT NULL,
completed_scan_id bigint,
root_repo_common_dir character varying
);
ALTER TABLE ONLY public.breakpoints ALTER COLUMN id SET DEFAULT nextval('public.breakpoints_id_seq'::regclass);
ALTER TABLE ONLY public.buffers ALTER COLUMN id SET DEFAULT nextval('public.buffers_id_seq'::regclass);
ALTER TABLE ONLY public.channel_buffer_collaborators ALTER COLUMN id SET DEFAULT nextval('public.channel_buffer_collaborators_id_seq'::regclass);
ALTER TABLE ONLY public.channel_chat_participants ALTER COLUMN id SET DEFAULT nextval('public.channel_chat_participants_id_seq'::regclass);
ALTER TABLE ONLY public.channel_members ALTER COLUMN id SET DEFAULT nextval('public.channel_members_id_seq'::regclass);
ALTER TABLE ONLY public.channels ALTER COLUMN id SET DEFAULT nextval('public.channels_id_seq'::regclass);
ALTER TABLE ONLY public.contacts ALTER COLUMN id SET DEFAULT nextval('public.contacts_id_seq'::regclass);
ALTER TABLE ONLY public.extensions ALTER COLUMN id SET DEFAULT nextval('public.extensions_id_seq'::regclass);
ALTER TABLE ONLY public.followers ALTER COLUMN id SET DEFAULT nextval('public.followers_id_seq'::regclass);
ALTER TABLE ONLY public.notification_kinds ALTER COLUMN id SET DEFAULT nextval('public.notification_kinds_id_seq'::regclass);
ALTER TABLE ONLY public.notifications ALTER COLUMN id SET DEFAULT nextval('public.notifications_id_seq'::regclass);
ALTER TABLE ONLY public.project_collaborators ALTER COLUMN id SET DEFAULT nextval('public.project_collaborators_id_seq'::regclass);
ALTER TABLE ONLY public.projects ALTER COLUMN id SET DEFAULT nextval('public.projects_id_seq'::regclass);
ALTER TABLE ONLY public.room_participants ALTER COLUMN id SET DEFAULT nextval('public.room_participants_id_seq'::regclass);
ALTER TABLE ONLY public.rooms ALTER COLUMN id SET DEFAULT nextval('public.rooms_id_seq'::regclass);
ALTER TABLE ONLY public.servers ALTER COLUMN id SET DEFAULT nextval('public.servers_id_seq'::regclass);
ALTER TABLE ONLY public.users ALTER COLUMN id SET DEFAULT nextval('public.users_id_seq'::regclass);
ALTER TABLE ONLY public.breakpoints
ADD CONSTRAINT breakpoints_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.buffer_operations
ADD CONSTRAINT buffer_operations_pkey PRIMARY KEY (buffer_id, epoch, lamport_timestamp, replica_id);
ALTER TABLE ONLY public.buffer_snapshots
ADD CONSTRAINT buffer_snapshots_pkey PRIMARY KEY (buffer_id, epoch);
ALTER TABLE ONLY public.buffers
ADD CONSTRAINT buffers_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.channel_buffer_collaborators
ADD CONSTRAINT channel_buffer_collaborators_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.channel_chat_participants
ADD CONSTRAINT channel_chat_participants_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.channel_members
ADD CONSTRAINT channel_members_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.channels
ADD CONSTRAINT channels_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.contacts
ADD CONSTRAINT contacts_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.contributors
ADD CONSTRAINT contributors_pkey PRIMARY KEY (user_id);
ALTER TABLE ONLY public.extension_versions
ADD CONSTRAINT extension_versions_pkey PRIMARY KEY (extension_id, version);
ALTER TABLE ONLY public.extensions
ADD CONSTRAINT extensions_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.followers
ADD CONSTRAINT followers_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.language_servers
ADD CONSTRAINT language_servers_pkey PRIMARY KEY (project_id, id);
ALTER TABLE ONLY public.notification_kinds
ADD CONSTRAINT notification_kinds_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.notifications
ADD CONSTRAINT notifications_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.observed_buffer_edits
ADD CONSTRAINT observed_buffer_edits_pkey PRIMARY KEY (user_id, buffer_id);
ALTER TABLE ONLY public.project_collaborators
ADD CONSTRAINT project_collaborators_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.project_repositories
ADD CONSTRAINT project_repositories_pkey PRIMARY KEY (project_id, id);
ALTER TABLE ONLY public.project_repository_statuses
ADD CONSTRAINT project_repository_statuses_pkey PRIMARY KEY (project_id, repository_id, repo_path);
ALTER TABLE ONLY public.projects
ADD CONSTRAINT projects_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.room_participants
ADD CONSTRAINT room_participants_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.rooms
ADD CONSTRAINT rooms_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.servers
ADD CONSTRAINT servers_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.shared_threads
ADD CONSTRAINT shared_threads_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.users
ADD CONSTRAINT users_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.worktree_diagnostic_summaries
ADD CONSTRAINT worktree_diagnostic_summaries_pkey PRIMARY KEY (project_id, worktree_id, path);
ALTER TABLE ONLY public.worktree_entries
ADD CONSTRAINT worktree_entries_pkey PRIMARY KEY (project_id, worktree_id, id);
ALTER TABLE ONLY public.worktree_settings_files
ADD CONSTRAINT worktree_settings_files_pkey PRIMARY KEY (project_id, worktree_id, path);
ALTER TABLE ONLY public.worktrees
ADD CONSTRAINT worktrees_pkey PRIMARY KEY (project_id, id);
CREATE INDEX idx_shared_threads_user_id ON public.shared_threads USING btree (user_id);
CREATE INDEX index_breakpoints_on_project_id ON public.breakpoints USING btree (project_id);
CREATE INDEX index_buffers_on_channel_id ON public.buffers USING btree (channel_id);
CREATE INDEX index_channel_buffer_collaborators_on_channel_id ON public.channel_buffer_collaborators USING btree (channel_id);
CREATE UNIQUE INDEX index_channel_buffer_collaborators_on_channel_id_and_replica_id ON public.channel_buffer_collaborators USING btree (channel_id, replica_id);
CREATE UNIQUE INDEX index_channel_buffer_collaborators_on_channel_id_connection_id_ ON public.channel_buffer_collaborators USING btree (channel_id, connection_id, connection_server_id);
CREATE INDEX index_channel_buffer_collaborators_on_connection_id ON public.channel_buffer_collaborators USING btree (connection_id);
CREATE INDEX index_channel_buffer_collaborators_on_connection_server_id ON public.channel_buffer_collaborators USING btree (connection_server_id);
CREATE INDEX index_channel_chat_participants_on_channel_id ON public.channel_chat_participants USING btree (channel_id);
CREATE UNIQUE INDEX index_channel_members_on_channel_id_and_user_id ON public.channel_members USING btree (channel_id, user_id);
CREATE INDEX index_channels_on_parent_path ON public.channels USING btree (parent_path text_pattern_ops);
CREATE INDEX index_channels_on_parent_path_and_order ON public.channels USING btree (parent_path, channel_order);
CREATE INDEX index_contacts_user_id_b ON public.contacts USING btree (user_id_b);
CREATE UNIQUE INDEX index_contacts_user_ids ON public.contacts USING btree (user_id_a, user_id_b);
CREATE UNIQUE INDEX index_extensions_external_id ON public.extensions USING btree (external_id);
CREATE INDEX index_extensions_total_download_count ON public.extensions USING btree (total_download_count);
CREATE UNIQUE INDEX index_followers_on_project_id_and_leader_connection_server_id_a ON public.followers USING btree (project_id, leader_connection_server_id, leader_connection_id, follower_connection_server_id, follower_connection_id);
CREATE INDEX index_followers_on_room_id ON public.followers USING btree (room_id);
CREATE INDEX index_language_servers_on_project_id ON public.language_servers USING btree (project_id);
CREATE UNIQUE INDEX index_notification_kinds_on_name ON public.notification_kinds USING btree (name);
CREATE INDEX index_notifications_on_recipient_id_is_read_kind_entity_id ON public.notifications USING btree (recipient_id, is_read, kind, entity_id);
CREATE UNIQUE INDEX index_observed_buffer_user_and_buffer_id ON public.observed_buffer_edits USING btree (user_id, buffer_id);
CREATE INDEX index_project_collaborators_on_connection_id ON public.project_collaborators USING btree (connection_id);
CREATE INDEX index_project_collaborators_on_connection_server_id ON public.project_collaborators USING btree (connection_server_id);
CREATE INDEX index_project_collaborators_on_project_id ON public.project_collaborators USING btree (project_id);
CREATE UNIQUE INDEX index_project_collaborators_on_project_id_and_replica_id ON public.project_collaborators USING btree (project_id, replica_id);
CREATE UNIQUE INDEX index_project_collaborators_on_project_id_connection_id_and_ser ON public.project_collaborators USING btree (project_id, connection_id, connection_server_id);
CREATE INDEX index_project_repos_statuses_on_project_id ON public.project_repository_statuses USING btree (project_id);
CREATE INDEX index_project_repos_statuses_on_project_id_and_repo_id ON public.project_repository_statuses USING btree (project_id, repository_id);
CREATE INDEX index_project_repositories_on_project_id ON public.project_repositories USING btree (project_id);
CREATE INDEX index_projects_on_host_connection_id_and_host_connection_server ON public.projects USING btree (host_connection_id, host_connection_server_id);
CREATE INDEX index_projects_on_host_connection_server_id ON public.projects USING btree (host_connection_server_id);
CREATE INDEX index_room_participants_on_answering_connection_id ON public.room_participants USING btree (answering_connection_id);
CREATE UNIQUE INDEX index_room_participants_on_answering_connection_id_and_answerin ON public.room_participants USING btree (answering_connection_id, answering_connection_server_id);
CREATE INDEX index_room_participants_on_answering_connection_server_id ON public.room_participants USING btree (answering_connection_server_id);
CREATE INDEX index_room_participants_on_calling_connection_server_id ON public.room_participants USING btree (calling_connection_server_id);
CREATE INDEX index_room_participants_on_room_id ON public.room_participants USING btree (room_id);
CREATE UNIQUE INDEX index_room_participants_on_user_id ON public.room_participants USING btree (user_id);
CREATE UNIQUE INDEX index_rooms_on_channel_id ON public.rooms USING btree (channel_id);
CREATE INDEX index_settings_files_on_project_id ON public.worktree_settings_files USING btree (project_id);
CREATE INDEX index_settings_files_on_project_id_and_wt_id ON public.worktree_settings_files USING btree (project_id, worktree_id);
CREATE UNIQUE INDEX index_users_github_login ON public.users USING btree (github_login);
CREATE INDEX index_users_on_email_address ON public.users USING btree (email_address);
CREATE INDEX index_worktree_diagnostic_summaries_on_project_id ON public.worktree_diagnostic_summaries USING btree (project_id);
CREATE INDEX index_worktree_diagnostic_summaries_on_project_id_and_worktree_ ON public.worktree_diagnostic_summaries USING btree (project_id, worktree_id);
CREATE INDEX index_worktree_entries_on_project_id ON public.worktree_entries USING btree (project_id);
CREATE INDEX index_worktree_entries_on_project_id_and_worktree_id ON public.worktree_entries USING btree (project_id, worktree_id);
CREATE INDEX index_worktrees_on_project_id ON public.worktrees USING btree (project_id);
CREATE INDEX trigram_index_extensions_name ON public.extensions USING gin (name public.gin_trgm_ops);
CREATE INDEX trigram_index_users_on_github_login ON public.users USING gin (github_login public.gin_trgm_ops);
CREATE INDEX trigram_index_users_on_name ON public.users USING gin (name public.gin_trgm_ops);
CREATE UNIQUE INDEX uix_channels_parent_path_name ON public.channels USING btree (parent_path, name) WHERE ((parent_path IS NOT NULL) AND (parent_path <> ''::text));
CREATE UNIQUE INDEX uix_users_on_github_user_id ON public.users USING btree (github_user_id);
ALTER TABLE ONLY public.breakpoints
ADD CONSTRAINT breakpoints_project_id_fkey FOREIGN KEY (project_id) REFERENCES public.projects(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.buffer_operations
ADD CONSTRAINT buffer_operations_buffer_id_fkey FOREIGN KEY (buffer_id) REFERENCES public.buffers(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.buffer_snapshots
ADD CONSTRAINT buffer_snapshots_buffer_id_fkey FOREIGN KEY (buffer_id) REFERENCES public.buffers(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.buffers
ADD CONSTRAINT buffers_channel_id_fkey FOREIGN KEY (channel_id) REFERENCES public.channels(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.channel_buffer_collaborators
ADD CONSTRAINT channel_buffer_collaborators_channel_id_fkey FOREIGN KEY (channel_id) REFERENCES public.channels(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.channel_buffer_collaborators
ADD CONSTRAINT channel_buffer_collaborators_connection_server_id_fkey FOREIGN KEY (connection_server_id) REFERENCES public.servers(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.channel_buffer_collaborators
ADD CONSTRAINT channel_buffer_collaborators_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.channel_chat_participants
ADD CONSTRAINT channel_chat_participants_channel_id_fkey FOREIGN KEY (channel_id) REFERENCES public.channels(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.channel_chat_participants
ADD CONSTRAINT channel_chat_participants_connection_server_id_fkey FOREIGN KEY (connection_server_id) REFERENCES public.servers(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.channel_chat_participants
ADD CONSTRAINT channel_chat_participants_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id);
ALTER TABLE ONLY public.channel_members
ADD CONSTRAINT channel_members_channel_id_fkey FOREIGN KEY (channel_id) REFERENCES public.channels(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.channel_members
ADD CONSTRAINT channel_members_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.contacts
ADD CONSTRAINT contacts_user_id_a_fkey FOREIGN KEY (user_id_a) REFERENCES public.users(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.contacts
ADD CONSTRAINT contacts_user_id_b_fkey FOREIGN KEY (user_id_b) REFERENCES public.users(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.contributors
ADD CONSTRAINT contributors_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.extension_versions
ADD CONSTRAINT extension_versions_extension_id_fkey FOREIGN KEY (extension_id) REFERENCES public.extensions(id);
ALTER TABLE ONLY public.project_repositories
ADD CONSTRAINT fk_project_repositories_project_id FOREIGN KEY (project_id) REFERENCES public.projects(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.project_repository_statuses
ADD CONSTRAINT fk_project_repository_statuses_project_id FOREIGN KEY (project_id) REFERENCES public.projects(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.followers
ADD CONSTRAINT followers_follower_connection_server_id_fkey FOREIGN KEY (follower_connection_server_id) REFERENCES public.servers(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.followers
ADD CONSTRAINT followers_leader_connection_server_id_fkey FOREIGN KEY (leader_connection_server_id) REFERENCES public.servers(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.followers
ADD CONSTRAINT followers_project_id_fkey FOREIGN KEY (project_id) REFERENCES public.projects(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.followers
ADD CONSTRAINT followers_room_id_fkey FOREIGN KEY (room_id) REFERENCES public.rooms(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.language_servers
ADD CONSTRAINT language_servers_project_id_fkey FOREIGN KEY (project_id) REFERENCES public.projects(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.notifications
ADD CONSTRAINT notifications_kind_fkey FOREIGN KEY (kind) REFERENCES public.notification_kinds(id);
ALTER TABLE ONLY public.notifications
ADD CONSTRAINT notifications_recipient_id_fkey FOREIGN KEY (recipient_id) REFERENCES public.users(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.observed_buffer_edits
ADD CONSTRAINT observed_buffer_edits_buffer_id_fkey FOREIGN KEY (buffer_id) REFERENCES public.buffers(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.observed_buffer_edits
ADD CONSTRAINT observed_buffer_edits_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.project_collaborators
ADD CONSTRAINT project_collaborators_connection_server_id_fkey FOREIGN KEY (connection_server_id) REFERENCES public.servers(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.project_collaborators
ADD CONSTRAINT project_collaborators_project_id_fkey FOREIGN KEY (project_id) REFERENCES public.projects(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.projects
ADD CONSTRAINT projects_host_connection_server_id_fkey FOREIGN KEY (host_connection_server_id) REFERENCES public.servers(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.projects
ADD CONSTRAINT projects_host_user_id_fkey FOREIGN KEY (host_user_id) REFERENCES public.users(id);
ALTER TABLE ONLY public.projects
ADD CONSTRAINT projects_room_id_fkey FOREIGN KEY (room_id) REFERENCES public.rooms(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.room_participants
ADD CONSTRAINT room_participants_answering_connection_server_id_fkey FOREIGN KEY (answering_connection_server_id) REFERENCES public.servers(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.room_participants
ADD CONSTRAINT room_participants_calling_connection_server_id_fkey FOREIGN KEY (calling_connection_server_id) REFERENCES public.servers(id) ON DELETE SET NULL;
ALTER TABLE ONLY public.room_participants
ADD CONSTRAINT room_participants_calling_user_id_fkey FOREIGN KEY (calling_user_id) REFERENCES public.users(id);
ALTER TABLE ONLY public.room_participants
ADD CONSTRAINT room_participants_room_id_fkey FOREIGN KEY (room_id) REFERENCES public.rooms(id);
ALTER TABLE ONLY public.room_participants
ADD CONSTRAINT room_participants_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id);
ALTER TABLE ONLY public.rooms
ADD CONSTRAINT rooms_channel_id_fkey FOREIGN KEY (channel_id) REFERENCES public.channels(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.shared_threads
ADD CONSTRAINT shared_threads_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE;
ALTER TABLE ONLY public.worktree_diagnostic_summaries
ADD CONSTRAINT worktree_diagnostic_summaries_project_id_worktree_id_fkey FOREIGN KEY (project_id, worktree_id) REFERENCES public.worktrees(project_id, id) ON DELETE CASCADE;
ALTER TABLE ONLY public.worktree_entries
ADD CONSTRAINT worktree_entries_project_id_worktree_id_fkey FOREIGN KEY (project_id, worktree_id) REFERENCES public.worktrees(project_id, id) ON DELETE CASCADE;
ALTER TABLE ONLY public.worktree_settings_files
ADD CONSTRAINT worktree_settings_files_project_id_worktree_id_fkey FOREIGN KEY (project_id, worktree_id) REFERENCES public.worktrees(project_id, id) ON DELETE CASCADE;
ALTER TABLE ONLY public.worktrees
ADD CONSTRAINT worktrees_project_id_fkey FOREIGN KEY (project_id) REFERENCES public.projects(id) ON DELETE CASCADE;

View File

@@ -0,0 +1,33 @@
{
"admins": [
"nathansobo",
"maxbrunsfeld",
"as-cii",
"JosephTLyons",
"maxdeviant",
"SomeoneToIgnore",
"mikayla-maki",
"agu-z",
"osiewicz",
"ConradIrwin",
"benbrandt",
"bennetbo",
"smitbarmase",
"notpeter",
"rgbkrk",
"JunkuiZhang",
"Anthony-Eid",
"rtfeldman",
"danilo-leal",
"MrSubidubi",
"cole-miller",
"osyvokon",
"probably-neb",
"mgsloan",
"P1n3appl3",
"mslzed",
"franciskafyi",
"katie-z-geer"
],
"channels": ["zed"]
}

View File

@@ -0,0 +1,602 @@
[
{
"id": 1,
"login": "mojombo",
"email": "tom@mojombo.com",
"created_at": "2007-10-20T05:24:19Z"
},
{
"id": 2,
"login": "defunkt",
"email": null,
"created_at": "2007-10-20T05:24:19Z"
},
{
"id": 3,
"login": "pjhyett",
"email": "pj@hyett.com",
"created_at": "2008-01-07T17:54:22Z"
},
{
"id": 4,
"login": "wycats",
"email": "wycats@gmail.com",
"created_at": "2008-01-12T05:38:33Z"
},
{
"id": 5,
"login": "ezmobius",
"email": null,
"created_at": "2008-01-12T07:51:46Z"
},
{
"id": 6,
"login": "ivey",
"email": "ivey@gweezlebur.com",
"created_at": "2008-01-12T15:15:00Z"
},
{
"id": 7,
"login": "evanphx",
"email": "evan@phx.io",
"created_at": "2008-01-12T16:46:24Z"
},
{
"id": 17,
"login": "vanpelt",
"email": "vanpelt@wandb.com",
"created_at": "2008-01-13T05:57:18Z"
},
{
"id": 18,
"login": "wayneeseguin",
"email": "wayneeseguin@gmail.com",
"created_at": "2008-01-13T06:02:21Z"
},
{
"id": 19,
"login": "brynary",
"email": null,
"created_at": "2008-01-13T10:19:47Z"
},
{
"id": 20,
"login": "kevinclark",
"email": "kevin.clark@gmail.com",
"created_at": "2008-01-13T18:33:26Z"
},
{
"id": 21,
"login": "technoweenie",
"email": "technoweenie@hey.com",
"created_at": "2008-01-14T04:33:35Z"
},
{
"id": 22,
"login": "macournoyer",
"email": "macournoyer@gmail.com",
"created_at": "2008-01-14T10:49:35Z"
},
{
"id": 23,
"login": "takeo",
"email": "toby@takeo.email",
"created_at": "2008-01-14T11:25:49Z"
},
{
"id": 25,
"login": "caged",
"email": "encytemedia@gmail.com",
"created_at": "2008-01-15T04:47:24Z"
},
{
"id": 26,
"login": "topfunky",
"email": null,
"created_at": "2008-01-15T05:40:05Z"
},
{
"id": 27,
"login": "anotherjesse",
"email": "anotherjesse@gmail.com",
"created_at": "2008-01-15T07:49:30Z"
},
{
"id": 28,
"login": "roland",
"email": null,
"created_at": "2008-01-15T08:12:51Z"
},
{
"id": 29,
"login": "lukas",
"email": "lukas@wandb.com",
"created_at": "2008-01-15T12:50:02Z"
},
{
"id": 30,
"login": "fanvsfan",
"email": null,
"created_at": "2008-01-15T14:15:23Z"
},
{
"id": 31,
"login": "tomtt",
"email": null,
"created_at": "2008-01-15T15:44:31Z"
},
{
"id": 32,
"login": "railsjitsu",
"email": null,
"created_at": "2008-01-16T04:57:23Z"
},
{
"id": 34,
"login": "nitay",
"email": null,
"created_at": "2008-01-18T14:09:11Z"
},
{
"id": 35,
"login": "kevwil",
"email": null,
"created_at": "2008-01-19T05:50:12Z"
},
{
"id": 36,
"login": "KirinDave",
"email": null,
"created_at": "2008-01-19T08:01:02Z"
},
{
"id": 37,
"login": "jamesgolick",
"email": "jamesgolick@gmail.com",
"created_at": "2008-01-19T22:52:30Z"
},
{
"id": 38,
"login": "atmos",
"email": "atmos@atmos.org",
"created_at": "2008-01-22T09:14:11Z"
},
{
"id": 44,
"login": "errfree",
"email": null,
"created_at": "2008-01-24T02:08:37Z"
},
{
"id": 45,
"login": "mojodna",
"email": null,
"created_at": "2008-01-24T04:40:22Z"
},
{
"id": 46,
"login": "bmizerany",
"email": "blake.mizerany@gmail.com",
"created_at": "2008-01-24T04:44:30Z"
},
{
"id": 47,
"login": "jnewland",
"email": "jesse@jnewland.com",
"created_at": "2008-01-25T02:28:12Z"
},
{
"id": 48,
"login": "joshknowles",
"email": "joshknowles@gmail.com",
"created_at": "2008-01-25T21:30:42Z"
},
{
"id": 49,
"login": "hornbeck",
"email": "hornbeck@gmail.com",
"created_at": "2008-01-25T21:49:23Z"
},
{
"id": 50,
"login": "jwhitmire",
"email": "jeff@jwhitmire.com",
"created_at": "2008-01-25T22:07:48Z"
},
{
"id": 51,
"login": "elbowdonkey",
"email": null,
"created_at": "2008-01-25T22:08:20Z"
},
{
"id": 52,
"login": "reinh",
"email": null,
"created_at": "2008-01-25T22:16:29Z"
},
{
"id": 53,
"login": "knzai",
"email": "git@knz.ai",
"created_at": "2008-01-25T22:33:10Z"
},
{
"id": 68,
"login": "bs",
"email": "yap@bri.tt",
"created_at": "2008-01-27T01:46:29Z"
},
{
"id": 69,
"login": "rsanheim",
"email": null,
"created_at": "2008-01-27T07:09:47Z"
},
{
"id": 70,
"login": "schacon",
"email": "schacon@gmail.com",
"created_at": "2008-01-27T17:19:28Z"
},
{
"id": 71,
"login": "uggedal",
"email": null,
"created_at": "2008-01-27T22:18:57Z"
},
{
"id": 72,
"login": "bruce",
"email": "brwcodes@gmail.com",
"created_at": "2008-01-28T07:16:45Z"
},
{
"id": 73,
"login": "sam",
"email": "ssmoot@gmail.com",
"created_at": "2008-01-28T19:01:26Z"
},
{
"id": 74,
"login": "mmower",
"email": "self@mattmower.com",
"created_at": "2008-01-28T19:47:50Z"
},
{
"id": 75,
"login": "abhay",
"email": null,
"created_at": "2008-01-28T21:08:23Z"
},
{
"id": 76,
"login": "rabble",
"email": "evan@protest.net",
"created_at": "2008-01-28T23:27:02Z"
},
{
"id": 77,
"login": "benburkert",
"email": "ben@benburkert.com",
"created_at": "2008-01-28T23:44:14Z"
},
{
"id": 78,
"login": "indirect",
"email": "andre@arko.net",
"created_at": "2008-01-29T07:59:27Z"
},
{
"id": 79,
"login": "fearoffish",
"email": "me@fearof.fish",
"created_at": "2008-01-29T08:43:10Z"
},
{
"id": 80,
"login": "ry",
"email": "ry@tinyclouds.org",
"created_at": "2008-01-29T08:50:34Z"
},
{
"id": 81,
"login": "engineyard",
"email": null,
"created_at": "2008-01-29T09:51:30Z"
},
{
"id": 82,
"login": "jsierles",
"email": null,
"created_at": "2008-01-29T11:10:25Z"
},
{
"id": 83,
"login": "tweibley",
"email": null,
"created_at": "2008-01-29T13:52:07Z"
},
{
"id": 84,
"login": "peimei",
"email": "james@railsjitsu.com",
"created_at": "2008-01-29T15:44:11Z"
},
{
"id": 85,
"login": "brixen",
"email": "brixen@gmail.com",
"created_at": "2008-01-29T16:47:55Z"
},
{
"id": 87,
"login": "tmornini",
"email": null,
"created_at": "2008-01-29T18:43:39Z"
},
{
"id": 88,
"login": "outerim",
"email": "lee@outerim.com",
"created_at": "2008-01-29T18:48:32Z"
},
{
"id": 89,
"login": "daksis",
"email": null,
"created_at": "2008-01-29T19:18:16Z"
},
{
"id": 90,
"login": "sr",
"email": "me@simonrozet.com",
"created_at": "2008-01-29T20:37:53Z"
},
{
"id": 91,
"login": "lifo",
"email": null,
"created_at": "2008-01-29T23:09:30Z"
},
{
"id": 92,
"login": "rsl",
"email": "sconds@gmail.com",
"created_at": "2008-01-29T23:13:36Z"
},
{
"id": 93,
"login": "imownbey",
"email": null,
"created_at": "2008-01-29T23:13:44Z"
},
{
"id": 94,
"login": "dylanegan",
"email": null,
"created_at": "2008-01-29T23:15:18Z"
},
{
"id": 95,
"login": "jm",
"email": "jeremymcanally@gmail.com",
"created_at": "2008-01-29T23:15:32Z"
},
{
"id": 100,
"login": "kmarsh",
"email": "kevin.marsh@gmail.com",
"created_at": "2008-01-29T23:48:24Z"
},
{
"id": 101,
"login": "jvantuyl",
"email": "jayson@aggressive.ly",
"created_at": "2008-01-30T01:11:50Z"
},
{
"id": 102,
"login": "BrianTheCoder",
"email": "wbsmith83@gmail.com",
"created_at": "2008-01-30T02:22:32Z"
},
{
"id": 103,
"login": "freeformz",
"email": "freeformz@gmail.com",
"created_at": "2008-01-30T06:19:57Z"
},
{
"id": 104,
"login": "hassox",
"email": "dneighman@gmail.com",
"created_at": "2008-01-30T06:31:06Z"
},
{
"id": 105,
"login": "automatthew",
"email": "automatthew@gmail.com",
"created_at": "2008-01-30T19:00:58Z"
},
{
"id": 106,
"login": "queso",
"email": "Joshua.owens@gmail.com",
"created_at": "2008-01-30T19:48:45Z"
},
{
"id": 107,
"login": "lancecarlson",
"email": null,
"created_at": "2008-01-30T19:53:29Z"
},
{
"id": 108,
"login": "drnic",
"email": "drnicwilliams@gmail.com",
"created_at": "2008-01-30T23:19:18Z"
},
{
"id": 109,
"login": "lukesutton",
"email": null,
"created_at": "2008-01-31T04:01:02Z"
},
{
"id": 110,
"login": "danwrong",
"email": null,
"created_at": "2008-01-31T08:51:31Z"
},
{
"id": 111,
"login": "HamptonMakes",
"email": "hampton@hamptoncatlin.com",
"created_at": "2008-01-31T17:03:51Z"
},
{
"id": 112,
"login": "jfrost",
"email": null,
"created_at": "2008-01-31T22:14:27Z"
},
{
"id": 113,
"login": "mattetti",
"email": null,
"created_at": "2008-01-31T22:56:31Z"
},
{
"id": 114,
"login": "ctennis",
"email": "c@leb.tennis",
"created_at": "2008-01-31T23:43:14Z"
},
{
"id": 115,
"login": "lawrencepit",
"email": "lawrence.pit@gmail.com",
"created_at": "2008-01-31T23:57:16Z"
},
{
"id": 116,
"login": "marcjeanson",
"email": "github@marcjeanson.com",
"created_at": "2008-02-01T01:27:19Z"
},
{
"id": 117,
"login": "grempe",
"email": null,
"created_at": "2008-02-01T04:12:42Z"
},
{
"id": 118,
"login": "peterc",
"email": "git@peterc.org",
"created_at": "2008-02-02T01:00:36Z"
},
{
"id": 119,
"login": "ministrycentered",
"email": null,
"created_at": "2008-02-02T03:50:26Z"
},
{
"id": 120,
"login": "afarnham",
"email": null,
"created_at": "2008-02-02T05:11:03Z"
},
{
"id": 121,
"login": "up_the_irons",
"email": null,
"created_at": "2008-02-02T10:59:51Z"
},
{
"id": 122,
"login": "cristibalan",
"email": "cristibalan@gmail.com",
"created_at": "2008-02-02T11:29:45Z"
},
{
"id": 123,
"login": "heavysixer",
"email": null,
"created_at": "2008-02-02T15:06:53Z"
},
{
"id": 124,
"login": "brosner",
"email": "brosner@gmail.com",
"created_at": "2008-02-02T19:03:54Z"
},
{
"id": 125,
"login": "danielmorrison",
"email": "daniel@collectiveidea.com",
"created_at": "2008-02-02T19:46:35Z"
},
{
"id": 126,
"login": "danielharan",
"email": "chebuctonian@gmail.com",
"created_at": "2008-02-02T21:42:21Z"
},
{
"id": 127,
"login": "kvnsmth",
"email": null,
"created_at": "2008-02-02T22:00:03Z"
},
{
"id": 128,
"login": "collectiveidea",
"email": "info@collectiveidea.com",
"created_at": "2008-02-02T22:34:46Z"
},
{
"id": 129,
"login": "canadaduane",
"email": "duane.johnson@gmail.com",
"created_at": "2008-02-02T23:25:39Z"
},
{
"id": 130,
"login": "corasaurus-hex",
"email": "cora@sutton.me",
"created_at": "2008-02-03T04:20:22Z"
},
{
"id": 131,
"login": "dstrelau",
"email": null,
"created_at": "2008-02-03T14:59:12Z"
},
{
"id": 132,
"login": "sunny",
"email": "sunny@sunfox.org",
"created_at": "2008-02-03T15:43:43Z"
},
{
"id": 133,
"login": "dkubb",
"email": "github@dan.kubb.ca",
"created_at": "2008-02-03T20:40:13Z"
},
{
"id": 134,
"login": "jnicklas",
"email": "jonas@jnicklas.com",
"created_at": "2008-02-03T20:43:50Z"
},
{
"id": 135,
"login": "richcollins",
"email": "richcollins@gmail.com",
"created_at": "2008-02-03T21:11:25Z"
}
]

74
crates/collab/src/api.rs Normal file
View File

@@ -0,0 +1,74 @@
pub mod events;
pub mod extensions;
use crate::Result;
use axum::{headers::Header, http::HeaderName};
use std::sync::OnceLock;
pub use extensions::fetch_extensions_from_blob_store_periodically;
pub struct CloudflareIpCountryHeader(String);
impl Header for CloudflareIpCountryHeader {
fn name() -> &'static HeaderName {
static CLOUDFLARE_IP_COUNTRY_HEADER: OnceLock<HeaderName> = OnceLock::new();
CLOUDFLARE_IP_COUNTRY_HEADER.get_or_init(|| HeaderName::from_static("cf-ipcountry"))
}
fn decode<'i, I>(values: &mut I) -> Result<Self, axum::headers::Error>
where
Self: Sized,
I: Iterator<Item = &'i axum::http::HeaderValue>,
{
let country_code = values
.next()
.ok_or_else(axum::headers::Error::invalid)?
.to_str()
.map_err(|_| axum::headers::Error::invalid())?;
Ok(Self(country_code.to_string()))
}
fn encode<E: Extend<axum::http::HeaderValue>>(&self, _values: &mut E) {
unimplemented!()
}
}
impl std::fmt::Display for CloudflareIpCountryHeader {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
pub struct SystemIdHeader(String);
impl Header for SystemIdHeader {
fn name() -> &'static HeaderName {
static SYSTEM_ID_HEADER: OnceLock<HeaderName> = OnceLock::new();
SYSTEM_ID_HEADER.get_or_init(|| HeaderName::from_static("x-zed-system-id"))
}
fn decode<'i, I>(values: &mut I) -> Result<Self, axum::headers::Error>
where
Self: Sized,
I: Iterator<Item = &'i axum::http::HeaderValue>,
{
let system_id = values
.next()
.ok_or_else(axum::headers::Error::invalid)?
.to_str()
.map_err(|_| axum::headers::Error::invalid())?;
Ok(Self(system_id.to_string()))
}
fn encode<E: Extend<axum::http::HeaderValue>>(&self, _values: &mut E) {
unimplemented!()
}
}
impl std::fmt::Display for SystemIdHeader {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}

View File

@@ -0,0 +1,232 @@
use crate::api::CloudflareIpCountryHeader;
use crate::{AppState, Error, Result};
use anyhow::anyhow;
use axum::{
Extension, Router, TypedHeader,
body::Bytes,
headers::Header,
http::{HeaderName, StatusCode},
routing::post,
};
use chrono::Duration;
use serde::{Deserialize, Serialize};
use serde_json::json;
use sha2::{Digest, Sha256};
use std::sync::{Arc, OnceLock};
use telemetry_events::{Event, EventRequestBody};
use util::ResultExt;
use uuid::Uuid;
pub fn router() -> Router {
Router::new()
.route("/telemetry/events", post(post_events))
.route("/telemetry/crashes", post(post_panic))
.route("/telemetry/panics", post(post_panic))
.route("/telemetry/hangs", post(post_panic))
}
pub struct ZedChecksumHeader(Vec<u8>);
impl Header for ZedChecksumHeader {
fn name() -> &'static HeaderName {
static ZED_CHECKSUM_HEADER: OnceLock<HeaderName> = OnceLock::new();
ZED_CHECKSUM_HEADER.get_or_init(|| HeaderName::from_static("x-zed-checksum"))
}
fn decode<'i, I>(values: &mut I) -> Result<Self, axum::headers::Error>
where
Self: Sized,
I: Iterator<Item = &'i axum::http::HeaderValue>,
{
let checksum = values
.next()
.ok_or_else(axum::headers::Error::invalid)?
.to_str()
.map_err(|_| axum::headers::Error::invalid())?;
let bytes = hex::decode(checksum).map_err(|_| axum::headers::Error::invalid())?;
Ok(Self(bytes))
}
fn encode<E: Extend<axum::http::HeaderValue>>(&self, _values: &mut E) {
unimplemented!()
}
}
pub async fn post_panic() -> Result<()> {
// as of v0.201.x crash/panic reporting is now done via Sentry.
// The endpoint returns OK to avoid spurious errors for old clients.
Ok(())
}
pub async fn post_events(
Extension(app): Extension<Arc<AppState>>,
TypedHeader(ZedChecksumHeader(checksum)): TypedHeader<ZedChecksumHeader>,
country_code_header: Option<TypedHeader<CloudflareIpCountryHeader>>,
body: Bytes,
) -> Result<()> {
let Some(expected) = calculate_json_checksum(app.clone(), &body) else {
return Err(Error::http(
StatusCode::INTERNAL_SERVER_ERROR,
"events not enabled".into(),
))?;
};
let checksum_matched = checksum == expected;
let request_body: telemetry_events::EventRequestBody =
serde_json::from_slice(&body).map_err(|err| {
log::error!("can't parse event json: {err}");
Error::Internal(anyhow!(err))
})?;
let Some(last_event) = request_body.events.last() else {
return Err(Error::http(StatusCode::BAD_REQUEST, "no events".into()))?;
};
let country_code = country_code_header.map(|h| h.to_string());
let first_event_at = chrono::Utc::now()
- chrono::Duration::milliseconds(last_event.milliseconds_since_first_event);
if let Some(kinesis_client) = app.kinesis_client.clone()
&& let Some(stream) = app.config.kinesis_stream.clone()
{
let mut request = kinesis_client.put_records().stream_name(stream);
let mut has_records = false;
for row in for_snowflake(
request_body.clone(),
first_event_at,
country_code.clone(),
checksum_matched,
) {
if let Some(data) = serde_json::to_vec(&row).log_err() {
request = request.records(
aws_sdk_kinesis::types::PutRecordsRequestEntry::builder()
.partition_key(request_body.system_id.clone().unwrap_or_default())
.data(data.into())
.build()
.unwrap(),
);
has_records = true;
}
}
if has_records {
request.send().await.log_err();
}
};
Ok(())
}
pub fn calculate_json_checksum(app: Arc<AppState>, json: &impl AsRef<[u8]>) -> Option<Vec<u8>> {
let checksum_seed = app.config.zed_client_checksum_seed.as_ref()?;
let mut summer = Sha256::new();
summer.update(checksum_seed);
summer.update(json);
summer.update(checksum_seed);
Some(summer.finalize().into_iter().collect())
}
fn for_snowflake(
body: EventRequestBody,
first_event_at: chrono::DateTime<chrono::Utc>,
country_code: Option<String>,
checksum_matched: bool,
) -> impl Iterator<Item = SnowflakeRow> {
body.events.into_iter().map(move |event| {
let timestamp =
first_event_at + Duration::milliseconds(event.milliseconds_since_first_event);
let (event_type, mut event_properties) = match &event.event {
Event::Flexible(e) => (
e.event_type.clone(),
serde_json::to_value(&e.event_properties).unwrap(),
),
};
if let serde_json::Value::Object(ref mut map) = event_properties {
map.insert("app_version".to_string(), body.app_version.clone().into());
map.insert("os_name".to_string(), body.os_name.clone().into());
map.insert("os_version".to_string(), body.os_version.clone().into());
map.insert("architecture".to_string(), body.architecture.clone().into());
map.insert(
"release_channel".to_string(),
body.release_channel.clone().into(),
);
map.insert("signed_in".to_string(), event.signed_in.into());
map.insert("checksum_matched".to_string(), checksum_matched.into());
if let Some(country_code) = country_code.as_ref() {
map.insert("country".to_string(), country_code.clone().into());
}
}
// NOTE: most amplitude user properties are read out of our event_properties
// dictionary. See https://app.amplitude.com/data/zed/Zed/sources/detail/production/falcon%3A159998
// for how that is configured.
let user_properties = body.is_staff.map(|is_staff| {
serde_json::json!({
"is_staff": is_staff,
})
});
SnowflakeRow {
time: timestamp,
user_id: body.metrics_id.clone(),
device_id: body.system_id.clone(),
event_type,
event_properties,
user_properties,
insert_id: Some(Uuid::new_v4().to_string()),
}
})
}
#[derive(Serialize, Deserialize, Debug)]
pub struct SnowflakeRow {
pub time: chrono::DateTime<chrono::Utc>,
pub user_id: Option<String>,
pub device_id: Option<String>,
pub event_type: String,
pub event_properties: serde_json::Value,
pub user_properties: Option<serde_json::Value>,
pub insert_id: Option<String>,
}
impl SnowflakeRow {
pub fn new(
event_type: impl Into<String>,
metrics_id: Option<Uuid>,
is_staff: bool,
system_id: Option<String>,
event_properties: serde_json::Value,
) -> Self {
Self {
time: chrono::Utc::now(),
event_type: event_type.into(),
device_id: system_id,
user_id: metrics_id.map(|id| id.to_string()),
insert_id: Some(uuid::Uuid::new_v4().to_string()),
event_properties,
user_properties: Some(json!({"is_staff": is_staff})),
}
}
pub async fn write(
self,
client: &Option<aws_sdk_kinesis::Client>,
stream: &Option<String>,
) -> anyhow::Result<()> {
let Some((client, stream)) = client.as_ref().zip(stream.as_ref()) else {
return Ok(());
};
let row = serde_json::to_vec(&self)?;
client
.put_record()
.stream_name(stream)
.partition_key(&self.user_id.unwrap_or_default())
.data(row.into())
.send()
.await?;
Ok(())
}
}

View File

@@ -0,0 +1,345 @@
use crate::db::ExtensionVersionConstraints;
use crate::{AppState, Error, Result, db::NewExtensionVersion};
use anyhow::Context as _;
use aws_sdk_s3::presigning::PresigningConfig;
use axum::{
Extension, Json, Router,
extract::{Path, Query},
http::StatusCode,
response::Redirect,
routing::get,
};
use cloud_api_types::{ExtensionApiManifest, GetExtensionsResponse};
use collections::HashMap;
use semver::Version as SemanticVersion;
use serde::Deserialize;
use std::{sync::Arc, time::Duration};
use time::PrimitiveDateTime;
use util::{ResultExt, maybe};
pub fn router() -> Router {
Router::new()
.route("/extensions/updates", get(get_extension_updates))
.route("/extensions/:extension_id", get(get_extension_versions))
.route(
"/extensions/:extension_id/download",
get(download_latest_extension),
)
.route(
"/extensions/:extension_id/:version/download",
get(download_extension),
)
}
#[derive(Debug, Deserialize)]
struct GetExtensionUpdatesParams {
ids: String,
min_schema_version: i32,
max_schema_version: i32,
min_wasm_api_version: semver::Version,
max_wasm_api_version: semver::Version,
}
async fn get_extension_updates(
Extension(app): Extension<Arc<AppState>>,
Query(params): Query<GetExtensionUpdatesParams>,
) -> Result<Json<GetExtensionsResponse>> {
let constraints = ExtensionVersionConstraints {
schema_versions: params.min_schema_version..=params.max_schema_version,
wasm_api_versions: params.min_wasm_api_version..=params.max_wasm_api_version,
};
let extension_ids = params.ids.split(',').map(|s| s.trim()).collect::<Vec<_>>();
let extensions = app
.db
.get_extensions_by_ids(&extension_ids, Some(&constraints))
.await?;
Ok(Json(GetExtensionsResponse { data: extensions }))
}
#[derive(Debug, Deserialize)]
struct GetExtensionVersionsParams {
extension_id: String,
}
async fn get_extension_versions(
Extension(app): Extension<Arc<AppState>>,
Path(params): Path<GetExtensionVersionsParams>,
) -> Result<Json<GetExtensionsResponse>> {
let extension_versions = app.db.get_extension_versions(&params.extension_id).await?;
Ok(Json(GetExtensionsResponse {
data: extension_versions,
}))
}
#[derive(Debug, Deserialize)]
struct DownloadLatestExtensionPathParams {
extension_id: String,
}
#[derive(Debug, Deserialize)]
struct DownloadLatestExtensionQueryParams {
min_schema_version: Option<i32>,
max_schema_version: Option<i32>,
min_wasm_api_version: Option<SemanticVersion>,
max_wasm_api_version: Option<SemanticVersion>,
}
async fn download_latest_extension(
Extension(app): Extension<Arc<AppState>>,
Path(params): Path<DownloadLatestExtensionPathParams>,
Query(query): Query<DownloadLatestExtensionQueryParams>,
) -> Result<Redirect> {
let constraints = maybe!({
let min_schema_version = query.min_schema_version?;
let max_schema_version = query.max_schema_version?;
let min_wasm_api_version = query.min_wasm_api_version?;
let max_wasm_api_version = query.max_wasm_api_version?;
Some(ExtensionVersionConstraints {
schema_versions: min_schema_version..=max_schema_version,
wasm_api_versions: min_wasm_api_version..=max_wasm_api_version,
})
});
let extension = app
.db
.get_extension(&params.extension_id, constraints.as_ref())
.await?
.context("unknown extension")?;
download_extension(
Extension(app),
Path(DownloadExtensionParams {
extension_id: params.extension_id,
version: extension.manifest.version.to_string(),
}),
)
.await
}
#[derive(Debug, Deserialize)]
struct DownloadExtensionParams {
extension_id: String,
version: String,
}
async fn download_extension(
Extension(app): Extension<Arc<AppState>>,
Path(params): Path<DownloadExtensionParams>,
) -> Result<Redirect> {
let Some((blob_store_client, bucket)) = app
.blob_store_client
.clone()
.zip(app.config.blob_store_bucket.clone())
else {
Err(Error::http(
StatusCode::NOT_IMPLEMENTED,
"not supported".into(),
))?
};
let DownloadExtensionParams {
extension_id,
version,
} = params;
let version_exists = app
.db
.record_extension_download(&extension_id, &version)
.await?;
if !version_exists {
Err(Error::http(
StatusCode::NOT_FOUND,
"unknown extension version".into(),
))?;
}
let url = blob_store_client
.get_object()
.bucket(bucket)
.key(format!(
"extensions/{extension_id}/{version}/archive.tar.gz"
))
.presigned(PresigningConfig::expires_in(EXTENSION_DOWNLOAD_URL_LIFETIME).unwrap())
.await
.context("creating presigned extension download url")?;
Ok(Redirect::temporary(url.uri()))
}
const EXTENSION_FETCH_INTERVAL: Duration = Duration::from_secs(5 * 60);
const EXTENSION_DOWNLOAD_URL_LIFETIME: Duration = Duration::from_secs(3 * 60);
pub fn fetch_extensions_from_blob_store_periodically(app_state: Arc<AppState>) {
let Some(blob_store_client) = app_state.blob_store_client.clone() else {
log::info!("no blob store client");
return;
};
let Some(blob_store_bucket) = app_state.config.blob_store_bucket.clone() else {
log::info!("no blob store bucket");
return;
};
let executor = app_state.executor.clone();
executor.spawn_detached({
let executor = executor.clone();
async move {
loop {
fetch_extensions_from_blob_store(
&blob_store_client,
&blob_store_bucket,
&app_state,
)
.await
.log_err();
executor.sleep(EXTENSION_FETCH_INTERVAL).await;
}
}
});
}
async fn fetch_extensions_from_blob_store(
blob_store_client: &aws_sdk_s3::Client,
blob_store_bucket: &String,
app_state: &Arc<AppState>,
) -> anyhow::Result<()> {
log::info!("fetching extensions from blob store");
let mut next_marker = None;
let mut published_versions = HashMap::<String, Vec<String>>::default();
loop {
let list = blob_store_client
.list_objects()
.bucket(blob_store_bucket)
.prefix("extensions/")
.set_marker(next_marker.clone())
.send()
.await?;
let objects = list.contents.unwrap_or_default();
log::info!("fetched {} object(s) from blob store", objects.len());
for object in &objects {
let Some(key) = object.key.as_ref() else {
continue;
};
let mut parts = key.split('/');
let Some(_) = parts.next().filter(|part| *part == "extensions") else {
continue;
};
let Some(extension_id) = parts.next() else {
continue;
};
let Some(version) = parts.next() else {
continue;
};
if parts.next() == Some("manifest.json") {
published_versions
.entry(extension_id.to_owned())
.or_default()
.push(version.to_owned());
}
}
if let (Some(true), Some(last_object)) = (list.is_truncated, objects.last()) {
next_marker.clone_from(&last_object.key);
} else {
break;
}
}
log::info!("found {} published extensions", published_versions.len());
let known_versions = app_state.db.get_known_extension_versions().await?;
let mut new_versions = HashMap::<&str, Vec<NewExtensionVersion>>::default();
let empty = Vec::new();
for (extension_id, published_versions) in &published_versions {
let known_versions = known_versions.get(extension_id).unwrap_or(&empty);
for published_version in published_versions {
if known_versions
.binary_search_by_key(&published_version, |known_version| known_version)
.is_err()
&& let Some(extension) = fetch_extension_manifest(
blob_store_client,
blob_store_bucket,
extension_id,
published_version,
)
.await
.log_err()
{
new_versions
.entry(extension_id)
.or_default()
.push(extension);
}
}
}
app_state
.db
.insert_extension_versions(&new_versions)
.await?;
log::info!(
"fetched {} new extensions from blob store",
new_versions.values().map(|v| v.len()).sum::<usize>()
);
Ok(())
}
async fn fetch_extension_manifest(
blob_store_client: &aws_sdk_s3::Client,
blob_store_bucket: &String,
extension_id: &str,
version: &str,
) -> anyhow::Result<NewExtensionVersion> {
let object = blob_store_client
.get_object()
.bucket(blob_store_bucket)
.key(format!("extensions/{extension_id}/{version}/manifest.json"))
.send()
.await?;
let manifest_bytes = object
.body
.collect()
.await
.map(|data| data.into_bytes())
.with_context(|| {
format!("failed to download manifest for extension {extension_id} version {version}")
})?
.to_vec();
let manifest =
serde_json::from_slice::<ExtensionApiManifest>(&manifest_bytes).with_context(|| {
format!(
"invalid manifest for extension {extension_id} version {version}: {}",
String::from_utf8_lossy(&manifest_bytes)
)
})?;
let published_at = object.last_modified.with_context(|| {
format!("missing last modified timestamp for extension {extension_id} version {version}")
})?;
let published_at = time::OffsetDateTime::from_unix_timestamp_nanos(published_at.as_nanos())?;
let published_at = PrimitiveDateTime::new(published_at.date(), published_at.time());
let version = semver::Version::parse(&manifest.version).with_context(|| {
format!("invalid version for extension {extension_id} version {version}")
})?;
Ok(NewExtensionVersion {
name: manifest.name,
version,
description: manifest.description.unwrap_or_default(),
authors: manifest.authors,
repository: manifest.repository,
schema_version: manifest.schema_version.unwrap_or(0),
wasm_api_version: manifest.wasm_api_version,
provides: manifest.provides,
published_at,
})
}

86
crates/collab/src/auth.rs Normal file
View File

@@ -0,0 +1,86 @@
use crate::entities::User;
use crate::{AppState, Error, db::UserId, rpc::Principal};
use anyhow::Context as _;
use axum::{
http::{self, Request, StatusCode},
middleware::Next,
response::IntoResponse,
};
use cloud_api_types::GetAuthenticatedUserResponse;
pub use rpc::auth::random_token;
use std::sync::Arc;
/// Validates the authorization header and adds an Extension<Principal> to the request.
/// Authorization: <user-id> <token>
/// <token> is the access_token attached to that user.
/// Authorization: "dev-server-token" <token>
pub async fn validate_header<B>(mut req: Request<B>, next: Next<B>) -> impl IntoResponse {
let mut auth_header = req
.headers()
.get(http::header::AUTHORIZATION)
.and_then(|header| header.to_str().ok())
.ok_or_else(|| {
Error::http(
StatusCode::UNAUTHORIZED,
"missing authorization header".to_string(),
)
})?
.split_whitespace();
let state = req.extensions().get::<Arc<AppState>>().unwrap();
let first = auth_header.next().unwrap_or("");
if first == "dev-server-token" {
Err(Error::http(
StatusCode::UNAUTHORIZED,
"Dev servers were removed in Zed 0.157 please upgrade to SSH remoting".to_string(),
))?;
}
let user_id = UserId(first.parse().map_err(|_| {
Error::http(
StatusCode::BAD_REQUEST,
"missing user id in authorization header".to_string(),
)
})?);
let access_token = auth_header.next().ok_or_else(|| {
Error::http(
StatusCode::BAD_REQUEST,
"missing access token in authorization header".to_string(),
)
})?;
let http_client = state.http_client.clone().expect("no HTTP client");
let response = http_client
.get(format!("{}/client/users/me", state.config.zed_cloud_url()))
.header("Content-Type", "application/json")
.header("Authorization", format!("{user_id} {access_token}"))
.send()
.await
.context("failed to validate access token")?;
if let Ok(response) = response.error_for_status() {
let response_body: GetAuthenticatedUserResponse = response
.json()
.await
.context("failed to parse response body")?;
let user = User {
id: UserId(response_body.user.id),
github_login: response_body.user.github_login,
avatar_url: response_body.user.avatar_url,
name: response_body.user.name,
admin: response_body.user.is_staff,
connected_once: response_body.user.has_connected_to_collab_once,
};
req.extensions_mut().insert(Principal::User(user));
return Ok::<_, Error>(next.run(req).await);
}
Err(Error::http(
StatusCode::UNAUTHORIZED,
"invalid credentials".to_string(),
))
}

View File

@@ -0,0 +1,12 @@
use collab::env::get_dotenv_vars;
fn main() -> anyhow::Result<()> {
for (key, value) in get_dotenv_vars(".")? {
if option_env!("POWERSHELL").is_some() {
println!("$env:{}=\"{}\"", key, value);
} else {
println!("export {}=\"{}\"", key, value);
}
}
Ok(())
}

View File

@@ -0,0 +1,2 @@
use anyhow::{Result, anyhow};
use rpc::proto;

777
crates/collab/src/db.rs Normal file
View File

@@ -0,0 +1,777 @@
mod ids;
pub mod queries;
mod tables;
use crate::{Error, Result};
use anyhow::{Context as _, anyhow};
use cloud_api_types::{ExtensionMetadata, ExtensionProvides};
use collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use dashmap::DashMap;
use futures::StreamExt;
use project_repository_statuses::StatusKind;
use rpc::{
ConnectionId,
proto::{self},
};
use sea_orm::{
ActiveValue, Condition, ConnectionTrait, DatabaseConnection, DatabaseTransaction,
FromQueryResult, IntoActiveModel, IsolationLevel, JoinType, QueryOrder, QuerySelect, Statement,
TransactionTrait,
entity::prelude::*,
sea_query::{Alias, Expr, OnConflict},
};
use semver::Version;
use serde::{Deserialize, Serialize};
use std::ops::RangeInclusive;
use std::{
future::Future,
marker::PhantomData,
ops::{Deref, DerefMut},
rc::Rc,
sync::Arc,
};
use time::PrimitiveDateTime;
use tokio::sync::{Mutex, OwnedMutexGuard};
use util::paths::PathStyle;
use worktree_settings_file::LocalSettingsKind;
pub use ids::*;
pub use sea_orm::ConnectOptions;
pub use tables::*;
#[cfg(feature = "test-support")]
pub struct DatabaseTestOptions {
pub executor: gpui::BackgroundExecutor,
pub runtime: tokio::runtime::Runtime,
pub query_failure_probability: parking_lot::Mutex<f64>,
}
/// Database gives you a handle that lets you access the database.
/// It handles pooling internally.
pub struct Database {
pub options: ConnectOptions,
pub pool: DatabaseConnection,
rooms: DashMap<RoomId, Arc<Mutex<()>>>,
projects: DashMap<ProjectId, Arc<Mutex<()>>>,
notification_kinds_by_id: HashMap<NotificationKindId, &'static str>,
notification_kinds_by_name: HashMap<String, NotificationKindId>,
#[cfg(feature = "test-support")]
pub test_options: Option<DatabaseTestOptions>,
}
// The `Database` type has so many methods that its impl blocks are split into
// separate files in the `queries` folder.
impl Database {
/// Connects to the database with the given options
pub async fn new(options: ConnectOptions) -> Result<Self> {
sqlx::any::install_default_drivers();
Ok(Self {
options: options.clone(),
pool: sea_orm::Database::connect(options).await?,
rooms: DashMap::with_capacity(16384),
projects: DashMap::with_capacity(16384),
notification_kinds_by_id: HashMap::default(),
notification_kinds_by_name: HashMap::default(),
#[cfg(feature = "test-support")]
test_options: None,
})
}
pub fn options(&self) -> &ConnectOptions {
&self.options
}
#[cfg(feature = "test-support")]
pub fn reset(&self) {
self.rooms.clear();
self.projects.clear();
}
pub async fn transaction<F, Fut, T>(&self, f: F) -> Result<T>
where
F: Send + Fn(TransactionHandle) -> Fut,
Fut: Send + Future<Output = Result<T>>,
{
let body = async {
let (tx, result) = self.with_transaction(&f).await?;
match result {
Ok(result) => match tx.commit().await.map_err(Into::into) {
Ok(()) => Ok(result),
Err(error) => Err(error),
},
Err(error) => {
tx.rollback().await?;
Err(error)
}
}
};
self.run(body).await
}
/// The same as room_transaction, but if you need to only optionally return a Room.
async fn optional_room_transaction<F, Fut, T>(
&self,
f: F,
) -> Result<Option<TransactionGuard<T>>>
where
F: Send + Fn(TransactionHandle) -> Fut,
Fut: Send + Future<Output = Result<Option<(RoomId, T)>>>,
{
let body = async {
let (tx, result) = self.with_transaction(&f).await?;
match result {
Ok(Some((room_id, data))) => {
let lock = self.rooms.entry(room_id).or_default().clone();
let _guard = lock.lock_owned().await;
match tx.commit().await.map_err(Into::into) {
Ok(()) => Ok(Some(TransactionGuard {
data,
_guard,
_not_send: PhantomData,
})),
Err(error) => Err(error),
}
}
Ok(None) => match tx.commit().await.map_err(Into::into) {
Ok(()) => Ok(None),
Err(error) => Err(error),
},
Err(error) => {
tx.rollback().await?;
Err(error)
}
}
};
self.run(body).await
}
async fn project_transaction<F, Fut, T>(
&self,
project_id: ProjectId,
f: F,
) -> Result<TransactionGuard<T>>
where
F: Send + Fn(TransactionHandle) -> Fut,
Fut: Send + Future<Output = Result<T>>,
{
let room_id = Database::room_id_for_project(self, project_id).await?;
let body = async {
let lock = if let Some(room_id) = room_id {
self.rooms.entry(room_id).or_default().clone()
} else {
self.projects.entry(project_id).or_default().clone()
};
let _guard = lock.lock_owned().await;
let (tx, result) = self.with_transaction(&f).await?;
match result {
Ok(data) => match tx.commit().await.map_err(Into::into) {
Ok(()) => Ok(TransactionGuard {
data,
_guard,
_not_send: PhantomData,
}),
Err(error) => Err(error),
},
Err(error) => {
tx.rollback().await?;
Err(error)
}
}
};
self.run(body).await
}
/// room_transaction runs the block in a transaction. It returns a RoomGuard, that keeps
/// the database locked until it is dropped. This ensures that updates sent to clients are
/// properly serialized with respect to database changes.
async fn room_transaction<F, Fut, T>(
&self,
room_id: RoomId,
f: F,
) -> Result<TransactionGuard<T>>
where
F: Send + Fn(TransactionHandle) -> Fut,
Fut: Send + Future<Output = Result<T>>,
{
let body = async {
let lock = self.rooms.entry(room_id).or_default().clone();
let _guard = lock.lock_owned().await;
let (tx, result) = self.with_transaction(&f).await?;
match result {
Ok(data) => match tx.commit().await.map_err(Into::into) {
Ok(()) => Ok(TransactionGuard {
data,
_guard,
_not_send: PhantomData,
}),
Err(error) => Err(error),
},
Err(error) => {
tx.rollback().await?;
Err(error)
}
}
};
self.run(body).await
}
async fn with_transaction<F, Fut, T>(&self, f: &F) -> Result<(DatabaseTransaction, Result<T>)>
where
F: Send + Fn(TransactionHandle) -> Fut,
Fut: Send + Future<Output = Result<T>>,
{
let tx = self
.pool
.begin_with_config(Some(IsolationLevel::ReadCommitted), None)
.await?;
let mut tx = Arc::new(Some(tx));
let result = f(TransactionHandle(tx.clone())).await;
let tx = Arc::get_mut(&mut tx)
.and_then(|tx| tx.take())
.context("couldn't complete transaction because it's still in use")?;
Ok((tx, result))
}
async fn run<F, T>(&self, future: F) -> Result<T>
where
F: Future<Output = Result<T>>,
{
#[cfg(feature = "test-support")]
{
let test_options = self.test_options.as_ref().unwrap();
test_options.executor.simulate_random_delay().await;
let fail_probability = *test_options.query_failure_probability.lock();
if test_options.executor.rng().random_bool(fail_probability) {
return Err(anyhow!("simulated query failure"))?;
}
test_options.runtime.block_on(future)
}
#[cfg(not(feature = "test-support"))]
{
future.await
}
}
}
/// A handle to a [`DatabaseTransaction`].
pub struct TransactionHandle(pub(crate) Arc<Option<DatabaseTransaction>>);
impl Deref for TransactionHandle {
type Target = DatabaseTransaction;
fn deref(&self) -> &Self::Target {
self.0.as_ref().as_ref().unwrap()
}
}
/// [`TransactionGuard`] keeps a database transaction alive until it is dropped.
/// It wraps data that depends on the state of the database and prevents an additional
/// transaction from starting that would invalidate that data.
pub struct TransactionGuard<T> {
data: T,
_guard: OwnedMutexGuard<()>,
_not_send: PhantomData<Rc<()>>,
}
impl<T> Deref for TransactionGuard<T> {
type Target = T;
fn deref(&self) -> &T {
&self.data
}
}
impl<T> DerefMut for TransactionGuard<T> {
fn deref_mut(&mut self) -> &mut T {
&mut self.data
}
}
impl<T> TransactionGuard<T> {
/// Returns the inner value of the guard.
pub fn into_inner(self) -> T {
self.data
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Contact {
Accepted { user_id: UserId, busy: bool },
Outgoing { user_id: UserId },
Incoming { user_id: UserId },
}
impl Contact {
pub fn user_id(&self) -> UserId {
match self {
Contact::Accepted { user_id, .. } => *user_id,
Contact::Outgoing { user_id } => *user_id,
Contact::Incoming { user_id, .. } => *user_id,
}
}
}
pub type NotificationBatch = Vec<(UserId, proto::Notification)>;
pub struct CreatedChannelMessage {
pub message_id: MessageId,
pub participant_connection_ids: HashSet<ConnectionId>,
pub notifications: NotificationBatch,
}
pub struct UpdatedChannelMessage {
pub message_id: MessageId,
pub participant_connection_ids: Vec<ConnectionId>,
pub notifications: NotificationBatch,
pub reply_to_message_id: Option<MessageId>,
pub timestamp: PrimitiveDateTime,
pub deleted_mention_notification_ids: Vec<NotificationId>,
pub updated_mention_notifications: Vec<rpc::proto::Notification>,
}
#[derive(Clone, Debug, PartialEq, Eq, FromQueryResult, Serialize, Deserialize)]
pub struct Invite {
pub email_address: String,
pub email_confirmation_code: String,
}
#[derive(Clone, Debug, Deserialize)]
pub struct NewSignup {
pub email_address: String,
pub platform_mac: bool,
pub platform_windows: bool,
pub platform_linux: bool,
pub editor_features: Vec<String>,
pub programming_languages: Vec<String>,
pub device_id: Option<String>,
pub added_to_mailing_list: bool,
pub created_at: Option<DateTime>,
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, FromQueryResult)]
pub struct WaitlistSummary {
pub count: i64,
pub linux_count: i64,
pub mac_count: i64,
pub windows_count: i64,
pub unknown_count: i64,
}
/// The parameters to create a new user.
#[derive(Debug, Serialize, Deserialize)]
pub struct NewUserParams {
pub github_login: String,
pub github_user_id: i32,
}
/// The result of creating a new user.
#[derive(Debug)]
pub struct NewUserResult {
pub user_id: UserId,
}
/// The result of updating a channel membership.
#[derive(Debug)]
pub struct MembershipUpdated {
pub channel_id: ChannelId,
pub new_channels: ChannelsForUser,
pub removed_channels: Vec<ChannelId>,
}
/// The result of setting a member's role.
#[derive(Debug)]
pub enum SetMemberRoleResult {
InviteUpdated(Channel),
MembershipUpdated(MembershipUpdated),
}
/// The result of inviting a member to a channel.
#[derive(Debug)]
pub struct InviteMemberResult {
pub channel: Channel,
pub notifications: NotificationBatch,
}
#[derive(Debug)]
pub struct RespondToChannelInvite {
pub membership_update: Option<MembershipUpdated>,
pub notifications: NotificationBatch,
}
#[derive(Debug)]
pub struct RemoveChannelMemberResult {
pub membership_update: MembershipUpdated,
pub notification_id: Option<NotificationId>,
}
#[derive(Debug, PartialEq, Eq, Hash)]
pub struct Channel {
pub id: ChannelId,
pub name: String,
pub visibility: ChannelVisibility,
/// parent_path is the channel ids from the root to this one (not including this one)
pub parent_path: Vec<ChannelId>,
pub channel_order: i32,
}
impl Channel {
pub fn from_model(value: channel::Model) -> Self {
Channel {
id: value.id,
visibility: value.visibility,
name: value.clone().name,
parent_path: value.ancestors().collect(),
channel_order: value.channel_order,
}
}
pub fn to_proto(&self) -> proto::Channel {
proto::Channel {
id: self.id.to_proto(),
name: self.name.clone(),
visibility: self.visibility.into(),
parent_path: self.parent_path.iter().map(|c| c.to_proto()).collect(),
channel_order: self.channel_order,
}
}
pub fn root_id(&self) -> ChannelId {
self.parent_path.first().copied().unwrap_or(self.id)
}
}
#[derive(Debug, PartialEq, Eq, Hash)]
pub struct ChannelMember {
pub role: ChannelRole,
pub user_id: UserId,
pub kind: proto::channel_member::Kind,
}
impl ChannelMember {
pub fn to_proto(&self) -> proto::ChannelMember {
proto::ChannelMember {
role: self.role.into(),
user_id: self.user_id.to_proto(),
kind: self.kind.into(),
}
}
}
#[derive(Debug, PartialEq)]
pub struct ChannelsForUser {
pub channels: Vec<Channel>,
pub channel_memberships: Vec<channel_member::Model>,
pub channel_participants: HashMap<ChannelId, Vec<UserId>>,
pub invited_channels: Vec<Channel>,
pub observed_buffer_versions: Vec<proto::ChannelBufferVersion>,
pub latest_buffer_versions: Vec<proto::ChannelBufferVersion>,
}
#[derive(Debug)]
pub struct RejoinedChannelBuffer {
pub buffer: proto::RejoinedChannelBuffer,
pub old_connection_id: ConnectionId,
}
#[derive(Clone)]
pub struct JoinRoom {
pub room: proto::Room,
pub channel: Option<channel::Model>,
}
pub struct RejoinedRoom {
pub room: proto::Room,
pub rejoined_projects: Vec<RejoinedProject>,
pub reshared_projects: Vec<ResharedProject>,
pub channel: Option<channel::Model>,
}
pub struct ResharedProject {
pub id: ProjectId,
pub old_connection_id: ConnectionId,
pub collaborators: Vec<ProjectCollaborator>,
pub worktrees: Vec<proto::WorktreeMetadata>,
}
pub struct RejoinedProject {
pub id: ProjectId,
pub old_connection_id: ConnectionId,
pub collaborators: Vec<ProjectCollaborator>,
pub worktrees: Vec<RejoinedWorktree>,
pub updated_repositories: Vec<proto::UpdateRepository>,
pub removed_repositories: Vec<u64>,
pub language_servers: Vec<LanguageServer>,
}
impl RejoinedProject {
pub fn to_proto(&self) -> proto::RejoinedProject {
let (language_servers, language_server_capabilities) = self
.language_servers
.clone()
.into_iter()
.map(|server| (server.server, server.capabilities))
.unzip();
proto::RejoinedProject {
id: self.id.to_proto(),
worktrees: self
.worktrees
.iter()
.map(|worktree| proto::WorktreeMetadata {
id: worktree.id,
root_name: worktree.root_name.clone(),
visible: worktree.visible,
abs_path: worktree.abs_path.clone(),
root_repo_common_dir: None,
})
.collect(),
collaborators: self
.collaborators
.iter()
.map(|collaborator| collaborator.to_proto())
.collect(),
language_servers,
language_server_capabilities,
}
}
}
#[derive(Debug)]
pub struct RejoinedWorktree {
pub id: u64,
pub abs_path: String,
pub root_name: String,
pub visible: bool,
pub updated_entries: Vec<proto::Entry>,
pub removed_entries: Vec<u64>,
pub updated_repositories: Vec<proto::RepositoryEntry>,
pub removed_repositories: Vec<u64>,
pub diagnostic_summaries: Vec<proto::DiagnosticSummary>,
pub settings_files: Vec<WorktreeSettingsFile>,
pub scan_id: u64,
pub completed_scan_id: u64,
pub root_repo_common_dir: Option<String>,
}
pub struct LeftRoom {
pub room: proto::Room,
pub channel: Option<channel::Model>,
pub left_projects: HashMap<ProjectId, LeftProject>,
pub canceled_calls_to_user_ids: Vec<UserId>,
pub deleted: bool,
}
pub struct RefreshedRoom {
pub room: proto::Room,
pub channel: Option<channel::Model>,
pub stale_participant_user_ids: Vec<UserId>,
pub canceled_calls_to_user_ids: Vec<UserId>,
}
pub struct RefreshedChannelBuffer {
pub connection_ids: Vec<ConnectionId>,
pub collaborators: Vec<proto::Collaborator>,
}
pub struct Project {
pub id: ProjectId,
pub role: ChannelRole,
pub collaborators: Vec<ProjectCollaborator>,
pub worktrees: BTreeMap<u64, Worktree>,
pub repositories: Vec<proto::UpdateRepository>,
pub language_servers: Vec<LanguageServer>,
pub path_style: PathStyle,
pub features: Vec<String>,
}
pub struct ProjectCollaborator {
pub connection_id: ConnectionId,
pub user_id: UserId,
pub replica_id: ReplicaId,
pub is_host: bool,
pub committer_name: Option<String>,
pub committer_email: Option<String>,
}
impl ProjectCollaborator {
pub fn to_proto(&self) -> proto::Collaborator {
proto::Collaborator {
peer_id: Some(self.connection_id.into()),
replica_id: self.replica_id.0 as u32,
user_id: self.user_id.to_proto(),
is_host: self.is_host,
committer_name: self.committer_name.clone(),
committer_email: self.committer_email.clone(),
}
}
}
#[derive(Debug, Clone)]
pub struct LanguageServer {
pub server: proto::LanguageServer,
pub capabilities: String,
}
#[derive(Debug)]
pub struct LeftProject {
pub id: ProjectId,
pub should_unshare: bool,
pub connection_ids: Vec<ConnectionId>,
}
pub struct Worktree {
pub id: u64,
pub abs_path: String,
pub root_name: String,
pub visible: bool,
pub entries: Vec<proto::Entry>,
pub legacy_repository_entries: BTreeMap<u64, proto::RepositoryEntry>,
pub diagnostic_summaries: Vec<proto::DiagnosticSummary>,
pub settings_files: Vec<WorktreeSettingsFile>,
pub scan_id: u64,
pub completed_scan_id: u64,
pub root_repo_common_dir: Option<String>,
}
#[derive(Debug)]
pub struct WorktreeSettingsFile {
pub path: String,
pub content: String,
pub kind: LocalSettingsKind,
pub outside_worktree: bool,
}
pub struct NewExtensionVersion {
pub name: String,
pub version: semver::Version,
pub description: String,
pub authors: Vec<String>,
pub repository: String,
pub schema_version: i32,
pub wasm_api_version: Option<String>,
pub provides: BTreeSet<ExtensionProvides>,
pub published_at: PrimitiveDateTime,
}
pub struct ExtensionVersionConstraints {
pub schema_versions: RangeInclusive<i32>,
pub wasm_api_versions: RangeInclusive<semver::Version>,
}
impl LocalSettingsKind {
pub fn from_proto(proto_kind: proto::LocalSettingsKind) -> Self {
match proto_kind {
proto::LocalSettingsKind::Settings => Self::Settings,
proto::LocalSettingsKind::Tasks => Self::Tasks,
proto::LocalSettingsKind::Editorconfig => Self::Editorconfig,
proto::LocalSettingsKind::Debug => Self::Debug,
}
}
pub fn to_proto(self) -> proto::LocalSettingsKind {
match self {
Self::Settings => proto::LocalSettingsKind::Settings,
Self::Tasks => proto::LocalSettingsKind::Tasks,
Self::Editorconfig => proto::LocalSettingsKind::Editorconfig,
Self::Debug => proto::LocalSettingsKind::Debug,
}
}
}
fn db_status_to_proto(
entry: project_repository_statuses::Model,
) -> anyhow::Result<proto::StatusEntry> {
use proto::git_file_status::{Tracked, Unmerged, Variant};
let (simple_status, variant) =
match (entry.status_kind, entry.first_status, entry.second_status) {
(StatusKind::Untracked, None, None) => (
proto::GitStatus::Added as i32,
Variant::Untracked(Default::default()),
),
(StatusKind::Ignored, None, None) => (
proto::GitStatus::Added as i32,
Variant::Ignored(Default::default()),
),
(StatusKind::Unmerged, Some(first_head), Some(second_head)) => (
proto::GitStatus::Conflict as i32,
Variant::Unmerged(Unmerged {
first_head,
second_head,
}),
),
(StatusKind::Tracked, Some(index_status), Some(worktree_status)) => {
let simple_status = if worktree_status != proto::GitStatus::Unmodified as i32 {
worktree_status
} else if index_status != proto::GitStatus::Unmodified as i32 {
index_status
} else {
proto::GitStatus::Unmodified as i32
};
(
simple_status,
Variant::Tracked(Tracked {
index_status,
worktree_status,
}),
)
}
_ => {
anyhow::bail!("Unexpected combination of status fields: {entry:?}");
}
};
Ok(proto::StatusEntry {
repo_path: entry.repo_path,
simple_status,
status: Some(proto::GitFileStatus {
variant: Some(variant),
}),
diff_stat_added: entry.lines_added.map(|v| v as u32),
diff_stat_deleted: entry.lines_deleted.map(|v| v as u32),
})
}
fn proto_status_to_db(
status_entry: proto::StatusEntry,
) -> (String, StatusKind, Option<i32>, Option<i32>) {
use proto::git_file_status::{Tracked, Unmerged, Variant};
let (status_kind, first_status, second_status) = status_entry
.status
.clone()
.and_then(|status| status.variant)
.map_or(
(StatusKind::Untracked, None, None),
|variant| match variant {
Variant::Untracked(_) => (StatusKind::Untracked, None, None),
Variant::Ignored(_) => (StatusKind::Ignored, None, None),
Variant::Unmerged(Unmerged {
first_head,
second_head,
}) => (StatusKind::Unmerged, Some(first_head), Some(second_head)),
Variant::Tracked(Tracked {
index_status,
worktree_status,
}) => (
StatusKind::Tracked,
Some(index_status),
Some(worktree_status),
),
},
);
(
status_entry.repo_path,
status_kind,
first_status,
second_status,
)
}

315
crates/collab/src/db/ids.rs Normal file
View File

@@ -0,0 +1,315 @@
use crate::Result;
use rpc::proto;
use sea_orm::{DbErr, entity::prelude::*};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[macro_export]
macro_rules! id_type {
($name:ident) => {
#[derive(
Clone,
Copy,
Debug,
Default,
PartialEq,
Eq,
PartialOrd,
Ord,
Hash,
Serialize,
Deserialize,
DeriveValueType,
)]
#[allow(missing_docs)]
#[serde(transparent)]
pub struct $name(pub i32);
impl $name {
#[allow(unused)]
#[allow(missing_docs)]
pub const MAX: Self = Self(i32::MAX);
#[allow(unused)]
#[allow(missing_docs)]
pub fn from_proto(value: u64) -> Self {
debug_assert!(value != 0);
Self(value as i32)
}
#[allow(unused)]
#[allow(missing_docs)]
pub fn to_proto(self) -> u64 {
self.0 as u64
}
}
impl std::fmt::Display for $name {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
self.0.fmt(f)
}
}
impl sea_orm::TryFromU64 for $name {
fn try_from_u64(n: u64) -> Result<Self, DbErr> {
Ok(Self(n.try_into().map_err(|_| {
DbErr::ConvertFromU64(concat!(
"error converting ",
stringify!($name),
" to u64"
))
})?))
}
}
impl sea_orm::sea_query::Nullable for $name {
fn null() -> Value {
Value::Int(None)
}
}
};
}
id_type!(BufferId);
id_type!(ChannelBufferCollaboratorId);
id_type!(ChannelChatParticipantId);
id_type!(ChannelId);
id_type!(ChannelMemberId);
id_type!(ContactId);
id_type!(ExtensionId);
id_type!(FlagId);
id_type!(FollowerId);
id_type!(HostedProjectId);
id_type!(MessageId);
id_type!(NotificationId);
id_type!(NotificationKindId);
id_type!(ProjectCollaboratorId);
id_type!(ProjectId);
id_type!(ReplicaId);
id_type!(RoomId);
id_type!(RoomParticipantId);
id_type!(ServerId);
id_type!(SignupId);
id_type!(UserId);
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, DeriveValueType)]
pub struct SharedThreadId(pub Uuid);
impl SharedThreadId {
pub fn from_proto(id: String) -> Option<Self> {
Uuid::parse_str(&id).ok().map(SharedThreadId)
}
pub fn to_proto(self) -> String {
self.0.to_string()
}
}
impl sea_orm::TryFromU64 for SharedThreadId {
fn try_from_u64(_n: u64) -> std::result::Result<Self, DbErr> {
Err(DbErr::ConvertFromU64(
"SharedThreadId uses UUID and cannot be converted from u64",
))
}
}
impl sea_orm::sea_query::Nullable for SharedThreadId {
fn null() -> Value {
Value::Uuid(None)
}
}
impl std::fmt::Display for SharedThreadId {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
self.0.fmt(f)
}
}
/// ChannelRole gives you permissions for both channels and calls.
#[derive(
Eq, PartialEq, Copy, Clone, Debug, EnumIter, DeriveActiveEnum, Default, Hash, Serialize,
)]
#[sea_orm(rs_type = "String", db_type = "String(StringLen::None)")]
pub enum ChannelRole {
/// Admin can read/write and change permissions.
#[sea_orm(string_value = "admin")]
Admin,
/// Member can read/write, but not change permissions.
#[sea_orm(string_value = "member")]
#[default]
Member,
/// Talker can read, but not write.
/// They can use microphones and the channel chat
#[sea_orm(string_value = "talker")]
Talker,
/// Guest can read, but not write.
/// They can not use microphones but can use the chat.
#[sea_orm(string_value = "guest")]
Guest,
/// Banned may not read.
#[sea_orm(string_value = "banned")]
Banned,
}
impl ChannelRole {
/// Returns true if this role is more powerful than the other role.
pub fn should_override(&self, other: Self) -> bool {
use ChannelRole::*;
match self {
Admin => matches!(other, Member | Banned | Talker | Guest),
Member => matches!(other, Banned | Talker | Guest),
Talker => matches!(other, Guest),
Banned => matches!(other, Guest),
Guest => false,
}
}
/// Returns the maximal role between the two
pub fn max(&self, other: Self) -> Self {
if self.should_override(other) {
*self
} else {
other
}
}
pub fn can_see_channel(&self, visibility: ChannelVisibility) -> bool {
use ChannelRole::*;
match self {
Admin | Member => true,
Guest | Talker => visibility == ChannelVisibility::Public,
Banned => false,
}
}
/// True if the role allows access to all descendant channels
pub fn can_see_all_descendants(&self) -> bool {
use ChannelRole::*;
match self {
Admin | Member => true,
Guest | Talker | Banned => false,
}
}
/// True if the role only allows access to public descendant channels
pub fn can_only_see_public_descendants(&self) -> bool {
use ChannelRole::*;
match self {
Guest | Talker => true,
Admin | Member | Banned => false,
}
}
/// True if the role can share screen/microphone/projects into rooms.
pub fn can_use_microphone(&self) -> bool {
use ChannelRole::*;
match self {
Admin | Member | Talker => true,
Guest | Banned => false,
}
}
/// True if the role can edit shared projects.
pub fn can_edit_projects(&self) -> bool {
use ChannelRole::*;
match self {
Admin | Member => true,
Talker | Guest | Banned => false,
}
}
/// True if the role can read shared projects.
pub fn can_read_projects(&self) -> bool {
use ChannelRole::*;
match self {
Admin | Member | Guest | Talker => true,
Banned => false,
}
}
pub fn requires_cla(&self) -> bool {
use ChannelRole::*;
match self {
Admin | Member => true,
Banned | Guest | Talker => false,
}
}
}
impl From<proto::ChannelRole> for ChannelRole {
fn from(value: proto::ChannelRole) -> Self {
match value {
proto::ChannelRole::Admin => ChannelRole::Admin,
proto::ChannelRole::Member => ChannelRole::Member,
proto::ChannelRole::Talker => ChannelRole::Talker,
proto::ChannelRole::Guest => ChannelRole::Guest,
proto::ChannelRole::Banned => ChannelRole::Banned,
}
}
}
impl From<ChannelRole> for proto::ChannelRole {
fn from(val: ChannelRole) -> Self {
match val {
ChannelRole::Admin => proto::ChannelRole::Admin,
ChannelRole::Member => proto::ChannelRole::Member,
ChannelRole::Talker => proto::ChannelRole::Talker,
ChannelRole::Guest => proto::ChannelRole::Guest,
ChannelRole::Banned => proto::ChannelRole::Banned,
}
}
}
impl From<ChannelRole> for i32 {
fn from(val: ChannelRole) -> Self {
let proto: proto::ChannelRole = val.into();
proto.into()
}
}
/// ChannelVisibility controls whether channels are public or private.
#[derive(Eq, PartialEq, Copy, Clone, Debug, EnumIter, DeriveActiveEnum, Default, Hash)]
#[sea_orm(rs_type = "String", db_type = "String(StringLen::None)")]
pub enum ChannelVisibility {
/// Public channels are visible to anyone with the link. People join with the Guest role by default.
#[sea_orm(string_value = "public")]
Public,
/// Members channels are only visible to members of this channel or its parents.
#[sea_orm(string_value = "members")]
#[default]
Members,
}
impl From<proto::ChannelVisibility> for ChannelVisibility {
fn from(value: proto::ChannelVisibility) -> Self {
match value {
proto::ChannelVisibility::Public => ChannelVisibility::Public,
proto::ChannelVisibility::Members => ChannelVisibility::Members,
}
}
}
impl From<ChannelVisibility> for proto::ChannelVisibility {
fn from(val: ChannelVisibility) -> Self {
match val {
ChannelVisibility::Public => proto::ChannelVisibility::Public,
ChannelVisibility::Members => proto::ChannelVisibility::Members,
}
}
}
impl From<ChannelVisibility> for i32 {
fn from(val: ChannelVisibility) -> Self {
let proto: proto::ChannelVisibility = val.into();
proto.into()
}
}
/// Indicate whether a [Buffer] has permissions to edit.
#[derive(PartialEq, Clone, Copy, Debug)]
pub enum Capability {
/// The buffer is a mutable replica.
ReadWrite,
/// The buffer is a read-only replica.
ReadOnly,
}

View File

@@ -0,0 +1,13 @@
use super::*;
pub mod buffers;
pub mod channels;
pub mod contacts;
pub mod contributors;
pub mod extensions;
pub mod notifications;
pub mod projects;
pub mod rooms;
pub mod servers;
pub mod shared_threads;
pub mod users;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,363 @@
use anyhow::Context as _;
use super::*;
impl Database {
/// Retrieves the contacts for the user with the given ID.
pub async fn get_contacts(&self, user_id: UserId) -> Result<Vec<Contact>> {
#[derive(Debug, FromQueryResult)]
struct ContactWithUserBusyStatuses {
user_id_a: UserId,
user_id_b: UserId,
a_to_b: bool,
accepted: bool,
user_a_busy: bool,
user_b_busy: bool,
}
self.transaction(|tx| async move {
let user_a_participant = Alias::new("user_a_participant");
let user_b_participant = Alias::new("user_b_participant");
let mut db_contacts = contact::Entity::find()
.column_as(
Expr::col((user_a_participant.clone(), room_participant::Column::Id))
.is_not_null(),
"user_a_busy",
)
.column_as(
Expr::col((user_b_participant.clone(), room_participant::Column::Id))
.is_not_null(),
"user_b_busy",
)
.filter(
contact::Column::UserIdA
.eq(user_id)
.or(contact::Column::UserIdB.eq(user_id)),
)
.join_as(
JoinType::LeftJoin,
contact::Relation::UserARoomParticipant.def(),
user_a_participant,
)
.join_as(
JoinType::LeftJoin,
contact::Relation::UserBRoomParticipant.def(),
user_b_participant,
)
.into_model::<ContactWithUserBusyStatuses>()
.stream(&*tx)
.await?;
let mut contacts = Vec::new();
while let Some(db_contact) = db_contacts.next().await {
let db_contact = db_contact?;
if db_contact.user_id_a == user_id {
if db_contact.accepted {
contacts.push(Contact::Accepted {
user_id: db_contact.user_id_b,
busy: db_contact.user_b_busy,
});
} else if db_contact.a_to_b {
contacts.push(Contact::Outgoing {
user_id: db_contact.user_id_b,
})
} else {
contacts.push(Contact::Incoming {
user_id: db_contact.user_id_b,
});
}
} else if db_contact.accepted {
contacts.push(Contact::Accepted {
user_id: db_contact.user_id_a,
busy: db_contact.user_a_busy,
});
} else if db_contact.a_to_b {
contacts.push(Contact::Incoming {
user_id: db_contact.user_id_a,
});
} else {
contacts.push(Contact::Outgoing {
user_id: db_contact.user_id_a,
});
}
}
contacts.sort_unstable_by_key(|contact| contact.user_id());
Ok(contacts)
})
.await
}
/// Returns whether the given user is a busy (on a call).
pub async fn is_user_busy(&self, user_id: UserId) -> Result<bool> {
self.transaction(|tx| async move {
let participant = room_participant::Entity::find()
.filter(room_participant::Column::UserId.eq(user_id))
.one(&*tx)
.await?;
Ok(participant.is_some())
})
.await
}
/// Returns whether the user with `user_id_1` has the user with `user_id_2` as a contact.
///
/// In order for this to return `true`, `user_id_2` must have an accepted invite from `user_id_1`.
pub async fn has_contact(&self, user_id_1: UserId, user_id_2: UserId) -> Result<bool> {
self.transaction(|tx| async move {
let (id_a, id_b) = if user_id_1 < user_id_2 {
(user_id_1, user_id_2)
} else {
(user_id_2, user_id_1)
};
Ok(contact::Entity::find()
.filter(
contact::Column::UserIdA
.eq(id_a)
.and(contact::Column::UserIdB.eq(id_b))
.and(contact::Column::Accepted.eq(true)),
)
.one(&*tx)
.await?
.is_some())
})
.await
}
/// Invite the user with `receiver_id` to be a contact of the user with `sender_id`.
pub async fn send_contact_request(
&self,
sender_id: UserId,
receiver_id: UserId,
) -> Result<NotificationBatch> {
self.transaction(|tx| async move {
let (id_a, id_b, a_to_b) = if sender_id < receiver_id {
(sender_id, receiver_id, true)
} else {
(receiver_id, sender_id, false)
};
let rows_affected = contact::Entity::insert(contact::ActiveModel {
user_id_a: ActiveValue::set(id_a),
user_id_b: ActiveValue::set(id_b),
a_to_b: ActiveValue::set(a_to_b),
accepted: ActiveValue::set(false),
should_notify: ActiveValue::set(true),
..Default::default()
})
.on_conflict(
OnConflict::columns([contact::Column::UserIdA, contact::Column::UserIdB])
.values([
(contact::Column::Accepted, true.into()),
(contact::Column::ShouldNotify, false.into()),
])
.action_and_where(
contact::Column::Accepted.eq(false).and(
contact::Column::AToB
.eq(a_to_b)
.and(contact::Column::UserIdA.eq(id_b))
.or(contact::Column::AToB
.ne(a_to_b)
.and(contact::Column::UserIdA.eq(id_a))),
),
)
.to_owned(),
)
.exec_without_returning(&*tx)
.await?;
if rows_affected == 0 {
Err(anyhow!("contact already requested"))?;
}
Ok(self
.create_notification(
receiver_id,
rpc::Notification::ContactRequest {
sender_id: sender_id.to_proto(),
},
true,
&tx,
)
.await?
.into_iter()
.collect())
})
.await
}
/// Returns a bool indicating whether the removed contact had originally accepted or not
///
/// Deletes the contact identified by the requester and responder ids, and then returns
/// whether the deleted contact had originally accepted or was a pending contact request.
///
/// # Arguments
///
/// * `requester_id` - The user that initiates this request
/// * `responder_id` - The user that will be removed
pub async fn remove_contact(
&self,
requester_id: UserId,
responder_id: UserId,
) -> Result<(bool, Option<NotificationId>)> {
self.transaction(|tx| async move {
let (id_a, id_b) = if responder_id < requester_id {
(responder_id, requester_id)
} else {
(requester_id, responder_id)
};
let contact = contact::Entity::find()
.filter(
contact::Column::UserIdA
.eq(id_a)
.and(contact::Column::UserIdB.eq(id_b)),
)
.one(&*tx)
.await?
.context("no such contact")?;
contact::Entity::delete_by_id(contact.id).exec(&*tx).await?;
let mut deleted_notification_id = None;
if !contact.accepted {
deleted_notification_id = self
.remove_notification(
responder_id,
rpc::Notification::ContactRequest {
sender_id: requester_id.to_proto(),
},
&tx,
)
.await?;
}
Ok((contact.accepted, deleted_notification_id))
})
.await
}
/// Dismisses a contact notification for the given user.
pub async fn dismiss_contact_notification(
&self,
user_id: UserId,
contact_user_id: UserId,
) -> Result<()> {
self.transaction(|tx| async move {
let (id_a, id_b, a_to_b) = if user_id < contact_user_id {
(user_id, contact_user_id, true)
} else {
(contact_user_id, user_id, false)
};
let result = contact::Entity::update_many()
.set(contact::ActiveModel {
should_notify: ActiveValue::set(false),
..Default::default()
})
.filter(
contact::Column::UserIdA
.eq(id_a)
.and(contact::Column::UserIdB.eq(id_b))
.and(
contact::Column::AToB
.eq(a_to_b)
.and(contact::Column::Accepted.eq(true))
.or(contact::Column::AToB
.ne(a_to_b)
.and(contact::Column::Accepted.eq(false))),
),
)
.exec(&*tx)
.await?;
if result.rows_affected == 0 {
Err(anyhow!("no such contact request"))?
} else {
Ok(())
}
})
.await
}
/// Accept or decline a contact request
pub async fn respond_to_contact_request(
&self,
responder_id: UserId,
requester_id: UserId,
accept: bool,
) -> Result<NotificationBatch> {
self.transaction(|tx| async move {
let (id_a, id_b, a_to_b) = if responder_id < requester_id {
(responder_id, requester_id, false)
} else {
(requester_id, responder_id, true)
};
let rows_affected = if accept {
let result = contact::Entity::update_many()
.set(contact::ActiveModel {
accepted: ActiveValue::set(true),
should_notify: ActiveValue::set(true),
..Default::default()
})
.filter(
contact::Column::UserIdA
.eq(id_a)
.and(contact::Column::UserIdB.eq(id_b))
.and(contact::Column::AToB.eq(a_to_b)),
)
.exec(&*tx)
.await?;
result.rows_affected
} else {
let result = contact::Entity::delete_many()
.filter(
contact::Column::UserIdA
.eq(id_a)
.and(contact::Column::UserIdB.eq(id_b))
.and(contact::Column::AToB.eq(a_to_b))
.and(contact::Column::Accepted.eq(false)),
)
.exec(&*tx)
.await?;
result.rows_affected
};
if rows_affected == 0 {
Err(anyhow!("no such contact request"))?
}
let mut notifications = Vec::new();
notifications.extend(
self.mark_notification_as_read_with_response(
responder_id,
&rpc::Notification::ContactRequest {
sender_id: requester_id.to_proto(),
},
accept,
&tx,
)
.await?,
);
if accept {
notifications.extend(
self.create_notification(
requester_id,
rpc::Notification::ContactRequestAccepted {
responder_id: responder_id.to_proto(),
},
true,
&tx,
)
.await?,
);
}
Ok(notifications)
})
.await
}
}

View File

@@ -0,0 +1,23 @@
use super::*;
impl Database {
/// Records that a given user has signed the CLA.
#[cfg(feature = "test-support")]
pub async fn add_contributor(&self, user_id: UserId) -> Result<()> {
self.transaction(|tx| async move {
contributor::Entity::insert(contributor::ActiveModel {
user_id: ActiveValue::Set(user_id),
signed_at: ActiveValue::NotSet,
})
.on_conflict(
OnConflict::column(contributor::Column::UserId)
.do_nothing()
.to_owned(),
)
.exec_without_returning(&*tx)
.await?;
Ok(())
})
.await
}
}

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,403 @@
use std::str::FromStr;
use anyhow::Context;
use chrono::Utc;
use sea_orm::sea_query::IntoCondition;
use util::ResultExt;
use super::*;
impl Database {
pub async fn get_extensions_by_ids(
&self,
ids: &[&str],
constraints: Option<&ExtensionVersionConstraints>,
) -> Result<Vec<ExtensionMetadata>> {
self.transaction(|tx| async move {
let extensions = extension::Entity::find()
.filter(extension::Column::ExternalId.is_in(ids.iter().copied()))
.all(&*tx)
.await?;
let mut max_versions = self
.get_latest_versions_for_extensions(&extensions, constraints, &tx)
.await?;
Ok(extensions
.into_iter()
.filter_map(|extension| {
let (version, _) = max_versions.remove(&extension.id)?;
Some(metadata_from_extension_and_version(extension, version))
})
.collect())
})
.await
}
async fn get_latest_versions_for_extensions(
&self,
extensions: &[extension::Model],
constraints: Option<&ExtensionVersionConstraints>,
tx: &DatabaseTransaction,
) -> Result<HashMap<ExtensionId, (extension_version::Model, Version)>> {
let mut versions = extension_version::Entity::find()
.filter(
extension_version::Column::ExtensionId
.is_in(extensions.iter().map(|extension| extension.id)),
)
.stream(tx)
.await?;
let mut max_versions =
HashMap::<ExtensionId, (extension_version::Model, Version)>::default();
while let Some(version) = versions.next().await {
let version = version?;
let Some(extension_version) = Version::from_str(&version.version).log_err() else {
continue;
};
if let Some((_, max_extension_version)) = &max_versions.get(&version.extension_id)
&& max_extension_version > &extension_version
{
continue;
}
if let Some(constraints) = constraints {
if !constraints
.schema_versions
.contains(&version.schema_version)
{
continue;
}
if let Some(wasm_api_version) = version.wasm_api_version.as_ref() {
if let Some(version) = Version::from_str(wasm_api_version).log_err() {
if !constraints.wasm_api_versions.contains(&version) {
continue;
}
} else {
continue;
}
}
}
max_versions.insert(version.extension_id, (version, extension_version));
}
Ok(max_versions)
}
/// Returns all of the versions for the extension with the given ID.
pub async fn get_extension_versions(
&self,
extension_id: &str,
) -> Result<Vec<ExtensionMetadata>> {
self.transaction(|tx| async move {
let condition = extension::Column::ExternalId
.eq(extension_id)
.into_condition();
self.get_extensions_where(condition, None, &tx).await
})
.await
}
async fn get_extensions_where(
&self,
condition: Condition,
limit: Option<u64>,
tx: &DatabaseTransaction,
) -> Result<Vec<ExtensionMetadata>> {
let extensions = extension::Entity::find()
.inner_join(extension_version::Entity)
.select_also(extension_version::Entity)
.filter(condition)
.order_by_desc(extension::Column::TotalDownloadCount)
.order_by_asc(extension::Column::Name)
.limit(limit)
.all(tx)
.await?;
Ok(extensions
.into_iter()
.filter_map(|(extension, version)| {
Some(metadata_from_extension_and_version(extension, version?))
})
.collect())
}
pub async fn get_extension(
&self,
extension_id: &str,
constraints: Option<&ExtensionVersionConstraints>,
) -> Result<Option<ExtensionMetadata>> {
self.transaction(|tx| async move {
let extension = extension::Entity::find()
.filter(extension::Column::ExternalId.eq(extension_id))
.one(&*tx)
.await?
.with_context(|| format!("no such extension: {extension_id}"))?;
let extensions = [extension];
let mut versions = self
.get_latest_versions_for_extensions(&extensions, constraints, &tx)
.await?;
let [extension] = extensions;
Ok(versions.remove(&extension.id).map(|(max_version, _)| {
metadata_from_extension_and_version(extension, max_version)
}))
})
.await
}
pub async fn get_extension_version(
&self,
extension_id: &str,
version: &str,
) -> Result<Option<ExtensionMetadata>> {
self.transaction(|tx| async move {
let extension = extension::Entity::find()
.filter(extension::Column::ExternalId.eq(extension_id))
.filter(extension_version::Column::Version.eq(version))
.inner_join(extension_version::Entity)
.select_also(extension_version::Entity)
.one(&*tx)
.await?;
Ok(extension.and_then(|(extension, version)| {
Some(metadata_from_extension_and_version(extension, version?))
}))
})
.await
}
pub async fn get_known_extension_versions(&self) -> Result<HashMap<String, Vec<String>>> {
self.transaction(|tx| async move {
let mut extension_external_ids_by_id = HashMap::default();
let mut rows = extension::Entity::find().stream(&*tx).await?;
while let Some(row) = rows.next().await {
let row = row?;
extension_external_ids_by_id.insert(row.id, row.external_id);
}
drop(rows);
let mut known_versions_by_extension_id: HashMap<String, Vec<String>> =
HashMap::default();
let mut rows = extension_version::Entity::find().stream(&*tx).await?;
while let Some(row) = rows.next().await {
let row = row?;
let Some(extension_id) = extension_external_ids_by_id.get(&row.extension_id) else {
continue;
};
let versions = known_versions_by_extension_id
.entry(extension_id.clone())
.or_default();
if let Err(ix) = versions.binary_search(&row.version) {
versions.insert(ix, row.version);
}
}
drop(rows);
Ok(known_versions_by_extension_id)
})
.await
}
pub async fn insert_extension_versions(
&self,
versions_by_extension_id: &HashMap<&str, Vec<NewExtensionVersion>>,
) -> Result<()> {
self.transaction(|tx| async move {
for (external_id, versions) in versions_by_extension_id {
if versions.is_empty() {
continue;
}
let latest_version = versions
.iter()
.max_by_key(|version| &version.version)
.unwrap();
let insert = extension::Entity::insert(extension::ActiveModel {
name: ActiveValue::Set(latest_version.name.clone()),
external_id: ActiveValue::Set((*external_id).to_owned()),
id: ActiveValue::NotSet,
latest_version: ActiveValue::Set(latest_version.version.to_string()),
total_download_count: ActiveValue::NotSet,
})
.on_conflict(
OnConflict::columns([extension::Column::ExternalId])
.update_column(extension::Column::ExternalId)
.to_owned(),
);
let extension = if tx.support_returning() {
insert.exec_with_returning(&*tx).await?
} else {
// Sqlite
insert.exec_without_returning(&*tx).await?;
extension::Entity::find()
.filter(extension::Column::ExternalId.eq(*external_id))
.one(&*tx)
.await?
.context("failed to insert extension")?
};
extension_version::Entity::insert_many(versions.iter().map(|version| {
extension_version::ActiveModel {
extension_id: ActiveValue::Set(extension.id),
published_at: ActiveValue::Set(version.published_at),
version: ActiveValue::Set(version.version.to_string()),
authors: ActiveValue::Set(version.authors.join(", ")),
repository: ActiveValue::Set(version.repository.clone()),
description: ActiveValue::Set(version.description.clone()),
schema_version: ActiveValue::Set(version.schema_version),
wasm_api_version: ActiveValue::Set(version.wasm_api_version.clone()),
provides_themes: ActiveValue::Set(
version.provides.contains(&ExtensionProvides::Themes),
),
provides_icon_themes: ActiveValue::Set(
version.provides.contains(&ExtensionProvides::IconThemes),
),
provides_languages: ActiveValue::Set(
version.provides.contains(&ExtensionProvides::Languages),
),
provides_grammars: ActiveValue::Set(
version.provides.contains(&ExtensionProvides::Grammars),
),
provides_language_servers: ActiveValue::Set(
version
.provides
.contains(&ExtensionProvides::LanguageServers),
),
provides_context_servers: ActiveValue::Set(
version
.provides
.contains(&ExtensionProvides::ContextServers),
),
provides_agent_servers: ActiveValue::Set(
version.provides.contains(&ExtensionProvides::AgentServers),
),
provides_slash_commands: ActiveValue::Set(
version.provides.contains(&ExtensionProvides::SlashCommands),
),
provides_indexed_docs_providers: ActiveValue::Set(
version
.provides
.contains(&ExtensionProvides::IndexedDocsProviders),
),
provides_snippets: ActiveValue::Set(
version.provides.contains(&ExtensionProvides::Snippets),
),
provides_debug_adapters: ActiveValue::Set(
version.provides.contains(&ExtensionProvides::DebugAdapters),
),
download_count: ActiveValue::NotSet,
}
}))
.on_conflict(OnConflict::new().do_nothing().to_owned())
.exec_without_returning(&*tx)
.await?;
if let Ok(db_version) = semver::Version::parse(&extension.latest_version)
&& db_version >= latest_version.version
{
continue;
}
let mut extension = extension.into_active_model();
extension.latest_version = ActiveValue::Set(latest_version.version.to_string());
extension.name = ActiveValue::set(latest_version.name.clone());
extension::Entity::update(extension).exec(&*tx).await?;
}
Ok(())
})
.await
}
pub async fn record_extension_download(&self, extension: &str, version: &str) -> Result<bool> {
self.transaction(|tx| async move {
#[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
enum QueryId {
Id,
}
let extension_id: Option<ExtensionId> = extension::Entity::find()
.filter(extension::Column::ExternalId.eq(extension))
.select_only()
.column(extension::Column::Id)
.into_values::<_, QueryId>()
.one(&*tx)
.await?;
let Some(extension_id) = extension_id else {
return Ok(false);
};
extension_version::Entity::update_many()
.col_expr(
extension_version::Column::DownloadCount,
extension_version::Column::DownloadCount.into_expr().add(1),
)
.filter(
extension_version::Column::ExtensionId
.eq(extension_id)
.and(extension_version::Column::Version.eq(version)),
)
.exec(&*tx)
.await?;
extension::Entity::update_many()
.col_expr(
extension::Column::TotalDownloadCount,
extension::Column::TotalDownloadCount.into_expr().add(1),
)
.filter(extension::Column::Id.eq(extension_id))
.exec(&*tx)
.await?;
Ok(true)
})
.await
}
}
fn metadata_from_extension_and_version(
extension: extension::Model,
version: extension_version::Model,
) -> ExtensionMetadata {
let provides = version.provides();
ExtensionMetadata {
id: extension.external_id.into(),
manifest: cloud_api_types::ExtensionApiManifest {
name: extension.name,
version: version.version.into(),
authors: version
.authors
.split(',')
.map(|author| author.trim().to_string())
.collect::<Vec<_>>(),
description: Some(version.description),
repository: version.repository,
schema_version: Some(version.schema_version),
wasm_api_version: version.wasm_api_version,
provides,
},
published_at: convert_time_to_chrono(version.published_at),
download_count: extension.total_download_count as u64,
}
}
pub fn convert_time_to_chrono(time: time::PrimitiveDateTime) -> chrono::DateTime<Utc> {
chrono::DateTime::from_naive_utc_and_offset(
#[allow(deprecated)]
chrono::NaiveDateTime::from_timestamp_opt(time.assume_utc().unix_timestamp(), 0).unwrap(),
Utc,
)
}

View File

@@ -0,0 +1,281 @@
use super::*;
use anyhow::Context as _;
use rpc::Notification;
use util::ResultExt;
impl Database {
/// Initializes the different kinds of notifications by upserting records for them.
pub async fn initialize_notification_kinds(&mut self) -> Result<()> {
let all_kinds = Notification::all_variant_names();
let existing_kinds = notification_kind::Entity::find().all(&self.pool).await?;
let kinds_to_create: Vec<_> = all_kinds
.iter()
.filter(|&kind| {
!existing_kinds
.iter()
.any(|existing| existing.name == **kind)
})
.map(|kind| notification_kind::ActiveModel {
name: ActiveValue::Set((*kind).to_owned()),
..Default::default()
})
.collect();
if !kinds_to_create.is_empty() {
notification_kind::Entity::insert_many(kinds_to_create)
.exec_without_returning(&self.pool)
.await?;
}
let mut rows = notification_kind::Entity::find().stream(&self.pool).await?;
while let Some(row) = rows.next().await {
let row = row?;
self.notification_kinds_by_name.insert(row.name, row.id);
}
for name in Notification::all_variant_names() {
if let Some(id) = self.notification_kinds_by_name.get(*name).copied() {
self.notification_kinds_by_id.insert(id, name);
}
}
Ok(())
}
/// Returns the notifications for the given recipient.
pub async fn get_notifications(
&self,
recipient_id: UserId,
limit: usize,
before_id: Option<NotificationId>,
) -> Result<Vec<proto::Notification>> {
self.transaction(|tx| async move {
let mut result = Vec::new();
let mut condition =
Condition::all().add(notification::Column::RecipientId.eq(recipient_id));
if let Some(before_id) = before_id {
condition = condition.add(notification::Column::Id.lt(before_id));
}
let mut rows = notification::Entity::find()
.filter(condition)
.order_by_desc(notification::Column::Id)
.limit(limit as u64)
.stream(&*tx)
.await?;
while let Some(row) = rows.next().await {
let row = row?;
if let Some(proto) = model_to_proto(self, row).log_err() {
result.push(proto);
}
}
result.reverse();
Ok(result)
})
.await
}
/// Creates a notification. If `avoid_duplicates` is set to true, then avoid
/// creating a new notification if the given recipient already has an
/// unread notification with the given kind and entity id.
pub async fn create_notification(
&self,
recipient_id: UserId,
notification: Notification,
avoid_duplicates: bool,
tx: &DatabaseTransaction,
) -> Result<Option<(UserId, proto::Notification)>> {
if avoid_duplicates
&& self
.find_notification(recipient_id, &notification, tx)
.await?
.is_some()
{
return Ok(None);
}
let proto = notification.to_proto();
let kind = notification_kind_from_proto(self, &proto)?;
let model = notification::ActiveModel {
recipient_id: ActiveValue::Set(recipient_id),
kind: ActiveValue::Set(kind),
entity_id: ActiveValue::Set(proto.entity_id.map(|id| id as i32)),
content: ActiveValue::Set(proto.content.clone()),
..Default::default()
}
.save(tx)
.await?;
Ok(Some((
recipient_id,
proto::Notification {
id: model.id.as_ref().to_proto(),
kind: proto.kind,
timestamp: model.created_at.as_ref().assume_utc().unix_timestamp() as u64,
is_read: false,
response: None,
content: proto.content,
entity_id: proto.entity_id,
},
)))
}
/// Remove an unread notification with the given recipient, kind and
/// entity id.
pub async fn remove_notification(
&self,
recipient_id: UserId,
notification: Notification,
tx: &DatabaseTransaction,
) -> Result<Option<NotificationId>> {
let id = self
.find_notification(recipient_id, &notification, tx)
.await?;
if let Some(id) = id {
notification::Entity::delete_by_id(id).exec(tx).await?;
}
Ok(id)
}
/// Populate the response for the notification with the given kind and
/// entity id.
pub async fn mark_notification_as_read_with_response(
&self,
recipient_id: UserId,
notification: &Notification,
response: bool,
tx: &DatabaseTransaction,
) -> Result<Option<(UserId, proto::Notification)>> {
self.mark_notification_as_read_internal(recipient_id, notification, Some(response), tx)
.await
}
/// Marks the given notification as read.
pub async fn mark_notification_as_read(
&self,
recipient_id: UserId,
notification: &Notification,
tx: &DatabaseTransaction,
) -> Result<Option<(UserId, proto::Notification)>> {
self.mark_notification_as_read_internal(recipient_id, notification, None, tx)
.await
}
/// Marks the notification with the given ID as read.
pub async fn mark_notification_as_read_by_id(
&self,
recipient_id: UserId,
notification_id: NotificationId,
) -> Result<NotificationBatch> {
self.transaction(|tx| async move {
let row = notification::Entity::update(notification::ActiveModel {
id: ActiveValue::Unchanged(notification_id),
recipient_id: ActiveValue::Unchanged(recipient_id),
is_read: ActiveValue::Set(true),
..Default::default()
})
.exec(&*tx)
.await?;
Ok(model_to_proto(self, row)
.map(|notification| (recipient_id, notification))
.into_iter()
.collect())
})
.await
}
async fn mark_notification_as_read_internal(
&self,
recipient_id: UserId,
notification: &Notification,
response: Option<bool>,
tx: &DatabaseTransaction,
) -> Result<Option<(UserId, proto::Notification)>> {
if let Some(id) = self
.find_notification(recipient_id, notification, tx)
.await?
{
let row = notification::Entity::update(notification::ActiveModel {
id: ActiveValue::Unchanged(id),
recipient_id: ActiveValue::Unchanged(recipient_id),
is_read: ActiveValue::Set(true),
response: if let Some(response) = response {
ActiveValue::Set(Some(response))
} else {
ActiveValue::NotSet
},
..Default::default()
})
.exec(tx)
.await?;
Ok(model_to_proto(self, row)
.map(|notification| (recipient_id, notification))
.ok())
} else {
Ok(None)
}
}
/// Find an unread notification by its recipient, kind and entity id.
async fn find_notification(
&self,
recipient_id: UserId,
notification: &Notification,
tx: &DatabaseTransaction,
) -> Result<Option<NotificationId>> {
let proto = notification.to_proto();
let kind = notification_kind_from_proto(self, &proto)?;
#[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
enum QueryIds {
Id,
}
Ok(notification::Entity::find()
.select_only()
.column(notification::Column::Id)
.filter(
Condition::all()
.add(notification::Column::RecipientId.eq(recipient_id))
.add(notification::Column::IsRead.eq(false))
.add(notification::Column::Kind.eq(kind))
.add(if proto.entity_id.is_some() {
notification::Column::EntityId.eq(proto.entity_id)
} else {
notification::Column::EntityId.is_null()
}),
)
.into_values::<_, QueryIds>()
.one(tx)
.await?)
}
}
pub fn model_to_proto(this: &Database, row: notification::Model) -> Result<proto::Notification> {
let kind = this
.notification_kinds_by_id
.get(&row.kind)
.context("Unknown notification kind")?;
Ok(proto::Notification {
id: row.id.to_proto(),
kind: (*kind).to_owned(),
timestamp: row.created_at.assume_utc().unix_timestamp() as u64,
is_read: row.is_read,
response: row.response,
content: row.content,
entity_id: row.entity_id.map(|id| id as u64),
})
}
fn notification_kind_from_proto(
this: &Database,
proto: &proto::Notification,
) -> Result<NotificationKindId> {
Ok(this
.notification_kinds_by_name
.get(&proto.kind)
.copied()
.with_context(|| format!("invalid notification kind {:?}", proto.kind))?)
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,230 @@
use super::*;
impl Database {
/// Creates a new server in the given environment.
pub async fn create_server(&self, environment: &str) -> Result<ServerId> {
self.transaction(|tx| async move {
let server = server::ActiveModel {
environment: ActiveValue::set(environment.into()),
..Default::default()
}
.insert(&*tx)
.await?;
Ok(server.id)
})
.await
}
/// Returns the IDs of resources associated with stale servers.
///
/// A server is stale if it is in the specified `environment` and does not
/// match the provided `new_server_id`.
pub async fn stale_server_resource_ids(
&self,
environment: &str,
new_server_id: ServerId,
) -> Result<(Vec<RoomId>, Vec<ChannelId>)> {
self.transaction(|tx| async move {
#[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
enum QueryRoomIds {
RoomId,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
enum QueryChannelIds {
ChannelId,
}
let stale_server_epochs = self
.stale_server_ids(environment, new_server_id, &tx)
.await?;
let room_ids = room_participant::Entity::find()
.select_only()
.column(room_participant::Column::RoomId)
.distinct()
.filter(
room_participant::Column::AnsweringConnectionServerId
.is_in(stale_server_epochs.iter().copied()),
)
.into_values::<_, QueryRoomIds>()
.all(&*tx)
.await?;
let channel_ids = channel_buffer_collaborator::Entity::find()
.select_only()
.column(channel_buffer_collaborator::Column::ChannelId)
.distinct()
.filter(
channel_buffer_collaborator::Column::ConnectionServerId
.is_in(stale_server_epochs.iter().copied()),
)
.into_values::<_, QueryChannelIds>()
.all(&*tx)
.await?;
Ok((room_ids, channel_ids))
})
.await
}
/// Delete all channel chat participants from previous servers
pub async fn delete_stale_channel_chat_participants(
&self,
environment: &str,
new_server_id: ServerId,
) -> Result<()> {
self.transaction(|tx| async move {
let stale_server_epochs = self
.stale_server_ids(environment, new_server_id, &tx)
.await?;
channel_chat_participant::Entity::delete_many()
.filter(
channel_chat_participant::Column::ConnectionServerId
.is_in(stale_server_epochs.iter().copied()),
)
.exec(&*tx)
.await?;
Ok(())
})
.await
}
pub async fn clear_old_worktree_entries(&self, server_id: ServerId) -> Result<()> {
self.transaction(|tx| async move {
use sea_orm::Statement;
use sea_orm::sea_query::{Expr, Query};
loop {
let delete_query = Query::delete()
.from_table(worktree_entry::Entity)
.and_where(
Expr::tuple([
Expr::col((worktree_entry::Entity, worktree_entry::Column::ProjectId))
.into(),
Expr::col((worktree_entry::Entity, worktree_entry::Column::WorktreeId))
.into(),
Expr::col((worktree_entry::Entity, worktree_entry::Column::Id)).into(),
])
.in_subquery(
Query::select()
.columns([
(worktree_entry::Entity, worktree_entry::Column::ProjectId),
(worktree_entry::Entity, worktree_entry::Column::WorktreeId),
(worktree_entry::Entity, worktree_entry::Column::Id),
])
.from(worktree_entry::Entity)
.inner_join(
project::Entity,
Expr::col((project::Entity, project::Column::Id)).equals((
worktree_entry::Entity,
worktree_entry::Column::ProjectId,
)),
)
.and_where(project::Column::HostConnectionServerId.ne(server_id))
.limit(10000)
.to_owned(),
),
)
.to_owned();
let statement = Statement::from_sql_and_values(
tx.get_database_backend(),
delete_query
.to_string(sea_orm::sea_query::PostgresQueryBuilder)
.as_str(),
vec![],
);
let result = tx.execute(statement).await?;
if result.rows_affected() == 0 {
break;
}
}
loop {
let delete_query = Query::delete()
.from_table(project_repository_statuses::Entity)
.and_where(
Expr::tuple([Expr::col((
project_repository_statuses::Entity,
project_repository_statuses::Column::ProjectId,
))
.into()])
.in_subquery(
Query::select()
.columns([(
project_repository_statuses::Entity,
project_repository_statuses::Column::ProjectId,
)])
.from(project_repository_statuses::Entity)
.inner_join(
project::Entity,
Expr::col((project::Entity, project::Column::Id)).equals((
project_repository_statuses::Entity,
project_repository_statuses::Column::ProjectId,
)),
)
.and_where(project::Column::HostConnectionServerId.ne(server_id))
.limit(10000)
.to_owned(),
),
)
.to_owned();
let statement = Statement::from_sql_and_values(
tx.get_database_backend(),
delete_query
.to_string(sea_orm::sea_query::PostgresQueryBuilder)
.as_str(),
vec![],
);
let result = tx.execute(statement).await?;
if result.rows_affected() == 0 {
break;
}
}
Ok(())
})
.await
}
/// Deletes any stale servers in the environment that don't match the `new_server_id`.
pub async fn delete_stale_servers(
&self,
environment: &str,
new_server_id: ServerId,
) -> Result<()> {
self.transaction(|tx| async move {
server::Entity::delete_many()
.filter(
Condition::all()
.add(server::Column::Environment.eq(environment))
.add(server::Column::Id.ne(new_server_id)),
)
.exec(&*tx)
.await?;
Ok(())
})
.await
}
pub async fn stale_server_ids(
&self,
environment: &str,
new_server_id: ServerId,
tx: &DatabaseTransaction,
) -> Result<Vec<ServerId>> {
let stale_servers = server::Entity::find()
.filter(
Condition::all()
.add(server::Column::Environment.eq(environment))
.add(server::Column::Id.ne(new_server_id)),
)
.all(tx)
.await?;
Ok(stale_servers.into_iter().map(|server| server.id).collect())
}
}

View File

@@ -0,0 +1,77 @@
use chrono::Utc;
use super::*;
use crate::db::tables::shared_thread;
impl Database {
pub async fn upsert_shared_thread(
&self,
id: SharedThreadId,
user_id: UserId,
title: &str,
data: Vec<u8>,
) -> Result<()> {
let title = title.to_string();
self.transaction(|tx| {
let title = title.clone();
let data = data.clone();
async move {
let now = Utc::now().naive_utc();
let existing = shared_thread::Entity::find_by_id(id).one(&*tx).await?;
match existing {
Some(existing) => {
if existing.user_id != user_id {
Err(anyhow!("Cannot update shared thread owned by another user"))?;
}
let mut active: shared_thread::ActiveModel = existing.into();
active.title = ActiveValue::Set(title);
active.data = ActiveValue::Set(data);
active.updated_at = ActiveValue::Set(now);
active.update(&*tx).await?;
}
None => {
shared_thread::ActiveModel {
id: ActiveValue::Set(id),
user_id: ActiveValue::Set(user_id),
title: ActiveValue::Set(title),
data: ActiveValue::Set(data),
created_at: ActiveValue::Set(now),
updated_at: ActiveValue::Set(now),
}
.insert(&*tx)
.await?;
}
}
Ok(())
}
})
.await
}
pub async fn get_shared_thread(
&self,
share_id: SharedThreadId,
) -> Result<Option<(shared_thread::Model, String)>> {
self.transaction(|tx| async move {
let Some(thread) = shared_thread::Entity::find_by_id(share_id)
.one(&*tx)
.await?
else {
return Ok(None);
};
let user = user::Entity::find_by_id(thread.user_id).one(&*tx).await?;
let username = user
.map(|u| u.github_login)
.unwrap_or_else(|| "Unknown".to_string());
Ok(Some((thread, username)))
})
.await
}
}

View File

@@ -0,0 +1,176 @@
use chrono::NaiveDateTime;
use super::*;
impl Database {
/// Creates a new user.
pub async fn create_user(
&self,
email_address: &str,
name: Option<&str>,
admin: bool,
params: NewUserParams,
) -> Result<NewUserResult> {
self.transaction(|tx| async {
let tx = tx;
let user = user::Entity::insert(user::ActiveModel {
email_address: ActiveValue::set(Some(email_address.into())),
name: ActiveValue::set(name.map(|s| s.into())),
github_login: ActiveValue::set(params.github_login.clone()),
github_user_id: ActiveValue::set(params.github_user_id),
admin: ActiveValue::set(admin),
..Default::default()
})
.on_conflict(
OnConflict::column(user::Column::GithubUserId)
.update_columns([
user::Column::Admin,
user::Column::EmailAddress,
user::Column::GithubLogin,
])
.to_owned(),
)
.exec_with_returning(&*tx)
.await?;
Ok(NewUserResult { user_id: user.id })
})
.await
}
pub async fn update_or_create_user_by_github_account(
&self,
github_login: &str,
github_user_id: i32,
github_email: Option<&str>,
github_name: Option<&str>,
github_user_created_at: DateTimeUtc,
initial_channel_id: Option<ChannelId>,
) -> Result<user::Model> {
self.transaction(|tx| async move {
self.update_or_create_user_by_github_account_tx(
github_login,
github_user_id,
github_email,
github_name,
github_user_created_at.naive_utc(),
initial_channel_id,
&tx,
)
.await
})
.await
}
pub async fn update_or_create_user_by_github_account_tx(
&self,
github_login: &str,
github_user_id: i32,
github_email: Option<&str>,
github_name: Option<&str>,
github_user_created_at: NaiveDateTime,
initial_channel_id: Option<ChannelId>,
tx: &DatabaseTransaction,
) -> Result<user::Model> {
if let Some(existing_user) = self
.get_user_by_github_user_id_or_github_login(github_user_id, github_login, tx)
.await?
{
let mut existing_user = existing_user.into_active_model();
existing_user.github_login = ActiveValue::set(github_login.into());
existing_user.github_user_created_at = ActiveValue::set(Some(github_user_created_at));
if let Some(github_email) = github_email {
existing_user.email_address = ActiveValue::set(Some(github_email.into()));
}
if let Some(github_name) = github_name {
existing_user.name = ActiveValue::set(Some(github_name.into()));
}
Ok(existing_user.update(tx).await?)
} else {
let user = user::Entity::insert(user::ActiveModel {
email_address: ActiveValue::set(github_email.map(|email| email.into())),
name: ActiveValue::set(github_name.map(|name| name.into())),
github_login: ActiveValue::set(github_login.into()),
github_user_id: ActiveValue::set(github_user_id),
github_user_created_at: ActiveValue::set(Some(github_user_created_at)),
admin: ActiveValue::set(false),
..Default::default()
})
.exec_with_returning(tx)
.await?;
if let Some(channel_id) = initial_channel_id {
channel_member::Entity::insert(channel_member::ActiveModel {
id: ActiveValue::NotSet,
channel_id: ActiveValue::Set(channel_id),
user_id: ActiveValue::Set(user.id),
accepted: ActiveValue::Set(true),
role: ActiveValue::Set(ChannelRole::Guest),
})
.exec(tx)
.await?;
}
Ok(user)
}
}
/// Tries to retrieve a user, first by their GitHub user ID, and then by their GitHub login.
///
/// Returns `None` if a user is not found with this GitHub user ID or GitHub login.
pub async fn get_user_by_github_user_id_or_github_login(
&self,
github_user_id: i32,
github_login: &str,
tx: &DatabaseTransaction,
) -> Result<Option<user::Model>> {
if let Some(user_by_github_user_id) = user::Entity::find()
.filter(user::Column::GithubUserId.eq(github_user_id))
.one(tx)
.await?
{
return Ok(Some(user_by_github_user_id));
}
if let Some(user_by_github_login) = user::Entity::find()
.filter(user::Column::GithubLogin.eq(github_login))
.one(tx)
.await?
{
return Ok(Some(user_by_github_login));
}
Ok(None)
}
/// get_all_users returns the next page of users. To get more call again with
/// the same limit and the page incremented by 1.
pub async fn get_all_users(&self, page: u32, limit: u32) -> Result<Vec<user::Model>> {
self.transaction(|tx| async move {
Ok(user::Entity::find()
.order_by_asc(user::Column::GithubLogin)
.limit(limit as u64)
.offset(page as u64 * limit as u64)
.all(&*tx)
.await?)
})
.await
}
/// Sets "connected_once" on the user for analytics.
pub async fn set_user_connected_once(&self, id: UserId, connected_once: bool) -> Result<()> {
self.transaction(|tx| async move {
user::Entity::update_many()
.filter(user::Column::Id.eq(id))
.set(user::ActiveModel {
connected_once: ActiveValue::set(connected_once),
..Default::default()
})
.exec(&*tx)
.await?;
Ok(())
})
.await
}
}

View File

@@ -0,0 +1,29 @@
pub mod buffer;
pub mod buffer_operation;
pub mod buffer_snapshot;
pub mod channel;
pub mod channel_buffer_collaborator;
pub mod channel_chat_participant;
pub mod channel_member;
pub mod contact;
pub mod contributor;
pub mod extension;
pub mod extension_version;
pub mod follower;
pub mod language_server;
pub mod notification;
pub mod notification_kind;
pub mod observed_buffer_edits;
pub mod project;
pub mod project_collaborator;
pub mod project_repository;
pub mod project_repository_statuses;
pub mod room;
pub mod room_participant;
pub mod server;
pub mod shared_thread;
pub mod user;
pub mod worktree;
pub mod worktree_diagnostic_summary;
pub mod worktree_entry;
pub mod worktree_settings_file;

View File

@@ -0,0 +1,48 @@
use crate::db::{BufferId, ChannelId};
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "buffers")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: BufferId,
pub epoch: i32,
pub channel_id: ChannelId,
pub latest_operation_epoch: Option<i32>,
pub latest_operation_lamport_timestamp: Option<i32>,
pub latest_operation_replica_id: Option<i32>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::buffer_operation::Entity")]
Operations,
#[sea_orm(has_many = "super::buffer_snapshot::Entity")]
Snapshots,
#[sea_orm(
belongs_to = "super::channel::Entity",
from = "Column::ChannelId",
to = "super::channel::Column::Id"
)]
Channel,
}
impl Related<super::buffer_operation::Entity> for Entity {
fn to() -> RelationDef {
Relation::Operations.def()
}
}
impl Related<super::buffer_snapshot::Entity> for Entity {
fn to() -> RelationDef {
Relation::Snapshots.def()
}
}
impl Related<super::channel::Entity> for Entity {
fn to() -> RelationDef {
Relation::Channel.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,34 @@
use crate::db::BufferId;
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "buffer_operations")]
pub struct Model {
#[sea_orm(primary_key)]
pub buffer_id: BufferId,
#[sea_orm(primary_key)]
pub epoch: i32,
#[sea_orm(primary_key)]
pub lamport_timestamp: i32,
#[sea_orm(primary_key)]
pub replica_id: i32,
pub value: Vec<u8>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::buffer::Entity",
from = "Column::BufferId",
to = "super::buffer::Column::Id"
)]
Buffer,
}
impl Related<super::buffer::Entity> for Entity {
fn to() -> RelationDef {
Relation::Buffer.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,31 @@
use crate::db::BufferId;
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "buffer_snapshots")]
pub struct Model {
#[sea_orm(primary_key)]
pub buffer_id: BufferId,
#[sea_orm(primary_key)]
pub epoch: i32,
pub text: String,
pub operation_serialization_version: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::buffer::Entity",
from = "Column::BufferId",
to = "super::buffer::Column::Id"
)]
Buffer,
}
impl Related<super::buffer::Entity> for Entity {
fn to() -> RelationDef {
Relation::Buffer.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,95 @@
use crate::db::{ChannelId, ChannelVisibility};
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, Default, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "channels")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: ChannelId,
pub name: String,
pub visibility: ChannelVisibility,
pub parent_path: String,
pub requires_zed_cla: bool,
/// The order of this channel relative to its siblings within the same parent.
/// Lower values appear first. Channels are sorted by parent_path first, then by channel_order.
pub channel_order: i32,
}
impl Model {
pub fn parent_id(&self) -> Option<ChannelId> {
self.ancestors().last()
}
pub fn is_root(&self) -> bool {
self.parent_path.is_empty()
}
pub fn root_id(&self) -> ChannelId {
self.ancestors().next().unwrap_or(self.id)
}
pub fn ancestors(&self) -> impl Iterator<Item = ChannelId> + '_ {
self.parent_path
.trim_end_matches('/')
.split('/')
.filter_map(|id| Some(ChannelId::from_proto(id.parse().ok()?)))
}
pub fn ancestors_including_self(&self) -> impl Iterator<Item = ChannelId> + '_ {
self.ancestors().chain(Some(self.id))
}
pub fn path(&self) -> String {
format!("{}{}/", self.parent_path, self.id)
}
pub fn descendant_path_filter(&self) -> String {
format!("{}{}/%", self.parent_path, self.id)
}
}
impl ActiveModelBehavior for ActiveModel {}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_one = "super::room::Entity")]
Room,
#[sea_orm(has_one = "super::buffer::Entity")]
Buffer,
#[sea_orm(has_many = "super::channel_member::Entity")]
Member,
#[sea_orm(has_many = "super::channel_buffer_collaborator::Entity")]
BufferCollaborators,
#[sea_orm(has_many = "super::channel_chat_participant::Entity")]
ChatParticipants,
}
impl Related<super::channel_member::Entity> for Entity {
fn to() -> RelationDef {
Relation::Member.def()
}
}
impl Related<super::room::Entity> for Entity {
fn to() -> RelationDef {
Relation::Room.def()
}
}
impl Related<super::buffer::Entity> for Entity {
fn to() -> RelationDef {
Relation::Buffer.def()
}
}
impl Related<super::channel_buffer_collaborator::Entity> for Entity {
fn to() -> RelationDef {
Relation::BufferCollaborators.def()
}
}
impl Related<super::channel_chat_participant::Entity> for Entity {
fn to() -> RelationDef {
Relation::ChatParticipants.def()
}
}

View File

@@ -0,0 +1,43 @@
use crate::db::{ChannelBufferCollaboratorId, ChannelId, ReplicaId, ServerId, UserId};
use rpc::ConnectionId;
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "channel_buffer_collaborators")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: ChannelBufferCollaboratorId,
pub channel_id: ChannelId,
pub connection_id: i32,
pub connection_server_id: ServerId,
pub connection_lost: bool,
pub user_id: UserId,
pub replica_id: ReplicaId,
}
impl Model {
pub fn connection(&self) -> ConnectionId {
ConnectionId {
owner_id: self.connection_server_id.0 as u32,
id: self.connection_id as u32,
}
}
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::channel::Entity",
from = "Column::ChannelId",
to = "super::channel::Column::Id"
)]
Channel,
}
impl Related<super::channel::Entity> for Entity {
fn to() -> RelationDef {
Relation::Channel.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,41 @@
use crate::db::{ChannelChatParticipantId, ChannelId, ServerId, UserId};
use rpc::ConnectionId;
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "channel_chat_participants")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: ChannelChatParticipantId,
pub channel_id: ChannelId,
pub user_id: UserId,
pub connection_id: i32,
pub connection_server_id: ServerId,
}
impl Model {
pub fn connection(&self) -> ConnectionId {
ConnectionId {
owner_id: self.connection_server_id.0 as u32,
id: self.connection_id as u32,
}
}
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::channel::Entity",
from = "Column::ChannelId",
to = "super::channel::Column::Id"
)]
Channel,
}
impl Related<super::channel::Entity> for Entity {
fn to() -> RelationDef {
Relation::Channel.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,75 @@
use crate::db::{ChannelId, ChannelMemberId, ChannelRole, UserId, channel_member};
use rpc::proto;
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "channel_members")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: ChannelMemberId,
pub channel_id: ChannelId,
pub user_id: UserId,
pub accepted: bool,
pub role: ChannelRole,
}
impl From<Model> for proto::ChannelMember {
fn from(member: Model) -> Self {
Self {
role: member.role.into(),
user_id: member.user_id.to_proto(),
kind: if member.accepted {
proto::channel_member::Kind::Member
} else {
proto::channel_member::Kind::Invitee
}
.into(),
}
}
}
impl ActiveModelBehavior for ActiveModel {}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::channel::Entity",
from = "Column::ChannelId",
to = "super::channel::Column::Id"
)]
Channel,
#[sea_orm(
belongs_to = "super::user::Entity",
from = "Column::UserId",
to = "super::user::Column::Id"
)]
User,
}
impl Related<super::channel::Entity> for Entity {
fn to() -> RelationDef {
Relation::Channel.def()
}
}
impl Related<super::user::Entity> for Entity {
fn to() -> RelationDef {
Relation::User.def()
}
}
#[derive(Debug)]
pub struct UserToChannel;
impl Linked for UserToChannel {
type FromEntity = super::user::Entity;
type ToEntity = super::channel::Entity;
fn link(&self) -> Vec<RelationDef> {
vec![
channel_member::Relation::User.def().rev(),
channel_member::Relation::Channel.def(),
]
}
}

View File

@@ -0,0 +1,32 @@
use crate::db::{ContactId, UserId};
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, Default, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "contacts")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: ContactId,
pub user_id_a: UserId,
pub user_id_b: UserId,
pub a_to_b: bool,
pub should_notify: bool,
pub accepted: bool,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::room_participant::Entity",
from = "Column::UserIdA",
to = "super::room_participant::Column::UserId"
)]
UserARoomParticipant,
#[sea_orm(
belongs_to = "super::room_participant::Entity",
from = "Column::UserIdB",
to = "super::room_participant::Column::UserId"
)]
UserBRoomParticipant,
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,30 @@
use crate::db::UserId;
use sea_orm::entity::prelude::*;
use serde::Serialize;
/// A user who has signed the CLA.
#[derive(Clone, Debug, Default, PartialEq, Eq, DeriveEntityModel, Serialize)]
#[sea_orm(table_name = "contributors")]
pub struct Model {
#[sea_orm(primary_key)]
pub user_id: UserId,
pub signed_at: DateTime,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::user::Entity",
from = "Column::UserId",
to = "super::user::Column::Id"
)]
User,
}
impl ActiveModelBehavior for ActiveModel {}
impl Related<super::user::Entity> for Entity {
fn to() -> RelationDef {
Relation::User.def()
}
}

View File

@@ -0,0 +1,27 @@
use crate::db::ExtensionId;
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "extensions")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: ExtensionId,
pub external_id: String,
pub name: String,
pub latest_version: String,
pub total_download_count: i64,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_one = "super::extension_version::Entity")]
LatestVersion,
}
impl Related<super::extension_version::Entity> for Entity {
fn to() -> RelationDef {
Relation::LatestVersion.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,102 @@
use crate::db::ExtensionId;
use cloud_api_types::ExtensionProvides;
use collections::BTreeSet;
use sea_orm::entity::prelude::*;
use time::PrimitiveDateTime;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "extension_versions")]
pub struct Model {
#[sea_orm(primary_key)]
pub extension_id: ExtensionId,
#[sea_orm(primary_key)]
pub version: String,
pub published_at: PrimitiveDateTime,
pub authors: String,
pub repository: String,
pub description: String,
pub schema_version: i32,
pub wasm_api_version: Option<String>,
pub download_count: i64,
pub provides_themes: bool,
pub provides_icon_themes: bool,
pub provides_languages: bool,
pub provides_grammars: bool,
pub provides_language_servers: bool,
pub provides_context_servers: bool,
pub provides_agent_servers: bool,
pub provides_slash_commands: bool,
pub provides_indexed_docs_providers: bool,
pub provides_snippets: bool,
pub provides_debug_adapters: bool,
}
impl Model {
pub fn provides(&self) -> BTreeSet<ExtensionProvides> {
let mut provides = BTreeSet::default();
if self.provides_themes {
provides.insert(ExtensionProvides::Themes);
}
if self.provides_icon_themes {
provides.insert(ExtensionProvides::IconThemes);
}
if self.provides_languages {
provides.insert(ExtensionProvides::Languages);
}
if self.provides_grammars {
provides.insert(ExtensionProvides::Grammars);
}
if self.provides_language_servers {
provides.insert(ExtensionProvides::LanguageServers);
}
if self.provides_context_servers {
provides.insert(ExtensionProvides::ContextServers);
}
if self.provides_agent_servers {
provides.insert(ExtensionProvides::AgentServers);
}
if self.provides_slash_commands {
provides.insert(ExtensionProvides::SlashCommands);
}
if self.provides_indexed_docs_providers {
provides.insert(ExtensionProvides::IndexedDocsProviders);
}
if self.provides_snippets {
provides.insert(ExtensionProvides::Snippets);
}
if self.provides_debug_adapters {
provides.insert(ExtensionProvides::DebugAdapters);
}
provides
}
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::extension::Entity",
from = "Column::ExtensionId",
to = "super::extension::Column::Id"
on_condition = r#"super::extension::Column::LatestVersion.into_expr().eq(Column::Version.into_expr())"#
)]
Extension,
}
impl Related<super::extension::Entity> for Entity {
fn to() -> RelationDef {
Relation::Extension.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,50 @@
use crate::db::{FollowerId, ProjectId, RoomId, ServerId};
use rpc::ConnectionId;
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, Default, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "followers")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: FollowerId,
pub room_id: RoomId,
pub project_id: ProjectId,
pub leader_connection_server_id: ServerId,
pub leader_connection_id: i32,
pub follower_connection_server_id: ServerId,
pub follower_connection_id: i32,
}
impl Model {
pub fn leader_connection(&self) -> ConnectionId {
ConnectionId {
owner_id: self.leader_connection_server_id.0 as u32,
id: self.leader_connection_id as u32,
}
}
pub fn follower_connection(&self) -> ConnectionId {
ConnectionId {
owner_id: self.follower_connection_server_id.0 as u32,
id: self.follower_connection_id as u32,
}
}
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::room::Entity",
from = "Column::RoomId",
to = "super::room::Column::Id"
)]
Room,
}
impl Related<super::room::Entity> for Entity {
fn to() -> RelationDef {
Relation::Room.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,32 @@
use crate::db::ProjectId;
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "language_servers")]
pub struct Model {
#[sea_orm(primary_key)]
pub project_id: ProjectId,
#[sea_orm(primary_key)]
pub id: i64,
pub name: String,
pub capabilities: String,
pub worktree_id: Option<i64>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::project::Entity",
from = "Column::ProjectId",
to = "super::project::Column::Id"
)]
Project,
}
impl Related<super::project::Entity> for Entity {
fn to() -> RelationDef {
Relation::Project.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,29 @@
use crate::db::{NotificationId, NotificationKindId, UserId};
use sea_orm::entity::prelude::*;
use time::PrimitiveDateTime;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "notifications")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: NotificationId,
pub created_at: PrimitiveDateTime,
pub recipient_id: UserId,
pub kind: NotificationKindId,
pub entity_id: Option<i32>,
pub content: String,
pub is_read: bool,
pub response: Option<bool>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::user::Entity",
from = "Column::RecipientId",
to = "super::user::Column::Id"
)]
Recipient,
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,15 @@
use crate::db::NotificationKindId;
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "notification_kinds")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: NotificationKindId,
pub name: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,43 @@
use crate::db::{BufferId, UserId};
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "observed_buffer_edits")]
pub struct Model {
#[sea_orm(primary_key)]
pub user_id: UserId,
pub buffer_id: BufferId,
pub epoch: i32,
pub lamport_timestamp: i32,
pub replica_id: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::buffer::Entity",
from = "Column::BufferId",
to = "super::buffer::Column::Id"
)]
Buffer,
#[sea_orm(
belongs_to = "super::user::Entity",
from = "Column::UserId",
to = "super::user::Column::Id"
)]
User,
}
impl Related<super::buffer::Entity> for Entity {
fn to() -> RelationDef {
Relation::Buffer.def()
}
}
impl Related<super::user::Entity> for Entity {
fn to() -> RelationDef {
Relation::User.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,94 @@
use crate::db::{ProjectId, Result, RoomId, ServerId, UserId};
use anyhow::Context as _;
use rpc::ConnectionId;
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "projects")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: ProjectId,
pub room_id: Option<RoomId>,
pub host_user_id: Option<UserId>,
pub host_connection_id: Option<i32>,
pub host_connection_server_id: Option<ServerId>,
pub windows_paths: bool,
pub features: String,
}
impl Model {
pub fn host_connection(&self) -> Result<ConnectionId> {
let host_connection_server_id = self
.host_connection_server_id
.context("empty host_connection_server_id")?;
let host_connection_id = self
.host_connection_id
.context("empty host_connection_id")?;
Ok(ConnectionId {
owner_id: host_connection_server_id.0 as u32,
id: host_connection_id as u32,
})
}
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::user::Entity",
from = "Column::HostUserId",
to = "super::user::Column::Id"
)]
HostUser,
#[sea_orm(
belongs_to = "super::room::Entity",
from = "Column::RoomId",
to = "super::room::Column::Id"
)]
Room,
#[sea_orm(has_many = "super::worktree::Entity")]
Worktrees,
#[sea_orm(has_many = "super::project_repository::Entity")]
Repositories,
#[sea_orm(has_many = "super::project_collaborator::Entity")]
Collaborators,
#[sea_orm(has_many = "super::language_server::Entity")]
LanguageServers,
}
impl Related<super::user::Entity> for Entity {
fn to() -> RelationDef {
Relation::HostUser.def()
}
}
impl Related<super::room::Entity> for Entity {
fn to() -> RelationDef {
Relation::Room.def()
}
}
impl Related<super::worktree::Entity> for Entity {
fn to() -> RelationDef {
Relation::Worktrees.def()
}
}
impl Related<super::project_repository::Entity> for Entity {
fn to() -> RelationDef {
Relation::Repositories.def()
}
}
impl Related<super::project_collaborator::Entity> for Entity {
fn to() -> RelationDef {
Relation::Collaborators.def()
}
}
impl Related<super::language_server::Entity> for Entity {
fn to() -> RelationDef {
Relation::LanguageServers.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,45 @@
use crate::db::{ProjectCollaboratorId, ProjectId, ReplicaId, ServerId, UserId};
use rpc::ConnectionId;
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "project_collaborators")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: ProjectCollaboratorId,
pub project_id: ProjectId,
pub connection_id: i32,
pub connection_server_id: ServerId,
pub user_id: UserId,
pub replica_id: ReplicaId,
pub is_host: bool,
pub committer_name: Option<String>,
pub committer_email: Option<String>,
}
impl Model {
pub fn connection(&self) -> ConnectionId {
ConnectionId {
owner_id: self.connection_server_id.0 as u32,
id: self.connection_id as u32,
}
}
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::project::Entity",
from = "Column::ProjectId",
to = "super::project::Column::Id"
)]
Project,
}
impl Related<super::project::Entity> for Entity {
fn to() -> RelationDef {
Relation::Project.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,49 @@
use crate::db::ProjectId;
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "project_repositories")]
pub struct Model {
#[sea_orm(primary_key)]
pub project_id: ProjectId,
#[sea_orm(primary_key)]
pub id: i64,
pub abs_path: String,
pub legacy_worktree_id: Option<i64>,
// JSON array containing 1 or more integer project entry ids
pub entry_ids: String,
pub scan_id: i64,
pub is_deleted: bool,
// JSON array typed string
pub current_merge_conflicts: Option<String>,
// The suggested merge commit message
pub merge_message: Option<String>,
// A JSON object representing the current Branch values
pub branch_summary: Option<String>,
// A JSON object representing the current Head commit values
pub head_commit_details: Option<String>,
pub remote_upstream_url: Option<String>,
pub remote_origin_url: Option<String>,
pub repository_dir_abs_path: Option<String>,
pub common_dir_abs_path: Option<String>,
// JSON array of linked worktree objects
pub linked_worktrees: Option<String>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::project::Entity",
from = "Column::ProjectId",
to = "super::project::Column::Id"
)]
Project,
}
impl Related<super::project::Entity> for Entity {
fn to() -> RelationDef {
Relation::Project.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,38 @@
use crate::db::ProjectId;
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "project_repository_statuses")]
pub struct Model {
#[sea_orm(primary_key)]
pub project_id: ProjectId,
#[sea_orm(primary_key)]
pub repository_id: i64,
#[sea_orm(primary_key)]
pub repo_path: String,
/// Old single-code status field, no longer used but kept here to mirror the DB schema.
pub status: i64,
pub status_kind: StatusKind,
/// For unmerged entries, this is the `first_head` status. For tracked entries, this is the `index_status`.
pub first_status: Option<i32>,
/// For unmerged entries, this is the `second_head` status. For tracked entries, this is the `worktree_status`.
pub second_status: Option<i32>,
pub lines_added: Option<i32>,
pub lines_deleted: Option<i32>,
pub scan_id: i64,
pub is_deleted: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, EnumIter, DeriveActiveEnum)]
#[sea_orm(rs_type = "i32", db_type = "Integer")]
pub enum StatusKind {
Untracked = 0,
Ignored = 1,
Unmerged = 2,
Tracked = 3,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,53 @@
use crate::db::{ChannelId, RoomId};
use sea_orm::entity::prelude::*;
#[derive(Clone, Default, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "rooms")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: RoomId,
pub live_kit_room: String,
pub channel_id: Option<ChannelId>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::room_participant::Entity")]
RoomParticipant,
#[sea_orm(has_many = "super::project::Entity")]
Project,
#[sea_orm(has_many = "super::follower::Entity")]
Follower,
#[sea_orm(
belongs_to = "super::channel::Entity",
from = "Column::ChannelId",
to = "super::channel::Column::Id"
)]
Channel,
}
impl Related<super::room_participant::Entity> for Entity {
fn to() -> RelationDef {
Relation::RoomParticipant.def()
}
}
impl Related<super::project::Entity> for Entity {
fn to() -> RelationDef {
Relation::Project.def()
}
}
impl Related<super::follower::Entity> for Entity {
fn to() -> RelationDef {
Relation::Follower.def()
}
}
impl Related<super::channel::Entity> for Entity {
fn to() -> RelationDef {
Relation::Channel.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,62 @@
use crate::db::{ChannelRole, ProjectId, RoomId, RoomParticipantId, ServerId, UserId};
use rpc::ConnectionId;
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "room_participants")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: RoomParticipantId,
pub room_id: RoomId,
pub user_id: UserId,
pub answering_connection_id: Option<i32>,
pub answering_connection_server_id: Option<ServerId>,
pub answering_connection_lost: bool,
pub location_kind: Option<i32>,
pub location_project_id: Option<ProjectId>,
pub initial_project_id: Option<ProjectId>,
pub calling_user_id: UserId,
pub calling_connection_id: i32,
pub calling_connection_server_id: Option<ServerId>,
pub participant_index: Option<i32>,
pub role: Option<ChannelRole>,
}
impl Model {
pub fn answering_connection(&self) -> Option<ConnectionId> {
Some(ConnectionId {
owner_id: self.answering_connection_server_id?.0 as u32,
id: self.answering_connection_id? as u32,
})
}
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::user::Entity",
from = "Column::UserId",
to = "super::user::Column::Id"
)]
User,
#[sea_orm(
belongs_to = "super::room::Entity",
from = "Column::RoomId",
to = "super::room::Column::Id"
)]
Room,
}
impl Related<super::user::Entity> for Entity {
fn to() -> RelationDef {
Relation::User.def()
}
}
impl Related<super::room::Entity> for Entity {
fn to() -> RelationDef {
Relation::Room.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,15 @@
use crate::db::ServerId;
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "servers")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: ServerId,
pub environment: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,32 @@
use crate::db::{SharedThreadId, UserId};
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "shared_threads")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: SharedThreadId,
pub user_id: UserId,
pub title: String,
pub data: Vec<u8>,
pub created_at: DateTime,
pub updated_at: DateTime,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::user::Entity",
from = "Column::UserId",
to = "super::user::Column::Id"
)]
User,
}
impl Related<super::user::Entity> for Entity {
fn to() -> RelationDef {
Relation::User.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,50 @@
use crate::db::UserId;
use chrono::NaiveDateTime;
use sea_orm::entity::prelude::*;
use serde::Serialize;
/// A user model.
#[derive(Clone, Debug, Default, PartialEq, Eq, DeriveEntityModel, Serialize)]
#[sea_orm(table_name = "users")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: UserId,
pub github_login: String,
pub github_user_id: i32,
pub github_user_created_at: Option<NaiveDateTime>,
pub email_address: Option<String>,
pub name: Option<String>,
pub admin: bool,
pub connected_once: bool,
pub created_at: NaiveDateTime,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_one = "super::room_participant::Entity")]
RoomParticipant,
#[sea_orm(has_many = "super::project::Entity")]
HostedProjects,
#[sea_orm(has_many = "super::channel_member::Entity")]
ChannelMemberships,
}
impl Related<super::room_participant::Entity> for Entity {
fn to() -> RelationDef {
Relation::RoomParticipant.def()
}
}
impl Related<super::project::Entity> for Entity {
fn to() -> RelationDef {
Relation::HostedProjects.def()
}
}
impl Related<super::channel_member::Entity> for Entity {
fn to() -> RelationDef {
Relation::ChannelMemberships.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,37 @@
use crate::db::ProjectId;
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "worktrees")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i64,
#[sea_orm(primary_key)]
pub project_id: ProjectId,
pub abs_path: String,
pub root_name: String,
pub visible: bool,
/// The last scan for which we've observed entries. It may be in progress.
pub scan_id: i64,
/// The last scan that fully completed.
pub completed_scan_id: i64,
pub root_repo_common_dir: Option<String>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::project::Entity",
from = "Column::ProjectId",
to = "super::project::Column::Id"
)]
Project,
}
impl Related<super::project::Entity> for Entity {
fn to() -> RelationDef {
Relation::Project.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,21 @@
use crate::db::ProjectId;
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "worktree_diagnostic_summaries")]
pub struct Model {
#[sea_orm(primary_key)]
pub project_id: ProjectId,
#[sea_orm(primary_key)]
pub worktree_id: i64,
#[sea_orm(primary_key)]
pub path: String,
pub language_server_id: i64,
pub error_count: i32,
pub warning_count: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,31 @@
use crate::db::ProjectId;
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "worktree_entries")]
pub struct Model {
#[sea_orm(primary_key)]
pub project_id: ProjectId,
#[sea_orm(primary_key)]
pub worktree_id: i64,
#[sea_orm(primary_key)]
pub id: i64,
pub is_dir: bool,
pub path: String,
pub inode: i64,
pub mtime_seconds: i64,
pub mtime_nanos: i32,
pub git_status: Option<i64>,
pub is_ignored: bool,
pub is_external: bool,
pub is_deleted: bool,
pub is_hidden: bool,
pub scan_id: i64,
pub is_fifo: bool,
pub canonical_path: Option<String>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,38 @@
use crate::db::ProjectId;
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "worktree_settings_files")]
pub struct Model {
#[sea_orm(primary_key)]
pub project_id: ProjectId,
#[sea_orm(primary_key)]
pub worktree_id: i64,
#[sea_orm(primary_key)]
pub path: String,
pub content: String,
pub kind: LocalSettingsKind,
pub outside_worktree: bool,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
#[derive(
Copy, Clone, Debug, PartialEq, Eq, EnumIter, DeriveActiveEnum, Default, Hash, serde::Serialize,
)]
#[sea_orm(rs_type = "String", db_type = "String(StringLen::None)")]
#[serde(rename_all = "snake_case")]
pub enum LocalSettingsKind {
#[default]
#[sea_orm(string_value = "settings")]
Settings,
#[sea_orm(string_value = "tasks")]
Tasks,
#[sea_orm(string_value = "editorconfig")]
Editorconfig,
#[sea_orm(string_value = "debug")]
Debug,
}

View File

@@ -0,0 +1,3 @@
mod user;
pub use user::*;

View File

@@ -0,0 +1,11 @@
use crate::db::UserId;
#[derive(Debug, Clone)]
pub struct User {
pub id: UserId,
pub github_login: String,
pub avatar_url: String,
pub name: Option<String>,
pub admin: bool,
pub connected_once: bool,
}

41
crates/collab/src/env.rs Normal file
View File

@@ -0,0 +1,41 @@
use anyhow::{Context as _, Result};
use std::fs;
use std::path::Path;
pub fn get_dotenv_vars(current_dir: impl AsRef<Path>) -> Result<Vec<(String, String)>> {
let current_dir = current_dir.as_ref();
let mut vars = Vec::new();
let env_content =
fs::read_to_string(current_dir.join(".env.toml")).context("no .env.toml file found")?;
add_vars(env_content, &mut vars)?;
if let Ok(secret_content) = fs::read_to_string(current_dir.join(".env.secret.toml")) {
add_vars(secret_content, &mut vars)?;
}
Ok(vars)
}
pub fn load_dotenv() -> Result<()> {
for (key, value) in get_dotenv_vars("./crates/collab")? {
unsafe { std::env::set_var(key, value) };
}
Ok(())
}
fn add_vars(env_content: String, vars: &mut Vec<(String, String)>) -> Result<()> {
let env: toml::map::Map<String, toml::Value> = toml::de::from_str(&env_content)?;
for (key, value) in env {
let value = match value {
toml::Value::String(value) => value,
toml::Value::Integer(value) => value.to_string(),
toml::Value::Float(value) => value.to_string(),
toml::Value::Boolean(value) => value.to_string(),
_ => panic!("unsupported TOML value in .env.toml for key {}", key),
};
vars.push((key, value));
}
Ok(())
}

View File

@@ -0,0 +1,29 @@
// Allow tide Results to accept context like other Results do when
// using anyhow.
pub trait TideResultExt {
fn context<C>(self, cx: C) -> Self
where
C: std::fmt::Display + Send + Sync + 'static;
fn with_context<C, F>(self, f: F) -> Self
where
C: std::fmt::Display + Send + Sync + 'static,
F: FnOnce() -> C;
}
impl<T> TideResultExt for tide::Result<T> {
fn context<C>(self, cx: C) -> Self
where
C: std::fmt::Display + Send + Sync + 'static,
{
self.map_err(|e| tide::Error::new(e.status(), e.into_inner().context(cx)))
}
fn with_context<C, F>(self, f: F) -> Self
where
C: std::fmt::Display + Send + Sync + 'static,
F: FnOnce() -> C,
{
self.map_err(|e| tide::Error::new(e.status(), e.into_inner().context(f())))
}
}

View File

@@ -0,0 +1,39 @@
use std::{future::Future, time::Duration};
#[cfg(feature = "test-support")]
use gpui::BackgroundExecutor;
#[derive(Clone)]
pub enum Executor {
Production,
#[cfg(feature = "test-support")]
Deterministic(BackgroundExecutor),
}
impl Executor {
pub fn spawn_detached<F>(&self, future: F)
where
F: 'static + Send + Future<Output = ()>,
{
match self {
Executor::Production => {
tokio::spawn(future);
}
#[cfg(feature = "test-support")]
Executor::Deterministic(background) => {
background.spawn(future).detach();
}
}
}
pub fn sleep(&self, duration: Duration) -> impl Future<Output = ()> + use<> {
let this = self.clone();
async move {
match this {
Executor::Production => tokio::time::sleep(duration).await,
#[cfg(feature = "test-support")]
Executor::Deterministic(background) => background.timer(duration).await,
}
}
}
}

339
crates/collab/src/lib.rs Normal file
View File

@@ -0,0 +1,339 @@
pub mod api;
pub mod auth;
pub mod db;
pub mod entities;
pub mod env;
pub mod executor;
pub mod rpc;
pub mod seed;
pub mod services;
use anyhow::Context as _;
use aws_config::{BehaviorVersion, Region};
use axum::{
http::{HeaderMap, StatusCode},
response::IntoResponse,
};
use db::Database;
use executor::Executor;
use serde::Deserialize;
use std::{path::PathBuf, sync::Arc};
use util::ResultExt;
use crate::services::{CloudUserService, UserService};
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
pub const REVISION: Option<&'static str> = option_env!("GITHUB_SHA");
pub type Result<T, E = Error> = std::result::Result<T, E>;
pub enum Error {
Http(StatusCode, String, HeaderMap),
Database(sea_orm::error::DbErr),
Internal(anyhow::Error),
}
impl From<anyhow::Error> for Error {
fn from(error: anyhow::Error) -> Self {
Self::Internal(error)
}
}
impl From<sea_orm::error::DbErr> for Error {
fn from(error: sea_orm::error::DbErr) -> Self {
Self::Database(error)
}
}
impl From<axum::Error> for Error {
fn from(error: axum::Error) -> Self {
Self::Internal(error.into())
}
}
impl From<axum::http::Error> for Error {
fn from(error: axum::http::Error) -> Self {
Self::Internal(error.into())
}
}
impl From<serde_json::Error> for Error {
fn from(error: serde_json::Error) -> Self {
Self::Internal(error.into())
}
}
impl Error {
fn http(code: StatusCode, message: String) -> Self {
Self::Http(code, message, HeaderMap::default())
}
}
impl IntoResponse for Error {
fn into_response(self) -> axum::response::Response {
match self {
Error::Http(code, message, headers) => {
log::error!("HTTP error {}: {}", code, &message);
(code, headers, message).into_response()
}
Error::Database(error) => {
log::error!(
"HTTP error {}: {:?}",
StatusCode::INTERNAL_SERVER_ERROR,
&error
);
(StatusCode::INTERNAL_SERVER_ERROR, format!("{}", &error)).into_response()
}
Error::Internal(error) => {
log::error!(
"HTTP error {}: {:?}",
StatusCode::INTERNAL_SERVER_ERROR,
&error
);
(StatusCode::INTERNAL_SERVER_ERROR, format!("{}", &error)).into_response()
}
}
}
}
impl std::fmt::Debug for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Error::Http(code, message, _headers) => (code, message).fmt(f),
Error::Database(error) => error.fmt(f),
Error::Internal(error) => error.fmt(f),
}
}
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Error::Http(code, message, _) => write!(f, "{code}: {message}"),
Error::Database(error) => error.fmt(f),
Error::Internal(error) => error.fmt(f),
}
}
}
impl std::error::Error for Error {}
#[derive(Clone, Deserialize)]
pub struct Config {
pub http_port: u16,
pub database_url: String,
pub seed_path: Option<PathBuf>,
pub database_max_connections: u32,
pub livekit_server: Option<String>,
pub livekit_key: Option<String>,
pub livekit_secret: Option<String>,
pub rust_log: Option<String>,
pub log_json: Option<bool>,
pub blob_store_url: Option<String>,
pub blob_store_region: Option<String>,
pub blob_store_access_key: Option<String>,
pub blob_store_secret_key: Option<String>,
pub blob_store_bucket: Option<String>,
pub kinesis_region: Option<String>,
pub kinesis_stream: Option<String>,
pub kinesis_access_key: Option<String>,
pub kinesis_secret_key: Option<String>,
pub zed_environment: Arc<str>,
pub zed_cloud_internal_api_key: String,
pub zed_client_checksum_seed: Option<String>,
}
impl Config {
pub fn is_development(&self) -> bool {
self.zed_environment == "development".into()
}
/// Returns the base `zed.dev` URL.
pub fn zed_dot_dev_url(&self) -> &str {
match self.zed_environment.as_ref() {
"development" => "http://localhost:3000",
"staging" => "https://staging.zed.dev",
_ => "https://zed.dev",
}
}
/// Returns the base Zed Cloud URL.
pub fn zed_cloud_url(&self) -> &str {
match self.zed_environment.as_ref() {
"development" => "http://localhost:8787",
_ => "https://cloud.zed.dev",
}
}
#[cfg(feature = "test-support")]
pub fn test() -> Self {
Self {
http_port: 0,
database_url: "".into(),
database_max_connections: 0,
livekit_server: None,
livekit_key: None,
livekit_secret: None,
rust_log: None,
log_json: None,
zed_environment: "test".into(),
zed_cloud_internal_api_key: "test-internal-api-key".into(),
blob_store_url: None,
blob_store_region: None,
blob_store_access_key: None,
blob_store_secret_key: None,
blob_store_bucket: None,
zed_client_checksum_seed: None,
seed_path: None,
kinesis_region: None,
kinesis_access_key: None,
kinesis_secret_key: None,
kinesis_stream: None,
}
}
}
/// The service mode that collab should run in.
#[derive(Debug, PartialEq, Eq, Clone, Copy, strum::Display)]
#[strum(serialize_all = "snake_case")]
pub enum ServiceMode {
Api,
Collab,
All,
}
impl ServiceMode {
pub fn is_collab(&self) -> bool {
matches!(self, Self::Collab | Self::All)
}
pub fn is_api(&self) -> bool {
matches!(self, Self::Api | Self::All)
}
}
pub struct AppState {
pub db: Arc<Database>,
pub http_client: Option<reqwest::Client>,
pub livekit_client: Option<Arc<dyn livekit_api::Client>>,
pub blob_store_client: Option<aws_sdk_s3::Client>,
pub executor: Executor,
pub kinesis_client: Option<::aws_sdk_kinesis::Client>,
pub user_service: Arc<dyn UserService>,
pub config: Config,
}
impl AppState {
pub async fn new(config: Config, executor: Executor) -> Result<Arc<Self>> {
let mut db_options = db::ConnectOptions::new(config.database_url.clone());
db_options.max_connections(config.database_max_connections);
let mut db = Database::new(db_options).await?;
db.initialize_notification_kinds().await?;
let livekit_client = if let Some(((server, key), secret)) = config
.livekit_server
.as_ref()
.zip(config.livekit_key.as_ref())
.zip(config.livekit_secret.as_ref())
{
Some(Arc::new(livekit_api::LiveKitClient::new(
server.clone(),
key.clone(),
secret.clone(),
)) as Arc<dyn livekit_api::Client>)
} else {
None
};
let user_agent = format!("Collab/{VERSION} ({})", REVISION.unwrap_or("unknown"));
let http_client = reqwest::Client::builder()
.user_agent(user_agent)
.build()
.context("failed to construct HTTP client")?;
let db = Arc::new(db);
let this = Self {
db: db.clone(),
http_client: Some(http_client.clone()),
livekit_client,
blob_store_client: build_blob_store_client(&config).await.log_err(),
executor,
kinesis_client: if config.kinesis_access_key.is_some() {
build_kinesis_client(&config).await.log_err()
} else {
None
},
user_service: Arc::new(CloudUserService::new(
http_client,
config.zed_cloud_url().to_string(),
config.zed_cloud_internal_api_key.clone(),
)),
config,
};
Ok(Arc::new(this))
}
}
async fn build_blob_store_client(config: &Config) -> anyhow::Result<aws_sdk_s3::Client> {
let keys = aws_sdk_s3::config::Credentials::new(
config
.blob_store_access_key
.clone()
.context("missing blob_store_access_key")?,
config
.blob_store_secret_key
.clone()
.context("missing blob_store_secret_key")?,
None,
None,
"env",
);
let s3_config = aws_config::defaults(BehaviorVersion::latest())
.endpoint_url(
config
.blob_store_url
.as_ref()
.context("missing blob_store_url")?,
)
.region(Region::new(
config
.blob_store_region
.clone()
.context("missing blob_store_region")?,
))
.credentials_provider(keys)
.load()
.await;
Ok(aws_sdk_s3::Client::new(&s3_config))
}
async fn build_kinesis_client(config: &Config) -> anyhow::Result<aws_sdk_kinesis::Client> {
let keys = aws_sdk_s3::config::Credentials::new(
config
.kinesis_access_key
.clone()
.context("missing kinesis_access_key")?,
config
.kinesis_secret_key
.clone()
.context("missing kinesis_secret_key")?,
None,
None,
"env",
);
let kinesis_config = aws_config::defaults(BehaviorVersion::latest())
.region(Region::new(
config
.kinesis_region
.clone()
.context("missing kinesis_region")?,
))
.credentials_provider(keys)
.load()
.await;
Ok(aws_sdk_kinesis::Client::new(&kinesis_config))
}

268
crates/collab/src/main.rs Normal file
View File

@@ -0,0 +1,268 @@
use anyhow::anyhow;
use axum::headers::HeaderMapExt;
use axum::{
Extension, Router,
extract::MatchedPath,
http::{Request, Response},
routing::get,
};
use collab::api::CloudflareIpCountryHeader;
use collab::{
AppState, Config, Result, api::fetch_extensions_from_blob_store_periodically, db, env,
executor::Executor,
};
use collab::{REVISION, ServiceMode, VERSION};
use db::Database;
use std::{
env::args,
net::{SocketAddr, TcpListener},
sync::Arc,
time::Duration,
};
#[cfg(unix)]
use tokio::signal::unix::SignalKind;
use tower_http::trace::TraceLayer;
use tracing_subscriber::{
Layer, filter::EnvFilter, fmt::format::JsonFields, util::SubscriberInitExt,
};
use util::ResultExt as _;
#[expect(clippy::result_large_err)]
#[tokio::main]
async fn main() -> Result<()> {
if let Err(error) = env::load_dotenv() {
eprintln!(
"error loading .env.toml (this is expected in production): {}",
error
);
}
let mut args = args().skip(1);
match args.next().as_deref() {
Some("version") => {
println!("collab v{} ({})", VERSION, REVISION.unwrap_or("unknown"));
}
Some("seed") => {
let config = envy::from_env::<Config>().expect("error loading config");
let db_options = db::ConnectOptions::new(config.database_url.clone());
let mut db = Database::new(db_options).await?;
db.initialize_notification_kinds().await?;
collab::seed::seed(&config, &db, false).await?;
}
Some("serve") => {
let mode = match args.next().as_deref() {
Some("collab") => ServiceMode::Collab,
Some("api") => ServiceMode::Api,
Some("all") => ServiceMode::All,
_ => {
return Err(anyhow!(
"usage: collab <version | seed | serve <api|collab|all>>"
))?;
}
};
let config = envy::from_env::<Config>().expect("error loading config");
init_tracing(&config);
init_panic_hook();
let mut app = Router::new()
.route("/", get(handle_root))
.route("/healthz", get(handle_liveness_probe))
.layer(Extension(mode));
let listener = TcpListener::bind(format!("0.0.0.0:{}", config.http_port))
.expect("failed to bind TCP listener");
let mut on_shutdown = None;
if mode.is_collab() || mode.is_api() {
setup_app_database(&config).await?;
let state = AppState::new(config, Executor::Production).await?;
if mode.is_collab() {
let epoch = state
.db
.create_server(&state.config.zed_environment)
.await?;
let rpc_server = collab::rpc::Server::new(epoch, state.clone());
rpc_server.start().await?;
app = app.merge(collab::rpc::routes(rpc_server.clone()));
on_shutdown = Some(Box::new(move || rpc_server.teardown()));
}
if mode.is_api() {
fetch_extensions_from_blob_store_periodically(state.clone());
app = app
.merge(collab::api::events::router())
.merge(collab::api::extensions::router())
}
app = app.layer(Extension(state.clone()));
}
app = app.layer(
TraceLayer::new_for_http()
.make_span_with(|request: &Request<_>| {
let matched_path = request
.extensions()
.get::<MatchedPath>()
.map(MatchedPath::as_str);
let geoip_country_code = request
.headers()
.typed_get::<CloudflareIpCountryHeader>()
.map(|header| header.to_string());
tracing::info_span!(
"http_request",
method = ?request.method(),
matched_path,
geoip_country_code,
user_id = tracing::field::Empty,
login = tracing::field::Empty,
authn.jti = tracing::field::Empty,
is_staff = tracing::field::Empty
)
})
.on_response(
|response: &Response<_>, latency: Duration, _: &tracing::Span| {
let duration_ms = latency.as_micros() as f64 / 1000.;
tracing::info!(
duration_ms,
status = response.status().as_u16(),
"finished processing request"
);
},
),
);
#[cfg(unix)]
let signal = async move {
let mut sigterm = tokio::signal::unix::signal(SignalKind::terminate())
.expect("failed to listen for interrupt signal");
let mut sigint = tokio::signal::unix::signal(SignalKind::interrupt())
.expect("failed to listen for interrupt signal");
let sigterm = sigterm.recv();
let sigint = sigint.recv();
futures::pin_mut!(sigterm, sigint);
futures::future::select(sigterm, sigint).await;
};
#[cfg(windows)]
let signal = async move {
// todo(windows):
// `ctrl_close` does not work well, because tokio's signal handler always returns soon,
// but system terminates the application soon after returning CTRL+CLOSE handler.
// So we should implement blocking handler to treat CTRL+CLOSE signal.
let mut ctrl_break = tokio::signal::windows::ctrl_break()
.expect("failed to listen for interrupt signal");
let mut ctrl_c = tokio::signal::windows::ctrl_c()
.expect("failed to listen for interrupt signal");
let ctrl_break = ctrl_break.recv();
let ctrl_c = ctrl_c.recv();
futures::pin_mut!(ctrl_break, ctrl_c);
futures::future::select(ctrl_break, ctrl_c).await;
};
axum::Server::from_tcp(listener)
.map_err(|e| anyhow!(e))?
.serve(app.into_make_service_with_connect_info::<SocketAddr>())
.with_graceful_shutdown(async move {
signal.await;
tracing::info!("Received interrupt signal");
if let Some(on_shutdown) = on_shutdown {
on_shutdown();
}
})
.await
.map_err(|e| anyhow!(e))?;
}
_ => {
Err(anyhow!(
"usage: collab <version | migrate | seed | serve <api|collab|llm|all>>"
))?;
}
}
Ok(())
}
async fn setup_app_database(config: &Config) -> Result<()> {
let db_options = db::ConnectOptions::new(config.database_url.clone());
let mut db = Database::new(db_options).await?;
db.initialize_notification_kinds().await?;
if config.seed_path.is_some() {
collab::seed::seed(config, &db, false).await?;
}
Ok(())
}
async fn handle_root(Extension(mode): Extension<ServiceMode>) -> String {
format!("zed:{mode} v{VERSION} ({})", REVISION.unwrap_or("unknown"))
}
async fn handle_liveness_probe(app_state: Option<Extension<Arc<AppState>>>) -> Result<String> {
if let Some(state) = app_state {
state.db.get_all_users(0, 1).await?;
}
Ok("ok".to_string())
}
pub fn init_tracing(config: &Config) -> Option<()> {
use std::str::FromStr;
use tracing_subscriber::layer::SubscriberExt;
let filter = EnvFilter::from_str(config.rust_log.as_deref()?).log_err()?;
tracing_subscriber::registry()
.with(if config.log_json.unwrap_or(false) {
Box::new(
tracing_subscriber::fmt::layer()
.fmt_fields(JsonFields::default())
.event_format(
tracing_subscriber::fmt::format()
.json()
.flatten_event(true)
.with_span_list(false),
)
.with_filter(filter),
) as Box<dyn Layer<_> + Send + Sync>
} else {
Box::new(
tracing_subscriber::fmt::layer()
.event_format(tracing_subscriber::fmt::format().pretty())
.with_filter(filter),
)
})
.init();
None
}
fn init_panic_hook() {
std::panic::set_hook(Box::new(move |panic_info| {
let panic_message = match panic_info.payload().downcast_ref::<&'static str>() {
Some(message) => *message,
None => match panic_info.payload().downcast_ref::<String>() {
Some(message) => message.as_str(),
None => "Box<Any>",
},
};
let backtrace = std::backtrace::Backtrace::force_capture();
let location = panic_info
.location()
.map(|loc| format!("{}:{}", loc.file(), loc.line()));
tracing::error!(panic = true, ?location, %panic_message, %backtrace, "Server Panic");
}));
}

4234
crates/collab/src/rpc.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,253 @@
use crate::db::{ChannelId, ChannelRole, UserId};
use anyhow::{Context as _, Result};
use collections::{BTreeMap, HashMap, HashSet};
use rpc::ConnectionId;
use semver::Version;
use serde::Serialize;
use std::fmt;
use tracing::instrument;
#[derive(Default, Serialize)]
pub struct ConnectionPool {
connections: BTreeMap<ConnectionId, Connection>,
connected_users: BTreeMap<UserId, ConnectedPrincipal>,
channels: ChannelPool,
}
#[derive(Default, Serialize)]
struct ConnectedPrincipal {
connection_ids: HashSet<ConnectionId>,
}
#[derive(Clone, Debug, Serialize, PartialOrd, PartialEq, Eq, Ord)]
pub struct ZedVersion(pub Version);
impl fmt::Display for ZedVersion {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl ZedVersion {
pub fn can_collaborate(&self) -> bool {
// v0.204.1 was the first version after the auto-update bug.
// We reject any clients older than that to hope we can persuade them to upgrade.
if self.0 < Version::new(0, 204, 1) {
return false;
}
true
}
}
#[derive(Serialize)]
pub struct Connection {
pub user_id: UserId,
pub admin: bool,
pub zed_version: ZedVersion,
}
impl ConnectionPool {
pub fn reset(&mut self) {
self.connections.clear();
self.connected_users.clear();
self.channels.clear();
}
pub fn connection(&mut self, connection_id: ConnectionId) -> Option<&Connection> {
self.connections.get(&connection_id)
}
#[instrument(skip(self))]
pub fn add_connection(
&mut self,
connection_id: ConnectionId,
user_id: UserId,
admin: bool,
zed_version: ZedVersion,
) {
self.connections.insert(
connection_id,
Connection {
user_id,
admin,
zed_version,
},
);
let connected_user = self.connected_users.entry(user_id).or_default();
connected_user.connection_ids.insert(connection_id);
}
#[instrument(skip(self))]
pub fn remove_connection(&mut self, connection_id: ConnectionId) -> Result<()> {
let connection = self
.connections
.get_mut(&connection_id)
.context("no such connection")?;
let user_id = connection.user_id;
let connected_user = self.connected_users.get_mut(&user_id).unwrap();
connected_user.connection_ids.remove(&connection_id);
if connected_user.connection_ids.is_empty() {
self.connected_users.remove(&user_id);
self.channels.remove_user(&user_id);
};
self.connections.remove(&connection_id).unwrap();
Ok(())
}
pub fn connections(&self) -> impl Iterator<Item = &Connection> {
self.connections.values()
}
pub fn user_connections(&self, user_id: UserId) -> impl Iterator<Item = &Connection> + '_ {
self.connected_users
.get(&user_id)
.into_iter()
.flat_map(|state| {
state
.connection_ids
.iter()
.flat_map(|cid| self.connections.get(cid))
})
}
pub fn user_connection_ids(&self, user_id: UserId) -> impl Iterator<Item = ConnectionId> + '_ {
self.connected_users
.get(&user_id)
.into_iter()
.flat_map(|state| &state.connection_ids)
.copied()
}
pub fn channel_user_ids(
&self,
channel_id: ChannelId,
) -> impl Iterator<Item = (UserId, ChannelRole)> + '_ {
self.channels.users_to_notify(channel_id)
}
pub fn channel_connection_ids(
&self,
channel_id: ChannelId,
) -> impl Iterator<Item = (ConnectionId, ChannelRole)> + '_ {
self.channels
.users_to_notify(channel_id)
.flat_map(|(user_id, role)| {
self.user_connection_ids(user_id)
.map(move |connection_id| (connection_id, role))
})
}
pub fn subscribe_to_channel(
&mut self,
user_id: UserId,
channel_id: ChannelId,
role: ChannelRole,
) {
self.channels.subscribe(user_id, channel_id, role);
}
pub fn unsubscribe_from_channel(&mut self, user_id: &UserId, channel_id: &ChannelId) {
self.channels.unsubscribe(user_id, channel_id);
}
pub fn is_user_online(&self, user_id: UserId) -> bool {
!self
.connected_users
.get(&user_id)
.unwrap_or(&Default::default())
.connection_ids
.is_empty()
}
#[cfg(feature = "test-support")]
pub fn check_invariants(&self) {
for (connection_id, connection) in &self.connections {
assert!(
self.connected_users
.get(&connection.user_id)
.unwrap()
.connection_ids
.contains(connection_id)
);
}
for (user_id, state) in &self.connected_users {
for connection_id in &state.connection_ids {
assert_eq!(
self.connections.get(connection_id).unwrap().user_id,
*user_id
);
}
}
}
}
#[derive(Default, Serialize)]
pub struct ChannelPool {
by_user: HashMap<UserId, HashMap<ChannelId, ChannelRole>>,
by_channel: HashMap<ChannelId, HashSet<UserId>>,
}
impl ChannelPool {
pub fn clear(&mut self) {
self.by_user.clear();
self.by_channel.clear();
}
pub fn subscribe(&mut self, user_id: UserId, channel_id: ChannelId, role: ChannelRole) {
self.by_user
.entry(user_id)
.or_default()
.insert(channel_id, role);
self.by_channel
.entry(channel_id)
.or_default()
.insert(user_id);
}
pub fn unsubscribe(&mut self, user_id: &UserId, channel_id: &ChannelId) {
if let Some(channels) = self.by_user.get_mut(user_id) {
channels.remove(channel_id);
if channels.is_empty() {
self.by_user.remove(user_id);
}
}
if let Some(users) = self.by_channel.get_mut(channel_id) {
users.remove(user_id);
if users.is_empty() {
self.by_channel.remove(channel_id);
}
}
}
pub fn remove_user(&mut self, user_id: &UserId) {
if let Some(channels) = self.by_user.remove(user_id) {
for channel_id in channels.keys() {
self.unsubscribe(user_id, channel_id)
}
}
}
pub fn users_to_notify(
&self,
channel_id: ChannelId,
) -> impl '_ + Iterator<Item = (UserId, ChannelRole)> {
self.by_channel
.get(&channel_id)
.into_iter()
.flat_map(move |users| {
users.iter().flat_map(move |user_id| {
Some((
*user_id,
self.by_user
.get(user_id)
.and_then(|channels| channels.get(&channel_id))
.copied()?,
))
})
})
}
}

136
crates/collab/src/seed.rs Normal file
View File

@@ -0,0 +1,136 @@
use crate::db::{self, ChannelRole, NewUserParams};
use anyhow::Context as _;
use chrono::{DateTime, Utc};
use db::Database;
use serde::{Deserialize, de::DeserializeOwned};
use std::{fs, path::Path};
use crate::Config;
/// A GitHub user.
///
/// This representation corresponds to the entries in the `seed/github_users.json` file.
#[derive(Debug, Deserialize)]
struct GithubUser {
id: i32,
login: String,
email: Option<String>,
name: Option<String>,
created_at: DateTime<Utc>,
}
#[derive(Deserialize)]
struct SeedConfig {
/// Which users to create as admins.
admins: Vec<String>,
/// Which channels to create (all admins are invited to all channels).
channels: Vec<String>,
}
pub async fn seed(config: &Config, db: &Database, force: bool) -> anyhow::Result<()> {
let client = reqwest::Client::new();
if !db.get_all_users(0, 1).await?.is_empty() && !force {
return Ok(());
}
let seed_path = config
.seed_path
.as_ref()
.context("called seed with no SEED_PATH")?;
let seed_config = load_admins(seed_path)
.context(format!("failed to load {}", seed_path.to_string_lossy()))?;
let mut first_user = None;
let mut others = vec![];
for admin_login in seed_config.admins {
let user = fetch_github::<GithubUser>(
&client,
&format!("https://api.github.com/users/{admin_login}"),
)
.await;
let user = db
.create_user(
&user.email.unwrap_or(format!("{admin_login}@example.com")),
user.name.as_deref(),
true,
NewUserParams {
github_login: user.login,
github_user_id: user.id,
},
)
.await
.context("failed to create admin user")?;
if first_user.is_none() {
first_user = Some(user.user_id);
} else {
others.push(user.user_id)
}
}
for channel in seed_config.channels {
let (channel, _) = db
.create_channel(&channel, None, first_user.unwrap())
.await
.context("failed to create channel")?;
for user_id in &others {
db.invite_channel_member(
channel.id,
*user_id,
first_user.unwrap(),
ChannelRole::Admin,
)
.await
.context("failed to add user to channel")?;
}
}
let github_users_filepath = seed_path.parent().unwrap().join("seed/github_users.json");
let github_users: Vec<GithubUser> =
serde_json::from_str(&fs::read_to_string(github_users_filepath)?)?;
for github_user in github_users {
log::info!("Seeding {:?} from GitHub", github_user.login);
db.update_or_create_user_by_github_account(
&github_user.login,
github_user.id,
github_user.email.as_deref(),
github_user.name.as_deref(),
github_user.created_at,
None,
)
.await
.expect("failed to insert user");
}
Ok(())
}
fn load_admins(path: impl AsRef<Path>) -> anyhow::Result<SeedConfig> {
let file_content = fs::read_to_string(path)?;
Ok(serde_json::from_str(&file_content)?)
}
async fn fetch_github<T: DeserializeOwned>(client: &reqwest::Client, url: &str) -> T {
let mut request_builder = client.get(url);
if let Ok(github_token) = std::env::var("GITHUB_TOKEN") {
request_builder =
request_builder.header("Authorization", format!("Bearer {}", github_token));
}
let response = request_builder
.header("user-agent", "zed")
.send()
.await
.unwrap_or_else(|error| panic!("failed to fetch '{url}': {error}"));
let response_text = response.text().await.unwrap_or_else(|error| {
panic!("failed to fetch '{url}': {error}");
});
serde_json::from_str(&response_text).unwrap_or_else(|error| {
panic!("failed to deserialize github user from '{url}'. Error: '{error}', text: '{response_text}'");
})
}

View File

@@ -0,0 +1,3 @@
mod user_service;
pub use user_service::*;

View File

@@ -0,0 +1,377 @@
use anyhow::{Context as _, anyhow};
use async_trait::async_trait;
use cloud_api_types::internal_api::{
self, FuzzySearchChannelMembersByGithubLoginBody,
FuzzySearchChannelMembersByGithubLoginResponse, FuzzySearchUsersBody, FuzzySearchUsersResponse,
LookUpUserByGithubLoginBody, LookUpUserByGithubLoginResponse, LookUpUsersByLegacyIdBody,
LookUpUsersByLegacyIdResponse,
};
use reqwest::RequestBuilder;
use rpc::proto;
use serde::de::DeserializeOwned;
use crate::Result;
use crate::db::{Channel, UserId};
use crate::entities::User;
#[cfg(feature = "test-support")]
pub use self::fake_user_service::*;
#[async_trait]
pub trait UserService: Send + Sync + 'static {
async fn get_users_by_ids(&self, ids: Vec<UserId>) -> Result<Vec<User>>;
async fn get_user_by_github_login(&self, github_login: &str) -> Result<Option<User>>;
async fn fuzzy_search_users(&self, query: &str, limit: u32) -> Result<Vec<User>>;
// NOTE: This method is only tangentially related to users, but we're putting it on the `UserService` to avoid
// introducing a separate service.
//
// We're also using the `proto::ChannelMember` representation in the return type, as we don't yet have a domain
// representation of a channel member (and doesn't seem necessary to introduce one, at this point).
async fn search_channel_members(
&self,
channel: &Channel,
query: &str,
limit: u32,
) -> Result<(Vec<proto::ChannelMember>, Vec<User>)>;
#[cfg(feature = "test-support")]
fn as_fake(&self) -> std::sync::Arc<FakeUserService> {
panic!("called as_fake on a real `UserService`");
}
}
/// A [`UserService`] implementation backed by Cloud.
pub struct CloudUserService {
http_client: reqwest::Client,
zed_cloud_url: String,
internal_api_key: String,
}
impl CloudUserService {
pub fn new(
http_client: reqwest::Client,
zed_cloud_url: String,
internal_api_key: String,
) -> Self {
Self {
http_client,
zed_cloud_url,
internal_api_key,
}
}
async fn send_request<T: DeserializeOwned + 'static>(
&self,
request: RequestBuilder,
) -> Result<T> {
let request = request
.header("Content-Type", "application/json")
.header(
"Authorization",
format!("Bearer {}", &self.internal_api_key),
)
.build()
.context("failed to build request")?;
let response = self
.http_client
.execute(request)
.await
.context("failed to send request to Cloud")?;
let status = response.status();
match response.error_for_status() {
Ok(response) => {
let response_body: T = response
.json()
.await
.context("failed to parse response body")?;
Ok(response_body)
}
Err(_err) => Err(anyhow!("request to Cloud failed with status {status}",))?,
}
}
}
#[async_trait]
impl UserService for CloudUserService {
async fn get_users_by_ids(&self, ids: Vec<UserId>) -> Result<Vec<User>> {
let response_body: LookUpUsersByLegacyIdResponse = self
.send_request(
self.http_client
.post(format!(
"{}/internal/users/look_up_by_legacy_id",
&self.zed_cloud_url
))
.json(&LookUpUsersByLegacyIdBody {
legacy_user_ids: ids.into_iter().map(|id| id.0).collect(),
}),
)
.await?;
Ok(response_body.users.into_iter().map(User::from).collect())
}
async fn get_user_by_github_login(&self, github_login: &str) -> Result<Option<User>> {
let response_body: LookUpUserByGithubLoginResponse = self
.send_request(
self.http_client
.post(format!(
"{}/internal/users/look_up_by_github_login",
&self.zed_cloud_url
))
.json(&LookUpUserByGithubLoginBody {
github_login: github_login.to_string(),
}),
)
.await?;
Ok(response_body.user.map(User::from))
}
async fn fuzzy_search_users(&self, query: &str, limit: u32) -> Result<Vec<User>> {
let response_body: FuzzySearchUsersResponse = self
.send_request(
self.http_client
.post(format!(
"{}/internal/users/fuzzy_search",
&self.zed_cloud_url
))
.json(&FuzzySearchUsersBody {
query: query.to_string(),
limit,
}),
)
.await?;
Ok(response_body.users.into_iter().map(User::from).collect())
}
async fn search_channel_members(
&self,
channel: &Channel,
query: &str,
limit: u32,
) -> Result<(Vec<proto::ChannelMember>, Vec<User>)> {
let response_body: FuzzySearchChannelMembersByGithubLoginResponse = self
.send_request(
self.http_client
.post(format!(
"{}/internal/channel_members/fuzzy_search_by_github_login",
&self.zed_cloud_url
))
.json(&FuzzySearchChannelMembersByGithubLoginBody {
channel_id: channel.root_id().0,
query: query.to_string(),
limit,
}),
)
.await?;
let members = response_body
.channel_members
.into_iter()
.map(channel_member_to_proto)
.collect::<Vec<_>>();
let users = response_body
.users
.into_iter()
.map(User::from)
.collect::<Vec<_>>();
Ok((members, users))
}
}
fn channel_member_to_proto(member: internal_api::ChannelMember) -> proto::ChannelMember {
let kind = match member.kind {
internal_api::ChannelMemberKind::Member => proto::channel_member::Kind::Member,
internal_api::ChannelMemberKind::Invitee => proto::channel_member::Kind::Invitee,
};
let role = match member.role {
internal_api::ChannelMemberRole::Admin => proto::ChannelRole::Admin,
internal_api::ChannelMemberRole::Member => proto::ChannelRole::Member,
internal_api::ChannelMemberRole::Talker => proto::ChannelRole::Talker,
internal_api::ChannelMemberRole::Guest => proto::ChannelRole::Guest,
internal_api::ChannelMemberRole::Banned => proto::ChannelRole::Banned,
};
proto::ChannelMember {
user_id: UserId(member.legacy_user_id).to_proto(),
kind: kind.into(),
role: role.into(),
}
}
impl From<internal_api::User> for User {
fn from(user: internal_api::User) -> Self {
Self {
id: UserId(user.legacy_user_id),
avatar_url: user.avatar_url,
github_login: user.github_login,
name: user.name,
admin: user.admin,
connected_once: user.connected_once,
}
}
}
#[cfg(feature = "test-support")]
mod fake_user_service {
use std::sync::{Arc, Weak};
use collections::HashMap;
use tokio::sync::Mutex;
use crate::db::Database;
use super::*;
#[derive(Debug)]
pub struct NewUserParams {
pub github_login: String,
pub github_user_id: i32,
}
pub struct FakeUserService {
this: Weak<Self>,
state: Arc<Mutex<FakeUserServiceState>>,
database: Arc<Database>,
}
struct FakeUserServiceState {
next_user_id: UserId,
users: HashMap<UserId, User>,
}
impl Default for FakeUserServiceState {
fn default() -> Self {
Self {
next_user_id: UserId(1),
users: HashMap::default(),
}
}
}
impl FakeUserService {
pub fn new(database: Arc<Database>) -> Arc<Self> {
Arc::new_cyclic(|this| Self {
this: this.clone(),
state: Arc::new(Mutex::default()),
database,
})
}
pub async fn create_user(
&self,
email_address: &str,
name: Option<&str>,
admin: bool,
params: NewUserParams,
) -> UserId {
let mut state = self.state.lock().await;
let user_id = state.next_user_id;
let _ = email_address;
state.users.insert(
user_id,
User {
id: user_id,
avatar_url: format!("https://github.com/{}.png?size=128", params.github_login),
github_login: params.github_login,
name: name.map(|name| name.to_string()),
admin,
connected_once: false,
},
);
state.next_user_id = UserId(state.next_user_id.0 + 1);
user_id
}
pub async fn get_user_by_id(&self, id: UserId) -> Result<Option<User>> {
let state = self.state.lock().await;
let user = state.users.get(&id).cloned();
Ok(user)
}
}
#[async_trait]
impl UserService for FakeUserService {
async fn get_users_by_ids(&self, ids: Vec<UserId>) -> Result<Vec<User>> {
let state = self.state.lock().await;
let users = state
.users
.values()
.filter(|user| ids.contains(&user.id))
.cloned()
.collect();
Ok(users)
}
async fn get_user_by_github_login(&self, github_login: &str) -> Result<Option<User>> {
let state = self.state.lock().await;
let user = state
.users
.values()
.find(|user| user.github_login == github_login)
.cloned();
Ok(user)
}
async fn fuzzy_search_users(&self, query: &str, limit: u32) -> Result<Vec<User>> {
let _ = query;
let _ = limit;
unimplemented!("not currently exercised by any tests")
}
async fn search_channel_members(
&self,
channel: &Channel,
query: &str,
limit: u32,
) -> Result<(Vec<proto::ChannelMember>, Vec<User>)> {
let state = self.state.lock().await;
let users = state
.users
.values()
.filter(|user| user.github_login.contains(query))
.take(limit as usize)
.cloned()
.collect::<Vec<_>>();
let members = self
.database
.get_channel_memberships_for_user_ids(
channel,
users.iter().map(|user| user.id).collect(),
)
.await?;
Ok((
members
.into_iter()
.map(proto::ChannelMember::from)
.collect(),
users,
))
}
#[cfg(feature = "test-support")]
fn as_fake(&self) -> Arc<FakeUserService> {
self.this.upgrade().unwrap()
}
}
}

View File

@@ -0,0 +1,214 @@
use agent::SharedThread;
use gpui::{BackgroundExecutor, TestAppContext};
use rpc::proto;
use uuid::Uuid;
use crate::TestServer;
#[gpui::test]
async fn test_share_and_retrieve_thread(
executor: BackgroundExecutor,
cx_a: &mut TestAppContext,
cx_b: &mut TestAppContext,
) {
let mut server = TestServer::start(executor.clone()).await;
let client_a = server.create_client(cx_a, "user_a").await;
let client_b = server.create_client(cx_b, "user_b").await;
executor.run_until_parked();
let session_id = Uuid::new_v4().to_string();
let original_thread = SharedThread {
title: "Shared Test Thread".into(),
messages: vec![],
updated_at: chrono::Utc::now(),
model: None,
version: SharedThread::VERSION.to_string(),
};
let thread_data = original_thread
.to_bytes()
.expect("Failed to serialize thread");
client_a
.client()
.request(proto::ShareAgentThread {
session_id: session_id.clone(),
title: original_thread.title.to_string(),
thread_data,
})
.await
.expect("Failed to share thread");
let get_response = client_b
.client()
.request(proto::GetSharedAgentThread {
session_id: session_id.clone(),
})
.await
.expect("Failed to get shared thread");
let imported_shared_thread =
SharedThread::from_bytes(&get_response.thread_data).expect("Failed to deserialize thread");
assert_eq!(imported_shared_thread.title, original_thread.title);
assert_eq!(imported_shared_thread.version, SharedThread::VERSION);
let db_thread = imported_shared_thread.to_db_thread();
assert!(
db_thread.title.starts_with("🔗"),
"Imported thread title should have link prefix"
);
assert!(
db_thread.title.contains("Shared Test Thread"),
"Imported thread should preserve original title"
);
}
#[gpui::test]
async fn test_reshare_updates_existing_thread(
executor: BackgroundExecutor,
cx_a: &mut TestAppContext,
cx_b: &mut TestAppContext,
) {
let mut server = TestServer::start(executor.clone()).await;
let client_a = server.create_client(cx_a, "user_a").await;
let client_b = server.create_client(cx_b, "user_b").await;
executor.run_until_parked();
let session_id = Uuid::new_v4().to_string();
client_a
.client()
.request(proto::ShareAgentThread {
session_id: session_id.clone(),
title: "Original Title".to_string(),
thread_data: b"original data".to_vec(),
})
.await
.expect("Failed to share thread");
client_a
.client()
.request(proto::ShareAgentThread {
session_id: session_id.clone(),
title: "Updated Title".to_string(),
thread_data: b"updated data".to_vec(),
})
.await
.expect("Failed to re-share thread");
let get_response = client_b
.client()
.request(proto::GetSharedAgentThread {
session_id: session_id.clone(),
})
.await
.expect("Failed to get shared thread");
assert_eq!(get_response.title, "Updated Title");
assert_eq!(get_response.thread_data, b"updated data".to_vec());
}
#[gpui::test]
async fn test_get_nonexistent_thread(executor: BackgroundExecutor, cx: &mut TestAppContext) {
let mut server = TestServer::start(executor.clone()).await;
let client = server.create_client(cx, "user_a").await;
executor.run_until_parked();
let nonexistent_session_id = Uuid::new_v4().to_string();
let result = client
.client()
.request(proto::GetSharedAgentThread {
session_id: nonexistent_session_id,
})
.await;
assert!(result.is_err(), "Should fail for nonexistent thread");
}
#[gpui::test]
async fn test_sync_imported_thread(
executor: BackgroundExecutor,
cx_a: &mut TestAppContext,
cx_b: &mut TestAppContext,
) {
let mut server = TestServer::start(executor.clone()).await;
let client_a = server.create_client(cx_a, "user_a").await;
let client_b = server.create_client(cx_b, "user_b").await;
executor.run_until_parked();
let session_id = Uuid::new_v4().to_string();
// User A shares a thread with initial content.
let initial_thread = SharedThread {
title: "Initial Title".into(),
messages: vec![],
updated_at: chrono::Utc::now(),
model: None,
version: SharedThread::VERSION.to_string(),
};
client_a
.client()
.request(proto::ShareAgentThread {
session_id: session_id.clone(),
title: initial_thread.title.to_string(),
thread_data: initial_thread.to_bytes().expect("Failed to serialize"),
})
.await
.expect("Failed to share thread");
// User B imports the thread.
let initial_response = client_b
.client()
.request(proto::GetSharedAgentThread {
session_id: session_id.clone(),
})
.await
.expect("Failed to get shared thread");
let initial_imported =
SharedThread::from_bytes(&initial_response.thread_data).expect("Failed to deserialize");
assert_eq!(initial_imported.title.as_ref(), "Initial Title");
// User A updates the shared thread.
let updated_thread = SharedThread {
title: "Updated Title".into(),
messages: vec![],
updated_at: chrono::Utc::now(),
model: None,
version: SharedThread::VERSION.to_string(),
};
client_a
.client()
.request(proto::ShareAgentThread {
session_id: session_id.clone(),
title: updated_thread.title.to_string(),
thread_data: updated_thread.to_bytes().expect("Failed to serialize"),
})
.await
.expect("Failed to re-share thread");
// User B syncs the imported thread (fetches the latest version).
let synced_response = client_b
.client()
.request(proto::GetSharedAgentThread {
session_id: session_id.clone(),
})
.await
.expect("Failed to sync shared thread");
let synced_thread =
SharedThread::from_bytes(&synced_response.thread_data).expect("Failed to deserialize");
// The synced thread should have the updated title.
assert_eq!(synced_thread.title.as_ref(), "Updated Title");
}

View File

@@ -0,0 +1,484 @@
use crate::TestServer;
use call::ActiveCall;
use client::ChannelId;
use gpui::{App, BackgroundExecutor, Entity, TestAppContext, TestScreenCaptureSource};
use project::Project;
use rpc::proto::PeerId;
use workspace::{AutoWatch, SharedScreen, Workspace};
use super::TestClient;
struct AutoWatchTestSetup {
client_a: TestClient,
client_b: TestClient,
client_c: TestClient,
channel_id: ChannelId,
user_a_project: Entity<Project>,
user_b_project: Entity<Project>,
}
async fn setup_auto_watch_test(
server: &mut TestServer,
user_a: &mut TestAppContext,
user_b: &mut TestAppContext,
user_c: &mut TestAppContext,
) -> AutoWatchTestSetup {
setup_auto_watch_test_with_initial_participants(server, user_a, user_b, user_c, true).await
}
async fn setup_auto_watch_late_joiner_test(
server: &mut TestServer,
user_a: &mut TestAppContext,
user_b: &mut TestAppContext,
user_c: &mut TestAppContext,
) -> AutoWatchTestSetup {
setup_auto_watch_test_with_initial_participants(server, user_a, user_b, user_c, false).await
}
async fn setup_auto_watch_test_with_initial_participants(
server: &mut TestServer,
user_a: &mut TestAppContext,
user_b: &mut TestAppContext,
user_c: &mut TestAppContext,
join_user_c: bool,
) -> AutoWatchTestSetup {
let client_a = server.create_client(user_a, "user_a").await;
let client_b = server.create_client(user_b, "user_b").await;
let client_c = server.create_client(user_c, "user_c").await;
let channel_id = server
.make_channel(
"the-channel",
None,
(&client_a, user_a),
&mut [(&client_b, user_b), (&client_c, user_c)],
)
.await;
let user_a_project = client_a.build_empty_local_project(false, user_a);
let user_b_project = client_b.build_empty_local_project(false, user_b);
let active_call_a = user_a.read(ActiveCall::global);
active_call_a
.update(user_a, |call, cx| call.join_channel(channel_id, cx))
.await
.unwrap();
let active_call_b = user_b.read(ActiveCall::global);
active_call_b
.update(user_b, |call, cx| call.join_channel(channel_id, cx))
.await
.unwrap();
if join_user_c {
let active_call_c = user_c.read(ActiveCall::global);
active_call_c
.update(user_c, |call, cx| call.join_channel(channel_id, cx))
.await
.unwrap();
}
AutoWatchTestSetup {
client_a,
client_b,
client_c,
channel_id,
user_a_project,
user_b_project,
}
}
#[gpui::test]
async fn test_auto_watch_opens_existing_share_on_toggle(
executor: BackgroundExecutor,
user_a: &mut TestAppContext,
user_b: &mut TestAppContext,
user_c: &mut TestAppContext,
) {
let mut server = TestServer::start(executor.clone()).await;
let setup = setup_auto_watch_test(&mut server, user_a, user_b, user_c).await;
let (workspace_a, user_a) = setup
.client_a
.build_workspace(&setup.user_a_project, user_a);
executor.run_until_parked();
start_screen_share(user_b).await;
executor.run_until_parked();
workspace_a.update_in(user_a, |workspace, window, cx| {
workspace.toggle_auto_watch(window, cx);
});
executor.run_until_parked();
workspace_a.update(user_a, |workspace, cx| {
assert_active_item_is_screen_share_for_peer(
workspace,
setup.client_b.peer_id().unwrap(),
cx,
);
});
}
#[gpui::test]
async fn test_auto_watch_opens_share_when_no_one_is_sharing_yet(
executor: BackgroundExecutor,
user_a: &mut TestAppContext,
user_b: &mut TestAppContext,
user_c: &mut TestAppContext,
) {
let mut server = TestServer::start(executor.clone()).await;
let setup = setup_auto_watch_test(&mut server, user_a, user_b, user_c).await;
let (workspace_a, user_a) = setup
.client_a
.build_workspace(&setup.user_a_project, user_a);
workspace_a.update_in(user_a, |workspace, window, cx| {
workspace.toggle_auto_watch(window, cx);
});
start_screen_share(user_b).await;
executor.run_until_parked();
workspace_a.update(user_a, |workspace, cx| {
assert_active_item_is_screen_share_for_peer(
workspace,
setup.client_b.peer_id().unwrap(),
cx,
);
});
}
#[gpui::test]
async fn test_auto_watch_switches_to_next_share_on_share_end(
executor: BackgroundExecutor,
user_a: &mut TestAppContext,
user_b: &mut TestAppContext,
user_c: &mut TestAppContext,
) {
let mut server = TestServer::start(executor.clone()).await;
let setup = setup_auto_watch_test(&mut server, user_a, user_b, user_c).await;
let (workspace_a, user_a) = setup
.client_a
.build_workspace(&setup.user_a_project, user_a);
workspace_a.update_in(user_a, |workspace, window, cx| {
workspace.toggle_auto_watch(window, cx);
});
start_screen_share(user_b).await;
executor.run_until_parked();
workspace_a.update(user_a, |workspace, cx| {
assert_active_item_is_screen_share_for_peer(
workspace,
setup.client_b.peer_id().unwrap(),
cx,
);
});
start_screen_share(user_c).await;
executor.run_until_parked();
stop_screen_share(user_b);
executor.run_until_parked();
workspace_a.update(user_a, |workspace, cx| {
assert_active_item_is_screen_share_for_peer(
workspace,
setup.client_c.peer_id().unwrap(),
cx,
);
});
}
#[gpui::test]
async fn test_auto_watch_ignores_shares_while_user_is_sharing(
executor: BackgroundExecutor,
user_a: &mut TestAppContext,
user_b: &mut TestAppContext,
user_c: &mut TestAppContext,
) {
let mut server = TestServer::start(executor.clone()).await;
let setup = setup_auto_watch_test(&mut server, user_a, user_b, user_c).await;
let (workspace_a, user_a) = setup
.client_a
.build_workspace(&setup.user_a_project, user_a);
start_screen_share(user_a).await;
executor.run_until_parked();
start_screen_share(user_b).await;
executor.run_until_parked();
// Should NOT open B's screen cause we are sharing
workspace_a.update_in(user_a, |workspace, window, cx| {
workspace.toggle_auto_watch(window, cx);
});
executor.run_until_parked();
workspace_a.update(user_a, |workspace, cx| {
assert_no_screen_share_tabs_exist(
workspace,
"should not open anyone's screen share when toggling on while sharing",
cx,
);
});
}
#[gpui::test]
async fn test_auto_watch_opens_share_after_local_user_stops_sharing(
executor: BackgroundExecutor,
user_a: &mut TestAppContext,
user_b: &mut TestAppContext,
user_c: &mut TestAppContext,
) {
let mut server = TestServer::start(executor.clone()).await;
let setup = setup_auto_watch_test(&mut server, user_a, user_b, user_c).await;
let (workspace_a, user_a) = setup
.client_a
.build_workspace(&setup.user_a_project, user_a);
workspace_a.update_in(user_a, |workspace, window, cx| {
workspace.toggle_auto_watch(window, cx);
});
start_screen_share(user_a).await;
executor.run_until_parked();
start_screen_share(user_b).await;
executor.run_until_parked();
stop_screen_share(user_a);
executor.run_until_parked();
workspace_a.update(user_a, |workspace, cx| {
assert_active_item_is_screen_share_for_peer(
workspace,
setup.client_b.peer_id().unwrap(),
cx,
);
});
}
#[gpui::test]
async fn test_auto_watch_toggle_off_leaves_tabs_open(
executor: BackgroundExecutor,
user_a: &mut TestAppContext,
user_b: &mut TestAppContext,
user_c: &mut TestAppContext,
) {
let mut server = TestServer::start(executor.clone()).await;
let setup = setup_auto_watch_test(&mut server, user_a, user_b, user_c).await;
let (workspace_a, user_a) = setup
.client_a
.build_workspace(&setup.user_a_project, user_a);
workspace_a.update_in(user_a, |workspace, window, cx| {
workspace.toggle_auto_watch(window, cx);
});
start_screen_share(user_b).await;
executor.run_until_parked();
workspace_a.update(user_a, |workspace, cx| {
assert_active_item_is_screen_share_for_peer(
workspace,
setup.client_b.peer_id().unwrap(),
cx,
);
});
workspace_a.update_in(user_a, |workspace, window, cx| {
workspace.toggle_auto_watch(window, cx);
});
workspace_a.update(user_a, |workspace, cx| {
assert_active_item_is_screen_share_for_peer(
workspace,
setup.client_b.peer_id().unwrap(),
cx,
);
});
}
#[gpui::test]
async fn test_auto_watch_reopens_screen_share_from_returning_channel_participant(
executor: BackgroundExecutor,
user_a: &mut TestAppContext,
user_b: &mut TestAppContext,
user_c: &mut TestAppContext,
) {
let mut server = TestServer::start(executor.clone()).await;
let setup = setup_auto_watch_late_joiner_test(&mut server, user_a, user_b, user_c).await;
let (workspace_a, user_a) = setup
.client_a
.build_workspace(&setup.user_a_project, user_a);
let (workspace_b, user_b) = setup
.client_b
.build_workspace(&setup.user_b_project, user_b);
workspace_a.update_in(user_a, |workspace, window, cx| {
workspace.toggle_auto_watch(window, cx);
});
workspace_b.update_in(user_b, |workspace, window, cx| {
workspace.toggle_auto_watch(window, cx);
});
executor.run_until_parked();
let active_call_c = user_c.read(ActiveCall::global);
active_call_c
.update(user_c, |call, cx| call.join_channel(setup.channel_id, cx))
.await
.unwrap();
executor.run_until_parked();
start_screen_share(user_c).await;
executor.run_until_parked();
workspace_a.update(user_a, |workspace, cx| {
assert_active_item_is_screen_share_for_peer(
workspace,
setup.client_c.peer_id().unwrap(),
cx,
);
});
workspace_b.update(user_b, |workspace, cx| {
assert_active_item_is_screen_share_for_peer(
workspace,
setup.client_c.peer_id().unwrap(),
cx,
);
});
active_call_c
.update(user_c, |call, cx| call.hang_up(cx))
.await
.unwrap();
executor.run_until_parked();
workspace_a.update(user_a, |workspace, cx| {
assert_no_screen_share_tabs_exist(
workspace,
"user A should stop seeing user C's screen after user C hangs up",
cx,
);
});
workspace_b.update(user_b, |workspace, cx| {
assert_no_screen_share_tabs_exist(
workspace,
"user B should stop seeing user C's screen after user C hangs up",
cx,
);
});
let active_call_c = user_c.read(ActiveCall::global);
active_call_c
.update(user_c, |call, cx| call.join_channel(setup.channel_id, cx))
.await
.unwrap();
executor.run_until_parked();
start_screen_share(user_c).await;
executor.run_until_parked();
workspace_a.update(user_a, |workspace, cx| {
assert_active_item_is_screen_share_for_peer(
workspace,
setup.client_c.peer_id().unwrap(),
cx,
);
});
workspace_b.update(user_b, |workspace, cx| {
assert_active_item_is_screen_share_for_peer(
workspace,
setup.client_c.peer_id().unwrap(),
cx,
);
});
}
#[gpui::test]
async fn test_auto_watch_is_disabled_when_following_collaborator(
executor: BackgroundExecutor,
user_a: &mut TestAppContext,
user_b: &mut TestAppContext,
user_c: &mut TestAppContext,
) {
let mut server = TestServer::start(executor.clone()).await;
let setup = setup_auto_watch_test(&mut server, user_a, user_b, user_c).await;
let (workspace_a, user_a) = setup
.client_a
.build_workspace(&setup.user_a_project, user_a);
let user_b_peer_id = setup.client_b.peer_id().unwrap();
workspace_a.update_in(user_a, |workspace, window, cx| {
workspace.toggle_auto_watch(window, cx);
});
start_screen_share(user_b).await;
executor.run_until_parked();
workspace_a.update(user_a, |workspace, cx| {
assert_active_item_is_screen_share_for_peer(
workspace,
setup.client_b.peer_id().unwrap(),
cx,
);
});
workspace_a.update_in(user_a, |workspace, window, cx| {
workspace.follow(user_b_peer_id, window, cx);
});
executor.run_until_parked();
workspace_a.update(user_a, |workspace, _cx| {
assert_eq!(*workspace.auto_watch_state(), AutoWatch::Off);
});
}
#[track_caller]
fn assert_no_screen_share_tabs_exist(workspace: &Workspace, message: &str, cx: &App) {
let has_shared_screen_tab = workspace
.active_pane()
.read(cx)
.items()
.any(|item| item.downcast::<SharedScreen>().is_some());
assert!(!has_shared_screen_tab, "{message}");
}
#[track_caller]
fn assert_active_item_is_screen_share_for_peer(workspace: &Workspace, peer_id: PeerId, cx: &App) {
let active_item = workspace.active_item(cx).expect("no active item");
let shared_screen = active_item
.downcast::<SharedScreen>()
.expect("expected active item to be a shared screen");
assert_eq!(shared_screen.read(cx).peer_id, peer_id);
}
async fn start_screen_share(cx: &mut TestAppContext) {
let display = TestScreenCaptureSource::new();
cx.set_screen_capture_sources(vec![display]);
let screen = cx
.update(|cx| cx.screen_capture_sources())
.await
.unwrap()
.unwrap()
.into_iter()
.next()
.unwrap();
let active_call = cx.read(ActiveCall::global);
active_call
.update(cx, |call, cx| {
call.room()
.unwrap()
.update(cx, |room, cx| room.share_screen(screen, cx))
})
.await
.unwrap();
}
#[track_caller]
fn stop_screen_share(cx: &mut TestAppContext) {
let active_call = cx.read(ActiveCall::global);
active_call
.update(cx, |call, cx| {
call.room()
.unwrap()
.update(cx, |room, cx| room.unshare_screen(true, cx))
})
.unwrap();
}

View File

@@ -0,0 +1,884 @@
use crate::{TestServer, test_server::open_channel_notes};
use call::ActiveCall;
use channel::ACKNOWLEDGE_DEBOUNCE_INTERVAL;
use client::{Collaborator, LegacyUserId, ParticipantIndex};
use collab::rpc::{CLEANUP_TIMEOUT, RECONNECT_TIMEOUT};
use collab_ui::channel_view::ChannelView;
use collections::HashMap;
use editor::{Anchor, Editor, MultiBufferOffset, ToOffset};
use futures::future;
use gpui::{BackgroundExecutor, Context, Entity, TestAppContext, Window};
use rpc::{RECEIVE_TIMEOUT, proto::PeerId};
use serde_json::json;
use std::ops::Range;
use util::rel_path::rel_path;
use workspace::CollaboratorId;
#[gpui::test]
async fn test_core_channel_buffers(
executor: BackgroundExecutor,
cx_a: &mut TestAppContext,
cx_b: &mut TestAppContext,
) {
let mut server = TestServer::start(executor.clone()).await;
let client_a = server.create_client(cx_a, "user_a").await;
let client_b = server.create_client(cx_b, "user_b").await;
let channel_id = server
.make_channel("zed", None, (&client_a, cx_a), &mut [(&client_b, cx_b)])
.await;
// Client A joins the channel buffer
let channel_buffer_a = client_a
.channel_store()
.update(cx_a, |store, cx| store.open_channel_buffer(channel_id, cx))
.await
.unwrap();
// Client A edits the buffer
let buffer_a = channel_buffer_a.read_with(cx_a, |buffer, _| buffer.buffer());
buffer_a.update(cx_a, |buffer, cx| {
buffer.edit([(0..0, "hello world")], None, cx)
});
buffer_a.update(cx_a, |buffer, cx| {
buffer.edit([(5..5, ", cruel")], None, cx)
});
buffer_a.update(cx_a, |buffer, cx| {
buffer.edit([(0..5, "goodbye")], None, cx)
});
buffer_a.update(cx_a, |buffer, cx| buffer.undo(cx));
assert_eq!(buffer_text(&buffer_a, cx_a), "hello, cruel world");
executor.run_until_parked();
// Client B joins the channel buffer
let channel_buffer_b = client_b
.channel_store()
.update(cx_b, |store, cx| store.open_channel_buffer(channel_id, cx))
.await
.unwrap();
channel_buffer_b.read_with(cx_b, |buffer, _| {
assert_collaborators(
buffer.collaborators(),
&[client_a.user_id(), client_b.user_id()],
);
});
// Client B sees the correct text, and then edits it
let buffer_b = channel_buffer_b.read_with(cx_b, |buffer, _| buffer.buffer());
assert_eq!(
buffer_b.read_with(cx_b, |buffer, _| buffer.remote_id()),
buffer_a.read_with(cx_a, |buffer, _| buffer.remote_id())
);
assert_eq!(buffer_text(&buffer_b, cx_b), "hello, cruel world");
buffer_b.update(cx_b, |buffer, cx| {
buffer.edit([(7..12, "beautiful")], None, cx)
});
// Both A and B see the new edit
executor.run_until_parked();
assert_eq!(buffer_text(&buffer_a, cx_a), "hello, beautiful world");
assert_eq!(buffer_text(&buffer_b, cx_b), "hello, beautiful world");
// Client A closes the channel buffer.
cx_a.update(|_| drop(channel_buffer_a));
executor.run_until_parked();
// Client B sees that client A is gone from the channel buffer.
channel_buffer_b.read_with(cx_b, |buffer, _| {
assert_collaborators(buffer.collaborators(), &[client_b.user_id()]);
});
// Client A rejoins the channel buffer
let _channel_buffer_a = client_a
.channel_store()
.update(cx_a, |store, cx| store.open_channel_buffer(channel_id, cx))
.await
.unwrap();
executor.run_until_parked();
// Sanity test, make sure we saw A rejoining
channel_buffer_b.read_with(cx_b, |buffer, _| {
assert_collaborators(
buffer.collaborators(),
&[client_a.user_id(), client_b.user_id()],
);
});
// Client A loses connection.
server.forbid_connections();
server.disconnect_client(client_a.peer_id().unwrap());
executor.advance_clock(RECEIVE_TIMEOUT + RECONNECT_TIMEOUT);
// Client B observes A disconnect
channel_buffer_b.read_with(cx_b, |buffer, _| {
assert_collaborators(buffer.collaborators(), &[client_b.user_id()]);
});
// TODO:
// - Test synchronizing offline updates, what happens to A's channel buffer when A disconnects
// - Test interaction with channel deletion while buffer is open
}
#[gpui::test]
async fn test_channel_notes_participant_indices(
executor: BackgroundExecutor,
cx_a: &mut TestAppContext,
cx_b: &mut TestAppContext,
cx_c: &mut TestAppContext,
) {
let mut server = TestServer::start(executor.clone()).await;
let client_a = server.create_client(cx_a, "user_a").await;
let client_b = server.create_client(cx_b, "user_b").await;
let client_c = server.create_client(cx_c, "user_c").await;
let active_call_a = cx_a.read(ActiveCall::global);
let active_call_b = cx_b.read(ActiveCall::global);
cx_a.update(editor::init);
cx_b.update(editor::init);
cx_c.update(editor::init);
let channel_id = server
.make_channel(
"the-channel",
None,
(&client_a, cx_a),
&mut [(&client_b, cx_b), (&client_c, cx_c)],
)
.await;
client_a
.fs()
.insert_tree("/root", json!({"file.txt": "123"}))
.await;
let (project_a, worktree_id_a) = client_a.build_local_project_with_trust("/root", cx_a).await;
let project_b = client_b.build_empty_local_project(false, cx_b);
let project_c = client_c.build_empty_local_project(false, cx_c);
let (workspace_a, mut cx_a) = client_a.build_workspace(&project_a, cx_a);
let (workspace_b, mut cx_b) = client_b.build_workspace(&project_b, cx_b);
let (workspace_c, cx_c) = client_c.build_workspace(&project_c, cx_c);
// Clients A, B, and C open the channel notes
let channel_view_a = cx_a
.update(|window, cx| ChannelView::open(channel_id, None, workspace_a.clone(), window, cx))
.await
.unwrap();
let channel_view_b = cx_b
.update(|window, cx| ChannelView::open(channel_id, None, workspace_b.clone(), window, cx))
.await
.unwrap();
let channel_view_c = cx_c
.update(|window, cx| ChannelView::open(channel_id, None, workspace_c.clone(), window, cx))
.await
.unwrap();
// Clients A, B, and C all insert and select some text
channel_view_a.update_in(cx_a, |notes, window, cx| {
notes.editor.update(cx, |editor, cx| {
editor.insert("a", window, cx);
editor.change_selections(Default::default(), window, cx, |selections| {
selections.select_ranges(vec![MultiBufferOffset(0)..MultiBufferOffset(1)]);
});
});
});
executor.run_until_parked();
channel_view_b.update_in(cx_b, |notes, window, cx| {
notes.editor.update(cx, |editor, cx| {
editor.move_down(&Default::default(), window, cx);
editor.insert("b", window, cx);
editor.change_selections(Default::default(), window, cx, |selections| {
selections.select_ranges(vec![MultiBufferOffset(1)..MultiBufferOffset(2)]);
});
});
});
executor.run_until_parked();
channel_view_c.update_in(cx_c, |notes, window, cx| {
notes.editor.update(cx, |editor, cx| {
editor.move_down(&Default::default(), window, cx);
editor.insert("c", window, cx);
editor.change_selections(Default::default(), window, cx, |selections| {
selections.select_ranges(vec![MultiBufferOffset(2)..MultiBufferOffset(3)]);
});
});
});
// Client A sees clients B and C without assigned colors, because they aren't
// in a call together.
executor.run_until_parked();
channel_view_a.update_in(cx_a, |notes, window, cx| {
notes.editor.update(cx, |editor, cx| {
assert_remote_selections(editor, &[(None, 1..2), (None, 2..3)], window, cx);
});
});
// Clients A and B join the same call.
for (call, cx) in [(&active_call_a, &mut cx_a), (&active_call_b, &mut cx_b)] {
call.update(*cx, |call, cx| call.join_channel(channel_id, cx))
.await
.unwrap();
}
// Clients A and B see each other with two different assigned colors. Client C
// still doesn't have a color.
executor.run_until_parked();
channel_view_a.update_in(cx_a, |notes, window, cx| {
notes.editor.update(cx, |editor, cx| {
assert_remote_selections(
editor,
&[(Some(ParticipantIndex(1)), 1..2), (None, 2..3)],
window,
cx,
);
});
});
channel_view_b.update_in(cx_b, |notes, window, cx| {
notes.editor.update(cx, |editor, cx| {
assert_remote_selections(
editor,
&[(Some(ParticipantIndex(0)), 0..1), (None, 2..3)],
window,
cx,
);
});
});
// Client A shares a project, and client B joins.
let project_id = active_call_a
.update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
.await
.unwrap();
let project_b = client_b.join_remote_project(project_id, cx_b).await;
let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b);
// Clients A and B open the same file.
let editor_a = workspace_a
.update_in(cx_a, |workspace, window, cx| {
workspace.open_path(
(worktree_id_a, rel_path("file.txt")),
None,
true,
window,
cx,
)
})
.await
.unwrap()
.downcast::<Editor>()
.unwrap();
let editor_b = workspace_b
.update_in(cx_b, |workspace, window, cx| {
workspace.open_path(
(worktree_id_a, rel_path("file.txt")),
None,
true,
window,
cx,
)
})
.await
.unwrap()
.downcast::<Editor>()
.unwrap();
editor_a.update_in(cx_a, |editor, window, cx| {
editor.change_selections(Default::default(), window, cx, |selections| {
selections.select_ranges(vec![MultiBufferOffset(0)..MultiBufferOffset(1)]);
});
});
editor_b.update_in(cx_b, |editor, window, cx| {
editor.change_selections(Default::default(), window, cx, |selections| {
selections.select_ranges(vec![MultiBufferOffset(2)..MultiBufferOffset(3)]);
});
});
executor.run_until_parked();
// Clients A and B see each other with the same colors as in the channel notes.
editor_a.update_in(cx_a, |editor, window, cx| {
assert_remote_selections(editor, &[(Some(ParticipantIndex(1)), 2..3)], window, cx);
});
editor_b.update_in(cx_b, |editor, window, cx| {
assert_remote_selections(editor, &[(Some(ParticipantIndex(0)), 0..1)], window, cx);
});
}
#[track_caller]
fn assert_remote_selections(
editor: &mut Editor,
expected_selections: &[(Option<ParticipantIndex>, Range<usize>)],
window: &mut Window,
cx: &mut Context<Editor>,
) {
let snapshot = editor.snapshot(window, cx);
let hub = editor.collaboration_hub().unwrap();
let collaborators = hub.collaborators(cx);
let range = Anchor::Min..Anchor::Max;
let remote_selections = snapshot
.remote_selections_in_range(&range, hub, cx)
.map(|s| {
let CollaboratorId::PeerId(peer_id) = s.collaborator_id else {
panic!("unexpected collaborator id");
};
let start = s.selection.start.to_offset(snapshot.buffer_snapshot());
let end = s.selection.end.to_offset(snapshot.buffer_snapshot());
let user_id = collaborators.get(&peer_id).unwrap().user_id;
let participant_index = hub.user_participant_indices(cx).get(&user_id).copied();
(participant_index, start.0..end.0)
})
.collect::<Vec<_>>();
assert_eq!(
remote_selections, expected_selections,
"incorrect remote selections"
);
}
#[gpui::test]
async fn test_multiple_handles_to_channel_buffer(
deterministic: BackgroundExecutor,
cx_a: &mut TestAppContext,
) {
let mut server = TestServer::start(deterministic.clone()).await;
let client_a = server.create_client(cx_a, "user_a").await;
let channel_id = server
.make_channel("the-channel", None, (&client_a, cx_a), &mut [])
.await;
let channel_buffer_1 = client_a
.channel_store()
.update(cx_a, |store, cx| store.open_channel_buffer(channel_id, cx));
let channel_buffer_2 = client_a
.channel_store()
.update(cx_a, |store, cx| store.open_channel_buffer(channel_id, cx));
let channel_buffer_3 = client_a
.channel_store()
.update(cx_a, |store, cx| store.open_channel_buffer(channel_id, cx));
// All concurrent tasks for opening a channel buffer return the same model handle.
let (channel_buffer, channel_buffer_2, channel_buffer_3) =
future::try_join3(channel_buffer_1, channel_buffer_2, channel_buffer_3)
.await
.unwrap();
let channel_buffer_entity_id = channel_buffer.entity_id();
assert_eq!(channel_buffer, channel_buffer_2);
assert_eq!(channel_buffer, channel_buffer_3);
channel_buffer.update(cx_a, |buffer, cx| {
buffer.buffer().update(cx, |buffer, cx| {
buffer.edit([(0..0, "hello")], None, cx);
})
});
deterministic.run_until_parked();
cx_a.update(|_| {
drop(channel_buffer);
drop(channel_buffer_2);
drop(channel_buffer_3);
});
deterministic.run_until_parked();
// The channel buffer can be reopened after dropping it.
let channel_buffer = client_a
.channel_store()
.update(cx_a, |store, cx| store.open_channel_buffer(channel_id, cx))
.await
.unwrap();
assert_ne!(channel_buffer.entity_id(), channel_buffer_entity_id);
channel_buffer.update(cx_a, |buffer, cx| {
buffer.buffer().update(cx, |buffer, _| {
assert_eq!(buffer.text(), "hello");
})
});
}
#[gpui::test]
async fn test_channel_buffer_disconnect(
deterministic: BackgroundExecutor,
cx_a: &mut TestAppContext,
cx_b: &mut TestAppContext,
) {
let mut server = TestServer::start(deterministic.clone()).await;
let client_a = server.create_client(cx_a, "user_a").await;
let client_b = server.create_client(cx_b, "user_b").await;
let channel_id = server
.make_channel(
"the-channel",
None,
(&client_a, cx_a),
&mut [(&client_b, cx_b)],
)
.await;
let channel_buffer_a = client_a
.channel_store()
.update(cx_a, |store, cx| store.open_channel_buffer(channel_id, cx))
.await
.unwrap();
let channel_buffer_b = client_b
.channel_store()
.update(cx_b, |store, cx| store.open_channel_buffer(channel_id, cx))
.await
.unwrap();
server.forbid_connections();
server.disconnect_client(client_a.peer_id().unwrap());
deterministic.advance_clock(RECEIVE_TIMEOUT + RECONNECT_TIMEOUT);
channel_buffer_a.update(cx_a, |buffer, cx| {
assert_eq!(buffer.channel(cx).unwrap().name, "the-channel");
assert!(!buffer.is_connected());
});
deterministic.run_until_parked();
server.allow_connections();
deterministic.advance_clock(RECEIVE_TIMEOUT + RECONNECT_TIMEOUT);
deterministic.run_until_parked();
client_a
.channel_store()
.update(cx_a, |channel_store, _| {
channel_store.remove_channel(channel_id)
})
.await
.unwrap();
deterministic.run_until_parked();
// Channel buffer observed the deletion
channel_buffer_b.update(cx_b, |buffer, cx| {
assert!(buffer.channel(cx).is_none());
assert!(!buffer.is_connected());
});
}
#[gpui::test]
async fn test_rejoin_channel_buffer(
deterministic: BackgroundExecutor,
cx_a: &mut TestAppContext,
cx_b: &mut TestAppContext,
) {
let mut server = TestServer::start(deterministic.clone()).await;
let client_a = server.create_client(cx_a, "user_a").await;
let client_b = server.create_client(cx_b, "user_b").await;
let channel_id = server
.make_channel(
"the-channel",
None,
(&client_a, cx_a),
&mut [(&client_b, cx_b)],
)
.await;
let channel_buffer_a = client_a
.channel_store()
.update(cx_a, |store, cx| store.open_channel_buffer(channel_id, cx))
.await
.unwrap();
let channel_buffer_b = client_b
.channel_store()
.update(cx_b, |store, cx| store.open_channel_buffer(channel_id, cx))
.await
.unwrap();
channel_buffer_a.update(cx_a, |buffer, cx| {
buffer.buffer().update(cx, |buffer, cx| {
buffer.edit([(0..0, "1")], None, cx);
})
});
deterministic.run_until_parked();
// Client A disconnects.
server.forbid_connections();
server.disconnect_client(client_a.peer_id().unwrap());
// Both clients make an edit.
channel_buffer_a.update(cx_a, |buffer, cx| {
buffer.buffer().update(cx, |buffer, cx| {
buffer.edit([(1..1, "2")], None, cx);
})
});
channel_buffer_b.update(cx_b, |buffer, cx| {
buffer.buffer().update(cx, |buffer, cx| {
buffer.edit([(0..0, "0")], None, cx);
})
});
// Both clients see their own edit.
deterministic.run_until_parked();
channel_buffer_a.read_with(cx_a, |buffer, cx| {
assert_eq!(buffer.buffer().read(cx).text(), "12");
});
channel_buffer_b.read_with(cx_b, |buffer, cx| {
assert_eq!(buffer.buffer().read(cx).text(), "01");
});
// Client A reconnects. Both clients see each other's edits, and see
// the same collaborators.
server.allow_connections();
deterministic.advance_clock(RECEIVE_TIMEOUT);
channel_buffer_a.read_with(cx_a, |buffer, cx| {
assert_eq!(buffer.buffer().read(cx).text(), "012");
});
channel_buffer_b.read_with(cx_b, |buffer, cx| {
assert_eq!(buffer.buffer().read(cx).text(), "012");
});
channel_buffer_a.read_with(cx_a, |buffer_a, _| {
channel_buffer_b.read_with(cx_b, |buffer_b, _| {
assert_eq!(buffer_a.collaborators(), buffer_b.collaborators());
});
});
}
#[gpui::test]
async fn test_channel_buffers_and_server_restarts(
deterministic: BackgroundExecutor,
cx_a: &mut TestAppContext,
cx_b: &mut TestAppContext,
cx_c: &mut TestAppContext,
) {
let mut server = TestServer::start(deterministic.clone()).await;
let client_a = server.create_client(cx_a, "user_a").await;
let client_b = server.create_client(cx_b, "user_b").await;
let client_c = server.create_client(cx_c, "user_c").await;
let channel_id = server
.make_channel(
"the-channel",
None,
(&client_a, cx_a),
&mut [(&client_b, cx_b), (&client_c, cx_c)],
)
.await;
let channel_buffer_a = client_a
.channel_store()
.update(cx_a, |store, cx| store.open_channel_buffer(channel_id, cx))
.await
.unwrap();
let channel_buffer_b = client_b
.channel_store()
.update(cx_b, |store, cx| store.open_channel_buffer(channel_id, cx))
.await
.unwrap();
let _channel_buffer_c = client_c
.channel_store()
.update(cx_c, |store, cx| store.open_channel_buffer(channel_id, cx))
.await
.unwrap();
channel_buffer_a.update(cx_a, |buffer, cx| {
buffer.buffer().update(cx, |buffer, cx| {
buffer.edit([(0..0, "1")], None, cx);
})
});
deterministic.run_until_parked();
// Client C can't reconnect.
client_c.override_establish_connection(|_, cx| cx.spawn(async |_| future::pending().await));
// Server stops.
server.reset().await;
deterministic.advance_clock(RECEIVE_TIMEOUT);
// While the server is down, both clients make an edit.
channel_buffer_a.update(cx_a, |buffer, cx| {
buffer.buffer().update(cx, |buffer, cx| {
buffer.edit([(1..1, "2")], None, cx);
})
});
channel_buffer_b.update(cx_b, |buffer, cx| {
buffer.buffer().update(cx, |buffer, cx| {
buffer.edit([(0..0, "0")], None, cx);
})
});
// Server restarts.
server.start().await.unwrap();
deterministic.advance_clock(CLEANUP_TIMEOUT);
// Clients reconnects. Clients A and B see each other's edits, and see
// that client C has disconnected.
channel_buffer_a.read_with(cx_a, |buffer, cx| {
assert_eq!(buffer.buffer().read(cx).text(), "012");
});
channel_buffer_b.read_with(cx_b, |buffer, cx| {
assert_eq!(buffer.buffer().read(cx).text(), "012");
});
channel_buffer_a.read_with(cx_a, |buffer_a, _| {
channel_buffer_b.read_with(cx_b, |buffer_b, _| {
assert_collaborators(
buffer_a.collaborators(),
&[client_a.user_id(), client_b.user_id()],
);
assert_eq!(buffer_a.collaborators(), buffer_b.collaborators());
});
});
}
#[gpui::test]
async fn test_channel_buffer_changes(
deterministic: BackgroundExecutor,
cx_a: &mut TestAppContext,
cx_b: &mut TestAppContext,
) {
let (server, client_a, client_b, channel_id) = TestServer::start2(cx_a, cx_b).await;
let (_, cx_a) = client_a.build_test_workspace(cx_a).await;
let (workspace_b, cx_b) = client_b.build_test_workspace(cx_b).await;
let channel_store_b = client_b.channel_store().clone();
// Editing the channel notes should set them to dirty
open_channel_notes(channel_id, cx_a).await.unwrap();
cx_a.simulate_keystrokes("1");
channel_store_b.read_with(cx_b, |channel_store, _| {
assert!(channel_store.has_channel_buffer_changed(channel_id))
});
// Opening the buffer should clear the changed flag.
open_channel_notes(channel_id, cx_b).await.unwrap();
channel_store_b.read_with(cx_b, |channel_store, _| {
assert!(!channel_store.has_channel_buffer_changed(channel_id))
});
// Editing the channel while the buffer is open should not show that the buffer has changed.
cx_a.simulate_keystrokes("2");
channel_store_b.read_with(cx_b, |channel_store, _| {
assert!(!channel_store.has_channel_buffer_changed(channel_id))
});
// Test that the server is tracking things correctly, and we retain our 'not changed'
// state across a disconnect
deterministic.advance_clock(ACKNOWLEDGE_DEBOUNCE_INTERVAL);
server
.simulate_long_connection_interruption(client_b.peer_id().unwrap(), deterministic.clone());
// Re-subscribe to channels after reconnection (simulates collab panel re-rendering)
client_b.initialize_channel_store(cx_b);
deterministic.run_until_parked();
channel_store_b.read_with(cx_b, |channel_store, _| {
assert!(!channel_store.has_channel_buffer_changed(channel_id))
});
// Closing the buffer should re-enable change tracking
cx_b.update(|window, cx| {
workspace_b.update(cx, |workspace, cx| {
workspace.close_all_items_and_panes(&Default::default(), window, cx)
});
});
deterministic.run_until_parked();
cx_a.simulate_keystrokes("3");
channel_store_b.read_with(cx_b, |channel_store, _| {
assert!(channel_store.has_channel_buffer_changed(channel_id))
});
}
#[gpui::test]
async fn test_channel_buffer_changes_persist(
cx_a: &mut TestAppContext,
cx_b: &mut TestAppContext,
cx_b2: &mut TestAppContext,
) {
let (mut server, client_a, client_b, channel_id) = TestServer::start2(cx_a, cx_b).await;
let (_, cx_a) = client_a.build_test_workspace(cx_a).await;
let (_, cx_b) = client_b.build_test_workspace(cx_b).await;
// a) edits the notes
open_channel_notes(channel_id, cx_a).await.unwrap();
cx_a.simulate_keystrokes("1");
// b) opens them to observe the current version
open_channel_notes(channel_id, cx_b).await.unwrap();
// On boot the client should get the correct state.
let client_b2 = server.create_client(cx_b2, "user_b").await;
let channel_store_b2 = client_b2.channel_store().clone();
channel_store_b2.read_with(cx_b2, |channel_store, _| {
assert!(!channel_store.has_channel_buffer_changed(channel_id))
});
}
#[gpui::test]
async fn test_channel_buffer_operations_lost_on_reconnect(
executor: BackgroundExecutor,
cx_a: &mut TestAppContext,
cx_b: &mut TestAppContext,
) {
let mut server = TestServer::start(executor.clone()).await;
let client_a = server.create_client(cx_a, "user_a").await;
let client_b = server.create_client(cx_b, "user_b").await;
let channel_id = server
.make_channel(
"the-channel",
None,
(&client_a, cx_a),
&mut [(&client_b, cx_b)],
)
.await;
// Both clients open the channel buffer.
let channel_buffer_a = client_a
.channel_store()
.update(cx_a, |store, cx| store.open_channel_buffer(channel_id, cx))
.await
.unwrap();
let channel_buffer_b = client_b
.channel_store()
.update(cx_b, |store, cx| store.open_channel_buffer(channel_id, cx))
.await
.unwrap();
// Step 1: Client A makes an initial edit that syncs to B.
channel_buffer_a.update(cx_a, |buffer, cx| {
buffer.buffer().update(cx, |buffer, cx| {
buffer.edit([(0..0, "a")], None, cx);
})
});
executor.run_until_parked();
// Verify both clients see "a".
channel_buffer_a.read_with(cx_a, |buffer, cx| {
assert_eq!(buffer.buffer().read(cx).text(), "a");
});
channel_buffer_b.read_with(cx_b, |buffer, cx| {
assert_eq!(buffer.buffer().read(cx).text(), "a");
});
// Step 2: Disconnect client A. Do NOT advance past RECONNECT_TIMEOUT
// so that the buffer stays in `opened_buffers` for rejoin.
server.forbid_connections();
server.disconnect_client(client_a.peer_id().unwrap());
executor.run_until_parked();
// Step 3: While disconnected, client A makes an offline edit ("b").
// on_buffer_update fires but client.send() fails because transport is down.
channel_buffer_a.update(cx_a, |buffer, cx| {
buffer.buffer().update(cx, |buffer, cx| {
buffer.edit([(1..1, "b")], None, cx);
})
});
executor.run_until_parked();
// Client A sees "ab" locally; B still sees "a".
channel_buffer_a.read_with(cx_a, |buffer, cx| {
assert_eq!(buffer.buffer().read(cx).text(), "ab");
});
channel_buffer_b.read_with(cx_b, |buffer, cx| {
assert_eq!(buffer.buffer().read(cx).text(), "a");
});
// Step 4: Reconnect and make a racing edit in parallel.
//
// The race condition occurs when:
// 1. Transport reconnects, handle_connect captures version V (with "b") and sends RejoinChannelBuffers
// 2. DURING the async gap (awaiting response), user makes edit "c"
// 3. on_buffer_update sends UpdateChannelBuffer (succeeds because transport is up)
// 4. Server receives BOTH messages concurrently (FuturesUnordered)
// 5. If UpdateChannelBuffer commits first, server version is inflated to include "c"
// 6. RejoinChannelBuffers reads inflated version and sends it back
// 7. Client's serialize_ops(inflated_version) filters out "b" (offline edit)
// because the inflated version's timestamp covers "b"'s timestamp
// Get the buffer handle for spawning
let buffer_for_edit = channel_buffer_a.read_with(cx_a, |buffer, _| buffer.buffer());
// Spawn the edit task - it will wait for executor to run it
let edit_task = cx_a.spawn({
let buffer = buffer_for_edit;
async move |mut cx| {
let _ = buffer.update(&mut cx, |buffer, cx| {
buffer.edit([(2..2, "c")], None, cx);
});
}
});
// Allow connections so reconnect can succeed
server.allow_connections();
// Advance clock to trigger reconnection attempt
executor.advance_clock(RECEIVE_TIMEOUT);
// Run the edit task - this races with handle_connect
edit_task.detach();
// Let everything settle.
executor.run_until_parked();
// Step 7: Read final buffer text from both clients.
let text_a = channel_buffer_a.read_with(cx_a, |buffer, cx| buffer.buffer().read(cx).text());
let text_b = channel_buffer_b.read_with(cx_b, |buffer, cx| buffer.buffer().read(cx).text());
// Both clients must see the same text containing all three edits.
assert_eq!(
text_a, text_b,
"Client A and B diverged! A sees {:?}, B sees {:?}. \
Operations were lost during reconnection.",
text_a, text_b
);
assert!(
text_a.contains('a'),
"Initial edit 'a' missing from final text {:?}",
text_a
);
assert!(
text_a.contains('b'),
"Offline edit 'b' missing from final text {:?}. \
This is the reconnection race bug: the offline operation was \
filtered out by serialize_ops because the server_version was \
inflated by a racing UpdateChannelBuffer.",
text_a
);
assert!(
text_a.contains('c'),
"Racing edit 'c' missing from final text {:?}",
text_a
);
// Step 8: Verify the invariant directly — every operation known to
// client A must be observed by client B's version. If any operation
// in A's history is not covered by B's version, it was lost.
channel_buffer_a.read_with(cx_a, |buf_a, cx_a_inner| {
let buffer_a = buf_a.buffer().read(cx_a_inner);
let ops_a = buffer_a.operations();
channel_buffer_b.read_with(cx_b, |buf_b, cx_b_inner| {
let buffer_b = buf_b.buffer().read(cx_b_inner);
let version_b = buffer_b.version();
for (lamport, _op) in ops_a.iter() {
assert!(
version_b.observed(*lamport),
"Operation with lamport timestamp {:?} from client A \
is NOT observed by client B's version. This operation \
was lost during reconnection.",
lamport
);
}
});
});
}
#[track_caller]
fn assert_collaborators(
collaborators: &HashMap<PeerId, Collaborator>,
ids: &[Option<LegacyUserId>],
) {
let mut user_ids = collaborators
.values()
.map(|collaborator| collaborator.user_id)
.collect::<Vec<_>>();
user_ids.sort();
assert_eq!(
user_ids,
ids.iter().map(|id| id.unwrap()).collect::<Vec<_>>()
);
}
fn buffer_text(channel_buffer: &Entity<language::Buffer>, cx: &mut TestAppContext) -> String {
channel_buffer.read_with(cx, |buffer, _| buffer.text())
}

View File

@@ -0,0 +1,314 @@
use crate::TestServer;
use call::ActiveCall;
use collab::db::ChannelId;
use editor::Editor;
use gpui::{BackgroundExecutor, TestAppContext};
use rpc::proto;
use util::rel_path::rel_path;
#[gpui::test]
async fn test_channel_guests(
executor: BackgroundExecutor,
cx_a: &mut TestAppContext,
cx_b: &mut TestAppContext,
) {
let mut server = TestServer::start(executor.clone()).await;
let client_a = server.create_client(cx_a, "user_a").await;
let client_b = server.create_client(cx_b, "user_b").await;
let active_call_a = cx_a.read(ActiveCall::global);
let channel_id = server
.make_public_channel("the-channel", &client_a, cx_a)
.await;
// Client A shares a project in the channel
let project_a = client_a.build_test_project(cx_a).await;
active_call_a
.update(cx_a, |call, cx| call.join_channel(channel_id, cx))
.await
.unwrap();
let project_id = active_call_a
.update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
.await
.unwrap();
cx_a.executor().run_until_parked();
// Client B joins channel A as a guest
cx_b.update(|cx| {
workspace::join_channel(channel_id, client_b.app_state.clone(), None, None, cx)
})
.await
.unwrap();
// b should be following a in the shared project.
// B is a guest,
executor.run_until_parked();
let active_call_b = cx_b.read(ActiveCall::global);
let project_b =
active_call_b.read_with(cx_b, |call, _| call.location().unwrap().upgrade().unwrap());
let room_b = active_call_b.update(cx_b, |call, _| call.room().unwrap().clone());
assert_eq!(
project_b.read_with(cx_b, |project, _| project.remote_id()),
Some(project_id),
);
assert!(project_b.read_with(cx_b, |project, cx| project.is_read_only(cx)));
assert!(
project_b
.update(cx_b, |project, cx| {
let worktree_id = project.worktrees(cx).next().unwrap().read(cx).id();
project.create_entry((worktree_id, rel_path("b.txt")), false, cx)
})
.await
.is_err()
);
assert!(room_b.read_with(cx_b, |room, _| room.is_muted()));
}
#[gpui::test]
async fn test_channel_guest_promotion(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) {
let mut server = TestServer::start(cx_a.executor()).await;
let client_a = server.create_client(cx_a, "user_a").await;
let client_b = server.create_client(cx_b, "user_b").await;
let active_call_a = cx_a.read(ActiveCall::global);
let channel_id = server
.make_public_channel("the-channel", &client_a, cx_a)
.await;
let project_a = client_a.build_test_project(cx_a).await;
cx_a.update(|cx| {
workspace::join_channel(channel_id, client_a.app_state.clone(), None, None, cx)
})
.await
.unwrap();
// Client A shares a project in the channel
active_call_a
.update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
.await
.unwrap();
cx_a.run_until_parked();
// Client B joins channel A as a guest
cx_b.update(|cx| {
workspace::join_channel(channel_id, client_b.app_state.clone(), None, None, cx)
})
.await
.unwrap();
cx_a.run_until_parked();
// client B opens 1.txt as a guest
let (workspace_b, cx_b) = client_b.active_workspace(cx_b);
let room_b = cx_b
.read(ActiveCall::global)
.update(cx_b, |call, _| call.room().unwrap().clone());
cx_b.simulate_keystrokes("cmd-p");
cx_a.run_until_parked();
cx_b.simulate_keystrokes("1 enter");
let (project_b, editor_b) = workspace_b.update(cx_b, |workspace, cx| {
(
workspace.project().clone(),
workspace.active_item_as::<Editor>(cx).unwrap(),
)
});
assert!(project_b.read_with(cx_b, |project, cx| project.is_read_only(cx)));
assert!(editor_b.update(cx_b, |e, cx| e.read_only(cx)));
cx_b.update(|_window, cx_b| {
assert!(room_b.read_with(cx_b, |room, _| !room.can_use_microphone()));
});
assert!(
room_b
.update(cx_b, |room, cx| room.share_microphone(cx))
.await
.is_err()
);
// B is promoted
active_call_a
.update(cx_a, |call, cx| {
call.room().unwrap().update(cx, |room, cx| {
room.set_participant_role(
client_b.user_id().unwrap(),
proto::ChannelRole::Member,
cx,
)
})
})
.await
.unwrap();
cx_a.run_until_parked();
// project and buffers are now editable
assert!(project_b.read_with(cx_b, |project, cx| !project.is_read_only(cx)));
assert!(editor_b.update(cx_b, |editor, cx| !editor.read_only(cx)));
// B sees themselves as muted, and can unmute.
cx_b.update(|_window, cx_b| {
assert!(room_b.read_with(cx_b, |room, _| room.can_use_microphone()));
});
room_b.read_with(cx_b, |room, _| assert!(room.is_muted()));
room_b.update(cx_b, |room, cx| room.toggle_mute(cx));
cx_a.run_until_parked();
room_b.read_with(cx_b, |room, _| assert!(!room.is_muted()));
// B is demoted
active_call_a
.update(cx_a, |call, cx| {
call.room().unwrap().update(cx, |room, cx| {
room.set_participant_role(
client_b.user_id().unwrap(),
proto::ChannelRole::Guest,
cx,
)
})
})
.await
.unwrap();
cx_a.run_until_parked();
// project and buffers are no longer editable
assert!(project_b.read_with(cx_b, |project, cx| project.is_read_only(cx)));
assert!(editor_b.update(cx_b, |editor, cx| editor.read_only(cx)));
assert!(
room_b
.update(cx_b, |room, cx| room.share_microphone(cx))
.await
.is_err()
);
}
#[gpui::test]
async fn test_channel_requires_zed_cla(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) {
let mut server = TestServer::start(cx_a.executor()).await;
let client_a = server.create_client(cx_a, "user_a").await;
let client_b = server.create_client(cx_b, "user_b").await;
let active_call_a = cx_a.read(ActiveCall::global);
let active_call_b = cx_b.read(ActiveCall::global);
// Create a parent channel that requires the Zed CLA
let parent_channel_id = server
.make_channel("the-channel", None, (&client_a, cx_a), &mut [])
.await;
server
.app_state
.db
.set_channel_requires_zed_cla(ChannelId::from_proto(parent_channel_id.0), true)
.await
.unwrap();
// Create a public channel that is a child of the parent channel.
let channel_id = client_a
.channel_store()
.update(cx_a, |store, cx| {
store.create_channel("the-sub-channel", Some(parent_channel_id), cx)
})
.await
.unwrap();
client_a
.channel_store()
.update(cx_a, |store, cx| {
store.set_channel_visibility(parent_channel_id, proto::ChannelVisibility::Public, cx)
})
.await
.unwrap();
client_a
.channel_store()
.update(cx_a, |store, cx| {
store.set_channel_visibility(channel_id, proto::ChannelVisibility::Public, cx)
})
.await
.unwrap();
// Users A and B join the channel. B is a guest.
active_call_a
.update(cx_a, |call, cx| call.join_channel(channel_id, cx))
.await
.unwrap();
active_call_b
.update(cx_b, |call, cx| call.join_channel(channel_id, cx))
.await
.unwrap();
cx_a.run_until_parked();
let room_b = cx_b
.read(ActiveCall::global)
.update(cx_b, |call, _| call.room().unwrap().clone());
cx_b.update(|cx_b| {
assert!(room_b.read_with(cx_b, |room, _| !room.can_use_microphone()));
});
// A tries to grant write access to B, but cannot because B has not
// yet signed the zed CLA.
active_call_a
.update(cx_a, |call, cx| {
call.room().unwrap().update(cx, |room, cx| {
room.set_participant_role(
client_b.user_id().unwrap(),
proto::ChannelRole::Member,
cx,
)
})
})
.await
.unwrap_err();
cx_a.run_until_parked();
assert!(room_b.read_with(cx_b, |room, _| !room.can_share_projects()));
cx_b.update(|cx_b| {
assert!(room_b.read_with(cx_b, |room, _| !room.can_use_microphone()));
});
// A tries to grant write access to B, but cannot because B has not
// yet signed the zed CLA.
active_call_a
.update(cx_a, |call, cx| {
call.room().unwrap().update(cx, |room, cx| {
room.set_participant_role(
client_b.user_id().unwrap(),
proto::ChannelRole::Talker,
cx,
)
})
})
.await
.unwrap();
cx_a.run_until_parked();
assert!(room_b.read_with(cx_b, |room, _| !room.can_share_projects()));
cx_b.update(|cx_b| {
assert!(room_b.read_with(cx_b, |room, _| room.can_use_microphone()));
});
// User B signs the zed CLA.
let user_b = server
.app_state
.user_service
.get_user_by_github_login("user_b")
.await
.unwrap()
.expect("user_b not found");
server
.app_state
.db
.add_contributor(user_b.id)
.await
.unwrap();
// A can now grant write access to B.
active_call_a
.update(cx_a, |call, cx| {
call.room().unwrap().update(cx, |room, cx| {
room.set_participant_role(
client_b.user_id().unwrap(),
proto::ChannelRole::Member,
cx,
)
})
})
.await
.unwrap();
cx_a.run_until_parked();
assert!(room_b.read_with(cx_b, |room, _| room.can_share_projects()));
cx_b.update(|cx_b| {
assert!(room_b.read_with(cx_b, |room, _| room.can_use_microphone()));
});
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,356 @@
use crate::TestServer;
use collab_ui::CollabPanel;
use collab_ui::collab_panel::{MoveChannelDown, MoveChannelUp, ToggleSelectedChannelFavorite};
use gpui::TestAppContext;
use menu::{SelectNext, SelectPrevious};
#[gpui::test]
async fn test_reorder_favorite_channels_independently_of_channels(cx: &mut TestAppContext) {
let (server, client) = TestServer::start1(cx).await;
let root = server
.make_channel("root", None, (&client, cx), &mut [])
.await;
let _ = server
.make_channel("channel-a", Some(root), (&client, cx), &mut [])
.await;
let _ = server
.make_channel("channel-b", Some(root), (&client, cx), &mut [])
.await;
let _ = server
.make_channel("channel-c", Some(root), (&client, cx), &mut [])
.await;
let (workspace, cx) = client.build_test_workspace(cx).await;
let panel = workspace.update_in(cx, |workspace, window, cx| {
let panel = CollabPanel::new(workspace, window, cx);
workspace.add_panel(panel.clone(), window, cx);
panel
});
cx.run_until_parked();
// Verify initial state.
assert_eq!(
panel.read_with(cx, |panel, _| panel.entries_as_strings()),
&[
"[Channels]",
" v root",
" #️⃣ channel-a",
" #️⃣ channel-b",
" #️⃣ channel-c",
"[Contacts]",
]
);
// Select channel-b.
panel.update_in(cx, |panel, window, cx| {
panel.select_next(&SelectNext, window, cx);
panel.select_next(&SelectNext, window, cx);
panel.select_next(&SelectNext, window, cx);
panel.select_next(&SelectNext, window, cx);
});
assert_eq!(
panel.read_with(cx, |panel, _| panel.entries_as_strings()),
&[
"[Channels]",
" v root",
" #️⃣ channel-a",
" #️⃣ channel-b <== selected",
" #️⃣ channel-c",
"[Contacts]",
]
);
// Favorite channel-b.
panel.update_in(cx, |panel, window, cx| {
panel.toggle_selected_channel_favorite(&ToggleSelectedChannelFavorite, window, cx);
});
assert_eq!(
panel.read_with(cx, |panel, _| panel.entries_as_strings()),
&[
"[Favorites]",
" #️⃣ channel-b",
"[Channels]",
" v root",
" #️⃣ channel-a",
" #️⃣ channel-b <== selected",
" #️⃣ channel-c",
"[Contacts]",
]
);
// Select channel-c.
panel.update_in(cx, |panel, window, cx| {
panel.select_next(&SelectNext, window, cx);
});
// Favorite channel-c.
panel.update_in(cx, |panel, window, cx| {
panel.toggle_selected_channel_favorite(&ToggleSelectedChannelFavorite, window, cx);
});
assert_eq!(
panel.read_with(cx, |panel, _| panel.entries_as_strings()),
&[
"[Favorites]",
" #️⃣ channel-b",
" #️⃣ channel-c",
"[Channels]",
" v root",
" #️⃣ channel-a",
" #️⃣ channel-b",
" #️⃣ channel-c <== selected",
"[Contacts]",
]
);
// Navigate up to favorite channel-b .
panel.update_in(cx, |panel, window, cx| {
panel.select_previous(&SelectPrevious, window, cx);
panel.select_previous(&SelectPrevious, window, cx);
panel.select_previous(&SelectPrevious, window, cx);
panel.select_previous(&SelectPrevious, window, cx);
panel.select_previous(&SelectPrevious, window, cx);
panel.select_previous(&SelectPrevious, window, cx);
});
assert_eq!(
panel.read_with(cx, |panel, _| panel.entries_as_strings()),
&[
"[Favorites]",
" #️⃣ channel-b <== selected",
" #️⃣ channel-c",
"[Channels]",
" v root",
" #️⃣ channel-a",
" #️⃣ channel-b",
" #️⃣ channel-c",
"[Contacts]",
]
);
// Move favorite channel-b down.
// The Channels section should remain unchanged
panel.update_in(cx, |panel, window, cx| {
panel.move_channel_down(&MoveChannelDown, window, cx);
});
assert_eq!(
panel.read_with(cx, |panel, _| panel.entries_as_strings()),
&[
"[Favorites]",
" #️⃣ channel-c",
" #️⃣ channel-b <== selected",
"[Channels]",
" v root",
" #️⃣ channel-a",
" #️⃣ channel-b",
" #️⃣ channel-c",
"[Contacts]",
]
);
// Move favorite channel-b down again when it's already last (should be no-op).
panel.update_in(cx, |panel, window, cx| {
panel.move_channel_down(&MoveChannelDown, window, cx);
});
assert_eq!(
panel.read_with(cx, |panel, _| panel.entries_as_strings()),
&[
"[Favorites]",
" #️⃣ channel-c",
" #️⃣ channel-b <== selected",
"[Channels]",
" v root",
" #️⃣ channel-a",
" #️⃣ channel-b",
" #️⃣ channel-c",
"[Contacts]",
]
);
// Move favorite channel-b back up.
// The Channels section should remain unchanged.
panel.update_in(cx, |panel, window, cx| {
panel.move_channel_up(&MoveChannelUp, window, cx);
});
assert_eq!(
panel.read_with(cx, |panel, _| panel.entries_as_strings()),
&[
"[Favorites]",
" #️⃣ channel-b <== selected",
" #️⃣ channel-c",
"[Channels]",
" v root",
" #️⃣ channel-a",
" #️⃣ channel-b",
" #️⃣ channel-c",
"[Contacts]",
]
);
// Move favorite channel-b up again when it's already first (should be no-op).
panel.update_in(cx, |panel, window, cx| {
panel.move_channel_up(&MoveChannelUp, window, cx);
});
assert_eq!(
panel.read_with(cx, |panel, _| panel.entries_as_strings()),
&[
"[Favorites]",
" #️⃣ channel-b <== selected",
" #️⃣ channel-c",
"[Channels]",
" v root",
" #️⃣ channel-a",
" #️⃣ channel-b",
" #️⃣ channel-c",
"[Contacts]",
]
);
// Unfavorite channel-b.
// Selection should move to the next favorite (channel-c).
panel.update_in(cx, |panel, window, cx| {
panel.toggle_selected_channel_favorite(&ToggleSelectedChannelFavorite, window, cx);
});
assert_eq!(
panel.read_with(cx, |panel, _| panel.entries_as_strings()),
&[
"[Favorites]",
" #️⃣ channel-c <== selected",
"[Channels]",
" v root",
" #️⃣ channel-a",
" #️⃣ channel-b",
" #️⃣ channel-c",
"[Contacts]",
]
);
// Unfavorite channel-c.
// Favorites section should disappear entirely.
// Selection should move to the next available item.
panel.update_in(cx, |panel, window, cx| {
panel.toggle_selected_channel_favorite(&ToggleSelectedChannelFavorite, window, cx);
});
assert_eq!(
panel.read_with(cx, |panel, _| panel.entries_as_strings()),
&[
"[Channels]",
" v root <== selected",
" #️⃣ channel-a",
" #️⃣ channel-b",
" #️⃣ channel-c",
"[Contacts]",
]
);
}
#[gpui::test]
async fn test_reorder_channels_independently_of_favorites(cx: &mut TestAppContext) {
let (server, client) = TestServer::start1(cx).await;
let root = server
.make_channel("root", None, (&client, cx), &mut [])
.await;
let _ = server
.make_channel("channel-a", Some(root), (&client, cx), &mut [])
.await;
let _ = server
.make_channel("channel-b", Some(root), (&client, cx), &mut [])
.await;
let _ = server
.make_channel("channel-c", Some(root), (&client, cx), &mut [])
.await;
let (workspace, cx) = client.build_test_workspace(cx).await;
let panel = workspace.update_in(cx, |workspace, window, cx| {
let panel = CollabPanel::new(workspace, window, cx);
workspace.add_panel(panel.clone(), window, cx);
panel
});
cx.run_until_parked();
// Select channel-a.
panel.update_in(cx, |panel, window, cx| {
panel.select_next(&SelectNext, window, cx);
panel.select_next(&SelectNext, window, cx);
panel.select_next(&SelectNext, window, cx);
});
assert_eq!(
panel.read_with(cx, |panel, _| panel.entries_as_strings()),
&[
"[Channels]",
" v root",
" #️⃣ channel-a <== selected",
" #️⃣ channel-b",
" #️⃣ channel-c",
"[Contacts]",
]
);
// Favorite channel-a.
panel.update_in(cx, |panel, window, cx| {
panel.toggle_selected_channel_favorite(&ToggleSelectedChannelFavorite, window, cx);
});
// Select channel-b.
// Favorite channel-b.
panel.update_in(cx, |panel, window, cx| {
panel.select_next(&SelectNext, window, cx);
panel.toggle_selected_channel_favorite(&ToggleSelectedChannelFavorite, window, cx);
});
cx.run_until_parked();
assert_eq!(
panel.read_with(cx, |panel, _| panel.entries_as_strings()),
&[
"[Favorites]",
" #️⃣ channel-a",
" #️⃣ channel-b",
"[Channels]",
" v root",
" #️⃣ channel-a",
" #️⃣ channel-b <== selected",
" #️⃣ channel-c",
"[Contacts]",
]
);
// Select channel-a in the Channels section.
panel.update_in(cx, |panel, window, cx| {
panel.select_previous(&SelectPrevious, window, cx);
});
assert_eq!(
panel.read_with(cx, |panel, _| panel.entries_as_strings()),
&[
"[Favorites]",
" #️⃣ channel-a",
" #️⃣ channel-b",
"[Channels]",
" v root",
" #️⃣ channel-a <== selected",
" #️⃣ channel-b",
" #️⃣ channel-c",
"[Contacts]",
]
);
// Move channel-a down.
// The Favorites section should remain unchanged.
// Selection should remain on channel-a in the Channels section,
// not jump to channel-a in Favorites.
panel.update_in(cx, |panel, window, cx| {
panel.move_channel_down(&MoveChannelDown, window, cx);
});
cx.run_until_parked();
assert_eq!(
panel.read_with(cx, |panel, _| panel.entries_as_strings()),
&[
"[Favorites]",
" #️⃣ channel-a",
" #️⃣ channel-b",
"[Channels]",
" v root",
" #️⃣ channel-b",
" #️⃣ channel-a <== selected",
" #️⃣ channel-c",
"[Contacts]",
]
);
}

View File

@@ -0,0 +1,54 @@
use call::Room;
use client::ChannelId;
use gpui::{Entity, TestAppContext};
mod agent_sharing_tests;
mod auto_watch_tests;
mod channel_buffer_tests;
mod channel_guest_tests;
mod channel_tests;
mod collab_panel_tests;
mod db_tests;
mod editor_tests;
mod following_tests;
mod git_tests;
mod integration_tests;
mod notification_tests;
mod random_channel_buffer_tests;
mod random_project_collaboration_tests;
mod randomized_test_helpers;
mod remote_editing_collaboration_tests;
mod test_server;
pub use randomized_test_helpers::{
RandomizedTest, TestError, UserTestPlan, run_randomized_test, save_randomized_test_plan,
};
pub use test_server::{TestClient, TestServer};
#[derive(Debug, Eq, PartialEq)]
struct RoomParticipants {
remote: Vec<String>,
pending: Vec<String>,
}
fn room_participants(room: &Entity<Room>, cx: &mut TestAppContext) -> RoomParticipants {
room.read_with(cx, |room, _| {
let mut remote = room
.remote_participants()
.values()
.map(|participant| participant.user.github_login.clone().to_string())
.collect::<Vec<_>>();
let mut pending = room
.pending_participants()
.iter()
.map(|user| user.github_login.clone().to_string())
.collect::<Vec<_>>();
remote.sort();
pending.sort();
RoomParticipants { remote, pending }
})
}
fn channel_id(room: &Entity<Room>, cx: &mut TestAppContext) -> Option<ChannelId> {
cx.read(|cx| room.read(cx).channel_id())
}

View File

@@ -0,0 +1,223 @@
mod buffer_tests;
mod channel_tests;
mod db_tests;
mod extension_tests;
mod migrations;
use std::sync::Arc;
use std::sync::atomic::{AtomicI32, Ordering::SeqCst};
use std::time::Duration;
use collections::HashSet;
use gpui::BackgroundExecutor;
use parking_lot::Mutex;
use rand::prelude::*;
use sea_orm::ConnectionTrait;
use sqlx::migrate::MigrateDatabase;
use self::migrations::run_database_migrations;
use collab::db::*;
pub struct TestDb {
pub db: Option<Arc<Database>>,
pub connection: Option<sqlx::AnyConnection>,
}
impl TestDb {
pub fn sqlite(executor: BackgroundExecutor) -> Self {
let url = "sqlite::memory:";
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_io()
.enable_time()
.build()
.unwrap();
let mut db = runtime.block_on(async {
let mut options = ConnectOptions::new(url);
options.max_connections(5);
let mut db = Database::new(options).await.unwrap();
let sql = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/migrations.sqlite/20221109000000_test_schema.sql"
));
db.pool
.execute(sea_orm::Statement::from_string(
db.pool.get_database_backend(),
sql,
))
.await
.unwrap();
db.initialize_notification_kinds().await.unwrap();
db
});
db.test_options = Some(DatabaseTestOptions {
executor,
runtime,
query_failure_probability: parking_lot::Mutex::new(0.0),
});
Self {
db: Some(Arc::new(db)),
connection: None,
}
}
pub fn postgres(executor: BackgroundExecutor) -> Self {
static LOCK: Mutex<()> = Mutex::new(());
let _guard = LOCK.lock();
let mut rng = StdRng::from_os_rng();
let url = format!(
"postgres://postgres@localhost/zed-test-{}",
rng.random::<u128>()
);
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_io()
.enable_time()
.build()
.unwrap();
let mut db = runtime.block_on(async {
sqlx::Postgres::create_database(&url)
.await
.expect("failed to create test db");
let mut options = ConnectOptions::new(url);
options
.max_connections(5)
.idle_timeout(Duration::from_secs(0));
let mut db = Database::new(options).await.unwrap();
let migrations_path = concat!(env!("CARGO_MANIFEST_DIR"), "/migrations");
run_database_migrations(db.options(), migrations_path)
.await
.unwrap();
db.initialize_notification_kinds().await.unwrap();
db
});
db.test_options = Some(DatabaseTestOptions {
executor,
runtime,
query_failure_probability: parking_lot::Mutex::new(0.0),
});
Self {
db: Some(Arc::new(db)),
connection: None,
}
}
pub fn db(&self) -> &Arc<Database> {
self.db.as_ref().unwrap()
}
pub fn set_query_failure_probability(&self, probability: f64) {
let database = self.db.as_ref().unwrap();
let test_options = database.test_options.as_ref().unwrap();
*test_options.query_failure_probability.lock() = probability;
}
}
#[macro_export]
macro_rules! test_both_dbs {
($test_name:ident, $postgres_test_name:ident, $sqlite_test_name:ident) => {
#[gpui::test]
async fn $postgres_test_name(cx: &mut gpui::TestAppContext) {
// In CI, only run postgres tests on Linux (where we have the postgres service).
// Locally, always run them (assuming postgres is available).
if std::env::var("CI").is_ok() && !cfg!(target_os = "linux") {
return;
}
let test_db = $crate::db_tests::TestDb::postgres(cx.executor().clone());
$test_name(test_db.db()).await;
}
#[gpui::test]
async fn $sqlite_test_name(cx: &mut gpui::TestAppContext) {
let test_db = $crate::db_tests::TestDb::sqlite(cx.executor().clone());
$test_name(test_db.db()).await;
}
};
}
impl Drop for TestDb {
fn drop(&mut self) {
let db = self.db.take().unwrap();
if let sea_orm::DatabaseBackend::Postgres = db.pool.get_database_backend() {
db.test_options.as_ref().unwrap().runtime.block_on(async {
use util::ResultExt;
let query = "
SELECT pg_terminate_backend(pg_stat_activity.pid)
FROM pg_stat_activity
WHERE
pg_stat_activity.datname = current_database() AND
pid <> pg_backend_pid();
";
db.pool
.execute(sea_orm::Statement::from_string(
db.pool.get_database_backend(),
query,
))
.await
.log_err();
sqlx::Postgres::drop_database(db.options.get_url())
.await
.log_err();
})
}
}
}
#[track_caller]
fn assert_channel_tree_matches(actual: Vec<Channel>, expected: Vec<Channel>) {
let expected_channels = expected.into_iter().collect::<HashSet<_>>();
let actual_channels = actual.into_iter().collect::<HashSet<_>>();
pretty_assertions::assert_eq!(expected_channels, actual_channels);
}
fn channel_tree(channels: &[(ChannelId, &[ChannelId], &'static str)]) -> Vec<Channel> {
use std::collections::HashMap;
let mut result = Vec::new();
let mut order_by_parent: HashMap<Vec<ChannelId>, i32> = HashMap::new();
for (id, parent_path, name) in channels {
let parent_key = parent_path.to_vec();
let order = if parent_key.is_empty() {
1
} else {
*order_by_parent
.entry(parent_key.clone())
.and_modify(|e| *e += 1)
.or_insert(1)
};
result.push(Channel {
id: *id,
name: (*name).to_owned(),
visibility: ChannelVisibility::Members,
parent_path: parent_key,
channel_order: order,
});
}
result
}
static GITHUB_USER_ID: AtomicI32 = AtomicI32::new(5);
async fn new_test_user(db: &Arc<Database>, email: &str) -> UserId {
db.create_user(
email,
None,
false,
NewUserParams {
github_login: email[0..email.find('@').unwrap()].to_string(),
github_user_id: GITHUB_USER_ID.fetch_add(1, SeqCst),
},
)
.await
.unwrap()
.user_id
}

View File

@@ -0,0 +1,362 @@
use super::*;
use crate::test_both_dbs;
use language::proto::{self, serialize_version};
use rpc::ConnectionId;
use text::{Buffer, ReplicaId};
test_both_dbs!(
test_channel_buffers,
test_channel_buffers_postgres,
test_channel_buffers_sqlite
);
async fn test_channel_buffers(db: &Arc<Database>) {
let a_id = db
.create_user(
"user_a@example.com",
None,
false,
NewUserParams {
github_login: "user_a".into(),
github_user_id: 101,
},
)
.await
.unwrap()
.user_id;
let b_id = db
.create_user(
"user_b@example.com",
None,
false,
NewUserParams {
github_login: "user_b".into(),
github_user_id: 102,
},
)
.await
.unwrap()
.user_id;
// This user will not be a part of the channel
let c_id = db
.create_user(
"user_c@example.com",
None,
false,
NewUserParams {
github_login: "user_c".into(),
github_user_id: 103,
},
)
.await
.unwrap()
.user_id;
let owner_id = db.create_server("production").await.unwrap().0 as u32;
let zed_id = db.create_root_channel("zed", a_id).await.unwrap();
db.invite_channel_member(zed_id, b_id, a_id, ChannelRole::Member)
.await
.unwrap();
db.respond_to_channel_invite(zed_id, b_id, true)
.await
.unwrap();
let connection_id_a = ConnectionId { owner_id, id: 1 };
let _ = db
.join_channel_buffer(zed_id, a_id, connection_id_a)
.await
.unwrap();
let mut buffer_a = Buffer::new(
ReplicaId::new(0),
text::BufferId::new(1).unwrap(),
"".to_string(),
);
let operations = vec![
buffer_a.edit([(0..0, "hello world")]),
buffer_a.edit([(5..5, ", cruel")]),
buffer_a.edit([(0..5, "goodbye")]),
buffer_a.undo().unwrap().1,
];
assert_eq!(buffer_a.text(), "hello, cruel world");
let operations = operations
.into_iter()
.map(|op| proto::serialize_operation(&language::Operation::Buffer(op)))
.collect::<Vec<_>>();
db.update_channel_buffer(zed_id, a_id, &operations)
.await
.unwrap();
let connection_id_b = ConnectionId { owner_id, id: 2 };
let buffer_response_b = db
.join_channel_buffer(zed_id, b_id, connection_id_b)
.await
.unwrap();
let mut buffer_b = Buffer::new(
ReplicaId::new(0),
text::BufferId::new(1).unwrap(),
buffer_response_b.base_text,
);
buffer_b.apply_ops(buffer_response_b.operations.into_iter().map(|operation| {
let operation = proto::deserialize_operation(operation).unwrap();
if let language::Operation::Buffer(operation) = operation {
operation
} else {
unreachable!()
}
}));
assert_eq!(buffer_b.text(), "hello, cruel world");
// Ensure that C fails to open the buffer
assert!(
db.join_channel_buffer(zed_id, c_id, ConnectionId { owner_id, id: 3 })
.await
.is_err()
);
// Ensure that both collaborators have shown up
assert_eq!(
buffer_response_b.collaborators,
&[
rpc::proto::Collaborator {
user_id: a_id.to_proto(),
peer_id: Some(rpc::proto::PeerId { id: 1, owner_id }),
replica_id: ReplicaId::FIRST_COLLAB_ID.as_u16() as u32,
is_host: false,
committer_name: None,
committer_email: None,
},
rpc::proto::Collaborator {
user_id: b_id.to_proto(),
peer_id: Some(rpc::proto::PeerId { id: 2, owner_id }),
replica_id: ReplicaId::FIRST_COLLAB_ID.as_u16() as u32 + 1,
is_host: false,
committer_name: None,
committer_email: None,
}
]
);
// Ensure that get_channel_buffer_collaborators works
let zed_collaborats = db.get_channel_buffer_collaborators(zed_id).await.unwrap();
assert_eq!(zed_collaborats, &[a_id, b_id]);
let left_buffer = db
.leave_channel_buffer(zed_id, connection_id_b)
.await
.unwrap();
assert_eq!(left_buffer.connections, &[connection_id_a],);
let cargo_id = db.create_root_channel("cargo", a_id).await.unwrap();
let _ = db
.join_channel_buffer(cargo_id, a_id, connection_id_a)
.await
.unwrap();
db.leave_channel_buffers(connection_id_a).await.unwrap();
let zed_collaborators = db.get_channel_buffer_collaborators(zed_id).await.unwrap();
let cargo_collaborators = db.get_channel_buffer_collaborators(cargo_id).await.unwrap();
assert_eq!(zed_collaborators, &[]);
assert_eq!(cargo_collaborators, &[]);
// When everyone has left the channel, the operations are collapsed into
// a new base text.
let buffer_response_b = db
.join_channel_buffer(zed_id, b_id, connection_id_b)
.await
.unwrap();
assert_eq!(buffer_response_b.base_text, "hello, cruel world");
assert_eq!(buffer_response_b.operations, &[]);
}
test_both_dbs!(
test_channel_buffers_last_operations,
test_channel_buffers_last_operations_postgres,
test_channel_buffers_last_operations_sqlite
);
async fn test_channel_buffers_last_operations(db: &Database) {
let user_id = db
.create_user(
"user_a@example.com",
None,
false,
NewUserParams {
github_login: "user_a".into(),
github_user_id: 101,
},
)
.await
.unwrap()
.user_id;
let observer_id = db
.create_user(
"user_b@example.com",
None,
false,
NewUserParams {
github_login: "user_b".into(),
github_user_id: 102,
},
)
.await
.unwrap()
.user_id;
let owner_id = db.create_server("production").await.unwrap().0 as u32;
let connection_id = ConnectionId {
owner_id,
id: user_id.0 as u32,
};
let mut buffers = Vec::new();
let mut text_buffers = Vec::new();
for i in 0..3 {
let channel = db
.create_root_channel(&format!("channel-{i}"), user_id)
.await
.unwrap();
db.invite_channel_member(channel, observer_id, user_id, ChannelRole::Member)
.await
.unwrap();
db.respond_to_channel_invite(channel, observer_id, true)
.await
.unwrap();
let res = db
.join_channel_buffer(channel, user_id, connection_id)
.await
.unwrap();
buffers.push(
db.transaction(|tx| async move { db.get_channel_buffer(channel, &tx).await })
.await
.unwrap(),
);
text_buffers.push(Buffer::new(
ReplicaId::new(res.replica_id as u16),
text::BufferId::new(1).unwrap(),
"".to_string(),
));
}
update_buffer(
buffers[0].channel_id,
user_id,
db,
vec![
text_buffers[0].edit([(0..0, "a")]),
text_buffers[0].edit([(0..0, "b")]),
text_buffers[0].edit([(0..0, "c")]),
],
)
.await;
update_buffer(
buffers[1].channel_id,
user_id,
db,
vec![
text_buffers[1].edit([(0..0, "d")]),
text_buffers[1].edit([(1..1, "e")]),
text_buffers[1].edit([(2..2, "f")]),
],
)
.await;
// cause buffer 1's epoch to increment.
db.leave_channel_buffer(buffers[1].channel_id, connection_id)
.await
.unwrap();
db.join_channel_buffer(buffers[1].channel_id, user_id, connection_id)
.await
.unwrap();
let replica_id = text_buffers[1].replica_id();
text_buffers[1] = Buffer::new(
replica_id,
text::BufferId::new(1).unwrap(),
"def".to_string(),
);
update_buffer(
buffers[1].channel_id,
user_id,
db,
vec![
text_buffers[1].edit([(0..0, "g")]),
text_buffers[1].edit([(0..0, "h")]),
],
)
.await;
update_buffer(
buffers[2].channel_id,
user_id,
db,
vec![text_buffers[2].edit([(0..0, "i")])],
)
.await;
let channels_for_user = db.get_channels_for_user(user_id).await.unwrap();
pretty_assertions::assert_eq!(
channels_for_user.latest_buffer_versions,
[
rpc::proto::ChannelBufferVersion {
channel_id: buffers[0].channel_id.to_proto(),
epoch: 0,
version: serialize_version(&text_buffers[0].version())
.into_iter()
.filter(
|vector| vector.replica_id == text_buffers[0].replica_id().as_u16() as u32
)
.collect::<Vec<_>>(),
},
rpc::proto::ChannelBufferVersion {
channel_id: buffers[1].channel_id.to_proto(),
epoch: 1,
version: serialize_version(&text_buffers[1].version())
.into_iter()
.filter(
|vector| vector.replica_id == text_buffers[1].replica_id().as_u16() as u32
)
.collect::<Vec<_>>(),
},
rpc::proto::ChannelBufferVersion {
channel_id: buffers[2].channel_id.to_proto(),
epoch: 0,
version: serialize_version(&text_buffers[2].version())
.into_iter()
.filter(
|vector| vector.replica_id == text_buffers[2].replica_id().as_u16() as u32
)
.collect::<Vec<_>>(),
},
]
);
}
async fn update_buffer(
channel_id: ChannelId,
user_id: UserId,
db: &Database,
operations: Vec<text::Operation>,
) {
let operations = operations
.into_iter()
.map(|op| proto::serialize_operation(&language::Operation::Buffer(op)))
.collect::<Vec<_>>();
db.update_channel_buffer(channel_id, user_id, &operations)
.await
.unwrap();
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,383 @@
use crate::test_both_dbs;
use super::*;
use collab::db::RoomId;
use collab::db::*;
use pretty_assertions::assert_eq;
use rpc::ConnectionId;
use std::sync::Arc;
test_both_dbs!(
test_add_contacts,
test_add_contacts_postgres,
test_add_contacts_sqlite
);
async fn test_add_contacts(db: &Arc<Database>) {
let mut user_ids = Vec::new();
for i in 0..3 {
user_ids.push(
db.create_user(
&format!("user{i}@example.com"),
None,
false,
NewUserParams {
github_login: format!("user{i}"),
github_user_id: i,
},
)
.await
.unwrap()
.user_id,
);
}
let user_1 = user_ids[0];
let user_2 = user_ids[1];
let user_3 = user_ids[2];
// User starts with no contacts
assert_eq!(db.get_contacts(user_1).await.unwrap(), &[]);
// User requests a contact. Both users see the pending request.
db.send_contact_request(user_1, user_2).await.unwrap();
assert!(!db.has_contact(user_1, user_2).await.unwrap());
assert!(!db.has_contact(user_2, user_1).await.unwrap());
assert_eq!(
db.get_contacts(user_1).await.unwrap(),
&[Contact::Outgoing { user_id: user_2 }],
);
assert_eq!(
db.get_contacts(user_2).await.unwrap(),
&[Contact::Incoming { user_id: user_1 }]
);
// User 2 dismisses the contact request notification without accepting or rejecting.
// We shouldn't notify them again.
db.dismiss_contact_notification(user_1, user_2)
.await
.unwrap_err();
db.dismiss_contact_notification(user_2, user_1)
.await
.unwrap();
assert_eq!(
db.get_contacts(user_2).await.unwrap(),
&[Contact::Incoming { user_id: user_1 }]
);
// User can't accept their own contact request
db.respond_to_contact_request(user_1, user_2, true)
.await
.unwrap_err();
// User accepts a contact request. Both users see the contact.
db.respond_to_contact_request(user_2, user_1, true)
.await
.unwrap();
assert_eq!(
db.get_contacts(user_1).await.unwrap(),
&[Contact::Accepted {
user_id: user_2,
busy: false,
}],
);
assert!(db.has_contact(user_1, user_2).await.unwrap());
assert!(db.has_contact(user_2, user_1).await.unwrap());
assert_eq!(
db.get_contacts(user_2).await.unwrap(),
&[Contact::Accepted {
user_id: user_1,
busy: false,
}]
);
// Users cannot re-request existing contacts.
db.send_contact_request(user_1, user_2).await.unwrap_err();
db.send_contact_request(user_2, user_1).await.unwrap_err();
// Users can't dismiss notifications of them accepting other users' requests.
db.dismiss_contact_notification(user_2, user_1)
.await
.unwrap_err();
assert_eq!(
db.get_contacts(user_1).await.unwrap(),
&[Contact::Accepted {
user_id: user_2,
busy: false,
}]
);
// Users can dismiss notifications of other users accepting their requests.
db.dismiss_contact_notification(user_1, user_2)
.await
.unwrap();
assert_eq!(
db.get_contacts(user_1).await.unwrap(),
&[Contact::Accepted {
user_id: user_2,
busy: false,
}]
);
// Users send each other concurrent contact requests and
// see that they are immediately accepted.
db.send_contact_request(user_1, user_3).await.unwrap();
db.send_contact_request(user_3, user_1).await.unwrap();
assert_eq!(
db.get_contacts(user_1).await.unwrap(),
&[
Contact::Accepted {
user_id: user_2,
busy: false,
},
Contact::Accepted {
user_id: user_3,
busy: false,
}
]
);
assert_eq!(
db.get_contacts(user_3).await.unwrap(),
&[Contact::Accepted {
user_id: user_1,
busy: false,
}],
);
// User declines a contact request. Both users see that it is gone.
db.send_contact_request(user_2, user_3).await.unwrap();
db.respond_to_contact_request(user_3, user_2, false)
.await
.unwrap();
assert!(!db.has_contact(user_2, user_3).await.unwrap());
assert!(!db.has_contact(user_3, user_2).await.unwrap());
assert_eq!(
db.get_contacts(user_2).await.unwrap(),
&[Contact::Accepted {
user_id: user_1,
busy: false,
}]
);
assert_eq!(
db.get_contacts(user_3).await.unwrap(),
&[Contact::Accepted {
user_id: user_1,
busy: false,
}],
);
}
test_both_dbs!(
test_project_count,
test_project_count_postgres,
test_project_count_sqlite
);
async fn test_project_count(db: &Arc<Database>) {
let owner_id = db.create_server("test").await.unwrap().0 as u32;
let user1 = db
.create_user(
"admin@example.com",
None,
true,
NewUserParams {
github_login: "admin".into(),
github_user_id: 0,
},
)
.await
.unwrap();
let user2 = db
.create_user(
"user@example.com",
None,
false,
NewUserParams {
github_login: "user".into(),
github_user_id: 1,
},
)
.await
.unwrap();
let room_id = RoomId::from_proto(
db.create_room(user1.user_id, ConnectionId { owner_id, id: 0 }, "")
.await
.unwrap()
.id,
);
db.call(
room_id,
user1.user_id,
ConnectionId { owner_id, id: 0 },
user2.user_id,
None,
)
.await
.unwrap();
db.join_room(room_id, user2.user_id, ConnectionId { owner_id, id: 1 })
.await
.unwrap();
assert_eq!(db.project_count_excluding_admins().await.unwrap(), 0);
db.share_project(
room_id,
ConnectionId { owner_id, id: 1 },
&[],
false,
false,
&[],
)
.await
.unwrap();
assert_eq!(db.project_count_excluding_admins().await.unwrap(), 1);
db.share_project(
room_id,
ConnectionId { owner_id, id: 1 },
&[],
false,
false,
&[],
)
.await
.unwrap();
assert_eq!(db.project_count_excluding_admins().await.unwrap(), 2);
// Projects shared by admins aren't counted.
db.share_project(
room_id,
ConnectionId { owner_id, id: 0 },
&[],
false,
false,
&[],
)
.await
.unwrap();
assert_eq!(db.project_count_excluding_admins().await.unwrap(), 2);
db.leave_room(ConnectionId { owner_id, id: 1 })
.await
.unwrap();
assert_eq!(db.project_count_excluding_admins().await.unwrap(), 0);
}
test_both_dbs!(
test_upsert_shared_thread,
test_upsert_shared_thread_postgres,
test_upsert_shared_thread_sqlite
);
async fn test_upsert_shared_thread(db: &Arc<Database>) {
use collab::db::SharedThreadId;
use uuid::Uuid;
let user_id = new_test_user(db, "user1@example.com").await;
let thread_id = SharedThreadId(Uuid::new_v4());
let title = "My Test Thread";
let data = b"test thread data".to_vec();
db.upsert_shared_thread(thread_id, user_id, title, data.clone())
.await
.unwrap();
let result = db.get_shared_thread(thread_id).await.unwrap();
assert!(result.is_some(), "Should find the shared thread");
let (thread, username) = result.unwrap();
assert_eq!(thread.title, title);
assert_eq!(thread.data, data);
assert_eq!(thread.user_id, user_id);
assert_eq!(username, "user1");
}
test_both_dbs!(
test_upsert_shared_thread_updates_existing,
test_upsert_shared_thread_updates_existing_postgres,
test_upsert_shared_thread_updates_existing_sqlite
);
async fn test_upsert_shared_thread_updates_existing(db: &Arc<Database>) {
use collab::db::SharedThreadId;
use uuid::Uuid;
let user_id = new_test_user(db, "user1@example.com").await;
let thread_id = SharedThreadId(Uuid::new_v4());
// Create initial thread.
db.upsert_shared_thread(
thread_id,
user_id,
"Original Title",
b"original data".to_vec(),
)
.await
.unwrap();
// Update the same thread.
db.upsert_shared_thread(
thread_id,
user_id,
"Updated Title",
b"updated data".to_vec(),
)
.await
.unwrap();
let result = db.get_shared_thread(thread_id).await.unwrap();
let (thread, _) = result.unwrap();
assert_eq!(thread.title, "Updated Title");
assert_eq!(thread.data, b"updated data".to_vec());
}
test_both_dbs!(
test_cannot_update_another_users_shared_thread,
test_cannot_update_another_users_shared_thread_postgres,
test_cannot_update_another_users_shared_thread_sqlite
);
async fn test_cannot_update_another_users_shared_thread(db: &Arc<Database>) {
use collab::db::SharedThreadId;
use uuid::Uuid;
let user1_id = new_test_user(db, "user1@example.com").await;
let user2_id = new_test_user(db, "user2@example.com").await;
let thread_id = SharedThreadId(Uuid::new_v4());
db.upsert_shared_thread(thread_id, user1_id, "User 1 Thread", b"user1 data".to_vec())
.await
.unwrap();
let result = db
.upsert_shared_thread(thread_id, user2_id, "User 2 Title", b"user2 data".to_vec())
.await;
assert!(
result.is_err(),
"Should not allow updating another user's thread"
);
}
test_both_dbs!(
test_get_nonexistent_shared_thread,
test_get_nonexistent_shared_thread_postgres,
test_get_nonexistent_shared_thread_sqlite
);
async fn test_get_nonexistent_shared_thread(db: &Arc<Database>) {
use collab::db::SharedThreadId;
use uuid::Uuid;
let result = db
.get_shared_thread(SharedThreadId(Uuid::new_v4()))
.await
.unwrap();
assert!(result.is_none(), "Should not find non-existent thread");
}

View File

@@ -0,0 +1,131 @@
use std::collections::BTreeSet;
use std::sync::Arc;
use cloud_api_types::{ExtensionMetadata, ExtensionProvides};
use collab::db::Database;
use collab::db::ExtensionVersionConstraints;
use collab::db::{NewExtensionVersion, queries::extensions::convert_time_to_chrono};
use crate::test_both_dbs;
test_both_dbs!(
test_extensions_by_id,
test_extensions_by_id_postgres,
test_extensions_by_id_sqlite
);
async fn test_extensions_by_id(db: &Arc<Database>) {
let versions = db.get_known_extension_versions().await.unwrap();
assert!(versions.is_empty());
let t0 = time::OffsetDateTime::from_unix_timestamp_nanos(0).unwrap();
let t0 = time::PrimitiveDateTime::new(t0.date(), t0.time());
let t0_chrono = convert_time_to_chrono(t0);
db.insert_extension_versions(
&[
(
"ext1",
vec![
NewExtensionVersion {
name: "Extension 1".into(),
version: semver::Version::parse("0.0.1").unwrap(),
description: "an extension".into(),
authors: vec!["max".into()],
repository: "ext1/repo".into(),
schema_version: 1,
wasm_api_version: Some("0.0.4".into()),
provides: BTreeSet::from_iter([
ExtensionProvides::Grammars,
ExtensionProvides::Languages,
]),
published_at: t0,
},
NewExtensionVersion {
name: "Extension 1".into(),
version: semver::Version::parse("0.0.2").unwrap(),
description: "a good extension".into(),
authors: vec!["max".into()],
repository: "ext1/repo".into(),
schema_version: 1,
wasm_api_version: Some("0.0.4".into()),
provides: BTreeSet::from_iter([
ExtensionProvides::Grammars,
ExtensionProvides::Languages,
ExtensionProvides::LanguageServers,
]),
published_at: t0,
},
NewExtensionVersion {
name: "Extension 1".into(),
version: semver::Version::parse("0.0.3").unwrap(),
description: "a real good extension".into(),
authors: vec!["max".into(), "marshall".into()],
repository: "ext1/repo".into(),
schema_version: 1,
wasm_api_version: Some("0.0.5".into()),
provides: BTreeSet::from_iter([
ExtensionProvides::Grammars,
ExtensionProvides::Languages,
ExtensionProvides::LanguageServers,
]),
published_at: t0,
},
],
),
(
"ext2",
vec![NewExtensionVersion {
name: "Extension 2".into(),
version: semver::Version::parse("0.2.0").unwrap(),
description: "a great extension".into(),
authors: vec!["marshall".into()],
repository: "ext2/repo".into(),
schema_version: 0,
wasm_api_version: None,
provides: BTreeSet::default(),
published_at: t0,
}],
),
]
.into_iter()
.collect(),
)
.await
.unwrap();
let extensions = db
.get_extensions_by_ids(
&["ext1"],
Some(&ExtensionVersionConstraints {
schema_versions: 1..=1,
wasm_api_versions: "0.0.1".parse().unwrap()..="0.0.4".parse().unwrap(),
}),
)
.await
.unwrap();
assert_eq!(
extensions,
&[ExtensionMetadata {
id: "ext1".into(),
manifest: cloud_api_types::ExtensionApiManifest {
name: "Extension 1".into(),
version: "0.0.2".into(),
authors: vec!["max".into()],
description: Some("a good extension".into()),
repository: "ext1/repo".into(),
schema_version: Some(1),
wasm_api_version: Some("0.0.4".into()),
provides: BTreeSet::from_iter([
ExtensionProvides::Grammars,
ExtensionProvides::Languages,
ExtensionProvides::LanguageServers,
]),
},
published_at: t0_chrono,
download_count: 0,
}]
);
}

View File

@@ -0,0 +1,47 @@
use std::path::Path;
use std::time::Duration;
use anyhow::{Result, anyhow};
use collections::HashMap;
use sea_orm::ConnectOptions;
use sqlx::Connection;
use sqlx::migrate::{Migrate, Migration, MigrationSource};
/// Runs the database migrations for the specified database.
pub async fn run_database_migrations(
database_options: &ConnectOptions,
migrations_path: impl AsRef<Path>,
) -> Result<Vec<(Migration, Duration)>> {
let migrations = MigrationSource::resolve(migrations_path.as_ref())
.await
.map_err(|err| anyhow!("failed to load migrations: {err:?}"))?;
let mut connection = sqlx::AnyConnection::connect(database_options.get_url()).await?;
connection.ensure_migrations_table().await?;
let applied_migrations: HashMap<_, _> = connection
.list_applied_migrations()
.await?
.into_iter()
.map(|migration| (migration.version, migration))
.collect();
let mut new_migrations = Vec::new();
for migration in migrations {
match applied_migrations.get(&migration.version) {
Some(applied_migration) => {
anyhow::ensure!(
migration.checksum == applied_migration.checksum,
"checksum mismatch for applied migration {}",
migration.description
);
}
None => {
let elapsed = connection.apply(&migration).await?;
new_migrations.push((migration, elapsed));
}
}
}
Ok(new_migrations)
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,164 @@
use std::sync::Arc;
use gpui::{BackgroundExecutor, TestAppContext};
use notifications::NotificationEvent;
use parking_lot::Mutex;
use pretty_assertions::assert_eq;
use rpc::{Notification, proto};
use crate::TestServer;
#[gpui::test]
async fn test_notifications(
executor: BackgroundExecutor,
cx_a: &mut TestAppContext,
cx_b: &mut TestAppContext,
) {
let mut server = TestServer::start(executor.clone()).await;
let client_a = server.create_client(cx_a, "user_a").await;
let client_b = server.create_client(cx_b, "user_b").await;
// Wait for authentication/connection to Collab to be established.
executor.run_until_parked();
let notification_events_a = Arc::new(Mutex::new(Vec::new()));
let notification_events_b = Arc::new(Mutex::new(Vec::new()));
client_a.notification_store().update(cx_a, |_, cx| {
let events = notification_events_a.clone();
cx.subscribe(&cx.entity(), move |_, _, event, _| {
events.lock().push(event.clone());
})
.detach()
});
client_b.notification_store().update(cx_b, |_, cx| {
let events = notification_events_b.clone();
cx.subscribe(&cx.entity(), move |_, _, event, _| {
events.lock().push(event.clone());
})
.detach()
});
// Client A sends a contact request to client B.
client_a
.user_store()
.update(cx_a, |store, cx| store.request_contact(client_b.id(), cx))
.await
.unwrap();
// Client B receives a contact request notification and responds to the
// request, accepting it.
executor.run_until_parked();
client_b.notification_store().update(cx_b, |store, cx| {
assert_eq!(store.notification_count(), 1);
assert_eq!(store.unread_notification_count(), 1);
let entry = store.notification_at(0).unwrap();
assert_eq!(
entry.notification,
Notification::ContactRequest {
sender_id: client_a.id()
}
);
assert!(!entry.is_read);
assert_eq!(
&notification_events_b.lock()[0..],
&[
NotificationEvent::NewNotification {
entry: entry.clone(),
},
NotificationEvent::NotificationsUpdated {
old_range: 0..0,
new_count: 1
}
]
);
store.respond_to_notification(entry.notification.clone(), true, cx);
});
// Client B sees the notification is now read, and that they responded.
executor.run_until_parked();
client_b.notification_store().read_with(cx_b, |store, _| {
assert_eq!(store.notification_count(), 1);
assert_eq!(store.unread_notification_count(), 0);
let entry = store.notification_at(0).unwrap();
assert!(entry.is_read);
assert_eq!(entry.response, Some(true));
assert_eq!(
&notification_events_b.lock()[2..],
&[
NotificationEvent::NotificationRead {
entry: entry.clone(),
},
NotificationEvent::NotificationsUpdated {
old_range: 0..1,
new_count: 1
}
]
);
});
// Client A receives a notification that client B accepted their request.
client_a.notification_store().read_with(cx_a, |store, _| {
assert_eq!(store.notification_count(), 1);
assert_eq!(store.unread_notification_count(), 1);
let entry = store.notification_at(0).unwrap();
assert_eq!(
entry.notification,
Notification::ContactRequestAccepted {
responder_id: client_b.id()
}
);
assert!(!entry.is_read);
});
// Client A creates a channel and invites client B to be a member.
let channel_id = client_a
.channel_store()
.update(cx_a, |store, cx| {
store.create_channel("the-channel", None, cx)
})
.await
.unwrap();
client_a
.channel_store()
.update(cx_a, |store, cx| {
store.invite_member(channel_id, client_b.id(), proto::ChannelRole::Member, cx)
})
.await
.unwrap();
// Client B receives a channel invitation notification and responds to the
// invitation, accepting it.
executor.run_until_parked();
client_b.notification_store().update(cx_b, |store, cx| {
assert_eq!(store.notification_count(), 2);
assert_eq!(store.unread_notification_count(), 1);
let entry = store.notification_at(0).unwrap();
assert_eq!(
entry.notification,
Notification::ChannelInvitation {
channel_id: channel_id.0,
channel_name: "the-channel".to_string(),
inviter_id: client_a.id()
}
);
assert!(!entry.is_read);
store.respond_to_notification(entry.notification.clone(), true, cx);
});
// Client B sees the notification is now read, and that they responded.
executor.run_until_parked();
client_b.notification_store().read_with(cx_b, |store, _| {
assert_eq!(store.notification_count(), 2);
assert_eq!(store.unread_notification_count(), 0);
let entry = store.notification_at(0).unwrap();
assert!(entry.is_read);
assert_eq!(entry.response, Some(true));
});
}

View File

@@ -0,0 +1,285 @@
use collab::db::*;
use super::{RandomizedTest, TestClient, TestError, TestServer, UserTestPlan, run_randomized_test};
use anyhow::Result;
use async_trait::async_trait;
use gpui::{BackgroundExecutor, SharedString, TestAppContext};
use rand::prelude::*;
use serde::{Deserialize, Serialize};
use std::{
ops::{Deref, DerefMut, Range},
rc::Rc,
sync::Arc,
};
use text::Bias;
#[gpui::test(iterations = 100, on_failure = "crate::save_randomized_test_plan")]
async fn test_random_channel_buffers(
cx: &mut TestAppContext,
executor: BackgroundExecutor,
rng: StdRng,
) {
run_randomized_test::<RandomChannelBufferTest>(cx, executor, rng).await;
}
struct RandomChannelBufferTest;
#[derive(Clone, Serialize, Deserialize)]
enum ChannelBufferOperation {
JoinChannelNotes {
channel_name: SharedString,
},
LeaveChannelNotes {
channel_name: SharedString,
},
EditChannelNotes {
channel_name: SharedString,
edits: Vec<(Range<usize>, Arc<str>)>,
},
Noop,
}
const CHANNEL_COUNT: usize = 3;
#[async_trait(?Send)]
impl RandomizedTest for RandomChannelBufferTest {
type Operation = ChannelBufferOperation;
async fn initialize(server: &mut TestServer, users: &[UserTestPlan]) {
let db = &server.app_state.db;
for ix in 0..CHANNEL_COUNT {
let id = db
.create_root_channel(&format!("channel-{ix}"), users[0].user_id)
.await
.unwrap();
for user in &users[1..] {
db.invite_channel_member(id, user.user_id, users[0].user_id, ChannelRole::Member)
.await
.unwrap();
db.respond_to_channel_invite(id, user.user_id, true)
.await
.unwrap();
}
}
}
fn generate_operation(
client: &TestClient,
rng: &mut StdRng,
_: &mut UserTestPlan,
cx: &TestAppContext,
) -> ChannelBufferOperation {
let channel_store = client.channel_store().clone();
let mut channel_buffers = client.channel_buffers();
// When signed out, we can't do anything unless a channel buffer is
// already open.
if channel_buffers.deref_mut().is_empty()
&& channel_store.read_with(cx, |store, _| store.channel_count() == 0)
{
return ChannelBufferOperation::Noop;
}
loop {
match rng.random_range(0..100_u32) {
0..=29 => {
let channel_name = client.channel_store().read_with(cx, |store, cx| {
store.ordered_channels().find_map(|(_, channel)| {
if store.has_open_channel_buffer(channel.id, cx) {
None
} else {
Some(channel.name.clone())
}
})
});
if let Some(channel_name) = channel_name {
break ChannelBufferOperation::JoinChannelNotes { channel_name };
}
}
30..=40 => {
if let Some(buffer) = channel_buffers.deref().iter().choose(rng) {
let channel_name =
buffer.read_with(cx, |b, cx| b.channel(cx).unwrap().name.clone());
break ChannelBufferOperation::LeaveChannelNotes { channel_name };
}
}
_ => {
if let Some(buffer) = channel_buffers.deref().iter().choose(rng) {
break buffer.read_with(cx, |b, cx| {
let channel_name = b.channel(cx).unwrap().name.clone();
let edits = b
.buffer()
.read_with(cx, |buffer, _| buffer.get_random_edits(rng, 3));
ChannelBufferOperation::EditChannelNotes {
channel_name,
edits,
}
});
}
}
}
}
}
async fn apply_operation(
client: &TestClient,
operation: ChannelBufferOperation,
cx: &mut TestAppContext,
) -> Result<(), TestError> {
match operation {
ChannelBufferOperation::JoinChannelNotes { channel_name } => {
let buffer = client.channel_store().update(cx, |store, cx| {
let channel_id = store
.ordered_channels()
.find(|(_, c)| c.name == channel_name)
.unwrap()
.1
.id;
if store.has_open_channel_buffer(channel_id, cx) {
Err(TestError::Inapplicable)
} else {
Ok(store.open_channel_buffer(channel_id, cx))
}
})?;
log::info!(
"{}: opening notes for channel {channel_name}",
client.username
);
client.channel_buffers().deref_mut().insert(buffer.await?);
}
ChannelBufferOperation::LeaveChannelNotes { channel_name } => {
let buffer = cx.update(|cx| {
let mut left_buffer = Err(TestError::Inapplicable);
client.channel_buffers().deref_mut().retain(|buffer| {
if buffer.read(cx).channel(cx).unwrap().name == channel_name {
left_buffer = Ok(buffer.clone());
false
} else {
true
}
});
left_buffer
})?;
log::info!(
"{}: closing notes for channel {channel_name}",
client.username
);
cx.update(|_| drop(buffer));
}
ChannelBufferOperation::EditChannelNotes {
channel_name,
edits,
} => {
let channel_buffer = cx
.read(|cx| {
client
.channel_buffers()
.deref()
.iter()
.find(|buffer| {
buffer.read(cx).channel(cx).unwrap().name == channel_name
})
.cloned()
})
.ok_or_else(|| TestError::Inapplicable)?;
log::info!(
"{}: editing notes for channel {channel_name} with {:?}",
client.username,
edits
);
channel_buffer.update(cx, |buffer, cx| {
let buffer = buffer.buffer();
buffer.update(cx, |buffer, cx| {
let snapshot = buffer.snapshot();
buffer.edit(
edits.into_iter().map(|(range, text)| {
let start = snapshot.clip_offset(range.start, Bias::Left);
let end = snapshot.clip_offset(range.end, Bias::Right);
(start..end, text)
}),
None,
cx,
);
});
});
}
ChannelBufferOperation::Noop => Err(TestError::Inapplicable)?,
}
Ok(())
}
async fn on_quiesce(server: &mut TestServer, clients: &mut [(Rc<TestClient>, TestAppContext)]) {
let channels = server.app_state.db.all_channels().await.unwrap();
for (client, client_cx) in clients.iter_mut() {
client_cx.update(|cx| {
client
.channel_buffers()
.deref_mut()
.retain(|b| b.read(cx).is_connected());
});
}
for (channel_id, channel_name) in channels {
let mut prev_text: Option<(u64, String)> = None;
let mut collaborator_user_ids = server
.app_state
.db
.get_channel_buffer_collaborators(channel_id)
.await
.unwrap()
.into_iter()
.map(|id| id.to_proto())
.collect::<Vec<_>>();
collaborator_user_ids.sort();
for (client, client_cx) in clients.iter() {
let user_id = client.user_id().unwrap();
client_cx.read(|cx| {
if let Some(channel_buffer) = client
.channel_buffers()
.deref()
.iter()
.find(|b| b.read(cx).channel_id.0 == channel_id.to_proto())
{
let channel_buffer = channel_buffer.read(cx);
// Assert that channel buffer's text matches other clients' copies.
let text = channel_buffer.buffer().read(cx).text();
if let Some((prev_user_id, prev_text)) = &prev_text {
assert_eq!(
&text,
prev_text,
"client {user_id} has different text than client {prev_user_id} for channel {channel_name}",
);
} else {
prev_text = Some((user_id, text));
}
// Assert that all clients and the server agree about who is present in the
// channel buffer.
let collaborators = channel_buffer.collaborators();
let mut user_ids =
collaborators.values().map(|c| c.user_id).collect::<Vec<_>>();
user_ids.sort();
assert_eq!(
user_ids,
collaborator_user_ids,
"client {user_id} has different user ids for channel {channel_name} than the server",
);
}
});
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,702 @@
use crate::{TestClient, TestServer};
use async_trait::async_trait;
use collab::{
db::{self, NewUserParams, UserId},
rpc::{CLEANUP_TIMEOUT, RECONNECT_TIMEOUT},
};
use futures::StreamExt;
use gpui::{BackgroundExecutor, Task, TestAppContext};
use parking_lot::Mutex;
use rand::prelude::*;
use rpc::RECEIVE_TIMEOUT;
use serde::{Deserialize, Serialize, de::DeserializeOwned};
use settings::SettingsStore;
use std::sync::OnceLock;
use std::{
env,
path::PathBuf,
rc::Rc,
sync::{
Arc,
atomic::{AtomicBool, Ordering::SeqCst},
},
};
fn plan_load_path() -> &'static Option<PathBuf> {
static PLAN_LOAD_PATH: OnceLock<Option<PathBuf>> = OnceLock::new();
PLAN_LOAD_PATH.get_or_init(|| path_env_var("LOAD_PLAN"))
}
fn plan_save_path() -> &'static Option<PathBuf> {
static PLAN_SAVE_PATH: OnceLock<Option<PathBuf>> = OnceLock::new();
PLAN_SAVE_PATH.get_or_init(|| path_env_var("SAVE_PLAN"))
}
fn max_peers() -> usize {
static MAX_PEERS: OnceLock<usize> = OnceLock::new();
*MAX_PEERS.get_or_init(|| {
env::var("MAX_PEERS")
.map(|i| i.parse().expect("invalid `MAX_PEERS` variable"))
.unwrap_or(3)
})
}
fn max_operations() -> usize {
static MAX_OPERATIONS: OnceLock<usize> = OnceLock::new();
*MAX_OPERATIONS.get_or_init(|| {
env::var("OPERATIONS")
.map(|i| i.parse().expect("invalid `OPERATIONS` variable"))
.unwrap_or(10)
})
}
static LOADED_PLAN_JSON: Mutex<Option<Vec<u8>>> = Mutex::new(None);
static LAST_PLAN: Mutex<Option<Box<dyn Send + FnOnce() -> Vec<u8>>>> = Mutex::new(None);
struct TestPlan<T: RandomizedTest> {
rng: StdRng,
replay: bool,
stored_operations: Vec<(StoredOperation<T::Operation>, Arc<AtomicBool>)>,
max_operations: usize,
operation_ix: usize,
users: Vec<UserTestPlan>,
next_batch_id: usize,
allow_server_restarts: bool,
allow_client_reconnection: bool,
allow_client_disconnection: bool,
}
pub struct UserTestPlan {
pub user_id: UserId,
pub username: String,
pub allow_client_disconnection: bool,
next_root_id: usize,
operation_ix: usize,
online: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(untagged)]
enum StoredOperation<T> {
Server(ServerOperation),
Client {
user_id: UserId,
batch_id: usize,
operation: T,
},
}
#[derive(Clone, Debug, Serialize, Deserialize)]
enum ServerOperation {
AddConnection {
user_id: UserId,
},
RemoveConnection {
user_id: UserId,
},
BounceConnection {
user_id: UserId,
},
RestartServer,
MutateClients {
batch_id: usize,
#[serde(skip_serializing)]
#[serde(skip_deserializing)]
user_ids: Vec<UserId>,
quiesce: bool,
},
}
pub enum TestError {
Inapplicable,
Other(anyhow::Error),
}
#[async_trait(?Send)]
pub trait RandomizedTest: 'static + Sized {
type Operation: Send + Clone + Serialize + DeserializeOwned;
fn generate_operation(
client: &TestClient,
rng: &mut StdRng,
plan: &mut UserTestPlan,
cx: &TestAppContext,
) -> Self::Operation;
async fn apply_operation(
client: &TestClient,
operation: Self::Operation,
cx: &mut TestAppContext,
) -> Result<(), TestError>;
async fn initialize(server: &mut TestServer, users: &[UserTestPlan]);
async fn on_client_added(_client: &Rc<TestClient>, _cx: &mut TestAppContext) {}
async fn on_quiesce(server: &mut TestServer, client: &mut [(Rc<TestClient>, TestAppContext)]);
}
pub async fn run_randomized_test<T: RandomizedTest>(
cx: &mut TestAppContext,
executor: BackgroundExecutor,
rng: StdRng,
) {
let mut server = TestServer::start(executor.clone()).await;
let plan = TestPlan::<T>::new(&mut server, rng).await;
LAST_PLAN.lock().replace({
let plan = plan.clone();
Box::new(move || plan.lock().serialize())
});
let mut clients = Vec::new();
let mut client_tasks = Vec::new();
let mut operation_channels = Vec::new();
loop {
let Some((next_operation, applied)) = plan.lock().next_server_operation(&clients) else {
break;
};
applied.store(true, SeqCst);
let did_apply = TestPlan::apply_server_operation(
plan.clone(),
executor.clone(),
&mut server,
&mut clients,
&mut client_tasks,
&mut operation_channels,
next_operation,
cx,
)
.await;
if !did_apply {
applied.store(false, SeqCst);
}
}
drop(operation_channels);
futures::future::join_all(client_tasks).await;
executor.run_until_parked();
T::on_quiesce(&mut server, &mut clients).await;
for (client, cx) in clients {
cx.update(|cx| {
for window in cx.windows() {
window
.update(cx, |_, window, _| window.remove_window())
.ok();
}
});
cx.update(|cx| {
let settings = cx.remove_global::<SettingsStore>();
cx.clear_globals();
cx.set_global(settings);
theme_settings::init(theme::LoadThemes::JustBase, cx);
drop(client);
});
executor.run_until_parked();
}
if let Some(path) = plan_save_path() {
eprintln!("saved test plan to path {:?}", path);
std::fs::write(path, plan.lock().serialize()).unwrap();
}
}
pub fn save_randomized_test_plan() {
if let Some(serialize_plan) = LAST_PLAN.lock().take()
&& let Some(path) = plan_save_path()
{
eprintln!("saved test plan to path {:?}", path);
std::fs::write(path, serialize_plan()).unwrap();
}
}
impl<T: RandomizedTest> TestPlan<T> {
pub async fn new(server: &mut TestServer, mut rng: StdRng) -> Arc<Mutex<Self>> {
let allow_server_restarts = rng.random_bool(0.7);
let allow_client_reconnection = rng.random_bool(0.7);
let allow_client_disconnection = rng.random_bool(0.1);
let mut users = Vec::new();
for ix in 0..max_peers() {
let username = format!("user-{}", ix + 1);
let user_id = server
.app_state
.db
.create_user(
&format!("{username}@example.com"),
None,
false,
NewUserParams {
github_login: username.clone(),
github_user_id: ix as i32,
},
)
.await
.unwrap()
.user_id;
users.push(UserTestPlan {
user_id,
username,
online: false,
next_root_id: 0,
operation_ix: 0,
allow_client_disconnection,
});
}
T::initialize(server, &users).await;
let plan = Arc::new(Mutex::new(Self {
replay: false,
allow_server_restarts,
allow_client_reconnection,
allow_client_disconnection,
stored_operations: Vec::new(),
operation_ix: 0,
next_batch_id: 0,
max_operations: max_operations(),
users,
rng,
}));
if let Some(path) = plan_load_path() {
let json = LOADED_PLAN_JSON
.lock()
.get_or_insert_with(|| {
eprintln!("loaded test plan from path {:?}", path);
std::fs::read(path).unwrap()
})
.clone();
plan.lock().deserialize(json);
}
plan
}
fn deserialize(&mut self, json: Vec<u8>) {
let stored_operations: Vec<StoredOperation<T::Operation>> =
serde_json::from_slice(&json).unwrap();
self.replay = true;
self.stored_operations = stored_operations
.iter()
.cloned()
.enumerate()
.map(|(i, mut operation)| {
let did_apply = Arc::new(AtomicBool::new(false));
if let StoredOperation::Server(ServerOperation::MutateClients {
batch_id: current_batch_id,
user_ids,
..
}) = &mut operation
{
assert!(user_ids.is_empty());
user_ids.extend(stored_operations[i + 1..].iter().filter_map(|operation| {
if let StoredOperation::Client {
user_id, batch_id, ..
} = operation
&& batch_id == current_batch_id
{
return Some(user_id);
}
None
}));
user_ids.sort_unstable();
}
(operation, did_apply)
})
.collect()
}
fn serialize(&mut self) -> Vec<u8> {
// Format each operation as one line
let mut json = Vec::new();
json.push(b'[');
for (operation, applied) in &self.stored_operations {
if !applied.load(SeqCst) {
continue;
}
if json.len() > 1 {
json.push(b',');
}
json.extend_from_slice(b"\n ");
serde_json::to_writer(&mut json, operation).unwrap();
}
json.extend_from_slice(b"\n]\n");
json
}
fn next_server_operation(
&mut self,
clients: &[(Rc<TestClient>, TestAppContext)],
) -> Option<(ServerOperation, Arc<AtomicBool>)> {
if self.replay {
while let Some(stored_operation) = self.stored_operations.get(self.operation_ix) {
self.operation_ix += 1;
if let (StoredOperation::Server(operation), applied) = stored_operation {
return Some((operation.clone(), applied.clone()));
}
}
None
} else {
let operation = self.generate_server_operation(clients)?;
let applied = Arc::new(AtomicBool::new(false));
self.stored_operations
.push((StoredOperation::Server(operation.clone()), applied.clone()));
Some((operation, applied))
}
}
fn next_client_operation(
&mut self,
client: &TestClient,
current_batch_id: usize,
cx: &TestAppContext,
) -> Option<(T::Operation, Arc<AtomicBool>)> {
let current_user_id = client.current_user_id(cx);
let user_ix = self
.users
.iter()
.position(|user| user.user_id == current_user_id)
.unwrap();
let user_plan = &mut self.users[user_ix];
if self.replay {
while let Some(stored_operation) = self.stored_operations.get(user_plan.operation_ix) {
user_plan.operation_ix += 1;
if let (
StoredOperation::Client {
user_id, operation, ..
},
applied,
) = stored_operation
&& user_id == &current_user_id
{
return Some((operation.clone(), applied.clone()));
}
}
None
} else {
if self.operation_ix == self.max_operations {
return None;
}
self.operation_ix += 1;
let operation = T::generate_operation(
client,
&mut self.rng,
self.users
.iter_mut()
.find(|user| user.user_id == current_user_id)
.unwrap(),
cx,
);
let applied = Arc::new(AtomicBool::new(false));
self.stored_operations.push((
StoredOperation::Client {
user_id: current_user_id,
batch_id: current_batch_id,
operation: operation.clone(),
},
applied.clone(),
));
Some((operation, applied))
}
}
fn generate_server_operation(
&mut self,
clients: &[(Rc<TestClient>, TestAppContext)],
) -> Option<ServerOperation> {
if self.operation_ix == self.max_operations {
return None;
}
Some(loop {
break match self.rng.random_range(0..100) {
0..=29 if clients.len() < self.users.len() => {
let user = self
.users
.iter()
.filter(|u| !u.online)
.choose(&mut self.rng)
.unwrap();
self.operation_ix += 1;
ServerOperation::AddConnection {
user_id: user.user_id,
}
}
30..=34 if clients.len() > 1 && self.allow_client_disconnection => {
let (client, cx) = &clients[self.rng.random_range(0..clients.len())];
let user_id = client.current_user_id(cx);
self.operation_ix += 1;
ServerOperation::RemoveConnection { user_id }
}
35..=39 if clients.len() > 1 && self.allow_client_reconnection => {
let (client, cx) = &clients[self.rng.random_range(0..clients.len())];
let user_id = client.current_user_id(cx);
self.operation_ix += 1;
ServerOperation::BounceConnection { user_id }
}
40..=44 if self.allow_server_restarts && clients.len() > 1 => {
self.operation_ix += 1;
ServerOperation::RestartServer
}
_ if !clients.is_empty() => {
let count = self
.rng
.random_range(1..10)
.min(self.max_operations - self.operation_ix);
let batch_id = util::post_inc(&mut self.next_batch_id);
let mut user_ids = (0..count)
.map(|_| {
let ix = self.rng.random_range(0..clients.len());
let (client, cx) = &clients[ix];
client.current_user_id(cx)
})
.collect::<Vec<_>>();
user_ids.sort_unstable();
ServerOperation::MutateClients {
user_ids,
batch_id,
quiesce: self.rng.random_bool(0.7),
}
}
_ => continue,
};
})
}
async fn apply_server_operation(
plan: Arc<Mutex<Self>>,
deterministic: BackgroundExecutor,
server: &mut TestServer,
clients: &mut Vec<(Rc<TestClient>, TestAppContext)>,
client_tasks: &mut Vec<Task<()>>,
operation_channels: &mut Vec<futures::channel::mpsc::UnboundedSender<usize>>,
operation: ServerOperation,
cx: &mut TestAppContext,
) -> bool {
match operation {
ServerOperation::AddConnection { user_id } => {
let username;
{
let mut plan = plan.lock();
let user = plan.user(user_id);
if user.online {
return false;
}
user.online = true;
username = user.username.clone();
};
log::info!("adding new connection for {}", username);
let mut client_cx = cx.new_app();
let (operation_tx, operation_rx) = futures::channel::mpsc::unbounded();
let client = Rc::new(server.create_client(&mut client_cx, &username).await);
operation_channels.push(operation_tx);
clients.push((client.clone(), client_cx.clone()));
let foreground_executor = client_cx.foreground_executor().clone();
let simulate_client =
Self::simulate_client(plan.clone(), client, operation_rx, client_cx);
client_tasks.push(foreground_executor.spawn(simulate_client));
log::info!("added connection for {}", username);
}
ServerOperation::RemoveConnection {
user_id: removed_user_id,
} => {
log::info!("simulating full disconnection of user {}", removed_user_id);
let client_ix = clients
.iter()
.position(|(client, cx)| client.current_user_id(cx) == removed_user_id);
let Some(client_ix) = client_ix else {
return false;
};
let user_connection_ids = server
.connection_pool
.lock()
.user_connection_ids(removed_user_id)
.collect::<Vec<_>>();
assert_eq!(user_connection_ids.len(), 1);
let removed_peer_id = user_connection_ids[0].into();
let (client, client_cx) = clients.remove(client_ix);
let client_task = client_tasks.remove(client_ix);
operation_channels.remove(client_ix);
server.forbid_connections();
server.disconnect_client(removed_peer_id);
deterministic.advance_clock(RECEIVE_TIMEOUT + RECONNECT_TIMEOUT);
log::info!("waiting for user {} to exit...", removed_user_id);
client_task.await;
server.allow_connections();
for project in client.dev_server_projects().iter() {
project.read_with(&client_cx, |project, cx| {
assert!(
project.is_disconnected(cx),
"project {:?} should be read only",
project.remote_id()
)
});
}
for (client, cx) in clients {
let contacts = server
.app_state
.db
.get_contacts(client.current_user_id(cx))
.await
.unwrap();
let pool = server.connection_pool.lock();
for contact in contacts {
if let db::Contact::Accepted { user_id, busy, .. } = contact
&& user_id == removed_user_id
{
assert!(!pool.is_user_online(user_id));
assert!(!busy);
}
}
}
log::info!("{} removed", client.username);
plan.lock().user(removed_user_id).online = false;
client_cx.update(|cx| {
for window in cx.windows() {
window
.update(cx, |_, window, _| window.remove_window())
.ok();
}
});
client_cx.update(|cx| {
cx.clear_globals();
drop(client);
});
}
ServerOperation::BounceConnection { user_id } => {
log::info!("simulating temporary disconnection of user {}", user_id);
let user_connection_ids = server
.connection_pool
.lock()
.user_connection_ids(user_id)
.collect::<Vec<_>>();
if user_connection_ids.is_empty() {
return false;
}
assert_eq!(user_connection_ids.len(), 1);
let peer_id = user_connection_ids[0].into();
server.disconnect_client(peer_id);
deterministic.advance_clock(RECEIVE_TIMEOUT + RECONNECT_TIMEOUT);
}
ServerOperation::RestartServer => {
log::info!("simulating server restart");
server.reset().await;
deterministic.advance_clock(RECEIVE_TIMEOUT);
server.start().await.unwrap();
deterministic.advance_clock(CLEANUP_TIMEOUT);
let environment = &server.app_state.config.zed_environment;
let (stale_room_ids, _) = server
.app_state
.db
.stale_server_resource_ids(environment, server.id())
.await
.unwrap();
assert_eq!(stale_room_ids, vec![]);
}
ServerOperation::MutateClients {
user_ids,
batch_id,
quiesce,
} => {
let mut applied = false;
for user_id in user_ids {
let client_ix = clients
.iter()
.position(|(client, cx)| client.current_user_id(cx) == user_id);
let Some(client_ix) = client_ix else { continue };
applied = true;
if let Err(err) = operation_channels[client_ix].unbounded_send(batch_id) {
log::error!("error signaling user {user_id}: {err}");
}
}
if quiesce && applied {
deterministic.run_until_parked();
T::on_quiesce(server, clients).await;
}
return applied;
}
}
true
}
async fn simulate_client(
plan: Arc<Mutex<Self>>,
client: Rc<TestClient>,
mut operation_rx: futures::channel::mpsc::UnboundedReceiver<usize>,
mut cx: TestAppContext,
) {
T::on_client_added(&client, &mut cx).await;
while let Some(batch_id) = operation_rx.next().await {
let Some((operation, applied)) =
plan.lock().next_client_operation(&client, batch_id, &cx)
else {
break;
};
applied.store(true, SeqCst);
match T::apply_operation(&client, operation, &mut cx).await {
Ok(()) => {}
Err(TestError::Inapplicable) => {
applied.store(false, SeqCst);
log::info!("skipped operation");
}
Err(TestError::Other(error)) => {
log::error!("{} error: {}", client.username, error);
}
}
cx.executor().simulate_random_delay().await;
}
log::info!("{}: done", client.username);
}
fn user(&mut self, user_id: UserId) -> &mut UserTestPlan {
self.users
.iter_mut()
.find(|user| user.user_id == user_id)
.unwrap()
}
}
impl UserTestPlan {
pub fn next_root_dir_name(&mut self) -> String {
let user_id = self.user_id;
let root_id = util::post_inc(&mut self.next_root_id);
format!("dir-{user_id}-{root_id}")
}
}
impl From<anyhow::Error> for TestError {
fn from(value: anyhow::Error) -> Self {
Self::Other(value)
}
}
fn path_env_var(name: &str) -> Option<PathBuf> {
let value = env::var(name).ok()?;
let mut path = PathBuf::from(value);
if path.is_relative() {
let mut abs_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
abs_path.pop();
abs_path.pop();
abs_path.push(path);
path = abs_path
}
Some(path)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,976 @@
use anyhow::anyhow;
use call::ActiveCall;
use channel::{ChannelBuffer, ChannelStore};
use client::test::{make_get_authenticated_user_response, parse_authorization_header};
use client::{
self, ChannelId, Client, Connection, Credentials, EstablishConnectionError, UserStore,
proto::PeerId,
};
use clock::FakeSystemClock;
use collab::services::{FakeUserService, NewUserParams};
use collab::{
AppState, Config,
db::UserId,
executor::Executor,
rpc::{CLEANUP_TIMEOUT, Principal, RECONNECT_TIMEOUT, Server, ZedVersion},
};
use collab_ui::channel_view::ChannelView;
use collections::{HashMap, HashSet};
use fs::FakeFs;
use futures::{StreamExt as _, channel::oneshot};
use git::GitHostingProviderRegistry;
use gpui::{AppContext as _, BackgroundExecutor, Entity, Task, TestAppContext, VisualTestContext};
use http_client::{FakeHttpClient, Method};
use language::LanguageRegistry;
use node_runtime::NodeRuntime;
use notifications::NotificationStore;
use parking_lot::Mutex;
use project::{Project, WorktreeId};
use remote::RemoteClient;
use rpc::{
RECEIVE_TIMEOUT,
proto::{self, ChannelRole},
};
use serde_json::json;
use session::{AppSession, Session};
use settings::SettingsStore;
use std::{
cell::{Ref, RefCell, RefMut},
env,
ops::{Deref, DerefMut},
path::Path,
sync::{
Arc,
atomic::{AtomicBool, AtomicUsize, Ordering::SeqCst},
},
};
use util::path;
use workspace::{MultiWorkspace, Workspace, WorkspaceStore};
use livekit_client::test::TestServer as LivekitTestServer;
use crate::db_tests::TestDb;
pub struct TestServer {
pub app_state: Arc<AppState>,
pub test_livekit_server: Arc<LivekitTestServer>,
pub test_db: TestDb,
server: Arc<Server>,
next_github_user_id: i32,
connection_killers: Arc<Mutex<HashMap<PeerId, Arc<AtomicBool>>>>,
forbid_connections: Arc<AtomicBool>,
}
pub struct TestClient {
pub username: String,
pub app_state: Arc<workspace::AppState>,
channel_store: Entity<ChannelStore>,
notification_store: Entity<NotificationStore>,
state: RefCell<TestClientState>,
}
#[derive(Default)]
struct TestClientState {
local_projects: Vec<Entity<Project>>,
dev_server_projects: Vec<Entity<Project>>,
buffers: HashMap<Entity<Project>, HashSet<Entity<language::Buffer>>>,
channel_buffers: HashSet<Entity<ChannelBuffer>>,
}
pub struct ContactsSummary {
pub current: Vec<String>,
pub outgoing_requests: Vec<String>,
pub incoming_requests: Vec<String>,
}
impl TestServer {
pub async fn start(deterministic: BackgroundExecutor) -> Self {
static NEXT_LIVEKIT_SERVER_ID: AtomicUsize = AtomicUsize::new(0);
let use_postgres = env::var("USE_POSTGRES").ok();
let use_postgres = use_postgres.as_deref();
let test_db = if use_postgres == Some("true") || use_postgres == Some("1") {
TestDb::postgres(deterministic.clone())
} else {
TestDb::sqlite(deterministic.clone())
};
let livekit_server_id = NEXT_LIVEKIT_SERVER_ID.fetch_add(1, SeqCst);
let livekit_server = LivekitTestServer::create(
format!("http://livekit.{}.test", livekit_server_id),
format!("devkey-{}", livekit_server_id),
format!("secret-{}", livekit_server_id),
deterministic.clone(),
)
.unwrap();
let executor = Executor::Deterministic(deterministic.clone());
let app_state = Self::build_app_state(&test_db, &livekit_server, executor.clone()).await;
let epoch = app_state
.db
.create_server(&app_state.config.zed_environment)
.await
.unwrap();
let server = Server::new(epoch, app_state.clone());
server.start().await.unwrap();
// Advance clock to ensure the server's cleanup task is finished.
deterministic.advance_clock(CLEANUP_TIMEOUT);
Self {
app_state,
server,
connection_killers: Default::default(),
forbid_connections: Default::default(),
next_github_user_id: 0,
test_db,
test_livekit_server: livekit_server,
}
}
pub async fn start2(
cx_a: &mut TestAppContext,
cx_b: &mut TestAppContext,
) -> (TestServer, TestClient, TestClient, ChannelId) {
let mut server = Self::start(cx_a.executor()).await;
let client_a = server.create_client(cx_a, "user_a").await;
let client_b = server.create_client(cx_b, "user_b").await;
let channel_id = server
.make_channel(
"test-channel",
None,
(&client_a, cx_a),
&mut [(&client_b, cx_b)],
)
.await;
cx_a.run_until_parked();
(server, client_a, client_b, channel_id)
}
pub async fn start1(cx: &mut TestAppContext) -> (TestServer, TestClient) {
let mut server = Self::start(cx.executor().clone()).await;
let client = server.create_client(cx, "user_a").await;
(server, client)
}
pub async fn reset(&self) {
self.app_state.db.reset();
let epoch = self
.app_state
.db
.create_server(&self.app_state.config.zed_environment)
.await
.unwrap();
self.server.reset(epoch);
}
pub async fn create_client(&mut self, cx: &mut TestAppContext, name: &str) -> TestClient {
const ACCESS_TOKEN: &str = "the-token";
let fs = FakeFs::new(cx.executor());
cx.update(|cx| {
gpui_tokio::init(cx);
if cx.has_global::<SettingsStore>() {
panic!("Same cx used to create two test clients")
}
let settings = SettingsStore::test(cx);
cx.set_global(settings);
theme_settings::init(theme::LoadThemes::JustBase, cx);
release_channel::init(semver::Version::new(0, 0, 0), cx);
});
let clock = Arc::new(FakeSystemClock::new());
let user_id = if let Ok(Some(user)) = self
.app_state
.user_service
.get_user_by_github_login(name)
.await
{
user.id
} else {
let github_user_id = self.next_github_user_id;
self.next_github_user_id += 1;
self.app_state
.user_service
.as_fake()
.create_user(
&format!("{name}@example.com"),
None,
false,
NewUserParams {
github_login: name.into(),
github_user_id,
},
)
.await
};
let http = FakeHttpClient::create({
let name = name.to_string();
move |req| {
let name = name.clone();
async move {
match (req.method(), req.uri().path()) {
(&Method::GET, "/client/users/me") => {
let credentials = parse_authorization_header(&req);
if credentials
!= Some(Credentials {
user_id: user_id.to_proto(),
access_token: ACCESS_TOKEN.into(),
})
{
return Ok(http_client::Response::builder()
.status(401)
.body("Unauthorized".into())
.unwrap());
}
Ok(http_client::Response::builder()
.status(200)
.body(
serde_json::to_string(&make_get_authenticated_user_response(
user_id.0, name,
))
.unwrap()
.into(),
)
.unwrap())
}
_ => Ok(http_client::Response::builder()
.status(404)
.body("Not Found".into())
.unwrap()),
}
}
}
});
let client_name = name.to_string();
let client = cx.update(|cx| Client::new(clock, http.clone(), cx));
let server = self.server.clone();
let user_service = self.app_state.user_service.clone();
let connection_killers = self.connection_killers.clone();
let forbid_connections = self.forbid_connections.clone();
client
.set_id(user_id.to_proto())
.override_authenticate(move |cx| {
cx.spawn(async move |_| {
Ok(Credentials {
user_id: user_id.to_proto(),
access_token: ACCESS_TOKEN.into(),
})
})
})
.override_establish_connection(move |credentials, cx| {
assert_eq!(
credentials,
&Credentials {
user_id: user_id.0 as u64,
access_token: ACCESS_TOKEN.into(),
}
);
let server = server.clone();
let user_service = user_service.clone();
let connection_killers = connection_killers.clone();
let forbid_connections = forbid_connections.clone();
let client_name = client_name.clone();
cx.spawn(async move |cx| {
if forbid_connections.load(SeqCst) {
Err(EstablishConnectionError::other(anyhow!(
"server is forbidding connections"
)))
} else {
let (client_conn, server_conn, killed) =
Connection::in_memory(cx.background_executor().clone());
let (connection_id_tx, connection_id_rx) = oneshot::channel();
let user = user_service
.as_fake()
.get_user_by_id(user_id)
.await
.map_err(|e| {
EstablishConnectionError::Other(anyhow!(
"retrieving user failed: {}",
e
))
})?
.unwrap();
cx.background_spawn(server.handle_connection(
server_conn,
client_name,
Principal::User(user),
ZedVersion(semver::Version::new(1, 0, 0)),
Some("test".to_string()),
None,
None,
None,
Some(connection_id_tx),
Executor::Deterministic(cx.background_executor().clone()),
None,
))
.detach();
let connection_id = connection_id_rx.await.map_err(|e| {
EstablishConnectionError::Other(anyhow!(
"{} (is server shutting down?)",
e
))
})?;
connection_killers
.lock()
.insert(connection_id.into(), killed);
Ok(client_conn)
}
})
});
let git_hosting_provider_registry = cx.update(GitHostingProviderRegistry::default_global);
git_hosting_provider_registry
.register_hosting_provider(Arc::new(git_hosting_providers::Github::public_instance()));
let user_store = cx.new(|cx| UserStore::new(client.clone(), cx));
let workspace_store = cx.new(|cx| WorkspaceStore::new(client.clone(), cx));
let language_registry = Arc::new(LanguageRegistry::test(cx.executor()));
let session = cx.new(|cx| AppSession::new(Session::test(), cx));
let app_state = Arc::new(workspace::AppState {
client: client.clone(),
user_store: user_store.clone(),
workspace_store,
languages: language_registry,
fs: fs.clone(),
build_window_options: |_, _| Default::default(),
node_runtime: NodeRuntime::unavailable(),
session,
});
let os_keymap = "keymaps/default-macos.json";
cx.update(|cx| {
theme_settings::init(theme::LoadThemes::JustBase, cx);
Project::init(&client, cx);
client::init(&client, cx);
editor::init(cx);
workspace::init(app_state.clone(), cx);
call::init(client.clone(), user_store.clone(), cx);
channel::init(&client, user_store.clone(), cx);
notifications::init(client.clone(), user_store, cx);
collab_ui::init(&app_state, cx);
file_finder::init(cx);
menu::init();
cx.bind_keys(
settings::KeymapFile::load_asset_allow_partial_failure(os_keymap, cx).unwrap(),
);
language_model::LanguageModelRegistry::test(cx);
});
client
.connect(false, &cx.to_async())
.await
.into_response()
.unwrap();
let client = TestClient {
app_state,
username: name.to_string(),
channel_store: cx.read(ChannelStore::global),
notification_store: cx.read(NotificationStore::global),
state: Default::default(),
};
client.wait_for_current_user(cx).await;
client
}
pub fn disconnect_client(&self, peer_id: PeerId) {
self.connection_killers
.lock()
.remove(&peer_id)
.unwrap()
.store(true, SeqCst);
}
pub fn simulate_long_connection_interruption(
&self,
peer_id: PeerId,
deterministic: BackgroundExecutor,
) {
self.forbid_connections();
self.disconnect_client(peer_id);
deterministic.advance_clock(RECEIVE_TIMEOUT + RECONNECT_TIMEOUT);
self.allow_connections();
deterministic.advance_clock(RECEIVE_TIMEOUT + RECONNECT_TIMEOUT);
deterministic.run_until_parked();
}
pub fn forbid_connections(&self) {
self.forbid_connections.store(true, SeqCst);
}
pub fn allow_connections(&self) {
self.forbid_connections.store(false, SeqCst);
}
pub async fn make_contacts(&self, clients: &mut [(&TestClient, &mut TestAppContext)]) {
for ix in 1..clients.len() {
let (left, right) = clients.split_at_mut(ix);
let (client_a, cx_a) = left.last_mut().unwrap();
for (client_b, cx_b) in right {
client_a
.app_state
.user_store
.update(*cx_a, |store, cx| {
store.request_contact(client_b.user_id().unwrap(), cx)
})
.await
.unwrap();
cx_a.executor().run_until_parked();
client_b
.app_state
.user_store
.update(*cx_b, |store, cx| {
store.respond_to_contact_request(client_a.user_id().unwrap(), true, cx)
})
.await
.unwrap();
}
}
}
pub async fn make_channel(
&self,
channel: &str,
parent: Option<ChannelId>,
admin: (&TestClient, &mut TestAppContext),
members: &mut [(&TestClient, &mut TestAppContext)],
) -> ChannelId {
let (admin_client, admin_cx) = admin;
// Subscribe to channels (simulates opening the collab panel)
admin_client.initialize_channel_store(admin_cx);
admin_cx.executor().run_until_parked();
let channel_id = admin_cx
.read(ChannelStore::global)
.update(admin_cx, |channel_store, cx| {
channel_store.create_channel(channel, parent, cx)
})
.await
.unwrap();
for (member_client, member_cx) in members {
// Subscribe member to channels (simulates opening the collab panel)
member_client.initialize_channel_store(member_cx);
member_cx.executor().run_until_parked();
admin_cx
.read(ChannelStore::global)
.update(admin_cx, |channel_store, cx| {
channel_store.invite_member(
channel_id,
member_client.user_id().unwrap(),
ChannelRole::Member,
cx,
)
})
.await
.unwrap();
admin_cx.executor().run_until_parked();
member_cx
.read(ChannelStore::global)
.update(*member_cx, |channels, cx| {
channels.respond_to_channel_invite(channel_id, true, cx)
})
.await
.unwrap();
}
channel_id
}
pub async fn make_public_channel(
&self,
channel: &str,
client: &TestClient,
cx: &mut TestAppContext,
) -> ChannelId {
let channel_id = self
.make_channel(channel, None, (client, cx), &mut [])
.await;
client
.channel_store()
.update(cx, |channel_store, cx| {
channel_store.set_channel_visibility(
channel_id,
proto::ChannelVisibility::Public,
cx,
)
})
.await
.unwrap();
channel_id
}
pub async fn make_channel_tree(
&self,
channels: &[(&str, Option<&str>)],
creator: (&TestClient, &mut TestAppContext),
) -> Vec<ChannelId> {
let mut observed_channels = HashMap::default();
let mut result = Vec::new();
for (channel, parent) in channels {
let id;
if let Some(parent) = parent {
if let Some(parent_id) = observed_channels.get(parent) {
id = self
.make_channel(channel, Some(*parent_id), (creator.0, creator.1), &mut [])
.await;
} else {
panic!(
"Edge {}->{} referenced before {} was created",
parent, channel, parent
)
}
} else {
id = self
.make_channel(channel, None, (creator.0, creator.1), &mut [])
.await;
}
observed_channels.insert(channel, id);
result.push(id);
}
result
}
pub async fn create_room(&self, clients: &mut [(&TestClient, &mut TestAppContext)]) {
self.make_contacts(clients).await;
let (left, right) = clients.split_at_mut(1);
let (_client_a, cx_a) = &mut left[0];
let active_call_a = cx_a.read(ActiveCall::global);
for (client_b, cx_b) in right {
let user_id_b = client_b.current_user_id(cx_b).to_proto();
active_call_a
.update(*cx_a, |call, cx| call.invite(user_id_b, None, cx))
.await
.unwrap();
cx_b.executor().run_until_parked();
let active_call_b = cx_b.read(ActiveCall::global);
active_call_b
.update(*cx_b, |call, cx| call.accept_incoming(cx))
.await
.unwrap();
}
}
pub async fn build_app_state(
test_db: &TestDb,
livekit_test_server: &LivekitTestServer,
executor: Executor,
) -> Arc<AppState> {
Arc::new(AppState {
db: test_db.db().clone(),
http_client: None,
livekit_client: Some(Arc::new(livekit_test_server.create_api_client())),
blob_store_client: None,
executor,
kinesis_client: None,
user_service: FakeUserService::new(test_db.db().clone()),
config: Config {
http_port: 0,
database_url: "".into(),
database_max_connections: 0,
livekit_server: None,
livekit_key: None,
livekit_secret: None,
rust_log: None,
log_json: None,
zed_environment: "test".into(),
zed_cloud_internal_api_key: "test-internal-api-key".into(),
blob_store_url: None,
blob_store_region: None,
blob_store_access_key: None,
blob_store_secret_key: None,
blob_store_bucket: None,
zed_client_checksum_seed: None,
seed_path: None,
kinesis_region: None,
kinesis_stream: None,
kinesis_access_key: None,
kinesis_secret_key: None,
},
})
}
}
impl Deref for TestServer {
type Target = Server;
fn deref(&self) -> &Self::Target {
&self.server
}
}
impl Drop for TestServer {
fn drop(&mut self) {
self.server.teardown();
self.test_livekit_server.teardown().unwrap();
}
}
impl Deref for TestClient {
type Target = Arc<Client>;
fn deref(&self) -> &Self::Target {
&self.app_state.client
}
}
impl TestClient {
pub fn fs(&self) -> Arc<FakeFs> {
self.app_state.fs.as_fake()
}
pub fn channel_store(&self) -> &Entity<ChannelStore> {
&self.channel_store
}
pub fn notification_store(&self) -> &Entity<NotificationStore> {
&self.notification_store
}
pub fn user_store(&self) -> &Entity<UserStore> {
&self.app_state.user_store
}
pub fn language_registry(&self) -> &Arc<LanguageRegistry> {
&self.app_state.languages
}
pub fn client(&self) -> &Arc<Client> {
&self.app_state.client
}
pub fn current_user_id(&self, cx: &TestAppContext) -> UserId {
UserId::from_proto(self.app_state.user_store.read_with(cx, |user_store, _| {
user_store.current_user().unwrap().legacy_id
}))
}
pub async fn wait_for_current_user(&self, cx: &TestAppContext) {
let mut authed_user = self
.app_state
.user_store
.read_with(cx, |user_store, _| user_store.watch_current_user());
while authed_user.next().await.unwrap().is_none() {}
}
pub async fn clear_contacts(&self, cx: &mut TestAppContext) {
self.app_state
.user_store
.update(cx, |store, _| store.clear_contacts())
.await;
}
/// Subscribe to channels. In production this happens when the user opens the collab panel.
pub fn initialize_channel_store(&self, cx: &mut TestAppContext) {
self.channel_store
.update(cx, |channel_store, _| channel_store.initialize());
}
pub fn local_projects(&self) -> impl Deref<Target = Vec<Entity<Project>>> + '_ {
Ref::map(self.state.borrow(), |state| &state.local_projects)
}
pub fn dev_server_projects(&self) -> impl Deref<Target = Vec<Entity<Project>>> + '_ {
Ref::map(self.state.borrow(), |state| &state.dev_server_projects)
}
pub fn local_projects_mut(&self) -> impl DerefMut<Target = Vec<Entity<Project>>> + '_ {
RefMut::map(self.state.borrow_mut(), |state| &mut state.local_projects)
}
pub fn dev_server_projects_mut(&self) -> impl DerefMut<Target = Vec<Entity<Project>>> + '_ {
RefMut::map(self.state.borrow_mut(), |state| {
&mut state.dev_server_projects
})
}
pub fn buffers_for_project<'a>(
&'a self,
project: &Entity<Project>,
) -> impl DerefMut<Target = HashSet<Entity<language::Buffer>>> + 'a {
RefMut::map(self.state.borrow_mut(), |state| {
state.buffers.entry(project.clone()).or_default()
})
}
pub fn buffers(
&self,
) -> impl DerefMut<Target = HashMap<Entity<Project>, HashSet<Entity<language::Buffer>>>> + '_
{
RefMut::map(self.state.borrow_mut(), |state| &mut state.buffers)
}
pub fn channel_buffers(&self) -> impl DerefMut<Target = HashSet<Entity<ChannelBuffer>>> + '_ {
RefMut::map(self.state.borrow_mut(), |state| &mut state.channel_buffers)
}
pub fn summarize_contacts(&self, cx: &TestAppContext) -> ContactsSummary {
self.app_state
.user_store
.read_with(cx, |store, _| ContactsSummary {
current: store
.contacts()
.iter()
.map(|contact| contact.user.github_login.clone().to_string())
.collect(),
outgoing_requests: store
.outgoing_contact_requests()
.iter()
.map(|user| user.github_login.clone().to_string())
.collect(),
incoming_requests: store
.incoming_contact_requests()
.iter()
.map(|user| user.github_login.clone().to_string())
.collect(),
})
}
pub async fn build_local_project(
&self,
root_path: impl AsRef<Path>,
cx: &mut TestAppContext,
) -> (Entity<Project>, WorktreeId) {
let project = self.build_empty_local_project(false, cx);
let (worktree, _) = project
.update(cx, |p, cx| p.find_or_create_worktree(root_path, true, cx))
.await
.unwrap();
worktree
.read_with(cx, |tree, _| tree.as_local().unwrap().scan_complete())
.await;
cx.run_until_parked();
(project, worktree.read_with(cx, |tree, _| tree.id()))
}
pub async fn build_local_project_with_trust(
&self,
root_path: impl AsRef<Path>,
cx: &mut TestAppContext,
) -> (Entity<Project>, WorktreeId) {
let project = self.build_empty_local_project(true, cx);
let (worktree, _) = project
.update(cx, |p, cx| p.find_or_create_worktree(root_path, true, cx))
.await
.unwrap();
worktree
.read_with(cx, |tree, _| tree.as_local().unwrap().scan_complete())
.await;
cx.run_until_parked();
(project, worktree.read_with(cx, |tree, _| tree.id()))
}
pub async fn build_ssh_project(
&self,
root_path: impl AsRef<Path>,
ssh: Entity<RemoteClient>,
init_worktree_trust: bool,
cx: &mut TestAppContext,
) -> (Entity<Project>, WorktreeId) {
let project = cx.update(|cx| {
Project::remote(
ssh,
self.client().clone(),
self.app_state.node_runtime.clone(),
self.app_state.user_store.clone(),
self.app_state.languages.clone(),
self.app_state.fs.clone(),
init_worktree_trust,
cx,
)
});
let (worktree, _) = project
.update(cx, |p, cx| p.find_or_create_worktree(root_path, true, cx))
.await
.unwrap();
(project, worktree.read_with(cx, |tree, _| tree.id()))
}
pub async fn build_test_project(&self, cx: &mut TestAppContext) -> Entity<Project> {
self.fs()
.insert_tree(
path!("/a"),
json!({
"1.txt": "one\none\none",
"2.js": "function two() { return 2; }",
"3.rs": "mod test",
}),
)
.await;
self.build_local_project(path!("/a"), cx).await.0
}
pub async fn host_workspace(
&self,
workspace: &Entity<Workspace>,
channel_id: ChannelId,
cx: &mut VisualTestContext,
) {
cx.update(|_, cx| {
let active_call = ActiveCall::global(cx);
active_call.update(cx, |call, cx| call.join_channel(channel_id, cx))
})
.await
.unwrap();
cx.update(|_, cx| {
let active_call = ActiveCall::global(cx);
let project = workspace.read(cx).project().clone();
active_call.update(cx, |call, cx| call.share_project(project, cx))
})
.await
.unwrap();
cx.executor().run_until_parked();
}
pub async fn join_workspace<'a>(
&'a self,
channel_id: ChannelId,
cx: &'a mut TestAppContext,
) -> (Entity<Workspace>, &'a mut VisualTestContext) {
cx.update(|cx| workspace::join_channel(channel_id, self.app_state.clone(), None, None, cx))
.await
.unwrap();
cx.run_until_parked();
self.active_workspace(cx)
}
pub fn build_empty_local_project(
&self,
init_worktree_trust: bool,
cx: &mut TestAppContext,
) -> Entity<Project> {
cx.update(|cx| {
Project::local(
self.client().clone(),
self.app_state.node_runtime.clone(),
self.app_state.user_store.clone(),
self.app_state.languages.clone(),
self.app_state.fs.clone(),
None,
project::LocalProjectFlags {
init_worktree_trust,
..Default::default()
},
cx,
)
})
}
pub async fn join_remote_project(
&self,
host_project_id: u64,
guest_cx: &mut TestAppContext,
) -> Entity<Project> {
let active_call = guest_cx.read(ActiveCall::global);
let room = active_call.read_with(guest_cx, |call, _| call.room().unwrap().clone());
room.update(guest_cx, |room, cx| {
room.join_project(
host_project_id,
self.app_state.languages.clone(),
self.app_state.fs.clone(),
cx,
)
})
.await
.unwrap()
}
pub fn build_workspace<'a>(
&'a self,
project: &Entity<Project>,
cx: &'a mut TestAppContext,
) -> (Entity<Workspace>, &'a mut VisualTestContext) {
let app_state = self.app_state.clone();
let project = project.clone();
let window = cx.add_window(|window, cx| {
window.activate_window();
let workspace = cx.new(|cx| Workspace::new(None, project, app_state, window, cx));
MultiWorkspace::new(workspace, window, cx)
});
let cx = VisualTestContext::from_window(*window, cx).into_mut();
cx.run_until_parked();
let workspace = window
.read_with(cx, |mw, _| mw.workspace().clone())
.unwrap();
(workspace, cx)
}
pub async fn build_test_workspace<'a>(
&'a self,
cx: &'a mut TestAppContext,
) -> (Entity<Workspace>, &'a mut VisualTestContext) {
let project = self.build_test_project(cx).await;
let app_state = self.app_state.clone();
let window = cx.add_window(|window, cx| {
window.activate_window();
let workspace = cx.new(|cx| Workspace::new(None, project, app_state, window, cx));
MultiWorkspace::new(workspace, window, cx)
});
let cx = VisualTestContext::from_window(*window, cx).into_mut();
let workspace = window
.read_with(cx, |mw, _| mw.workspace().clone())
.unwrap();
(workspace, cx)
}
pub fn active_workspace<'a>(
&'a self,
cx: &'a mut TestAppContext,
) -> (Entity<Workspace>, &'a mut VisualTestContext) {
let window = cx.update(|cx| {
cx.active_window()
.unwrap()
.downcast::<MultiWorkspace>()
.unwrap()
});
let entity = window
.read_with(cx, |mw, _| mw.workspace().clone())
.unwrap();
let cx = VisualTestContext::from_window(*window.deref(), cx).into_mut();
// it might be nice to try and cleanup these at the end of each test.
(entity, cx)
}
}
pub fn open_channel_notes(
channel_id: ChannelId,
cx: &mut VisualTestContext,
) -> Task<anyhow::Result<Entity<ChannelView>>> {
let window = cx.update(|_, cx| {
cx.active_window()
.unwrap()
.downcast::<MultiWorkspace>()
.unwrap()
});
let entity = window
.read_with(cx, |mw, _| mw.workspace().clone())
.unwrap();
cx.update(|window, cx| ChannelView::open(channel_id, None, entity.clone(), window, cx))
}
impl Drop for TestClient {
fn drop(&mut self) {
self.app_state.client.teardown();
}
}