diff --git a/package-lock.json b/package-lock.json index 282ce0b5..19e912e9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -820,6 +820,45 @@ "integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==", "license": "MIT" }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.6", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.6.tgz", @@ -14412,12 +14451,13 @@ } } }, - "node_modules/react-dnd-html5-backend": { + "node_modules/react-dnd-touch-backend": { "version": "16.0.1", - "resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-16.0.1.tgz", - "integrity": "sha512-Wu3dw5aDJmOGw8WjH1I1/yTH+vlXEL4vmjk5p+MHxP8HuHJS1lAGeIdG/hze1AvNeXWo/JgULV87LyQOr+r5jw==", + "resolved": "https://registry.npmjs.org/react-dnd-touch-backend/-/react-dnd-touch-backend-16.0.1.tgz", + "integrity": "sha512-NonoCABzzjyWGZuDxSG77dbgMZ2Wad7eQiCd/ECtsR2/NBLTjGksPUx9UPezZ1nQ/L7iD130Tz3RUshL/ClKLA==", "license": "MIT", "dependencies": { + "@react-dnd/invariant": "^4.0.1", "dnd-core": "^16.0.1" } }, @@ -19089,6 +19129,7 @@ "@codemirror/lang-xml": "^6.1.0", "@codemirror/language": "^6.11.0", "@codemirror/search": "^6.5.11", + "@dnd-kit/core": "^6.3.1", "@gilbarbara/deep-equal": "^0.3.1", "@lezer/highlight": "^1.1.3", "@lezer/lr": "^1.3.3", @@ -19129,7 +19170,7 @@ "react": "^19.1.0", "react-colorful": "^5.6.1", "react-dnd": "^16.0.1", - "react-dnd-html5-backend": "^16.0.1", + "react-dnd-touch-backend": "^16.0.1", "react-dom": "^19.1.0", "react-markdown": "^10.1.0", "react-pdf": "^10.0.1", diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b1034764..80e6cb97 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1007,6 +1007,35 @@ async fn cmd_save_response( Ok(()) } +#[tauri::command] +async fn cmd_send_folder( + app_handle: AppHandle, + window: WebviewWindow, + environment_id: Option, + cookie_jar_id: Option, + folder_id: &str, +) -> YaakResult<()> { + let requests = app_handle.db().list_http_requests_for_folder_recursive(folder_id)?; + for request in requests { + let app_handle = app_handle.clone(); + let window = window.clone(); + let environment_id = environment_id.clone(); + let cookie_jar_id = cookie_jar_id.clone(); + tokio::spawn(async move { + let _ = cmd_send_http_request( + app_handle, + window, + environment_id.as_deref(), + cookie_jar_id.as_deref(), + request, + ) + .await; + }); + } + + Ok(()) +} + #[tauri::command] async fn cmd_send_http_request( app_handle: AppHandle, @@ -1386,6 +1415,7 @@ pub fn run() { cmd_save_response, cmd_send_ephemeral_request, cmd_send_http_request, + cmd_send_folder, cmd_template_functions, cmd_template_tokens_to_string, // @@ -1511,14 +1541,17 @@ fn monitor_plugin_events(app_handle: &AppHandle) { Ok(None) => return, Err(e) => { warn!("Failed to handle plugin event: {e:?}"); - let _ = app_handle.emit("show_toast", InternalEventPayload::ShowToastRequest(ShowToastRequest { - message: e.to_string(), - color: Some(Color::Danger), - icon: None, - timeout: Some(30000), - })); + let _ = app_handle.emit( + "show_toast", + InternalEventPayload::ShowToastRequest(ShowToastRequest { + message: e.to_string(), + color: Some(Color::Danger), + icon: None, + timeout: Some(30000), + }), + ); return; - }, + } }; let plugin_manager: State<'_, PluginManager> = app_handle.state(); diff --git a/src-tauri/src/window.rs b/src-tauri/src/window.rs index 4048c02d..5774afbd 100644 --- a/src-tauri/src/window.rs +++ b/src-tauri/src/window.rs @@ -1,3 +1,4 @@ +use crate::error::Result; use crate::window_menu::app_menu; use log::{info, warn}; use rand::random; @@ -6,7 +7,6 @@ use tauri::{ }; use tauri_plugin_opener::OpenerExt; use tokio::sync::mpsc; -use crate::error::Result; const DEFAULT_WINDOW_WIDTH: f64 = 1100.0; const DEFAULT_WINDOW_HEIGHT: f64 = 600.0; @@ -49,7 +49,6 @@ pub(crate) fn create_window( .resizable(true) .visible(false) // To prevent theme flashing, the frontend code calls show() immediately after configuring the theme .fullscreen(false) - .disable_drag_drop_handler() // Required for frontend Dnd on windows .min_inner_size(MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT); if let Some(key) = config.data_dir_key { @@ -216,10 +215,10 @@ pub(crate) fn create_child_window( ) -> Result { let app_handle = parent_window.app_handle(); let label = format!("{OTHER_WINDOW_PREFIX}_{label}"); - let scale_factor = parent_window.scale_factor().unwrap(); + let scale_factor = parent_window.scale_factor()?; - let current_pos = parent_window.inner_position().unwrap().to_logical::(scale_factor); - let current_size = parent_window.inner_size().unwrap().to_logical::(scale_factor); + let current_pos = parent_window.inner_position()?.to_logical::(scale_factor); + let current_size = parent_window.inner_size()?.to_logical::(scale_factor); // Position the new window in the middle of the parent let position = ( diff --git a/src-tauri/yaak-models/guest-js/store.ts b/src-tauri/yaak-models/guest-js/store.ts index 5514683e..2db25ead 100644 --- a/src-tauri/yaak-models/guest-js/store.ts +++ b/src-tauri/yaak-models/guest-js/store.ts @@ -81,11 +81,12 @@ export function getAnyModel(id: string): AnyModel | null { } export function getModel>( - modelType: M | M[], + modelType: M | ReadonlyArray, id: string, ): T | null { let data = mustStore().get(modelStoreDataAtom); - for (const t of Array.isArray(modelType) ? modelType : [modelType]) { + const types: ReadonlyArray = Array.isArray(modelType) ? modelType : [modelType]; + for (const t of types) { let v = data[t][id]; if (v?.model === t) return v as T; } @@ -139,7 +140,7 @@ export async function deleteModel, ->(modelType: M | M[], id: string) { +>(modelType: M | ReadonlyArray, id: string) { let model = getModel(modelType, id); return duplicateModel(model); } @@ -150,6 +151,8 @@ export function duplicateModel('plugin:yaak-models|duplicate', { model }); } diff --git a/src-tauri/yaak-models/src/queries/http_requests.rs b/src-tauri/yaak-models/src/queries/http_requests.rs index 021f9695..a4d6fe21 100644 --- a/src-tauri/yaak-models/src/queries/http_requests.rs +++ b/src-tauri/yaak-models/src/queries/http_requests.rs @@ -1,6 +1,6 @@ use crate::db_context::DbContext; use crate::error::Result; -use crate::models::{HttpRequest, HttpRequestHeader, HttpRequestIden}; +use crate::models::{Folder, FolderIden, HttpRequest, HttpRequestHeader, HttpRequestIden}; use crate::util::UpdateSource; use serde_json::Value; use std::collections::BTreeMap; @@ -89,4 +89,18 @@ impl<'a> DbContext<'a> { Ok(headers) } + + pub fn list_http_requests_for_folder_recursive( + &self, + folder_id: &str, + ) -> Result> { + let mut children = Vec::new(); + for m in self.find_many::(FolderIden::FolderId, folder_id, None)? { + children.extend(self.list_http_requests_for_folder_recursive(&m.id)?); + } + for m in self.find_many::(FolderIden::FolderId, folder_id, None)? { + children.push(m); + } + Ok(children) + } } diff --git a/src-tauri/yaak-templates/index.ts b/src-tauri/yaak-templates/index.ts index c024103a..23dd9166 100644 --- a/src-tauri/yaak-templates/index.ts +++ b/src-tauri/yaak-templates/index.ts @@ -1,7 +1,15 @@ export * from './bindings/parser'; import { Tokens } from './bindings/parser'; -import { parse_template } from './pkg'; +import { escape_template, parse_template, unescape_template } from './pkg'; export function parseTemplate(template: string) { return parse_template(template) as Tokens; } + +export function escapeTemplate(template: string) { + return escape_template(template) as string; +} + +export function unescapeTemplate(template: string) { + return unescape_template(template) as string; +} diff --git a/src-tauri/yaak-templates/src/escape.rs b/src-tauri/yaak-templates/src/escape.rs new file mode 100644 index 00000000..f7e42759 --- /dev/null +++ b/src-tauri/yaak-templates/src/escape.rs @@ -0,0 +1,166 @@ +pub fn escape_template(text: &str) -> String { + let mut result = String::with_capacity(text.len()); + let chars: Vec = text.chars().collect(); + let mut i = 0; + + while i < chars.len() { + // Check if we're at "${[" + if i + 2 < chars.len() && chars[i] == '$' && chars[i + 1] == '{' && chars[i + 2] == '[' { + // Count preceding backslashes + let mut backslash_count = 0; + let mut j = i; + while j > 0 && chars[j - 1] == '\\' { + backslash_count += 1; + j -= 1; + } + + // If odd number of backslashes, the $ is escaped + // If even number (including 0), the $ is not escaped + let already_escaped = backslash_count % 2 == 1; + + if already_escaped { + // Already escaped, just add the current character + result.push(chars[i]); + } else { + // Not escaped, add backslash before $ + result.push('\\'); + result.push(chars[i]); + } + } else { + result.push(chars[i]); + } + i += 1; + } + result +} + +pub fn unescape_template(text: &str) -> String { + let mut result = String::with_capacity(text.len()); + let chars: Vec = text.chars().collect(); + let mut i = 0; + + while i < chars.len() { + // Check if we're at "\${[" + if i + 3 < chars.len() + && chars[i] == '\\' + && chars[i + 1] == '$' + && chars[i + 2] == '{' + && chars[i + 3] == '[' + { + // Count preceding backslashes (before the current backslash) + let mut backslash_count = 0; + let mut j = i; + while j > 0 && chars[j - 1] == '\\' { + backslash_count += 1; + j -= 1; + } + + // If even number of preceding backslashes, this backslash escapes the $ + // If odd number, this backslash is itself escaped + let escapes_dollar = backslash_count % 2 == 0; + + if escapes_dollar { + // Skip the backslash, just add the $ + result.push(chars[i + 1]); + i += 1; // Skip the backslash + } else { + // This backslash is escaped itself, keep it + result.push(chars[i]); + } + } else { + result.push(chars[i]); + } + i += 1; + } + + result +} + +#[cfg(test)] +mod tests { + use crate::escape::{escape_template, unescape_template}; + + #[test] + fn test_escape_simple() { + let input = r#"${[foo]}"#; + let expected = r#"\${[foo]}"#; + assert_eq!(escape_template(input), expected); + } + + #[test] + fn test_already_escaped() { + let input = r#"\${[bar]}"#; + let expected = r#"\${[bar]}"#; + assert_eq!(escape_template(input), expected); + } + + #[test] + fn test_double_backslash() { + let input = r#"\\${[bar]}"#; + let expected = r#"\\\${[bar]}"#; + assert_eq!(escape_template(input), expected); + } + + #[test] + fn test_escape_with_surrounding_text() { + let input = r#"text ${[var]} more"#; + let expected = r#"text \${[var]} more"#; + assert_eq!(escape_template(input), expected); + } + + #[test] + fn test_preserve_already_escaped() { + let input = r#"already \${[escaped]}"#; + let expected = r#"already \${[escaped]}"#; + assert_eq!(escape_template(input), expected); + } + + #[test] + fn test_multiple_occurrences() { + let input = r#"${[one]} and ${[two]}"#; + let expected = r#"\${[one]} and \${[two]}"#; + assert_eq!(escape_template(input), expected); + } + + #[test] + fn test_mixed_escaped_and_unescaped() { + let input = r#"mixed \${[esc]} and ${[unesc]}"#; + let expected = r#"mixed \${[esc]} and \${[unesc]}"#; + assert_eq!(escape_template(input), expected); + } + + #[test] + fn test_unescape_simple() { + let input = r#"\${[foo]}"#; + let expected = r#"${[foo]}"#; + assert_eq!(unescape_template(input), expected); + } + + #[test] + fn test_unescape_with_text() { + let input = r#"text \${[var]} more"#; + let expected = r#"text ${[var]} more"#; + assert_eq!(unescape_template(input), expected); + } + + #[test] + fn test_unescape_multiple() { + let input = r#"\${[one]} and \${[two]}"#; + let expected = r#"${[one]} and ${[two]}"#; + assert_eq!(unescape_template(input), expected); + } + + #[test] + fn test_unescape_double_backslash() { + let input = r#"\\\${[bar]}"#; + let expected = r#"\\${[bar]}"#; + assert_eq!(unescape_template(input), expected); + } + + #[test] + fn test_unescape_plain_text() { + let input = r#"${[foo]}"#; + let expected = r#"${[foo]}"#; + assert_eq!(unescape_template(input), expected); + } +} diff --git a/src-tauri/yaak-templates/src/lib.rs b/src-tauri/yaak-templates/src/lib.rs index 406218cf..e4c699d3 100644 --- a/src-tauri/yaak-templates/src/lib.rs +++ b/src-tauri/yaak-templates/src/lib.rs @@ -1,7 +1,8 @@ +pub mod error; +pub mod escape; pub mod format; pub mod parser; pub mod renderer; -pub mod error; pub mod wasm; pub use parser::*; diff --git a/src-tauri/yaak-templates/src/parser.rs b/src-tauri/yaak-templates/src/parser.rs index 59fa32e5..2d74cb50 100644 --- a/src-tauri/yaak-templates/src/parser.rs +++ b/src-tauri/yaak-templates/src/parser.rs @@ -170,7 +170,13 @@ impl Parser { let start_pos = self.pos; while self.pos < self.chars.len() { - if self.match_str("${[") { + if self.match_str(r#"\\"#) { + // Skip double-escapes so we don't trigger our own escapes in the next case + self.curr_text += r#"\\"#; + } else if self.match_str(r#"\${["#) { + // Unescaped template syntax so we treat it as a string + self.curr_text += "${["; + } else if self.match_str("${[") { let start_curr = self.pos; if let Some(t) = self.parse_tag()? { self.push_token(t); @@ -490,6 +496,39 @@ mod tests { use crate::error::Result; use crate::*; + #[test] + fn escaped() -> Result<()> { + let mut p = Parser::new(r#"\${[ foo ]}"#); + assert_eq!( + p.parse()?.tokens, + vec![ + Token::Raw { + text: "${[ foo ]}".to_string() + }, + Token::Eof + ] + ); + Ok(()) + } + + #[test] + fn escaped_tricky() -> Result<()> { + let mut p = Parser::new(r#"\\${[ foo ]}"#); + assert_eq!( + p.parse()?.tokens, + vec![ + Token::Raw { + text: r#"\\"#.to_string() + }, + Token::Tag { + val: Val::Var { name: "foo".into() } + }, + Token::Eof + ] + ); + Ok(()) + } + #[test] fn var_simple() -> Result<()> { let mut p = Parser::new("${[ foo ]}"); diff --git a/src-tauri/yaak-templates/src/wasm.rs b/src-tauri/yaak-templates/src/wasm.rs index f71a2e84..7c291c44 100644 --- a/src-tauri/yaak-templates/src/wasm.rs +++ b/src-tauri/yaak-templates/src/wasm.rs @@ -1,5 +1,5 @@ use crate::error::Result; -use crate::Parser; +use crate::{escape, Parser}; use wasm_bindgen::prelude::wasm_bindgen; use wasm_bindgen::JsValue; @@ -7,4 +7,16 @@ use wasm_bindgen::JsValue; pub fn parse_template(template: &str) -> Result { let tokens = Parser::new(template).parse()?; Ok(serde_wasm_bindgen::to_value(&tokens).unwrap()) -} \ No newline at end of file +} + +#[wasm_bindgen] +pub fn escape_template(template: &str) -> Result { + let escaped = escape::escape_template(template); + Ok(serde_wasm_bindgen::to_value(&escaped).unwrap()) +} + +#[wasm_bindgen] +pub fn unescape_template(template: &str) -> Result { + let escaped = escape::unescape_template(template); + Ok(serde_wasm_bindgen::to_value(&escaped).unwrap()) +} diff --git a/src-web/commands/moveToWorkspace.tsx b/src-web/commands/moveToWorkspace.tsx new file mode 100644 index 00000000..61f597ab --- /dev/null +++ b/src-web/commands/moveToWorkspace.tsx @@ -0,0 +1,28 @@ +import type { GrpcRequest, HttpRequest, WebsocketRequest } from '@yaakapp-internal/models'; +import React from 'react'; +import { MoveToWorkspaceDialog } from '../components/MoveToWorkspaceDialog'; +import { activeWorkspaceIdAtom } from '../hooks/useActiveWorkspace'; +import { createFastMutation } from '../hooks/useFastMutation'; +import { showDialog } from '../lib/dialog'; +import { jotaiStore } from '../lib/jotai'; + +export const moveToWorkspace = createFastMutation({ + mutationKey: ['move_workspace'], + mutationFn: async (request: HttpRequest | GrpcRequest | WebsocketRequest) => { + const activeWorkspaceId = jotaiStore.get(activeWorkspaceIdAtom); + if (activeWorkspaceId == null) return; + + showDialog({ + id: 'change-workspace', + title: 'Move Workspace', + size: 'sm', + render: ({ hide }) => ( + + ), + }); + }, +}); diff --git a/src-web/commands/openFolderSettings.tsx b/src-web/commands/openFolderSettings.tsx index 06ab98cb..4271aa4b 100644 --- a/src-web/commands/openFolderSettings.tsx +++ b/src-web/commands/openFolderSettings.tsx @@ -1,11 +1,21 @@ +import { getModel } from '@yaakapp-internal/models'; +import { Icon } from '../components/core/Icon'; +import { HStack } from '../components/core/Stacks'; import type { FolderSettingsTab } from '../components/FolderSettingsDialog'; import { FolderSettingsDialog } from '../components/FolderSettingsDialog'; import { showDialog } from '../lib/dialog'; +import { resolvedModelName } from '../lib/resolvedModelName'; export function openFolderSettings(folderId: string, tab?: FolderSettingsTab) { + const folder = getModel('folder', folderId); showDialog({ id: 'folder-settings', - title: 'Folder Settings', + title: ( + + + {resolvedModelName(folder)} + + ), size: 'lg', className: 'h-[50rem]', noPadding: true, diff --git a/src-web/components/CommandPaletteDialog.tsx b/src-web/components/CommandPaletteDialog.tsx index 47941116..1daa35e2 100644 --- a/src-web/components/CommandPaletteDialog.tsx +++ b/src-web/components/CommandPaletteDialog.tsx @@ -16,8 +16,8 @@ import { useAllRequests } from '../hooks/useAllRequests'; import { useCreateWorkspace } from '../hooks/useCreateWorkspace'; import { useDebouncedState } from '../hooks/useDebouncedState'; import { useEnvironmentsBreakdown } from '../hooks/useEnvironmentsBreakdown'; +import { useGrpcRequestActions } from '../hooks/useGrpcRequestActions'; import type { HotkeyAction } from '../hooks/useHotKey'; -import { useHotKey } from '../hooks/useHotKey'; import { useHttpRequestActions } from '../hooks/useHttpRequestActions'; import { useRecentEnvironments } from '../hooks/useRecentEnvironments'; import { useRecentRequests } from '../hooks/useRecentRequests'; @@ -61,6 +61,7 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) { const [selectedItemKey, setSelectedItemKey] = useState(null); const activeEnvironment = useActiveEnvironment(); const httpRequestActions = useHttpRequestActions(); + const grpcRequestActions = useGrpcRequestActions(); const workspaceId = useAtomValue(activeWorkspaceIdAtom); const workspaces = useAtomValue(workspacesAtom); const { baseEnvironment, subEnvironments } = useEnvironmentsBreakdown(); @@ -90,7 +91,7 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) { onSelect: createWorkspace, }, { - key: 'http_request.create', + key: 'model.create', label: 'Create HTTP Request', onSelect: () => createRequestAndNavigate({ model: 'http_request', workspaceId }), }, @@ -142,8 +143,8 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) { if (activeRequest?.model === 'http_request') { commands.push({ - key: 'http_request.send', - action: 'http_request.send', + key: 'request.send', + action: 'request.send', label: 'Send Request', onSelect: () => sendRequest(activeRequest.id), }); @@ -157,6 +158,17 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) { } } + if (activeRequest?.model === 'grpc_request') { + for (let i = 0; i < grpcRequestActions.length; i++) { + const a = grpcRequestActions[i]!; + commands.push({ + key: `grpc_request_action.${i}`, + label: a.label, + onSelect: () => a.call(activeRequest), + }); + } + } + if (activeRequest != null) { commands.push({ key: 'http_request.rename', @@ -182,6 +194,7 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) { activeRequest, baseEnvironment, createWorkspace, + grpcRequestActions, httpRequestActions, sendRequest, setSidebarHidden, @@ -369,7 +382,6 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) { const handleKeyDown = useCallback( (e: KeyboardEvent) => { const index = filteredAllItems.findIndex((v) => v.key === selectedItem?.key); - if (e.key === 'ArrowDown' || (e.ctrlKey && e.key === 'n')) { const next = filteredAllItems[index + 1] ?? filteredAllItems[0]; setSelectedItemKey(next?.key ?? null); @@ -417,9 +429,7 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) { active={v.key === selectedItem?.key} key={v.key} onClick={() => handleSelectAndClose(v.onSelect)} - rightSlot={ - v.action && - } + rightSlot={v.action && } > {v.label} @@ -465,13 +475,6 @@ function CommandPaletteItem({ ); } -function CommandPaletteAction({ - action, - onAction, -}: { - action: HotkeyAction; - onAction: () => void; -}) { - useHotKey(action, onAction); +function CommandPaletteAction({ action }: { action: HotkeyAction }) { return ; } diff --git a/src-web/components/ErrorBoundary.tsx b/src-web/components/ErrorBoundary.tsx index 5ab7fc11..89aab27c 100644 --- a/src-web/components/ErrorBoundary.tsx +++ b/src-web/components/ErrorBoundary.tsx @@ -33,7 +33,7 @@ export class ErrorBoundary extends Component +
Error rendering {this.props.name} component
diff --git a/src-web/components/FolderLayout.tsx b/src-web/components/FolderLayout.tsx new file mode 100644 index 00000000..d9e4c771 --- /dev/null +++ b/src-web/components/FolderLayout.tsx @@ -0,0 +1,192 @@ +import type { Folder, GrpcRequest, HttpRequest, WebsocketRequest } from '@yaakapp-internal/models'; +import { foldersAtom } from '@yaakapp-internal/models'; +import classNames from 'classnames'; +import { useAtomValue } from 'jotai'; +import type { CSSProperties } from 'react'; +import { useCallback, useMemo } from 'react'; +import { allRequestsAtom } from '../hooks/useAllRequests'; +import { useLatestHttpResponse } from '../hooks/useLatestHttpResponse'; +import { sendAnyHttpRequest } from '../hooks/useSendAnyHttpRequest'; +import { showDialog } from '../lib/dialog'; +import { resolvedModelName } from '../lib/resolvedModelName'; +import { router } from '../lib/router'; +import { Button } from './core/Button'; +import { Heading } from './core/Heading'; +import { HttpResponseDurationTag } from './core/HttpResponseDurationTag'; +import { HttpStatusTag } from './core/HttpStatusTag'; +import { Icon } from './core/Icon'; +import { IconButton } from './core/IconButton'; +import { LoadingIcon } from './core/LoadingIcon'; +import { Separator } from './core/Separator'; +import { SizeTag } from './core/SizeTag'; +import { HStack } from './core/Stacks'; +import { HttpResponsePane } from './HttpResponsePane'; + +interface Props { + folder: Folder; + style: CSSProperties; +} + +export function FolderLayout({ folder, style }: Props) { + const folders = useAtomValue(foldersAtom); + const requests = useAtomValue(allRequestsAtom); + const children = useMemo(() => { + return [ + ...folders.filter((f) => f.folderId === folder.id), + ...requests.filter((r) => r.folderId === folder.id), + ]; + }, [folder.id, folders, requests]); + + return ( +
+ + + {resolvedModelName(folder)} + + + + + +
+ {children.map((child) => ( + + ))} +
+
+ ); +} + +function ChildCard({ child }: { child: Folder | HttpRequest | GrpcRequest | WebsocketRequest }) { + let card; + if (child.model === 'folder') { + card = ; + } else if (child.model === 'http_request') { + card = ; + } else if (child.model === 'grpc_request') { + card = ; + } else if (child.model === 'websocket_request') { + card = ; + } else { + card =
Unknown model {child['model']}
; + } + + const navigate = useCallback(async () => { + await router.navigate({ + to: '/workspaces/$workspaceId', + params: { workspaceId: child.workspaceId }, + search: (prev) => ({ ...prev, request_id: child.id }), + }); + }, [child.id, child.workspaceId]); + + return ( +
+ + {child.model === 'folder' && } + + {resolvedModelName(child)} + + + + { + sendAnyHttpRequest.mutate(child.id); + }} + /> + + +
{card}
+
+ ); +} + +function FolderCard({ folder }: { folder: Folder }) { + return ( +
+ +
+ ); +} + +function RequestCard({ request }: { request: HttpRequest | GrpcRequest | WebsocketRequest }) { + return
TODO {request.id}
; +} + +function HttpRequestCard({ request }: { request: HttpRequest }) { + const latestResponse = useLatestHttpResponse(request.id); + + return ( +
+ + {request.method} {request.url} + + {latestResponse ? ( + + ) : ( +
No Responses
+ )} +
+ ); +} diff --git a/src-web/components/FolderSettingsDialog.tsx b/src-web/components/FolderSettingsDialog.tsx index cbfc93a1..12f99b30 100644 --- a/src-web/components/FolderSettingsDialog.tsx +++ b/src-web/components/FolderSettingsDialog.tsx @@ -68,14 +68,15 @@ export function FolderSettingsDialog({ folderId, tab }: Props) { value={activeTab} onChangeValue={setActiveTab} label="Folder Settings" - className="px-1.5 pb-2" + className="pt-2 pb-2 pl-3 pr-1" + layout="horizontal" addBorders tabs={tabs} > - + - + - + - + {folderEnvironment == null ? ( diff --git a/src-web/components/GlobalHooks.tsx b/src-web/components/GlobalHooks.tsx index 5265f9f3..c6936add 100644 --- a/src-web/components/GlobalHooks.tsx +++ b/src-web/components/GlobalHooks.tsx @@ -1,5 +1,6 @@ import { useSubscribeActiveWorkspaceId } from '../hooks/useActiveWorkspace'; import { useActiveWorkspaceChangedToast } from '../hooks/useActiveWorkspaceChangedToast'; +import { useSubscribeHotKeys } from '../hooks/useHotKey'; import { useSubscribeHttpAuthentication } from '../hooks/useHttpAuthentication'; import { useSyncFontSizeSetting } from '../hooks/useSyncFontSizeSetting'; import { useSyncWorkspaceChildModels } from '../hooks/useSyncWorkspaceChildModels'; @@ -18,6 +19,7 @@ export function GlobalHooks() { // Other useful things useActiveWorkspaceChangedToast(); + useSubscribeHotKeys(); return null; } diff --git a/src-web/components/GrpcConnectionLayout.tsx b/src-web/components/GrpcConnectionLayout.tsx index 851ef6b9..223d6911 100644 --- a/src-web/components/GrpcConnectionLayout.tsx +++ b/src-web/components/GrpcConnectionLayout.tsx @@ -117,7 +117,7 @@ export function GrpcConnectionLayout({ style }: Props) { ) : grpcEvents.length >= 0 ? ( ) : ( - + )} ) diff --git a/src-web/components/GrpcRequestPane.tsx b/src-web/components/GrpcRequestPane.tsx index 7feb5e58..2b6eb466 100644 --- a/src-web/components/GrpcRequestPane.tsx +++ b/src-web/components/GrpcRequestPane.tsx @@ -240,7 +240,7 @@ export function GrpcRequestPane({ size="sm" variant="border" title={isStreaming ? 'Connect' : 'Send'} - hotkeyAction="grpc_request.send" + hotkeyAction="request.send" onClick={isStreaming ? handleSend : handleConnect} icon={isStreaming ? 'send_horizontal' : 'arrow_up_down'} /> @@ -250,7 +250,7 @@ export function GrpcRequestPane({ size="sm" variant="border" title={methodType === 'unary' ? 'Send' : 'Connect'} - hotkeyAction="grpc_request.send" + hotkeyAction="request.send" onClick={isStreaming ? onCancel : handleConnect} disabled={methodType === 'no-schema' || methodType === 'no-method'} icon={ diff --git a/src-web/components/GrpcResponsePane.tsx b/src-web/components/GrpcResponsePane.tsx index ca0f9dd7..2566cb32 100644 --- a/src-web/components/GrpcResponsePane.tsx +++ b/src-web/components/GrpcResponsePane.tsx @@ -74,7 +74,7 @@ export function GrpcResponsePane({ style, methodType, activeRequest }: Props) { firstSlot={() => activeConnection == null ? ( ) : (
diff --git a/src-web/components/HttpRequestPane.tsx b/src-web/components/HttpRequestPane.tsx index 8ab74fa1..ccabdb27 100644 --- a/src-web/components/HttpRequestPane.tsx +++ b/src-web/components/HttpRequestPane.tsx @@ -160,7 +160,11 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }: { label: 'GraphQL', value: BODY_TYPE_GRAPHQL }, { label: 'JSON', value: BODY_TYPE_JSON }, { label: 'XML', value: BODY_TYPE_XML }, - { label: 'Other', value: BODY_TYPE_OTHER }, + { + label: 'Other', + value: BODY_TYPE_OTHER, + shortLabel: nameOfContentTypeOr(contentType, 'Other'), + }, { type: 'separator', label: 'Other' }, { label: 'Binary File', value: BODY_TYPE_BINARY }, { label: 'No Body', shortLabel: 'Body', value: BODY_TYPE_NONE }, @@ -229,6 +233,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }: [ activeRequest, authTab, + contentType, handleContentTypeChange, headersTab, numParams, @@ -471,3 +476,11 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
); } + +function nameOfContentTypeOr(contentType: string | null, fallback: string) { + const language = languageFromContentType(contentType); + if (language === 'markdown') { + return 'Markdown'; + } + return fallback; +} diff --git a/src-web/components/HttpResponsePane.tsx b/src-web/components/HttpResponsePane.tsx index b328ab53..d8618736 100644 --- a/src-web/components/HttpResponsePane.tsx +++ b/src-web/components/HttpResponsePane.tsx @@ -107,7 +107,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) { > {activeResponse == null ? ( ) : (
diff --git a/src-web/components/LocalImage.tsx b/src-web/components/LocalImage.tsx index 59cf8a93..e39a0e2c 100644 --- a/src-web/components/LocalImage.tsx +++ b/src-web/components/LocalImage.tsx @@ -14,7 +14,6 @@ export function LocalImage({ src: srcPath, className }: Props) { queryKey: ['local-image', srcPath], queryFn: async () => { const p = await resolveResource(srcPath); - console.log("LOADING SRC", srcPath, p) return convertFileSrc(p); }, }); diff --git a/src-web/components/NewSidebar.tsx b/src-web/components/NewSidebar.tsx new file mode 100644 index 00000000..149e8245 --- /dev/null +++ b/src-web/components/NewSidebar.tsx @@ -0,0 +1,450 @@ +import type { + Folder, + GrpcRequest, + HttpRequest, + WebsocketRequest, + Workspace, +} from '@yaakapp-internal/models'; +import { + duplicateModel, + foldersAtom, + getModel, + grpcConnectionsAtom, + httpResponsesAtom, + patchModel, + websocketConnectionsAtom, + workspacesAtom, +} from '@yaakapp-internal/models'; +import classNames from 'classnames'; +import { atom, useAtomValue } from 'jotai'; +import { selectAtom } from 'jotai/utils'; +import React, { useCallback, useEffect, useMemo, useRef } from 'react'; +import { moveToWorkspace } from '../commands/moveToWorkspace'; +import { openFolderSettings } from '../commands/openFolderSettings'; +import { activeCookieJarAtom } from '../hooks/useActiveCookieJar'; +import { activeEnvironmentAtom } from '../hooks/useActiveEnvironment'; +import { activeFolderIdAtom } from '../hooks/useActiveFolderId'; +import { activeRequestIdAtom } from '../hooks/useActiveRequestId'; +import { activeWorkspaceAtom } from '../hooks/useActiveWorkspace'; +import { allRequestsAtom } from '../hooks/useAllRequests'; +import { getGrpcRequestActions } from '../hooks/useGrpcRequestActions'; +import { useHotKey } from '../hooks/useHotKey'; +import { getHttpRequestActions } from '../hooks/useHttpRequestActions'; +import { sendAnyHttpRequest } from '../hooks/useSendAnyHttpRequest'; +import { useSidebarHidden } from '../hooks/useSidebarHidden'; +import { deepEqualAtom } from '../lib/atoms'; +import { deleteModelWithConfirm } from '../lib/deleteModelWithConfirm'; +import { jotaiStore } from '../lib/jotai'; +import { renameModelWithPrompt } from '../lib/renameModelWithPrompt'; +import { resolvedModelName } from '../lib/resolvedModelName'; +import { isSidebarFocused } from '../lib/scopes'; +import { navigateToRequestOrFolderOrWorkspace } from '../lib/setWorkspaceSearchParams'; +import { invokeCmd } from '../lib/tauri'; +import type { ContextMenuProps, DropdownItem } from './core/Dropdown'; +import { HttpMethodTag } from './core/HttpMethodTag'; +import { HttpStatusTag } from './core/HttpStatusTag'; +import { Icon } from './core/Icon'; +import { LoadingIcon } from './core/LoadingIcon'; +import { isSelectedFamily } from './core/tree/atoms'; +import type { TreeNode } from './core/tree/common'; +import type { TreeHandle, TreeProps } from './core/tree/Tree'; +import { Tree } from './core/tree/Tree'; +import type { TreeItemProps } from './core/tree/TreeItem'; +import { GitDropdown } from './GitDropdown'; + +type Model = Workspace | Folder | HttpRequest | GrpcRequest | WebsocketRequest; + +const opacitySubtle = 'opacity-80'; + +function getItemKey(item: Model) { + const responses = jotaiStore.get(httpResponsesAtom); + const latestResponse = responses.find((r) => r.requestId === item.id) ?? null; + const url = 'url' in item ? item.url : 'n/a'; + const method = 'method' in item ? item.method : 'n/a'; + return [ + item.id, + item.name, + url, + method, + latestResponse?.elapsed, + latestResponse?.id ?? 'n/a', + ].join('::'); +} + +function SidebarLeftSlot({ treeId, item }: { treeId: string; item: Model }) { + if (item.model === 'folder') { + return ; + } else if (item.model === 'workspace') { + return null; + } else { + const isSelected = jotaiStore.get(isSelectedFamily({ treeId, itemId: item.id })); + return ( + + ); + } +} + +function SidebarInnerItem({ item }: { treeId: string; item: Model }) { + const response = useAtomValue( + useMemo( + () => + selectAtom( + atom((get) => [ + ...get(grpcConnectionsAtom), + ...get(httpResponsesAtom), + ...get(websocketConnectionsAtom), + ]), + (responses) => responses.find((r) => r.requestId === item.id), + (a, b) => a?.state === b?.state && a?.id === b?.id, // Only update when the response state changes updated + ), + [item.id], + ), + ); + + return ( +
+
{resolvedModelName(item)}
+ {response != null && ( +
+ {response.state !== 'closed' ? ( + + ) : response.model === 'http_response' ? ( + + ) : null} +
+ )} +
+ ); +} + +function NewSidebar({ className }: { className?: string }) { + const [hidden, setHidden] = useSidebarHidden(); + const tree = useAtomValue(sidebarTreeAtom); + const activeWorkspaceId = useAtomValue(activeWorkspaceAtom)?.id; + const treeId = 'tree.' + (activeWorkspaceId ?? 'unknown'); + const wrapperRef = useRef(null); + const treeRef = useRef(null); + + const focusActiveItem = useCallback(() => { + treeRef.current?.focus(); + }, []); + + useHotKey('sidebar.focus', async function focusHotkey() { + // Hide the sidebar if it's already focused + if (!hidden && isSidebarFocused()) { + await setHidden(true); + return; + } + + // Show the sidebar if it's hidden + if (hidden) { + await setHidden(false); + } + + // Select the 0th index on focus if none selected + focusActiveItem(); + }); + + const handleDragEnd = useCallback(async function handleDragEnd({ + items, + parent, + children, + insertAt, + }: { + items: Model[]; + parent: Model; + children: Model[]; + insertAt: number; + }) { + const prev = children[insertAt - 1] as Exclude; + const next = children[insertAt] as Exclude; + const folderId = parent.model === 'folder' ? parent.id : null; + + const beforePriority = prev?.sortPriority ?? 0; + const afterPriority = next?.sortPriority ?? 0; + const shouldUpdateAll = afterPriority - beforePriority < 1; + + try { + if (shouldUpdateAll) { + // Add items to children at insertAt + children.splice(insertAt, 0, ...items); + await Promise.all( + children.map((m, i) => patchModel(m, { sortPriority: i * 1000, folderId })), + ); + } else { + const range = afterPriority - beforePriority; + const increment = range / (items.length + 2); + await Promise.all( + items.map((m, i) => + // Spread item sortPriority out over before/after range + patchModel(m, { sortPriority: beforePriority + (i + 1) * increment, folderId }), + ), + ); + } + } catch (e) { + console.error(e); + } + }, []); + + const handleTreeRefInit = useCallback((n: TreeHandle) => { + treeRef.current = n; + if (n == null) return; + const activeId = jotaiStore.get(activeIdAtom); + if (activeId == null) return; + n.selectItem(activeId); + }, []); + + useEffect(() => { + return jotaiStore.sub(activeIdAtom, () => { + const activeId = jotaiStore.get(activeIdAtom); + if (activeId == null) return; + treeRef.current?.selectItem(activeId); + }); + }, []); + + if (tree == null || hidden) { + return null; + } + + return ( + + ); +} + +export default NewSidebar; + +const activeIdAtom = atom((get) => { + return get(activeRequestIdAtom) || get(activeFolderIdAtom); +}); + +function getEditOptions( + item: Model, +): ReturnType['getEditOptions']>> { + return { + onChange: handleSubmitEdit, + defaultValue: resolvedModelName(item), + placeholder: item.name, + }; +} + +async function handleSubmitEdit(item: Model, text: string) { + await patchModel(item, { name: text }); +} + +function handleActivate(item: Model) { + // TODO: Add folder layout support + if (item.model !== 'folder' && item.model !== 'workspace') { + navigateToRequestOrFolderOrWorkspace(item.id, item.model); + } +} + +const allPotentialChildrenAtom = atom((get) => { + const requests = get(allRequestsAtom); + const folders = get(foldersAtom); + return [...requests, ...folders]; +}); + +const memoAllPotentialChildrenAtom = deepEqualAtom(allPotentialChildrenAtom); + +const sidebarTreeAtom = atom((get) => { + const allModels = get(memoAllPotentialChildrenAtom); + const activeWorkspace = get(activeWorkspaceAtom); + + const childrenMap: Record[]> = {}; + for (const item of allModels) { + if ('folderId' in item && item.folderId == null) { + childrenMap[item.workspaceId] = childrenMap[item.workspaceId] ?? []; + childrenMap[item.workspaceId]!.push(item); + } else if ('folderId' in item && item.folderId != null) { + childrenMap[item.folderId] = childrenMap[item.folderId] ?? []; + childrenMap[item.folderId]!.push(item); + } + } + + const treeParentMap: Record> = {}; + + if (activeWorkspace == null) { + return null; + } + + // Put requests and folders into a tree structure + const next = (node: TreeNode): TreeNode => { + const childItems = childrenMap[node.item.id] ?? []; + + // Recurse to children + childItems.sort((a, b) => a.sortPriority - b.sortPriority); + if (node.item.model === 'folder' || node.item.model === 'workspace') { + node.children = node.children ?? []; + for (const item of childItems) { + treeParentMap[item.id] = node; + node.children.push(next({ item, parent: node })); + } + } + + return node; + }; + + return next({ + item: activeWorkspace, + children: [], + parent: null, + }); +}); + +const actions = { + 'sidebar.delete_selected_item': async function (items: Model[]) { + await deleteModelWithConfirm(items); + }, + 'model.duplicate': async function (items: Model[]) { + if (items.length === 1) { + const item = items[0]!; + const newId = await duplicateModel(item); + navigateToRequestOrFolderOrWorkspace(newId, item.model); + } else { + await Promise.all(items.map(duplicateModel)); + } + }, + 'request.send': async function (items: Model[]) { + await Promise.all( + items.filter((i) => i.model === 'http_request').map((i) => sendAnyHttpRequest.mutate(i.id)), + ); + }, +} as const; + +const hotkeys: TreeProps['hotkeys'] = { + priority: 10, // So these ones take precedence over global hotkeys when the sidebar is focused + actions, + enable: () => isSidebarFocused(), +}; + +async function getContextMenu(items: Model[]): Promise { + const child = items[0]; + if (child == null) return []; + const workspaces = jotaiStore.get(workspacesAtom); + const onlyHttpRequests = items.every((i) => i.model === 'http_request'); + + const initialItems: ContextMenuProps['items'] = [ + { + label: 'Folder Settings', + hidden: !(items.length === 1 && child.model === 'folder'), + leftSlot: , + onSelect: () => openFolderSettings(child.id), + }, + { + label: 'Send All', + hidden: !(items.length === 1 && child.model === 'folder'), + leftSlot: , + onSelect: () => { + const environment = jotaiStore.get(activeEnvironmentAtom); + const cookieJar = jotaiStore.get(activeCookieJarAtom); + invokeCmd('cmd_send_folder', { + folderId: child.id, + environmentId: environment?.id, + cookieJarId: cookieJar?.id, + }); + }, + }, + { + label: 'Send', + hotKeyAction: 'request.send', + hotKeyLabelOnly: true, + hidden: !onlyHttpRequests, + leftSlot: , + onSelect: () => actions['request.send'](items), + }, + ...(items.length === 1 && child.model === 'http_request' + ? await getHttpRequestActions() + : [] + ).map((a) => ({ + label: a.label, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + leftSlot: , + onSelect: async () => { + const request = getModel('http_request', child.id); + if (request != null) await a.call(request); + }, + })), + ...(items.length === 1 && child.model === 'grpc_request' + ? await getGrpcRequestActions() + : [] + ).map((a) => ({ + label: a.label, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + leftSlot: , + onSelect: async () => { + const request = getModel('grpc_request', child.id); + if (request != null) await a.call(request); + }, + })), + ]; + + const menuItems: ContextMenuProps['items'] = [ + ...initialItems, + { type: 'separator', hidden: initialItems.filter((v) => !v.hidden).length === 0 }, + { + label: 'Rename', + leftSlot: , + hidden: items.length > 1, + onSelect: async () => { + const request = getModel( + ['folder', 'http_request', 'grpc_request', 'websocket_request'], + child.id, + ); + await renameModelWithPrompt(request); + }, + }, + { + label: 'Duplicate', + hotKeyAction: 'model.duplicate', + hotKeyLabelOnly: true, // Would trigger for every request (bad) + leftSlot: , + onSelect: () => actions['model.duplicate'](items), + }, + { + label: 'Move', + leftSlot: , + hidden: + workspaces.length <= 1 || + items.length > 1 || + child.model === 'folder' || + child.model === 'workspace', + onSelect: () => { + if (child.model === 'folder' || child.model === 'workspace') return; + moveToWorkspace.mutate(child); + }, + }, + { + color: 'danger', + label: 'Delete', + hotKeyAction: 'sidebar.delete_selected_item', + hotKeyLabelOnly: true, + leftSlot: , + onSelect: () => actions['sidebar.delete_selected_item'](items), + }, + ]; + return menuItems; +} diff --git a/src-web/components/Overlay.tsx b/src-web/components/Overlay.tsx index 3452d6b2..f60eac44 100644 --- a/src-web/components/Overlay.tsx +++ b/src-web/components/Overlay.tsx @@ -2,7 +2,7 @@ import classNames from 'classnames'; import { FocusTrap } from 'focus-trap-react'; import * as m from 'motion/react-m'; import type { ReactNode } from 'react'; -import React from 'react'; +import React, { useRef } from 'react'; import { Portal } from './Portal'; interface Props { @@ -32,6 +32,8 @@ export function Overlay({ noBackdrop, children, }: Props) { + const containerRef = useRef(null); + if (noBackdrop) { return ( @@ -44,15 +46,33 @@ export function Overlay({ ); } + return ( {open && ( containerRef.current!, // always have a target + initialFocus: () => + // Doing this explicitly seems to work better than the default behavior for some reason + containerRef.current?.querySelector( + [ + 'a[href]', + 'input:not([disabled])', + 'select:not([disabled])', + 'textarea:not([disabled])', + 'button:not([disabled])', + '[tabindex]:not([tabindex="-1"])', + '[contenteditable]:not([contenteditable="false"])', + ].join(', '), + ) ?? undefined, }} > (null); + + // Listen for dropped files on the element + // NOTE: This doesn't work for Windows since native drag-n-drop can't work at the same tmie + // as browser drag-n-drop. + useEffect(() => { + let unlisten: (() => void) | undefined; + const setup = async () => { + const webview = getCurrentWebviewWindow(); + unlisten = await webview.onDragDropEvent((event) => { + if (event.payload.type === 'over') { + const p = event.payload.position; + const r = ref.current?.getBoundingClientRect(); + if (r == null) return; + const isOver = p.x >= r.left && p.x <= r.right && p.y >= r.top && p.y <= r.bottom; + console.log('IS OVER', isOver); + setIsHovering(isOver); + } else if (event.payload.type === 'drop' && isHovering) { + console.log('User dropped', event.payload.paths); + const p = event.payload.paths[0]; + if (p) onChange({ filePath: p, contentType: null }); + setIsHovering(false); + } else { + console.log('File drop cancelled'); + setIsHovering(false); + } + }); + }; + setup().catch(console.error); + return () => { + if (unlisten) unlisten(); + }; + }, [isHovering, onChange]); return ( -
+
{label && (