mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-04-25 10:18:31 +02:00
Cmd Palette Improvements (#50)
- Fuzzy matching - Show hotkeys - Add actions
This commit is contained in:
42
package-lock.json
generated
42
package-lock.json
generated
@@ -32,6 +32,7 @@
|
|||||||
"codemirror": "^6.0.1",
|
"codemirror": "^6.0.1",
|
||||||
"codemirror-json-schema": "^0.6.1",
|
"codemirror-json-schema": "^0.6.1",
|
||||||
"date-fns": "^3.3.1",
|
"date-fns": "^3.3.1",
|
||||||
|
"fast-fuzzy": "^1.12.0",
|
||||||
"focus-trap-react": "^10.1.1",
|
"focus-trap-react": "^10.1.1",
|
||||||
"format-graphql": "^1.4.0",
|
"format-graphql": "^1.4.0",
|
||||||
"framer-motion": "^9.0.4",
|
"framer-motion": "^9.0.4",
|
||||||
@@ -5750,6 +5751,14 @@
|
|||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
|
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-fuzzy": {
|
||||||
|
"version": "1.12.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-fuzzy/-/fast-fuzzy-1.12.0.tgz",
|
||||||
|
"integrity": "sha512-sXxGgHS+ubYpsdLnvOvJ9w5GYYZrtL9mkosG3nfuD446ahvoWEsSKBP7ieGmWIKVLnaxRDgUJkZMdxRgA2Ni+Q==",
|
||||||
|
"dependencies": {
|
||||||
|
"graphemesplit": "^2.4.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fast-glob": {
|
"node_modules/fast-glob": {
|
||||||
"version": "3.3.2",
|
"version": "3.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz",
|
||||||
@@ -6410,6 +6419,15 @@
|
|||||||
"integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
|
"integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/graphemesplit": {
|
||||||
|
"version": "2.4.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/graphemesplit/-/graphemesplit-2.4.4.tgz",
|
||||||
|
"integrity": "sha512-lKrpp1mk1NH26USxC/Asw4OHbhSQf5XfrWZ+CDv/dFVvd1j17kFgMotdJvOesmHkbFX9P9sBfpH8VogxOWLg8w==",
|
||||||
|
"dependencies": {
|
||||||
|
"js-base64": "^3.6.0",
|
||||||
|
"unicode-trie": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/graphql": {
|
"node_modules/graphql": {
|
||||||
"version": "16.8.1",
|
"version": "16.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/graphql/-/graphql-16.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/graphql/-/graphql-16.8.1.tgz",
|
||||||
@@ -7288,6 +7306,11 @@
|
|||||||
"jiti": "bin/jiti.js"
|
"jiti": "bin/jiti.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/js-base64": {
|
||||||
|
"version": "3.7.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.7.tgz",
|
||||||
|
"integrity": "sha512-7rCnleh0z2CkXhH67J8K1Ytz0b2Y+yxTPL+/KOJoa20hfnVQ/3/T6W/KflYI4bRHRagNeXeU2bkNGI3v1oS/lw=="
|
||||||
|
},
|
||||||
"node_modules/js-cookie": {
|
"node_modules/js-cookie": {
|
||||||
"version": "2.2.1",
|
"version": "2.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-2.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-2.2.1.tgz",
|
||||||
@@ -8737,6 +8760,11 @@
|
|||||||
"semver": "bin/semver"
|
"semver": "bin/semver"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pako": {
|
||||||
|
"version": "0.2.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
|
||||||
|
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="
|
||||||
|
},
|
||||||
"node_modules/papaparse": {
|
"node_modules/papaparse": {
|
||||||
"version": "5.4.1",
|
"version": "5.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.4.1.tgz",
|
||||||
@@ -11106,6 +11134,11 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tiny-inflate": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="
|
||||||
|
},
|
||||||
"node_modules/tiny-invariant": {
|
"node_modules/tiny-invariant": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||||
@@ -11362,6 +11395,15 @@
|
|||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
|
||||||
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="
|
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="
|
||||||
},
|
},
|
||||||
|
"node_modules/unicode-trie": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"pako": "^0.2.5",
|
||||||
|
"tiny-inflate": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/unique-string": {
|
"node_modules/unique-string": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/unique-string/-/unique-string-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/unique-string/-/unique-string-1.0.0.tgz",
|
||||||
|
|||||||
@@ -52,6 +52,7 @@
|
|||||||
"codemirror": "^6.0.1",
|
"codemirror": "^6.0.1",
|
||||||
"codemirror-json-schema": "^0.6.1",
|
"codemirror-json-schema": "^0.6.1",
|
||||||
"date-fns": "^3.3.1",
|
"date-fns": "^3.3.1",
|
||||||
|
"fast-fuzzy": "^1.12.0",
|
||||||
"focus-trap-react": "^10.1.1",
|
"focus-trap-react": "^10.1.1",
|
||||||
"format-graphql": "^1.4.0",
|
"format-graphql": "^1.4.0",
|
||||||
"framer-motion": "^9.0.4",
|
"framer-motion": "^9.0.4",
|
||||||
|
|||||||
@@ -1,21 +1,40 @@
|
|||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
import { search } from 'fast-fuzzy';
|
||||||
import type { KeyboardEvent, ReactNode } from 'react';
|
import type { KeyboardEvent, ReactNode } from 'react';
|
||||||
import { useCallback, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useActiveCookieJar } from '../hooks/useActiveCookieJar';
|
||||||
|
import { useActiveEnvironment } from '../hooks/useActiveEnvironment';
|
||||||
import { useActiveEnvironmentId } from '../hooks/useActiveEnvironmentId';
|
import { useActiveEnvironmentId } from '../hooks/useActiveEnvironmentId';
|
||||||
import { useActiveRequestId } from '../hooks/useActiveRequestId';
|
import { useActiveRequestId } from '../hooks/useActiveRequestId';
|
||||||
import { useActiveWorkspaceId } from '../hooks/useActiveWorkspaceId';
|
import { useActiveWorkspaceId } from '../hooks/useActiveWorkspaceId';
|
||||||
import { useAppRoutes } from '../hooks/useAppRoutes';
|
import { useAppRoutes } from '../hooks/useAppRoutes';
|
||||||
|
import { useCreateEnvironment } from '../hooks/useCreateEnvironment';
|
||||||
|
import { useCreateGrpcRequest } from '../hooks/useCreateGrpcRequest';
|
||||||
|
import { useCreateHttpRequest } from '../hooks/useCreateHttpRequest';
|
||||||
|
import { useCreateWorkspace } from '../hooks/useCreateWorkspace';
|
||||||
|
import { useDebouncedState } from '../hooks/useDebouncedState';
|
||||||
|
import { useEnvironments } from '../hooks/useEnvironments';
|
||||||
|
import type { HotkeyAction } from '../hooks/useHotKey';
|
||||||
|
import { useHotKey } from '../hooks/useHotKey';
|
||||||
import { useOpenWorkspace } from '../hooks/useOpenWorkspace';
|
import { useOpenWorkspace } from '../hooks/useOpenWorkspace';
|
||||||
|
import { useRecentEnvironments } from '../hooks/useRecentEnvironments';
|
||||||
import { useRecentRequests } from '../hooks/useRecentRequests';
|
import { useRecentRequests } from '../hooks/useRecentRequests';
|
||||||
import { useRecentWorkspaces } from '../hooks/useRecentWorkspaces';
|
import { useRecentWorkspaces } from '../hooks/useRecentWorkspaces';
|
||||||
import { useRequests } from '../hooks/useRequests';
|
import { useRequests } from '../hooks/useRequests';
|
||||||
|
import { useSidebarHidden } from '../hooks/useSidebarHidden';
|
||||||
import { useWorkspaces } from '../hooks/useWorkspaces';
|
import { useWorkspaces } from '../hooks/useWorkspaces';
|
||||||
import { fallbackRequestName } from '../lib/fallbackRequestName';
|
import { fallbackRequestName } from '../lib/fallbackRequestName';
|
||||||
|
import { CookieDialog } from './CookieDialog';
|
||||||
|
import { Button } from './core/Button';
|
||||||
import { Heading } from './core/Heading';
|
import { Heading } from './core/Heading';
|
||||||
|
import { HotKey } from './core/HotKey';
|
||||||
import { HttpMethodTag } from './core/HttpMethodTag';
|
import { HttpMethodTag } from './core/HttpMethodTag';
|
||||||
import { Icon } from './core/Icon';
|
import { Icon } from './core/Icon';
|
||||||
import { PlainInput } from './core/PlainInput';
|
import { PlainInput } from './core/PlainInput';
|
||||||
import { HStack } from './core/Stacks';
|
import { HStack } from './core/Stacks';
|
||||||
|
import { useDialog } from './DialogContext';
|
||||||
|
import { EnvironmentEditDialog } from './EnvironmentEditDialog';
|
||||||
|
|
||||||
interface CommandPaletteGroup {
|
interface CommandPaletteGroup {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -26,22 +45,120 @@ interface CommandPaletteGroup {
|
|||||||
type CommandPaletteItem = {
|
type CommandPaletteItem = {
|
||||||
key: string;
|
key: string;
|
||||||
onSelect: () => void;
|
onSelect: () => void;
|
||||||
|
action?: HotkeyAction;
|
||||||
} & ({ searchText: string; label: ReactNode } | { label: string });
|
} & ({ searchText: string; label: ReactNode } | { label: string });
|
||||||
|
|
||||||
const MAX_PER_GROUP = 4;
|
const MAX_PER_GROUP = 8;
|
||||||
|
|
||||||
export function CommandPalette({ onClose }: { onClose: () => void }) {
|
export function CommandPalette({ onClose }: { onClose: () => void }) {
|
||||||
|
const [command, setCommand] = useDebouncedState<string>('', 150);
|
||||||
const [selectedItemKey, setSelectedItemKey] = useState<string | null>(null);
|
const [selectedItemKey, setSelectedItemKey] = useState<string | null>(null);
|
||||||
const routes = useAppRoutes();
|
const routes = useAppRoutes();
|
||||||
const activeEnvironmentId = useActiveEnvironmentId();
|
const activeEnvironmentId = useActiveEnvironmentId();
|
||||||
const activeRequestId = useActiveRequestId();
|
const activeRequestId = useActiveRequestId();
|
||||||
const activeWorkspaceId = useActiveWorkspaceId();
|
const active = useActiveWorkspaceId();
|
||||||
const workspaces = useWorkspaces();
|
const workspaces = useWorkspaces();
|
||||||
|
const environments = useEnvironments();
|
||||||
|
const recentEnvironments = useRecentEnvironments();
|
||||||
const recentWorkspaces = useRecentWorkspaces();
|
const recentWorkspaces = useRecentWorkspaces();
|
||||||
const requests = useRequests();
|
const requests = useRequests();
|
||||||
const recentRequests = useRecentRequests();
|
const recentRequests = useRecentRequests();
|
||||||
const [command, setCommand] = useState<string>('');
|
|
||||||
const openWorkspace = useOpenWorkspace();
|
const openWorkspace = useOpenWorkspace();
|
||||||
|
const createWorkspace = useCreateWorkspace();
|
||||||
|
const createHttpRequest = useCreateHttpRequest();
|
||||||
|
const { activeCookieJar } = useActiveCookieJar();
|
||||||
|
const createGrpcRequest = useCreateGrpcRequest();
|
||||||
|
const createEnvironment = useCreateEnvironment();
|
||||||
|
const dialog = useDialog();
|
||||||
|
const workspaceId = useActiveWorkspaceId();
|
||||||
|
const activeEnvironment = useActiveEnvironment();
|
||||||
|
const [, setSidebarHidden] = useSidebarHidden();
|
||||||
|
|
||||||
|
const workspaceCommands = useMemo<CommandPaletteItem[]>(() => {
|
||||||
|
const commands: CommandPaletteItem[] = [
|
||||||
|
{
|
||||||
|
key: 'settings.open',
|
||||||
|
label: 'Open Settings',
|
||||||
|
action: 'settings.show',
|
||||||
|
onSelect: async () => {
|
||||||
|
if (workspaceId == null) return;
|
||||||
|
await invoke('cmd_new_nested_window', {
|
||||||
|
url: routes.paths.workspaceSettings({ workspaceId }),
|
||||||
|
label: 'settings',
|
||||||
|
title: 'Yaak Settings',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'app.create',
|
||||||
|
label: 'Create Workspace',
|
||||||
|
onSelect: createWorkspace.mutate,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'http_request.create',
|
||||||
|
label: 'Create HTTP Request',
|
||||||
|
onSelect: () => createHttpRequest.mutate({}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'cookies.show',
|
||||||
|
label: 'Show Cookies',
|
||||||
|
onSelect: async () => {
|
||||||
|
dialog.show({
|
||||||
|
id: 'cookies',
|
||||||
|
title: 'Manage Cookies',
|
||||||
|
size: 'full',
|
||||||
|
render: () => <CookieDialog cookieJarId={activeCookieJar?.id ?? null} />,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'grpc_request.create',
|
||||||
|
label: 'Create GRPC Request',
|
||||||
|
onSelect: () => createGrpcRequest.mutate({}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'environment.edit',
|
||||||
|
label: 'Edit Environment',
|
||||||
|
action: 'environmentEditor.toggle',
|
||||||
|
onSelect: () => {
|
||||||
|
dialog.toggle({
|
||||||
|
id: 'environment-editor',
|
||||||
|
noPadding: true,
|
||||||
|
size: 'lg',
|
||||||
|
className: 'h-[80vh]',
|
||||||
|
render: () => <EnvironmentEditDialog initialEnvironment={activeEnvironment} />,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'environment.create',
|
||||||
|
label: 'Create Environment',
|
||||||
|
onSelect: createEnvironment.mutate,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'sidebar.toggle',
|
||||||
|
label: 'Toggle Sidebar',
|
||||||
|
action: 'sidebar.focus',
|
||||||
|
onSelect: () => setSidebarHidden((h) => !h),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return commands.sort((a, b) =>
|
||||||
|
('searchText' in a ? a.searchText : a.label).localeCompare(
|
||||||
|
'searchText' in b ? b.searchText : b.label,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
activeCookieJar,
|
||||||
|
activeEnvironment,
|
||||||
|
createEnvironment.mutate,
|
||||||
|
createGrpcRequest,
|
||||||
|
createHttpRequest,
|
||||||
|
createWorkspace.mutate,
|
||||||
|
dialog,
|
||||||
|
routes.paths,
|
||||||
|
setSidebarHidden,
|
||||||
|
workspaceId,
|
||||||
|
]);
|
||||||
|
|
||||||
const sortedRequests = useMemo(() => {
|
const sortedRequests = useMemo(() => {
|
||||||
return [...requests].sort((a, b) => {
|
return [...requests].sort((a, b) => {
|
||||||
@@ -60,6 +177,23 @@ export function CommandPalette({ onClose }: { onClose: () => void }) {
|
|||||||
});
|
});
|
||||||
}, [recentRequests, requests]);
|
}, [recentRequests, requests]);
|
||||||
|
|
||||||
|
const sortedEnvironments = useMemo(() => {
|
||||||
|
return [...environments].sort((a, b) => {
|
||||||
|
const aRecentIndex = recentEnvironments.indexOf(a.id);
|
||||||
|
const bRecentIndex = recentEnvironments.indexOf(b.id);
|
||||||
|
|
||||||
|
if (aRecentIndex >= 0 && bRecentIndex >= 0) {
|
||||||
|
return aRecentIndex - bRecentIndex;
|
||||||
|
} else if (aRecentIndex >= 0 && bRecentIndex === -1) {
|
||||||
|
return -1;
|
||||||
|
} else if (aRecentIndex === -1 && bRecentIndex >= 0) {
|
||||||
|
return 1;
|
||||||
|
} else {
|
||||||
|
return a.createdAt.localeCompare(b.createdAt);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [environments, recentEnvironments]);
|
||||||
|
|
||||||
const sortedWorkspaces = useMemo(() => {
|
const sortedWorkspaces = useMemo(() => {
|
||||||
return [...workspaces].sort((a, b) => {
|
return [...workspaces].sort((a, b) => {
|
||||||
const aRecentIndex = recentWorkspaces.indexOf(a.id);
|
const aRecentIndex = recentWorkspaces.indexOf(a.id);
|
||||||
@@ -78,6 +212,12 @@ export function CommandPalette({ onClose }: { onClose: () => void }) {
|
|||||||
}, [recentWorkspaces, workspaces]);
|
}, [recentWorkspaces, workspaces]);
|
||||||
|
|
||||||
const groups = useMemo<CommandPaletteGroup[]>(() => {
|
const groups = useMemo<CommandPaletteGroup[]>(() => {
|
||||||
|
const actionsGroup: CommandPaletteGroup = {
|
||||||
|
key: 'actions',
|
||||||
|
label: 'Actions',
|
||||||
|
items: workspaceCommands,
|
||||||
|
};
|
||||||
|
|
||||||
const requestGroup: CommandPaletteGroup = {
|
const requestGroup: CommandPaletteGroup = {
|
||||||
key: 'requests',
|
key: 'requests',
|
||||||
label: 'Requests',
|
label: 'Requests',
|
||||||
@@ -91,10 +231,10 @@ export function CommandPalette({ onClose }: { onClose: () => void }) {
|
|||||||
|
|
||||||
requestGroup.items.push({
|
requestGroup.items.push({
|
||||||
key: `switch-request-${r.id}`,
|
key: `switch-request-${r.id}`,
|
||||||
searchText: `${r.method} ${r.name}`,
|
searchText: fallbackRequestName(r),
|
||||||
label: (
|
label: (
|
||||||
<HStack space={2}>
|
<HStack space={2}>
|
||||||
<HttpMethodTag className="text-fg-subtler" shortNames request={r} />
|
<HttpMethodTag className="text-fg-subtler" request={r} />
|
||||||
<div className="truncate">{fallbackRequestName(r)}</div>
|
<div className="truncate">{fallbackRequestName(r)}</div>
|
||||||
</HStack>
|
</HStack>
|
||||||
),
|
),
|
||||||
@@ -108,6 +248,23 @@ export function CommandPalette({ onClose }: { onClose: () => void }) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const environmentGroup: CommandPaletteGroup = {
|
||||||
|
key: 'environments',
|
||||||
|
label: 'Environments',
|
||||||
|
items: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const e of sortedEnvironments) {
|
||||||
|
if (e.id === activeEnvironment?.id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
environmentGroup.items.push({
|
||||||
|
key: `switch-environment-${e.id}`,
|
||||||
|
label: e.name,
|
||||||
|
onSelect: () => routes.setEnvironment(e),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const workspaceGroup: CommandPaletteGroup = {
|
const workspaceGroup: CommandPaletteGroup = {
|
||||||
key: 'workspaces',
|
key: 'workspaces',
|
||||||
label: 'Workspaces',
|
label: 'Workspaces',
|
||||||
@@ -115,7 +272,7 @@ export function CommandPalette({ onClose }: { onClose: () => void }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
for (const w of sortedWorkspaces) {
|
for (const w of sortedWorkspaces) {
|
||||||
if (w.id === activeWorkspaceId) {
|
if (w.id === active) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
workspaceGroup.items.push({
|
workspaceGroup.items.push({
|
||||||
@@ -125,30 +282,44 @@ export function CommandPalette({ onClose }: { onClose: () => void }) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return [requestGroup, workspaceGroup];
|
return [actionsGroup, requestGroup, environmentGroup, workspaceGroup];
|
||||||
}, [
|
}, [
|
||||||
activeEnvironmentId,
|
workspaceCommands,
|
||||||
activeRequestId,
|
|
||||||
activeWorkspaceId,
|
|
||||||
openWorkspace,
|
|
||||||
routes,
|
|
||||||
sortedRequests,
|
sortedRequests,
|
||||||
|
activeRequestId,
|
||||||
|
routes,
|
||||||
|
activeEnvironmentId,
|
||||||
|
sortedEnvironments,
|
||||||
|
activeEnvironment?.id,
|
||||||
sortedWorkspaces,
|
sortedWorkspaces,
|
||||||
|
active,
|
||||||
|
openWorkspace,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const filteredGroups = useMemo(
|
const allItems = useMemo(() => groups.flatMap((g) => g.items), [groups]);
|
||||||
() =>
|
|
||||||
groups
|
useEffect(() => {
|
||||||
.map((g) => {
|
setSelectedItemKey(null);
|
||||||
g.items = g.items.filter((v) => {
|
}, [command]);
|
||||||
const s = 'searchText' in v ? v.searchText : v.label;
|
|
||||||
return s.toLowerCase().includes(command.toLowerCase());
|
const { filteredGroups, filteredAllItems } = useMemo(() => {
|
||||||
});
|
const result = command
|
||||||
return g;
|
? search(command, allItems, {
|
||||||
|
threshold: 0.4,
|
||||||
|
keySelector: (v) => ('searchText' in v ? v.searchText : v.label),
|
||||||
})
|
})
|
||||||
.filter((g) => g.items.length > 0),
|
: allItems;
|
||||||
[command, groups],
|
|
||||||
);
|
const filteredGroups = groups
|
||||||
|
.map((g) => {
|
||||||
|
g.items = result.filter((i) => g.items.includes(i)).slice(0, MAX_PER_GROUP);
|
||||||
|
return g;
|
||||||
|
})
|
||||||
|
.filter((g) => g.items.length > 0);
|
||||||
|
|
||||||
|
const filteredAllItems = filteredGroups.flatMap((g) => g.items);
|
||||||
|
return { filteredAllItems, filteredGroups };
|
||||||
|
}, [allItems, command, groups]);
|
||||||
|
|
||||||
const handleSelectAndClose = useCallback(
|
const handleSelectAndClose = useCallback(
|
||||||
(cb: () => void) => {
|
(cb: () => void) => {
|
||||||
@@ -158,38 +329,37 @@ export function CommandPalette({ onClose }: { onClose: () => void }) {
|
|||||||
[onClose],
|
[onClose],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { allItems, selectedItem } = useMemo(() => {
|
const selectedItem = useMemo(() => {
|
||||||
const allItems = filteredGroups.flatMap((g) => g.items);
|
let selectedItem = filteredAllItems.find((i) => i.key === selectedItemKey) ?? null;
|
||||||
let selectedItem = allItems.find((i) => i.key === selectedItemKey) ?? null;
|
|
||||||
if (selectedItem == null) {
|
if (selectedItem == null) {
|
||||||
selectedItem = allItems[0] ?? null;
|
selectedItem = filteredAllItems[0] ?? null;
|
||||||
}
|
}
|
||||||
return { selectedItem, allItems };
|
return selectedItem;
|
||||||
}, [filteredGroups, selectedItemKey]);
|
}, [filteredAllItems, selectedItemKey]);
|
||||||
|
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
(e: KeyboardEvent<HTMLInputElement>) => {
|
(e: KeyboardEvent<HTMLInputElement>) => {
|
||||||
const index = allItems.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 = allItems[index + 1];
|
const next = filteredAllItems[index + 1] ?? filteredAllItems[0];
|
||||||
setSelectedItemKey(next?.key ?? null);
|
setSelectedItemKey(next?.key ?? null);
|
||||||
} else if (e.key === 'ArrowUp' || (e.ctrlKey && e.key === 'k')) {
|
} else if (e.key === 'ArrowUp' || (e.ctrlKey && e.key === 'k')) {
|
||||||
const prev = allItems[index - 1];
|
const prev = filteredAllItems[index - 1] ?? filteredAllItems[filteredAllItems.length - 1];
|
||||||
setSelectedItemKey(prev?.key ?? null);
|
setSelectedItemKey(prev?.key ?? null);
|
||||||
} else if (e.key === 'Enter') {
|
} else if (e.key === 'Enter') {
|
||||||
const selected = allItems[index];
|
const selected = filteredAllItems[index];
|
||||||
setSelectedItemKey(selected?.key ?? null);
|
setSelectedItemKey(selected?.key ?? null);
|
||||||
if (selected) {
|
if (selected) {
|
||||||
handleSelectAndClose(selected.onSelect);
|
handleSelectAndClose(selected.onSelect);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[allItems, handleSelectAndClose, selectedItem?.key],
|
[filteredAllItems, handleSelectAndClose, selectedItem?.key],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full max-h-[20rem] w-[400px] grid grid-rows-[auto_minmax(0,1fr)]">
|
<div className="h-full w-[400px] grid grid-rows-[auto_minmax(0,1fr)] overflow-hidden">
|
||||||
<div className="px-2 py-2 w-full">
|
<div className="px-2 py-2 w-full">
|
||||||
<PlainInput
|
<PlainInput
|
||||||
hideLabel
|
hideLabel
|
||||||
@@ -209,15 +379,18 @@ export function CommandPalette({ onClose }: { onClose: () => void }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="h-full px-1.5 overflow-y-auto pb-1">
|
<div className="h-full px-1.5 overflow-y-auto pb-1">
|
||||||
{filteredGroups.map((g) => (
|
{filteredGroups.map((g) => (
|
||||||
<div key={g.key} className="mb-1.5">
|
<div key={g.key} className="mb-1.5 w-full">
|
||||||
<Heading size={2} className="!text-xs uppercase px-1.5 h-sm flex items-center">
|
<Heading size={2} className="!text-xs uppercase px-1.5 h-sm flex items-center">
|
||||||
{g.label}
|
{g.label}
|
||||||
</Heading>
|
</Heading>
|
||||||
{g.items.slice(0, MAX_PER_GROUP).map((v) => (
|
{g.items.map((v) => (
|
||||||
<CommandPaletteItem
|
<CommandPaletteItem
|
||||||
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={
|
||||||
|
v.action && <CommandPaletteAction action={v.action} onAction={v.onSelect} />
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{v.label}
|
{v.label}
|
||||||
</CommandPaletteItem>
|
</CommandPaletteItem>
|
||||||
@@ -233,15 +406,20 @@ function CommandPaletteItem({
|
|||||||
children,
|
children,
|
||||||
active,
|
active,
|
||||||
onClick,
|
onClick,
|
||||||
|
rightSlot,
|
||||||
}: {
|
}: {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
|
rightSlot?: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<button
|
<Button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
tabIndex={active ? undefined : -1}
|
tabIndex={active ? undefined : -1}
|
||||||
|
rightSlot={rightSlot}
|
||||||
|
color="custom"
|
||||||
|
justify="start"
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'w-full h-sm flex items-center rounded px-1.5',
|
'w-full h-sm flex items-center rounded px-1.5',
|
||||||
'hover:text-fg',
|
'hover:text-fg',
|
||||||
@@ -250,6 +428,17 @@ function CommandPaletteItem({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="truncate">{children}</span>
|
<span className="truncate">{children}</span>
|
||||||
</button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CommandPaletteAction({
|
||||||
|
action,
|
||||||
|
onAction,
|
||||||
|
}: {
|
||||||
|
action: HotkeyAction;
|
||||||
|
onAction: () => void;
|
||||||
|
}) {
|
||||||
|
useHotKey(action, onAction);
|
||||||
|
return <HotKey className="ml-auto" action={action} />;
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { useCommandPalette } from '../hooks/useCommandPalette';
|
|||||||
import { cookieJarsQueryKey } from '../hooks/useCookieJars';
|
import { cookieJarsQueryKey } from '../hooks/useCookieJars';
|
||||||
import { environmentsQueryKey } from '../hooks/useEnvironments';
|
import { environmentsQueryKey } from '../hooks/useEnvironments';
|
||||||
import { foldersQueryKey } from '../hooks/useFolders';
|
import { foldersQueryKey } from '../hooks/useFolders';
|
||||||
import { useGlobalCommands } from '../hooks/useGlobalCommands';
|
|
||||||
import { grpcConnectionsQueryKey } from '../hooks/useGrpcConnections';
|
import { grpcConnectionsQueryKey } from '../hooks/useGrpcConnections';
|
||||||
import { grpcEventsQueryKey } from '../hooks/useGrpcEvents';
|
import { grpcEventsQueryKey } from '../hooks/useGrpcEvents';
|
||||||
import { grpcRequestsQueryKey } from '../hooks/useGrpcRequests';
|
import { grpcRequestsQueryKey } from '../hooks/useGrpcRequests';
|
||||||
@@ -42,7 +41,6 @@ export function GlobalHooks() {
|
|||||||
|
|
||||||
// Other useful things
|
// Other useful things
|
||||||
useSyncThemeToDocument();
|
useSyncThemeToDocument();
|
||||||
useGlobalCommands();
|
|
||||||
useCommandPalette();
|
useCommandPalette();
|
||||||
useNotificationToast();
|
useNotificationToast();
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { memo, useMemo } from 'react';
|
import { memo, useMemo } from 'react';
|
||||||
import { useActiveWorkspace } from '../hooks/useActiveWorkspace';
|
import { useActiveWorkspace } from '../hooks/useActiveWorkspace';
|
||||||
import { useCommand } from '../hooks/useCommands';
|
import { useCreateWorkspace } from '../hooks/useCreateWorkspace';
|
||||||
import { useDeleteWorkspace } from '../hooks/useDeleteWorkspace';
|
import { useDeleteWorkspace } from '../hooks/useDeleteWorkspace';
|
||||||
import { useOpenWorkspace } from '../hooks/useOpenWorkspace';
|
import { useOpenWorkspace } from '../hooks/useOpenWorkspace';
|
||||||
import { usePrompt } from '../hooks/usePrompt';
|
import { usePrompt } from '../hooks/usePrompt';
|
||||||
@@ -28,7 +28,7 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
|
|||||||
const activeWorkspaceId = activeWorkspace?.id ?? null;
|
const activeWorkspaceId = activeWorkspace?.id ?? null;
|
||||||
const updateWorkspace = useUpdateWorkspace(activeWorkspaceId);
|
const updateWorkspace = useUpdateWorkspace(activeWorkspaceId);
|
||||||
const deleteWorkspace = useDeleteWorkspace(activeWorkspace);
|
const deleteWorkspace = useDeleteWorkspace(activeWorkspace);
|
||||||
const createWorkspace = useCommand('workspace.create');
|
const createWorkspace = useCreateWorkspace();
|
||||||
const dialog = useDialog();
|
const dialog = useDialog();
|
||||||
const prompt = usePrompt();
|
const prompt = usePrompt();
|
||||||
const settings = useSettings();
|
const settings = useSettings();
|
||||||
@@ -101,7 +101,7 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
|
|||||||
key: 'create-workspace',
|
key: 'create-workspace',
|
||||||
label: 'New Workspace',
|
label: 'New Workspace',
|
||||||
leftSlot: <Icon icon="plus" />,
|
leftSlot: <Icon icon="plus" />,
|
||||||
onSelect: () => createWorkspace.mutate({}),
|
onSelect: createWorkspace.mutate,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}, [
|
}, [
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export function Dialog({
|
|||||||
<Overlay open={open} onClose={onClose} portalName="dialog">
|
<Overlay open={open} onClose={onClose} portalName="dialog">
|
||||||
<div
|
<div
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'x-theme-dialog absolute inset-0 flex flex-col items-center pointer-events-none my-5',
|
'x-theme-dialog absolute inset-0 flex flex-col items-center pointer-events-none',
|
||||||
vAlign === 'top' && 'justify-start',
|
vAlign === 'top' && 'justify-start',
|
||||||
vAlign === 'center' && 'justify-center',
|
vAlign === 'center' && 'justify-center',
|
||||||
)}
|
)}
|
||||||
@@ -74,12 +74,12 @@ export function Dialog({
|
|||||||
'relative bg-background pointer-events-auto',
|
'relative bg-background pointer-events-auto',
|
||||||
'rounded-lg',
|
'rounded-lg',
|
||||||
'border border-background-highlight-secondary shadow-lg shadow-[rgba(0,0,0,0.1)]',
|
'border border-background-highlight-secondary shadow-lg shadow-[rgba(0,0,0,0.1)]',
|
||||||
'max-w-[calc(100vw-5rem)] max-h-[calc(100vh-6rem)]',
|
'max-w-[calc(100vw-5rem)] max-h-[calc(100vh-4rem)]',
|
||||||
size === 'sm' && 'w-[28rem] max-h-[80vh]',
|
size === 'sm' && 'w-[28rem] max-h-[80vh]',
|
||||||
size === 'md' && 'w-[45rem] max-h-[80vh]',
|
size === 'md' && 'w-[45rem] max-h-[80vh]',
|
||||||
size === 'lg' && 'w-[65rem] max-h-[80vh]',
|
size === 'lg' && 'w-[65rem] max-h-[80vh]',
|
||||||
size === 'full' && 'w-[100vw] h-[100vh]',
|
size === 'full' && 'w-[100vw] h-[100vh]',
|
||||||
size === 'dynamic' && 'min-w-[20rem] max-w-[80vw] max-h-[80vh]',
|
size === 'dynamic' && 'min-w-[20rem] max-w-[100vw] w-full mt-8',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{title ? (
|
{title ? (
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export function useCommandPalette() {
|
|||||||
id: 'command_palette',
|
id: 'command_palette',
|
||||||
size: 'dynamic',
|
size: 'dynamic',
|
||||||
hideX: true,
|
hideX: true,
|
||||||
|
className: '!max-h-[min(30rem,calc(100vh-4rem))]',
|
||||||
vAlign: 'top',
|
vAlign: 'top',
|
||||||
noPadding: true,
|
noPadding: true,
|
||||||
noScroll: true,
|
noScroll: true,
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
import type { UseMutationOptions } from '@tanstack/react-query';
|
|
||||||
import { useMutation } from '@tanstack/react-query';
|
|
||||||
import { useEffect } from 'react';
|
|
||||||
import { createGlobalState } from 'react-use';
|
|
||||||
import type { TrackAction, TrackResource } from '../lib/analytics';
|
|
||||||
import type { Workspace } from '../lib/models';
|
|
||||||
|
|
||||||
interface CommandInstance<T, V> extends UseMutationOptions<V, unknown, T> {
|
|
||||||
track?: [TrackResource, TrackAction];
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Commands = {
|
|
||||||
'workspace.create': CommandInstance<Partial<Pick<Workspace, 'name'>>, Workspace>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const useCommandState = createGlobalState<Commands>();
|
|
||||||
|
|
||||||
export function useRegisterCommand<K extends keyof Commands>(action: K, command: Commands[K]) {
|
|
||||||
const [, setState] = useCommandState();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setState((commands) => {
|
|
||||||
return { ...commands, [action]: command };
|
|
||||||
});
|
|
||||||
|
|
||||||
// Remove action when it goes out of scope
|
|
||||||
return () => {
|
|
||||||
setState((commands) => {
|
|
||||||
return { ...commands, [action]: undefined };
|
|
||||||
});
|
|
||||||
};
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [action]);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCommand<K extends keyof Commands>(action: K) {
|
|
||||||
const [commands] = useCommandState();
|
|
||||||
const cmd = commands[action];
|
|
||||||
return useMutation({ ...cmd });
|
|
||||||
}
|
|
||||||
@@ -14,7 +14,7 @@ export function useCreateHttpRequest() {
|
|||||||
const routes = useAppRoutes();
|
const routes = useAppRoutes();
|
||||||
|
|
||||||
return useMutation<HttpRequest, unknown, Partial<HttpRequest>>({
|
return useMutation<HttpRequest, unknown, Partial<HttpRequest>>({
|
||||||
mutationFn: (patch) => {
|
mutationFn: (patch = {}) => {
|
||||||
if (workspaceId === null) {
|
if (workspaceId === null) {
|
||||||
throw new Error("Cannot create request when there's no active workspace");
|
throw new Error("Cannot create request when there's no active workspace");
|
||||||
}
|
}
|
||||||
@@ -28,7 +28,6 @@ export function useCreateHttpRequest() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
patch.folderId = patch.folderId || activeRequest?.folderId;
|
patch.folderId = patch.folderId || activeRequest?.folderId;
|
||||||
console.log('PATCH', patch);
|
|
||||||
return invoke('cmd_create_http_request', { request: { workspaceId, ...patch } });
|
return invoke('cmd_create_http_request', { request: { workspaceId, ...patch } });
|
||||||
},
|
},
|
||||||
onSettled: () => trackEvent('http_request', 'create'),
|
onSettled: () => trackEvent('http_request', 'create'),
|
||||||
|
|||||||
27
src-web/hooks/useCreateWorkspace.ts
Normal file
27
src-web/hooks/useCreateWorkspace.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import type { Workspace } from '../lib/models';
|
||||||
|
import { useAppRoutes } from './useAppRoutes';
|
||||||
|
import { usePrompt } from './usePrompt';
|
||||||
|
|
||||||
|
export function useCreateWorkspace() {
|
||||||
|
const routes = useAppRoutes();
|
||||||
|
const prompt = usePrompt();
|
||||||
|
return useMutation<Workspace, void, void>({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const name = await prompt({
|
||||||
|
id: 'new-workspace',
|
||||||
|
name: 'name',
|
||||||
|
label: 'Name',
|
||||||
|
defaultValue: 'My Workspace',
|
||||||
|
title: 'New Workspace',
|
||||||
|
confirmLabel: 'Create',
|
||||||
|
placeholder: 'My Workspace',
|
||||||
|
});
|
||||||
|
return invoke('cmd_create_workspace', { name });
|
||||||
|
},
|
||||||
|
onSuccess: async (workspace) => {
|
||||||
|
routes.navigate('workspace', { workspaceId: workspace.id });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { invoke } from '@tauri-apps/api/core';
|
|
||||||
import { useAppRoutes } from './useAppRoutes';
|
|
||||||
import { useRegisterCommand } from './useCommands';
|
|
||||||
import { usePrompt } from './usePrompt';
|
|
||||||
|
|
||||||
export function useGlobalCommands() {
|
|
||||||
const prompt = usePrompt();
|
|
||||||
const routes = useAppRoutes();
|
|
||||||
|
|
||||||
useRegisterCommand('workspace.create', {
|
|
||||||
name: 'New Workspace',
|
|
||||||
track: ['workspace', 'create'],
|
|
||||||
onSuccess: async (workspace) => {
|
|
||||||
routes.navigate('workspace', { workspaceId: workspace.id });
|
|
||||||
},
|
|
||||||
mutationFn: async ({ name: patchName }) => {
|
|
||||||
const name =
|
|
||||||
patchName ??
|
|
||||||
(await prompt({
|
|
||||||
id: 'new-workspace',
|
|
||||||
name: 'name',
|
|
||||||
label: 'Name',
|
|
||||||
defaultValue: 'My Workspace',
|
|
||||||
title: 'New Workspace',
|
|
||||||
confirmLabel: 'Create',
|
|
||||||
placeholder: 'My Workspace',
|
|
||||||
}));
|
|
||||||
return invoke('cmd_create_workspace', { name });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user