Replace the default workspace with a home screen (#660)

This commit is contained in:
Gregory Schier
2026-09-15 13:33:57 -07:00
committed by GitHub
parent 8a6e4810fd
commit 06142d007a
19 changed files with 560 additions and 40 deletions
File diff suppressed because one or more lines are too long
+5
View File
@@ -366,6 +366,10 @@ pub struct CmdCurlToRequestReq {
pub workspace_id: String,
}
#[derive(Debug, Deserialize, TS)]
#[ts(export, export_to = "gen_rpc.ts")]
pub struct CmdCreateExampleWorkspaceReq {}
#[derive(Debug, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_rpc.ts")]
@@ -957,6 +961,7 @@ macro_rules! with_commands {
cmd_call_http_authentication_action(CmdCallHttpAuthenticationActionReq) -> (),
cmd_curl_to_request(CmdCurlToRequestReq) -> HttpRequest,
cmd_export_data(CmdExportDataReq) -> (),
cmd_create_example_workspace(CmdCreateExampleWorkspaceReq) -> BatchUpsertResult,
cmd_save_base64_to_binary(CmdSaveBase64ToBinaryReq) -> (),
cmd_save_response(CmdSaveResponseReq) -> (),
cmd_send_http_request(CmdSendHttpRequestReq) -> HttpResponse,
+9
View File
@@ -3,7 +3,9 @@
use crate::error::Result;
use crate::host::Host;
use std::path::Path;
use yaak::example::create_example_workspace;
use yaak::export::{self, ExportDataParams};
use yaak_models::util::BatchUpsertResult;
use yaak_rpc_schema::*;
use yaak_templates::format_json::format_json;
@@ -18,6 +20,13 @@ pub async fn cmd_export_data<H: Host>(host: H, req: CmdExportDataReq) -> Result<
})?)
}
pub async fn cmd_create_example_workspace<H: Host>(
host: H,
_req: CmdCreateExampleWorkspaceReq,
) -> Result<BatchUpsertResult> {
Ok(create_example_workspace(host.query_manager(), &host.update_source())?)
}
pub async fn cmd_format_json<H: Host>(_host: H, req: CmdFormatJsonReq) -> Result<String> {
Ok(format_json(&req.text, " "))
}
+2 -4
View File
@@ -6,7 +6,6 @@ use sea_query::{IntoColumnRef, IntoIden, SimpleExpr};
use std::cell::RefCell;
use std::fmt::Debug;
use std::ops::Deref;
use std::sync::mpsc;
use yaak_database::DbContext;
/// A read handle. Comes from the reader pool and can only query.
@@ -80,7 +79,6 @@ impl<'a> ClientDb<'a> {
/// discards them along with the rows.
pub struct WriteDb<'a> {
db: ClientDb<'a>,
events_tx: mpsc::Sender<ModelPayload>,
pending_events: RefCell<Vec<ModelPayload>>,
}
@@ -93,8 +91,8 @@ impl<'a> Deref for WriteDb<'a> {
}
impl<'a> WriteDb<'a> {
pub fn new(ctx: DbContext<'a>, events_tx: mpsc::Sender<ModelPayload>) -> Self {
Self { db: ClientDb::new(ctx), events_tx, pending_events: RefCell::new(Vec::new()) }
pub fn new(ctx: DbContext<'a>) -> Self {
Self { db: ClientDb::new(ctx), pending_events: RefCell::new(Vec::new()) }
}
/// The events for everything written so far, to send once the
+2 -2
View File
@@ -171,11 +171,11 @@ pub fn init_in_memory() -> Result<(QueryManager, BlobManager, mpsc::Receiver<Mod
Ok((query_manager, blob_manager, rx))
}
/// The rows every client assumes exist: settings and at least one workspace.
/// The rows every client assumes exist. Workspaces are not among them: a fresh
/// install has none, and the client shows onboarding until the user makes one.
fn bootstrap(query_manager: &QueryManager) -> Result<()> {
query_manager.with_tx(|tx| {
tx.ensure_settings()?;
tx.ensure_default_workspace()?;
Ok(())
})
}
@@ -192,8 +192,10 @@ mod tests {
#[test]
fn request_resolution_preserves_duplicate_request_headers() {
let (query_manager, _blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
let workspace = query_manager
.with_tx(|tx| tx.upsert_workspace(&Workspace::default(), &UpdateSource::Background))
.expect("Failed to create workspace");
let db = query_manager.connect();
let workspace = db.list_workspaces().expect("Failed to list workspaces").remove(0);
let request = HttpRequest {
workspace_id: workspace.id,
headers: vec![
+21 -20
View File
@@ -79,18 +79,6 @@ impl<'a> ClientDb<'a> {
}
impl<'a> WriteDb<'a> {
/// There is always at least one workspace. Called at startup and after a
/// workspace is deleted.
pub fn ensure_default_workspace(&self) -> Result<()> {
if self.find_all::<Workspace>()?.is_empty() {
self.upsert_workspace(
&Workspace { name: "Yaak".to_string(), ..Default::default() },
&UpdateSource::Background,
)?;
}
Ok(())
}
/// Delete a workspace and everything in it.
///
/// Children are bulk-deleted with one statement per table and are NOT
@@ -138,7 +126,6 @@ impl<'a> WriteDb<'a> {
self.delete_many_untracked::<SyncState>(SyncStateIden::WorkspaceId, wid)?;
self.delete_many_untracked::<WorkspaceMeta>(WorkspaceMetaIden::WorkspaceId, wid)?;
let deleted = self.delete(workspace, source)?;
self.ensure_default_workspace()?;
// Best-effort cleanup of response bodies (disk files and blob chunks).
// Failures only orphan unreferenced data, and are logged.
@@ -193,18 +180,32 @@ pub fn default_headers() -> Vec<HttpRequestHeader> {
#[cfg(test)]
mod tests {
use crate::init_in_memory;
use crate::models::Workspace;
use crate::util::UpdateSource;
#[test]
fn bootstraps_first_workspace_with_real_defaults() {
fn fresh_install_has_no_workspaces() {
let (query_manager, _blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
let db = query_manager.connect();
let workspaces = query_manager.connect().list_workspaces().expect("Failed to list");
assert!(workspaces.is_empty());
}
let workspaces = db.list_workspaces().expect("Failed to list workspaces");
let workspace = workspaces.first().expect("No workspace was bootstrapped");
#[test]
fn default_workspace_carries_real_defaults() {
let (query_manager, _blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
let created = query_manager
.with_tx(|tx| {
tx.upsert_workspace(
&Workspace { name: "Yaak".to_string(), ..Default::default() },
&UpdateSource::Background,
)
})
.expect("Failed to create workspace");
let workspace = query_manager.connect().get_workspace(&created.id).expect("get");
// This workspace is built in Rust and never deserialized, so it only gets
// these values if `Workspace::default()` carries them. Asserted through the
// DB round trip, since the column values are what a fresh install lives with.
// A workspace built in Rust and never deserialized only gets these values
// if `Workspace::default()` carries them. Asserted through the DB round
// trip, since the column values are what the user lives with.
assert!(workspace.setting_send_cookies, "setting_send_cookies");
assert!(workspace.setting_store_cookies, "setting_store_cookies");
assert!(workspace.setting_follow_redirects, "setting_follow_redirects");
+1 -2
View File
@@ -62,8 +62,7 @@ impl QueryManager {
let tx = Transaction::new_unchecked(&conn, TransactionBehavior::Immediate)
.map_err(crate::error::Error::SqlError)?;
let db =
WriteDb::new(DbContext::new(ConnectionOrTx::Transaction(&tx)), self.events_tx.clone());
let db = WriteDb::new(DbContext::new(ConnectionOrTx::Transaction(&tx)));
match func(&db) {
Ok(val) => {
+2 -2
View File
@@ -247,8 +247,8 @@ fn dispatch(
source: &UpdateSource,
) -> Result<serde_json::Value> {
match cmd {
// The one big read. Same list, same order, and the same four lazy
// creates (settings, first workspace, cookie jar, base environment) as
// The one big read. Same list, same order, and the same lazy
// creates (settings, cookie jar, base environment) as
// `models_workspace_models` on the desktop — this call is where an
// empty database becomes a usable one. Returned as a JSON *string*
// because that is what the desktop returns and what the store parses.
+102
View File
@@ -0,0 +1,102 @@
//! The example workspace offered during onboarding.
//!
//! Authored as JSON with `{{PLACEHOLDER}}` ids so requests can reference each
//! other (the chaining example names another request by id). Every placeholder
//! gets a fresh id per creation, so the example can be created more than once.
use crate::Result;
use yaak_models::models::{Environment, Folder, HttpRequest, UpsertModelInfo, Workspace};
use yaak_models::query_manager::QueryManager;
use yaak_models::util::{BatchUpsertResult, UpdateSource};
const EXAMPLE_JSON: &str = include_str!("example_workspace.json");
const WORKSPACE_IDS: &[&str] = &["WORKSPACE"];
const ENVIRONMENT_IDS: &[&str] = &["ENV_BASE", "ENV_USER_2"];
const FOLDER_IDS: &[&str] = &[
"FOLDER_BASICS",
"FOLDER_VARIABLES",
"FOLDER_CHAINING",
"FOLDER_AUTH",
];
const REQUEST_IDS: &[&str] = &[
"RQ_LIST_POSTS",
"RQ_GET_POST",
"RQ_CREATE_POST",
"RQ_CURRENT_USER",
"RQ_LIST_USERS",
"RQ_POSTS_BY_FIRST_USER",
"RQ_TODOS",
];
pub fn create_example_workspace(
query_manager: &QueryManager,
source: &UpdateSource,
) -> Result<BatchUpsertResult> {
let resources = example_resources()?;
Ok(query_manager.with_tx(|tx| {
tx.batch_upsert(
resources.workspaces,
resources.environments,
resources.folders,
resources.http_requests,
resources.grpc_requests,
resources.websocket_requests,
source,
)
})?)
}
fn example_resources() -> Result<BatchUpsertResult> {
let mut json = EXAMPLE_JSON.to_string();
let placeholders = WORKSPACE_IDS
.iter()
.map(|p| (*p, Workspace::generate_id()))
.chain(ENVIRONMENT_IDS.iter().map(|p| (*p, Environment::generate_id())))
.chain(FOLDER_IDS.iter().map(|p| (*p, Folder::generate_id())))
.chain(REQUEST_IDS.iter().map(|p| (*p, HttpRequest::generate_id())));
for (placeholder, id) in placeholders {
json = json.replace(&format!("{{{{{placeholder}}}}}"), &id);
}
debug_assert!(!json.contains("{{"), "example workspace has an unmapped placeholder");
Ok(serde_json::from_str(&json)?)
}
#[cfg(test)]
mod tests {
use super::*;
use yaak_models::init_in_memory;
#[test]
fn every_placeholder_is_mapped() {
let json = serde_json::to_string(&example_resources().unwrap()).unwrap();
assert!(!json.contains("{{"), "unmapped placeholder in {json}");
}
#[test]
fn chained_request_references_a_created_request() {
let (query_manager, _blobs, _rx) = init_in_memory().unwrap();
let created = create_example_workspace(&query_manager, &UpdateSource::Background).unwrap();
assert_eq!(created.workspaces.len(), 1);
let workspace_id = &created.workspaces[0].id;
let db = query_manager.connect();
let requests = db.list_http_requests(workspace_id).unwrap();
assert_eq!(requests.len(), REQUEST_IDS.len());
assert_eq!(db.list_folders(workspace_id).unwrap().len(), FOLDER_IDS.len());
assert_eq!(db.list_environments(workspace_id).unwrap().len(), ENVIRONMENT_IDS.len());
let list_users = requests.iter().find(|r| r.name == "List users").unwrap();
let chained = requests.iter().find(|r| r.name == "Posts by the first user").unwrap();
let param = &chained.url_parameters[0].value;
assert!(param.contains(&format!("request='{}'", list_users.id)), "{param}");
}
#[test]
fn creating_twice_makes_two_workspaces() {
let (query_manager, _blobs, _rx) = init_in_memory().unwrap();
create_example_workspace(&query_manager, &UpdateSource::Background).unwrap();
create_example_workspace(&query_manager, &UpdateSource::Background).unwrap();
assert_eq!(query_manager.connect().list_workspaces().unwrap().len(), 2);
}
}
+149
View File
@@ -0,0 +1,149 @@
{
"workspaces": [
{
"id": "{{WORKSPACE}}",
"name": "Example Workspace",
"description": "A small tour of Yaak using the free [JSONPlaceholder](https://jsonplaceholder.typicode.com) API. Nothing here needs an account.\n\n1. Open **Getting Started / List posts** and press Send\n2. Open **Variables / Get the current user**, then switch the environment in the top-left to change which user it fetches\n3. Open **Chaining / Posts by the first user** to see how one request reads a value out of another request's response\n\nDelete this workspace whenever you're done with it."
}
],
"environments": [
{
"id": "{{ENV_BASE}}",
"workspaceId": "{{WORKSPACE}}",
"name": "Global Variables",
"parentModel": "workspace",
"public": true,
"variables": [
{ "name": "base_url", "value": "https://jsonplaceholder.typicode.com", "enabled": true },
{ "name": "user_id", "value": "1", "enabled": true },
{ "name": "api_token", "value": "example-token", "enabled": true }
]
},
{
"id": "{{ENV_USER_2}}",
"workspaceId": "{{WORKSPACE}}",
"name": "Another User",
"parentModel": "environment",
"public": true,
"color": "#c084fc",
"variables": [{ "name": "user_id", "value": "2", "enabled": true }]
}
],
"folders": [
{
"id": "{{FOLDER_BASICS}}",
"workspaceId": "{{WORKSPACE}}",
"name": "Getting Started",
"sortPriority": 1,
"description": "Plain requests. Send one, then look through the response tabs: Body, Headers, Cookies, and Timeline."
},
{
"id": "{{FOLDER_VARIABLES}}",
"workspaceId": "{{WORKSPACE}}",
"name": "Variables",
"sortPriority": 2,
"description": "Anything wrapped in `${[ ... ]}` is a template. Variables come from the environment selected in the top-left, and sub-environments override the base values."
},
{
"id": "{{FOLDER_CHAINING}}",
"workspaceId": "{{WORKSPACE}}",
"name": "Chaining",
"sortPriority": 3,
"description": "The `response()` template function reads a value out of another request's response, sending that request first if it has to. Put the cursor in a URL and press Ctrl+Space to insert one."
},
{
"id": "{{FOLDER_AUTH}}",
"workspaceId": "{{WORKSPACE}}",
"name": "Authentication",
"sortPriority": 4,
"description": "This folder sets Bearer auth from the `api_token` variable. Every request inside inherits it, so the token lives in one place. JSONPlaceholder ignores the header, so these still succeed.",
"authenticationType": "bearer",
"authentication": { "token": "${[ api_token ]}" }
}
],
"httpRequests": [
{
"id": "{{RQ_LIST_POSTS}}",
"workspaceId": "{{WORKSPACE}}",
"folderId": "{{FOLDER_BASICS}}",
"name": "List posts",
"method": "GET",
"url": "${[ base_url ]}/posts",
"sortPriority": 1,
"urlParameters": [{ "name": "_limit", "value": "5", "enabled": true }],
"description": "Query parameters live in the Params tab. Try raising `_limit`."
},
{
"id": "{{RQ_GET_POST}}",
"workspaceId": "{{WORKSPACE}}",
"folderId": "{{FOLDER_BASICS}}",
"name": "Get a post",
"method": "GET",
"url": "${[ base_url ]}/posts/1",
"sortPriority": 2,
"description": "Change the ID at the end of the URL and send again."
},
{
"id": "{{RQ_CREATE_POST}}",
"workspaceId": "{{WORKSPACE}}",
"folderId": "{{FOLDER_BASICS}}",
"name": "Create a post",
"method": "POST",
"url": "${[ base_url ]}/posts",
"sortPriority": 3,
"bodyType": "application/json",
"body": {
"text": "{\n \"title\": \"Hello from Yaak\",\n \"body\": \"Templates work inside bodies too\",\n \"userId\": ${[ user_id ]}\n}"
},
"headers": [{ "name": "Content-Type", "value": "application/json", "enabled": true }],
"description": "JSONPlaceholder pretends to create the post and echoes it back with a new ID."
},
{
"id": "{{RQ_CURRENT_USER}}",
"workspaceId": "{{WORKSPACE}}",
"folderId": "{{FOLDER_VARIABLES}}",
"name": "Get the current user",
"method": "GET",
"url": "${[ base_url ]}/users/${[ user_id ]}",
"sortPriority": 1,
"description": "Switch to the **Another User** environment in the top-left and send again. Hover a template in the URL to see what it resolves to."
},
{
"id": "{{RQ_LIST_USERS}}",
"workspaceId": "{{WORKSPACE}}",
"folderId": "{{FOLDER_CHAINING}}",
"name": "List users",
"method": "GET",
"url": "${[ base_url ]}/users",
"sortPriority": 1
},
{
"id": "{{RQ_POSTS_BY_FIRST_USER}}",
"workspaceId": "{{WORKSPACE}}",
"folderId": "{{FOLDER_CHAINING}}",
"name": "Posts by the first user",
"method": "GET",
"url": "${[ base_url ]}/posts",
"sortPriority": 2,
"urlParameters": [
{
"name": "userId",
"value": "${[ response.body.path(request='{{RQ_LIST_USERS}}', path='$[0].id') ]}",
"enabled": true
}
],
"description": "The `userId` parameter is pulled from the **List users** response with the JSONPath `$[0].id`. If that request hasn't been sent yet, Yaak sends it first."
},
{
"id": "{{RQ_TODOS}}",
"workspaceId": "{{WORKSPACE}}",
"folderId": "{{FOLDER_AUTH}}",
"name": "List the user's todos",
"method": "GET",
"url": "${[ base_url ]}/users/${[ user_id ]}/todos",
"sortPriority": 1,
"urlParameters": [{ "name": "_limit", "value": "5", "enabled": true }],
"description": "Open the Auth tab: it's set to inherit from the folder. Check the Timeline tab after sending to see the Authorization header that was sent."
}
]
}
+1
View File
@@ -1,4 +1,5 @@
pub mod error;
pub mod example;
pub mod export;
pub mod import;
pub mod plugin_events;