mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-04-14 04:59:41 +02:00
New sidebar and folder view (#263)
This commit is contained in:
49
package-lock.json
generated
49
package-lock.json
generated
@@ -820,6 +820,45 @@
|
|||||||
"integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==",
|
"integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.25.6",
|
"version": "0.25.6",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.6.tgz",
|
"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",
|
"version": "16.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-16.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-dnd-touch-backend/-/react-dnd-touch-backend-16.0.1.tgz",
|
||||||
"integrity": "sha512-Wu3dw5aDJmOGw8WjH1I1/yTH+vlXEL4vmjk5p+MHxP8HuHJS1lAGeIdG/hze1AvNeXWo/JgULV87LyQOr+r5jw==",
|
"integrity": "sha512-NonoCABzzjyWGZuDxSG77dbgMZ2Wad7eQiCd/ECtsR2/NBLTjGksPUx9UPezZ1nQ/L7iD130Tz3RUshL/ClKLA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@react-dnd/invariant": "^4.0.1",
|
||||||
"dnd-core": "^16.0.1"
|
"dnd-core": "^16.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -19089,6 +19129,7 @@
|
|||||||
"@codemirror/lang-xml": "^6.1.0",
|
"@codemirror/lang-xml": "^6.1.0",
|
||||||
"@codemirror/language": "^6.11.0",
|
"@codemirror/language": "^6.11.0",
|
||||||
"@codemirror/search": "^6.5.11",
|
"@codemirror/search": "^6.5.11",
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@gilbarbara/deep-equal": "^0.3.1",
|
"@gilbarbara/deep-equal": "^0.3.1",
|
||||||
"@lezer/highlight": "^1.1.3",
|
"@lezer/highlight": "^1.1.3",
|
||||||
"@lezer/lr": "^1.3.3",
|
"@lezer/lr": "^1.3.3",
|
||||||
@@ -19129,7 +19170,7 @@
|
|||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-colorful": "^5.6.1",
|
"react-colorful": "^5.6.1",
|
||||||
"react-dnd": "^16.0.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-dom": "^19.1.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-pdf": "^10.0.1",
|
"react-pdf": "^10.0.1",
|
||||||
|
|||||||
@@ -1007,6 +1007,35 @@ async fn cmd_save_response<R: Runtime>(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn cmd_send_folder<R: Runtime>(
|
||||||
|
app_handle: AppHandle<R>,
|
||||||
|
window: WebviewWindow<R>,
|
||||||
|
environment_id: Option<String>,
|
||||||
|
cookie_jar_id: Option<String>,
|
||||||
|
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]
|
#[tauri::command]
|
||||||
async fn cmd_send_http_request<R: Runtime>(
|
async fn cmd_send_http_request<R: Runtime>(
|
||||||
app_handle: AppHandle<R>,
|
app_handle: AppHandle<R>,
|
||||||
@@ -1386,6 +1415,7 @@ pub fn run() {
|
|||||||
cmd_save_response,
|
cmd_save_response,
|
||||||
cmd_send_ephemeral_request,
|
cmd_send_ephemeral_request,
|
||||||
cmd_send_http_request,
|
cmd_send_http_request,
|
||||||
|
cmd_send_folder,
|
||||||
cmd_template_functions,
|
cmd_template_functions,
|
||||||
cmd_template_tokens_to_string,
|
cmd_template_tokens_to_string,
|
||||||
//
|
//
|
||||||
@@ -1511,14 +1541,17 @@ fn monitor_plugin_events<R: Runtime>(app_handle: &AppHandle<R>) {
|
|||||||
Ok(None) => return,
|
Ok(None) => return,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("Failed to handle plugin event: {e:?}");
|
warn!("Failed to handle plugin event: {e:?}");
|
||||||
let _ = app_handle.emit("show_toast", InternalEventPayload::ShowToastRequest(ShowToastRequest {
|
let _ = app_handle.emit(
|
||||||
message: e.to_string(),
|
"show_toast",
|
||||||
color: Some(Color::Danger),
|
InternalEventPayload::ShowToastRequest(ShowToastRequest {
|
||||||
icon: None,
|
message: e.to_string(),
|
||||||
timeout: Some(30000),
|
color: Some(Color::Danger),
|
||||||
}));
|
icon: None,
|
||||||
|
timeout: Some(30000),
|
||||||
|
}),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
},
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let plugin_manager: State<'_, PluginManager> = app_handle.state();
|
let plugin_manager: State<'_, PluginManager> = app_handle.state();
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::error::Result;
|
||||||
use crate::window_menu::app_menu;
|
use crate::window_menu::app_menu;
|
||||||
use log::{info, warn};
|
use log::{info, warn};
|
||||||
use rand::random;
|
use rand::random;
|
||||||
@@ -6,7 +7,6 @@ use tauri::{
|
|||||||
};
|
};
|
||||||
use tauri_plugin_opener::OpenerExt;
|
use tauri_plugin_opener::OpenerExt;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use crate::error::Result;
|
|
||||||
|
|
||||||
const DEFAULT_WINDOW_WIDTH: f64 = 1100.0;
|
const DEFAULT_WINDOW_WIDTH: f64 = 1100.0;
|
||||||
const DEFAULT_WINDOW_HEIGHT: f64 = 600.0;
|
const DEFAULT_WINDOW_HEIGHT: f64 = 600.0;
|
||||||
@@ -49,7 +49,6 @@ pub(crate) fn create_window<R: Runtime>(
|
|||||||
.resizable(true)
|
.resizable(true)
|
||||||
.visible(false) // To prevent theme flashing, the frontend code calls show() immediately after configuring the theme
|
.visible(false) // To prevent theme flashing, the frontend code calls show() immediately after configuring the theme
|
||||||
.fullscreen(false)
|
.fullscreen(false)
|
||||||
.disable_drag_drop_handler() // Required for frontend Dnd on windows
|
|
||||||
.min_inner_size(MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT);
|
.min_inner_size(MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT);
|
||||||
|
|
||||||
if let Some(key) = config.data_dir_key {
|
if let Some(key) = config.data_dir_key {
|
||||||
@@ -216,10 +215,10 @@ pub(crate) fn create_child_window(
|
|||||||
) -> Result<WebviewWindow> {
|
) -> Result<WebviewWindow> {
|
||||||
let app_handle = parent_window.app_handle();
|
let app_handle = parent_window.app_handle();
|
||||||
let label = format!("{OTHER_WINDOW_PREFIX}_{label}");
|
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::<f64>(scale_factor);
|
let current_pos = parent_window.inner_position()?.to_logical::<f64>(scale_factor);
|
||||||
let current_size = parent_window.inner_size().unwrap().to_logical::<f64>(scale_factor);
|
let current_size = parent_window.inner_size()?.to_logical::<f64>(scale_factor);
|
||||||
|
|
||||||
// Position the new window in the middle of the parent
|
// Position the new window in the middle of the parent
|
||||||
let position = (
|
let position = (
|
||||||
|
|||||||
@@ -81,11 +81,12 @@ export function getAnyModel(id: string): AnyModel | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getModel<M extends AnyModel['model'], T extends ExtractModel<AnyModel, M>>(
|
export function getModel<M extends AnyModel['model'], T extends ExtractModel<AnyModel, M>>(
|
||||||
modelType: M | M[],
|
modelType: M | ReadonlyArray<M>,
|
||||||
id: string,
|
id: string,
|
||||||
): T | null {
|
): T | null {
|
||||||
let data = mustStore().get(modelStoreDataAtom);
|
let data = mustStore().get(modelStoreDataAtom);
|
||||||
for (const t of Array.isArray(modelType) ? modelType : [modelType]) {
|
const types: ReadonlyArray<M> = Array.isArray(modelType) ? modelType : [modelType];
|
||||||
|
for (const t of types) {
|
||||||
let v = data[t][id];
|
let v = data[t][id];
|
||||||
if (v?.model === t) return v as T;
|
if (v?.model === t) return v as T;
|
||||||
}
|
}
|
||||||
@@ -139,7 +140,7 @@ export async function deleteModel<M extends AnyModel['model'], T extends Extract
|
|||||||
export function duplicateModelById<
|
export function duplicateModelById<
|
||||||
M extends AnyModel['model'],
|
M extends AnyModel['model'],
|
||||||
T extends ExtractModel<AnyModel, M>,
|
T extends ExtractModel<AnyModel, M>,
|
||||||
>(modelType: M | M[], id: string) {
|
>(modelType: M | ReadonlyArray<M>, id: string) {
|
||||||
let model = getModel<M, T>(modelType, id);
|
let model = getModel<M, T>(modelType, id);
|
||||||
return duplicateModel(model);
|
return duplicateModel(model);
|
||||||
}
|
}
|
||||||
@@ -150,6 +151,8 @@ export function duplicateModel<M extends AnyModel['model'], T extends ExtractMod
|
|||||||
if (model == null) {
|
if (model == null) {
|
||||||
throw new Error('Failed to delete null model');
|
throw new Error('Failed to delete null model');
|
||||||
}
|
}
|
||||||
|
if ('sortPriority' in model) model.sortPriority = model.sortPriority + 0.0001;
|
||||||
|
|
||||||
return invoke<string>('plugin:yaak-models|duplicate', { model });
|
return invoke<string>('plugin:yaak-models|duplicate', { model });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use crate::db_context::DbContext;
|
use crate::db_context::DbContext;
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::models::{HttpRequest, HttpRequestHeader, HttpRequestIden};
|
use crate::models::{Folder, FolderIden, HttpRequest, HttpRequestHeader, HttpRequestIden};
|
||||||
use crate::util::UpdateSource;
|
use crate::util::UpdateSource;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
@@ -89,4 +89,18 @@ impl<'a> DbContext<'a> {
|
|||||||
|
|
||||||
Ok(headers)
|
Ok(headers)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn list_http_requests_for_folder_recursive(
|
||||||
|
&self,
|
||||||
|
folder_id: &str,
|
||||||
|
) -> Result<Vec<HttpRequest>> {
|
||||||
|
let mut children = Vec::new();
|
||||||
|
for m in self.find_many::<Folder>(FolderIden::FolderId, folder_id, None)? {
|
||||||
|
children.extend(self.list_http_requests_for_folder_recursive(&m.id)?);
|
||||||
|
}
|
||||||
|
for m in self.find_many::<HttpRequest>(FolderIden::FolderId, folder_id, None)? {
|
||||||
|
children.push(m);
|
||||||
|
}
|
||||||
|
Ok(children)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
export * from './bindings/parser';
|
export * from './bindings/parser';
|
||||||
import { Tokens } 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) {
|
export function parseTemplate(template: string) {
|
||||||
return parse_template(template) as Tokens;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
166
src-tauri/yaak-templates/src/escape.rs
Normal file
166
src-tauri/yaak-templates/src/escape.rs
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
pub fn escape_template(text: &str) -> String {
|
||||||
|
let mut result = String::with_capacity(text.len());
|
||||||
|
let chars: Vec<char> = 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<char> = 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
|
pub mod error;
|
||||||
|
pub mod escape;
|
||||||
pub mod format;
|
pub mod format;
|
||||||
pub mod parser;
|
pub mod parser;
|
||||||
pub mod renderer;
|
pub mod renderer;
|
||||||
pub mod error;
|
|
||||||
pub mod wasm;
|
pub mod wasm;
|
||||||
|
|
||||||
pub use parser::*;
|
pub use parser::*;
|
||||||
|
|||||||
@@ -170,7 +170,13 @@ impl Parser {
|
|||||||
let start_pos = self.pos;
|
let start_pos = self.pos;
|
||||||
|
|
||||||
while self.pos < self.chars.len() {
|
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;
|
let start_curr = self.pos;
|
||||||
if let Some(t) = self.parse_tag()? {
|
if let Some(t) = self.parse_tag()? {
|
||||||
self.push_token(t);
|
self.push_token(t);
|
||||||
@@ -490,6 +496,39 @@ mod tests {
|
|||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::*;
|
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]
|
#[test]
|
||||||
fn var_simple() -> Result<()> {
|
fn var_simple() -> Result<()> {
|
||||||
let mut p = Parser::new("${[ foo ]}");
|
let mut p = Parser::new("${[ foo ]}");
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::Parser;
|
use crate::{escape, Parser};
|
||||||
use wasm_bindgen::prelude::wasm_bindgen;
|
use wasm_bindgen::prelude::wasm_bindgen;
|
||||||
use wasm_bindgen::JsValue;
|
use wasm_bindgen::JsValue;
|
||||||
|
|
||||||
@@ -7,4 +7,16 @@ use wasm_bindgen::JsValue;
|
|||||||
pub fn parse_template(template: &str) -> Result<JsValue> {
|
pub fn parse_template(template: &str) -> Result<JsValue> {
|
||||||
let tokens = Parser::new(template).parse()?;
|
let tokens = Parser::new(template).parse()?;
|
||||||
Ok(serde_wasm_bindgen::to_value(&tokens).unwrap())
|
Ok(serde_wasm_bindgen::to_value(&tokens).unwrap())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn escape_template(template: &str) -> Result<JsValue> {
|
||||||
|
let escaped = escape::escape_template(template);
|
||||||
|
Ok(serde_wasm_bindgen::to_value(&escaped).unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn unescape_template(template: &str) -> Result<JsValue> {
|
||||||
|
let escaped = escape::unescape_template(template);
|
||||||
|
Ok(serde_wasm_bindgen::to_value(&escaped).unwrap())
|
||||||
|
}
|
||||||
|
|||||||
28
src-web/commands/moveToWorkspace.tsx
Normal file
28
src-web/commands/moveToWorkspace.tsx
Normal file
@@ -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 }) => (
|
||||||
|
<MoveToWorkspaceDialog
|
||||||
|
onDone={hide}
|
||||||
|
request={request}
|
||||||
|
activeWorkspaceId={activeWorkspaceId}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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 type { FolderSettingsTab } from '../components/FolderSettingsDialog';
|
||||||
import { FolderSettingsDialog } from '../components/FolderSettingsDialog';
|
import { FolderSettingsDialog } from '../components/FolderSettingsDialog';
|
||||||
import { showDialog } from '../lib/dialog';
|
import { showDialog } from '../lib/dialog';
|
||||||
|
import { resolvedModelName } from '../lib/resolvedModelName';
|
||||||
|
|
||||||
export function openFolderSettings(folderId: string, tab?: FolderSettingsTab) {
|
export function openFolderSettings(folderId: string, tab?: FolderSettingsTab) {
|
||||||
|
const folder = getModel('folder', folderId);
|
||||||
showDialog({
|
showDialog({
|
||||||
id: 'folder-settings',
|
id: 'folder-settings',
|
||||||
title: 'Folder Settings',
|
title: (
|
||||||
|
<HStack space={2} alignItems="center">
|
||||||
|
<Icon icon="folder_cog" size="xl" color="secondary" />
|
||||||
|
{resolvedModelName(folder)}
|
||||||
|
</HStack>
|
||||||
|
),
|
||||||
size: 'lg',
|
size: 'lg',
|
||||||
className: 'h-[50rem]',
|
className: 'h-[50rem]',
|
||||||
noPadding: true,
|
noPadding: true,
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ import { useAllRequests } from '../hooks/useAllRequests';
|
|||||||
import { useCreateWorkspace } from '../hooks/useCreateWorkspace';
|
import { useCreateWorkspace } from '../hooks/useCreateWorkspace';
|
||||||
import { useDebouncedState } from '../hooks/useDebouncedState';
|
import { useDebouncedState } from '../hooks/useDebouncedState';
|
||||||
import { useEnvironmentsBreakdown } from '../hooks/useEnvironmentsBreakdown';
|
import { useEnvironmentsBreakdown } from '../hooks/useEnvironmentsBreakdown';
|
||||||
|
import { useGrpcRequestActions } from '../hooks/useGrpcRequestActions';
|
||||||
import type { HotkeyAction } from '../hooks/useHotKey';
|
import type { HotkeyAction } from '../hooks/useHotKey';
|
||||||
import { useHotKey } from '../hooks/useHotKey';
|
|
||||||
import { useHttpRequestActions } from '../hooks/useHttpRequestActions';
|
import { useHttpRequestActions } from '../hooks/useHttpRequestActions';
|
||||||
import { useRecentEnvironments } from '../hooks/useRecentEnvironments';
|
import { useRecentEnvironments } from '../hooks/useRecentEnvironments';
|
||||||
import { useRecentRequests } from '../hooks/useRecentRequests';
|
import { useRecentRequests } from '../hooks/useRecentRequests';
|
||||||
@@ -61,6 +61,7 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
|
|||||||
const [selectedItemKey, setSelectedItemKey] = useState<string | null>(null);
|
const [selectedItemKey, setSelectedItemKey] = useState<string | null>(null);
|
||||||
const activeEnvironment = useActiveEnvironment();
|
const activeEnvironment = useActiveEnvironment();
|
||||||
const httpRequestActions = useHttpRequestActions();
|
const httpRequestActions = useHttpRequestActions();
|
||||||
|
const grpcRequestActions = useGrpcRequestActions();
|
||||||
const workspaceId = useAtomValue(activeWorkspaceIdAtom);
|
const workspaceId = useAtomValue(activeWorkspaceIdAtom);
|
||||||
const workspaces = useAtomValue(workspacesAtom);
|
const workspaces = useAtomValue(workspacesAtom);
|
||||||
const { baseEnvironment, subEnvironments } = useEnvironmentsBreakdown();
|
const { baseEnvironment, subEnvironments } = useEnvironmentsBreakdown();
|
||||||
@@ -90,7 +91,7 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
|
|||||||
onSelect: createWorkspace,
|
onSelect: createWorkspace,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'http_request.create',
|
key: 'model.create',
|
||||||
label: 'Create HTTP Request',
|
label: 'Create HTTP Request',
|
||||||
onSelect: () => createRequestAndNavigate({ model: 'http_request', workspaceId }),
|
onSelect: () => createRequestAndNavigate({ model: 'http_request', workspaceId }),
|
||||||
},
|
},
|
||||||
@@ -142,8 +143,8 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
|
|||||||
|
|
||||||
if (activeRequest?.model === 'http_request') {
|
if (activeRequest?.model === 'http_request') {
|
||||||
commands.push({
|
commands.push({
|
||||||
key: 'http_request.send',
|
key: 'request.send',
|
||||||
action: 'http_request.send',
|
action: 'request.send',
|
||||||
label: 'Send Request',
|
label: 'Send Request',
|
||||||
onSelect: () => sendRequest(activeRequest.id),
|
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) {
|
if (activeRequest != null) {
|
||||||
commands.push({
|
commands.push({
|
||||||
key: 'http_request.rename',
|
key: 'http_request.rename',
|
||||||
@@ -182,6 +194,7 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
|
|||||||
activeRequest,
|
activeRequest,
|
||||||
baseEnvironment,
|
baseEnvironment,
|
||||||
createWorkspace,
|
createWorkspace,
|
||||||
|
grpcRequestActions,
|
||||||
httpRequestActions,
|
httpRequestActions,
|
||||||
sendRequest,
|
sendRequest,
|
||||||
setSidebarHidden,
|
setSidebarHidden,
|
||||||
@@ -369,7 +382,6 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
|
|||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
(e: KeyboardEvent<HTMLInputElement>) => {
|
(e: KeyboardEvent<HTMLInputElement>) => {
|
||||||
const index = filteredAllItems.findIndex((v) => v.key === selectedItem?.key);
|
const index = filteredAllItems.findIndex((v) => v.key === selectedItem?.key);
|
||||||
|
|
||||||
if (e.key === 'ArrowDown' || (e.ctrlKey && e.key === 'n')) {
|
if (e.key === 'ArrowDown' || (e.ctrlKey && e.key === 'n')) {
|
||||||
const next = filteredAllItems[index + 1] ?? filteredAllItems[0];
|
const next = filteredAllItems[index + 1] ?? filteredAllItems[0];
|
||||||
setSelectedItemKey(next?.key ?? null);
|
setSelectedItemKey(next?.key ?? null);
|
||||||
@@ -417,9 +429,7 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
|
|||||||
active={v.key === selectedItem?.key}
|
active={v.key === selectedItem?.key}
|
||||||
key={v.key}
|
key={v.key}
|
||||||
onClick={() => handleSelectAndClose(v.onSelect)}
|
onClick={() => handleSelectAndClose(v.onSelect)}
|
||||||
rightSlot={
|
rightSlot={v.action && <CommandPaletteAction action={v.action} />}
|
||||||
v.action && <CommandPaletteAction action={v.action} onAction={v.onSelect} />
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{v.label}
|
{v.label}
|
||||||
</CommandPaletteItem>
|
</CommandPaletteItem>
|
||||||
@@ -465,13 +475,6 @@ function CommandPaletteItem({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CommandPaletteAction({
|
function CommandPaletteAction({ action }: { action: HotkeyAction }) {
|
||||||
action,
|
|
||||||
onAction,
|
|
||||||
}: {
|
|
||||||
action: HotkeyAction;
|
|
||||||
onAction: () => void;
|
|
||||||
}) {
|
|
||||||
useHotKey(action, onAction);
|
|
||||||
return <HotKey className="ml-auto" action={action} />;
|
return <HotKey className="ml-auto" action={action} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
|
|||||||
render() {
|
render() {
|
||||||
if (this.state.hasError) {
|
if (this.state.hasError) {
|
||||||
return (
|
return (
|
||||||
<Banner color="danger" className="flex items-center gap-2">
|
<Banner color="danger" className="flex items-center gap-2 overflow-auto">
|
||||||
<div>
|
<div>
|
||||||
Error rendering <InlineCode>{this.props.name}</InlineCode> component
|
Error rendering <InlineCode>{this.props.name}</InlineCode> component
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
192
src-web/components/FolderLayout.tsx
Normal file
192
src-web/components/FolderLayout.tsx
Normal file
@@ -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 (
|
||||||
|
<div style={style} className="p-6 pt-4 overflow-y-auto @container">
|
||||||
|
<HStack space={2} alignItems="center">
|
||||||
|
<Icon icon="folder" size="xl" color="secondary" />
|
||||||
|
<Heading level={1}>{resolvedModelName(folder)}</Heading>
|
||||||
|
<HStack className="ml-auto" alignItems="center">
|
||||||
|
<Button rightSlot={<Icon icon="send_horizontal" />} color="secondary" size="sm" variant="border">Send All</Button>
|
||||||
|
</HStack>
|
||||||
|
</HStack>
|
||||||
|
<Separator className="mt-3 mb-8" />
|
||||||
|
<div className="grid grid-cols-1 @lg:grid-cols-2 @4xl:grid-cols-3 gap-4 min-w-0">
|
||||||
|
{children.map((child) => (
|
||||||
|
<ChildCard key={child.id} child={child} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChildCard({ child }: { child: Folder | HttpRequest | GrpcRequest | WebsocketRequest }) {
|
||||||
|
let card;
|
||||||
|
if (child.model === 'folder') {
|
||||||
|
card = <FolderCard folder={child} />;
|
||||||
|
} else if (child.model === 'http_request') {
|
||||||
|
card = <HttpRequestCard request={child} />;
|
||||||
|
} else if (child.model === 'grpc_request') {
|
||||||
|
card = <RequestCard request={child} />;
|
||||||
|
} else if (child.model === 'websocket_request') {
|
||||||
|
card = <RequestCard request={child} />;
|
||||||
|
} else {
|
||||||
|
card = <div>Unknown model {child['model']}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
'rounded-lg bg-surface-highlight p-3 pt-1 border border-border',
|
||||||
|
'flex flex-col gap-3',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<HStack space={2}>
|
||||||
|
{child.model === 'folder' && <Icon icon="folder" size="lg" />}
|
||||||
|
<Heading className="truncate" level={2}>
|
||||||
|
{resolvedModelName(child)}
|
||||||
|
</Heading>
|
||||||
|
<HStack space={0.5} className="ml-auto -mr-1.5">
|
||||||
|
<IconButton
|
||||||
|
color="custom"
|
||||||
|
title="Send Request"
|
||||||
|
size="sm"
|
||||||
|
icon="external_link"
|
||||||
|
className="opacity-70 hover:opacity-100"
|
||||||
|
onClick={navigate}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
color="custom"
|
||||||
|
title="Send Request"
|
||||||
|
size="sm"
|
||||||
|
icon="send_horizontal"
|
||||||
|
className="opacity-70 hover:opacity-100"
|
||||||
|
onClick={() => {
|
||||||
|
sendAnyHttpRequest.mutate(child.id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
</HStack>
|
||||||
|
<div className="text-text-subtle">{card}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FolderCard({ folder }: { folder: Folder }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
color="primary"
|
||||||
|
onClick={async () => {
|
||||||
|
await router.navigate({
|
||||||
|
to: '/workspaces/$workspaceId',
|
||||||
|
params: { workspaceId: folder.workspaceId },
|
||||||
|
search: (prev) => {
|
||||||
|
return { ...prev, request_id: null, folder_id: folder.id };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Open
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RequestCard({ request }: { request: HttpRequest | GrpcRequest | WebsocketRequest }) {
|
||||||
|
return <div>TODO {request.id}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function HttpRequestCard({ request }: { request: HttpRequest }) {
|
||||||
|
const latestResponse = useLatestHttpResponse(request.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-rows-2 grid-cols-[minmax(0,1fr)] gap-2 overflow-hidden">
|
||||||
|
<code className="font-mono text-editor text-info border border-info rounded px-2.5 py-0.5 truncate w-full min-w-0">
|
||||||
|
{request.method} {request.url}
|
||||||
|
</code>
|
||||||
|
{latestResponse ? (
|
||||||
|
<button
|
||||||
|
className="block mr-auto"
|
||||||
|
type="button"
|
||||||
|
tabIndex={-1}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
showDialog({
|
||||||
|
id: 'response-preview',
|
||||||
|
title: 'Response Preview',
|
||||||
|
size: 'md',
|
||||||
|
className: 'h-full',
|
||||||
|
render: () => {
|
||||||
|
return <HttpResponsePane activeRequestId={request.id} />;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<HStack
|
||||||
|
space={2}
|
||||||
|
alignItems="center"
|
||||||
|
className={classNames(
|
||||||
|
'cursor-default select-none',
|
||||||
|
'whitespace-nowrap w-full pl-3 overflow-x-auto font-mono text-sm hide-scrollbars',
|
||||||
|
'font-mono text-editor border rounded px-1.5 py-0.5 truncate w-full',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{latestResponse.state !== 'closed' && <LoadingIcon size="sm" />}
|
||||||
|
<HttpStatusTag showReason response={latestResponse} />
|
||||||
|
<span>•</span>
|
||||||
|
<HttpResponseDurationTag response={latestResponse} />
|
||||||
|
<span>•</span>
|
||||||
|
<SizeTag contentLength={latestResponse.contentLength ?? 0} />
|
||||||
|
</HStack>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div>No Responses</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -68,14 +68,15 @@ export function FolderSettingsDialog({ folderId, tab }: Props) {
|
|||||||
value={activeTab}
|
value={activeTab}
|
||||||
onChangeValue={setActiveTab}
|
onChangeValue={setActiveTab}
|
||||||
label="Folder Settings"
|
label="Folder Settings"
|
||||||
className="px-1.5 pb-2"
|
className="pt-2 pb-2 pl-3 pr-1"
|
||||||
|
layout="horizontal"
|
||||||
addBorders
|
addBorders
|
||||||
tabs={tabs}
|
tabs={tabs}
|
||||||
>
|
>
|
||||||
<TabContent value={TAB_AUTH} className="pt-3 overflow-y-auto h-full px-4">
|
<TabContent value={TAB_AUTH} className="overflow-y-auto h-full px-4">
|
||||||
<HttpAuthenticationEditor model={folder} />
|
<HttpAuthenticationEditor model={folder} />
|
||||||
</TabContent>
|
</TabContent>
|
||||||
<TabContent value={TAB_GENERAL} className="pt-3 overflow-y-auto h-full px-4">
|
<TabContent value={TAB_GENERAL} className="overflow-y-auto h-full px-4">
|
||||||
<VStack space={3} className="pb-3 h-full">
|
<VStack space={3} className="pb-3 h-full">
|
||||||
<Input
|
<Input
|
||||||
label="Folder Name"
|
label="Folder Name"
|
||||||
@@ -93,7 +94,7 @@ export function FolderSettingsDialog({ folderId, tab }: Props) {
|
|||||||
/>
|
/>
|
||||||
</VStack>
|
</VStack>
|
||||||
</TabContent>
|
</TabContent>
|
||||||
<TabContent value={TAB_HEADERS} className="pt-3 overflow-y-auto h-full px-4">
|
<TabContent value={TAB_HEADERS} className="overflow-y-auto h-full px-4">
|
||||||
<HeadersEditor
|
<HeadersEditor
|
||||||
inheritedHeaders={inheritedHeaders}
|
inheritedHeaders={inheritedHeaders}
|
||||||
forceUpdateKey={folder.id}
|
forceUpdateKey={folder.id}
|
||||||
@@ -102,7 +103,7 @@ export function FolderSettingsDialog({ folderId, tab }: Props) {
|
|||||||
stateKey={`headers.${folder.id}`}
|
stateKey={`headers.${folder.id}`}
|
||||||
/>
|
/>
|
||||||
</TabContent>
|
</TabContent>
|
||||||
<TabContent value={TAB_VARIABLES} className="pt-3 overflow-y-auto h-full px-4">
|
<TabContent value={TAB_VARIABLES} className="overflow-y-auto h-full px-4">
|
||||||
{folderEnvironment == null ? (
|
{folderEnvironment == null ? (
|
||||||
<EmptyStateText>
|
<EmptyStateText>
|
||||||
<VStack alignItems="center" space={1.5}>
|
<VStack alignItems="center" space={1.5}>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useSubscribeActiveWorkspaceId } from '../hooks/useActiveWorkspace';
|
import { useSubscribeActiveWorkspaceId } from '../hooks/useActiveWorkspace';
|
||||||
import { useActiveWorkspaceChangedToast } from '../hooks/useActiveWorkspaceChangedToast';
|
import { useActiveWorkspaceChangedToast } from '../hooks/useActiveWorkspaceChangedToast';
|
||||||
|
import { useSubscribeHotKeys } from '../hooks/useHotKey';
|
||||||
import { useSubscribeHttpAuthentication } from '../hooks/useHttpAuthentication';
|
import { useSubscribeHttpAuthentication } from '../hooks/useHttpAuthentication';
|
||||||
import { useSyncFontSizeSetting } from '../hooks/useSyncFontSizeSetting';
|
import { useSyncFontSizeSetting } from '../hooks/useSyncFontSizeSetting';
|
||||||
import { useSyncWorkspaceChildModels } from '../hooks/useSyncWorkspaceChildModels';
|
import { useSyncWorkspaceChildModels } from '../hooks/useSyncWorkspaceChildModels';
|
||||||
@@ -18,6 +19,7 @@ export function GlobalHooks() {
|
|||||||
|
|
||||||
// Other useful things
|
// Other useful things
|
||||||
useActiveWorkspaceChangedToast();
|
useActiveWorkspaceChangedToast();
|
||||||
|
useSubscribeHotKeys();
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ export function GrpcConnectionLayout({ style }: Props) {
|
|||||||
) : grpcEvents.length >= 0 ? (
|
) : grpcEvents.length >= 0 ? (
|
||||||
<GrpcResponsePane activeRequest={activeRequest} methodType={methodType} />
|
<GrpcResponsePane activeRequest={activeRequest} methodType={methodType} />
|
||||||
) : (
|
) : (
|
||||||
<HotKeyList hotkeys={['grpc_request.send', 'sidebar.focus', 'url_bar.focus']} />
|
<HotKeyList hotkeys={['request.send', 'sidebar.focus', 'url_bar.focus']} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -240,7 +240,7 @@ export function GrpcRequestPane({
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="border"
|
variant="border"
|
||||||
title={isStreaming ? 'Connect' : 'Send'}
|
title={isStreaming ? 'Connect' : 'Send'}
|
||||||
hotkeyAction="grpc_request.send"
|
hotkeyAction="request.send"
|
||||||
onClick={isStreaming ? handleSend : handleConnect}
|
onClick={isStreaming ? handleSend : handleConnect}
|
||||||
icon={isStreaming ? 'send_horizontal' : 'arrow_up_down'}
|
icon={isStreaming ? 'send_horizontal' : 'arrow_up_down'}
|
||||||
/>
|
/>
|
||||||
@@ -250,7 +250,7 @@ export function GrpcRequestPane({
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="border"
|
variant="border"
|
||||||
title={methodType === 'unary' ? 'Send' : 'Connect'}
|
title={methodType === 'unary' ? 'Send' : 'Connect'}
|
||||||
hotkeyAction="grpc_request.send"
|
hotkeyAction="request.send"
|
||||||
onClick={isStreaming ? onCancel : handleConnect}
|
onClick={isStreaming ? onCancel : handleConnect}
|
||||||
disabled={methodType === 'no-schema' || methodType === 'no-method'}
|
disabled={methodType === 'no-schema' || methodType === 'no-method'}
|
||||||
icon={
|
icon={
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export function GrpcResponsePane({ style, methodType, activeRequest }: Props) {
|
|||||||
firstSlot={() =>
|
firstSlot={() =>
|
||||||
activeConnection == null ? (
|
activeConnection == null ? (
|
||||||
<HotKeyList
|
<HotKeyList
|
||||||
hotkeys={['http_request.send', 'http_request.create', 'sidebar.focus', 'url_bar.focus']}
|
hotkeys={['request.send', 'model.create', 'sidebar.focus', 'url_bar.focus']}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1 items-center">
|
<div className="w-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1 items-center">
|
||||||
|
|||||||
@@ -160,7 +160,11 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
|||||||
{ label: 'GraphQL', value: BODY_TYPE_GRAPHQL },
|
{ label: 'GraphQL', value: BODY_TYPE_GRAPHQL },
|
||||||
{ label: 'JSON', value: BODY_TYPE_JSON },
|
{ label: 'JSON', value: BODY_TYPE_JSON },
|
||||||
{ label: 'XML', value: BODY_TYPE_XML },
|
{ 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' },
|
{ type: 'separator', label: 'Other' },
|
||||||
{ label: 'Binary File', value: BODY_TYPE_BINARY },
|
{ label: 'Binary File', value: BODY_TYPE_BINARY },
|
||||||
{ label: 'No Body', shortLabel: 'Body', value: BODY_TYPE_NONE },
|
{ label: 'No Body', shortLabel: 'Body', value: BODY_TYPE_NONE },
|
||||||
@@ -229,6 +233,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
|||||||
[
|
[
|
||||||
activeRequest,
|
activeRequest,
|
||||||
authTab,
|
authTab,
|
||||||
|
contentType,
|
||||||
handleContentTypeChange,
|
handleContentTypeChange,
|
||||||
headersTab,
|
headersTab,
|
||||||
numParams,
|
numParams,
|
||||||
@@ -471,3 +476,11 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function nameOfContentTypeOr(contentType: string | null, fallback: string) {
|
||||||
|
const language = languageFromContentType(contentType);
|
||||||
|
if (language === 'markdown') {
|
||||||
|
return 'Markdown';
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
|||||||
>
|
>
|
||||||
{activeResponse == null ? (
|
{activeResponse == null ? (
|
||||||
<HotKeyList
|
<HotKeyList
|
||||||
hotkeys={['http_request.send', 'http_request.create', 'sidebar.focus', 'url_bar.focus']}
|
hotkeys={['request.send', 'model.create', 'sidebar.focus', 'url_bar.focus']}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-full w-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1">
|
<div className="h-full w-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1">
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ export function LocalImage({ src: srcPath, className }: Props) {
|
|||||||
queryKey: ['local-image', srcPath],
|
queryKey: ['local-image', srcPath],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const p = await resolveResource(srcPath);
|
const p = await resolveResource(srcPath);
|
||||||
console.log("LOADING SRC", srcPath, p)
|
|
||||||
return convertFileSrc(p);
|
return convertFileSrc(p);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
450
src-web/components/NewSidebar.tsx
Normal file
450
src-web/components/NewSidebar.tsx
Normal file
@@ -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 <Icon icon="folder" />;
|
||||||
|
} else if (item.model === 'workspace') {
|
||||||
|
return null;
|
||||||
|
} else {
|
||||||
|
const isSelected = jotaiStore.get(isSelectedFamily({ treeId, itemId: item.id }));
|
||||||
|
return (
|
||||||
|
<HttpMethodTag
|
||||||
|
short
|
||||||
|
className={classNames('text-xs', !isSelected && opacitySubtle)}
|
||||||
|
request={item}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="flex items-center gap-2 min-w-0 h-full w-full text-left">
|
||||||
|
<div className="truncate">{resolvedModelName(item)}</div>
|
||||||
|
{response != null && (
|
||||||
|
<div className="ml-auto">
|
||||||
|
{response.state !== 'closed' ? (
|
||||||
|
<LoadingIcon size="sm" className="text-text-subtlest" />
|
||||||
|
) : response.model === 'http_response' ? (
|
||||||
|
<HttpStatusTag short className="text-xs" response={response} />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<HTMLElement>(null);
|
||||||
|
const treeRef = useRef<TreeHandle>(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<Model, Workspace>;
|
||||||
|
const next = children[insertAt] as Exclude<Model, Workspace>;
|
||||||
|
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 (
|
||||||
|
<aside
|
||||||
|
ref={wrapperRef}
|
||||||
|
aria-hidden={hidden ?? undefined}
|
||||||
|
className={classNames(className, 'h-full grid grid-rows-[minmax(0,1fr)_auto]')}
|
||||||
|
>
|
||||||
|
<Tree
|
||||||
|
ref={handleTreeRefInit}
|
||||||
|
root={tree}
|
||||||
|
treeId={treeId}
|
||||||
|
hotkeys={hotkeys}
|
||||||
|
getItemKey={getItemKey}
|
||||||
|
ItemInner={SidebarInnerItem}
|
||||||
|
ItemLeftSlot={SidebarLeftSlot}
|
||||||
|
getContextMenu={getContextMenu}
|
||||||
|
onActivate={handleActivate}
|
||||||
|
getEditOptions={getEditOptions}
|
||||||
|
className="pl-2 pr-3 pt-2 pb-2"
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
/>
|
||||||
|
<GitDropdown />
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NewSidebar;
|
||||||
|
|
||||||
|
const activeIdAtom = atom<string | null>((get) => {
|
||||||
|
return get(activeRequestIdAtom) || get(activeFolderIdAtom);
|
||||||
|
});
|
||||||
|
|
||||||
|
function getEditOptions(
|
||||||
|
item: Model,
|
||||||
|
): ReturnType<NonNullable<TreeItemProps<Model>['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<Model[]>((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<string, Exclude<Model, Workspace>[]> = {};
|
||||||
|
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<string, TreeNode<Model>> = {};
|
||||||
|
|
||||||
|
if (activeWorkspace == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Put requests and folders into a tree structure
|
||||||
|
const next = (node: TreeNode<Model>): TreeNode<Model> => {
|
||||||
|
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<Model>['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<DropdownItem[]> {
|
||||||
|
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: <Icon icon="folder_cog" />,
|
||||||
|
onSelect: () => openFolderSettings(child.id),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Send All',
|
||||||
|
hidden: !(items.length === 1 && child.model === 'folder'),
|
||||||
|
leftSlot: <Icon icon="send_horizontal" />,
|
||||||
|
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: <Icon icon="send_horizontal" />,
|
||||||
|
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: <Icon icon={(a.icon as any) ?? 'empty'} />,
|
||||||
|
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: <Icon icon={(a.icon as any) ?? 'empty'} />,
|
||||||
|
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: <Icon icon="pencil" />,
|
||||||
|
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: <Icon icon="copy" />,
|
||||||
|
onSelect: () => actions['model.duplicate'](items),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Move',
|
||||||
|
leftSlot: <Icon icon="arrow_right_circle" />,
|
||||||
|
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: <Icon icon="trash" />,
|
||||||
|
onSelect: () => actions['sidebar.delete_selected_item'](items),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return menuItems;
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import classNames from 'classnames';
|
|||||||
import { FocusTrap } from 'focus-trap-react';
|
import { FocusTrap } from 'focus-trap-react';
|
||||||
import * as m from 'motion/react-m';
|
import * as m from 'motion/react-m';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import React from 'react';
|
import React, { useRef } from 'react';
|
||||||
import { Portal } from './Portal';
|
import { Portal } from './Portal';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -32,6 +32,8 @@ export function Overlay({
|
|||||||
noBackdrop,
|
noBackdrop,
|
||||||
children,
|
children,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
if (noBackdrop) {
|
if (noBackdrop) {
|
||||||
return (
|
return (
|
||||||
<Portal name={portalName}>
|
<Portal name={portalName}>
|
||||||
@@ -44,15 +46,33 @@ export function Overlay({
|
|||||||
</Portal>
|
</Portal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Portal name={portalName}>
|
<Portal name={portalName}>
|
||||||
{open && (
|
{open && (
|
||||||
<FocusTrap
|
<FocusTrap
|
||||||
focusTrapOptions={{
|
focusTrapOptions={{
|
||||||
allowOutsideClick: true, // So we can still click toasts and things
|
allowOutsideClick: true, // So we can still click toasts and things
|
||||||
|
delayInitialFocus: true,
|
||||||
|
fallbackFocus: () => containerRef.current!, // always have a target
|
||||||
|
initialFocus: () =>
|
||||||
|
// Doing this explicitly seems to work better than the default behavior for some reason
|
||||||
|
containerRef.current?.querySelector<HTMLElement>(
|
||||||
|
[
|
||||||
|
'a[href]',
|
||||||
|
'input:not([disabled])',
|
||||||
|
'select:not([disabled])',
|
||||||
|
'textarea:not([disabled])',
|
||||||
|
'button:not([disabled])',
|
||||||
|
'[tabindex]:not([tabindex="-1"])',
|
||||||
|
'[contenteditable]:not([contenteditable="false"])',
|
||||||
|
].join(', '),
|
||||||
|
) ?? undefined,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<m.div
|
<m.div
|
||||||
|
ref={containerRef}
|
||||||
|
tabIndex={-1}
|
||||||
className={classNames('fixed inset-0', zIndexes[zIndex])}
|
className={classNames('fixed inset-0', zIndexes[zIndex])}
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export function RedirectToLatestWorkspace() {
|
|||||||
request_id: requestId,
|
request_id: requestId,
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log("Redirecting to workspace", params, search);
|
console.log('Redirecting to workspace', params, search);
|
||||||
await router.navigate({ to: '/workspaces/$workspaceId', params, search });
|
await router.navigate({ to: '/workspaces/$workspaceId', params, search });
|
||||||
})();
|
})();
|
||||||
}, [recentWorkspaces, workspaces, workspaces.length]);
|
}, [recentWorkspaces, workspaces, workspaces.length]);
|
||||||
|
|||||||
@@ -25,9 +25,8 @@ export function ResizeHandle({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
aria-hidden
|
aria-hidden
|
||||||
draggable
|
|
||||||
style={style}
|
style={style}
|
||||||
onDragStart={onResizeStart}
|
onPointerDown={onResizeStart}
|
||||||
onDoubleClick={onReset}
|
onDoubleClick={onReset}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
className,
|
className,
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow';
|
||||||
import { open } from '@tauri-apps/plugin-dialog';
|
import { open } from '@tauri-apps/plugin-dialog';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import mime from 'mime';
|
import mime from 'mime';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import type { ButtonProps } from './core/Button';
|
import type { ButtonProps } from './core/Button';
|
||||||
import { Button } from './core/Button';
|
import { Button } from './core/Button';
|
||||||
import { IconButton } from './core/IconButton';
|
import { IconButton } from './core/IconButton';
|
||||||
@@ -51,9 +53,43 @@ export function SelectFile({
|
|||||||
|
|
||||||
const itemLabel = noun ?? (directory ? 'Folder' : 'File');
|
const itemLabel = noun ?? (directory ? 'Folder' : 'File');
|
||||||
const selectOrChange = (filePath ? 'Change ' : 'Select ') + itemLabel;
|
const selectOrChange = (filePath ? 'Change ' : 'Select ') + itemLabel;
|
||||||
|
const [isHovering, setIsHovering] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement>(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 (
|
return (
|
||||||
<div>
|
<div ref={ref} className="w-full">
|
||||||
{label && (
|
{label && (
|
||||||
<Label htmlFor={null} help={help}>
|
<Label htmlFor={null} help={help}>
|
||||||
{label}
|
{label}
|
||||||
@@ -66,8 +102,9 @@ export function SelectFile({
|
|||||||
'rtl mr-1.5',
|
'rtl mr-1.5',
|
||||||
inline && 'w-full',
|
inline && 'w-full',
|
||||||
filePath && inline && 'font-mono text-xs',
|
filePath && inline && 'font-mono text-xs',
|
||||||
|
isHovering && '!border-notice',
|
||||||
)}
|
)}
|
||||||
color="secondary"
|
color={isHovering ? 'primary' : 'secondary'}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
size={size}
|
size={size}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useFloatingSidebarHidden } from '../../hooks/useFloatingSidebarHidden';
|
import { useFloatingSidebarHidden } from '../hooks/useFloatingSidebarHidden';
|
||||||
import { useShouldFloatSidebar } from '../../hooks/useShouldFloatSidebar';
|
import { useShouldFloatSidebar } from '../hooks/useShouldFloatSidebar';
|
||||||
import { useSidebarHidden } from '../../hooks/useSidebarHidden';
|
import { useSidebarHidden } from '../hooks/useSidebarHidden';
|
||||||
import { IconButton } from '../core/IconButton';
|
import { IconButton } from './core/IconButton';
|
||||||
import { HStack } from '../core/Stacks';
|
import { HStack } from './core/Stacks';
|
||||||
import { CreateDropdown } from '../CreateDropdown';
|
import { CreateDropdown } from './CreateDropdown';
|
||||||
|
|
||||||
export function SidebarActions() {
|
export function SidebarActions() {
|
||||||
const floating = useShouldFloatSidebar();
|
const floating = useShouldFloatSidebar();
|
||||||
@@ -31,7 +31,7 @@ export function SidebarActions() {
|
|||||||
icon={hidden ? 'left_panel_hidden' : 'left_panel_visible'}
|
icon={hidden ? 'left_panel_hidden' : 'left_panel_visible'}
|
||||||
iconColor="secondary"
|
iconColor="secondary"
|
||||||
/>
|
/>
|
||||||
<CreateDropdown hotKeyAction="http_request.create">
|
<CreateDropdown hotKeyAction="model.create">
|
||||||
<IconButton size="sm" icon="plus_circle" iconColor="secondary" title="Add Resource" />
|
<IconButton size="sm" icon="plus_circle" iconColor="secondary" title="Add Resource" />
|
||||||
</CreateDropdown>
|
</CreateDropdown>
|
||||||
</HStack>
|
</HStack>
|
||||||
@@ -98,7 +98,7 @@ export const UrlBar = memo(function UrlBar({
|
|||||||
className="w-8 mr-0.5 !h-full"
|
className="w-8 mr-0.5 !h-full"
|
||||||
iconColor="secondary"
|
iconColor="secondary"
|
||||||
icon={isLoading ? 'x' : submitIcon}
|
icon={isLoading ? 'x' : submitIcon}
|
||||||
hotkeyAction="http_request.send"
|
hotkeyAction="request.send"
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
// Prevent the button from taking focus
|
// Prevent the button from taking focus
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export function WebsocketResponsePane({ activeRequest }: Props) {
|
|||||||
firstSlot={() =>
|
firstSlot={() =>
|
||||||
activeConnection == null ? (
|
activeConnection == null ? (
|
||||||
<HotKeyList
|
<HotKeyList
|
||||||
hotkeys={['http_request.send', 'http_request.create', 'sidebar.focus', 'url_bar.focus']}
|
hotkeys={['request.send', 'model.create', 'sidebar.focus', 'url_bar.focus']}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-full grid grid-rows-[auto_minmax(0,1fr)] items-center">
|
<div className="w-full grid grid-rows-[auto_minmax(0,1fr)] items-center">
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
activeEnvironmentAtom,
|
activeEnvironmentAtom,
|
||||||
useSubscribeActiveEnvironmentId,
|
useSubscribeActiveEnvironmentId,
|
||||||
} from '../hooks/useActiveEnvironment';
|
} from '../hooks/useActiveEnvironment';
|
||||||
|
import { activeFolderAtom } from '../hooks/useActiveFolder';
|
||||||
|
import { useSubscribeActiveFolderId } from '../hooks/useActiveFolderId';
|
||||||
import { activeRequestAtom } from '../hooks/useActiveRequest';
|
import { activeRequestAtom } from '../hooks/useActiveRequest';
|
||||||
import { useSubscribeActiveRequestId } from '../hooks/useActiveRequestId';
|
import { useSubscribeActiveRequestId } from '../hooks/useActiveRequestId';
|
||||||
import { activeWorkspaceAtom } from '../hooks/useActiveWorkspace';
|
import { activeWorkspaceAtom } from '../hooks/useActiveWorkspace';
|
||||||
@@ -26,7 +28,7 @@ import { useSidebarHidden } from '../hooks/useSidebarHidden';
|
|||||||
import { useSidebarWidth } from '../hooks/useSidebarWidth';
|
import { useSidebarWidth } from '../hooks/useSidebarWidth';
|
||||||
import { useSyncWorkspaceRequestTitle } from '../hooks/useSyncWorkspaceRequestTitle';
|
import { useSyncWorkspaceRequestTitle } from '../hooks/useSyncWorkspaceRequestTitle';
|
||||||
import { useToggleCommandPalette } from '../hooks/useToggleCommandPalette';
|
import { useToggleCommandPalette } from '../hooks/useToggleCommandPalette';
|
||||||
import { duplicateRequestAndNavigate } from '../lib/duplicateRequestAndNavigate';
|
import { duplicateRequestOrFolderAndNavigate } from '../lib/duplicateRequestOrFolderAndNavigate';
|
||||||
import { importData } from '../lib/importData';
|
import { importData } from '../lib/importData';
|
||||||
import { jotaiStore } from '../lib/jotai';
|
import { jotaiStore } from '../lib/jotai';
|
||||||
import { Banner } from './core/Banner';
|
import { Banner } from './core/Banner';
|
||||||
@@ -36,13 +38,14 @@ import { FeedbackLink } from './core/Link';
|
|||||||
import { HStack } from './core/Stacks';
|
import { HStack } from './core/Stacks';
|
||||||
import { CreateDropdown } from './CreateDropdown';
|
import { CreateDropdown } from './CreateDropdown';
|
||||||
import { ErrorBoundary } from './ErrorBoundary';
|
import { ErrorBoundary } from './ErrorBoundary';
|
||||||
|
import { FolderLayout } from './FolderLayout';
|
||||||
import { GrpcConnectionLayout } from './GrpcConnectionLayout';
|
import { GrpcConnectionLayout } from './GrpcConnectionLayout';
|
||||||
import { HeaderSize } from './HeaderSize';
|
import { HeaderSize } from './HeaderSize';
|
||||||
import { HttpRequestLayout } from './HttpRequestLayout';
|
import { HttpRequestLayout } from './HttpRequestLayout';
|
||||||
|
import NewSidebar from './NewSidebar';
|
||||||
import { Overlay } from './Overlay';
|
import { Overlay } from './Overlay';
|
||||||
import { ResizeHandle } from './ResizeHandle';
|
import { ResizeHandle } from './ResizeHandle';
|
||||||
import { Sidebar } from './sidebar/Sidebar';
|
import { SidebarActions } from './SidebarActions';
|
||||||
import { SidebarActions } from './sidebar/SidebarActions';
|
|
||||||
import { WebsocketRequestLayout } from './WebsocketRequestLayout';
|
import { WebsocketRequestLayout } from './WebsocketRequestLayout';
|
||||||
import { WorkspaceHeader } from './WorkspaceHeader';
|
import { WorkspaceHeader } from './WorkspaceHeader';
|
||||||
|
|
||||||
@@ -161,7 +164,7 @@ export function Workspace() {
|
|||||||
<SidebarActions />
|
<SidebarActions />
|
||||||
</HeaderSize>
|
</HeaderSize>
|
||||||
<ErrorBoundary name="Sidebar (Floating)">
|
<ErrorBoundary name="Sidebar (Floating)">
|
||||||
<Sidebar />
|
<NewSidebar />
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</m.div>
|
</m.div>
|
||||||
</Overlay>
|
</Overlay>
|
||||||
@@ -169,7 +172,7 @@ export function Workspace() {
|
|||||||
<>
|
<>
|
||||||
<div style={side} className={classNames('x-theme-sidebar', 'overflow-hidden bg-surface')}>
|
<div style={side} className={classNames('x-theme-sidebar', 'overflow-hidden bg-surface')}>
|
||||||
<ErrorBoundary name="Sidebar">
|
<ErrorBoundary name="Sidebar">
|
||||||
<Sidebar className="border-r border-border-subtle" />
|
<NewSidebar className="border-r border-border-subtle" />
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
<ResizeHandle
|
<ResizeHandle
|
||||||
@@ -193,7 +196,7 @@ export function Workspace() {
|
|||||||
style={environmentBgStyle}
|
style={environmentBgStyle}
|
||||||
className="absolute inset-0 opacity-5"
|
className="absolute inset-0 opacity-5"
|
||||||
/>
|
/>
|
||||||
<div // Add subtle border bottom
|
<div // Add a subtle border bottom
|
||||||
style={environmentBgStyle}
|
style={environmentBgStyle}
|
||||||
className="absolute left-0 right-0 bottom-0 h-[0.5px] opacity-20"
|
className="absolute left-0 right-0 bottom-0 h-[0.5px] opacity-20"
|
||||||
/>
|
/>
|
||||||
@@ -209,6 +212,7 @@ export function Workspace() {
|
|||||||
|
|
||||||
function WorkspaceBody() {
|
function WorkspaceBody() {
|
||||||
const activeRequest = useAtomValue(activeRequestAtom);
|
const activeRequest = useAtomValue(activeRequestAtom);
|
||||||
|
const activeFolder = useAtomValue(activeFolderAtom);
|
||||||
const activeWorkspace = useAtomValue(activeWorkspaceAtom);
|
const activeWorkspace = useAtomValue(activeWorkspaceAtom);
|
||||||
|
|
||||||
if (activeWorkspace == null) {
|
if (activeWorkspace == null) {
|
||||||
@@ -228,39 +232,40 @@ function WorkspaceBody() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeRequest == null) {
|
if (activeRequest?.model === 'grpc_request') {
|
||||||
return (
|
return <GrpcConnectionLayout style={body} />;
|
||||||
<HotKeyList
|
} else if (activeRequest?.model === 'websocket_request') {
|
||||||
hotkeys={['http_request.create', 'sidebar.focus', 'settings.show']}
|
return <WebsocketRequestLayout style={body} activeRequest={activeRequest} />;
|
||||||
bottomSlot={
|
} else if (activeRequest?.model === 'http_request') {
|
||||||
<HStack space={1} justifyContent="center" className="mt-3">
|
return <HttpRequestLayout activeRequest={activeRequest} style={body} />;
|
||||||
<Button variant="border" size="sm" onClick={() => importData.mutate()}>
|
} else if (activeFolder != null) {
|
||||||
Import
|
return <FolderLayout folder={activeFolder} style={body} />;
|
||||||
</Button>
|
|
||||||
<CreateDropdown hideFolder>
|
|
||||||
<Button variant="border" forDropdown size="sm">
|
|
||||||
New Request
|
|
||||||
</Button>
|
|
||||||
</CreateDropdown>
|
|
||||||
</HStack>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeRequest.model === 'grpc_request') {
|
return (
|
||||||
return <GrpcConnectionLayout style={body} />;
|
<HotKeyList
|
||||||
} else if (activeRequest.model === 'websocket_request') {
|
hotkeys={['model.create', 'sidebar.focus', 'settings.show']}
|
||||||
return <WebsocketRequestLayout style={body} activeRequest={activeRequest} />;
|
bottomSlot={
|
||||||
} else {
|
<HStack space={1} justifyContent="center" className="mt-3">
|
||||||
return <HttpRequestLayout activeRequest={activeRequest} style={body} />;
|
<Button variant="border" size="sm" onClick={() => importData.mutate()}>
|
||||||
}
|
Import
|
||||||
|
</Button>
|
||||||
|
<CreateDropdown hideFolder>
|
||||||
|
<Button variant="border" forDropdown size="sm">
|
||||||
|
New Request
|
||||||
|
</Button>
|
||||||
|
</CreateDropdown>
|
||||||
|
</HStack>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function useGlobalWorkspaceHooks() {
|
function useGlobalWorkspaceHooks() {
|
||||||
useEnsureActiveCookieJar();
|
useEnsureActiveCookieJar();
|
||||||
|
|
||||||
useSubscribeActiveRequestId();
|
useSubscribeActiveRequestId();
|
||||||
|
useSubscribeActiveFolderId();
|
||||||
useSubscribeActiveEnvironmentId();
|
useSubscribeActiveEnvironmentId();
|
||||||
useSubscribeActiveCookieJarId();
|
useSubscribeActiveCookieJarId();
|
||||||
|
|
||||||
@@ -274,7 +279,7 @@ function useGlobalWorkspaceHooks() {
|
|||||||
const toggleCommandPalette = useToggleCommandPalette();
|
const toggleCommandPalette = useToggleCommandPalette();
|
||||||
useHotKey('command_palette.toggle', toggleCommandPalette);
|
useHotKey('command_palette.toggle', toggleCommandPalette);
|
||||||
|
|
||||||
useHotKey('http_request.duplicate', () =>
|
useHotKey('model.duplicate', () =>
|
||||||
duplicateRequestAndNavigate(jotaiStore.get(activeRequestAtom)),
|
duplicateRequestOrFolderAndNavigate(jotaiStore.get(activeRequestAtom)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { ImportCurlButton } from './ImportCurlButton';
|
|||||||
import { LicenseBadge } from './LicenseBadge';
|
import { LicenseBadge } from './LicenseBadge';
|
||||||
import { RecentRequestsDropdown } from './RecentRequestsDropdown';
|
import { RecentRequestsDropdown } from './RecentRequestsDropdown';
|
||||||
import { SettingsDropdown } from './SettingsDropdown';
|
import { SettingsDropdown } from './SettingsDropdown';
|
||||||
import { SidebarActions } from './sidebar/SidebarActions';
|
import { SidebarActions } from './SidebarActions';
|
||||||
import { WorkspaceActionsDropdown } from './WorkspaceActionsDropdown';
|
import { WorkspaceActionsDropdown } from './WorkspaceActionsDropdown';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|||||||
@@ -221,7 +221,7 @@ export const Dropdown = forwardRef<DropdownRef, DropdownProps>(function Dropdown
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
interface ContextMenuProps {
|
export interface ContextMenuProps {
|
||||||
triggerPosition: { x: number; y: number } | null;
|
triggerPosition: { x: number; y: number } | null;
|
||||||
className?: string;
|
className?: string;
|
||||||
items: DropdownProps['items'];
|
items: DropdownProps['items'];
|
||||||
|
|||||||
@@ -24,11 +24,13 @@ import {
|
|||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
useImperativeHandle,
|
useImperativeHandle,
|
||||||
|
useLayoutEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import type { WrappedEnvironmentVariable } from '../../../hooks/useEnvironmentVariables';
|
import type { WrappedEnvironmentVariable } from '../../../hooks/useEnvironmentVariables';
|
||||||
import { useEnvironmentVariables } from '../../../hooks/useEnvironmentVariables';
|
import { useEnvironmentVariables } from '../../../hooks/useEnvironmentVariables';
|
||||||
|
import { useRandomKey } from '../../../hooks/useRandomKey';
|
||||||
import { useRequestEditor } from '../../../hooks/useRequestEditor';
|
import { useRequestEditor } from '../../../hooks/useRequestEditor';
|
||||||
import { useTemplateFunctionCompletionOptions } from '../../../hooks/useTemplateFunctions';
|
import { useTemplateFunctionCompletionOptions } from '../../../hooks/useTemplateFunctions';
|
||||||
import { showDialog } from '../../../lib/dialog';
|
import { showDialog } from '../../../lib/dialog';
|
||||||
@@ -114,7 +116,7 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
|
|||||||
disabled,
|
disabled,
|
||||||
extraExtensions,
|
extraExtensions,
|
||||||
forcedEnvironmentId,
|
forcedEnvironmentId,
|
||||||
forceUpdateKey,
|
forceUpdateKey: forceUpdateKeyFromAbove,
|
||||||
format,
|
format,
|
||||||
heightMode,
|
heightMode,
|
||||||
hideGutter,
|
hideGutter,
|
||||||
@@ -145,6 +147,10 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
|
|||||||
? allEnvironmentVariables.filter(autocompleteVariables)
|
? allEnvironmentVariables.filter(autocompleteVariables)
|
||||||
: allEnvironmentVariables;
|
: allEnvironmentVariables;
|
||||||
}, [allEnvironmentVariables, autocompleteVariables]);
|
}, [allEnvironmentVariables, autocompleteVariables]);
|
||||||
|
// Track a local key for updates. If the default value is changed when the input is not in focus,
|
||||||
|
// regenerate this to force the field to update.
|
||||||
|
const [focusedUpdateKey, regenerateFocusedUpdateKey] = useRandomKey();
|
||||||
|
const forceUpdateKey = `${forceUpdateKeyFromAbove}::${focusedUpdateKey}`;
|
||||||
|
|
||||||
if (settings && wrapLines === undefined) {
|
if (settings && wrapLines === undefined) {
|
||||||
wrapLines = settings.editorSoftWrap;
|
wrapLines = settings.editorSoftWrap;
|
||||||
@@ -340,6 +346,17 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Force input to update when receiving change and not in focus
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const currDoc = cm.current?.view.state.doc.toString() || '';
|
||||||
|
const nextDoc = defaultValue || '';
|
||||||
|
const notFocused = !cm.current?.view.hasFocus;
|
||||||
|
const hasChanged = currDoc !== nextDoc;
|
||||||
|
if (notFocused && hasChanged) {
|
||||||
|
regenerateFocusedUpdateKey();
|
||||||
|
}
|
||||||
|
}, [defaultValue, regenerateFocusedUpdateKey]);
|
||||||
|
|
||||||
const [, { focusParamValue }] = useRequestEditor();
|
const [, { focusParamValue }] = useRequestEditor();
|
||||||
const onClickPathParameter = useCallback(
|
const onClickPathParameter = useCallback(
|
||||||
async (name: string) => {
|
async (name: string) => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { settingsAtom } from '@yaakapp-internal/models';
|
|
||||||
import type { GrpcRequest, HttpRequest, WebsocketRequest } from '@yaakapp-internal/models';
|
import type { GrpcRequest, HttpRequest, WebsocketRequest } from '@yaakapp-internal/models';
|
||||||
|
import { settingsAtom } from '@yaakapp-internal/models';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { useAtomValue } from 'jotai';
|
import { useAtomValue } from 'jotai';
|
||||||
|
|
||||||
@@ -18,42 +18,69 @@ const methodNames: Record<string, string> = {
|
|||||||
options: 'OPTN',
|
options: 'OPTN',
|
||||||
head: 'HEAD',
|
head: 'HEAD',
|
||||||
query: 'QURY',
|
query: 'QURY',
|
||||||
|
graphql: 'GQL',
|
||||||
|
grpc: 'GRPC',
|
||||||
|
websocket: 'WS',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function HttpMethodTag({ request, className, short }: Props) {
|
export function HttpMethodTag({ request, className, short }: Props) {
|
||||||
const settings = useAtomValue(settingsAtom);
|
const settings = useAtomValue(settingsAtom);
|
||||||
const method =
|
const method =
|
||||||
request.model === 'http_request' && request.bodyType === 'graphql'
|
request.model === 'http_request' && request.bodyType === 'graphql'
|
||||||
? 'GQL'
|
? 'graphql'
|
||||||
: request.model === 'grpc_request'
|
: request.model === 'grpc_request'
|
||||||
? 'GRPC'
|
? 'grpc'
|
||||||
: request.model === 'websocket_request'
|
: request.model === 'websocket_request'
|
||||||
? 'WS'
|
? 'websocket'
|
||||||
: request.method;
|
: request.method;
|
||||||
let label = method.toUpperCase();
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HttpMethodTagRaw
|
||||||
|
method={method}
|
||||||
|
colored={settings.coloredMethods}
|
||||||
|
className={className}
|
||||||
|
short={short}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HttpMethodTagRaw({
|
||||||
|
className,
|
||||||
|
method,
|
||||||
|
colored,
|
||||||
|
short,
|
||||||
|
}: {
|
||||||
|
method: string;
|
||||||
|
className?: string;
|
||||||
|
colored: boolean;
|
||||||
|
short?: boolean;
|
||||||
|
}) {
|
||||||
|
let label = method.toUpperCase();
|
||||||
if (short) {
|
if (short) {
|
||||||
label = methodNames[method.toLowerCase()] ?? method.slice(0, 4);
|
label = methodNames[method.toLowerCase()] ?? method.slice(0, 4);
|
||||||
label = label.padStart(4, ' ');
|
label = label.padStart(4, ' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const m = method.toUpperCase();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={classNames(
|
className={classNames(
|
||||||
className,
|
className,
|
||||||
!settings.coloredMethods && 'text-text-subtle',
|
!colored && 'text-text-subtle',
|
||||||
settings.coloredMethods && method === 'GQL' && 'text-info',
|
colored && m === 'GRAPHQL' && 'text-info',
|
||||||
settings.coloredMethods && method === 'WS' && 'text-info',
|
colored && m === 'WEBSOCKET' && 'text-info',
|
||||||
settings.coloredMethods && method === 'GRPC' && 'text-info',
|
colored && m === 'GRPC' && 'text-info',
|
||||||
settings.coloredMethods && method === 'OPTIONS' && 'text-info',
|
colored && m === 'QUERY' && 'text-secondary',
|
||||||
settings.coloredMethods && method === 'HEAD' && 'text-info',
|
colored && m === 'OPTIONS' && 'text-info',
|
||||||
settings.coloredMethods && method === 'GET' && 'text-primary',
|
colored && m === 'HEAD' && 'text-secondary',
|
||||||
settings.coloredMethods && method === 'PUT' && 'text-warning',
|
colored && m === 'GET' && 'text-primary',
|
||||||
settings.coloredMethods && method === 'PATCH' && 'text-notice',
|
colored && m === 'PUT' && 'text-warning',
|
||||||
settings.coloredMethods && method === 'POST' && 'text-success',
|
colored && m === 'PATCH' && 'text-notice',
|
||||||
settings.coloredMethods && method === 'DELETE' && 'text-danger',
|
colored && m === 'POST' && 'text-success',
|
||||||
|
colored && m === 'DELETE' && 'text-danger',
|
||||||
'font-mono flex-shrink-0 whitespace-pre',
|
'font-mono flex-shrink-0 whitespace-pre',
|
||||||
'pt-[0.25em]', // Fix for monospace font not vertically centering
|
'pt-[0.15em]', // Fix for monospace font not vertically centering
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export function HttpResponseDurationTag({ response }: Props) {
|
|||||||
// Calculate the duration of the response for use when the response hasn't finished yet
|
// Calculate the duration of the response for use when the response hasn't finished yet
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
clearInterval(timeout.current);
|
clearInterval(timeout.current);
|
||||||
|
if (response.state === 'closed') return;
|
||||||
timeout.current = setInterval(() => {
|
timeout.current = setInterval(() => {
|
||||||
setFallbackElapsed(Date.now() - new Date(response.createdAt + 'Z').getTime());
|
setFallbackElapsed(Date.now() - new Date(response.createdAt + 'Z').getTime());
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export function HttpStatusTag({ response, className, showReason, short }: Props)
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className={classNames(className, 'font-mono', colorClass)}>
|
<span className={classNames(className, 'font-mono min-w-0', colorClass)}>
|
||||||
{label} {showReason && 'statusReason' in response ? response.statusReason : null}
|
{label} {showReason && 'statusReason' in response ? response.statusReason : null}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ const icons = {
|
|||||||
code: lucide.CodeIcon,
|
code: lucide.CodeIcon,
|
||||||
columns_2: lucide.Columns2Icon,
|
columns_2: lucide.Columns2Icon,
|
||||||
command: lucide.CommandIcon,
|
command: lucide.CommandIcon,
|
||||||
|
corner_right_up: lucide.CornerRightUpIcon,
|
||||||
credit_card: lucide.CreditCardIcon,
|
credit_card: lucide.CreditCardIcon,
|
||||||
cookie: lucide.CookieIcon,
|
cookie: lucide.CookieIcon,
|
||||||
copy: lucide.CopyIcon,
|
copy: lucide.CopyIcon,
|
||||||
@@ -54,6 +55,7 @@ const icons = {
|
|||||||
flame: lucide.FlameIcon,
|
flame: lucide.FlameIcon,
|
||||||
flask: lucide.FlaskConicalIcon,
|
flask: lucide.FlaskConicalIcon,
|
||||||
folder: lucide.FolderIcon,
|
folder: lucide.FolderIcon,
|
||||||
|
folder_cog: lucide.FolderCogIcon,
|
||||||
folder_code: lucide.FolderCodeIcon,
|
folder_code: lucide.FolderCodeIcon,
|
||||||
folder_git: lucide.FolderGitIcon,
|
folder_git: lucide.FolderGitIcon,
|
||||||
folder_input: lucide.FolderInputIcon,
|
folder_input: lucide.FolderInputIcon,
|
||||||
@@ -61,6 +63,7 @@ const icons = {
|
|||||||
folder_output: lucide.FolderOutputIcon,
|
folder_output: lucide.FolderOutputIcon,
|
||||||
folder_symlink: lucide.FolderSymlinkIcon,
|
folder_symlink: lucide.FolderSymlinkIcon,
|
||||||
folder_sync: lucide.FolderSyncIcon,
|
folder_sync: lucide.FolderSyncIcon,
|
||||||
|
folder_up: lucide.FolderUpIcon,
|
||||||
git_branch: lucide.GitBranchIcon,
|
git_branch: lucide.GitBranchIcon,
|
||||||
git_branch_plus: lucide.GitBranchPlusIcon,
|
git_branch_plus: lucide.GitBranchPlusIcon,
|
||||||
git_commit: lucide.GitCommitIcon,
|
git_commit: lucide.GitCommitIcon,
|
||||||
@@ -118,7 +121,7 @@ const icons = {
|
|||||||
x: lucide.XIcon,
|
x: lucide.XIcon,
|
||||||
_unknown: lucide.ShieldAlertIcon,
|
_unknown: lucide.ShieldAlertIcon,
|
||||||
|
|
||||||
empty: (props: HTMLAttributes<HTMLSpanElement>) => <span {...props} />,
|
empty: (props: HTMLAttributes<HTMLSpanElement>) => <div {...props} />,
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface IconProps {
|
export interface IconProps {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { EditorSelection } from '@codemirror/state';
|
||||||
import type { EditorView } from '@codemirror/view';
|
import type { EditorView } from '@codemirror/view';
|
||||||
import type { Color } from '@yaakapp-internal/plugins';
|
import type { Color } from '@yaakapp-internal/plugins';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
@@ -164,7 +165,7 @@ const BaseInput = forwardRef<EditorView, InputProps>(function InputBase(
|
|||||||
setFocused(false);
|
setFocused(false);
|
||||||
// Move selection to the end on blur
|
// Move selection to the end on blur
|
||||||
editorRef.current?.dispatch({
|
editorRef.current?.dispatch({
|
||||||
selection: { anchor: editorRef.current.state.doc.length },
|
selection: EditorSelection.single(editorRef.current.state.doc.length ),
|
||||||
});
|
});
|
||||||
onBlur?.();
|
onBlur?.();
|
||||||
}, [onBlur]);
|
}, [onBlur]);
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import type { FocusEvent, HTMLAttributes } from 'react';
|
import type { FocusEvent, HTMLAttributes } from 'react';
|
||||||
import { forwardRef, useCallback, useImperativeHandle, useRef, useState } from 'react';
|
import {
|
||||||
|
forwardRef,
|
||||||
|
useCallback,
|
||||||
|
useImperativeHandle,
|
||||||
|
useLayoutEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import { useRandomKey } from '../../hooks/useRandomKey';
|
||||||
import { useStateWithDeps } from '../../hooks/useStateWithDeps';
|
import { useStateWithDeps } from '../../hooks/useStateWithDeps';
|
||||||
import { IconButton } from './IconButton';
|
import { IconButton } from './IconButton';
|
||||||
import type { InputProps } from './Input';
|
import type { InputProps } from './Input';
|
||||||
@@ -22,7 +30,7 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
|
|||||||
className,
|
className,
|
||||||
containerClassName,
|
containerClassName,
|
||||||
defaultValue,
|
defaultValue,
|
||||||
forceUpdateKey,
|
forceUpdateKey: forceUpdateKeyFromAbove,
|
||||||
help,
|
help,
|
||||||
hideLabel,
|
hideLabel,
|
||||||
hideObscureToggle,
|
hideObscureToggle,
|
||||||
@@ -47,15 +55,21 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
|
|||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
|
// Track a local key for updates. If the default value is changed when the input is not in focus,
|
||||||
|
// regenerate this to force the field to update.
|
||||||
|
const [focusedUpdateKey, regenerateFocusedUpdateKey] = useRandomKey();
|
||||||
|
const forceUpdateKey = `${forceUpdateKeyFromAbove}::${focusedUpdateKey}`;
|
||||||
|
|
||||||
const [obscured, setObscured] = useStateWithDeps(type === 'password', [type]);
|
const [obscured, setObscured] = useStateWithDeps(type === 'password', [type]);
|
||||||
const [focused, setFocused] = useState(false);
|
const [focused, setFocused] = useState(false);
|
||||||
const [hasChanged, setHasChanged] = useStateWithDeps<boolean>(false, [forceUpdateKey]);
|
const [hasChanged, setHasChanged] = useStateWithDeps<boolean>(false, [forceUpdateKey]);
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
useImperativeHandle<{ focus: () => void } | null, { focus: () => void } | null>(
|
useImperativeHandle<{ focus: () => void } | null, { focus: () => void } | null>(
|
||||||
ref,
|
ref,
|
||||||
() => inputRef.current,
|
() => inputRef.current,
|
||||||
);
|
);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
||||||
|
|
||||||
const handleFocus = useCallback(
|
const handleFocus = useCallback(
|
||||||
(e: FocusEvent<HTMLInputElement>) => {
|
(e: FocusEvent<HTMLInputElement>) => {
|
||||||
@@ -75,6 +89,13 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
|
|||||||
onBlur?.();
|
onBlur?.();
|
||||||
}, [onBlur]);
|
}, [onBlur]);
|
||||||
|
|
||||||
|
// Force input to update when receiving change and not in focus
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!focused) {
|
||||||
|
regenerateFocusedUpdateKey();
|
||||||
|
}
|
||||||
|
}, [focused, regenerateFocusedUpdateKey, defaultValue]);
|
||||||
|
|
||||||
const id = `input-${name}`;
|
const id = `input-${name}`;
|
||||||
const commonClassName = classNames(
|
const commonClassName = classNames(
|
||||||
className,
|
className,
|
||||||
@@ -152,9 +173,9 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
|
id={id}
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
key={forceUpdateKey}
|
key={forceUpdateKey}
|
||||||
id={id}
|
|
||||||
type={type === 'password' && !obscured ? 'text' : type}
|
type={type === 'password' && !obscured ? 'text' : type}
|
||||||
defaultValue={defaultValue ?? undefined}
|
defaultValue={defaultValue ?? undefined}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
|
|||||||
@@ -33,7 +33,15 @@ export function RadioDropdown<T = string | null>({
|
|||||||
}: RadioDropdownProps<T>) {
|
}: RadioDropdownProps<T>) {
|
||||||
const dropdownItems = useMemo(
|
const dropdownItems = useMemo(
|
||||||
() => [
|
() => [
|
||||||
...((itemsBefore ? [...itemsBefore, { type: 'separator' }] : []) as DropdownItem[]),
|
...((itemsBefore
|
||||||
|
? [
|
||||||
|
...itemsBefore,
|
||||||
|
{
|
||||||
|
type: 'separator',
|
||||||
|
hidden: itemsBefore[itemsBefore.length - 1]?.type === 'separator',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []) as DropdownItem[]),
|
||||||
...items.map((item) => {
|
...items.map((item) => {
|
||||||
if (item.type === 'separator') {
|
if (item.type === 'separator') {
|
||||||
return item;
|
return item;
|
||||||
@@ -47,7 +55,9 @@ export function RadioDropdown<T = string | null>({
|
|||||||
} as DropdownItem;
|
} as DropdownItem;
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
...((itemsAfter ? [{ type: 'separator' }, ...itemsAfter] : []) as DropdownItem[]),
|
...((itemsAfter
|
||||||
|
? [{ type: 'separator', hidden: itemsAfter[0]?.type === 'separator' }, ...itemsAfter]
|
||||||
|
: []) as DropdownItem[]),
|
||||||
],
|
],
|
||||||
[itemsBefore, items, itemsAfter, value, onChange],
|
[itemsBefore, items, itemsAfter, value, onChange],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -88,15 +88,15 @@ export function SplitLayout({
|
|||||||
|
|
||||||
const unsub = () => {
|
const unsub = () => {
|
||||||
if (moveState.current !== null) {
|
if (moveState.current !== null) {
|
||||||
document.documentElement.removeEventListener('mousemove', moveState.current.move);
|
document.documentElement.removeEventListener('pointermove', moveState.current.move);
|
||||||
document.documentElement.removeEventListener('mouseup', moveState.current.up);
|
document.documentElement.removeEventListener('pointerup', moveState.current.up);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReset = useCallback(
|
const handleReset = useCallback(() => {
|
||||||
() => (vertical ? setHeight(defaultRatio) : setWidth(defaultRatio)),
|
if (vertical) setHeight(defaultRatio);
|
||||||
[vertical, setHeight, defaultRatio, setWidth],
|
else setWidth(defaultRatio);
|
||||||
);
|
}, [vertical, setHeight, defaultRatio, setWidth]);
|
||||||
|
|
||||||
const handleResizeStart = useCallback(
|
const handleResizeStart = useCallback(
|
||||||
(e: ReactMouseEvent<HTMLDivElement>) => {
|
(e: ReactMouseEvent<HTMLDivElement>) => {
|
||||||
@@ -112,6 +112,7 @@ export function SplitLayout({
|
|||||||
|
|
||||||
moveState.current = {
|
moveState.current = {
|
||||||
move: (e: MouseEvent) => {
|
move: (e: MouseEvent) => {
|
||||||
|
setIsResizing(true); // Set this here so we don't block double-clicks
|
||||||
e.preventDefault(); // Prevent text selection and things
|
e.preventDefault(); // Prevent text selection and things
|
||||||
if (vertical) {
|
if (vertical) {
|
||||||
const maxHeightPx = containerRect.height - minHeightPx;
|
const maxHeightPx = containerRect.height - minHeightPx;
|
||||||
@@ -137,9 +138,8 @@ export function SplitLayout({
|
|||||||
setIsResizing(false);
|
setIsResizing(false);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
document.documentElement.addEventListener('mousemove', moveState.current.move);
|
document.documentElement.addEventListener('pointermove', moveState.current.move);
|
||||||
document.documentElement.addEventListener('mouseup', moveState.current.up);
|
document.documentElement.addEventListener('pointerup', moveState.current.up);
|
||||||
setIsResizing(true);
|
|
||||||
},
|
},
|
||||||
[width, height, vertical, minHeightPx, setHeight, minWidthPx, setWidth],
|
[width, height, vertical, minHeightPx, setHeight, minWidthPx, setWidth],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -83,13 +83,13 @@ export function Tabs({
|
|||||||
aria-label={label}
|
aria-label={label}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
tabListClassName,
|
tabListClassName,
|
||||||
addBorders && '!-ml-1',
|
addBorders && layout === 'horizontal' && 'pl-3 -ml-1',
|
||||||
addBorders && layout === 'vertical' && 'mb-2',
|
addBorders && layout === 'vertical' && 'ml-0 mb-2',
|
||||||
'flex items-center hide-scrollbars',
|
'flex items-center hide-scrollbars',
|
||||||
layout === 'horizontal' && 'h-full overflow-auto p-2 -mr-2',
|
layout === 'horizontal' && 'h-full overflow-auto p-2 -mr-2',
|
||||||
layout === 'vertical' && 'overflow-x-auto overflow-y-visible ',
|
layout === 'vertical' && 'overflow-x-auto overflow-y-visible ',
|
||||||
// Give space for button focus states within overflow boundary.
|
// Give space for button focus states within overflow boundary.
|
||||||
layout === 'vertical' && 'py-1 -ml-5 pl-3 pr-1',
|
!addBorders && layout === 'vertical' && 'py-1 pl-3 -ml-5 pr-1',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -125,6 +125,8 @@ export function Tabs({
|
|||||||
<RadioDropdown
|
<RadioDropdown
|
||||||
key={t.value}
|
key={t.value}
|
||||||
items={t.options.items}
|
items={t.options.items}
|
||||||
|
itemsAfter={t.options.itemsAfter}
|
||||||
|
itemsBefore={t.options.itemsBefore}
|
||||||
value={t.options.value}
|
value={t.options.value}
|
||||||
onChange={t.options.onChange}
|
onChange={t.options.onChange}
|
||||||
>
|
>
|
||||||
|
|||||||
63
src-web/components/core/tree/AutoScrollWhileDragging.tsx
Normal file
63
src-web/components/core/tree/AutoScrollWhileDragging.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
// AutoScrollWhileDragging.tsx
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { useDragLayer } from 'react-dnd';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
container: HTMLElement | null | undefined;
|
||||||
|
edgeDistance?: number;
|
||||||
|
maxSpeedPerFrame?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AutoScrollWhileDragging({
|
||||||
|
container,
|
||||||
|
edgeDistance = 30,
|
||||||
|
maxSpeedPerFrame = 6,
|
||||||
|
}: Props) {
|
||||||
|
const rafId = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const { isDragging, pointer } = useDragLayer((monitor) => ({
|
||||||
|
isDragging: monitor.isDragging(),
|
||||||
|
pointer: monitor.getClientOffset(), // { x, y } | null
|
||||||
|
}));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!container || !isDragging) {
|
||||||
|
if (rafId.current != null) cancelAnimationFrame(rafId.current);
|
||||||
|
rafId.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tick = () => {
|
||||||
|
if (!container || !isDragging || !pointer) return;
|
||||||
|
|
||||||
|
const rect = container.getBoundingClientRect();
|
||||||
|
const y = pointer.y;
|
||||||
|
|
||||||
|
// Compute vertical speed based on proximity to edges
|
||||||
|
let dy = 0;
|
||||||
|
if (y < rect.top + edgeDistance) {
|
||||||
|
const t = (rect.top + edgeDistance - y) / edgeDistance; // 0..1
|
||||||
|
dy = -Math.min(maxSpeedPerFrame, Math.ceil(t * maxSpeedPerFrame));
|
||||||
|
} else if (y > rect.bottom - edgeDistance) {
|
||||||
|
const t = (y - (rect.bottom - edgeDistance)) / edgeDistance; // 0..1
|
||||||
|
dy = Math.min(maxSpeedPerFrame, Math.ceil(t * maxSpeedPerFrame));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dy !== 0) {
|
||||||
|
// Only scroll if there’s more content in that direction
|
||||||
|
const prev = container.scrollTop;
|
||||||
|
container.scrollTop = prev + dy;
|
||||||
|
}
|
||||||
|
|
||||||
|
rafId.current = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
|
||||||
|
rafId.current = requestAnimationFrame(tick);
|
||||||
|
return () => {
|
||||||
|
if (rafId.current != null) cancelAnimationFrame(rafId.current);
|
||||||
|
rafId.current = null;
|
||||||
|
};
|
||||||
|
}, [container, isDragging, pointer, edgeDistance, maxSpeedPerFrame]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
557
src-web/components/core/tree/Tree.tsx
Normal file
557
src-web/components/core/tree/Tree.tsx
Normal file
@@ -0,0 +1,557 @@
|
|||||||
|
import type { DragEndEvent, DragMoveEvent, DragStartEvent } from '@dnd-kit/core';
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
PointerSensor,
|
||||||
|
pointerWithin,
|
||||||
|
useDroppable,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
} from '@dnd-kit/core';
|
||||||
|
import { type } from '@tauri-apps/plugin-os';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
|
import type { ComponentType, ReactElement, Ref, RefAttributes } 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';
|
||||||
|
import { sidebarCollapsedAtom } from '../../../hooks/useSidebarItemCollapsed';
|
||||||
|
import { jotaiStore } from '../../../lib/jotai';
|
||||||
|
import type { ContextMenuProps } from '../Dropdown';
|
||||||
|
import { draggingIdsFamily, focusIdsFamily, hoveredParentFamily, selectedIdsFamily } from './atoms';
|
||||||
|
import type { SelectableTreeNode, TreeNode } from './common';
|
||||||
|
import { computeSideForDragMove, equalSubtree, getSelectedItems, hasAncestor } from './common';
|
||||||
|
import { TreeDragOverlay } from './TreeDragOverlay';
|
||||||
|
import type { TreeItemProps } from './TreeItem';
|
||||||
|
import type { TreeItemListProps } from './TreeItemList';
|
||||||
|
import { TreeItemList } from './TreeItemList';
|
||||||
|
|
||||||
|
export interface TreeProps<T extends { id: string }> {
|
||||||
|
root: TreeNode<T>;
|
||||||
|
treeId: string;
|
||||||
|
getItemKey: (item: T) => string;
|
||||||
|
getContextMenu?: (items: T[]) => Promise<ContextMenuProps['items']>;
|
||||||
|
ItemInner: ComponentType<{ treeId: string; item: T }>;
|
||||||
|
ItemLeftSlot?: ComponentType<{ treeId: string; item: T }>;
|
||||||
|
className?: string;
|
||||||
|
onActivate?: (item: T) => void;
|
||||||
|
onDragEnd?: (opt: { items: T[]; parent: T; children: T[]; insertAt: number }) => void;
|
||||||
|
hotkeys?: { actions: Partial<Record<HotkeyAction, (items: T[]) => void>> } & HotKeyOptions;
|
||||||
|
getEditOptions?: (item: T) => {
|
||||||
|
defaultValue: string;
|
||||||
|
placeholder?: string;
|
||||||
|
onChange: (item: T, text: string) => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TreeHandle {
|
||||||
|
focus: () => void;
|
||||||
|
selectItem: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TreeInner<T extends { id: string }>(
|
||||||
|
{
|
||||||
|
className,
|
||||||
|
getContextMenu,
|
||||||
|
getEditOptions,
|
||||||
|
getItemKey,
|
||||||
|
hotkeys,
|
||||||
|
onActivate,
|
||||||
|
onDragEnd,
|
||||||
|
ItemInner,
|
||||||
|
ItemLeftSlot,
|
||||||
|
root,
|
||||||
|
treeId,
|
||||||
|
}: TreeProps<T>,
|
||||||
|
ref: Ref<TreeHandle>,
|
||||||
|
) {
|
||||||
|
const treeRef = useRef<HTMLDivElement>(null);
|
||||||
|
const { treeParentMap, selectableItems } = useTreeParentMap(root, getItemKey);
|
||||||
|
const [isFocused, setIsFocused] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const tryFocus = useCallback(() => {
|
||||||
|
treeRef.current?.querySelector<HTMLButtonElement>('.tree-item button[tabindex="0"]')?.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setSelected = useCallback(
|
||||||
|
function setSelected(ids: string[], focus: boolean) {
|
||||||
|
jotaiStore.set(selectedIdsFamily(treeId), ids);
|
||||||
|
// TODO: Figure out a better way than timeout
|
||||||
|
if (focus) setTimeout(tryFocus, 50);
|
||||||
|
},
|
||||||
|
[treeId, tryFocus],
|
||||||
|
);
|
||||||
|
|
||||||
|
useImperativeHandle(
|
||||||
|
ref,
|
||||||
|
(): TreeHandle => ({
|
||||||
|
focus: tryFocus,
|
||||||
|
selectItem(id) {
|
||||||
|
setSelected([id], false);
|
||||||
|
jotaiStore.set(focusIdsFamily(treeId), { anchorId: id, lastId: id });
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[setSelected, treeId, tryFocus],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleGetContextMenu = useMemo(() => {
|
||||||
|
if (getContextMenu == null) return;
|
||||||
|
return (item: T) => {
|
||||||
|
const items = getSelectedItems(treeId, selectableItems);
|
||||||
|
const isSelected = items.find((i) => i.id === item.id);
|
||||||
|
if (isSelected) {
|
||||||
|
// If right-clicked an item that was in the multiple-selection, use the entire selection
|
||||||
|
return getContextMenu(items);
|
||||||
|
} else {
|
||||||
|
// If right-clicked an item that was NOT in the multiple-selection, just use that one
|
||||||
|
// Also update the selection with it
|
||||||
|
jotaiStore.set(selectedIdsFamily(treeId), [item.id]);
|
||||||
|
jotaiStore.set(focusIdsFamily(treeId), (prev) => ({ ...prev, lastId: item.id }));
|
||||||
|
return getContextMenu([item]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [getContextMenu, selectableItems, treeId]);
|
||||||
|
|
||||||
|
const handleSelect = useCallback<NonNullable<TreeItemProps<T>['onClick']>>(
|
||||||
|
(item, { shiftKey, metaKey, ctrlKey }) => {
|
||||||
|
const anchorSelectedId = jotaiStore.get(focusIdsFamily(treeId)).anchorId;
|
||||||
|
const selectedIdsAtom = selectedIdsFamily(treeId);
|
||||||
|
const selectedIds = jotaiStore.get(selectedIdsAtom);
|
||||||
|
|
||||||
|
// Mark item as the last one selected
|
||||||
|
jotaiStore.set(focusIdsFamily(treeId), (prev) => ({ ...prev, lastId: item.id }));
|
||||||
|
|
||||||
|
if (shiftKey) {
|
||||||
|
const anchorIndex = selectableItems.findIndex((i) => i.node.item.id === anchorSelectedId);
|
||||||
|
const currIndex = selectableItems.findIndex((v) => v.node.item.id === item.id);
|
||||||
|
// Nothing was selected yet, so just select this item
|
||||||
|
if (selectedIds.length === 0 || anchorIndex === -1 || currIndex === -1) {
|
||||||
|
setSelected([item.id], true);
|
||||||
|
jotaiStore.set(focusIdsFamily(treeId), (prev) => ({ ...prev, anchorId: item.id }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currIndex > anchorIndex) {
|
||||||
|
// Selecting down
|
||||||
|
const itemsToSelect = selectableItems.slice(anchorIndex, currIndex + 1);
|
||||||
|
setSelected(
|
||||||
|
itemsToSelect.map((v) => v.node.item.id),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
} else if (currIndex < anchorIndex) {
|
||||||
|
// Selecting up
|
||||||
|
const itemsToSelect = selectableItems.slice(currIndex, anchorIndex + 1);
|
||||||
|
setSelected(
|
||||||
|
itemsToSelect.map((v) => v.node.item.id),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setSelected([item.id], true);
|
||||||
|
}
|
||||||
|
} else if (type() === 'macos' ? metaKey : ctrlKey) {
|
||||||
|
const withoutCurr = selectedIds.filter((id) => id !== item.id);
|
||||||
|
if (withoutCurr.length === selectedIds.length) {
|
||||||
|
// It wasn't in there, so add it
|
||||||
|
setSelected([...selectedIds, item.id], true);
|
||||||
|
} else {
|
||||||
|
// It was in there, so remove it
|
||||||
|
setSelected(withoutCurr, true);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Select single
|
||||||
|
setSelected([item.id], true);
|
||||||
|
jotaiStore.set(focusIdsFamily(treeId), (prev) => ({ ...prev, anchorId: item.id }));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[selectableItems, setSelected, treeId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClick = useCallback<NonNullable<TreeItemProps<T>['onClick']>>(
|
||||||
|
(item, e) => {
|
||||||
|
if (e.shiftKey || e.ctrlKey || e.metaKey) {
|
||||||
|
handleSelect(item, e);
|
||||||
|
} else {
|
||||||
|
handleSelect(item, e);
|
||||||
|
onActivate?.(item);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleSelect, onActivate],
|
||||||
|
);
|
||||||
|
|
||||||
|
useKey(
|
||||||
|
'ArrowUp',
|
||||||
|
(e) => {
|
||||||
|
if (!treeRef.current?.contains(document.activeElement)) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId;
|
||||||
|
const index = selectableItems.findIndex((i) => i.node.item.id === lastSelectedId);
|
||||||
|
const item = selectableItems[index - 1];
|
||||||
|
if (item != null) handleSelect(item.node.item, e);
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
[selectableItems, handleSelect],
|
||||||
|
);
|
||||||
|
|
||||||
|
useKey(
|
||||||
|
'ArrowDown',
|
||||||
|
(e) => {
|
||||||
|
if (!treeRef.current?.contains(document.activeElement)) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId;
|
||||||
|
const index = selectableItems.findIndex((i) => i.node.item.id === lastSelectedId);
|
||||||
|
const item = selectableItems[index + 1];
|
||||||
|
if (item != null) handleSelect(item.node.item, e);
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
[selectableItems, handleSelect],
|
||||||
|
);
|
||||||
|
|
||||||
|
useKeyPressEvent('Escape', async () => {
|
||||||
|
if (!treeRef.current?.contains(document.activeElement)) return;
|
||||||
|
clearDragState();
|
||||||
|
const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId;
|
||||||
|
if (lastSelectedId == null) return;
|
||||||
|
setSelected([lastSelectedId], false);
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleDragMove = useCallback(
|
||||||
|
function handleDragMove(e: DragMoveEvent) {
|
||||||
|
const over = e.over;
|
||||||
|
if (!over) {
|
||||||
|
// Clear the drop indicator when hovering outside the tree
|
||||||
|
jotaiStore.set(hoveredParentFamily(treeId), { parentId: null, index: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not sure when or if this happens
|
||||||
|
if (e.active.rect.current.initial == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Root is anything past the end of the list, so set it to the end
|
||||||
|
const hoveringRoot = over.id === root.item.id;
|
||||||
|
if (hoveringRoot) {
|
||||||
|
jotaiStore.set(hoveredParentFamily(treeId), {
|
||||||
|
parentId: root.item.id,
|
||||||
|
index: root.children?.length ?? 0,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const node = selectableItems.find((i) => i.node.item.id === over.id)?.node ?? null;
|
||||||
|
if (node == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const side = computeSideForDragMove(node, e);
|
||||||
|
|
||||||
|
const item = node.item;
|
||||||
|
let hoveredParent = treeParentMap[item.id] ?? null;
|
||||||
|
const dragIndex = hoveredParent?.children?.findIndex((n) => n.item.id === item.id) ?? -99;
|
||||||
|
const hovered = hoveredParent?.children?.[dragIndex] ?? null;
|
||||||
|
let hoveredIndex = dragIndex + (side === 'above' ? 0 : 1);
|
||||||
|
|
||||||
|
const collapsedMap = jotaiStore.get(jotaiStore.get(sidebarCollapsedAtom));
|
||||||
|
const isHoveredItemCollapsed = hovered != null ? collapsedMap[hovered.item.id] : false;
|
||||||
|
|
||||||
|
if (hovered?.children != null && side === 'below' && !isHoveredItemCollapsed) {
|
||||||
|
// Move into the folder if it's open and we're moving below it
|
||||||
|
hoveredParent = hoveredParent?.children?.find((n) => n.item.id === item.id) ?? null;
|
||||||
|
hoveredIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
jotaiStore.set(hoveredParentFamily(treeId), {
|
||||||
|
parentId: hoveredParent?.item.id ?? null,
|
||||||
|
index: hoveredIndex,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[root.children?.length, root.item.id, selectableItems, treeId, treeParentMap],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDragStart = useCallback(
|
||||||
|
function handleDragStart(e: DragStartEvent) {
|
||||||
|
const item = selectableItems.find((i) => i.node.item.id === e.active.id)?.node.item ?? null;
|
||||||
|
if (item == null) return;
|
||||||
|
|
||||||
|
const selectedItems = getSelectedItems(treeId, selectableItems);
|
||||||
|
const isDraggingSelectedItem = selectedItems.find((i) => i.id === item.id);
|
||||||
|
if (isDraggingSelectedItem) {
|
||||||
|
jotaiStore.set(
|
||||||
|
draggingIdsFamily(treeId),
|
||||||
|
selectedItems.map((i) => i.id),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
jotaiStore.set(draggingIdsFamily(treeId), [item.id]);
|
||||||
|
// Also update selection to just be this one
|
||||||
|
handleSelect(item, { shiftKey: false, metaKey: false, ctrlKey: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleSelect, selectableItems, treeId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const clearDragState = useCallback(() => {
|
||||||
|
jotaiStore.set(hoveredParentFamily(treeId), { parentId: null, index: null });
|
||||||
|
// jotaiStore.set(draggingIdsFamily(treeId), []);
|
||||||
|
}, [treeId]);
|
||||||
|
|
||||||
|
const handleDragEnd = useCallback(
|
||||||
|
function handleDragEnd(e: DragEndEvent) {
|
||||||
|
// Get this from the store so our callback doesn't change all the time
|
||||||
|
const hovered = jotaiStore.get(hoveredParentFamily(treeId));
|
||||||
|
const draggingItems = jotaiStore.get(draggingIdsFamily(treeId));
|
||||||
|
clearDragState();
|
||||||
|
|
||||||
|
// Dropped outside the tree?
|
||||||
|
if (e.over == null) return;
|
||||||
|
|
||||||
|
const hoveredParent =
|
||||||
|
hovered.parentId == root.item.id
|
||||||
|
? root
|
||||||
|
: selectableItems.find((n) => n.node.item.id === hovered.parentId)?.node;
|
||||||
|
|
||||||
|
if (hoveredParent == null || hovered.index == null || !draggingItems?.length) return;
|
||||||
|
|
||||||
|
// Optional tiny guard: don't drop into itself
|
||||||
|
if (draggingItems.some((id) => id === hovered.parentId)) return;
|
||||||
|
|
||||||
|
// Resolve the actual tree nodes for each dragged item (keeps order of draggingItems)
|
||||||
|
const draggedNodes: TreeNode<T>[] = draggingItems
|
||||||
|
.map((id) => {
|
||||||
|
const parent = treeParentMap[id];
|
||||||
|
const idx = parent?.children?.findIndex((n) => n.item.id === id) ?? -1;
|
||||||
|
return idx >= 0 ? parent!.children![idx]! : null;
|
||||||
|
})
|
||||||
|
.filter((n) => n != null)
|
||||||
|
// Filter out invalid drags (dragging into descendant)
|
||||||
|
.filter((n) => !hasAncestor(hoveredParent, n.item.id));
|
||||||
|
|
||||||
|
// Work on a local copy of target children
|
||||||
|
const nextChildren = [...(hoveredParent.children ?? [])];
|
||||||
|
|
||||||
|
// Remove any of the dragged nodes already in the target, adjusting hoveredIndex
|
||||||
|
let insertAt = hovered.index;
|
||||||
|
for (const node of draggedNodes) {
|
||||||
|
const i = nextChildren.findIndex((n) => n.item.id === node.item.id);
|
||||||
|
if (i !== -1) {
|
||||||
|
nextChildren.splice(i, 1);
|
||||||
|
if (i < insertAt) insertAt -= 1; // account for removed-before
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch callback
|
||||||
|
onDragEnd?.({
|
||||||
|
items: draggedNodes.map((n) => n.item),
|
||||||
|
parent: hoveredParent.item,
|
||||||
|
children: nextChildren.map((c) => c.item),
|
||||||
|
insertAt,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[treeId, clearDragState, root, selectableItems, onDragEnd, treeParentMap],
|
||||||
|
);
|
||||||
|
|
||||||
|
const treeItemListProps: Omit<
|
||||||
|
TreeItemListProps<T>,
|
||||||
|
'node' | 'treeId' | 'activeIdAtom' | 'hoveredParent' | 'hoveredIndex'
|
||||||
|
> = {
|
||||||
|
depth: 0,
|
||||||
|
getItemKey,
|
||||||
|
getContextMenu: handleGetContextMenu,
|
||||||
|
onClick: handleClick,
|
||||||
|
getEditOptions,
|
||||||
|
ItemInner,
|
||||||
|
ItemLeftSlot,
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFocus = useCallback(function handleFocus() {
|
||||||
|
setIsFocused(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleBlur = useCallback(function handleBlur() {
|
||||||
|
setIsFocused(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TreeHotKeys treeId={treeId} hotkeys={hotkeys} selectableItems={selectableItems} />
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={pointerWithin}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
onDragCancel={clearDragState}
|
||||||
|
onDragAbort={clearDragState}
|
||||||
|
onDragMove={handleDragMove}
|
||||||
|
autoScroll
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={treeRef}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
className={classNames(
|
||||||
|
className,
|
||||||
|
'outline-none h-full',
|
||||||
|
'overflow-y-auto overflow-x-hidden',
|
||||||
|
'grid grid-rows-[auto_1fr]',
|
||||||
|
' [&_.tree-item.selected]:text-text',
|
||||||
|
isFocused
|
||||||
|
? '[&_.tree-item.selected]:bg-surface-active'
|
||||||
|
: '[&_.tree-item.selected]:bg-surface-highlight',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<TreeItemList node={root} treeId={treeId} {...treeItemListProps} />
|
||||||
|
{/* Assign root ID so we can reuse our same move/end logic */}
|
||||||
|
<DropRegionAfterList id={root.item.id} />
|
||||||
|
<TreeDragOverlay
|
||||||
|
treeId={treeId}
|
||||||
|
root={root}
|
||||||
|
selectableItems={selectableItems}
|
||||||
|
ItemInner={ItemInner}
|
||||||
|
getItemKey={getItemKey}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DndContext>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) Preserve generics through forwardRef:
|
||||||
|
const Tree_ = forwardRef(TreeInner) as <T extends { id: string }>(
|
||||||
|
props: TreeProps<T> & RefAttributes<TreeHandle>,
|
||||||
|
) => ReactElement | null;
|
||||||
|
|
||||||
|
export const Tree = memo(
|
||||||
|
Tree_,
|
||||||
|
({ root: prevNode, ...prevProps }, { root: nextNode, ...nextProps }) => {
|
||||||
|
for (const key of Object.keys(prevProps)) {
|
||||||
|
if (prevProps[key as keyof typeof prevProps] !== nextProps[key as keyof typeof nextProps]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return equalSubtree(prevNode, nextNode, nextProps.getItemKey);
|
||||||
|
},
|
||||||
|
) as typeof Tree_;
|
||||||
|
|
||||||
|
function DropRegionAfterList({ id }: { id: string }) {
|
||||||
|
const { setNodeRef } = useDroppable({ id });
|
||||||
|
return <div ref={setNodeRef} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useTreeParentMap<T extends { id: string }>(
|
||||||
|
root: TreeNode<T>,
|
||||||
|
getItemKey: (item: T) => string,
|
||||||
|
) {
|
||||||
|
const collapsedMap = useAtomValue(useAtomValue(sidebarCollapsedAtom));
|
||||||
|
const [{ treeParentMap, selectableItems }, setData] = useState(() => {
|
||||||
|
return compute(root, collapsedMap);
|
||||||
|
});
|
||||||
|
|
||||||
|
const prevRoot = useRef<TreeNode<T> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const shouldRecompute =
|
||||||
|
root == null || prevRoot.current == null || !equalSubtree(root, prevRoot.current, getItemKey);
|
||||||
|
if (!shouldRecompute) return;
|
||||||
|
setData(compute(root, collapsedMap));
|
||||||
|
prevRoot.current = root;
|
||||||
|
}, [collapsedMap, getItemKey, root]);
|
||||||
|
|
||||||
|
return { treeParentMap, selectableItems };
|
||||||
|
}
|
||||||
|
|
||||||
|
function compute<T extends { id: string }>(
|
||||||
|
root: TreeNode<T>,
|
||||||
|
collapsedMap: Record<string, boolean>,
|
||||||
|
) {
|
||||||
|
const treeParentMap: Record<string, TreeNode<T>> = {};
|
||||||
|
const selectableItems: SelectableTreeNode<T>[] = [];
|
||||||
|
|
||||||
|
// Put requests and folders into a tree structure
|
||||||
|
const next = (node: TreeNode<T>, depth: number = 0) => {
|
||||||
|
const isCollapsed = collapsedMap[node.item.id] === true;
|
||||||
|
// console.log("IS COLLAPSED", node.item.name, isCollapsed);
|
||||||
|
if (node.children == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recurse to children
|
||||||
|
let selectableIndex = 0;
|
||||||
|
for (const child of node.children) {
|
||||||
|
treeParentMap[child.item.id] = node;
|
||||||
|
if (!isCollapsed) {
|
||||||
|
selectableItems.push({
|
||||||
|
node: child,
|
||||||
|
index: selectableIndex++,
|
||||||
|
depth,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
next(child, depth + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
next(root);
|
||||||
|
return { treeParentMap, selectableItems };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TreeHotKeyProps<T extends { id: string }> extends HotKeyOptions {
|
||||||
|
action: HotkeyAction;
|
||||||
|
selectableItems: SelectableTreeNode<T>[];
|
||||||
|
treeId: string;
|
||||||
|
onDone: (items: T[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TreeHotKey<T extends { id: string }>({
|
||||||
|
treeId,
|
||||||
|
action,
|
||||||
|
onDone,
|
||||||
|
selectableItems,
|
||||||
|
...options
|
||||||
|
}: TreeHotKeyProps<T>) {
|
||||||
|
useHotKey(
|
||||||
|
action,
|
||||||
|
() => {
|
||||||
|
onDone(getSelectedItems(treeId, selectableItems));
|
||||||
|
},
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TreeHotKeys<T extends { id: string }>({
|
||||||
|
treeId,
|
||||||
|
hotkeys,
|
||||||
|
selectableItems,
|
||||||
|
}: {
|
||||||
|
treeId: string;
|
||||||
|
hotkeys: TreeProps<T>['hotkeys'];
|
||||||
|
selectableItems: SelectableTreeNode<T>[];
|
||||||
|
}) {
|
||||||
|
if (hotkeys == null) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{Object.entries(hotkeys.actions).map(([hotkey, onDone]) => (
|
||||||
|
<TreeHotKey
|
||||||
|
key={hotkey}
|
||||||
|
action={hotkey as HotkeyAction}
|
||||||
|
priority={hotkeys.priority}
|
||||||
|
enable={hotkeys.enable}
|
||||||
|
treeId={treeId}
|
||||||
|
onDone={onDone}
|
||||||
|
selectableItems={selectableItems}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
src-web/components/core/tree/TreeDragOverlay.tsx
Normal file
43
src-web/components/core/tree/TreeDragOverlay.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { DragOverlay } from '@dnd-kit/core';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
|
import { draggingIdsFamily } from './atoms';
|
||||||
|
import type { SelectableTreeNode, TreeNode } from './common';
|
||||||
|
import type { TreeProps } from './Tree';
|
||||||
|
import { TreeItemList } from './TreeItemList';
|
||||||
|
|
||||||
|
export function TreeDragOverlay<T extends { id: string }>({
|
||||||
|
treeId,
|
||||||
|
root,
|
||||||
|
selectableItems,
|
||||||
|
getItemKey,
|
||||||
|
ItemInner,
|
||||||
|
ItemLeftSlot,
|
||||||
|
}: {
|
||||||
|
treeId: string;
|
||||||
|
root: TreeNode<T>;
|
||||||
|
selectableItems: SelectableTreeNode<T>[];
|
||||||
|
} & Pick<TreeProps<T>, 'getItemKey' | 'ItemInner' | 'ItemLeftSlot'>) {
|
||||||
|
const draggingItems = useAtomValue(draggingIdsFamily(treeId));
|
||||||
|
return (
|
||||||
|
<DragOverlay dropAnimation={null}>
|
||||||
|
<TreeItemList
|
||||||
|
treeId={treeId + '.dragging'}
|
||||||
|
node={{
|
||||||
|
item: { ...root.item, id: `${root.item.id}_dragging` },
|
||||||
|
parent: null,
|
||||||
|
children: draggingItems.map((id) => {
|
||||||
|
const child = selectableItems.find((i2) => {
|
||||||
|
return i2.node.item.id === id;
|
||||||
|
})!.node;
|
||||||
|
return { ...child, children: undefined };
|
||||||
|
// Remove children so we don't render them in the drag preview
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
getItemKey={getItemKey}
|
||||||
|
ItemInner={ItemInner}
|
||||||
|
ItemLeftSlot={ItemLeftSlot}
|
||||||
|
depth={0}
|
||||||
|
/>
|
||||||
|
</DragOverlay>
|
||||||
|
);
|
||||||
|
}
|
||||||
273
src-web/components/core/tree/TreeItem.tsx
Normal file
273
src-web/components/core/tree/TreeItem.tsx
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
import type { DragMoveEvent } from '@dnd-kit/core';
|
||||||
|
import { useDndMonitor, useDraggable, useDroppable } from '@dnd-kit/core';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
|
import type { MouseEvent, PointerEvent } from 'react';
|
||||||
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { jotaiStore } from '../../../lib/jotai';
|
||||||
|
import type { ContextMenuProps, DropdownItem } from '../Dropdown';
|
||||||
|
import { ContextMenu } from '../Dropdown';
|
||||||
|
import { Icon } from '../Icon';
|
||||||
|
import {
|
||||||
|
isCollapsedFamily,
|
||||||
|
isLastFocusedFamily,
|
||||||
|
isParentHoveredFamily,
|
||||||
|
isSelectedFamily,
|
||||||
|
} from './atoms';
|
||||||
|
import type { TreeNode } from './common';
|
||||||
|
import { computeSideForDragMove } from './common';
|
||||||
|
import type { TreeProps } from './Tree';
|
||||||
|
|
||||||
|
interface OnClickEvent {
|
||||||
|
shiftKey: boolean;
|
||||||
|
ctrlKey: boolean;
|
||||||
|
metaKey: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TreeItemProps<T extends { id: string }> = Pick<
|
||||||
|
TreeProps<T>,
|
||||||
|
'ItemInner' | 'ItemLeftSlot' | 'treeId' | 'getEditOptions'
|
||||||
|
> & {
|
||||||
|
node: TreeNode<T>;
|
||||||
|
className?: string;
|
||||||
|
onClick?: (item: T, e: OnClickEvent) => void;
|
||||||
|
getContextMenu?: (item: T) => Promise<ContextMenuProps['items']>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const HOVER_CLOSED_FOLDER_DELAY = 800;
|
||||||
|
|
||||||
|
export function TreeItem<T extends { id: string }>({
|
||||||
|
treeId,
|
||||||
|
node,
|
||||||
|
ItemInner,
|
||||||
|
ItemLeftSlot,
|
||||||
|
getContextMenu,
|
||||||
|
onClick,
|
||||||
|
getEditOptions,
|
||||||
|
className,
|
||||||
|
}: TreeItemProps<T>) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const draggableRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const isSelected = useAtomValue(isSelectedFamily({ treeId, itemId: node.item.id }));
|
||||||
|
const isCollapsed = useAtomValue(isCollapsedFamily({ treeId, itemId: node.item.id }));
|
||||||
|
const isHoveredAsParent = useAtomValue(isParentHoveredFamily({ treeId, parentId: node.item.id }));
|
||||||
|
const isLastSelected = useAtomValue(isLastFocusedFamily({ treeId, itemId: node.item.id }));
|
||||||
|
const [editing, setEditing] = useState<boolean>(false);
|
||||||
|
const [isDropHover, setIsDropHover] = useState<boolean>(false);
|
||||||
|
const startedHoverTimeout = useRef<NodeJS.Timeout>(undefined);
|
||||||
|
|
||||||
|
const [showContextMenu, setShowContextMenu] = useState<{
|
||||||
|
items: DropdownItem[];
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
function scrollIntoViewWhenSelected() {
|
||||||
|
return jotaiStore.sub(isSelectedFamily({ treeId, itemId: node.item.id }), () => {
|
||||||
|
ref.current?.scrollIntoView({ block: 'nearest' });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[node.item.id, treeId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClick = useCallback(
|
||||||
|
function handleClick(e: MouseEvent<HTMLButtonElement>) {
|
||||||
|
onClick?.(node.item, e);
|
||||||
|
},
|
||||||
|
[node, onClick],
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleCollapsed = useCallback(
|
||||||
|
function toggleCollapsed() {
|
||||||
|
jotaiStore.set(isCollapsedFamily({ treeId, itemId: node.item.id }), (prev) => !prev);
|
||||||
|
},
|
||||||
|
[node.item.id, treeId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSubmitNameEdit = useCallback(
|
||||||
|
async function submitNameEdit(el: HTMLInputElement) {
|
||||||
|
getEditOptions?.(node.item).onChange(node.item, el.value);
|
||||||
|
// Slight delay for the model to propagate to the local store
|
||||||
|
setTimeout(() => setEditing(false), 200);
|
||||||
|
},
|
||||||
|
[getEditOptions, node.item],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleEditFocus = useCallback(function handleEditFocus(el: HTMLInputElement | null) {
|
||||||
|
el?.focus();
|
||||||
|
el?.select();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleEditBlur = useCallback(
|
||||||
|
async function editBlur(e: React.FocusEvent<HTMLInputElement>) {
|
||||||
|
await handleSubmitNameEdit(e.currentTarget);
|
||||||
|
},
|
||||||
|
[handleSubmitNameEdit],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleEditKeyDown = useCallback(
|
||||||
|
async (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
switch (e.key) {
|
||||||
|
case 'Enter':
|
||||||
|
e.preventDefault();
|
||||||
|
await handleSubmitNameEdit(e.currentTarget);
|
||||||
|
break;
|
||||||
|
case 'Escape':
|
||||||
|
e.preventDefault();
|
||||||
|
setEditing(false);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleSubmitNameEdit],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDoubleClick = useCallback(() => {
|
||||||
|
const isFolder = node.children != null;
|
||||||
|
if (isFolder) {
|
||||||
|
toggleCollapsed();
|
||||||
|
} else if (getEditOptions != null) {
|
||||||
|
setEditing(true);
|
||||||
|
}
|
||||||
|
}, [getEditOptions, node.children, toggleCollapsed]);
|
||||||
|
|
||||||
|
const clearHoverTimer = () => {
|
||||||
|
if (startedHoverTimeout.current) {
|
||||||
|
setIsDropHover(false); // NEW
|
||||||
|
clearTimeout(startedHoverTimeout.current); // NEW
|
||||||
|
startedHoverTimeout.current = undefined; // NEW
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Toggle auto-expand of folders when hovering over them
|
||||||
|
useDndMonitor({
|
||||||
|
onDragMove(e: DragMoveEvent) {
|
||||||
|
const side = computeSideForDragMove(node, e);
|
||||||
|
const isFolderWithChildren = (node.children?.length ?? 0) > 0;
|
||||||
|
const isCollapsed = jotaiStore.get(isCollapsedFamily({ treeId, itemId: node.item.id }));
|
||||||
|
if (isCollapsed && isFolderWithChildren && side === 'below') {
|
||||||
|
setIsDropHover(true);
|
||||||
|
clearTimeout(startedHoverTimeout.current);
|
||||||
|
startedHoverTimeout.current = setTimeout(() => {
|
||||||
|
jotaiStore.set(isCollapsedFamily({ treeId, itemId: node.item.id }), false);
|
||||||
|
setIsDropHover(false);
|
||||||
|
}, HOVER_CLOSED_FOLDER_DELAY);
|
||||||
|
} else {
|
||||||
|
clearHoverTimer();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleContextMenu = useCallback(
|
||||||
|
async (e: MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (getContextMenu == null) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const items = await getContextMenu(node.item);
|
||||||
|
setShowContextMenu({ items, x: e.clientX, y: e.clientY });
|
||||||
|
},
|
||||||
|
[getContextMenu, node.item],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCloseContextMenu = useCallback(() => {
|
||||||
|
setShowContextMenu(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const { attributes, listeners, setNodeRef: setDraggableRef } = useDraggable({ id: node.item.id });
|
||||||
|
const { setNodeRef: setDroppableRef } = useDroppable({ id: node.item.id });
|
||||||
|
|
||||||
|
const handlePointerDown = useCallback(
|
||||||
|
function handlePointerDown(e: PointerEvent<HTMLButtonElement>) {
|
||||||
|
const handleByTree = e.metaKey || e.ctrlKey || e.shiftKey;
|
||||||
|
if (!handleByTree) {
|
||||||
|
listeners?.onPointerDown?.(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[listeners],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSetDraggableRef = useCallback(
|
||||||
|
(node: HTMLButtonElement | null) => {
|
||||||
|
draggableRef.current = node;
|
||||||
|
setDraggableRef(node);
|
||||||
|
setDroppableRef(node);
|
||||||
|
},
|
||||||
|
[setDraggableRef, setDroppableRef],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
onContextMenu={handleContextMenu}
|
||||||
|
className={classNames(
|
||||||
|
className,
|
||||||
|
'tree-item',
|
||||||
|
isSelected && 'selected',
|
||||||
|
'text-text-subtle',
|
||||||
|
'h-sm grid grid-cols-[auto_minmax(0,1fr)] items-center rounded-md px-1.5',
|
||||||
|
editing && 'ring-1 focus-within:ring-focus',
|
||||||
|
isDropHover && 'relative z-10 ring-2 ring-primary animate-blinkRing',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{showContextMenu && (
|
||||||
|
<ContextMenu
|
||||||
|
items={showContextMenu.items}
|
||||||
|
triggerPosition={showContextMenu}
|
||||||
|
onClose={handleCloseContextMenu}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{node.children != null ? (
|
||||||
|
<button
|
||||||
|
tabIndex={-1}
|
||||||
|
className="h-full w-[2.8rem] pr-[0.5rem] -ml-[1rem]"
|
||||||
|
onClick={toggleCollapsed}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon="chevron_right"
|
||||||
|
className={classNames(
|
||||||
|
'transition-transform text-text-subtlest',
|
||||||
|
'ml-auto !h-[1rem] !w-[1rem]',
|
||||||
|
node.children.length == 0 && 'opacity-0',
|
||||||
|
!isCollapsed && 'rotate-90',
|
||||||
|
isHoveredAsParent && '!text-text',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
ref={handleSetDraggableRef}
|
||||||
|
onPointerDown={handlePointerDown}
|
||||||
|
onClick={handleClick}
|
||||||
|
onDoubleClick={handleDoubleClick}
|
||||||
|
disabled={editing}
|
||||||
|
className="focus:outline-none flex items-center gap-2 h-full whitespace-nowrap"
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
tabIndex={isLastSelected ? 0 : -1}
|
||||||
|
>
|
||||||
|
{ItemLeftSlot != null && <ItemLeftSlot treeId={treeId} item={node.item} />}
|
||||||
|
{getEditOptions != null && editing ? (
|
||||||
|
(() => {
|
||||||
|
const { defaultValue, placeholder } = getEditOptions(node.item);
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
ref={handleEditFocus}
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="bg-transparent outline-none w-full cursor-text"
|
||||||
|
onBlur={handleEditBlur}
|
||||||
|
onKeyDown={handleEditKeyDown}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()
|
||||||
|
) : (
|
||||||
|
<ItemInner treeId={treeId} item={node.item} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
131
src-web/components/core/tree/TreeItemList.tsx
Normal file
131
src-web/components/core/tree/TreeItemList.tsx
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import classNames from 'classnames';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
|
import type { CSSProperties } from 'react';
|
||||||
|
import { Fragment, memo } from 'react';
|
||||||
|
import { DropMarker } from '../../DropMarker';
|
||||||
|
import { isCollapsedFamily, isItemHoveredFamily, isParentHoveredFamily } from './atoms';
|
||||||
|
import type { TreeNode } from './common';
|
||||||
|
import { equalSubtree } from './common';
|
||||||
|
import type { TreeProps } from './Tree';
|
||||||
|
import type { TreeItemProps } from './TreeItem';
|
||||||
|
import { TreeItem } from './TreeItem';
|
||||||
|
|
||||||
|
export type TreeItemListProps<T extends { id: string }> = Pick<
|
||||||
|
TreeProps<T>,
|
||||||
|
'ItemInner' | 'ItemLeftSlot' | 'treeId' | 'getItemKey' | 'getEditOptions'
|
||||||
|
> &
|
||||||
|
Pick<TreeItemProps<T>, 'onClick' | 'getContextMenu'> & {
|
||||||
|
node: TreeNode<T>;
|
||||||
|
depth: number;
|
||||||
|
style?: CSSProperties;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function TreeItemList_<T extends { id: string }>({
|
||||||
|
className,
|
||||||
|
depth,
|
||||||
|
getContextMenu,
|
||||||
|
getEditOptions,
|
||||||
|
getItemKey,
|
||||||
|
node,
|
||||||
|
onClick,
|
||||||
|
ItemInner,
|
||||||
|
ItemLeftSlot,
|
||||||
|
style,
|
||||||
|
treeId,
|
||||||
|
}: TreeItemListProps<T>) {
|
||||||
|
const isHovered = useAtomValue(isParentHoveredFamily({ treeId, parentId: node.item.id }));
|
||||||
|
const isCollapsed = useAtomValue(isCollapsedFamily({ treeId, itemId: node.item.id }));
|
||||||
|
const childList = !isCollapsed && node.children != null && (
|
||||||
|
<ul
|
||||||
|
style={style}
|
||||||
|
className={classNames(
|
||||||
|
className,
|
||||||
|
depth > 0 && 'ml-[calc(1.2rem+0.5px)] pl-[0.7rem] border-l',
|
||||||
|
isHovered ? 'border-l-text-subtle' : 'border-l-border-subtle',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{node.children.map(function mapChild(child, i) {
|
||||||
|
return (
|
||||||
|
<Fragment key={getItemKey(child.item)}>
|
||||||
|
<TreeDropMarker treeId={treeId} parent={node} index={i} />
|
||||||
|
<TreeItemList
|
||||||
|
treeId={treeId}
|
||||||
|
node={child}
|
||||||
|
ItemInner={ItemInner}
|
||||||
|
ItemLeftSlot={ItemLeftSlot}
|
||||||
|
onClick={onClick}
|
||||||
|
getEditOptions={getEditOptions}
|
||||||
|
depth={depth + 1}
|
||||||
|
getItemKey={getItemKey}
|
||||||
|
getContextMenu={getContextMenu}
|
||||||
|
/>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<TreeDropMarker treeId={treeId} parent={node ?? null} index={node.children?.length ?? 0} />
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (depth === 0) {
|
||||||
|
return childList;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li>
|
||||||
|
<TreeItem
|
||||||
|
treeId={treeId}
|
||||||
|
node={node}
|
||||||
|
getContextMenu={getContextMenu}
|
||||||
|
ItemInner={ItemInner}
|
||||||
|
ItemLeftSlot={ItemLeftSlot}
|
||||||
|
onClick={onClick}
|
||||||
|
getEditOptions={getEditOptions}
|
||||||
|
/>
|
||||||
|
{childList}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TreeItemList = memo(
|
||||||
|
TreeItemList_,
|
||||||
|
({ node: prevNode, ...prevProps }, { node: nextNode, ...nextProps }) => {
|
||||||
|
const nonEqualKeys = [];
|
||||||
|
for (const key of Object.keys(prevProps)) {
|
||||||
|
if (prevProps[key as keyof typeof prevProps] !== nextProps[key as keyof typeof nextProps]) {
|
||||||
|
nonEqualKeys.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (nonEqualKeys.length > 0) {
|
||||||
|
// console.log('TreeItemList: ', nonEqualKeys);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return equalSubtree(prevNode, nextNode, nextProps.getItemKey);
|
||||||
|
},
|
||||||
|
) as typeof TreeItemList_;
|
||||||
|
|
||||||
|
const TreeDropMarker = memo(function TreeDropMarker<T extends { id: string }>({
|
||||||
|
className,
|
||||||
|
treeId,
|
||||||
|
parent,
|
||||||
|
index,
|
||||||
|
}: {
|
||||||
|
treeId: string;
|
||||||
|
parent: TreeNode<T> | null;
|
||||||
|
index: number;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const isHovered = useAtomValue(isItemHoveredFamily({ treeId, parentId: parent?.item.id, index }));
|
||||||
|
const isLastItem = parent?.children?.length === index;
|
||||||
|
const isLastItemHovered = useAtomValue(
|
||||||
|
isItemHoveredFamily({
|
||||||
|
treeId,
|
||||||
|
parentId: parent?.item.id,
|
||||||
|
index: parent?.children?.length ?? 0,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isHovered && !(isLastItem && isLastItemHovered)) return null;
|
||||||
|
|
||||||
|
return <DropMarker className={classNames(className)} />;
|
||||||
|
});
|
||||||
89
src-web/components/core/tree/atoms.ts
Normal file
89
src-web/components/core/tree/atoms.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { atom } from 'jotai';
|
||||||
|
import { atomFamily, selectAtom } from 'jotai/utils';
|
||||||
|
import { atomWithKVStorage } from '../../../lib/atoms/atomWithKVStorage';
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
export const selectedIdsFamily = atomFamily((_treeId: string) => {
|
||||||
|
return atom<string[]>([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const isSelectedFamily = atomFamily(
|
||||||
|
({ treeId, itemId }: { treeId: string; itemId: string }) => {
|
||||||
|
return selectAtom(selectedIdsFamily(treeId), (ids) => ids.includes(itemId), Object.is);
|
||||||
|
},
|
||||||
|
(a, b) => a.treeId === b.treeId && a.itemId === b.itemId,
|
||||||
|
);
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
export const focusIdsFamily = atomFamily((_treeId: string) => {
|
||||||
|
return atom<{ lastId: string | null; anchorId: string | null }>({ lastId: null, anchorId: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
export const isLastFocusedFamily = atomFamily(
|
||||||
|
({ treeId, itemId }: { treeId: string; itemId: string }) =>
|
||||||
|
selectAtom(focusIdsFamily(treeId), (v) => v.lastId == itemId, Object.is),
|
||||||
|
(a, b) => a.treeId === b.treeId && a.itemId === b.itemId,
|
||||||
|
);
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
export const draggingIdsFamily = atomFamily((_treeId: string) => {
|
||||||
|
return atom<string[]>([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
export const hoveredParentFamily = atomFamily((_treeId: string) => {
|
||||||
|
return atom<{ index: number | null; parentId: string | null }>({ index: null, parentId: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
export const isParentHoveredFamily = atomFamily(
|
||||||
|
({ treeId, parentId }: { treeId: string; parentId: string | null | undefined }) =>
|
||||||
|
selectAtom(hoveredParentFamily(treeId), (v) => v.parentId === parentId, Object.is),
|
||||||
|
(a, b) => a.treeId === b.treeId && a.parentId === b.parentId,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const isItemHoveredFamily = atomFamily(
|
||||||
|
({
|
||||||
|
treeId,
|
||||||
|
parentId,
|
||||||
|
index,
|
||||||
|
}: {
|
||||||
|
treeId: string;
|
||||||
|
parentId: string | null | undefined;
|
||||||
|
index: number | null;
|
||||||
|
}) =>
|
||||||
|
selectAtom(
|
||||||
|
hoveredParentFamily(treeId),
|
||||||
|
(v) => v.parentId === parentId && v.index === index,
|
||||||
|
Object.is,
|
||||||
|
),
|
||||||
|
(a, b) => a.treeId === b.treeId && a.parentId === b.parentId && a.index === b.index,
|
||||||
|
);
|
||||||
|
|
||||||
|
function kvKey(workspaceId: string | null) {
|
||||||
|
return ['sidebar_collapsed', workspaceId ?? 'n/a'];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const collapsedFamily = atomFamily((workspaceId: string) => {
|
||||||
|
return atomWithKVStorage<Record<string, boolean>>(kvKey(workspaceId), {});
|
||||||
|
});
|
||||||
|
|
||||||
|
export const isCollapsedFamily = atomFamily(
|
||||||
|
({ treeId, itemId }: { treeId: string; itemId: string }) =>
|
||||||
|
atom(
|
||||||
|
// --- getter ---
|
||||||
|
(get) => !!get(collapsedFamily(treeId))[itemId],
|
||||||
|
|
||||||
|
// --- setter ---
|
||||||
|
(get, set, next: boolean | ((prev: boolean) => boolean)) => {
|
||||||
|
const a = collapsedFamily(treeId);
|
||||||
|
const prevMap = get(a);
|
||||||
|
const prevValue = !!prevMap[itemId];
|
||||||
|
const value = typeof next === 'function' ? next(prevValue) : next;
|
||||||
|
|
||||||
|
if (value === prevValue) return; // no-op
|
||||||
|
|
||||||
|
set(a, { ...prevMap, [itemId]: value });
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(a, b) => a.treeId === b.treeId && a.itemId === b.itemId,
|
||||||
|
);
|
||||||
70
src-web/components/core/tree/common.ts
Normal file
70
src-web/components/core/tree/common.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import type { DragMoveEvent } from '@dnd-kit/core';
|
||||||
|
import { jotaiStore } from '../../../lib/jotai';
|
||||||
|
import { selectedIdsFamily } from './atoms';
|
||||||
|
|
||||||
|
export interface TreeNode<T extends { id: string }> {
|
||||||
|
children?: TreeNode<T>[];
|
||||||
|
item: T;
|
||||||
|
parent: TreeNode<T> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectableTreeNode<T extends { id: string }> {
|
||||||
|
node: TreeNode<T>;
|
||||||
|
depth: number;
|
||||||
|
index: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSelectedItems<T extends { id: string }>(
|
||||||
|
treeId: string,
|
||||||
|
selectableItems: SelectableTreeNode<T>[],
|
||||||
|
) {
|
||||||
|
const selectedItemIds = jotaiStore.get(selectedIdsFamily(treeId));
|
||||||
|
return selectableItems
|
||||||
|
.filter((i) => selectedItemIds.includes(i.node.item.id))
|
||||||
|
.map((i) => i.node.item);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function equalSubtree<T extends { id: string }>(
|
||||||
|
a: TreeNode<T>,
|
||||||
|
b: TreeNode<T>,
|
||||||
|
getKey: (t: T) => string,
|
||||||
|
): boolean {
|
||||||
|
if (getKey(a.item) !== getKey(b.item)) return false;
|
||||||
|
const ak = a.children ?? [];
|
||||||
|
const bk = b.children ?? [];
|
||||||
|
if (ak.length !== bk.length) return false;
|
||||||
|
for (let i = 0; i < ak.length; i++) {
|
||||||
|
if (!equalSubtree(ak[i]!, bk[i]!, getKey)) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasAncestor<T extends { id: string }>(node: TreeNode<T>, ancestorId: string) {
|
||||||
|
// Check parents recursively
|
||||||
|
if (node.parent == null) return false;
|
||||||
|
if (node.parent.item.id === ancestorId) return true;
|
||||||
|
return hasAncestor(node.parent, ancestorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeSideForDragMove<T extends { id: string }>(
|
||||||
|
node: TreeNode<T>,
|
||||||
|
e: DragMoveEvent,
|
||||||
|
): 'above' | 'below' | null {
|
||||||
|
if (e.over == null || e.over.id !== node.item.id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (e.active.rect.current.initial == null) return null;
|
||||||
|
|
||||||
|
const overRect = e.over.rect;
|
||||||
|
const activeTop =
|
||||||
|
e.active.rect.current.translated?.top ?? e.active.rect.current.initial.top + e.delta.y;
|
||||||
|
const pointerY = activeTop + e.active.rect.current.initial.height / 2;
|
||||||
|
|
||||||
|
const hoverTop = overRect.top;
|
||||||
|
const hoverBottom = overRect.bottom;
|
||||||
|
const hoverMiddleY = (hoverBottom - hoverTop) / 2;
|
||||||
|
const hoverClientY = pointerY - hoverTop;
|
||||||
|
|
||||||
|
return hoverClientY < hoverMiddleY ? 'above' : 'below';
|
||||||
|
}
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
export enum ItemTypes {
|
export enum ItemTypes {
|
||||||
REQUEST = 'request',
|
TREE_ITEM = 'tree.item',
|
||||||
SIDEBAR = 'sidebar',
|
TREE = 'tree',
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DragItem = {
|
export type DragItem = {
|
||||||
id: string;
|
id: string;
|
||||||
itemName: string;
|
|
||||||
};
|
};
|
||||||
@@ -25,7 +25,7 @@ export function WebPageViewer({ response }: Props) {
|
|||||||
srcDoc={contentForIframe}
|
srcDoc={contentForIframe}
|
||||||
sandbox="allow-scripts allow-forms"
|
sandbox="allow-scripts allow-forms"
|
||||||
referrerPolicy="no-referrer"
|
referrerPolicy="no-referrer"
|
||||||
className="h-full w-full rounded border border-border-subtle"
|
className="h-full w-full rounded-lg border border-border-subtle"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,369 +0,0 @@
|
|||||||
import type {
|
|
||||||
Folder,
|
|
||||||
GrpcRequest,
|
|
||||||
HttpRequest,
|
|
||||||
WebsocketRequest,
|
|
||||||
Workspace,
|
|
||||||
} from '@yaakapp-internal/models';
|
|
||||||
import { getAnyModel, patchModelById } from '@yaakapp-internal/models';
|
|
||||||
import classNames from 'classnames';
|
|
||||||
import { useAtom, useAtomValue } from 'jotai';
|
|
||||||
import React, { useCallback, useRef, useState } from 'react';
|
|
||||||
import { useDrop } from 'react-dnd';
|
|
||||||
import { useKey, useKeyPressEvent } from 'react-use';
|
|
||||||
import { activeRequestIdAtom } from '../../hooks/useActiveRequestId';
|
|
||||||
import { activeWorkspaceAtom } from '../../hooks/useActiveWorkspace';
|
|
||||||
import { useCreateDropdownItems } from '../../hooks/useCreateDropdownItems';
|
|
||||||
import { useHotKey } from '../../hooks/useHotKey';
|
|
||||||
import { useSidebarHidden } from '../../hooks/useSidebarHidden';
|
|
||||||
import { getSidebarCollapsedMap } from '../../hooks/useSidebarItemCollapsed';
|
|
||||||
import { deleteModelWithConfirm } from '../../lib/deleteModelWithConfirm';
|
|
||||||
import { jotaiStore } from '../../lib/jotai';
|
|
||||||
import { router } from '../../lib/router';
|
|
||||||
import { setWorkspaceSearchParams } from '../../lib/setWorkspaceSearchParams';
|
|
||||||
import { ContextMenu } from '../core/Dropdown';
|
|
||||||
import { GitDropdown } from '../GitDropdown';
|
|
||||||
import type { DragItem } from './dnd';
|
|
||||||
import { ItemTypes } from './dnd';
|
|
||||||
import { sidebarSelectedIdAtom, sidebarTreeAtom } from './SidebarAtoms';
|
|
||||||
import type { SidebarItemProps } from './SidebarItem';
|
|
||||||
import { SidebarItems } from './SidebarItems';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SidebarModel = Folder | GrpcRequest | HttpRequest | WebsocketRequest | Workspace;
|
|
||||||
|
|
||||||
export interface SidebarTreeNode {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
model: SidebarModel['model'];
|
|
||||||
sortPriority?: number;
|
|
||||||
workspaceId?: string;
|
|
||||||
folderId?: string | null;
|
|
||||||
children: SidebarTreeNode[];
|
|
||||||
depth: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Sidebar({ className }: Props) {
|
|
||||||
const [hidden, setHidden] = useSidebarHidden();
|
|
||||||
const sidebarRef = useRef<HTMLElement>(null);
|
|
||||||
const activeWorkspace = useAtomValue(activeWorkspaceAtom);
|
|
||||||
const [hasFocus, setHasFocus] = useState<boolean>(false);
|
|
||||||
const [selectedId, setSelectedId] = useAtom(sidebarSelectedIdAtom);
|
|
||||||
const [selectedTree, setSelectedTree] = useState<SidebarTreeNode | null>(null);
|
|
||||||
const [draggingId, setDraggingId] = useState<string | null>(null);
|
|
||||||
const [hoveredTree, setHoveredTree] = useState<SidebarTreeNode | null>(null);
|
|
||||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
|
||||||
|
|
||||||
const { tree, treeParentMap, selectableRequests } = useAtomValue(sidebarTreeAtom);
|
|
||||||
|
|
||||||
const focusActiveRequest = useCallback(
|
|
||||||
(
|
|
||||||
args: {
|
|
||||||
forced?: {
|
|
||||||
id: string;
|
|
||||||
tree: SidebarTreeNode;
|
|
||||||
};
|
|
||||||
noFocusSidebar?: boolean;
|
|
||||||
} = {},
|
|
||||||
) => {
|
|
||||||
const activeRequestId = jotaiStore.get(activeRequestIdAtom);
|
|
||||||
const { forced, noFocusSidebar } = args;
|
|
||||||
const tree = forced?.tree ?? treeParentMap[activeRequestId ?? 'n/a'] ?? null;
|
|
||||||
const children = tree?.children ?? [];
|
|
||||||
const id = forced?.id ?? children.find((m) => m.id === activeRequestId)?.id ?? null;
|
|
||||||
|
|
||||||
setHasFocus(true);
|
|
||||||
setSelectedId(id);
|
|
||||||
setSelectedTree(tree);
|
|
||||||
|
|
||||||
if (id == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!noFocusSidebar) {
|
|
||||||
sidebarRef.current?.focus();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[setHasFocus, setSelectedId, treeParentMap],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSelect = useCallback(
|
|
||||||
async (id: string) => {
|
|
||||||
const tree = treeParentMap[id ?? 'n/a'] ?? null;
|
|
||||||
const children = tree?.children ?? [];
|
|
||||||
const node = children.find((m) => m.id === id) ?? null;
|
|
||||||
if (node == null || tree == null || node.model === 'workspace') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// NOTE: I'm not sure why, but TS thinks workspaceId is (string | undefined) here
|
|
||||||
if (node.model !== 'folder' && node.workspaceId) {
|
|
||||||
const workspaceId = node.workspaceId;
|
|
||||||
await router.navigate({
|
|
||||||
to: '/workspaces/$workspaceId',
|
|
||||||
params: { workspaceId },
|
|
||||||
search: (prev) => ({ ...prev, request_id: node.id }),
|
|
||||||
});
|
|
||||||
|
|
||||||
setHasFocus(true);
|
|
||||||
setSelectedId(id);
|
|
||||||
setSelectedTree(tree);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[treeParentMap, setSelectedId],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleClearSelected = useCallback(() => {
|
|
||||||
setSelectedId(null);
|
|
||||||
setSelectedTree(null);
|
|
||||||
}, [setSelectedId]);
|
|
||||||
|
|
||||||
const handleFocus = useCallback(() => {
|
|
||||||
if (hasFocus) return;
|
|
||||||
focusActiveRequest({ noFocusSidebar: true });
|
|
||||||
}, [focusActiveRequest, hasFocus]);
|
|
||||||
|
|
||||||
const handleBlur = useCallback(() => setHasFocus(false), [setHasFocus]);
|
|
||||||
|
|
||||||
useHotKey(
|
|
||||||
'sidebar.delete_selected_item',
|
|
||||||
async () => {
|
|
||||||
const request = getAnyModel(selectedId ?? 'n/a');
|
|
||||||
if (request != null) {
|
|
||||||
await deleteModelWithConfirm(request);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ enable: hasFocus },
|
|
||||||
);
|
|
||||||
|
|
||||||
useHotKey('sidebar.focus', async () => {
|
|
||||||
// Hide the sidebar if it's already focused
|
|
||||||
if (!hidden && hasFocus) {
|
|
||||||
await setHidden(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show the sidebar if it's hidden
|
|
||||||
if (hidden) {
|
|
||||||
await setHidden(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Select 0th index on focus if none selected
|
|
||||||
focusActiveRequest(
|
|
||||||
selectedTree != null && selectedId != null
|
|
||||||
? { forced: { id: selectedId, tree: selectedTree } }
|
|
||||||
: undefined,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
useKeyPressEvent('Enter', async (e) => {
|
|
||||||
if (!hasFocus) return;
|
|
||||||
const selected = selectableRequests.find((r) => r.id === selectedId);
|
|
||||||
if (!selected || activeWorkspace == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
e.preventDefault();
|
|
||||||
setWorkspaceSearchParams({ request_id: selected.id });
|
|
||||||
});
|
|
||||||
|
|
||||||
useKey(
|
|
||||||
'ArrowUp',
|
|
||||||
(e) => {
|
|
||||||
if (!hasFocus) return;
|
|
||||||
e.preventDefault();
|
|
||||||
const i = selectableRequests.findIndex((r) => r.id === selectedId);
|
|
||||||
const newI = i <= 0 ? selectableRequests.length - 1 : i - 1;
|
|
||||||
const newSelectable = selectableRequests[newI];
|
|
||||||
if (newSelectable == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSelectedId(newSelectable.id);
|
|
||||||
setSelectedTree(newSelectable.tree);
|
|
||||||
},
|
|
||||||
undefined,
|
|
||||||
[hasFocus, selectableRequests, selectedId, setSelectedId, setSelectedTree],
|
|
||||||
);
|
|
||||||
|
|
||||||
useKey(
|
|
||||||
'ArrowDown',
|
|
||||||
(e) => {
|
|
||||||
if (!hasFocus) return;
|
|
||||||
e.preventDefault();
|
|
||||||
const i = selectableRequests.findIndex((r) => r.id === selectedId);
|
|
||||||
const newI = i >= selectableRequests.length - 1 ? 0 : i + 1;
|
|
||||||
const newSelectable = selectableRequests[newI];
|
|
||||||
if (newSelectable == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSelectedId(newSelectable.id);
|
|
||||||
setSelectedTree(newSelectable.tree);
|
|
||||||
},
|
|
||||||
undefined,
|
|
||||||
[hasFocus, selectableRequests, selectedId, setSelectedId, setSelectedTree],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleMoveToSidebarEnd = useCallback(() => {
|
|
||||||
setHoveredTree(tree);
|
|
||||||
// Put at the end of the top tree
|
|
||||||
setHoveredIndex(tree?.children?.length ?? 0);
|
|
||||||
}, [tree]);
|
|
||||||
|
|
||||||
const handleMove = useCallback<SidebarItemProps['onMove']>(
|
|
||||||
(id, side) => {
|
|
||||||
let hoveredTree = treeParentMap[id] ?? null;
|
|
||||||
const dragIndex = hoveredTree?.children.findIndex((n) => n.id === id) ?? -99;
|
|
||||||
const hoveredItem = hoveredTree?.children[dragIndex] ?? null;
|
|
||||||
let hoveredIndex = dragIndex + (side === 'above' ? 0 : 1);
|
|
||||||
|
|
||||||
const collapsedMap = getSidebarCollapsedMap();
|
|
||||||
const isHoveredItemCollapsed = hoveredItem != null ? collapsedMap[hoveredItem.id] : false;
|
|
||||||
|
|
||||||
if (hoveredItem?.model === 'folder' && side === 'below' && !isHoveredItemCollapsed) {
|
|
||||||
// Move into the folder if it's open and we're moving below it
|
|
||||||
hoveredTree = hoveredTree?.children.find((n) => n.id === id) ?? null;
|
|
||||||
hoveredIndex = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
setHoveredTree(hoveredTree);
|
|
||||||
setHoveredIndex(hoveredIndex);
|
|
||||||
},
|
|
||||||
[treeParentMap],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleDragStart = useCallback<SidebarItemProps['onDragStart']>((id: string) => {
|
|
||||||
setDraggingId(id);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleEnd = useCallback<SidebarItemProps['onEnd']>(
|
|
||||||
async (itemId) => {
|
|
||||||
setHoveredTree(null);
|
|
||||||
setDraggingId(null);
|
|
||||||
handleClearSelected();
|
|
||||||
|
|
||||||
if (hoveredTree == null || hoveredIndex == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Block dragging folder into itself
|
|
||||||
if (hoveredTree.id === itemId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parentTree = treeParentMap[itemId] ?? null;
|
|
||||||
const index = parentTree?.children.findIndex((n) => n.id === itemId) ?? -1;
|
|
||||||
const child = parentTree?.children[index ?? -1];
|
|
||||||
if (child == null || parentTree == null) return;
|
|
||||||
|
|
||||||
const movedToDifferentTree = hoveredTree.id !== parentTree.id;
|
|
||||||
const movedUpInSameTree = !movedToDifferentTree && hoveredIndex < index;
|
|
||||||
|
|
||||||
const newChildren = hoveredTree.children.filter((c) => c.id !== itemId);
|
|
||||||
if (movedToDifferentTree || movedUpInSameTree) {
|
|
||||||
// Moving up or into a new tree is simply inserting before the hovered item
|
|
||||||
newChildren.splice(hoveredIndex, 0, child);
|
|
||||||
} else {
|
|
||||||
// Moving down has to account for the fact that the original item will be removed
|
|
||||||
newChildren.splice(hoveredIndex - 1, 0, child);
|
|
||||||
}
|
|
||||||
|
|
||||||
const insertedIndex = newChildren.findIndex((c) => c.id === child.id);
|
|
||||||
const prev = newChildren[insertedIndex - 1];
|
|
||||||
const next = newChildren[insertedIndex + 1];
|
|
||||||
const beforePriority = prev?.sortPriority ?? 0;
|
|
||||||
const afterPriority = next?.sortPriority ?? 0;
|
|
||||||
|
|
||||||
const folderId = hoveredTree.model === 'folder' ? hoveredTree.id : null;
|
|
||||||
const shouldUpdateAll = afterPriority - beforePriority < 1;
|
|
||||||
|
|
||||||
if (shouldUpdateAll) {
|
|
||||||
await Promise.all(
|
|
||||||
newChildren.map((child, i) => {
|
|
||||||
const sortPriority = i * 1000;
|
|
||||||
return patchModelById(child.model, child.id, { sortPriority, folderId });
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
const sortPriority = afterPriority - (afterPriority - beforePriority) / 2;
|
|
||||||
await patchModelById(child.model, child.id, { sortPriority, folderId });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[handleClearSelected, hoveredTree, hoveredIndex, treeParentMap],
|
|
||||||
);
|
|
||||||
|
|
||||||
const [showMainContextMenu, setShowMainContextMenu] = useState<{
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
const handleMainContextMenu = useCallback((e: React.MouseEvent<HTMLElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
setShowMainContextMenu({ x: e.clientX, y: e.clientY });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const mainContextMenuItems = useCreateDropdownItems({ folderId: null });
|
|
||||||
|
|
||||||
const [, connectDrop] = useDrop<DragItem, void>(
|
|
||||||
{
|
|
||||||
accept: ItemTypes.REQUEST,
|
|
||||||
hover: (_, monitor) => {
|
|
||||||
if (sidebarRef.current == null) return;
|
|
||||||
if (!monitor.isOver({ shallow: true })) return;
|
|
||||||
handleMoveToSidebarEnd();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
[handleMoveToSidebarEnd],
|
|
||||||
);
|
|
||||||
|
|
||||||
connectDrop(sidebarRef);
|
|
||||||
|
|
||||||
// Not ready to render yet
|
|
||||||
if (tree == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<aside
|
|
||||||
aria-hidden={hidden ?? undefined}
|
|
||||||
ref={sidebarRef}
|
|
||||||
onFocus={handleFocus}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
tabIndex={hidden ? -1 : 0}
|
|
||||||
onContextMenu={handleMainContextMenu}
|
|
||||||
data-focused={hasFocus}
|
|
||||||
className={classNames(
|
|
||||||
className,
|
|
||||||
// Style item selection color here, because it's very hard to do in an efficient
|
|
||||||
// way in the item itself (selection ID makes it hard)
|
|
||||||
hasFocus && '[&_[data-selected=true]]:bg-surface-active',
|
|
||||||
'h-full grid grid-rows-[minmax(0,1fr)_auto]',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="pb-3 overflow-x-visible overflow-y-scroll pt-2 pr-0.5">
|
|
||||||
<ContextMenu
|
|
||||||
triggerPosition={showMainContextMenu}
|
|
||||||
items={mainContextMenuItems}
|
|
||||||
onClose={() => setShowMainContextMenu(null)}
|
|
||||||
/>
|
|
||||||
<SidebarItems
|
|
||||||
treeParentMap={treeParentMap}
|
|
||||||
selectedTree={selectedTree}
|
|
||||||
tree={tree}
|
|
||||||
draggingId={draggingId}
|
|
||||||
onSelect={handleSelect}
|
|
||||||
hoveredIndex={hoveredIndex}
|
|
||||||
hoveredTree={hoveredTree}
|
|
||||||
handleMove={handleMove}
|
|
||||||
handleEnd={handleEnd}
|
|
||||||
handleDragStart={handleDragStart}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<GitDropdown />
|
|
||||||
</aside>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
import {
|
|
||||||
type Folder,
|
|
||||||
foldersAtom,
|
|
||||||
type GrpcRequest,
|
|
||||||
type HttpRequest,
|
|
||||||
type WebsocketRequest,
|
|
||||||
} from '@yaakapp-internal/models';
|
|
||||||
|
|
||||||
// This is an atom, so we can use it in the child items to avoid re-rendering the entire list
|
|
||||||
import { atom } from 'jotai';
|
|
||||||
import { activeWorkspaceAtom } from '../../hooks/useActiveWorkspace';
|
|
||||||
import { allRequestsAtom } from '../../hooks/useAllRequests';
|
|
||||||
import { deepEqualAtom } from '../../lib/atoms';
|
|
||||||
import { resolvedModelName } from '../../lib/resolvedModelName';
|
|
||||||
import type { SidebarTreeNode } from './Sidebar';
|
|
||||||
|
|
||||||
export const sidebarSelectedIdAtom = atom<string | null>(null);
|
|
||||||
|
|
||||||
const allPotentialChildrenAtom = atom((get) => {
|
|
||||||
const requests = get(allRequestsAtom);
|
|
||||||
const folders = get(foldersAtom);
|
|
||||||
return [...requests, ...folders].map((v) => ({
|
|
||||||
id: v.id,
|
|
||||||
model: v.model,
|
|
||||||
folderId: v.folderId,
|
|
||||||
name: resolvedModelName(v),
|
|
||||||
workspaceId: v.workspaceId,
|
|
||||||
sortPriority: v.sortPriority,
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
|
|
||||||
const memoAllPotentialChildrenAtom = deepEqualAtom(allPotentialChildrenAtom);
|
|
||||||
|
|
||||||
export const sidebarTreeAtom = atom<{
|
|
||||||
tree: SidebarTreeNode | null;
|
|
||||||
treeParentMap: Record<string, SidebarTreeNode>;
|
|
||||||
selectableRequests: {
|
|
||||||
id: string;
|
|
||||||
index: number;
|
|
||||||
tree: SidebarTreeNode;
|
|
||||||
}[];
|
|
||||||
}>((get) => {
|
|
||||||
const allModels = get(memoAllPotentialChildrenAtom);
|
|
||||||
const activeWorkspace = get(activeWorkspaceAtom);
|
|
||||||
|
|
||||||
const childrenMap: Record<string, typeof allModels> = {};
|
|
||||||
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<string, SidebarTreeNode> = {};
|
|
||||||
const selectableRequests: {
|
|
||||||
id: string;
|
|
||||||
index: number;
|
|
||||||
tree: SidebarTreeNode;
|
|
||||||
}[] = [];
|
|
||||||
|
|
||||||
if (activeWorkspace == null) {
|
|
||||||
return { tree: null, treeParentMap, selectableRequests };
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedRequest: HttpRequest | GrpcRequest | WebsocketRequest | null = null;
|
|
||||||
let selectableRequestIndex = 0;
|
|
||||||
|
|
||||||
// Put requests and folders into a tree structure
|
|
||||||
const next = (node: SidebarTreeNode): SidebarTreeNode => {
|
|
||||||
const childItems = childrenMap[node.id] ?? [];
|
|
||||||
|
|
||||||
// Recurse to children
|
|
||||||
const depth = node.depth + 1;
|
|
||||||
childItems.sort((a, b) => a.sortPriority - b.sortPriority);
|
|
||||||
for (const childItem of childItems) {
|
|
||||||
treeParentMap[childItem.id] = node;
|
|
||||||
// Add to children
|
|
||||||
node.children.push(next(itemFromModel(childItem, depth)));
|
|
||||||
// Add to selectable requests
|
|
||||||
if (childItem.model !== 'folder') {
|
|
||||||
selectableRequests.push({
|
|
||||||
id: childItem.id,
|
|
||||||
index: selectableRequestIndex++,
|
|
||||||
tree: node,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return node;
|
|
||||||
};
|
|
||||||
|
|
||||||
const tree = next({
|
|
||||||
id: activeWorkspace.id,
|
|
||||||
name: activeWorkspace.name,
|
|
||||||
model: activeWorkspace.model,
|
|
||||||
children: [],
|
|
||||||
depth: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
return { tree, treeParentMap, selectableRequests, selectedRequest };
|
|
||||||
});
|
|
||||||
|
|
||||||
function itemFromModel(
|
|
||||||
item: Pick<
|
|
||||||
Folder | HttpRequest | GrpcRequest | WebsocketRequest,
|
|
||||||
'folderId' | 'model' | 'workspaceId' | 'id' | 'name' | 'sortPriority'
|
|
||||||
>,
|
|
||||||
depth = 0,
|
|
||||||
): SidebarTreeNode {
|
|
||||||
return {
|
|
||||||
id: item.id,
|
|
||||||
name: item.name,
|
|
||||||
model: item.model,
|
|
||||||
sortPriority: 'sortPriority' in item ? item.sortPriority : -1,
|
|
||||||
workspaceId: item.workspaceId,
|
|
||||||
folderId: item.folderId,
|
|
||||||
depth,
|
|
||||||
children: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,300 +0,0 @@
|
|||||||
import type {
|
|
||||||
AnyModel,
|
|
||||||
GrpcConnection,
|
|
||||||
HttpResponse,
|
|
||||||
WebsocketConnection,
|
|
||||||
} from '@yaakapp-internal/models';
|
|
||||||
import { foldersAtom, patchModelById } from '@yaakapp-internal/models';
|
|
||||||
import classNames from 'classnames';
|
|
||||||
import { atom, useAtomValue } from 'jotai';
|
|
||||||
import type { ReactElement } from 'react';
|
|
||||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
||||||
import type { XYCoord } from 'react-dnd';
|
|
||||||
import { useDrag, useDrop } from 'react-dnd';
|
|
||||||
import { activeRequestAtom } from '../../hooks/useActiveRequest';
|
|
||||||
import { allRequestsAtom } from '../../hooks/useAllRequests';
|
|
||||||
import { useScrollIntoView } from '../../hooks/useScrollIntoView';
|
|
||||||
import { useSidebarItemCollapsed } from '../../hooks/useSidebarItemCollapsed';
|
|
||||||
import { jotaiStore } from '../../lib/jotai';
|
|
||||||
import { HttpMethodTag } from '../core/HttpMethodTag';
|
|
||||||
import { HttpStatusTag } from '../core/HttpStatusTag';
|
|
||||||
import { Icon } from '../core/Icon';
|
|
||||||
import { LoadingIcon } from '../core/LoadingIcon';
|
|
||||||
import type { DragItem} from './dnd';
|
|
||||||
import { ItemTypes } from './dnd';
|
|
||||||
import type { SidebarTreeNode } from './Sidebar';
|
|
||||||
import { sidebarSelectedIdAtom } from './SidebarAtoms';
|
|
||||||
import { SidebarItemContextMenu } from './SidebarItemContextMenu';
|
|
||||||
import type { SidebarItemsProps } from './SidebarItems';
|
|
||||||
|
|
||||||
export type SidebarItemProps = {
|
|
||||||
className?: string;
|
|
||||||
itemId: string;
|
|
||||||
itemName: string;
|
|
||||||
itemModel: AnyModel['model'];
|
|
||||||
onMove: (id: string, side: 'above' | 'below') => void;
|
|
||||||
onEnd: (id: string) => void;
|
|
||||||
onDragStart: (id: string) => void;
|
|
||||||
children: ReactElement<typeof SidebarItem> | null;
|
|
||||||
child: SidebarTreeNode;
|
|
||||||
latestHttpResponse: HttpResponse | null;
|
|
||||||
latestGrpcConnection: GrpcConnection | null;
|
|
||||||
latestWebsocketConnection: WebsocketConnection | null;
|
|
||||||
} & Pick<SidebarItemsProps, 'onSelect'>;
|
|
||||||
|
|
||||||
export const SidebarItem = memo(function SidebarItem({
|
|
||||||
itemName,
|
|
||||||
itemId,
|
|
||||||
itemModel,
|
|
||||||
child,
|
|
||||||
onMove,
|
|
||||||
onEnd,
|
|
||||||
onDragStart,
|
|
||||||
onSelect,
|
|
||||||
className,
|
|
||||||
latestHttpResponse,
|
|
||||||
latestGrpcConnection,
|
|
||||||
latestWebsocketConnection,
|
|
||||||
children,
|
|
||||||
}: SidebarItemProps) {
|
|
||||||
const ref = useRef<HTMLLIElement>(null);
|
|
||||||
const [collapsed, toggleCollapsed] = useSidebarItemCollapsed(itemId);
|
|
||||||
|
|
||||||
const [, connectDrop] = useDrop<DragItem, void>(
|
|
||||||
{
|
|
||||||
accept: [ItemTypes.REQUEST, ItemTypes.SIDEBAR],
|
|
||||||
hover: (_, monitor) => {
|
|
||||||
if (!ref.current) return;
|
|
||||||
if (!monitor.isOver()) return;
|
|
||||||
const hoverBoundingRect = ref.current?.getBoundingClientRect();
|
|
||||||
const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
|
|
||||||
const clientOffset = monitor.getClientOffset();
|
|
||||||
const hoverClientY = (clientOffset as XYCoord).y - hoverBoundingRect.top;
|
|
||||||
onMove(itemId, hoverClientY < hoverMiddleY ? 'above' : 'below');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
[onMove],
|
|
||||||
);
|
|
||||||
|
|
||||||
const [, connectDrag] = useDrag<
|
|
||||||
DragItem,
|
|
||||||
unknown,
|
|
||||||
{
|
|
||||||
isDragging: boolean;
|
|
||||||
}
|
|
||||||
>(
|
|
||||||
() => ({
|
|
||||||
type: ItemTypes.REQUEST,
|
|
||||||
item: () => {
|
|
||||||
// Cancel drag when editing
|
|
||||||
if (editing) return null;
|
|
||||||
onDragStart(itemId);
|
|
||||||
return { id: itemId, itemName };
|
|
||||||
},
|
|
||||||
collect: (m) => ({ isDragging: m.isDragging() }),
|
|
||||||
options: { dropEffect: 'move' },
|
|
||||||
end: () => onEnd(itemId),
|
|
||||||
}),
|
|
||||||
[onEnd],
|
|
||||||
);
|
|
||||||
|
|
||||||
connectDrag(connectDrop(ref));
|
|
||||||
|
|
||||||
const [editing, setEditing] = useState<boolean>(false);
|
|
||||||
|
|
||||||
const [selected, setSelected] = useState<boolean>(
|
|
||||||
jotaiStore.get(sidebarSelectedIdAtom) == itemId,
|
|
||||||
);
|
|
||||||
useEffect(() => {
|
|
||||||
return jotaiStore.sub(sidebarSelectedIdAtom, () => {
|
|
||||||
const value = jotaiStore.get(sidebarSelectedIdAtom);
|
|
||||||
setSelected(value === itemId);
|
|
||||||
});
|
|
||||||
}, [itemId]);
|
|
||||||
|
|
||||||
const [active, setActive] = useState<boolean>(jotaiStore.get(activeRequestAtom)?.id === itemId);
|
|
||||||
useEffect(
|
|
||||||
() =>
|
|
||||||
jotaiStore.sub(activeRequestAtom, () =>
|
|
||||||
setActive(jotaiStore.get(activeRequestAtom)?.id === itemId),
|
|
||||||
),
|
|
||||||
[itemId],
|
|
||||||
);
|
|
||||||
|
|
||||||
useScrollIntoView(ref.current, active);
|
|
||||||
|
|
||||||
const handleSubmitNameEdit = useCallback(
|
|
||||||
async (el: HTMLInputElement) => {
|
|
||||||
await patchModelById(itemModel, itemId, { name: el.value });
|
|
||||||
|
|
||||||
// Slight delay for the model to propagate to the local store
|
|
||||||
setTimeout(() => setEditing(false));
|
|
||||||
},
|
|
||||||
[itemId, itemModel],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleFocus = useCallback((el: HTMLInputElement | null) => {
|
|
||||||
el?.focus();
|
|
||||||
el?.select();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleInputKeyDown = useCallback(
|
|
||||||
async (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
switch (e.key) {
|
|
||||||
case 'Enter':
|
|
||||||
e.preventDefault();
|
|
||||||
await handleSubmitNameEdit(e.currentTarget);
|
|
||||||
break;
|
|
||||||
case 'Escape':
|
|
||||||
e.preventDefault();
|
|
||||||
setEditing(false);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[handleSubmitNameEdit],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleStartEditing = useCallback(() => {
|
|
||||||
if (
|
|
||||||
itemModel !== 'http_request' &&
|
|
||||||
itemModel !== 'grpc_request' &&
|
|
||||||
itemModel !== 'websocket_request'
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
setEditing(true);
|
|
||||||
}, [setEditing, itemModel]);
|
|
||||||
|
|
||||||
const handleBlur = useCallback(
|
|
||||||
async (e: React.FocusEvent<HTMLInputElement>) => {
|
|
||||||
await handleSubmitNameEdit(e.currentTarget);
|
|
||||||
},
|
|
||||||
[handleSubmitNameEdit],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSelect = useCallback(async () => {
|
|
||||||
if (itemModel === 'folder') {
|
|
||||||
toggleCollapsed();
|
|
||||||
} else {
|
|
||||||
onSelect(itemId);
|
|
||||||
}
|
|
||||||
}, [itemModel, toggleCollapsed, onSelect, itemId]);
|
|
||||||
const [showContextMenu, setShowContextMenu] = useState<{
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
const handleContextMenu = useCallback((e: React.MouseEvent<HTMLElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
setShowContextMenu({ x: e.clientX, y: e.clientY });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleCloseContextMenu = useCallback(() => setShowContextMenu(null), []);
|
|
||||||
|
|
||||||
const itemAtom = useMemo(() => {
|
|
||||||
return atom((get) => {
|
|
||||||
if (itemModel === 'folder') {
|
|
||||||
return get(foldersAtom).find((v) => v.id === itemId);
|
|
||||||
} else {
|
|
||||||
return get(allRequestsAtom).find((v) => v.id === itemId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [itemId, itemModel]);
|
|
||||||
|
|
||||||
const item = useAtomValue(itemAtom);
|
|
||||||
|
|
||||||
if (item == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const opacitySubtle = 'opacity-80';
|
|
||||||
|
|
||||||
const itemPrefix = item.model !== 'folder' && (
|
|
||||||
<HttpMethodTag
|
|
||||||
short
|
|
||||||
request={item}
|
|
||||||
className={classNames('text-xs', !(active || selected) && opacitySubtle)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<li ref={ref} draggable>
|
|
||||||
<div className={classNames(className, 'block relative group/item pl-2 pb-0.5')}>
|
|
||||||
{showContextMenu && (
|
|
||||||
<SidebarItemContextMenu
|
|
||||||
child={child}
|
|
||||||
show={showContextMenu}
|
|
||||||
close={handleCloseContextMenu}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
// tabIndex={-1} // Will prevent drag-n-drop
|
|
||||||
disabled={editing}
|
|
||||||
onClick={handleSelect}
|
|
||||||
onDoubleClick={handleStartEditing}
|
|
||||||
onContextMenu={handleContextMenu}
|
|
||||||
data-active={active}
|
|
||||||
data-selected={selected}
|
|
||||||
className={classNames(
|
|
||||||
'w-full flex gap-1.5 items-center h-xs px-1.5 rounded-md focus-visible:ring focus-visible:ring-border-focus outline-0',
|
|
||||||
editing && 'ring-1 focus-within:ring-focus',
|
|
||||||
'hover:bg-surface-highlight',
|
|
||||||
active && 'bg-surface-highlight text-text',
|
|
||||||
!active && 'text-text-subtle',
|
|
||||||
showContextMenu && '!text-text', // Show as "active" when the context menu is open
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{itemModel === 'folder' && (
|
|
||||||
<Icon
|
|
||||||
size="sm"
|
|
||||||
icon="chevron_right"
|
|
||||||
color="secondary"
|
|
||||||
className={classNames('transition-transform', !collapsed && 'transform rotate-90')}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
|
||||||
{itemPrefix}
|
|
||||||
{editing ? (
|
|
||||||
<input
|
|
||||||
ref={handleFocus}
|
|
||||||
defaultValue={itemName}
|
|
||||||
className="bg-transparent outline-none w-full cursor-text"
|
|
||||||
onBlur={handleBlur}
|
|
||||||
onKeyDown={handleInputKeyDown}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="truncate w-full">{itemName}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{latestGrpcConnection ? (
|
|
||||||
<div className="ml-auto">
|
|
||||||
{latestGrpcConnection.state !== 'closed' && (
|
|
||||||
<LoadingIcon size="sm" className="text-text-subtlest" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : latestWebsocketConnection ? (
|
|
||||||
<div className="ml-auto">
|
|
||||||
{latestWebsocketConnection.state !== 'closed' && (
|
|
||||||
<LoadingIcon size="sm" className="text-text-subtlest" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : latestHttpResponse ? (
|
|
||||||
<div className="ml-auto">
|
|
||||||
{latestHttpResponse.state !== 'closed' ? (
|
|
||||||
<LoadingIcon size="sm" className="text-text-subtlest" />
|
|
||||||
) : (
|
|
||||||
<HttpStatusTag
|
|
||||||
short
|
|
||||||
className={classNames('text-xs', !(active || selected) && opacitySubtle)}
|
|
||||||
response={latestHttpResponse}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{collapsed ? null : children}
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
import { duplicateModelById, getModel, workspacesAtom } from '@yaakapp-internal/models';
|
|
||||||
import { useAtomValue } from 'jotai';
|
|
||||||
import React, { useMemo } from 'react';
|
|
||||||
import { openFolderSettings } from '../../commands/openFolderSettings';
|
|
||||||
import { useCreateDropdownItems } from '../../hooks/useCreateDropdownItems';
|
|
||||||
import { useGrpcRequestActions } from '../../hooks/useGrpcRequestActions';
|
|
||||||
import { useHttpRequestActions } from '../../hooks/useHttpRequestActions';
|
|
||||||
import { useMoveToWorkspace } from '../../hooks/useMoveToWorkspace';
|
|
||||||
import { useSendAnyHttpRequest } from '../../hooks/useSendAnyHttpRequest';
|
|
||||||
import { useSendManyRequests } from '../../hooks/useSendManyRequests';
|
|
||||||
import { deleteModelWithConfirm } from '../../lib/deleteModelWithConfirm';
|
|
||||||
|
|
||||||
import { duplicateRequestAndNavigate } from '../../lib/duplicateRequestAndNavigate';
|
|
||||||
import { renameModelWithPrompt } from '../../lib/renameModelWithPrompt';
|
|
||||||
import type { DropdownItem } from '../core/Dropdown';
|
|
||||||
import { ContextMenu } from '../core/Dropdown';
|
|
||||||
import { Icon } from '../core/Icon';
|
|
||||||
import type { SidebarTreeNode } from './Sidebar';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
child: SidebarTreeNode;
|
|
||||||
show: { x: number; y: number } | null;
|
|
||||||
close: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SidebarItemContextMenu({ child, show, close }: Props) {
|
|
||||||
const sendManyRequests = useSendManyRequests();
|
|
||||||
const httpRequestActions = useHttpRequestActions();
|
|
||||||
const grpcRequestActions = useGrpcRequestActions();
|
|
||||||
const sendRequest = useSendAnyHttpRequest();
|
|
||||||
const workspaces = useAtomValue(workspacesAtom);
|
|
||||||
const moveToWorkspace = useMoveToWorkspace(child.id);
|
|
||||||
const createDropdownItems = useCreateDropdownItems({
|
|
||||||
folderId: child.model === 'folder' ? child.id : null,
|
|
||||||
});
|
|
||||||
|
|
||||||
const items = useMemo((): DropdownItem[] => {
|
|
||||||
if (child.model === 'folder') {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
label: 'Settings',
|
|
||||||
leftSlot: <Icon icon="settings" />,
|
|
||||||
onSelect: () => openFolderSettings(child.id),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Duplicate',
|
|
||||||
leftSlot: <Icon icon="copy" />,
|
|
||||||
onSelect: () => duplicateModelById(child.model, child.id),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Send All',
|
|
||||||
leftSlot: <Icon icon="send_horizontal" />,
|
|
||||||
onSelect: () => sendManyRequests.mutate(child.children.map((c) => c.id)),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Delete',
|
|
||||||
color: 'danger',
|
|
||||||
leftSlot: <Icon icon="trash" />,
|
|
||||||
onSelect: async () => {
|
|
||||||
await deleteModelWithConfirm(getModel(child.model, child.id));
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ type: 'separator' },
|
|
||||||
...createDropdownItems,
|
|
||||||
];
|
|
||||||
} else {
|
|
||||||
const requestItems: DropdownItem[] =
|
|
||||||
child.model === 'http_request'
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
label: 'Send',
|
|
||||||
hotKeyAction: 'http_request.send',
|
|
||||||
hotKeyLabelOnly: true, // Already bound in URL bar
|
|
||||||
leftSlot: <Icon icon="send_horizontal" />,
|
|
||||||
onSelect: () => sendRequest.mutate(child.id),
|
|
||||||
},
|
|
||||||
...httpRequestActions.map((a) => ({
|
|
||||||
label: a.label,
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
leftSlot: <Icon icon={(a.icon as any) ?? 'empty'} />,
|
|
||||||
onSelect: async () => {
|
|
||||||
const request = getModel('http_request', child.id);
|
|
||||||
if (request != null) await a.call(request);
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
{ type: 'separator' },
|
|
||||||
]
|
|
||||||
: child.model === 'grpc_request'
|
|
||||||
? grpcRequestActions.map((a) => ({
|
|
||||||
label: a.label,
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
leftSlot: <Icon icon={(a.icon as any) ?? 'empty'} />,
|
|
||||||
onSelect: async () => {
|
|
||||||
const request = getModel('grpc_request', child.id);
|
|
||||||
if (request != null) await a.call(request);
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
: [];
|
|
||||||
return [
|
|
||||||
...requestItems,
|
|
||||||
{
|
|
||||||
label: 'Rename',
|
|
||||||
leftSlot: <Icon icon="pencil" />,
|
|
||||||
onSelect: async () => {
|
|
||||||
const request = getModel(
|
|
||||||
['http_request', 'grpc_request', 'websocket_request'],
|
|
||||||
child.id,
|
|
||||||
);
|
|
||||||
await renameModelWithPrompt(request);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Duplicate',
|
|
||||||
hotKeyAction: 'http_request.duplicate',
|
|
||||||
hotKeyLabelOnly: true, // Would trigger for every request (bad)
|
|
||||||
leftSlot: <Icon icon="copy" />,
|
|
||||||
onSelect: async () => {
|
|
||||||
const request = getModel(
|
|
||||||
['http_request', 'grpc_request', 'websocket_request'],
|
|
||||||
child.id,
|
|
||||||
);
|
|
||||||
await duplicateRequestAndNavigate(request);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Move',
|
|
||||||
leftSlot: <Icon icon="arrow_right_circle" />,
|
|
||||||
hidden: workspaces.length <= 1,
|
|
||||||
onSelect: moveToWorkspace.mutate,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
color: 'danger',
|
|
||||||
label: 'Delete',
|
|
||||||
hotKeyAction: 'sidebar.delete_selected_item',
|
|
||||||
hotKeyLabelOnly: true,
|
|
||||||
leftSlot: <Icon icon="trash" />,
|
|
||||||
onSelect: async () => {
|
|
||||||
await deleteModelWithConfirm(getModel(child.model, child.id));
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
child.children,
|
|
||||||
child.id,
|
|
||||||
child.model,
|
|
||||||
createDropdownItems,
|
|
||||||
httpRequestActions,
|
|
||||||
grpcRequestActions,
|
|
||||||
moveToWorkspace.mutate,
|
|
||||||
sendManyRequests,
|
|
||||||
sendRequest,
|
|
||||||
workspaces.length,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return <ContextMenu triggerPosition={show} items={items} onClose={close} />;
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
import {
|
|
||||||
grpcConnectionsAtom,
|
|
||||||
httpResponsesAtom,
|
|
||||||
websocketConnectionsAtom,
|
|
||||||
} from '@yaakapp-internal/models';
|
|
||||||
import classNames from 'classnames';
|
|
||||||
import { useAtomValue } from 'jotai';
|
|
||||||
import React, { Fragment, memo } from 'react';
|
|
||||||
import { VStack } from '../core/Stacks';
|
|
||||||
import { DropMarker } from '../DropMarker';
|
|
||||||
import type { SidebarTreeNode } from './Sidebar';
|
|
||||||
import { SidebarItem } from './SidebarItem';
|
|
||||||
|
|
||||||
export interface SidebarItemsProps {
|
|
||||||
tree: SidebarTreeNode;
|
|
||||||
draggingId: string | null;
|
|
||||||
selectedTree: SidebarTreeNode | null;
|
|
||||||
treeParentMap: Record<string, SidebarTreeNode>;
|
|
||||||
hoveredTree: SidebarTreeNode | null;
|
|
||||||
hoveredIndex: number | null;
|
|
||||||
handleMove: (id: string, side: 'above' | 'below') => void;
|
|
||||||
handleEnd: (id: string) => void;
|
|
||||||
handleDragStart: (id: string) => void;
|
|
||||||
onSelect: (requestId: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SidebarItems = memo(function SidebarItems({
|
|
||||||
tree,
|
|
||||||
selectedTree,
|
|
||||||
draggingId,
|
|
||||||
onSelect,
|
|
||||||
treeParentMap,
|
|
||||||
hoveredTree,
|
|
||||||
hoveredIndex,
|
|
||||||
handleEnd,
|
|
||||||
handleMove,
|
|
||||||
handleDragStart,
|
|
||||||
}: SidebarItemsProps) {
|
|
||||||
const httpResponses = useAtomValue(httpResponsesAtom);
|
|
||||||
const grpcConnections = useAtomValue(grpcConnectionsAtom);
|
|
||||||
const websocketConnections = useAtomValue(websocketConnectionsAtom);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<VStack
|
|
||||||
as="ul"
|
|
||||||
role="menu"
|
|
||||||
aria-orientation="vertical"
|
|
||||||
dir="ltr"
|
|
||||||
className={classNames(
|
|
||||||
tree.depth > 0 && 'border-l border-border',
|
|
||||||
tree.depth === 0 && 'ml-0',
|
|
||||||
tree.depth >= 1 && 'ml-[1.2rem]',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{tree.children.map((child, i) => {
|
|
||||||
return (
|
|
||||||
<Fragment key={child.id}>
|
|
||||||
{hoveredIndex === i && hoveredTree?.id === tree.id && <DropMarker />}
|
|
||||||
<SidebarItem
|
|
||||||
itemId={child.id}
|
|
||||||
itemName={child.name}
|
|
||||||
itemModel={child.model}
|
|
||||||
latestHttpResponse={httpResponses.find((r) => r.requestId === child.id) ?? null}
|
|
||||||
latestGrpcConnection={grpcConnections.find((c) => c.requestId === child.id) ?? null}
|
|
||||||
latestWebsocketConnection={
|
|
||||||
websocketConnections.find((c) => c.requestId === child.id) ?? null
|
|
||||||
}
|
|
||||||
onMove={handleMove}
|
|
||||||
onEnd={handleEnd}
|
|
||||||
onSelect={onSelect}
|
|
||||||
onDragStart={handleDragStart}
|
|
||||||
child={child}
|
|
||||||
>
|
|
||||||
{child.model === 'folder' && draggingId !== child.id ? (
|
|
||||||
<SidebarItems
|
|
||||||
draggingId={draggingId}
|
|
||||||
handleDragStart={handleDragStart}
|
|
||||||
handleEnd={handleEnd}
|
|
||||||
handleMove={handleMove}
|
|
||||||
hoveredIndex={hoveredIndex}
|
|
||||||
hoveredTree={hoveredTree}
|
|
||||||
onSelect={onSelect}
|
|
||||||
selectedTree={selectedTree}
|
|
||||||
tree={child}
|
|
||||||
treeParentMap={treeParentMap}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</SidebarItem>
|
|
||||||
</Fragment>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{hoveredIndex === tree.children.length && hoveredTree?.id === tree.id && <DropMarker />}
|
|
||||||
</VStack>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
9
src-web/hooks/useActiveFolder.ts
Normal file
9
src-web/hooks/useActiveFolder.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { foldersAtom } from '@yaakapp-internal/models';
|
||||||
|
import { atom } from 'jotai';
|
||||||
|
import { activeFolderIdAtom } from './useActiveFolderId';
|
||||||
|
|
||||||
|
export const activeFolderAtom = atom((get) => {
|
||||||
|
const activeFolderId = get(activeFolderIdAtom);
|
||||||
|
const folders = get(foldersAtom);
|
||||||
|
return folders.find((r) => r.id === activeFolderId) ?? null;
|
||||||
|
});
|
||||||
11
src-web/hooks/useActiveFolderId.ts
Normal file
11
src-web/hooks/useActiveFolderId.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { useSearch } from '@tanstack/react-router';
|
||||||
|
import { atom } from 'jotai';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { jotaiStore } from '../lib/jotai';
|
||||||
|
|
||||||
|
export const activeFolderIdAtom = atom<string | null>(null);
|
||||||
|
|
||||||
|
export function useSubscribeActiveFolderId() {
|
||||||
|
const { folder_id } = useSearch({ strict: false });
|
||||||
|
useEffect(() => jotaiStore.set(activeFolderIdAtom, folder_id ?? null), [folder_id]);
|
||||||
|
}
|
||||||
@@ -1,14 +1,10 @@
|
|||||||
import { useSearch } from '@tanstack/react-router';
|
import { useSearch } from '@tanstack/react-router';
|
||||||
import { atom, useAtomValue } from 'jotai';
|
import { atom } from 'jotai';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { jotaiStore } from '../lib/jotai';
|
import { jotaiStore } from '../lib/jotai';
|
||||||
|
|
||||||
export const activeRequestIdAtom = atom<string | null>(null);
|
export const activeRequestIdAtom = atom<string | null>(null);
|
||||||
|
|
||||||
export function useActiveRequestId(): string | null {
|
|
||||||
return useAtomValue(activeRequestIdAtom);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSubscribeActiveRequestId() {
|
export function useSubscribeActiveRequestId() {
|
||||||
const { request_id } = useSearch({ strict: false });
|
const { request_id } = useSearch({ strict: false });
|
||||||
useEffect(() => jotaiStore.set(activeRequestIdAtom, request_id ?? null), [request_id]);
|
useEffect(() => jotaiStore.set(activeRequestIdAtom, request_id ?? null), [request_id]);
|
||||||
|
|||||||
@@ -1,16 +1,26 @@
|
|||||||
import type { Folder } from '@yaakapp-internal/models';
|
import type { Folder } from '@yaakapp-internal/models';
|
||||||
import { patchModel } from '@yaakapp-internal/models';
|
import { patchModel } from '@yaakapp-internal/models';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
import { openFolderSettings } from '../commands/openFolderSettings';
|
||||||
|
import { openWorkspaceSettings } from '../commands/openWorkspaceSettings';
|
||||||
|
import { Icon } from '../components/core/Icon';
|
||||||
import { IconTooltip } from '../components/core/IconTooltip';
|
import { IconTooltip } from '../components/core/IconTooltip';
|
||||||
|
import { InlineCode } from '../components/core/InlineCode';
|
||||||
import { HStack } from '../components/core/Stacks';
|
import { HStack } from '../components/core/Stacks';
|
||||||
import type { TabItem } from '../components/core/Tabs/Tabs';
|
import type { TabItem } from '../components/core/Tabs/Tabs';
|
||||||
|
import { capitalize } from '../lib/capitalize';
|
||||||
|
import { showConfirm } from '../lib/confirm';
|
||||||
|
import { resolvedModelName } from '../lib/resolvedModelName';
|
||||||
import { useHttpAuthenticationSummaries } from './useHttpAuthentication';
|
import { useHttpAuthenticationSummaries } from './useHttpAuthentication';
|
||||||
import type { AuthenticatedModel} from './useInheritedAuthentication';
|
import type { AuthenticatedModel } from './useInheritedAuthentication';
|
||||||
import { useInheritedAuthentication } from './useInheritedAuthentication';
|
import { useInheritedAuthentication } from './useInheritedAuthentication';
|
||||||
|
import { useModelAncestors } from './useModelAncestors';
|
||||||
|
|
||||||
export function useAuthTab<T extends string>(tabValue: T, model: AuthenticatedModel | null) {
|
export function useAuthTab<T extends string>(tabValue: T, model: AuthenticatedModel | null) {
|
||||||
const authentication = useHttpAuthenticationSummaries();
|
const authentication = useHttpAuthenticationSummaries();
|
||||||
const inheritedAuth = useInheritedAuthentication(model);
|
const inheritedAuth = useInheritedAuthentication(model);
|
||||||
|
const ancestors = useModelAncestors(model);
|
||||||
|
const parentModel = ancestors[0] ?? null;
|
||||||
|
|
||||||
return useMemo<TabItem[]>(() => {
|
return useMemo<TabItem[]>(() => {
|
||||||
if (model == null) return [];
|
if (model == null) return [];
|
||||||
@@ -47,6 +57,49 @@ export function useAuthTab<T extends string>(tabValue: T, model: AuthenticatedMo
|
|||||||
},
|
},
|
||||||
{ label: 'No Auth', shortLabel: 'No Auth', value: 'none' },
|
{ label: 'No Auth', shortLabel: 'No Auth', value: 'none' },
|
||||||
],
|
],
|
||||||
|
itemsAfter:
|
||||||
|
parentModel &&
|
||||||
|
model.authenticationType &&
|
||||||
|
model.authenticationType !== 'none' &&
|
||||||
|
(parentModel.authenticationType == null || parentModel.authenticationType === 'none')
|
||||||
|
? [
|
||||||
|
{ type: 'separator', label: 'Actions' },
|
||||||
|
{
|
||||||
|
label: `Promote to ${capitalize(parentModel.model)}`,
|
||||||
|
leftSlot: (
|
||||||
|
<Icon
|
||||||
|
icon={parentModel.model === 'workspace' ? 'corner_right_up' : 'folder_up'}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
onSelect: async () => {
|
||||||
|
const confirmed = await showConfirm({
|
||||||
|
id: 'promote-auth-confirm',
|
||||||
|
title: 'Promote Authentication',
|
||||||
|
confirmText: 'Promote',
|
||||||
|
description: (
|
||||||
|
<>
|
||||||
|
Move authentication config to{' '}
|
||||||
|
<InlineCode>{resolvedModelName(parentModel)}</InlineCode>?
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
if (confirmed) {
|
||||||
|
await patchModel(model, { authentication: {}, authenticationType: null });
|
||||||
|
await patchModel(parentModel, {
|
||||||
|
authentication: model.authentication,
|
||||||
|
authenticationType: model.authenticationType,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (parentModel.model === 'folder') {
|
||||||
|
openFolderSettings(parentModel.id, 'auth');
|
||||||
|
} else {
|
||||||
|
openWorkspaceSettings('auth');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: undefined,
|
||||||
onChange: async (authenticationType) => {
|
onChange: async (authenticationType) => {
|
||||||
let authentication: Folder['authentication'] = model.authentication;
|
let authentication: Folder['authentication'] = model.authentication;
|
||||||
if (model.authenticationType !== authenticationType) {
|
if (model.authenticationType !== authenticationType) {
|
||||||
@@ -60,5 +113,5 @@ export function useAuthTab<T extends string>(tabValue: T, model: AuthenticatedMo
|
|||||||
};
|
};
|
||||||
|
|
||||||
return [tab];
|
return [tab];
|
||||||
}, [authentication, inheritedAuth, model, tabValue]);
|
}, [authentication, inheritedAuth, model, parentModel, tabValue]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,25 +20,7 @@ export function useGrpcRequestActions() {
|
|||||||
const actionsResult = useQuery<CallableGrpcRequestAction[]>({
|
const actionsResult = useQuery<CallableGrpcRequestAction[]>({
|
||||||
queryKey: ['grpc_request_actions', pluginsKey],
|
queryKey: ['grpc_request_actions', pluginsKey],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const responses = await invokeCmd<GetGrpcRequestActionsResponse[]>(
|
return getGrpcRequestActions();
|
||||||
'cmd_grpc_request_actions',
|
|
||||||
);
|
|
||||||
|
|
||||||
return responses.flatMap((r) =>
|
|
||||||
r.actions.map((a, i) => ({
|
|
||||||
label: a.label,
|
|
||||||
icon: a.icon,
|
|
||||||
call: async (grpcRequest: GrpcRequest) => {
|
|
||||||
const protoFiles = await getGrpcProtoFiles(grpcRequest.id);
|
|
||||||
const payload: CallGrpcRequestActionRequest = {
|
|
||||||
index: i,
|
|
||||||
pluginRefId: r.pluginRefId,
|
|
||||||
args: { grpcRequest, protoFiles },
|
|
||||||
};
|
|
||||||
await invokeCmd('cmd_call_grpc_request_action', { req: payload });
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -49,3 +31,23 @@ export function useGrpcRequestActions() {
|
|||||||
|
|
||||||
return actions;
|
return actions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getGrpcRequestActions() {
|
||||||
|
const responses = await invokeCmd<GetGrpcRequestActionsResponse[]>('cmd_grpc_request_actions');
|
||||||
|
|
||||||
|
return responses.flatMap((r) =>
|
||||||
|
r.actions.map((a, i) => ({
|
||||||
|
label: a.label,
|
||||||
|
icon: a.icon,
|
||||||
|
call: async (grpcRequest: GrpcRequest) => {
|
||||||
|
const protoFiles = await getGrpcProtoFiles(grpcRequest.id);
|
||||||
|
const payload: CallGrpcRequestActionRequest = {
|
||||||
|
index: i,
|
||||||
|
pluginRefId: r.pluginRefId,
|
||||||
|
args: { grpcRequest, protoFiles },
|
||||||
|
};
|
||||||
|
await invokeCmd('cmd_call_grpc_request_action', { req: payload });
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { type } from '@tauri-apps/plugin-os';
|
import { type } from '@tauri-apps/plugin-os';
|
||||||
import { debounce } from '@yaakapp-internal/lib';
|
import { debounce } from '@yaakapp-internal/lib';
|
||||||
import { useEffect, useRef } from 'react';
|
import { atom } from 'jotai';
|
||||||
|
import { useEffect } from 'react';
|
||||||
import { capitalize } from '../lib/capitalize';
|
import { capitalize } from '../lib/capitalize';
|
||||||
|
import { jotaiStore } from '../lib/jotai';
|
||||||
|
|
||||||
const HOLD_KEYS = ['Shift', 'Control', 'Command', 'Alt', 'Meta'];
|
const HOLD_KEYS = ['Shift', 'Control', 'Command', 'Alt', 'Meta'];
|
||||||
|
|
||||||
@@ -11,11 +13,10 @@ export type HotkeyAction =
|
|||||||
| 'app.zoom_reset'
|
| 'app.zoom_reset'
|
||||||
| 'command_palette.toggle'
|
| 'command_palette.toggle'
|
||||||
| 'environmentEditor.toggle'
|
| 'environmentEditor.toggle'
|
||||||
| 'grpc_request.send'
|
|
||||||
| 'hotkeys.showHelp'
|
| 'hotkeys.showHelp'
|
||||||
| 'http_request.create'
|
| 'model.create'
|
||||||
| 'http_request.duplicate'
|
| 'model.duplicate'
|
||||||
| 'http_request.send'
|
| 'request.send'
|
||||||
| 'request_switcher.next'
|
| 'request_switcher.next'
|
||||||
| 'request_switcher.prev'
|
| 'request_switcher.prev'
|
||||||
| 'request_switcher.toggle'
|
| 'request_switcher.toggle'
|
||||||
@@ -31,11 +32,10 @@ const hotkeys: Record<HotkeyAction, string[]> = {
|
|||||||
'app.zoom_reset': ['CmdCtrl+0'],
|
'app.zoom_reset': ['CmdCtrl+0'],
|
||||||
'command_palette.toggle': ['CmdCtrl+k'],
|
'command_palette.toggle': ['CmdCtrl+k'],
|
||||||
'environmentEditor.toggle': ['CmdCtrl+Shift+E', 'CmdCtrl+Shift+e'],
|
'environmentEditor.toggle': ['CmdCtrl+Shift+E', 'CmdCtrl+Shift+e'],
|
||||||
'grpc_request.send': ['CmdCtrl+Enter', 'CmdCtrl+r'],
|
'request.send': ['CmdCtrl+Enter', 'CmdCtrl+r'],
|
||||||
'hotkeys.showHelp': ['CmdCtrl+Shift+/', 'CmdCtrl+Shift+?'], // when shift is pressed, it might be a question mark
|
'hotkeys.showHelp': ['CmdCtrl+Shift+/', 'CmdCtrl+Shift+?'], // when shift is pressed, it might be a question mark
|
||||||
'http_request.create': ['CmdCtrl+n'],
|
'model.create': ['CmdCtrl+n'],
|
||||||
'http_request.duplicate': ['CmdCtrl+d'],
|
'model.duplicate': ['CmdCtrl+d'],
|
||||||
'http_request.send': ['CmdCtrl+Enter', 'CmdCtrl+r'],
|
|
||||||
'request_switcher.next': ['Control+Shift+Tab'],
|
'request_switcher.next': ['Control+Shift+Tab'],
|
||||||
'request_switcher.prev': ['Control+Tab'],
|
'request_switcher.prev': ['Control+Tab'],
|
||||||
'request_switcher.toggle': ['CmdCtrl+p'],
|
'request_switcher.toggle': ['CmdCtrl+p'],
|
||||||
@@ -52,11 +52,10 @@ const hotkeyLabels: Record<HotkeyAction, string> = {
|
|||||||
'app.zoom_reset': 'Zoom to Actual Size',
|
'app.zoom_reset': 'Zoom to Actual Size',
|
||||||
'command_palette.toggle': 'Toggle Command Palette',
|
'command_palette.toggle': 'Toggle Command Palette',
|
||||||
'environmentEditor.toggle': 'Edit Environments',
|
'environmentEditor.toggle': 'Edit Environments',
|
||||||
'grpc_request.send': 'Send Message',
|
|
||||||
'hotkeys.showHelp': 'Show Keyboard Shortcuts',
|
'hotkeys.showHelp': 'Show Keyboard Shortcuts',
|
||||||
'http_request.create': 'New Request',
|
'model.create': 'New Request',
|
||||||
'http_request.duplicate': 'Duplicate Request',
|
'model.duplicate': 'Duplicate Request',
|
||||||
'http_request.send': 'Send Request',
|
'request.send': 'Send',
|
||||||
'request_switcher.next': 'Go To Previous Request',
|
'request_switcher.next': 'Go To Previous Request',
|
||||||
'request_switcher.prev': 'Go To Next Request',
|
'request_switcher.prev': 'Go To Next Request',
|
||||||
'request_switcher.toggle': 'Toggle Request Switcher',
|
'request_switcher.toggle': 'Toggle Request Switcher',
|
||||||
@@ -71,108 +70,139 @@ const layoutInsensitiveKeys = ['Equal', 'Minus', 'BracketLeft', 'BracketRight',
|
|||||||
|
|
||||||
export const hotkeyActions: HotkeyAction[] = Object.keys(hotkeys) as (keyof typeof hotkeys)[];
|
export const hotkeyActions: HotkeyAction[] = Object.keys(hotkeys) as (keyof typeof hotkeys)[];
|
||||||
|
|
||||||
interface Options {
|
export type HotKeyOptions = {
|
||||||
enable?: boolean;
|
enable?: boolean | (() => boolean);
|
||||||
|
priority?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Callback {
|
||||||
|
action: HotkeyAction;
|
||||||
|
callback: (e: KeyboardEvent) => void;
|
||||||
|
options: HotKeyOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const callbacksAtom = atom<Callback[]>([]);
|
||||||
|
const currentKeysAtom = atom<Set<string>>(new Set([]));
|
||||||
|
export const sortedCallbacksAtom = atom((get) =>
|
||||||
|
[...get(callbacksAtom)].sort((a, b) => (b.options.priority ?? 0) - (a.options.priority ?? 0)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const clearCurrentKeysDebounced = debounce(() => {
|
||||||
|
jotaiStore.set(currentKeysAtom, new Set([]));
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
export function useHotKey(
|
export function useHotKey(
|
||||||
action: HotkeyAction | null,
|
action: HotkeyAction | null,
|
||||||
callback: (e: KeyboardEvent) => void,
|
callback: (e: KeyboardEvent) => void,
|
||||||
options: Options = {},
|
options: HotKeyOptions = {},
|
||||||
) {
|
) {
|
||||||
const currentKeys = useRef<Set<string>>(new Set());
|
|
||||||
const callbackRef = useRef(callback);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
callbackRef.current = callback;
|
if (action == null) return;
|
||||||
}, [callback]);
|
jotaiStore.set(callbacksAtom, (prev) => {
|
||||||
|
const without = prev.filter((cb) => {
|
||||||
useEffect(() => {
|
const isTheSame = cb.action === action && cb.options.priority === options.priority;
|
||||||
// Sometimes the keyup event doesn't fire (eg, cmd+Tab), so we clear the keys after a timeout
|
return !isTheSame;
|
||||||
const clearCurrentKeys = debounce(() => currentKeys.current.clear(), 5000);
|
});
|
||||||
|
const newCb: Callback = { action, callback, options };
|
||||||
const down = (e: KeyboardEvent) => {
|
return [...without, newCb];
|
||||||
if (options.enable === false) {
|
});
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Don't add key if not holding modifier
|
|
||||||
const isValidKeymapKey =
|
|
||||||
e.altKey || e.ctrlKey || e.metaKey || e.shiftKey || e.key === 'Backspace' || e.key === 'Delete';
|
|
||||||
if (!isValidKeymapKey) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Don't add hold keys
|
|
||||||
if (HOLD_KEYS.includes(e.key)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const keyToAdd = layoutInsensitiveKeys.includes(e.code) ? e.code : e.key;
|
|
||||||
currentKeys.current.add(keyToAdd);
|
|
||||||
|
|
||||||
const currentKeysWithModifiers = new Set(currentKeys.current);
|
|
||||||
if (e.altKey) currentKeysWithModifiers.add('Alt');
|
|
||||||
if (e.ctrlKey) currentKeysWithModifiers.add('Control');
|
|
||||||
if (e.metaKey) currentKeysWithModifiers.add('Meta');
|
|
||||||
if (e.shiftKey) currentKeysWithModifiers.add('Shift');
|
|
||||||
|
|
||||||
for (const [hkAction, hkKeys] of Object.entries(hotkeys) as [HotkeyAction, string[]][]) {
|
|
||||||
if (
|
|
||||||
(e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) &&
|
|
||||||
currentKeysWithModifiers.size === 1 &&
|
|
||||||
currentKeysWithModifiers.has('Backspace')
|
|
||||||
) {
|
|
||||||
// Don't support Backspace-only modifiers within input fields. This is fairly brittle, so maybe there's a
|
|
||||||
// better way to do stuff like this in the future.
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const hkKey of hkKeys) {
|
|
||||||
if (hkAction !== action) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const keys = hkKey.split('+').map(resolveHotkeyKey);
|
|
||||||
if (
|
|
||||||
keys.length === currentKeysWithModifiers.size &&
|
|
||||||
keys.every((key) => currentKeysWithModifiers.has(key))
|
|
||||||
) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
callbackRef.current(e);
|
|
||||||
currentKeys.current.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
clearCurrentKeys();
|
|
||||||
};
|
|
||||||
|
|
||||||
const up = (e: KeyboardEvent) => {
|
|
||||||
if (options.enable === false) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const keyToRemove = layoutInsensitiveKeys.includes(e.code) ? e.code : e.key;
|
|
||||||
currentKeys.current.delete(keyToRemove);
|
|
||||||
|
|
||||||
// Clear all keys if no longer holding modifier
|
|
||||||
// HACK: This is to get around the case of DOWN SHIFT -> DOWN : -> UP SHIFT -> UP ;
|
|
||||||
// As you see, the ":" is not removed because it turned into ";" when shift was released
|
|
||||||
const isHoldingModifier = e.altKey || e.ctrlKey || e.metaKey || e.shiftKey;
|
|
||||||
if (!isHoldingModifier) {
|
|
||||||
currentKeys.current.clear();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener('keyup', up, { capture: true });
|
|
||||||
document.addEventListener('keydown', down, { capture: true });
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('keydown', down, { capture: true });
|
jotaiStore.set(callbacksAtom, (prev) => prev.filter((cb) => cb.action !== action));
|
||||||
document.removeEventListener('keyup', up, { capture: true });
|
|
||||||
};
|
};
|
||||||
}, [action, options.enable]);
|
}, [action, callback, options]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSubscribeHotKeys() {
|
||||||
|
useEffect(() => {
|
||||||
|
document.addEventListener('keyup', handleKeyUp, { capture: true });
|
||||||
|
document.addEventListener('keydown', handleKeyDown, { capture: true });
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', handleKeyDown, { capture: true });
|
||||||
|
document.removeEventListener('keyup', handleKeyUp, { capture: true });
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyUp(e: KeyboardEvent) {
|
||||||
|
const keyToRemove = layoutInsensitiveKeys.includes(e.code) ? e.code : e.key;
|
||||||
|
const currentKeys = new Set(jotaiStore.get(currentKeysAtom));
|
||||||
|
currentKeys.delete(keyToRemove);
|
||||||
|
|
||||||
|
// Clear all keys if no longer holding modifier
|
||||||
|
// HACK: This is to get around the case of DOWN SHIFT -> DOWN : -> UP SHIFT -> UP ;
|
||||||
|
// As you see, the ":" is not removed because it turned into ";" when shift was released
|
||||||
|
const isHoldingModifier = e.altKey || e.ctrlKey || e.metaKey || e.shiftKey;
|
||||||
|
if (!isHoldingModifier) {
|
||||||
|
currentKeys.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
jotaiStore.set(currentKeysAtom, currentKeys);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
|
// Don't add key if not holding modifier
|
||||||
|
const isValidKeymapKey =
|
||||||
|
e.altKey || e.ctrlKey || e.metaKey || e.shiftKey || e.key === 'Backspace' || e.key === 'Delete';
|
||||||
|
if (!isValidKeymapKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't add hold keys
|
||||||
|
if (HOLD_KEYS.includes(e.key)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyToAdd = layoutInsensitiveKeys.includes(e.code) ? e.code : e.key;
|
||||||
|
const currentKeys = new Set(jotaiStore.get(currentKeysAtom));
|
||||||
|
currentKeys.add(keyToAdd);
|
||||||
|
|
||||||
|
const currentKeysWithModifiers = new Set(currentKeys);
|
||||||
|
if (e.altKey) currentKeysWithModifiers.add('Alt');
|
||||||
|
if (e.ctrlKey) currentKeysWithModifiers.add('Control');
|
||||||
|
if (e.metaKey) currentKeysWithModifiers.add('Meta');
|
||||||
|
if (e.shiftKey) currentKeysWithModifiers.add('Shift');
|
||||||
|
|
||||||
|
for (const [hkAction, hkKeys] of Object.entries(hotkeys) as [HotkeyAction, string[]][]) {
|
||||||
|
if (
|
||||||
|
(e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) &&
|
||||||
|
currentKeysWithModifiers.size === 1 &&
|
||||||
|
currentKeysWithModifiers.has('Backspace')
|
||||||
|
) {
|
||||||
|
// Don't support Backspace-only modifiers within input fields. This is fairly brittle, so maybe there's a
|
||||||
|
// better way to do stuff like this in the future.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const executed: string[] = [];
|
||||||
|
for (const { action, callback, options } of jotaiStore.get(sortedCallbacksAtom)) {
|
||||||
|
const enable = typeof options.enable === 'function' ? options.enable() : options.enable;
|
||||||
|
if (enable === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (hkAction !== action) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const hkKey of hkKeys) {
|
||||||
|
const keys = hkKey.split('+').map(resolveHotkeyKey);
|
||||||
|
if (
|
||||||
|
keys.length === currentKeysWithModifiers.size &&
|
||||||
|
keys.every((key) => currentKeysWithModifiers.has(key))
|
||||||
|
) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
callback(e);
|
||||||
|
executed.push(`${action} ${options.priority ?? 0}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (executed.length > 0) {
|
||||||
|
console.log('Executed hotkey', executed.join(', '));
|
||||||
|
jotaiStore.set(currentKeysAtom, new Set([]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clearCurrentKeysDebounced();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useHotKeyLabel(action: HotkeyAction): string {
|
export function useHotKeyLabel(action: HotkeyAction): string {
|
||||||
|
|||||||
@@ -18,26 +18,7 @@ export function useHttpRequestActions() {
|
|||||||
|
|
||||||
const actionsResult = useQuery<CallableHttpRequestAction[]>({
|
const actionsResult = useQuery<CallableHttpRequestAction[]>({
|
||||||
queryKey: ['http_request_actions', pluginsKey],
|
queryKey: ['http_request_actions', pluginsKey],
|
||||||
queryFn: async () => {
|
queryFn: () => getHttpRequestActions(),
|
||||||
const responses = await invokeCmd<GetHttpRequestActionsResponse[]>(
|
|
||||||
'cmd_http_request_actions',
|
|
||||||
);
|
|
||||||
|
|
||||||
return responses.flatMap((r) =>
|
|
||||||
r.actions.map((a, i) => ({
|
|
||||||
label: a.label,
|
|
||||||
icon: a.icon,
|
|
||||||
call: async (httpRequest: HttpRequest) => {
|
|
||||||
const payload: CallHttpRequestActionRequest = {
|
|
||||||
index: i,
|
|
||||||
pluginRefId: r.pluginRefId,
|
|
||||||
args: { httpRequest },
|
|
||||||
};
|
|
||||||
await invokeCmd('cmd_call_http_request_action', { req: payload });
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const actions = useMemo(() => {
|
const actions = useMemo(() => {
|
||||||
@@ -47,3 +28,23 @@ export function useHttpRequestActions() {
|
|||||||
|
|
||||||
return actions;
|
return actions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getHttpRequestActions() {
|
||||||
|
const responses = await invokeCmd<GetHttpRequestActionsResponse[]>('cmd_http_request_actions');
|
||||||
|
const actions = responses.flatMap((r) =>
|
||||||
|
r.actions.map((a, i) => ({
|
||||||
|
label: a.label,
|
||||||
|
icon: a.icon,
|
||||||
|
call: async (httpRequest: HttpRequest) => {
|
||||||
|
const payload: CallHttpRequestActionRequest = {
|
||||||
|
index: i,
|
||||||
|
pluginRefId: r.pluginRefId,
|
||||||
|
args: { httpRequest },
|
||||||
|
};
|
||||||
|
await invokeCmd('cmd_call_http_request_action', { req: payload });
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
|||||||
41
src-web/hooks/useModelAncestors.ts
Normal file
41
src-web/hooks/useModelAncestors.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import type { AnyModel, Folder, Workspace } from '@yaakapp-internal/models';
|
||||||
|
import { foldersAtom, workspacesAtom } from '@yaakapp-internal/models';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
type ModelAncestor = Folder | Workspace;
|
||||||
|
|
||||||
|
export function useModelAncestors(m: AnyModel | null) {
|
||||||
|
const folders = useAtomValue(foldersAtom);
|
||||||
|
const workspaces = useAtomValue(workspacesAtom);
|
||||||
|
|
||||||
|
return useMemo(() => getParents(folders, workspaces, m), [folders, workspaces, m]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getParents(
|
||||||
|
folders: Folder[],
|
||||||
|
workspaces: Workspace[],
|
||||||
|
currentModel: AnyModel | null,
|
||||||
|
): ModelAncestor[] {
|
||||||
|
if (currentModel == null) return [];
|
||||||
|
|
||||||
|
const parentFolder =
|
||||||
|
'folderId' in currentModel && currentModel.folderId
|
||||||
|
? folders.find((f) => f.id === currentModel.folderId)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (parentFolder != null) {
|
||||||
|
return [parentFolder, ...getParents(folders, workspaces, parentFolder)];
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentWorkspace =
|
||||||
|
'workspaceId' in currentModel && currentModel.workspaceId
|
||||||
|
? workspaces.find((w) => w.id === currentModel.workspaceId)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (parentWorkspace != null) {
|
||||||
|
return [parentWorkspace, ...getParents(folders, workspaces, parentWorkspace)];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { MoveToWorkspaceDialog } from '../components/MoveToWorkspaceDialog';
|
|
||||||
import { showDialog } from '../lib/dialog';
|
|
||||||
import { jotaiStore } from '../lib/jotai';
|
|
||||||
import { activeWorkspaceIdAtom } from './useActiveWorkspace';
|
|
||||||
import { useFastMutation } from './useFastMutation';
|
|
||||||
import { allRequestsAtom } from './useAllRequests';
|
|
||||||
|
|
||||||
export function useMoveToWorkspace(id: string) {
|
|
||||||
return useFastMutation<void, unknown>({
|
|
||||||
mutationKey: ['move_workspace', id],
|
|
||||||
mutationFn: async () => {
|
|
||||||
const activeWorkspaceId = jotaiStore.get(activeWorkspaceIdAtom);
|
|
||||||
if (activeWorkspaceId == null) return;
|
|
||||||
|
|
||||||
const request = jotaiStore.get(allRequestsAtom).find((r) => r.id === id);
|
|
||||||
if (request == null) return;
|
|
||||||
|
|
||||||
showDialog({
|
|
||||||
id: 'change-workspace',
|
|
||||||
title: 'Move Workspace',
|
|
||||||
size: 'sm',
|
|
||||||
render: ({ hide }) => (
|
|
||||||
<MoveToWorkspaceDialog
|
|
||||||
onDone={hide}
|
|
||||||
request={request}
|
|
||||||
activeWorkspaceId={activeWorkspaceId}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,7 @@ import { getModel } from '@yaakapp-internal/models';
|
|||||||
import { invokeCmd } from '../lib/tauri';
|
import { invokeCmd } from '../lib/tauri';
|
||||||
import { getActiveCookieJar } from './useActiveCookieJar';
|
import { getActiveCookieJar } from './useActiveCookieJar';
|
||||||
import { getActiveEnvironment } from './useActiveEnvironment';
|
import { getActiveEnvironment } from './useActiveEnvironment';
|
||||||
import { useFastMutation } from './useFastMutation';
|
import { createFastMutation, useFastMutation } from './useFastMutation';
|
||||||
|
|
||||||
export function useSendAnyHttpRequest() {
|
export function useSendAnyHttpRequest() {
|
||||||
return useFastMutation<HttpResponse | null, string, string | null>({
|
return useFastMutation<HttpResponse | null, string, string | null>({
|
||||||
@@ -22,3 +22,19 @@ export function useSendAnyHttpRequest() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const sendAnyHttpRequest = createFastMutation<HttpResponse | null, string, string | null>({
|
||||||
|
mutationKey: ['send_any_request'],
|
||||||
|
mutationFn: async (id) => {
|
||||||
|
const request = getModel('http_request', id ?? 'n/a');
|
||||||
|
if (request == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return invokeCmd('cmd_send_http_request', {
|
||||||
|
request,
|
||||||
|
environmentId: getActiveEnvironment()?.id,
|
||||||
|
cookieJarId: getActiveCookieJar()?.id,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,43 +1,29 @@
|
|||||||
import { keyValuesAtom } from '@yaakapp-internal/models';
|
import { atom, useAtomValue } from 'jotai';
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback } from 'react';
|
||||||
|
import { atomWithKVStorage } from '../lib/atoms/atomWithKVStorage';
|
||||||
import { jotaiStore } from '../lib/jotai';
|
import { jotaiStore } from '../lib/jotai';
|
||||||
import { setKeyValue } from '../lib/keyValueStore';
|
|
||||||
import { activeWorkspaceIdAtom } from './useActiveWorkspace';
|
import { activeWorkspaceIdAtom } from './useActiveWorkspace';
|
||||||
import { getKeyValue } from './useKeyValue';
|
|
||||||
|
|
||||||
function kvKey(workspaceId: string | null) {
|
function kvKey(workspaceId: string | null) {
|
||||||
return ['sidebar_collapsed', workspaceId ?? 'n/a'];
|
return ['sidebar_collapsed', workspaceId ?? 'n/a'];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSidebarItemCollapsed(itemId: string) {
|
export const sidebarCollapsedAtom = atom((get) => {
|
||||||
const [isCollapsed, setIsCollapsed] = useState<boolean>(
|
const workspaceId = get(activeWorkspaceIdAtom);
|
||||||
getSidebarCollapsedMap()[itemId] === true,
|
return atomWithKVStorage<Record<string, boolean>>(kvKey(workspaceId), {});
|
||||||
);
|
});
|
||||||
useEffect(
|
|
||||||
() =>
|
|
||||||
jotaiStore.sub(keyValuesAtom, () => {
|
|
||||||
setIsCollapsed(getSidebarCollapsedMap()[itemId] === true);
|
|
||||||
}),
|
|
||||||
[itemId],
|
|
||||||
);
|
|
||||||
|
|
||||||
const toggle = useCallback(() => {
|
export function useSidebarItemCollapsed(itemId: string) {
|
||||||
setKeyValue({
|
const map = useAtomValue(useAtomValue(sidebarCollapsedAtom));
|
||||||
key: kvKey(jotaiStore.get(activeWorkspaceIdAtom)),
|
const isCollapsed = map[itemId] === true;
|
||||||
namespace: 'no_sync',
|
|
||||||
value: { ...getSidebarCollapsedMap(), [itemId]: !isCollapsed },
|
const toggle = useCallback(() => toggleSidebarItemCollapsed(itemId), [itemId]);
|
||||||
}).catch(console.error);
|
|
||||||
}, [isCollapsed, itemId]);
|
|
||||||
|
|
||||||
return [isCollapsed, toggle] as const;
|
return [isCollapsed, toggle] as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSidebarCollapsedMap() {
|
export function toggleSidebarItemCollapsed(itemId: string) {
|
||||||
const value = getKeyValue<Record<string, boolean>>({
|
jotaiStore.set(jotaiStore.get(sidebarCollapsedAtom), (prev) => {
|
||||||
key: kvKey(jotaiStore.get(activeWorkspaceIdAtom)),
|
return { ...prev, [itemId]: !prev[itemId] };
|
||||||
fallback: {},
|
|
||||||
namespace: 'no_sync',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return value;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { atom } from 'jotai';
|
|||||||
import { getKeyValue, setKeyValue } from '../keyValueStore';
|
import { getKeyValue, setKeyValue } from '../keyValueStore';
|
||||||
|
|
||||||
export function atomWithKVStorage<T extends object | boolean | number | string | null>(
|
export function atomWithKVStorage<T extends object | boolean | number | string | null>(
|
||||||
key: string,
|
key: string | string[],
|
||||||
fallback: T,
|
fallback: T,
|
||||||
namespace = 'global',
|
namespace = 'global',
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -1,25 +1,49 @@
|
|||||||
import type { AnyModel } from '@yaakapp-internal/models';
|
import type { AnyModel } from '@yaakapp-internal/models';
|
||||||
import { deleteModel, modelTypeLabel } from '@yaakapp-internal/models';
|
import { deleteModel, modelTypeLabel } from '@yaakapp-internal/models';
|
||||||
import { InlineCode } from '../components/core/InlineCode';
|
import { InlineCode } from '../components/core/InlineCode';
|
||||||
|
import { Prose } from '../components/Prose';
|
||||||
import { showConfirmDelete } from './confirm';
|
import { showConfirmDelete } from './confirm';
|
||||||
|
import { pluralizeCount } from './pluralize';
|
||||||
import { resolvedModelName } from './resolvedModelName';
|
import { resolvedModelName } from './resolvedModelName';
|
||||||
|
|
||||||
export async function deleteModelWithConfirm(
|
export async function deleteModelWithConfirm(
|
||||||
model: AnyModel | null,
|
model: AnyModel | AnyModel[] | null,
|
||||||
options: { confirmName?: string } = {},
|
options: { confirmName?: string } = {},
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
if (model == null) {
|
if (model == null) {
|
||||||
console.warn('Tried to delete null model');
|
console.warn('Tried to delete null model');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
const models = Array.isArray(model) ? model : [model];
|
||||||
|
const descriptor =
|
||||||
|
models.length === 1 ? modelTypeLabel(models[0]!) : pluralizeCount('Item', models.length);
|
||||||
const confirmed = await showConfirmDelete({
|
const confirmed = await showConfirmDelete({
|
||||||
id: 'delete-model-' + model.id,
|
id: 'delete-model-' + models.map((m) => m.id).join(','),
|
||||||
title: 'Delete ' + modelTypeLabel(model),
|
title: `Delete ${descriptor}`,
|
||||||
requireTyping: options.confirmName,
|
requireTyping: options.confirmName,
|
||||||
description: (
|
description: (
|
||||||
<>
|
<>
|
||||||
Permanently delete <InlineCode>{resolvedModelName(model)}</InlineCode>?
|
Permanently delete{' '}
|
||||||
|
{models.length === 1 ? (
|
||||||
|
<>
|
||||||
|
<InlineCode>{resolvedModelName(models[0]!)}</InlineCode>?
|
||||||
|
</>
|
||||||
|
) : models.length < 10 ? (
|
||||||
|
<>
|
||||||
|
the following?
|
||||||
|
<Prose className="mt-2">
|
||||||
|
<ul>
|
||||||
|
{models.map((m) => (
|
||||||
|
<li key={m.id}>
|
||||||
|
<InlineCode>{resolvedModelName(m)}</InlineCode>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Prose>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
`all ${pluralizeCount('item', models.length)}?`
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
@@ -28,6 +52,6 @@ export async function deleteModelWithConfirm(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
await deleteModel(model);
|
await Promise.allSettled(models.map((m) => deleteModel(m)));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
import type { GrpcRequest, HttpRequest, WebsocketRequest } from '@yaakapp-internal/models';
|
|
||||||
import { duplicateModel } from '@yaakapp-internal/models';
|
|
||||||
import { activeWorkspaceIdAtom } from '../hooks/useActiveWorkspace';
|
|
||||||
import { jotaiStore } from './jotai';
|
|
||||||
import { router } from './router';
|
|
||||||
|
|
||||||
export async function duplicateRequestAndNavigate(
|
|
||||||
model: HttpRequest | GrpcRequest | WebsocketRequest | null,
|
|
||||||
) {
|
|
||||||
if (model == null) {
|
|
||||||
throw new Error('Cannot duplicate null request');
|
|
||||||
}
|
|
||||||
|
|
||||||
const newId = await duplicateModel(model);
|
|
||||||
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
|
|
||||||
if (workspaceId == null) return;
|
|
||||||
|
|
||||||
await router.navigate({
|
|
||||||
to: '/workspaces/$workspaceId',
|
|
||||||
params: { workspaceId },
|
|
||||||
search: (prev) => ({ ...prev, request_id: newId }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
19
src-web/lib/duplicateRequestOrFolderAndNavigate.tsx
Normal file
19
src-web/lib/duplicateRequestOrFolderAndNavigate.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type { Folder, GrpcRequest, HttpRequest, WebsocketRequest } from '@yaakapp-internal/models';
|
||||||
|
import { duplicateModel } from '@yaakapp-internal/models';
|
||||||
|
import { activeWorkspaceIdAtom } from '../hooks/useActiveWorkspace';
|
||||||
|
import { jotaiStore } from './jotai';
|
||||||
|
import { navigateToRequestOrFolderOrWorkspace } from './setWorkspaceSearchParams';
|
||||||
|
|
||||||
|
export async function duplicateRequestOrFolderAndNavigate(
|
||||||
|
model: Folder | HttpRequest | GrpcRequest | WebsocketRequest | null,
|
||||||
|
) {
|
||||||
|
if (model == null) {
|
||||||
|
throw new Error('Cannot duplicate null item');
|
||||||
|
}
|
||||||
|
|
||||||
|
const newId = await duplicateModel(model);
|
||||||
|
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
|
||||||
|
if (workspaceId == null) return;
|
||||||
|
|
||||||
|
navigateToRequestOrFolderOrWorkspace(newId, model.model);
|
||||||
|
}
|
||||||
4
src-web/lib/scopes.ts
Normal file
4
src-web/lib/scopes.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
|
||||||
|
export function isSidebarFocused() {
|
||||||
|
return document.activeElement?.closest('.x-theme-sidebar') != null;
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { Folder, GrpcRequest, WebsocketRequest, Workspace } from '@yaakapp-internal/models';
|
||||||
|
import type { HttpRequest } from '@yaakapp-internal/sync';
|
||||||
import { router } from './router.js';
|
import { router } from './router.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -10,11 +12,28 @@ export function setWorkspaceSearchParams(
|
|||||||
cookie_jar_id: string | null;
|
cookie_jar_id: string | null;
|
||||||
environment_id: string | null;
|
environment_id: string | null;
|
||||||
request_id: string | null;
|
request_id: string | null;
|
||||||
|
folder_id: string | null;
|
||||||
}>,
|
}>,
|
||||||
) {
|
) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
(router as any).navigate({
|
(router as any).navigate({
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
search: (prev: any) => ({ ...prev, ...search }),
|
search: (prev: any) => {
|
||||||
|
console.log('Navigating to', { prev, search });
|
||||||
|
return { ...prev, ...search };
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function navigateToRequestOrFolderOrWorkspace(
|
||||||
|
id: string,
|
||||||
|
model: (Workspace | Folder | HttpRequest | GrpcRequest | WebsocketRequest)['model'],
|
||||||
|
) {
|
||||||
|
if (model === 'workspace') {
|
||||||
|
setWorkspaceSearchParams({ request_id: null, folder_id: null });
|
||||||
|
} else if (model === 'folder') {
|
||||||
|
setWorkspaceSearchParams({ request_id: null, folder_id: id });
|
||||||
|
} else {
|
||||||
|
setWorkspaceSearchParams({ request_id: id, folder_id: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ type TauriCmd =
|
|||||||
| 'cmd_save_response'
|
| 'cmd_save_response'
|
||||||
| 'cmd_secure_template'
|
| 'cmd_secure_template'
|
||||||
| 'cmd_send_ephemeral_request'
|
| 'cmd_send_ephemeral_request'
|
||||||
|
| 'cmd_send_folder'
|
||||||
| 'cmd_send_http_request'
|
| 'cmd_send_http_request'
|
||||||
| 'cmd_show_workspace_key'
|
| 'cmd_show_workspace_key'
|
||||||
| 'cmd_template_functions'
|
| 'cmd_template_functions'
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"@codemirror/lang-xml": "^6.1.0",
|
"@codemirror/lang-xml": "^6.1.0",
|
||||||
"@codemirror/language": "^6.11.0",
|
"@codemirror/language": "^6.11.0",
|
||||||
"@codemirror/search": "^6.5.11",
|
"@codemirror/search": "^6.5.11",
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@gilbarbara/deep-equal": "^0.3.1",
|
"@gilbarbara/deep-equal": "^0.3.1",
|
||||||
"@lezer/highlight": "^1.1.3",
|
"@lezer/highlight": "^1.1.3",
|
||||||
"@lezer/lr": "^1.3.3",
|
"@lezer/lr": "^1.3.3",
|
||||||
@@ -56,7 +57,7 @@
|
|||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-colorful": "^5.6.1",
|
"react-colorful": "^5.6.1",
|
||||||
"react-dnd": "^16.0.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-dom": "^19.1.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-pdf": "^10.0.1",
|
"react-pdf": "^10.0.1",
|
||||||
@@ -76,11 +77,11 @@
|
|||||||
"@tailwindcss/nesting": "^0.0.0-insiders.565cd3e",
|
"@tailwindcss/nesting": "^0.0.0-insiders.565cd3e",
|
||||||
"@tanstack/router-plugin": "^1.127.5",
|
"@tanstack/router-plugin": "^1.127.5",
|
||||||
"@types/node": "^24.0.13",
|
"@types/node": "^24.0.13",
|
||||||
"@types/react-syntax-highlighter": "^15.5.13",
|
|
||||||
"@types/papaparse": "^5.3.16",
|
"@types/papaparse": "^5.3.16",
|
||||||
"@types/parse-color": "^1.0.3",
|
"@types/parse-color": "^1.0.3",
|
||||||
"@types/react": "^19.1.8",
|
"@types/react": "^19.1.8",
|
||||||
"@types/react-dom": "^19.1.6",
|
"@types/react-dom": "^19.1.6",
|
||||||
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"@types/whatwg-mimetype": "^3.0.2",
|
"@types/whatwg-mimetype": "^3.0.2",
|
||||||
"@vitejs/plugin-react": "^4.6.0",
|
"@vitejs/plugin-react": "^4.6.0",
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Provider as JotaiProvider } from 'jotai';
|
|||||||
import { domAnimation, LazyMotion, MotionConfig } from 'motion/react';
|
import { domAnimation, LazyMotion, MotionConfig } from 'motion/react';
|
||||||
import React, { Suspense } from 'react';
|
import React, { Suspense } from 'react';
|
||||||
import { DndProvider } from 'react-dnd';
|
import { DndProvider } from 'react-dnd';
|
||||||
import { HTML5Backend } from 'react-dnd-html5-backend';
|
import { TouchBackend } from 'react-dnd-touch-backend';
|
||||||
import { Dialogs } from '../components/Dialogs';
|
import { Dialogs } from '../components/Dialogs';
|
||||||
import { GlobalHooks } from '../components/GlobalHooks';
|
import { GlobalHooks } from '../components/GlobalHooks';
|
||||||
import RouteError from '../components/RouteError';
|
import RouteError from '../components/RouteError';
|
||||||
@@ -25,7 +25,7 @@ function RouteComponent() {
|
|||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<LazyMotion features={domAnimation}>
|
<LazyMotion features={domAnimation}>
|
||||||
<MotionConfig transition={{ duration: 0.1 }}>
|
<MotionConfig transition={{ duration: 0.1 }}>
|
||||||
<DndProvider backend={HTML5Backend}>
|
<DndProvider backend={TouchBackend} options={{ enableMouseEvents: true }}>
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<GlobalHooks />
|
<GlobalHooks />
|
||||||
<Toasts />
|
<Toasts />
|
||||||
|
|||||||
@@ -1,19 +1,38 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router';
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
import { Workspace } from '../../../components/Workspace';
|
import { Workspace } from '../../../components/Workspace';
|
||||||
|
|
||||||
interface WorkspaceSearchSchema {
|
type WorkspaceSearchSchema = {
|
||||||
request_id?: string | null;
|
|
||||||
environment_id?: string | null;
|
environment_id?: string | null;
|
||||||
cookie_jar_id?: string | null;
|
cookie_jar_id?: string | null;
|
||||||
}
|
} & (
|
||||||
|
| {
|
||||||
|
request_id: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
folder_id: string;
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||||
|
| {}
|
||||||
|
);
|
||||||
|
|
||||||
export const Route = createFileRoute('/workspaces/$workspaceId/')({
|
export const Route = createFileRoute('/workspaces/$workspaceId/')({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
validateSearch: (search: Record<string, unknown>): WorkspaceSearchSchema => ({
|
validateSearch: (search: Record<string, unknown>): WorkspaceSearchSchema => {
|
||||||
request_id: search.request_id as string,
|
const base: Pick<WorkspaceSearchSchema, 'environment_id' | 'cookie_jar_id'> = {
|
||||||
environment_id: search.environment_id as string,
|
environment_id: search.environment_id as string,
|
||||||
cookie_jar_id: search.cookie_jar_id as string,
|
cookie_jar_id: search.cookie_jar_id as string,
|
||||||
}),
|
};
|
||||||
|
|
||||||
|
const requestId = search.request_id as string | undefined;
|
||||||
|
const folderId = search.folder_id as string | undefined;
|
||||||
|
if (requestId != null) {
|
||||||
|
return { ...base, request_id: requestId };
|
||||||
|
} else if (folderId) {
|
||||||
|
return { ...base, folder_id: folderId };
|
||||||
|
} else {
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
|
|||||||
@@ -22,6 +22,16 @@ module.exports = {
|
|||||||
],
|
],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
|
keyframes: {
|
||||||
|
blinkRing: {
|
||||||
|
'0%, 49%': { '--tw-ring-color': 'var(--primary)' },
|
||||||
|
'50%, 99%': { '--tw-ring-color': 'transparent' },
|
||||||
|
'100%': { '--tw-ring-color': 'var(--primary)' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
blinkRing: 'blinkRing 150ms step-start 400ms infinite',
|
||||||
|
},
|
||||||
opacity: {
|
opacity: {
|
||||||
disabled: '0.3',
|
disabled: '0.3',
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user