diff --git a/src-tauri/src/plugin_events.rs b/src-tauri/src/plugin_events.rs index 9a0bc8c3..ef3de607 100644 --- a/src-tauri/src/plugin_events.rs +++ b/src-tauri/src/plugin_events.rs @@ -8,7 +8,7 @@ use crate::{ }; use chrono::Utc; use cookie::Cookie; -use log::{debug, error}; +use log::error; use tauri::{AppHandle, Emitter, Manager, Runtime}; use tauri_plugin_clipboard_manager::ClipboardExt; use yaak_common::window::WorkspaceWindowTrait; diff --git a/src-tauri/yaak-models/bindings/gen_models.ts b/src-tauri/yaak-models/bindings/gen_models.ts index 6a616e8e..870484c1 100644 --- a/src-tauri/yaak-models/bindings/gen_models.ts +++ b/src-tauri/yaak-models/bindings/gen_models.ts @@ -46,7 +46,7 @@ export type HttpUrlParameter = { enabled?: boolean, name: string, value: string, export type KeyValue = { model: "key_value", id: string, createdAt: string, updatedAt: string, key: string, namespace: string, value: string, }; -export type ModelChangeEvent = { "type": "upsert" } | { "type": "delete" }; +export type ModelChangeEvent = { "type": "upsert", created: boolean, } | { "type": "delete" }; export type ModelPayload = { model: AnyModel, updateSource: UpdateSource, change: ModelChangeEvent, }; diff --git a/src-tauri/yaak-models/guest-js/store.ts b/src-tauri/yaak-models/guest-js/store.ts index 72215f37..b027d181 100644 --- a/src-tauri/yaak-models/guest-js/store.ts +++ b/src-tauri/yaak-models/guest-js/store.ts @@ -12,31 +12,23 @@ export function initModelStore(store: JotaiStore) { _store = store; getCurrentWebviewWindow() - .listen('upserted_model', ({ payload }) => { + .listen('model_write', ({ payload }) => { if (shouldIgnoreModel(payload)) return; mustStore().set(modelStoreDataAtom, (prev: ModelStoreData) => { - return { - ...prev, - [payload.model.model]: { - ...prev[payload.model.model], - [payload.model.id]: payload.model, - }, - }; - }); - }) - .catch(console.error); - - getCurrentWebviewWindow() - .listen('deleted_model', ({ payload }) => { - if (shouldIgnoreModel(payload)) return; - - console.log('Delete model', payload); - - mustStore().set(modelStoreDataAtom, (prev: ModelStoreData) => { - const modelData = { ...prev[payload.model.model] }; - delete modelData[payload.model.id]; - return { ...prev, [payload.model.model]: modelData }; + if (payload.change.type === 'upsert') { + return { + ...prev, + [payload.model.model]: { + ...prev[payload.model.model], + [payload.model.id]: payload.model, + }, + }; + } else { + const modelData = { ...prev[payload.model.model] }; + delete modelData[payload.model.id]; + return { ...prev, [payload.model.model]: modelData }; + } }); }) .catch(console.error); diff --git a/src-tauri/yaak-models/src/db_context.rs b/src-tauri/yaak-models/src/db_context.rs index 1bf76ac1..4b7d2970 100644 --- a/src-tauri/yaak-models/src/db_context.rs +++ b/src-tauri/yaak-models/src/db_context.rs @@ -3,11 +3,11 @@ use crate::error::Error::ModelNotFound; use crate::error::Result; use crate::models::{AnyModel, UpsertModelInfo}; use crate::util::{ModelChangeEvent, ModelPayload, UpdateSource}; -use log::error; +use log::{error, warn}; use rusqlite::OptionalExtension; use sea_query::{ - Asterisk, Expr, IntoColumnRef, IntoIden, IntoTableRef, OnConflict, Query, SimpleExpr, - SqliteQueryBuilder, + Alias, Asterisk, Expr, Func, IntoColumnRef, IntoIden, IntoTableRef, OnConflict, Query, + ReturningClause, SimpleExpr, SqliteQueryBuilder, }; use sea_query_rusqlite::RusqliteBinder; use std::fmt::Debug; @@ -152,21 +152,32 @@ impl<'a> DbContext<'a> { } let on_conflict = OnConflict::column(id_iden).update_columns(update_columns).to_owned(); + let (sql, params) = Query::insert() .into_table(table) .columns(column_vec) .values_panic(value_vec) .on_conflict(on_conflict) - .returning_all() + .returning(Query::returning().exprs(vec![ + Expr::col(Asterisk), + Expr::expr(Func::cust("last_insert_rowid")), + Expr::col("rowid"), + ])) .build_rusqlite(SqliteQueryBuilder); let mut stmt = self.conn.resolve().prepare(sql.as_str())?; - let m: M = stmt.query_row(&*params.as_params(), |row| M::from_row(row))?; + let (m, created): (M, bool) = stmt.query_row(&*params.as_params(), |row| { + M::from_row(row).and_then(|m| { + let rowid: i64 = row.get("rowid")?; + let last_rowid: i64 = row.get("last_insert_rowid()")?; + Ok((m, rowid == last_rowid)) + }) + })?; let payload = ModelPayload { model: m.clone().into(), update_source: source.clone(), - change: ModelChangeEvent::Upsert, + change: ModelChangeEvent::Upsert { created }, }; if let Err(e) = self.events_tx.send(payload.clone()) { diff --git a/src-tauri/yaak-models/src/lib.rs b/src-tauri/yaak-models/src/lib.rs index 6d8044a4..ecd0d970 100644 --- a/src-tauri/yaak-models/src/lib.rs +++ b/src-tauri/yaak-models/src/lib.rs @@ -77,11 +77,7 @@ pub fn init() -> TauriPlugin { let app_handle = app_handle.clone(); tauri::async_runtime::spawn(async move { for p in rx { - let name = match p.change { - ModelChangeEvent::Upsert => "upserted_model", - ModelChangeEvent::Delete => "deleted_model", - }; - app_handle.emit(name, p).unwrap(); + app_handle.emit("model_write", p).unwrap(); } }); } diff --git a/src-tauri/yaak-models/src/util.rs b/src-tauri/yaak-models/src/util.rs index e2cb4c25..44b72fcc 100644 --- a/src-tauri/yaak-models/src/util.rs +++ b/src-tauri/yaak-models/src/util.rs @@ -3,7 +3,6 @@ use crate::models::{ AnyModel, Environment, Folder, GrpcRequest, HttpRequest, UpsertModelInfo, WebsocketRequest, Workspace, WorkspaceIden, }; -use yaak_common::window::WorkspaceWindowTrait; use crate::query_manager::QueryManagerExt; use chrono::{NaiveDateTime, Utc}; use log::warn; @@ -12,6 +11,7 @@ use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use tauri::{AppHandle, Listener, Runtime, WebviewWindow}; use ts_rs::TS; +use yaak_common::window::WorkspaceWindowTrait; pub fn generate_prefixed_id(prefix: &str) -> String { format!("{prefix}_{}", generate_id()) @@ -45,7 +45,7 @@ pub struct ModelPayload { #[serde(rename_all = "snake_case", tag = "type")] #[ts(export, export_to = "gen_models.ts")] pub enum ModelChangeEvent { - Upsert, + Upsert { created: bool }, Delete, } diff --git a/src-web/commands/commands.tsx b/src-web/commands/commands.tsx index ca6b2bc6..16c316c7 100644 --- a/src-web/commands/commands.tsx +++ b/src-web/commands/commands.tsx @@ -13,7 +13,7 @@ import { showPrompt } from '../lib/prompt'; import { resolvedModelNameWithFolders } from '../lib/resolvedModelName'; export const createFolder = createFastMutation< - void, + string | null, void, Partial> >({ @@ -34,13 +34,14 @@ export const createFolder = createFastMutation< confirmText: 'Create', placeholder: 'Name', }); - if (name == null) return; + if (name == null) return null; patch.name = name; } patch.sortPriority = patch.sortPriority || -Date.now(); - await createWorkspaceModel({ model: 'folder', workspaceId, ...patch }); + const id = await createWorkspaceModel({ model: 'folder', workspaceId, ...patch }); + return id; }, }); diff --git a/src-web/components/Sidebar.tsx b/src-web/components/Sidebar.tsx index a17036a0..e2392334 100644 --- a/src-web/components/Sidebar.tsx +++ b/src-web/components/Sidebar.tsx @@ -2,9 +2,11 @@ import type { Extension } from '@codemirror/state'; import { Compartment } from '@codemirror/state'; import { debounce } from '@yaakapp-internal/lib'; import type { + AnyModel, Folder, GrpcRequest, HttpRequest, + ModelPayload, WebsocketRequest, Workspace, } from '@yaakapp-internal/models'; @@ -34,6 +36,7 @@ import { getCreateDropdownItems } from '../hooks/useCreateDropdownItems'; import { getGrpcRequestActions } from '../hooks/useGrpcRequestActions'; import { useHotKey } from '../hooks/useHotKey'; import { getHttpRequestActions } from '../hooks/useHttpRequestActions'; +import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent'; import { sendAnyHttpRequest } from '../hooks/useSendAnyHttpRequest'; import { useSidebarHidden } from '../hooks/useSidebarHidden'; import { deepEqualAtom } from '../lib/atoms'; @@ -64,6 +67,15 @@ import type { TreeItemProps } from './core/tree/TreeItem'; import { GitDropdown } from './GitDropdown'; type SidebarModel = Workspace | Folder | HttpRequest | GrpcRequest | WebsocketRequest; +function isSidebarLeafModel(m: AnyModel): boolean { + const modelMap: Record, null> = { + http_request: null, + grpc_request: null, + websocket_request: null, + folder: null, + }; + return m.model in modelMap; +} const OPACITY_SUBTLE = 'opacity-80'; @@ -91,6 +103,13 @@ function Sidebar({ className }: { className?: string }) { if (!didFocus) filterRef.current?.focus(); }, []); + // Focus any new sidebar models when created + useListenToTauriEvent('model_write', ({ payload }) => { + if (!isSidebarLeafModel(payload.model)) return; + if (!(payload.change.type === 'upsert' && payload.change.created)) return; + treeRef.current?.selectItem(payload.model.id, true); + }); + useHotKey( 'sidebar.filter', () => { diff --git a/src-web/components/core/tree/Tree.tsx b/src-web/components/core/tree/Tree.tsx index 5ae2826c..e70636b7 100644 --- a/src-web/components/core/tree/Tree.tsx +++ b/src-web/components/core/tree/Tree.tsx @@ -11,16 +11,7 @@ import { import { type } from '@tauri-apps/plugin-os'; import classNames from 'classnames'; import type { ComponentType, MouseEvent, ReactElement, Ref, RefAttributes } from 'react'; -import { - forwardRef, - memo, - useCallback, - useEffect, - useImperativeHandle, - useMemo, - useRef, - useState, -} from 'react'; +import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, } from 'react'; import { useKey, useKeyPressEvent } from 'react-use'; import type { HotkeyAction, HotKeyOptions } from '../../../hooks/useHotKey'; import { useHotKey } from '../../../hooks/useHotKey'; @@ -72,7 +63,7 @@ export interface TreeHandle { treeId: string; focus: () => boolean; hasFocus: () => boolean; - selectItem: (id: string) => void; + selectItem: (id: string, focus?: boolean) => void; renameItem: (id: string) => void; showContextMenu: () => void; } @@ -181,11 +172,16 @@ function TreeInner( requestAnimationFrame(ensureTabbableItem); }); + const hasFocus = useCallback(() => { + return treeRef.current?.contains(document.activeElement) ?? false; + }, []); + const setSelected = useCallback( - function setSelected(ids: string[], focus: boolean) { + (ids: string[], focus: boolean) => { jotaiStore.set(selectedIdsFamily(treeId), ids); // TODO: Figure out a better way than timeout - if (focus) setTimeout(tryFocus, 50); + if (!focus) return; + setTimeout(tryFocus, 50); }, [treeId, tryFocus], ); @@ -194,15 +190,15 @@ function TreeInner( () => ({ treeId, focus: tryFocus, - hasFocus: () => treeRef.current?.contains(document.activeElement) ?? false, + hasFocus: hasFocus, renameItem: (id) => treeItemRefs.current[id]?.rename(), - selectItem: (id) => { + selectItem: (id, focus) => { if (jotaiStore.get(selectedIdsFamily(treeId)).includes(id)) { // Already selected return; } - setSelected([id], false); jotaiStore.set(focusIdsFamily(treeId), { anchorId: id, lastId: id }); + setSelected([id], focus === true); }, showContextMenu: async () => { if (getContextMenu == null) return; @@ -214,7 +210,7 @@ function TreeInner( setShowContextMenu({ items: menuItems, x: rect.x, y: rect.y }); }, }), - [getContextMenu, selectableItems, setSelected, treeId, tryFocus], + [getContextMenu, hasFocus, selectableItems, setSelected, treeId, tryFocus], ); useImperativeHandle(ref, (): TreeHandle => treeHandle, [treeHandle]); @@ -248,7 +244,9 @@ function TreeInner( if (shiftKey) { const validSelectableItems = getValidSelectableItems(treeId, selectableItems); - const anchorIndex = validSelectableItems.findIndex((i) => i.node.item.id === anchorSelectedId); + const anchorIndex = validSelectableItems.findIndex( + (i) => i.node.item.id === anchorSelectedId, + ); const currIndex = validSelectableItems.findIndex((v) => v.node.item.id === item.id); // Nothing was selected yet, so just select this item diff --git a/src-web/font-size.ts b/src-web/font-size.ts index 09a78d5a..bdd57fcd 100644 --- a/src-web/font-size.ts +++ b/src-web/font-size.ts @@ -7,7 +7,8 @@ function setFontSizeOnDocument(fontSize: number) { document.documentElement.style.fontSize = `${fontSize}px`; } -listen('upserted_model', async (event) => { +listen('model_write', async (event) => { + if (event.payload.change.type !== 'upsert') return; if (event.payload.model.model !== 'settings') return; setFontSizeOnDocument(event.payload.model.interfaceFontSize); }).catch(console.error); diff --git a/src-web/font.ts b/src-web/font.ts index 9778dd0a..e02c65e2 100644 --- a/src-web/font.ts +++ b/src-web/font.ts @@ -11,7 +11,8 @@ function setFonts(settings: Settings) { ); } -listen('upserted_model', async (event) => { +listen('model_write', async (event) => { + if (event.payload.change.type !== 'upsert') return; if (event.payload.model.model !== 'settings') return; setFonts(event.payload.model); }).catch(console.error); diff --git a/src-web/hooks/useCreateDropdownItems.tsx b/src-web/hooks/useCreateDropdownItems.tsx index ca75fb4e..04776a55 100644 --- a/src-web/hooks/useCreateDropdownItems.tsx +++ b/src-web/hooks/useCreateDropdownItems.tsx @@ -36,46 +36,68 @@ export function getCreateDropdownItems({ folderId: folderIdOption, workspaceId, activeRequest, + onCreate, }: { hideFolder?: boolean; hideIcons?: boolean; folderId?: string | null | 'active-folder'; workspaceId: string | null; activeRequest: HttpRequest | GrpcRequest | WebsocketRequest | null; + onCreate?: ( + model: 'http_request' | 'grpc_request' | 'websocket_request' | 'folder', + id: string, + ) => void; }): DropdownItem[] { const folderId = (folderIdOption === 'active-folder' ? activeRequest?.folderId : folderIdOption) ?? null; - if (workspaceId == null) return []; + + if (workspaceId == null) { + return []; + } return [ { label: 'HTTP', leftSlot: hideIcons ? undefined : , - onSelect: () => createRequestAndNavigate({ model: 'http_request', workspaceId, folderId }), + onSelect: async () => { + const id = await createRequestAndNavigate({ model: 'http_request', workspaceId, folderId }); + onCreate?.('http_request', id); + }, }, { label: 'GraphQL', leftSlot: hideIcons ? undefined : , - onSelect: () => - createRequestAndNavigate({ + onSelect: async () => { + const id = await createRequestAndNavigate({ model: 'http_request', workspaceId, folderId, bodyType: BODY_TYPE_GRAPHQL, method: 'POST', headers: [{ name: 'Content-Type', value: 'application/json', id: generateId() }], - }), + }); + onCreate?.('http_request', id); + }, }, { label: 'gRPC', leftSlot: hideIcons ? undefined : , - onSelect: () => createRequestAndNavigate({ model: 'grpc_request', workspaceId, folderId }), + onSelect: async () => { + const id = await createRequestAndNavigate({ model: 'grpc_request', workspaceId, folderId }); + onCreate?.('grpc_request', id); + }, }, { label: 'WebSocket', leftSlot: hideIcons ? undefined : , - onSelect: () => - createRequestAndNavigate({ model: 'websocket_request', workspaceId, folderId }), + onSelect: async () => { + const id = await createRequestAndNavigate({ + model: 'websocket_request', + workspaceId, + folderId, + }); + onCreate?.('websocket_request', id); + }, }, ...((hideFolder ? [] @@ -84,7 +106,12 @@ export function getCreateDropdownItems({ { label: 'Folder', leftSlot: hideIcons ? undefined : , - onSelect: () => createFolder.mutate({ folderId }), + onSelect: async () => { + const id = await createFolder.mutateAsync({ folderId }); + if (id != null) { + onCreate?.('folder', id); + } + }, }, ]) as DropdownItem[]), ]; diff --git a/src-web/hooks/useListenToTauriEvent.ts b/src-web/hooks/useListenToTauriEvent.ts index cee6de93..e0c4781c 100644 --- a/src-web/hooks/useListenToTauriEvent.ts +++ b/src-web/hooks/useListenToTauriEvent.ts @@ -1,17 +1,21 @@ import type { EventCallback, EventName } from '@tauri-apps/api/event'; import { listen } from '@tauri-apps/api/event'; import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'; -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; -/** - * React hook to listen to a Tauri event. - */ export function useListenToTauriEvent(event: EventName, fn: EventCallback) { - useEffect(() => listenToTauriEvent(event, fn), [event, fn]); + const handlerRef = useRef(fn); + useEffect(() => { + handlerRef.current = fn; + }, [fn]); + + useEffect(() => { + return listenToTauriEvent(event, (p) => handlerRef.current(p)); + }, [event]); } export function listenToTauriEvent(event: EventName, fn: EventCallback) { - const unlisten = listen( + const unsubPromise = listen( event, fn, // Listen to `emit_all()` events or events specific to the current window @@ -19,6 +23,6 @@ export function listenToTauriEvent(event: EventName, fn: EventCallback) { ); return () => { - unlisten.then((fn) => fn()); + unsubPromise.then((unsub) => unsub()).catch(console.error); }; } diff --git a/src-web/hooks/useRequestUpdateKey.ts b/src-web/hooks/useRequestUpdateKey.ts index 0d626386..f2e34032 100644 --- a/src-web/hooks/useRequestUpdateKey.ts +++ b/src-web/hooks/useRequestUpdateKey.ts @@ -7,7 +7,9 @@ import { jotaiStore } from '../lib/jotai'; const requestUpdateKeyAtom = atom>({}); getCurrentWebviewWindow() - .listen('upserted_model', ({ payload }) => { + .listen('model_write', ({ payload }) => { + if (payload.change.type !== 'upsert') return; + if ( (payload.model.model === 'http_request' || payload.model.model === 'grpc_request' || diff --git a/src-web/init/sync.ts b/src-web/init/sync.ts index 6098625c..1dbb087f 100644 --- a/src-web/init/sync.ts +++ b/src-web/init/sync.ts @@ -34,10 +34,7 @@ const debouncedSync = debounce(async () => { * simply add long-lived subscribers for the lifetime of the app. */ function initModelListeners() { - listenToTauriEvent('upserted_model', (p) => { - if (isModelRelevant(p.payload.model)) debouncedSync(); - }); - listenToTauriEvent('deleted_model', (p) => { + listenToTauriEvent('model_write', (p) => { if (isModelRelevant(p.payload.model)) debouncedSync(); }); } diff --git a/src-web/lib/createRequestAndNavigate.tsx b/src-web/lib/createRequestAndNavigate.tsx index 2c85da17..b6047f1c 100644 --- a/src-web/lib/createRequestAndNavigate.tsx +++ b/src-web/lib/createRequestAndNavigate.tsx @@ -11,8 +11,8 @@ export async function createRequestAndNavigate< if (patch.sortPriority === undefined) { if (activeRequest != null) { - // Place above currently active request - patch.sortPriority = activeRequest.sortPriority - 0.0001; + // Place below the currently active request + patch.sortPriority = activeRequest.sortPriority; } else { // Place at the very top patch.sortPriority = -Date.now(); @@ -27,4 +27,5 @@ export async function createRequestAndNavigate< params: { workspaceId: patch.workspaceId }, search: (prev) => ({ ...prev, request_id: newId }), }); + return newId; } diff --git a/src-web/lib/duplicateRequestOrFolderAndNavigate.tsx b/src-web/lib/duplicateRequestOrFolderAndNavigate.tsx index 1cd42cb1..0d7ba940 100644 --- a/src-web/lib/duplicateRequestOrFolderAndNavigate.tsx +++ b/src-web/lib/duplicateRequestOrFolderAndNavigate.tsx @@ -13,7 +13,7 @@ export async function duplicateRequestOrFolderAndNavigate( const newId = await duplicateModel(model); const workspaceId = jotaiStore.get(activeWorkspaceIdAtom); - if (workspaceId == null) return; + if (workspaceId == null || model.model === 'folder') return; navigateToRequestOrFolderOrWorkspace(newId, model.model); } diff --git a/src-web/lib/setWorkspaceSearchParams.ts b/src-web/lib/setWorkspaceSearchParams.ts index e823bdf4..fc3ec824 100644 --- a/src-web/lib/setWorkspaceSearchParams.ts +++ b/src-web/lib/setWorkspaceSearchParams.ts @@ -19,7 +19,7 @@ export function setWorkspaceSearchParams( (router as any).navigate({ // eslint-disable-next-line @typescript-eslint/no-explicit-any search: (prev: any) => { - console.log('Navigating to', { prev, search }); + // console.log('Navigating to', { prev, search }); return { ...prev, ...search }; }, }); diff --git a/src-web/theme.ts b/src-web/theme.ts index be47862c..03957687 100644 --- a/src-web/theme.ts +++ b/src-web/theme.ts @@ -26,7 +26,9 @@ configureTheme().then( ); // Listen for settings changes, the re-compute theme -listen('upserted_model', async (event) => { +listen('model_write', async (event) => { + if (event.payload.change.type !== 'upsert') return; + const model = event.payload.model.model; if (model !== 'settings' && model !== 'plugin') return; await configureTheme();