Merge pull request #1635 from zed-industries/new-signup-flow

Implement APIs for new signup flow
This commit is contained in:
Max Brunsfeld 2022-09-28 10:08:12 -07:00 committed by GitHub
commit 5d8fe33bd2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
31 changed files with 2571 additions and 1287 deletions

View file

@ -56,6 +56,7 @@ jobs:
MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
APPLE_NOTARIZATION_USERNAME: ${{ secrets.APPLE_NOTARIZATION_USERNAME }} APPLE_NOTARIZATION_USERNAME: ${{ secrets.APPLE_NOTARIZATION_USERNAME }}
APPLE_NOTARIZATION_PASSWORD: ${{ secrets.APPLE_NOTARIZATION_PASSWORD }} APPLE_NOTARIZATION_PASSWORD: ${{ secrets.APPLE_NOTARIZATION_PASSWORD }}
ZED_AMPLITUDE_API_KEY: ${{ secrets.ZED_AMPLITUDE_API_KEY }}
steps: steps:
- name: Install Rust - name: Install Rust
run: | run: |

21
Cargo.lock generated
View file

@ -945,6 +945,7 @@ dependencies = [
"async-recursion", "async-recursion",
"async-tungstenite", "async-tungstenite",
"collections", "collections",
"db",
"futures", "futures",
"gpui", "gpui",
"image", "image",
@ -955,13 +956,16 @@ dependencies = [
"postage", "postage",
"rand 0.8.5", "rand 0.8.5",
"rpc", "rpc",
"serde",
"smol", "smol",
"sum_tree", "sum_tree",
"tempfile",
"thiserror", "thiserror",
"time 0.3.11", "time 0.3.11",
"tiny_http", "tiny_http",
"url", "url",
"util", "util",
"uuid 1.1.2",
] ]
[[package]] [[package]]
@ -1503,6 +1507,19 @@ dependencies = [
"matches", "matches",
] ]
[[package]]
name = "db"
version = "0.1.0"
dependencies = [
"anyhow",
"async-trait",
"collections",
"gpui",
"parking_lot 0.11.2",
"rocksdb",
"tempdir",
]
[[package]] [[package]]
name = "deflate" name = "deflate"
version = "0.8.6" version = "0.8.6"
@ -3949,6 +3966,7 @@ dependencies = [
"client", "client",
"clock", "clock",
"collections", "collections",
"db",
"fsevent", "fsevent",
"futures", "futures",
"fuzzy", "fuzzy",
@ -6334,6 +6352,9 @@ name = "uuid"
version = "1.1.2" version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd6469f4314d5f1ffec476e05f17cc9a78bc7a27a6a857842170bdf8d6f98d2f" checksum = "dd6469f4314d5f1ffec476e05f17cc9a78bc7a27a6a857842170bdf8d6f98d2f"
dependencies = [
"getrandom 0.2.7",
]
[[package]] [[package]]
name = "valuable" name = "valuable"

View file

@ -12,6 +12,7 @@ test-support = ["collections/test-support", "gpui/test-support", "rpc/test-suppo
[dependencies] [dependencies]
collections = { path = "../collections" } collections = { path = "../collections" }
db = { path = "../db" }
gpui = { path = "../gpui" } gpui = { path = "../gpui" }
util = { path = "../util" } util = { path = "../util" }
rpc = { path = "../rpc" } rpc = { path = "../rpc" }
@ -31,7 +32,10 @@ smol = "1.2.5"
thiserror = "1.0.29" thiserror = "1.0.29"
time = { version = "0.3", features = ["serde", "serde-well-known"] } time = { version = "0.3", features = ["serde", "serde-well-known"] }
tiny_http = "0.8" tiny_http = "0.8"
uuid = { version = "1.1.2", features = ["v4"] }
url = "2.2" url = "2.2"
serde = { version = "*", features = ["derive"] }
tempfile = "3"
[dev-dependencies] [dev-dependencies]
collections = { path = "../collections", features = ["test-support"] } collections = { path = "../collections", features = ["test-support"] }

View file

@ -601,7 +601,7 @@ mod tests {
let user_id = 5; let user_id = 5;
let http_client = FakeHttpClient::with_404_response(); let http_client = FakeHttpClient::with_404_response();
let client = Client::new(http_client.clone()); let client = cx.update(|cx| Client::new(http_client.clone(), cx));
let server = FakeServer::for_client(user_id, &client, cx).await; let server = FakeServer::for_client(user_id, &client, cx).await;
Channel::init(&client); Channel::init(&client);

View file

@ -3,6 +3,7 @@ pub mod test;
pub mod channel; pub mod channel;
pub mod http; pub mod http;
pub mod telemetry;
pub mod user; pub mod user;
use anyhow::{anyhow, Context, Result}; use anyhow::{anyhow, Context, Result};
@ -11,10 +12,14 @@ use async_tungstenite::tungstenite::{
error::Error as WebsocketError, error::Error as WebsocketError,
http::{Request, StatusCode}, http::{Request, StatusCode},
}; };
use db::Db;
use futures::{future::LocalBoxFuture, FutureExt, SinkExt, StreamExt, TryStreamExt}; use futures::{future::LocalBoxFuture, FutureExt, SinkExt, StreamExt, TryStreamExt};
use gpui::{ use gpui::{
actions, AnyModelHandle, AnyViewHandle, AnyWeakModelHandle, AnyWeakViewHandle, AsyncAppContext, actions,
Entity, ModelContext, ModelHandle, MutableAppContext, Task, View, ViewContext, ViewHandle, serde_json::{json, Value},
AnyModelHandle, AnyViewHandle, AnyWeakModelHandle, AnyWeakViewHandle, AppContext,
AsyncAppContext, Entity, ModelContext, ModelHandle, MutableAppContext, Task, View, ViewContext,
ViewHandle,
}; };
use http::HttpClient; use http::HttpClient;
use lazy_static::lazy_static; use lazy_static::lazy_static;
@ -28,9 +33,11 @@ use std::{
convert::TryFrom, convert::TryFrom,
fmt::Write as _, fmt::Write as _,
future::Future, future::Future,
path::PathBuf,
sync::{Arc, Weak}, sync::{Arc, Weak},
time::{Duration, Instant}, time::{Duration, Instant},
}; };
use telemetry::Telemetry;
use thiserror::Error; use thiserror::Error;
use url::Url; use url::Url;
use util::{ResultExt, TryFutureExt}; use util::{ResultExt, TryFutureExt};
@ -49,13 +56,29 @@ lazy_static! {
pub const ZED_SECRET_CLIENT_TOKEN: &str = "618033988749894"; pub const ZED_SECRET_CLIENT_TOKEN: &str = "618033988749894";
actions!(client, [Authenticate]); actions!(client, [Authenticate, TestTelemetry]);
pub fn init(rpc: Arc<Client>, cx: &mut MutableAppContext) { pub fn init(client: Arc<Client>, cx: &mut MutableAppContext) {
cx.add_global_action(move |_: &Authenticate, cx| { cx.add_global_action({
let rpc = rpc.clone(); let client = client.clone();
cx.spawn(|cx| async move { rpc.authenticate_and_connect(true, &cx).log_err().await }) move |_: &Authenticate, cx| {
let client = client.clone();
cx.spawn(
|cx| async move { client.authenticate_and_connect(true, &cx).log_err().await },
)
.detach(); .detach();
}
});
cx.add_global_action({
let client = client.clone();
move |_: &TestTelemetry, _| {
client.report_event(
"test_telemetry",
json!({
"test_property": "test_value"
}),
)
}
}); });
} }
@ -63,6 +86,7 @@ pub struct Client {
id: usize, id: usize,
peer: Arc<Peer>, peer: Arc<Peer>,
http: Arc<dyn HttpClient>, http: Arc<dyn HttpClient>,
telemetry: Arc<Telemetry>,
state: RwLock<ClientState>, state: RwLock<ClientState>,
#[allow(clippy::type_complexity)] #[allow(clippy::type_complexity)]
@ -232,10 +256,11 @@ impl Drop for Subscription {
} }
impl Client { impl Client {
pub fn new(http: Arc<dyn HttpClient>) -> Arc<Self> { pub fn new(http: Arc<dyn HttpClient>, cx: &AppContext) -> Arc<Self> {
Arc::new(Self { Arc::new(Self {
id: 0, id: 0,
peer: Peer::new(), peer: Peer::new(),
telemetry: Telemetry::new(http.clone(), cx),
http, http,
state: Default::default(), state: Default::default(),
@ -308,9 +333,11 @@ impl Client {
log::info!("set status on client {}: {:?}", self.id, status); log::info!("set status on client {}: {:?}", self.id, status);
let mut state = self.state.write(); let mut state = self.state.write();
*state.status.0.borrow_mut() = status; *state.status.0.borrow_mut() = status;
let user_id = state.credentials.as_ref().map(|c| c.user_id);
match status { match status {
Status::Connected { .. } => { Status::Connected { .. } => {
self.telemetry.set_user_id(user_id);
state._reconnect_task = None; state._reconnect_task = None;
} }
Status::ConnectionLost => { Status::ConnectionLost => {
@ -339,6 +366,7 @@ impl Client {
})); }));
} }
Status::SignedOut | Status::UpgradeRequired => { Status::SignedOut | Status::UpgradeRequired => {
self.telemetry.set_user_id(user_id);
state._reconnect_task.take(); state._reconnect_task.take();
} }
_ => {} _ => {}
@ -595,6 +623,9 @@ impl Client {
if credentials.is_none() && try_keychain { if credentials.is_none() && try_keychain {
credentials = read_credentials_from_keychain(cx); credentials = read_credentials_from_keychain(cx);
read_from_keychain = credentials.is_some(); read_from_keychain = credentials.is_some();
if read_from_keychain {
self.report_event("read credentials from keychain", Default::default());
}
} }
if credentials.is_none() { if credentials.is_none() {
let mut status_rx = self.status(); let mut status_rx = self.status();
@ -878,6 +909,7 @@ impl Client {
) -> Task<Result<Credentials>> { ) -> Task<Result<Credentials>> {
let platform = cx.platform(); let platform = cx.platform();
let executor = cx.background(); let executor = cx.background();
let telemetry = self.telemetry.clone();
executor.clone().spawn(async move { executor.clone().spawn(async move {
// Generate a pair of asymmetric encryption keys. The public key will be used by the // Generate a pair of asymmetric encryption keys. The public key will be used by the
// zed server to encrypt the user's access token, so that it can'be intercepted by // zed server to encrypt the user's access token, so that it can'be intercepted by
@ -956,6 +988,8 @@ impl Client {
.context("failed to decrypt access token")?; .context("failed to decrypt access token")?;
platform.activate(true); platform.activate(true);
telemetry.report_event("authenticate with browser", Default::default());
Ok(Credentials { Ok(Credentials {
user_id: user_id.parse()?, user_id: user_id.parse()?,
access_token, access_token,
@ -1020,6 +1054,18 @@ impl Client {
log::debug!("rpc respond. client_id:{}. name:{}", self.id, T::NAME); log::debug!("rpc respond. client_id:{}. name:{}", self.id, T::NAME);
self.peer.respond_with_error(receipt, error) self.peer.respond_with_error(receipt, error)
} }
pub fn start_telemetry(&self, db: Arc<Db>) {
self.telemetry.start(db);
}
pub fn report_event(&self, kind: &str, properties: Value) {
self.telemetry.report_event(kind, properties)
}
pub fn telemetry_log_file_path(&self) -> Option<PathBuf> {
self.telemetry.log_file_path()
}
} }
impl AnyWeakEntityHandle { impl AnyWeakEntityHandle {
@ -1085,7 +1131,7 @@ mod tests {
cx.foreground().forbid_parking(); cx.foreground().forbid_parking();
let user_id = 5; let user_id = 5;
let client = Client::new(FakeHttpClient::with_404_response()); let client = cx.update(|cx| Client::new(FakeHttpClient::with_404_response(), cx));
let server = FakeServer::for_client(user_id, &client, cx).await; let server = FakeServer::for_client(user_id, &client, cx).await;
let mut status = client.status(); let mut status = client.status();
assert!(matches!( assert!(matches!(
@ -1124,7 +1170,7 @@ mod tests {
let auth_count = Arc::new(Mutex::new(0)); let auth_count = Arc::new(Mutex::new(0));
let dropped_auth_count = Arc::new(Mutex::new(0)); let dropped_auth_count = Arc::new(Mutex::new(0));
let client = Client::new(FakeHttpClient::with_404_response()); let client = cx.update(|cx| Client::new(FakeHttpClient::with_404_response(), cx));
client.override_authenticate({ client.override_authenticate({
let auth_count = auth_count.clone(); let auth_count = auth_count.clone();
let dropped_auth_count = dropped_auth_count.clone(); let dropped_auth_count = dropped_auth_count.clone();
@ -1173,7 +1219,7 @@ mod tests {
cx.foreground().forbid_parking(); cx.foreground().forbid_parking();
let user_id = 5; let user_id = 5;
let client = Client::new(FakeHttpClient::with_404_response()); let client = cx.update(|cx| Client::new(FakeHttpClient::with_404_response(), cx));
let server = FakeServer::for_client(user_id, &client, cx).await; let server = FakeServer::for_client(user_id, &client, cx).await;
let (done_tx1, mut done_rx1) = smol::channel::unbounded(); let (done_tx1, mut done_rx1) = smol::channel::unbounded();
@ -1219,7 +1265,7 @@ mod tests {
cx.foreground().forbid_parking(); cx.foreground().forbid_parking();
let user_id = 5; let user_id = 5;
let client = Client::new(FakeHttpClient::with_404_response()); let client = cx.update(|cx| Client::new(FakeHttpClient::with_404_response(), cx));
let server = FakeServer::for_client(user_id, &client, cx).await; let server = FakeServer::for_client(user_id, &client, cx).await;
let model = cx.add_model(|_| Model::default()); let model = cx.add_model(|_| Model::default());
@ -1247,7 +1293,7 @@ mod tests {
cx.foreground().forbid_parking(); cx.foreground().forbid_parking();
let user_id = 5; let user_id = 5;
let client = Client::new(FakeHttpClient::with_404_response()); let client = cx.update(|cx| Client::new(FakeHttpClient::with_404_response(), cx));
let server = FakeServer::for_client(user_id, &client, cx).await; let server = FakeServer::for_client(user_id, &client, cx).await;
let model = cx.add_model(|_| Model::default()); let model = cx.add_model(|_| Model::default());

View file

@ -0,0 +1,255 @@
use crate::http::HttpClient;
use db::Db;
use gpui::{
executor::Background,
serde_json::{self, value::Map, Value},
AppContext, Task,
};
use isahc::Request;
use lazy_static::lazy_static;
use parking_lot::Mutex;
use serde::Serialize;
use std::{
io::Write,
mem,
path::PathBuf,
sync::Arc,
time::{Duration, SystemTime, UNIX_EPOCH},
};
use tempfile::NamedTempFile;
use util::{post_inc, ResultExt, TryFutureExt};
use uuid::Uuid;
pub struct Telemetry {
http_client: Arc<dyn HttpClient>,
executor: Arc<Background>,
session_id: u128,
state: Mutex<TelemetryState>,
}
#[derive(Default)]
struct TelemetryState {
user_id: Option<Arc<str>>,
device_id: Option<Arc<str>>,
app_version: Option<Arc<str>>,
os_version: Option<Arc<str>>,
os_name: &'static str,
queue: Vec<AmplitudeEvent>,
next_event_id: usize,
flush_task: Option<Task<()>>,
log_file: Option<NamedTempFile>,
}
const AMPLITUDE_EVENTS_URL: &'static str = "https://api2.amplitude.com/batch";
lazy_static! {
static ref AMPLITUDE_API_KEY: Option<String> = std::env::var("ZED_AMPLITUDE_API_KEY")
.ok()
.or_else(|| option_env!("ZED_AMPLITUDE_API_KEY").map(|key| key.to_string()));
}
#[derive(Serialize)]
struct AmplitudeEventBatch {
api_key: &'static str,
events: Vec<AmplitudeEvent>,
}
#[derive(Serialize)]
struct AmplitudeEvent {
#[serde(skip_serializing_if = "Option::is_none")]
user_id: Option<Arc<str>>,
device_id: Option<Arc<str>>,
event_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
event_properties: Option<Map<String, Value>>,
#[serde(skip_serializing_if = "Option::is_none")]
user_properties: Option<Map<String, Value>>,
os_name: &'static str,
os_version: Option<Arc<str>>,
app_version: Option<Arc<str>>,
event_id: usize,
session_id: u128,
time: u128,
}
#[cfg(debug_assertions)]
const MAX_QUEUE_LEN: usize = 1;
#[cfg(not(debug_assertions))]
const MAX_QUEUE_LEN: usize = 10;
#[cfg(debug_assertions)]
const DEBOUNCE_INTERVAL: Duration = Duration::from_secs(1);
#[cfg(not(debug_assertions))]
const DEBOUNCE_INTERVAL: Duration = Duration::from_secs(30);
impl Telemetry {
pub fn new(client: Arc<dyn HttpClient>, cx: &AppContext) -> Arc<Self> {
let platform = cx.platform();
let this = Arc::new(Self {
http_client: client,
executor: cx.background().clone(),
session_id: SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis(),
state: Mutex::new(TelemetryState {
os_version: platform
.os_version()
.log_err()
.map(|v| v.to_string().into()),
os_name: platform.os_name().into(),
app_version: platform
.app_version()
.log_err()
.map(|v| v.to_string().into()),
device_id: None,
queue: Default::default(),
flush_task: Default::default(),
next_event_id: 0,
log_file: None,
user_id: None,
}),
});
if AMPLITUDE_API_KEY.is_some() {
this.executor
.spawn({
let this = this.clone();
async move {
if let Some(tempfile) = NamedTempFile::new().log_err() {
this.state.lock().log_file = Some(tempfile);
}
}
})
.detach();
}
this
}
pub fn log_file_path(&self) -> Option<PathBuf> {
Some(self.state.lock().log_file.as_ref()?.path().to_path_buf())
}
pub fn start(self: &Arc<Self>, db: Arc<Db>) {
let this = self.clone();
self.executor
.spawn(
async move {
let device_id = if let Some(device_id) = db
.read(["device_id"])?
.into_iter()
.flatten()
.next()
.and_then(|bytes| String::from_utf8(bytes).ok())
{
device_id
} else {
let device_id = Uuid::new_v4().to_string();
db.write([("device_id", device_id.as_bytes())])?;
device_id
};
let device_id = Some(Arc::from(device_id));
let mut state = this.state.lock();
state.device_id = device_id.clone();
for event in &mut state.queue {
event.device_id = device_id.clone();
}
if !state.queue.is_empty() {
drop(state);
this.flush();
}
anyhow::Ok(())
}
.log_err(),
)
.detach();
}
pub fn set_user_id(&self, user_id: Option<u64>) {
self.state.lock().user_id = user_id.map(|id| id.to_string().into());
}
pub fn report_event(self: &Arc<Self>, kind: &str, properties: Value) {
if AMPLITUDE_API_KEY.is_none() {
return;
}
let mut state = self.state.lock();
let event = AmplitudeEvent {
event_type: kind.to_string(),
time: SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis(),
session_id: self.session_id,
event_properties: if let Value::Object(properties) = properties {
Some(properties)
} else {
None
},
user_properties: None,
user_id: state.user_id.clone(),
device_id: state.device_id.clone(),
os_name: state.os_name,
os_version: state.os_version.clone(),
app_version: state.app_version.clone(),
event_id: post_inc(&mut state.next_event_id),
};
state.queue.push(event);
if state.device_id.is_some() {
if state.queue.len() >= MAX_QUEUE_LEN {
drop(state);
self.flush();
} else {
let this = self.clone();
let executor = self.executor.clone();
state.flush_task = Some(self.executor.spawn(async move {
executor.timer(DEBOUNCE_INTERVAL).await;
this.flush();
}));
}
}
}
fn flush(self: &Arc<Self>) {
let mut state = self.state.lock();
let events = mem::take(&mut state.queue);
state.flush_task.take();
drop(state);
if let Some(api_key) = AMPLITUDE_API_KEY.as_ref() {
let this = self.clone();
self.executor
.spawn(
async move {
let mut json_bytes = Vec::new();
if let Some(file) = &mut this.state.lock().log_file {
let file = file.as_file_mut();
for event in &events {
json_bytes.clear();
serde_json::to_writer(&mut json_bytes, event)?;
file.write_all(&json_bytes)?;
file.write(b"\n")?;
}
}
let batch = AmplitudeEventBatch { api_key, events };
json_bytes.clear();
serde_json::to_writer(&mut json_bytes, &batch)?;
let request =
Request::post(AMPLITUDE_EVENTS_URL).body(json_bytes.into())?;
this.http_client.send(request).await?;
Ok(())
}
.log_err(),
)
.detach();
}
}
}

View file

@ -0,0 +1,6 @@
DROP TABLE signups;
ALTER TABLE users
DROP COLUMN github_user_id;
DROP INDEX index_users_on_email_address;

View file

@ -0,0 +1,27 @@
CREATE TABLE IF NOT EXISTS "signups" (
"id" SERIAL PRIMARY KEY,
"email_address" VARCHAR NOT NULL,
"email_confirmation_code" VARCHAR(64) NOT NULL,
"email_confirmation_sent" BOOLEAN NOT NULL,
"created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"device_id" VARCHAR,
"user_id" INTEGER REFERENCES users (id) ON DELETE CASCADE,
"inviting_user_id" INTEGER REFERENCES users (id) ON DELETE SET NULL,
"platform_mac" BOOLEAN NOT NULL,
"platform_linux" BOOLEAN NOT NULL,
"platform_windows" BOOLEAN NOT NULL,
"platform_unknown" BOOLEAN NOT NULL,
"editor_features" VARCHAR[],
"programming_languages" VARCHAR[]
);
CREATE UNIQUE INDEX "index_signups_on_email_address" ON "signups" ("email_address");
CREATE INDEX "index_signups_on_email_confirmation_sent" ON "signups" ("email_confirmation_sent");
ALTER TABLE "users"
ADD "github_user_id" INTEGER;
CREATE INDEX "index_users_on_email_address" ON "users" ("email_address");
CREATE INDEX "index_users_on_github_user_id" ON "users" ("github_user_id");

View file

@ -1,6 +1,6 @@
use crate::{ use crate::{
auth, auth,
db::{ProjectId, User, UserId}, db::{Invite, NewUserParams, ProjectId, Signup, User, UserId, WaitlistSummary},
rpc::{self, ResultExt}, rpc::{self, ResultExt},
AppState, Error, Result, AppState, Error, Result,
}; };
@ -25,12 +25,8 @@ use tracing::instrument;
pub fn routes(rpc_server: &Arc<rpc::Server>, state: Arc<AppState>) -> Router<Body> { pub fn routes(rpc_server: &Arc<rpc::Server>, state: Arc<AppState>) -> Router<Body> {
Router::new() Router::new()
.route("/users", get(get_users).post(create_user)) .route("/users", get(get_users).post(create_user))
.route( .route("/users/:id", put(update_user).delete(destroy_user))
"/users/:id",
put(update_user).delete(destroy_user).get(get_user),
)
.route("/users/:id/access_tokens", post(create_access_token)) .route("/users/:id/access_tokens", post(create_access_token))
.route("/bulk_users", post(create_users))
.route("/users_with_no_invites", get(get_users_with_no_invites)) .route("/users_with_no_invites", get(get_users_with_no_invites))
.route("/invite_codes/:code", get(get_user_for_invite_code)) .route("/invite_codes/:code", get(get_user_for_invite_code))
.route("/panic", post(trace_panic)) .route("/panic", post(trace_panic))
@ -45,6 +41,11 @@ pub fn routes(rpc_server: &Arc<rpc::Server>, state: Arc<AppState>) -> Router<Bod
) )
.route("/user_activity/counts", get(get_active_user_counts)) .route("/user_activity/counts", get(get_active_user_counts))
.route("/project_metadata", get(get_project_metadata)) .route("/project_metadata", get(get_project_metadata))
.route("/signups", post(create_signup))
.route("/signups_summary", get(get_waitlist_summary))
.route("/user_invites", post(create_invite_from_code))
.route("/unsent_invites", get(get_unsent_invites))
.route("/sent_invites", post(record_sent_invites))
.layer( .layer(
ServiceBuilder::new() ServiceBuilder::new()
.layer(Extension(state)) .layer(Extension(state))
@ -86,6 +87,8 @@ pub async fn validate_api_token<B>(req: Request<B>, next: Next<B>) -> impl IntoR
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct GetUsersQueryParams { struct GetUsersQueryParams {
github_user_id: Option<i32>,
github_login: Option<String>,
query: Option<String>, query: Option<String>,
page: Option<u32>, page: Option<u32>,
limit: Option<u32>, limit: Option<u32>,
@ -95,6 +98,14 @@ async fn get_users(
Query(params): Query<GetUsersQueryParams>, Query(params): Query<GetUsersQueryParams>,
Extension(app): Extension<Arc<AppState>>, Extension(app): Extension<Arc<AppState>>,
) -> Result<Json<Vec<User>>> { ) -> Result<Json<Vec<User>>> {
if let Some(github_login) = &params.github_login {
let user = app
.db
.get_user_by_github_account(github_login, params.github_user_id)
.await?;
return Ok(Json(Vec::from_iter(user)));
}
let limit = params.limit.unwrap_or(100); let limit = params.limit.unwrap_or(100);
let users = if let Some(query) = params.query { let users = if let Some(query) = params.query {
app.db.fuzzy_search_users(&query, limit).await? app.db.fuzzy_search_users(&query, limit).await?
@ -108,40 +119,61 @@ async fn get_users(
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
struct CreateUserParams { struct CreateUserParams {
github_user_id: i32,
github_login: String, github_login: String,
invite_code: Option<String>, email_address: String,
email_address: Option<String>, email_confirmation_code: Option<String>,
admin: bool, #[serde(default)]
invite_count: i32,
}
#[derive(Serialize, Debug)]
struct CreateUserResponse {
user: User,
signup_device_id: Option<String>,
} }
async fn create_user( async fn create_user(
Json(params): Json<CreateUserParams>, Json(params): Json<CreateUserParams>,
Extension(app): Extension<Arc<AppState>>, Extension(app): Extension<Arc<AppState>>,
Extension(rpc_server): Extension<Arc<rpc::Server>>, Extension(rpc_server): Extension<Arc<rpc::Server>>,
) -> Result<Json<User>> { ) -> Result<Json<CreateUserResponse>> {
let user_id = if let Some(invite_code) = params.invite_code { let user = NewUserParams {
let invitee_id = app github_login: params.github_login,
github_user_id: params.github_user_id,
invite_count: params.invite_count,
};
let user_id;
let signup_device_id;
// Creating a user via the normal signup process
if let Some(email_confirmation_code) = params.email_confirmation_code {
let result = app
.db .db
.redeem_invite_code( .create_user_from_invite(
&invite_code, &Invite {
&params.github_login, email_address: params.email_address,
params.email_address.as_deref(), email_confirmation_code,
},
user,
) )
.await?; .await?;
rpc_server user_id = result.user_id;
.invite_code_redeemed(&invite_code, invitee_id) signup_device_id = result.signup_device_id;
.await if let Some(inviter_id) = result.inviting_user_id {
.trace_err(); rpc_server
invitee_id .invite_code_redeemed(inviter_id, user_id)
} else { .await
app.db .trace_err();
.create_user( }
&params.github_login, }
params.email_address.as_deref(), // Creating a user as an admin
params.admin, else {
) user_id = app
.await? .db
}; .create_user(&params.email_address, false, user)
.await?;
signup_device_id = None;
}
let user = app let user = app
.db .db
@ -149,7 +181,10 @@ async fn create_user(
.await? .await?
.ok_or_else(|| anyhow!("couldn't find the user we just created"))?; .ok_or_else(|| anyhow!("couldn't find the user we just created"))?;
Ok(Json(user)) Ok(Json(CreateUserResponse {
user,
signup_device_id,
}))
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@ -171,7 +206,9 @@ async fn update_user(
} }
if let Some(invite_count) = params.invite_count { if let Some(invite_count) = params.invite_count {
app.db.set_invite_count(user_id, invite_count).await?; app.db
.set_invite_count_for_user(user_id, invite_count)
.await?;
rpc_server.invite_count_updated(user_id).await.trace_err(); rpc_server.invite_count_updated(user_id).await.trace_err();
} }
@ -186,54 +223,6 @@ async fn destroy_user(
Ok(()) Ok(())
} }
async fn get_user(
Path(login): Path<String>,
Extension(app): Extension<Arc<AppState>>,
) -> Result<Json<User>> {
let user = app
.db
.get_user_by_github_login(&login)
.await?
.ok_or_else(|| Error::Http(StatusCode::NOT_FOUND, "User not found".to_string()))?;
Ok(Json(user))
}
#[derive(Deserialize)]
struct CreateUsersParams {
users: Vec<CreateUsersEntry>,
}
#[derive(Deserialize)]
struct CreateUsersEntry {
github_login: String,
email_address: String,
invite_count: usize,
}
async fn create_users(
Json(params): Json<CreateUsersParams>,
Extension(app): Extension<Arc<AppState>>,
) -> Result<Json<Vec<User>>> {
let user_ids = app
.db
.create_users(
params
.users
.into_iter()
.map(|params| {
(
params.github_login,
params.email_address,
params.invite_count,
)
})
.collect(),
)
.await?;
let users = app.db.get_users_by_ids(user_ids).await?;
Ok(Json(users))
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct GetUsersWithNoInvites { struct GetUsersWithNoInvites {
invited_by_another_user: bool, invited_by_another_user: bool,
@ -368,22 +357,24 @@ struct CreateAccessTokenResponse {
} }
async fn create_access_token( async fn create_access_token(
Path(login): Path<String>, Path(user_id): Path<UserId>,
Query(params): Query<CreateAccessTokenQueryParams>, Query(params): Query<CreateAccessTokenQueryParams>,
Extension(app): Extension<Arc<AppState>>, Extension(app): Extension<Arc<AppState>>,
) -> Result<Json<CreateAccessTokenResponse>> { ) -> Result<Json<CreateAccessTokenResponse>> {
// request.require_token().await?;
let user = app let user = app
.db .db
.get_user_by_github_login(&login) .get_user_by_id(user_id)
.await? .await?
.ok_or_else(|| anyhow!("user not found"))?; .ok_or_else(|| anyhow!("user not found"))?;
let mut user_id = user.id; let mut user_id = user.id;
if let Some(impersonate) = params.impersonate { if let Some(impersonate) = params.impersonate {
if user.admin { if user.admin {
if let Some(impersonated_user) = app.db.get_user_by_github_login(&impersonate).await? { if let Some(impersonated_user) = app
.db
.get_user_by_github_account(&impersonate, None)
.await?
{
user_id = impersonated_user.id; user_id = impersonated_user.id;
} else { } else {
return Err(Error::Http( return Err(Error::Http(
@ -415,3 +406,59 @@ async fn get_user_for_invite_code(
) -> Result<Json<User>> { ) -> Result<Json<User>> {
Ok(Json(app.db.get_user_for_invite_code(&code).await?)) Ok(Json(app.db.get_user_for_invite_code(&code).await?))
} }
async fn create_signup(
Json(params): Json<Signup>,
Extension(app): Extension<Arc<AppState>>,
) -> Result<()> {
app.db.create_signup(params).await?;
Ok(())
}
async fn get_waitlist_summary(
Extension(app): Extension<Arc<AppState>>,
) -> Result<Json<WaitlistSummary>> {
Ok(Json(app.db.get_waitlist_summary().await?))
}
#[derive(Deserialize)]
pub struct CreateInviteFromCodeParams {
invite_code: String,
email_address: String,
device_id: Option<String>,
}
async fn create_invite_from_code(
Json(params): Json<CreateInviteFromCodeParams>,
Extension(app): Extension<Arc<AppState>>,
) -> Result<Json<Invite>> {
Ok(Json(
app.db
.create_invite_from_code(
&params.invite_code,
&params.email_address,
params.device_id.as_deref(),
)
.await?,
))
}
#[derive(Deserialize)]
pub struct GetUnsentInvitesParams {
pub count: usize,
}
async fn get_unsent_invites(
Query(params): Query<GetUnsentInvitesParams>,
Extension(app): Extension<Arc<AppState>>,
) -> Result<Json<Vec<Invite>>> {
Ok(Json(app.db.get_unsent_invites(params.count).await?))
}
async fn record_sent_invites(
Json(params): Json<Vec<Invite>>,
Extension(app): Extension<Arc<AppState>>,
) -> Result<()> {
app.db.record_sent_invites(&params).await?;
Ok(())
}

View file

@ -11,7 +11,7 @@ mod db;
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct GitHubUser { struct GitHubUser {
id: usize, id: i32,
login: String, login: String,
email: Option<String>, email: Option<String>,
} }
@ -26,8 +26,11 @@ async fn main() {
let github_token = std::env::var("GITHUB_TOKEN").expect("missing GITHUB_TOKEN env var"); let github_token = std::env::var("GITHUB_TOKEN").expect("missing GITHUB_TOKEN env var");
let client = reqwest::Client::new(); let client = reqwest::Client::new();
let current_user = let mut current_user =
fetch_github::<GitHubUser>(&client, &github_token, "https://api.github.com/user").await; fetch_github::<GitHubUser>(&client, &github_token, "https://api.github.com/user").await;
current_user
.email
.get_or_insert_with(|| "placeholder@example.com".to_string());
let staff_users = fetch_github::<Vec<GitHubUser>>( let staff_users = fetch_github::<Vec<GitHubUser>>(
&client, &client,
&github_token, &github_token,
@ -64,16 +67,24 @@ async fn main() {
let mut zed_user_ids = Vec::<UserId>::new(); let mut zed_user_ids = Vec::<UserId>::new();
for (github_user, admin) in zed_users { for (github_user, admin) in zed_users {
if let Some(user) = db if let Some(user) = db
.get_user_by_github_login(&github_user.login) .get_user_by_github_account(&github_user.login, Some(github_user.id))
.await .await
.expect("failed to fetch user") .expect("failed to fetch user")
{ {
zed_user_ids.push(user.id); zed_user_ids.push(user.id);
} else { } else if let Some(email) = &github_user.email {
zed_user_ids.push( zed_user_ids.push(
db.create_user(&github_user.login, github_user.email.as_deref(), admin) db.create_user(
.await email,
.expect("failed to insert user"), admin,
db::NewUserParams {
github_login: github_user.login,
github_user_id: github_user.id,
invite_count: 5,
},
)
.await
.expect("failed to insert user"),
); );
} }
} }

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,5 @@
use crate::{ use crate::{
db::{tests::TestDb, ProjectId, UserId}, db::{NewUserParams, ProjectId, TestDb, UserId},
rpc::{Executor, Server, Store}, rpc::{Executor, Server, Store},
AppState, AppState,
}; };
@ -4652,7 +4652,18 @@ async fn test_random_collaboration(
let mut server = TestServer::start(cx.foreground(), cx.background()).await; let mut server = TestServer::start(cx.foreground(), cx.background()).await;
let db = server.app_state.db.clone(); let db = server.app_state.db.clone();
let host_user_id = db.create_user("host", None, false).await.unwrap(); let host_user_id = db
.create_user(
"host@example.com",
false,
NewUserParams {
github_login: "host".into(),
github_user_id: 0,
invite_count: 0,
},
)
.await
.unwrap();
let mut available_guests = vec![ let mut available_guests = vec![
"guest-1".to_string(), "guest-1".to_string(),
"guest-2".to_string(), "guest-2".to_string(),
@ -4660,8 +4671,19 @@ async fn test_random_collaboration(
"guest-4".to_string(), "guest-4".to_string(),
]; ];
for username in &available_guests { for (ix, username) in available_guests.iter().enumerate() {
let guest_user_id = db.create_user(username, None, false).await.unwrap(); let guest_user_id = db
.create_user(
&format!("{username}@example.com"),
false,
NewUserParams {
github_login: username.into(),
github_user_id: ix as i32,
invite_count: 0,
},
)
.await
.unwrap();
assert_eq!(*username, format!("guest-{}", guest_user_id)); assert_eq!(*username, format!("guest-{}", guest_user_id));
server server
.app_state .app_state
@ -5163,18 +5185,30 @@ impl TestServer {
}); });
let http = FakeHttpClient::with_404_response(); let http = FakeHttpClient::with_404_response();
let user_id = if let Ok(Some(user)) = self.app_state.db.get_user_by_github_login(name).await let user_id = if let Ok(Some(user)) = self
.app_state
.db
.get_user_by_github_account(name, None)
.await
{ {
user.id user.id
} else { } else {
self.app_state self.app_state
.db .db
.create_user(name, None, false) .create_user(
&format!("{name}@example.com"),
false,
NewUserParams {
github_login: name.into(),
github_user_id: 0,
invite_count: 0,
},
)
.await .await
.unwrap() .unwrap()
}; };
let client_name = name.to_string(); let client_name = name.to_string();
let mut client = Client::new(http.clone()); let mut client = cx.read(|cx| Client::new(http.clone(), cx));
let server = self.server.clone(); let server = self.server.clone();
let db = self.app_state.db.clone(); let db = self.app_state.db.clone();
let connection_killers = self.connection_killers.clone(); let connection_killers = self.connection_killers.clone();

View file

@ -4,6 +4,8 @@ mod db;
mod env; mod env;
mod rpc; mod rpc;
#[cfg(test)]
mod db_tests;
#[cfg(test)] #[cfg(test)]
mod integration_tests; mod integration_tests;

View file

@ -541,27 +541,30 @@ impl Server {
pub async fn invite_code_redeemed( pub async fn invite_code_redeemed(
self: &Arc<Self>, self: &Arc<Self>,
code: &str, inviter_id: UserId,
invitee_id: UserId, invitee_id: UserId,
) -> Result<()> { ) -> Result<()> {
let user = self.app_state.db.get_user_for_invite_code(code).await?; if let Some(user) = self.app_state.db.get_user_by_id(inviter_id).await? {
let store = self.store().await; if let Some(code) = &user.invite_code {
let invitee_contact = store.contact_for_user(invitee_id, true); let store = self.store().await;
for connection_id in store.connection_ids_for_user(user.id) { let invitee_contact = store.contact_for_user(invitee_id, true);
self.peer.send( for connection_id in store.connection_ids_for_user(inviter_id) {
connection_id, self.peer.send(
proto::UpdateContacts { connection_id,
contacts: vec![invitee_contact.clone()], proto::UpdateContacts {
..Default::default() contacts: vec![invitee_contact.clone()],
}, ..Default::default()
)?; },
self.peer.send( )?;
connection_id, self.peer.send(
proto::UpdateInviteInfo { connection_id,
url: format!("{}{}", self.app_state.invite_link_prefix, code), proto::UpdateInviteInfo {
count: user.invite_count as u32, url: format!("{}{}", self.app_state.invite_link_prefix, &code),
}, count: user.invite_count as u32,
)?; },
)?;
}
}
} }
Ok(()) Ok(())
} }
@ -1401,7 +1404,7 @@ impl Server {
let users = match query.len() { let users = match query.len() {
0 => vec![], 0 => vec![],
1 | 2 => db 1 | 2 => db
.get_user_by_github_login(&query) .get_user_by_github_account(&query, None)
.await? .await?
.into_iter() .into_iter()
.collect(), .collect(),

View file

@ -1216,7 +1216,7 @@ mod tests {
let languages = Arc::new(LanguageRegistry::test()); let languages = Arc::new(LanguageRegistry::test());
let http_client = FakeHttpClient::with_404_response(); let http_client = FakeHttpClient::with_404_response();
let client = Client::new(http_client.clone()); let client = cx.read(|cx| Client::new(http_client.clone(), cx));
let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http_client, cx)); let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http_client, cx));
let project_store = cx.add_model(|_| ProjectStore::new(project::Db::open_fake())); let project_store = cx.add_model(|_| ProjectStore::new(project::Db::open_fake()));
let server = FakeServer::for_client(current_user_id, &client, cx).await; let server = FakeServer::for_client(current_user_id, &client, cx).await;

22
crates/db/Cargo.toml Normal file
View file

@ -0,0 +1,22 @@
[package]
name = "db"
version = "0.1.0"
edition = "2021"
[lib]
path = "src/db.rs"
doctest = false
[features]
test-support = []
[dependencies]
collections = { path = "../collections" }
anyhow = "1.0.57"
async-trait = "0.1"
parking_lot = "0.11.1"
rocksdb = "0.18"
[dev-dependencies]
gpui = { path = "../gpui", features = ["test-support"] }
tempdir = { version = "0.3.7" }

View file

@ -30,6 +30,7 @@ use gpui::{
geometry::vector::{vec2f, Vector2F}, geometry::vector::{vec2f, Vector2F},
impl_actions, impl_internal_actions, impl_actions, impl_internal_actions,
platform::CursorStyle, platform::CursorStyle,
serde_json::json,
text_layout, AnyViewHandle, AppContext, AsyncAppContext, ClipboardItem, Element, ElementBox, text_layout, AnyViewHandle, AppContext, AsyncAppContext, ClipboardItem, Element, ElementBox,
Entity, ModelHandle, MouseButton, MutableAppContext, RenderContext, Subscription, Task, View, Entity, ModelHandle, MouseButton, MutableAppContext, RenderContext, Subscription, Task, View,
ViewContext, ViewHandle, WeakViewHandle, ViewContext, ViewHandle, WeakViewHandle,
@ -1058,6 +1059,7 @@ impl Editor {
let editor_created_event = EditorCreated(cx.handle()); let editor_created_event = EditorCreated(cx.handle());
cx.emit_global(editor_created_event); cx.emit_global(editor_created_event);
this.report_event("open editor", cx);
this this
} }
@ -5983,6 +5985,25 @@ impl Editor {
}) })
.collect() .collect()
} }
fn report_event(&self, name: &str, cx: &AppContext) {
if let Some((project, file)) = self.project.as_ref().zip(
self.buffer
.read(cx)
.as_singleton()
.and_then(|b| b.read(cx).file()),
) {
project.read(cx).client().report_event(
name,
json!({
"file_extension": file
.path()
.extension()
.and_then(|e| e.to_str())
}),
);
}
}
} }
impl EditorSnapshot { impl EditorSnapshot {

View file

@ -404,6 +404,8 @@ impl Item for Editor {
project: ModelHandle<Project>, project: ModelHandle<Project>,
cx: &mut ViewContext<Self>, cx: &mut ViewContext<Self>,
) -> Task<Result<()>> { ) -> Task<Result<()>> {
self.report_event("save editor", cx);
let buffer = self.buffer().clone(); let buffer = self.buffer().clone();
let buffers = buffer.read(cx).all_buffers(); let buffers = buffer.read(cx).all_buffers();
let mut timeout = cx.background().timer(FORMAT_TIMEOUT).fuse(); let mut timeout = cx.background().timer(FORMAT_TIMEOUT).fuse();

View file

@ -69,6 +69,8 @@ pub trait Platform: Send + Sync {
fn path_for_auxiliary_executable(&self, name: &str) -> Result<PathBuf>; fn path_for_auxiliary_executable(&self, name: &str) -> Result<PathBuf>;
fn app_path(&self) -> Result<PathBuf>; fn app_path(&self) -> Result<PathBuf>;
fn app_version(&self) -> Result<AppVersion>; fn app_version(&self) -> Result<AppVersion>;
fn os_name(&self) -> &'static str;
fn os_version(&self) -> Result<AppVersion>;
} }
pub(crate) trait ForegroundPlatform { pub(crate) trait ForegroundPlatform {

View file

@ -4,7 +4,7 @@ use super::{
use crate::{ use crate::{
executor, keymap, executor, keymap,
platform::{self, CursorStyle}, platform::{self, CursorStyle},
Action, ClipboardItem, Event, Menu, MenuItem, Action, AppVersion, ClipboardItem, Event, Menu, MenuItem,
}; };
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use block::ConcreteBlock; use block::ConcreteBlock;
@ -16,7 +16,8 @@ use cocoa::{
}, },
base::{id, nil, selector, YES}, base::{id, nil, selector, YES},
foundation::{ foundation::{
NSArray, NSAutoreleasePool, NSBundle, NSData, NSInteger, NSString, NSUInteger, NSURL, NSArray, NSAutoreleasePool, NSBundle, NSData, NSInteger, NSProcessInfo, NSString,
NSUInteger, NSURL,
}, },
}; };
use core_foundation::{ use core_foundation::{
@ -748,6 +749,22 @@ impl platform::Platform for MacPlatform {
} }
} }
} }
fn os_name(&self) -> &'static str {
"macOS"
}
fn os_version(&self) -> Result<crate::AppVersion> {
unsafe {
let process_info = NSProcessInfo::processInfo(nil);
let version = process_info.operatingSystemVersion();
Ok(AppVersion {
major: version.majorVersion as usize,
minor: version.minorVersion as usize,
patch: version.patchVersion as usize,
})
}
}
} }
unsafe fn path_from_objc(path: id) -> PathBuf { unsafe fn path_from_objc(path: id) -> PathBuf {

View file

@ -196,6 +196,18 @@ impl super::Platform for Platform {
patch: 0, patch: 0,
}) })
} }
fn os_name(&self) -> &'static str {
"test"
}
fn os_version(&self) -> Result<AppVersion> {
Ok(AppVersion {
major: 1,
minor: 0,
patch: 0,
})
}
} }
impl Window { impl Window {

View file

@ -10,6 +10,7 @@ doctest = false
[features] [features]
test-support = [ test-support = [
"client/test-support", "client/test-support",
"db/test-support",
"language/test-support", "language/test-support",
"settings/test-support", "settings/test-support",
"text/test-support", "text/test-support",
@ -20,6 +21,7 @@ text = { path = "../text" }
client = { path = "../client" } client = { path = "../client" }
clock = { path = "../clock" } clock = { path = "../clock" }
collections = { path = "../collections" } collections = { path = "../collections" }
db = { path = "../db" }
fsevent = { path = "../fsevent" } fsevent = { path = "../fsevent" }
fuzzy = { path = "../fuzzy" } fuzzy = { path = "../fuzzy" }
gpui = { path = "../gpui" } gpui = { path = "../gpui" }
@ -54,6 +56,7 @@ rocksdb = "0.18"
[dev-dependencies] [dev-dependencies]
client = { path = "../client", features = ["test-support"] } client = { path = "../client", features = ["test-support"] }
collections = { path = "../collections", features = ["test-support"] } collections = { path = "../collections", features = ["test-support"] }
db = { path = "../db", features = ["test-support"] }
gpui = { path = "../gpui", features = ["test-support"] } gpui = { path = "../gpui", features = ["test-support"] }
language = { path = "../language", features = ["test-support"] } language = { path = "../language", features = ["test-support"] }
lsp = { path = "../lsp", features = ["test-support"] } lsp = { path = "../lsp", features = ["test-support"] }

View file

@ -1,4 +1,3 @@
mod db;
pub mod fs; pub mod fs;
mod ignore; mod ignore;
mod lsp_command; mod lsp_command;
@ -666,7 +665,7 @@ impl Project {
let languages = Arc::new(LanguageRegistry::test()); let languages = Arc::new(LanguageRegistry::test());
let http_client = client::test::FakeHttpClient::with_404_response(); let http_client = client::test::FakeHttpClient::with_404_response();
let client = client::Client::new(http_client.clone()); let client = cx.update(|cx| client::Client::new(http_client.clone(), cx));
let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http_client, cx)); let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http_client, cx));
let project_store = cx.add_model(|_| ProjectStore::new(Db::open_fake())); let project_store = cx.add_model(|_| ProjectStore::new(Db::open_fake()));
let project = cx.update(|cx| { let project = cx.update(|cx| {

View file

@ -2804,7 +2804,7 @@ mod tests {
.await; .await;
let http_client = FakeHttpClient::with_404_response(); let http_client = FakeHttpClient::with_404_response();
let client = Client::new(http_client); let client = cx.read(|cx| Client::new(http_client, cx));
let tree = Worktree::local( let tree = Worktree::local(
client, client,
@ -2866,8 +2866,7 @@ mod tests {
fs.insert_symlink("/root/lib/a/lib", "..".into()).await; fs.insert_symlink("/root/lib/a/lib", "..".into()).await;
fs.insert_symlink("/root/lib/b/lib", "..".into()).await; fs.insert_symlink("/root/lib/b/lib", "..".into()).await;
let http_client = FakeHttpClient::with_404_response(); let client = cx.read(|cx| Client::new(FakeHttpClient::with_404_response(), cx));
let client = Client::new(http_client);
let tree = Worktree::local( let tree = Worktree::local(
client, client,
Arc::from(Path::new("/root")), Arc::from(Path::new("/root")),
@ -2945,8 +2944,7 @@ mod tests {
})); }));
let dir = parent_dir.path().join("tree"); let dir = parent_dir.path().join("tree");
let http_client = FakeHttpClient::with_404_response(); let client = cx.read(|cx| Client::new(FakeHttpClient::with_404_response(), cx));
let client = Client::new(http_client.clone());
let tree = Worktree::local( let tree = Worktree::local(
client, client,
@ -3016,8 +3014,7 @@ mod tests {
"ignored-dir": {} "ignored-dir": {}
})); }));
let http_client = FakeHttpClient::with_404_response(); let client = cx.read(|cx| Client::new(FakeHttpClient::with_404_response(), cx));
let client = Client::new(http_client.clone());
let tree = Worktree::local( let tree = Worktree::local(
client, client,
@ -3064,8 +3061,7 @@ mod tests {
#[gpui::test(iterations = 30)] #[gpui::test(iterations = 30)]
async fn test_create_directory(cx: &mut TestAppContext) { async fn test_create_directory(cx: &mut TestAppContext) {
let http_client = FakeHttpClient::with_404_response(); let client = cx.read(|cx| Client::new(FakeHttpClient::with_404_response(), cx));
let client = Client::new(http_client.clone());
let fs = FakeFs::new(cx.background()); let fs = FakeFs::new(cx.background());
fs.insert_tree( fs.insert_tree(

View file

@ -856,7 +856,7 @@ impl AppState {
let fs = project::FakeFs::new(cx.background().clone()); let fs = project::FakeFs::new(cx.background().clone());
let languages = Arc::new(LanguageRegistry::test()); let languages = Arc::new(LanguageRegistry::test());
let http_client = client::test::FakeHttpClient::with_404_response(); let http_client = client::test::FakeHttpClient::with_404_response();
let client = Client::new(http_client.clone()); let client = Client::new(http_client.clone(), cx);
let project_store = cx.add_model(|_| ProjectStore::new(project::Db::open_fake())); let project_store = cx.add_model(|_| ProjectStore::new(project::Db::open_fake()));
let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http_client, cx)); let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http_client, cx));
let themes = ThemeRegistry::new((), cx.font_cache().clone()); let themes = ThemeRegistry::new((), cx.font_cache().clone());

View file

@ -3,6 +3,10 @@ use std::process::Command;
fn main() { fn main() {
println!("cargo:rustc-env=MACOSX_DEPLOYMENT_TARGET=10.14"); println!("cargo:rustc-env=MACOSX_DEPLOYMENT_TARGET=10.14");
if let Ok(api_key) = std::env::var("ZED_AMPLITUDE_API_KEY") {
println!("cargo:rustc-env=ZED_AMPLITUDE_API_KEY={api_key}");
}
let output = Command::new("npm") let output = Command::new("npm")
.current_dir("../../styles") .current_dir("../../styles")
.args(["install", "--no-save"]) .args(["install", "--no-save"])

View file

@ -20,7 +20,7 @@ use futures::{
FutureExt, SinkExt, StreamExt, FutureExt, SinkExt, StreamExt,
}; };
use gpui::{executor::Background, App, AssetSource, AsyncAppContext, Task, ViewContext}; use gpui::{executor::Background, App, AssetSource, AsyncAppContext, Task, ViewContext};
use isahc::{config::Configurable, AsyncBody, Request}; use isahc::{config::Configurable, Request};
use language::LanguageRegistry; use language::LanguageRegistry;
use log::LevelFilter; use log::LevelFilter;
use parking_lot::Mutex; use parking_lot::Mutex;
@ -88,7 +88,7 @@ fn main() {
}); });
app.run(move |cx| { app.run(move |cx| {
let client = client::Client::new(http.clone()); let client = client::Client::new(http.clone(), cx);
let mut languages = LanguageRegistry::new(login_shell_env_loaded); let mut languages = LanguageRegistry::new(login_shell_env_loaded);
languages.set_language_server_download_dir(zed::paths::LANGUAGES_DIR.clone()); languages.set_language_server_download_dir(zed::paths::LANGUAGES_DIR.clone());
let languages = Arc::new(languages); let languages = Arc::new(languages);
@ -121,7 +121,6 @@ fn main() {
vim::init(cx); vim::init(cx);
terminal::init(cx); terminal::init(cx);
let db = cx.background().block(db);
cx.spawn(|cx| watch_themes(fs.clone(), themes.clone(), cx)) cx.spawn(|cx| watch_themes(fs.clone(), themes.clone(), cx))
.detach(); .detach();
@ -139,6 +138,10 @@ fn main() {
}) })
.detach(); .detach();
let db = cx.background().block(db);
client.start_telemetry(db.clone());
client.report_event("start app", Default::default());
let project_store = cx.add_model(|_| ProjectStore::new(db.clone())); let project_store = cx.add_model(|_| ProjectStore::new(db.clone()));
let app_state = Arc::new(AppState { let app_state = Arc::new(AppState {
languages, languages,
@ -280,12 +283,10 @@ fn init_panic_hook(app_version: String, http: Arc<dyn HttpClient>, background: A
"token": ZED_SECRET_CLIENT_TOKEN, "token": ZED_SECRET_CLIENT_TOKEN,
})) }))
.unwrap(); .unwrap();
let request = Request::builder() let request = Request::post(&panic_report_url)
.uri(&panic_report_url)
.method(http::Method::POST)
.redirect_policy(isahc::config::RedirectPolicy::Follow) .redirect_policy(isahc::config::RedirectPolicy::Follow)
.header("Content-Type", "application/json") .header("Content-Type", "application/json")
.body(AsyncBody::from(body))?; .body(body.into())?;
let response = http.send(request).await.context("error sending panic")?; let response = http.send(request).await.context("error sending panic")?;
if response.status().is_success() { if response.status().is_success() {
fs::remove_file(child_path) fs::remove_file(child_path)

View file

@ -332,6 +332,11 @@ pub fn menus() -> Vec<Menu<'static>> {
action: Box::new(command_palette::Toggle), action: Box::new(command_palette::Toggle),
}, },
MenuItem::Separator, MenuItem::Separator,
MenuItem::Action {
name: "View Telemetry Log",
action: Box::new(crate::OpenTelemetryLog),
},
MenuItem::Separator,
MenuItem::Action { MenuItem::Action {
name: "Documentation", name: "Documentation",
action: Box::new(crate::OpenBrowser { action: Box::new(crate::OpenBrowser {

View file

@ -56,6 +56,7 @@ actions!(
DebugElements, DebugElements,
OpenSettings, OpenSettings,
OpenLog, OpenLog,
OpenTelemetryLog,
OpenKeymap, OpenKeymap,
OpenDefaultSettings, OpenDefaultSettings,
OpenDefaultKeymap, OpenDefaultKeymap,
@ -146,6 +147,12 @@ pub fn init(app_state: &Arc<AppState>, cx: &mut gpui::MutableAppContext) {
open_log_file(workspace, app_state.clone(), cx); open_log_file(workspace, app_state.clone(), cx);
} }
}); });
cx.add_action({
let app_state = app_state.clone();
move |workspace: &mut Workspace, _: &OpenTelemetryLog, cx: &mut ViewContext<Workspace>| {
open_telemetry_log_file(workspace, app_state.clone(), cx);
}
});
cx.add_action({ cx.add_action({
let app_state = app_state.clone(); let app_state = app_state.clone();
move |_: &mut Workspace, _: &OpenKeymap, cx: &mut ViewContext<Workspace>| { move |_: &mut Workspace, _: &OpenKeymap, cx: &mut ViewContext<Workspace>| {
@ -504,6 +511,62 @@ fn open_log_file(
}); });
} }
fn open_telemetry_log_file(
workspace: &mut Workspace,
app_state: Arc<AppState>,
cx: &mut ViewContext<Workspace>,
) {
workspace.with_local_workspace(cx, app_state.clone(), |_, cx| {
cx.spawn_weak(|workspace, mut cx| async move {
let workspace = workspace.upgrade(&cx)?;
let path = app_state.client.telemetry_log_file_path()?;
let log = app_state.fs.load(&path).await.log_err()?;
const MAX_TELEMETRY_LOG_LEN: usize = 5 * 1024 * 1024;
let mut start_offset = log.len().saturating_sub(MAX_TELEMETRY_LOG_LEN);
if let Some(newline_offset) = log[start_offset..].find('\n') {
start_offset += newline_offset + 1;
}
let log_suffix = &log[start_offset..];
workspace.update(&mut cx, |workspace, cx| {
let project = workspace.project().clone();
let buffer = project
.update(cx, |project, cx| project.create_buffer("", None, cx))
.expect("creating buffers on a local workspace always succeeds");
buffer.update(cx, |buffer, cx| {
buffer.set_language(app_state.languages.get_language("JSON"), cx);
buffer.edit(
[(
0..0,
concat!(
"// Zed collects anonymous usage data to help us understand how people are using the app.\n",
"// After the beta release, we'll provide the ability to opt out of this telemetry.\n",
"// Here is the data that has been reported for the current session:\n",
"\n"
),
)],
None,
cx,
);
buffer.edit([(buffer.len()..buffer.len(), log_suffix)], None, cx);
});
let buffer = cx.add_model(|cx| {
MultiBuffer::singleton(buffer, cx).with_title("Telemetry Log".into())
});
workspace.add_item(
Box::new(cx.add_view(|cx| Editor::for_multibuffer(buffer, Some(project), cx))),
cx,
);
});
Some(())
})
.detach();
});
}
fn open_bundled_config_file( fn open_bundled_config_file(
workspace: &mut Workspace, workspace: &mut Workspace,
app_state: Arc<AppState>, app_state: Arc<AppState>,