Markdown documentation for HTTP requests (#145)

This commit is contained in:
Gregory Schier
2024-12-19 05:57:40 -08:00
committed by GitHub
parent 42d350ef27
commit 833dc7d3f7
30 changed files with 2274 additions and 251 deletions

1560
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,8 @@
ALTER TABLE http_requests
ADD COLUMN description TEXT DEFAULT '' NOT NULL;
ALTER TABLE grpc_requests
ADD COLUMN description TEXT DEFAULT '' NOT NULL;
ALTER TABLE folders
ADD COLUMN description TEXT DEFAULT '' NOT NULL;

View File

@@ -903,7 +903,7 @@ async fn cmd_import_data<R: Runtime>(
v.workspace_id =
maybe_gen_id(v.workspace_id.as_str(), ModelType::TypeWorkspace, &mut id_map);
v.folder_id = maybe_gen_id_opt(v.folder_id, ModelType::TypeFolder, &mut id_map);
let x = upsert_grpc_request(&window, &v).await.map_err(|e| e.to_string())?;
let x = upsert_grpc_request(&window, v).await.map_err(|e| e.to_string())?;
imported_resources.grpc_requests.push(x.clone());
}
info!("Imported {} grpc_requests", imported_resources.grpc_requests.len());
@@ -1225,7 +1225,7 @@ async fn cmd_create_grpc_request(
) -> Result<GrpcRequest, String> {
upsert_grpc_request(
&w,
&GrpcRequest {
GrpcRequest {
workspace_id: workspace_id.to_string(),
name: name.to_string(),
folder_id: folder_id.map(|s| s.to_string()),
@@ -1273,7 +1273,7 @@ async fn cmd_update_grpc_request(
request: GrpcRequest,
w: WebviewWindow,
) -> Result<GrpcRequest, String> {
upsert_grpc_request(&w, &request).await.map_err(|e| e.to_string())
upsert_grpc_request(&w, request).await.map_err(|e| e.to_string())
}
#[tauri::command]

View File

@@ -14,7 +14,7 @@ export type Environment = { model: "environment", id: string, workspaceId: strin
export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, };
export type Folder = { model: "folder", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, name: string, sortPriority: number, };
export type Folder = { model: "folder", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, name: string, description: string, sortPriority: number, };
export type GrpcConnection = { model: "grpc_connection", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, elapsed: number, error: string | null, method: string, service: string, status: number, state: GrpcConnectionState, trailers: { [key in string]?: string }, url: string, };
@@ -26,9 +26,9 @@ export type GrpcEventType = "info" | "error" | "client_message" | "server_messag
export type GrpcMetadataEntry = { enabled?: boolean, name: string, value: string, };
export type GrpcRequest = { model: "grpc_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authenticationType: string | null, authentication: Record<string, any>, message: string, metadata: Array<GrpcMetadataEntry>, method: string | null, name: string, service: string | null, sortPriority: number, url: string, };
export type GrpcRequest = { model: "grpc_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authenticationType: string | null, authentication: Record<string, any>, description: string, message: string, metadata: Array<GrpcMetadataEntry>, method: string | null, name: string, service: string | null, sortPriority: number, url: string, };
export type HttpRequest = { model: "http_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, body: Record<string, any>, bodyType: string | null, headers: Array<HttpRequestHeader>, method: string, name: string, sortPriority: number, url: string, urlParameters: Array<HttpUrlParameter>, };
export type HttpRequest = { model: "http_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, body: Record<string, any>, bodyType: string | null, description: string, headers: Array<HttpRequestHeader>, method: string, name: string, sortPriority: number, url: string, urlParameters: Array<HttpUrlParameter>, };
export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, };

View File

@@ -310,6 +310,7 @@ pub struct Folder {
pub folder_id: Option<String>,
pub name: String,
pub description: String,
pub sort_priority: f32,
}
@@ -325,6 +326,7 @@ pub enum FolderIden {
UpdatedAt,
Name,
Description,
SortPriority,
}
@@ -341,6 +343,7 @@ impl<'s> TryFrom<&Row<'s>> for Folder {
updated_at: r.get("updated_at")?,
folder_id: r.get("folder_id")?,
name: r.get("name")?,
description: r.get("description")?,
})
}
}
@@ -385,6 +388,7 @@ pub struct HttpRequest {
#[ts(type = "Record<string, any>")]
pub body: BTreeMap<String, Value>,
pub body_type: Option<String>,
pub description: String,
pub headers: Vec<HttpRequestHeader>,
#[serde(default = "default_http_request_method")]
pub method: String,
@@ -409,6 +413,7 @@ pub enum HttpRequestIden {
AuthenticationType,
Body,
BodyType,
Description,
Headers,
Method,
Name,
@@ -437,6 +442,7 @@ impl<'s> TryFrom<&Row<'s>> for HttpRequest {
method: r.get("method")?,
body: serde_json::from_str(body.as_str()).unwrap_or_default(),
body_type: r.get("body_type")?,
description: r.get("description")?,
authentication: serde_json::from_str(authentication.as_str()).unwrap_or_default(),
authentication_type: r.get("authentication_type")?,
headers: serde_json::from_str(headers.as_str()).unwrap_or_default(),
@@ -584,6 +590,7 @@ pub struct GrpcRequest {
pub authentication_type: Option<String>,
#[ts(type = "Record<string, any>")]
pub authentication: BTreeMap<String, Value>,
pub description: String,
pub message: String,
pub metadata: Vec<GrpcMetadataEntry>,
pub method: Option<String>,
@@ -606,6 +613,7 @@ pub enum GrpcRequestIden {
Authentication,
AuthenticationType,
Description,
Message,
Metadata,
Method,
@@ -629,6 +637,7 @@ impl<'s> TryFrom<&Row<'s>> for GrpcRequest {
updated_at: r.get("updated_at")?,
folder_id: r.get("folder_id")?,
name: r.get("name")?,
description: r.get("description")?,
service: r.get("service")?,
method: r.get("method")?,
message: r.get("message")?,

View File

@@ -307,7 +307,7 @@ pub async fn duplicate_grpc_request<R: Runtime>(
}
};
request.id = "".to_string();
upsert_grpc_request(window, &request).await
upsert_grpc_request(window, request).await
}
pub async fn delete_grpc_request<R: Runtime>(
@@ -334,7 +334,7 @@ pub async fn delete_grpc_request<R: Runtime>(
pub async fn upsert_grpc_request<R: Runtime>(
window: &WebviewWindow<R>,
request: &GrpcRequest,
request: GrpcRequest,
) -> Result<GrpcRequest> {
let id = match request.id.as_str() {
"" => generate_model_id(ModelType::TypeGrpcRequest),
@@ -351,6 +351,7 @@ pub async fn upsert_grpc_request<R: Runtime>(
GrpcRequestIden::CreatedAt,
GrpcRequestIden::UpdatedAt,
GrpcRequestIden::Name,
GrpcRequestIden::Description,
GrpcRequestIden::WorkspaceId,
GrpcRequestIden::FolderId,
GrpcRequestIden::SortPriority,
@@ -363,17 +364,18 @@ pub async fn upsert_grpc_request<R: Runtime>(
GrpcRequestIden::Metadata,
])
.values_panic([
id.as_str().into(),
id.into(),
CurrentTimestamp.into(),
CurrentTimestamp.into(),
trimmed_name.into(),
request.workspace_id.as_str().into(),
request.description.into(),
request.workspace_id.into(),
request.folder_id.as_ref().map(|s| s.as_str()).into(),
request.sort_priority.into(),
request.url.as_str().into(),
request.url.into(),
request.service.as_ref().map(|s| s.as_str()).into(),
request.method.as_ref().map(|s| s.as_str()).into(),
request.message.as_str().into(),
request.message.into(),
request.authentication_type.as_ref().map(|s| s.as_str()).into(),
serde_json::to_string(&request.authentication)?.into(),
serde_json::to_string(&request.metadata)?.into(),
@@ -384,6 +386,7 @@ pub async fn upsert_grpc_request<R: Runtime>(
GrpcRequestIden::UpdatedAt,
GrpcRequestIden::WorkspaceId,
GrpcRequestIden::Name,
GrpcRequestIden::Description,
GrpcRequestIden::FolderId,
GrpcRequestIden::SortPriority,
GrpcRequestIden::Url,
@@ -1064,6 +1067,7 @@ pub async fn upsert_folder<R: Runtime>(window: &WebviewWindow<R>, r: Folder) ->
FolderIden::WorkspaceId,
FolderIden::FolderId,
FolderIden::Name,
FolderIden::Description,
FolderIden::SortPriority,
])
.values_panic([
@@ -1073,6 +1077,7 @@ pub async fn upsert_folder<R: Runtime>(window: &WebviewWindow<R>, r: Folder) ->
r.workspace_id.as_str().into(),
r.folder_id.as_ref().map(|s| s.as_str()).into(),
trimmed_name.into(),
r.description.into(),
r.sort_priority.into(),
])
.on_conflict(
@@ -1080,6 +1085,7 @@ pub async fn upsert_folder<R: Runtime>(window: &WebviewWindow<R>, r: Folder) ->
.update_columns([
FolderIden::UpdatedAt,
FolderIden::Name,
FolderIden::Description,
FolderIden::FolderId,
FolderIden::SortPriority,
])
@@ -1127,6 +1133,7 @@ pub async fn upsert_http_request<R: Runtime>(
HttpRequestIden::WorkspaceId,
HttpRequestIden::FolderId,
HttpRequestIden::Name,
HttpRequestIden::Description,
HttpRequestIden::Url,
HttpRequestIden::UrlParameters,
HttpRequestIden::Method,
@@ -1141,12 +1148,13 @@ pub async fn upsert_http_request<R: Runtime>(
id.as_str().into(),
CurrentTimestamp.into(),
CurrentTimestamp.into(),
r.workspace_id.as_str().into(),
r.workspace_id.into(),
r.folder_id.as_ref().map(|s| s.as_str()).into(),
trimmed_name.into(),
r.url.as_str().into(),
r.description.into(),
r.url.into(),
serde_json::to_string(&r.url_parameters)?.into(),
r.method.as_str().into(),
r.method.into(),
serde_json::to_string(&r.body)?.into(),
r.body_type.as_ref().map(|s| s.as_str()).into(),
serde_json::to_string(&r.authentication)?.into(),
@@ -1160,6 +1168,7 @@ pub async fn upsert_http_request<R: Runtime>(
HttpRequestIden::UpdatedAt,
HttpRequestIden::WorkspaceId,
HttpRequestIden::Name,
HttpRequestIden::Description,
HttpRequestIden::FolderId,
HttpRequestIden::Method,
HttpRequestIden::Headers,

View File

@@ -0,0 +1,46 @@
import { useFolders } from '../hooks/useFolders';
import { useUpdateAnyFolder } from '../hooks/useUpdateAnyFolder';
import { Banner } from './core/Banner';
import { PlainInput } from './core/PlainInput';
import { VStack } from './core/Stacks';
import { MarkdownEditor } from './MarkdownEditor';
interface Props {
folderId: string | null;
}
export function FolderSettingsDialog({ folderId }: Props) {
const updateFolder = useUpdateAnyFolder();
const folders = useFolders();
const folder = folders.find((f) => f.id === folderId);
if (folder == null) return null;
return (
<VStack space={3} className="pb-3">
{updateFolder.error != null && <Banner color="danger">{String(updateFolder.error)}</Banner>}
<PlainInput
label="Folder Name"
defaultValue={folder.name}
onChange={(name) => {
if (folderId == null) return;
updateFolder.mutate({ id: folderId, update: (folder) => ({ ...folder, name }) });
}}
/>
<MarkdownEditor
name="folder-description"
placeholder="A Markdown description of this folder."
className="min-h-[10rem] border border-border px-2"
defaultValue={folder.description}
onChange={(description) => {
if (folderId == null) return;
updateFolder.mutate({
id: folderId,
update: (folder) => ({ ...folder, description }),
});
}}
/>
</VStack>
);
}

View File

@@ -1,9 +1,9 @@
import useResizeObserver from '@react-hook/resize-observer';
import useSize from '@react-hook/size';
import type { GrpcMetadataEntry, GrpcRequest } from '@yaakapp-internal/models';
import classNames from 'classnames';
import type { CSSProperties } from 'react';
import React, { useCallback, useMemo, useRef, useState } from 'react';
import { createGlobalState } from 'react-use';
import React, { useCallback, useMemo, useRef } from 'react';
import { useLocalStorage } from 'react-use';
import type { ReflectResponseService } from '../hooks/useGrpc';
import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
import { useUpdateAnyGrpcRequest } from '../hooks/useUpdateAnyGrpcRequest';
@@ -11,6 +11,7 @@ import { AUTH_TYPE_BASIC, AUTH_TYPE_BEARER, AUTH_TYPE_NONE } from '../lib/model_
import { BasicAuth } from './BasicAuth';
import { BearerAuth } from './BearerAuth';
import { Button } from './core/Button';
import { CountBadge } from './core/CountBadge';
import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
import { PairOrBulkEditor } from './core/PairOrBulkEditor';
@@ -20,6 +21,7 @@ import type { TabItem } from './core/Tabs/Tabs';
import { TabContent, Tabs } from './core/Tabs/Tabs';
import { EmptyStateText } from './EmptyStateText';
import { GrpcEditor } from './GrpcEditor';
import { MarkdownEditor } from './MarkdownEditor';
import { UrlBar } from './UrlBar';
interface Props {
@@ -44,7 +46,10 @@ interface Props {
services: ReflectResponseService[] | null;
}
const useActiveTab = createGlobalState<string>('message');
const TAB_MESSAGE = 'message';
const TAB_METADATA = 'metadata';
const TAB_AUTH = 'auth';
const TAB_DESCRIPTION = 'description';
export function GrpcConnectionSetupPane({
style,
@@ -61,14 +66,14 @@ export function GrpcConnectionSetupPane({
onSend,
}: Props) {
const updateRequest = useUpdateAnyGrpcRequest();
const [activeTab, setActiveTab] = useActiveTab();
const [activeTabs, setActiveTabs] = useLocalStorage<Record<string, string>>(
'grpcRequestPaneActiveTabs',
{},
);
const { updateKey: forceUpdateKey } = useRequestUpdateKey(activeRequest.id ?? null);
const [paneSize, setPaneSize] = useState(99999);
const urlContainerEl = useRef<HTMLDivElement>(null);
useResizeObserver<HTMLDivElement>(urlContainerEl.current, (entry) => {
setPaneSize(entry.contentRect.width);
});
const [paneWidth] = useSize(urlContainerEl.current);
const handleChangeUrl = useCallback(
(url: string) => updateRequest.mutateAsync({ id: activeRequest.id, update: { url } }),
@@ -129,9 +134,18 @@ export function GrpcConnectionSetupPane({
const tabs: TabItem[] = useMemo(
() => [
{ value: 'message', label: 'Message' },
{
value: 'auth',
value: TAB_DESCRIPTION,
label: (
<div className="flex items-center">
Docs
{activeRequest.description && <CountBadge count={true} />}
</div>
),
},
{ value: TAB_MESSAGE, label: 'Message' },
{
value: TAB_AUTH,
label: 'Auth',
options: {
value: activeRequest.authenticationType,
@@ -160,29 +174,44 @@ export function GrpcConnectionSetupPane({
},
},
},
{ value: 'metadata', label: 'Metadata' },
{ value: TAB_METADATA, label: 'Metadata' },
],
[
activeRequest.authentication,
activeRequest.authenticationType,
activeRequest.description,
activeRequest.id,
updateRequest,
],
);
const activeTab = activeTabs?.[activeRequest.id];
const setActiveTab = useCallback(
(tab: string) => {
setActiveTabs((r) => ({ ...r, [activeRequest.id]: tab }));
},
[activeRequest.id, setActiveTabs],
);
const handleMetadataChange = useCallback(
(metadata: GrpcMetadataEntry[]) =>
updateRequest.mutate({ id: activeRequest.id, update: { metadata } }),
[activeRequest.id, updateRequest],
);
const handleDescriptionChange = useCallback(
(description: string) =>
updateRequest.mutate({ id: activeRequest.id, update: { description } }),
[activeRequest.id, updateRequest],
);
return (
<VStack style={style}>
<div
ref={urlContainerEl}
className={classNames(
'grid grid-cols-[minmax(0,1fr)_auto] gap-1.5',
paneSize < 400 && '!grid-cols-1',
paneWidth < 400 && '!grid-cols-1',
)}
>
<UrlBar
@@ -222,7 +251,7 @@ export function GrpcConnectionSetupPane({
disabled={isStreaming || services == null}
className={classNames(
'font-mono text-editor min-w-[5rem] !ring-0',
paneSize < 400 && 'flex-1',
paneWidth < 400 && 'flex-1',
)}
>
{select.options.find((o) => o.value === select.value)?.label ?? 'No Schema'}
@@ -312,6 +341,14 @@ export function GrpcConnectionSetupPane({
forceUpdateKey={forceUpdateKey}
/>
</TabContent>
<TabContent value={TAB_DESCRIPTION}>
<MarkdownEditor
name="request-description"
placeholder="A Markdown description of this request."
defaultValue={activeRequest.description}
onChange={handleDescriptionChange}
/>
</TabContent>
</Tabs>
</VStack>
);

View File

@@ -0,0 +1,137 @@
import useSize from '@react-hook/size';
import classNames from 'classnames';
import { useRef } from 'react';
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { useKeyValue } from '../hooks/useKeyValue';
import { Editor } from './core/Editor';
import { IconButton } from './core/IconButton';
import { SplitLayout } from './core/SplitLayout';
import { VStack } from './core/Stacks';
import { Prose } from './Prose';
interface Props {
placeholder: string;
className?: string;
defaultValue: string;
onChange: (value: string) => void;
name: string;
}
export function MarkdownEditor({ className, defaultValue, onChange, name, placeholder }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const [width] = useSize(containerRef.current);
const wideEnoughForSplit = width > 600;
const { set: setViewMode, value: rawViewMode } = useKeyValue<'edit' | 'preview' | 'both'>({
namespace: 'global',
key: ['md_view', name],
fallback: 'edit',
});
if (rawViewMode == null) return null;
let viewMode = rawViewMode;
if (rawViewMode === 'both' && !wideEnoughForSplit) {
viewMode = 'edit';
}
const editor = (
<Editor
className="max-w-xl"
language="markdown"
defaultValue={defaultValue}
onChange={onChange}
placeholder={placeholder}
hideGutter
wrapLines
/>
);
const preview =
defaultValue.length === 0 ? (
<p className="text-text-subtle">No description</p>
) : (
<Prose className="max-w-xl">
<Markdown
remarkPlugins={[remarkGfm]}
components={{
a: ({ href, children, ...rest }) => {
if (href && !href.match(/https?:\/\//)) {
href = `http://${href}`;
}
return (
<a target="_blank" rel="noreferrer noopener" href={href} {...rest}>
{children}
</a>
);
},
}}
>
{defaultValue}
</Markdown>
</Prose>
);
const contents =
viewMode === 'both' ? (
<SplitLayout
name="markdown-editor"
layout="horizontal"
firstSlot={({ style }) => <div style={style}>{editor}</div>}
secondSlot={({ style }) => (
<div style={style} className="border-l border-border-subtle pl-6">
{preview}
</div>
)}
/>
) : viewMode === 'preview' ? (
preview
) : (
editor
);
return (
<div
ref={containerRef}
className={classNames(
'w-full h-full pt-1.5 group rounded-md grid grid-cols-[minmax(0,1fr)_auto]',
className,
)}
>
<div className="pr-8 h-full w-full">{contents}</div>
<VStack
space={1}
className="bg-surface opacity-20 group-hover:opacity-100 transition-opacity transform-gpu"
>
<IconButton
size="xs"
icon="text"
title="Switch to edit mode"
className={classNames(viewMode === 'edit' && 'bg-surface-highlight !text-text')}
event={{ id: 'md_mode', mode: viewMode }}
onClick={() => setViewMode('edit')}
/>
{wideEnoughForSplit && (
<IconButton
size="xs"
icon="columns_2"
title="Switch to edit mode"
className={classNames(viewMode === 'both' && 'bg-surface-highlight !text-text')}
event={{ id: 'md_mode', mode: viewMode }}
onClick={() => setViewMode('both')}
/>
)}
<IconButton
size="xs"
icon="eye"
title="Switch to preview mode"
className={classNames(viewMode === 'preview' && 'bg-surface-highlight !text-text')}
event={{ id: 'md_mode', mode: viewMode }}
onClick={() => setViewMode('preview')}
/>
</VStack>
</div>
);
}

View File

@@ -0,0 +1,201 @@
.prose {
@apply text-text;
& > :first-child {
@apply mt-0;
}
img,
video,
p,
ul,
ol,
table,
blockquote,
hr,
h1,
h2,
h3,
h4,
h5,
h6 {
@apply my-5;
}
h1,
h2,
h3,
h4,
h5,
h6 {
@apply mt-10 leading-tight text-balance;
}
p {
@apply text-pretty;
}
h1 {
@apply text-4xl font-bold;
}
h2 {
@apply text-2xl font-bold;
}
h3 {
@apply text-xl font-bold;
}
em {
@apply italic;
}
strong {
@apply font-bold;
}
ul {
@apply list-disc;
ul, ol {
@apply my-0;
}
}
ol {
@apply list-decimal;
ol, ul {
@apply my-0;
}
}
ol, ul {
@apply pl-6;
li p {
@apply inline-block my-0;
}
li {
@apply pl-2;
}
li::marker {
@apply text-success;
}
}
a {
@apply text-notice hover:underline;
* {
@apply text-notice !important;
}
}
img,
video {
@apply max-h-[65vh];
@apply w-auto mx-auto rounded-md;
}
table code,
p code,
ol code,
ul code {
@apply text-xs bg-surface-active text-info font-normal whitespace-nowrap;
@apply px-1.5 py-0.5 rounded not-italic;
}
pre {
@apply bg-surface-highlight text-text !important;
@apply px-4 py-3 rounded-md;
@apply overflow-auto whitespace-pre;
code {
@apply text-xs font-normal;
}
}
.banner {
@apply border border-dashed;
@apply border-border bg-surface-highlight text-text px-4 py-3 rounded text-base;
&::before {
@apply block font-bold mb-1;
@apply text-text-subtlest;
content: 'Note';
}
&.x-theme-banner--secondary::before {
content: 'Info';
}
&.x-theme-banner--success::before {
content: 'Tip';
}
&.x-theme-banner--notice::before {
content: 'Important';
}
&.x-theme-banner--warning::before {
content: 'Warning';
}
&.x-theme-banner--danger::before {
content: 'Caution';
}
}
blockquote {
@apply italic py-3 pl-5 pr-3 border-l-8 border-surface-active text-lg text-text bg-surface-highlight rounded shadow-lg;
p {
@apply m-0;
}
}
h2[id] > a .icon.icon-link {
@apply hidden w-4 h-4 bg-success ml-2;
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24' stroke='currentColor' stroke-width='2' fill='none' stroke-linecap='round' stroke-linejoin='round' class='css-i6dzq1'%3E%3Cpath d='M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71'%3E%3C/path%3E%3Cpath d='M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71'%3E%3C/path%3E%3C/svg%3E");
mask-size: contain;
mask-repeat: no-repeat;
&:hover {
@apply bg-notice;
}
}
h2[id]:hover {
.icon.icon-link {
@apply inline-block;
}
}
hr {
@apply border-secondary border-dashed md:mx-[25%] my-10;
}
figure {
img {
@apply mb-0;
}
figcaption {
@apply relative pl-9 text-success text-sm pt-1;
p {
@apply m-0;
}
}
figcaption::before {
@apply border-info absolute left-2 top-0 h-3.5 w-6 rounded-bl border-l border-b border-dotted;
content: '';
}
}
}

View File

@@ -0,0 +1,12 @@
import classNames from 'classnames';
import type { ReactNode } from 'react';
import './Prose.css';
interface Props {
children: ReactNode;
className?: string;
}
export function Prose({ className, ...props }: Props) {
return <div className={classNames('prose', className)} {...props} />;
}

View File

@@ -1,4 +1,4 @@
import type { HttpRequest, HttpRequestHeader, HttpUrlParameter } from '@yaakapp-internal/models';
import type { HttpRequest } from '@yaakapp-internal/models';
import classNames from 'classnames';
import type { CSSProperties } from 'react';
import React, { memo, useCallback, useMemo, useState } from 'react';
@@ -15,6 +15,7 @@ import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
import { useSendAnyHttpRequest } from '../hooks/useSendAnyHttpRequest';
import { useUpdateAnyHttpRequest } from '../hooks/useUpdateAnyHttpRequest';
import { languageFromContentType } from '../lib/contentType';
import { fallbackRequestName } from '../lib/fallbackRequestName';
import { tryFormatJson } from '../lib/formatters';
import {
AUTH_TYPE_BASIC,
@@ -34,9 +35,13 @@ import { BearerAuth } from './BearerAuth';
import { BinaryFileEditor } from './BinaryFileEditor';
import { CountBadge } from './core/CountBadge';
import { Editor } from './core/Editor';
import type { GenericCompletionOption } from './core/Editor/genericCompletion';
import type {
GenericCompletionConfig,
GenericCompletionOption,
} from './core/Editor/genericCompletion';
import { InlineCode } from './core/InlineCode';
import type { Pair } from './core/PairEditor';
import { PlainInput } from './core/PlainInput';
import type { TabItem } from './core/Tabs/Tabs';
import { TabContent, Tabs } from './core/Tabs/Tabs';
import { EmptyStateText } from './EmptyStateText';
@@ -44,6 +49,7 @@ import { FormMultipartEditor } from './FormMultipartEditor';
import { FormUrlencodedEditor } from './FormUrlencodedEditor';
import { GraphQLEditor } from './GraphQLEditor';
import { HeadersEditor } from './HeadersEditor';
import { MarkdownEditor } from './MarkdownEditor';
import { useToast } from './ToastContext';
import { UrlBar } from './UrlBar';
import { UrlParametersEditor } from './UrlParameterEditor';
@@ -59,8 +65,7 @@ const TAB_BODY = 'body';
const TAB_PARAMS = 'params';
const TAB_HEADERS = 'headers';
const TAB_AUTH = 'auth';
const DEFAULT_TAB = TAB_BODY;
const TAB_DESCRIPTION = 'description';
export const RequestPane = memo(function RequestPane({
style,
@@ -120,6 +125,15 @@ export const RequestPane = memo(function RequestPane({
const tabs: TabItem[] = useMemo(
() => [
{
value: TAB_DESCRIPTION,
label: (
<div className="flex items-center">
Docs
{activeRequest.description && <CountBadge count={true} />}
</div>
),
},
{
value: TAB_BODY,
options: {
@@ -239,68 +253,36 @@ export const RequestPane = memo(function RequestPane({
activeRequest.authentication,
activeRequest.authenticationType,
activeRequest.bodyType,
activeRequest.description,
activeRequest.headers,
activeRequest.method,
activeRequestId,
handleContentTypeChange,
toast,
updateRequest,
urlParameterPairs,
urlParameterPairs.length,
],
);
const sendRequest = useSendAnyHttpRequest();
const { activeResponse } = usePinnedHttpResponse(activeRequest);
const cancelResponse = useCancelHttpResponse(activeResponse?.id ?? null);
const isLoading = useIsResponseLoading(activeRequestId);
const { updateKey } = useRequestUpdateKey(activeRequestId);
const importCurl = useImportCurl();
const importQuerystring = useImportQuerystring(activeRequestId);
const handleBodyChange = useCallback(
(body: HttpRequest['body']) => updateRequest.mutate({ id: activeRequestId, update: { body } }),
[activeRequestId, updateRequest],
);
const handleBinaryFileChange = useCallback(
(body: HttpRequest['body']) => {
updateRequest.mutate({ id: activeRequestId, update: { body } });
},
[activeRequestId, updateRequest],
);
const handleBodyTextChange = useCallback(
(text: string) => updateRequest.mutate({ id: activeRequestId, update: { body: { text } } }),
[activeRequestId, updateRequest],
);
const handleHeadersChange = useCallback(
(headers: HttpRequestHeader[]) =>
updateRequest.mutate({ id: activeRequestId, update: { headers } }),
[activeRequestId, updateRequest],
);
const handleUrlParametersChange = useCallback(
(urlParameters: HttpUrlParameter[]) =>
updateRequest.mutate({ id: activeRequestId, update: { urlParameters } }),
[activeRequestId, updateRequest],
);
const sendRequest = useSendAnyHttpRequest();
const { activeResponse } = usePinnedHttpResponse(activeRequest);
const cancelResponse = useCancelHttpResponse(activeResponse?.id ?? null);
const handleSend = useCallback(async () => {
await sendRequest.mutateAsync(activeRequest.id ?? null);
}, [activeRequest.id, sendRequest]);
const handleCancel = useCallback(async () => {
await cancelResponse.mutateAsync();
}, [cancelResponse]);
const handleMethodChange = useCallback(
(method: string) => updateRequest.mutate({ id: activeRequestId, update: { method } }),
[activeRequestId, updateRequest],
);
const handleUrlChange = useCallback(
(url: string) => updateRequest.mutate({ id: activeRequestId, update: { url } }),
[activeRequestId, updateRequest],
);
const isLoading = useIsResponseLoading(activeRequestId);
const { updateKey } = useRequestUpdateKey(activeRequestId);
const importCurl = useImportCurl();
const importQuerystring = useImportQuerystring(activeRequestId);
const activeTab = activeTabs?.[activeRequestId] ?? DEFAULT_TAB;
const activeTab = activeTabs?.[activeRequestId];
const setActiveTab = useCallback(
(tab: string) => {
setActiveTabs((r) => ({ ...r, [activeRequest.id]: tab }));
@@ -312,6 +294,21 @@ export const RequestPane = memo(function RequestPane({
setActiveTab(TAB_PARAMS);
});
const autocomplete: GenericCompletionConfig = {
minMatch: 3,
options:
requests.length > 0
? [
...requests
.filter((r) => r.id !== activeRequestId)
.map((r): GenericCompletionOption => ({ type: 'constant', label: r.url })),
]
: [
{ label: 'http://', type: 'constant' },
{ label: 'https://', type: 'constant' },
],
};
return (
<div
style={style}
@@ -332,30 +329,15 @@ export const RequestPane = memo(function RequestPane({
importQuerystring.mutate(text);
}
}}
autocomplete={{
minMatch: 3,
options:
requests.length > 0
? [
...requests
.filter((r) => r.id !== activeRequestId)
.map(
(r) =>
({
type: 'constant',
label: r.url,
}) as GenericCompletionOption,
),
]
: [
{ label: 'http://', type: 'constant' },
{ label: 'https://', type: 'constant' },
],
}}
onSend={handleSend}
onCancel={handleCancel}
onMethodChange={handleMethodChange}
onUrlChange={handleUrlChange}
autocomplete={autocomplete}
onSend={() => sendRequest.mutateAsync(activeRequest.id ?? null)}
onCancel={cancelResponse.mutate}
onMethodChange={(method) =>
updateRequest.mutate({ id: activeRequestId, update: { method } })
}
onUrlChange={(url: string) =>
updateRequest.mutate({ id: activeRequestId, update: { url } })
}
forceUpdateKey={updateKey}
isLoading={isLoading}
/>
@@ -382,14 +364,18 @@ export const RequestPane = memo(function RequestPane({
<HeadersEditor
forceUpdateKey={`${forceUpdateHeaderEditorKey}::${forceUpdateKey}`}
headers={activeRequest.headers}
onChange={handleHeadersChange}
onChange={(headers) =>
updateRequest.mutate({ id: activeRequestId, update: { headers } })
}
/>
</TabContent>
<TabContent value={TAB_PARAMS}>
<UrlParametersEditor
forceUpdateKey={forceUpdateKey + urlParametersKey}
pairs={urlParameterPairs}
onChange={handleUrlParametersChange}
onChange={(urlParameters) =>
updateRequest.mutate({ id: activeRequestId, update: { urlParameters } })
}
/>
</TabContent>
<TabContent value={TAB_BODY}>
@@ -440,7 +426,9 @@ export const RequestPane = memo(function RequestPane({
requestId={activeRequest.id}
contentType={contentType}
body={activeRequest.body}
onChange={handleBinaryFileChange}
onChange={(body) =>
updateRequest.mutate({ id: activeRequestId, update: { body } })
}
onChangeContentType={handleContentTypeChange}
/>
) : typeof activeRequest.bodyType === 'string' ? (
@@ -458,6 +446,26 @@ export const RequestPane = memo(function RequestPane({
<EmptyStateText>Empty Body</EmptyStateText>
)}
</TabContent>
<TabContent value={TAB_DESCRIPTION}><div className="grid grid-rows-[auto_minmax(0,1fr)] h-full">
<PlainInput
label="Request Name"
hideLabel
defaultValue={activeRequest.name}
className="font-sans !text-xl !px-0"
containerClassName="border-0"
placeholder={fallbackRequestName(activeRequest)}
onChange={(name) => updateRequest.mutate({ id: activeRequestId, update: { name } })}
/>
<MarkdownEditor
name="request-description"
placeholder="A Markdown description of this request."
defaultValue={activeRequest.description}
onChange={(description) =>
updateRequest.mutate({ id: activeRequestId, update: { description } })
}
/>
</div>
</TabContent>
</Tabs>
</>
)}

View File

@@ -38,7 +38,6 @@ interface Props {
const TAB_BODY = 'body';
const TAB_HEADERS = 'headers';
const TAB_INFO = 'info';
const DEFAULT_TAB = TAB_BODY;
export const ResponsePane = memo(function ResponsePane({ style, className, activeRequest }: Props) {
const { activeResponse, setPinnedResponseId, responses } = usePinnedHttpResponse(activeRequest);
@@ -48,13 +47,6 @@ export const ResponsePane = memo(function ResponsePane({ style, className, activ
{},
);
const contentType = useContentTypeFromHeaders(activeResponse?.headers ?? null);
const activeTab = activeTabs?.[activeRequest.id] ?? DEFAULT_TAB;
const setActiveTab = useCallback(
(tab: string) => {
setActiveTabs((r) => ({ ...r, [activeRequest.id]: tab }));
},
[activeRequest.id, setActiveTabs],
);
const tabs = useMemo<TabItem[]>(
() => [
@@ -88,6 +80,13 @@ export const ResponsePane = memo(function ResponsePane({ style, className, activ
],
[activeResponse?.headers, contentType, setViewMode, viewMode],
);
const activeTab = activeTabs?.[activeRequest.id];
const setActiveTab = useCallback(
(tab: string) => {
setActiveTabs((r) => ({ ...r, [activeRequest.id]: tab }));
},
[activeRequest.id, setActiveTabs],
);
const isLoading = isResponseLoading(activeResponse);

View File

@@ -32,7 +32,6 @@ import { useHttpRequestActions } from '../hooks/useHttpRequestActions';
import { useHttpResponses } from '../hooks/useHttpResponses';
import { useKeyValue } from '../hooks/useKeyValue';
import { useMoveToWorkspace } from '../hooks/useMoveToWorkspace';
import { usePrompt } from '../hooks/usePrompt';
import { useRenameRequest } from '../hooks/useRenameRequest';
import { useRequests } from '../hooks/useRequests';
import { useScrollIntoView } from '../hooks/useScrollIntoView';
@@ -50,10 +49,11 @@ import type { DropdownItem } from './core/Dropdown';
import { ContextMenu } from './core/Dropdown';
import { HttpMethodTag } from './core/HttpMethodTag';
import { Icon } from './core/Icon';
import { InlineCode } from './core/InlineCode';
import { VStack } from './core/Stacks';
import { StatusTag } from './core/StatusTag';
import { useDialog } from './DialogContext';
import { DropMarker } from './DropMarker';
import { FolderSettingsDialog } from './FolderSettingsDialog';
interface Props {
className?: string;
@@ -694,6 +694,7 @@ function SidebarItem({
connectDrag(connectDrop(ref));
const dialog = useDialog();
const activeRequest = useActiveRequest();
const deleteFolder = useDeleteFolder(itemId);
const deleteRequest = useDeleteRequest(itemId);
@@ -706,8 +707,6 @@ function SidebarItem({
const updateHttpRequest = useUpdateAnyHttpRequest();
const workspaces = useWorkspaces();
const updateGrpcRequest = useUpdateAnyGrpcRequest();
const updateAnyFolder = useUpdateAnyFolder();
const prompt = usePrompt();
const [editing, setEditing] = useState<boolean>(false);
const isActive = activeRequest?.id === itemId;
const createDropdownItems = useCreateDropdownItems({ folderId: itemId });
@@ -786,35 +785,25 @@ function SidebarItem({
if (itemModel === 'folder') {
return [
{
key: 'sendAll',
key: 'send-all',
label: 'Send All',
leftSlot: <Icon icon="send_horizontal" />,
onSelect: () => sendManyRequests.mutate(child.children.map((c) => c.item.id)),
},
{
key: 'rename',
label: 'Rename',
leftSlot: <Icon icon="pencil" />,
onSelect: async () => {
const name = await prompt({
id: 'rename-folder',
title: 'Rename Folder',
description: (
<>
Enter a new name for <InlineCode>{itemName}</InlineCode>
</>
),
confirmText: 'Save',
label: 'Name',
placeholder: 'New Name',
defaultValue: itemName,
});
if (name == null) return;
updateAnyFolder.mutate({ id: itemId, update: (f) => ({ ...f, name }) });
},
key: 'folder-settings',
label: 'Settings',
leftSlot: <Icon icon="settings" />,
onSelect: () =>
dialog.show({
id: 'folder-settings',
title: 'Folder Settings',
size: 'md',
render: () => <FolderSettingsDialog folderId={itemId} />,
}),
},
{
key: 'deleteFolder',
key: 'delete-folder',
label: 'Delete',
variant: 'danger',
leftSlot: <Icon icon="trash" />,
@@ -828,7 +817,7 @@ function SidebarItem({
itemModel === 'http_request'
? [
{
key: 'sendRequest',
key: 'send-request',
label: 'Send',
hotKeyAction: 'http_request.send',
hotKeyLabelOnly: true, // Already bound in URL bar
@@ -851,13 +840,13 @@ function SidebarItem({
return [
...requestItems,
{
key: 'renameRequest',
key: 'rename-request',
label: 'Rename',
leftSlot: <Icon icon="pencil" />,
onSelect: renameRequest.mutate,
},
{
key: 'duplicateRequest',
key: 'duplicate-request',
label: 'Duplicate',
hotKeyAction: 'http_request.duplicate',
hotKeyLabelOnly: true, // Would trigger for every request (bad)
@@ -868,14 +857,14 @@ function SidebarItem({
: duplicateGrpcRequest.mutate(),
},
{
key: 'moveWorkspace',
key: 'move-workspace',
label: 'Move',
leftSlot: <Icon icon="arrow_right_circle" />,
hidden: workspaces.length <= 1,
onSelect: moveToWorkspace.mutate,
},
{
key: 'deleteRequest',
key: 'delete-request',
variant: 'danger',
label: 'Delete',
leftSlot: <Icon icon="trash" />,
@@ -888,18 +877,16 @@ function SidebarItem({
createDropdownItems,
deleteFolder,
deleteRequest,
dialog,
duplicateGrpcRequest,
duplicateHttpRequest,
httpRequestActions,
itemId,
itemModel,
itemName,
moveToWorkspace.mutate,
prompt,
renameRequest.mutate,
sendManyRequests,
sendRequest,
updateAnyFolder,
workspaces.length,
]);

View File

@@ -66,7 +66,7 @@ export const UrlBar = memo(function UrlBar({
<Input
autocompleteVariables
ref={inputRef}
size="sm"
size="md"
wrapLines={isFocused}
hideLabel
useTemplating
@@ -86,26 +86,30 @@ export const UrlBar = memo(function UrlBar({
leftSlot={
method != null &&
onMethodChange != null && (
<RequestMethodDropdown
method={method}
onChange={onMethodChange}
className="my-0.5 ml-0.5"
/>
<div className="py-0.5">
<RequestMethodDropdown
method={method}
onChange={onMethodChange}
className="ml-0.5 !h-full"
/>
</div>
)
}
rightSlot={
<>
{rightSlot}
{submitIcon !== null && (
<IconButton
size="xs"
iconSize="md"
title="Send Request"
type="submit"
className="w-8 my-0.5 mr-0.5"
icon={isLoading ? 'x' : submitIcon}
hotkeyAction="http_request.send"
/>
<div className="py-0.5">
<IconButton
size="xs"
iconSize="md"
title="Send Request"
type="submit"
className="w-8 mr-0.5 !h-full"
icon={isLoading ? 'x' : submitIcon}
hotkeyAction="http_request.send"
/>
</div>
)}
</>
}

View File

@@ -0,0 +1,39 @@
import { useUpdateWorkspace } from '../hooks/useUpdateWorkspace';
import { useWorkspaces } from '../hooks/useWorkspaces';
import { Banner } from './core/Banner';
import { PlainInput } from './core/PlainInput';
import { VStack } from './core/Stacks';
import { MarkdownEditor } from './MarkdownEditor';
interface Props {
workspaceId: string | null;
}
export function WorkspaceSettingsDialog({ workspaceId }: Props) {
const updateWorkspace = useUpdateWorkspace(workspaceId ?? null);
const workspaces = useWorkspaces();
const workspace = workspaces.find((w) => w.id === workspaceId);
if (workspace == null) return null;
return (
<VStack space={3} className="pb-3">
{updateWorkspace.error != null && (
<Banner color="danger">{String(updateWorkspace.error)}</Banner>
)}
<PlainInput
label="Workspace Name"
defaultValue={workspace.name}
onChange={(name) => updateWorkspace.mutate({ name })}
/>
<MarkdownEditor
name="workspace-description"
placeholder="A Markdown description of this workspace."
className="min-h-[10rem] border border-border px-2"
defaultValue={workspace.description}
onChange={(description) => updateWorkspace.mutate({ description })}
/>
</VStack>
);
}

View File

@@ -2,7 +2,6 @@ import classNames from 'classnames';
import { motion } from 'framer-motion';
import type { CSSProperties, MouseEvent as ReactMouseEvent } from 'react';
import { useCallback, useMemo, useRef, useState } from 'react';
import { useWindowSize } from 'react-use';
import { useActiveRequest } from '../hooks/useActiveRequest';
import { useActiveWorkspace } from '../hooks/useActiveWorkspace';
import { useFloatingSidebarHidden } from '../hooks/useFloatingSidebarHidden';
@@ -34,13 +33,13 @@ const drag = { gridArea: 'drag' };
export default function Workspace() {
useSyncWorkspaceRequestTitle();
const workspaces = useWorkspaces();
const activeWorkspace = useActiveWorkspace();
const { setWidth, width, resetWidth } = useSidebarWidth();
const [sidebarHidden, setSidebarHidden] = useSidebarHidden();
const [floatingSidebarHidden, setFloatingSidebarHidden] = useFloatingSidebarHidden();
const activeRequest = useActiveRequest();
const windowSize = useWindowSize();
const importData = useImportData();
const floating = useShouldFloatSidebar();
const [isResizing, setIsResizing] = useState<boolean>(false);
@@ -103,14 +102,6 @@ export default function Workspace() {
[sideWidth, floating],
);
if (windowSize.width <= 100) {
return (
<div>
<Button>Send</Button>
</div>
);
}
// We're loading still
if (workspaces.length === 0) {
return null;

View File

@@ -5,20 +5,18 @@ import { useCreateWorkspace } from '../hooks/useCreateWorkspace';
import { useDeleteSendHistory } from '../hooks/useDeleteSendHistory';
import { useDeleteWorkspace } from '../hooks/useDeleteWorkspace';
import { useOpenWorkspace } from '../hooks/useOpenWorkspace';
import { usePrompt } from '../hooks/usePrompt';
import { useSettings } from '../hooks/useSettings';
import { useUpdateWorkspace } from '../hooks/useUpdateWorkspace';
import { useWorkspaces } from '../hooks/useWorkspaces';
import { getWorkspace } from '../lib/store';
import type { ButtonProps } from './core/Button';
import { Button } from './core/Button';
import type { DropdownItem } from './core/Dropdown';
import { Icon } from './core/Icon';
import { InlineCode } from './core/InlineCode';
import type { RadioDropdownItem } from './core/RadioDropdown';
import { RadioDropdown } from './core/RadioDropdown';
import { useDialog } from './DialogContext';
import { OpenWorkspaceDialog } from './OpenWorkspaceDialog';
import { WorkspaceSettingsDialog } from './WorkpaceSettingsDialog';
type Props = Pick<ButtonProps, 'className' | 'justify' | 'forDropdown' | 'leftSlot'>;
@@ -29,11 +27,9 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
const workspaces = useWorkspaces();
const activeWorkspace = useActiveWorkspace();
const activeWorkspaceId = activeWorkspace?.id ?? null;
const updateWorkspace = useUpdateWorkspace(activeWorkspaceId);
const deleteWorkspace = useDeleteWorkspace(activeWorkspace);
const createWorkspace = useCreateWorkspace();
const dialog = useDialog();
const prompt = usePrompt();
const settings = useSettings();
const openWorkspace = useOpenWorkspace();
const openWorkspaceNewWindow = settings?.openWorkspaceNewWindow ?? null;
@@ -52,24 +48,16 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
const extraItems: DropdownItem[] = [
{
key: 'rename',
label: 'Rename',
leftSlot: <Icon icon="pencil" />,
key: 'workspace-settings',
label: 'Settings',
leftSlot: <Icon icon="settings" />,
onSelect: async () => {
const name = await prompt({
id: 'rename-workspace',
title: 'Rename Workspace',
description: (
<>
Enter a new name for <InlineCode>{activeWorkspace?.name}</InlineCode>
</>
),
label: 'Name',
placeholder: 'New Name',
defaultValue: activeWorkspace?.name,
dialog.show({
id: 'workspace-settings',
title: 'Workspace Settings',
size: 'md',
render: () => <WorkspaceSettingsDialog workspaceId={activeWorkspace?.id ?? null} />,
});
if (name == null) return;
updateWorkspace.mutate({ name });
},
},
{
@@ -96,13 +84,12 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
return { workspaceItems, extraItems };
}, [
activeWorkspace?.name,
activeWorkspace,
activeWorkspaceId,
createWorkspace.mutate,
deleteSendHistory.mutate,
deleteWorkspace.mutate,
prompt,
updateWorkspace,
dialog,
workspaces,
]);

View File

@@ -45,6 +45,7 @@ export const WorkspaceHeader = memo(function WorkspaceHeader({ className }: Prop
icon="search"
title="Search or execute a command"
size="sm"
event="search"
onClick={togglePalette}
/>
<SettingsDropdown />

View File

@@ -1,7 +1,7 @@
import classNames from 'classnames';
interface Props {
count: number;
count: number | true;
className?: string;
}
@@ -12,10 +12,11 @@ export function CountBadge({ count, className }: Props) {
aria-hidden
className={classNames(
className,
'flex items-center',
'opacity-70 border border-border-subtle text-4xs rounded mb-0.5 px-1 ml-1 h-4 font-mono',
)}
>
{count}
{count === true ? <div aria-hidden className="rounded-full h-1 w-1 bg-text-subtle" /> : count}
</div>
);
}

View File

@@ -21,7 +21,7 @@
.cm-line {
@apply w-full;
/* Important! Ensure it spans the entire width */
@apply w-full text-text pl-1 pr-1.5;
@apply w-full text-text px-0;
}
.cm-placeholder {
@@ -51,7 +51,7 @@
/* Style gutters */
.cm-gutters {
@apply border-0 text-text-subtlest bg-surface;
@apply border-0 text-text-subtlest bg-surface pr-1.5;
/* Not sure why, but there's a tiny gap left of the gutter that you can see text
through. Move left slightly to fix that. */
@apply -left-[1px];
@@ -114,12 +114,6 @@
.cm-scroller {
@apply font-mono text-editor;
/*
* Round corners or they'll stick out of the editor bounds of editor is rounded.
* Could potentially be pushed up from the editor like we do with bg color but this
* is probably fine.
*/
@apply rounded-lg;
}
}
}

View File

@@ -8,12 +8,12 @@ import classNames from 'classnames';
import { EditorView } from 'codemirror';
import type { MutableRefObject, ReactNode } from 'react';
import {
useEffect,
Children,
cloneElement,
forwardRef,
isValidElement,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
@@ -45,7 +45,16 @@ export interface EditorProps {
type?: 'text' | 'password';
className?: string;
heightMode?: 'auto' | 'full';
language?: 'javascript' | 'json' | 'html' | 'xml' | 'graphql' | 'url' | 'pairs' | 'text';
language?:
| 'javascript'
| 'json'
| 'html'
| 'xml'
| 'graphql'
| 'url'
| 'pairs'
| 'text'
| 'markdown';
forceUpdateKey?: string | number;
autoFocus?: boolean;
autoSelect?: boolean;
@@ -66,6 +75,7 @@ export interface EditorProps {
autocompleteVariables?: boolean;
extraExtensions?: Extension[];
actions?: ReactNode;
hideGutter?: boolean;
}
const emptyVariables: EnvironmentVariable[] = [];
@@ -96,6 +106,7 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
autocompleteVariables,
actions,
wrapLines,
hideGutter,
}: EditorProps,
ref,
) {
@@ -310,6 +321,7 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
container,
readOnly,
singleLine,
hideGutter,
onChange: handleChange,
onPaste: handlePaste,
onPasteOverwrite: handlePasteOverwrite,
@@ -374,7 +386,7 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
const decoratedActions = useMemo(() => {
const results = [];
const actionClassName = classNames(
'bg-surface transition-opacity opacity-0 group-hover:opacity-100 hover:!opacity-100 shadow',
'bg-surface transition-opacity transform-gpu opacity-0 group-hover:opacity-100 hover:!opacity-100 shadow',
);
if (format) {
@@ -455,13 +467,14 @@ function getExtensions({
container,
readOnly,
singleLine,
hideGutter,
onChange,
onPaste,
onPasteOverwrite,
onFocus,
onBlur,
onKeyDown,
}: Pick<EditorProps, 'singleLine' | 'readOnly'> & {
}: Pick<EditorProps, 'singleLine' | 'readOnly' | 'hideGutter'> & {
container: HTMLDivElement | null;
onChange: MutableRefObject<EditorProps['onChange']>;
onPaste: MutableRefObject<EditorProps['onPaste']>;
@@ -499,7 +512,7 @@ function getExtensions({
tooltips({ parent }),
keymap.of(singleLine ? defaultKeymap.filter((k) => k.key !== 'Enter') : defaultKeymap),
...(singleLine ? [singleLineExt()] : []),
...(!singleLine ? [multiLineExtensions] : []),
...(!singleLine ? [multiLineExtensions({ hideGutter })] : []),
...(readOnly
? [EditorState.readOnly.of(true), EditorView.contentAttributes.of({ tabindex: '-1' })]
: []),

View File

@@ -6,6 +6,7 @@ import {
} from '@codemirror/autocomplete';
import { history, historyKeymap, indentWithTab } from '@codemirror/commands';
import { javascript } from '@codemirror/lang-javascript';
import { markdown } from '@codemirror/lang-markdown';
import { json } from '@codemirror/lang-json';
import { xml } from '@codemirror/lang-xml';
import type { LanguageSupport } from '@codemirror/language';
@@ -79,6 +80,7 @@ const syntaxExtensions: Record<NonNullable<EditorProps['language']>, LanguageSup
url: url(),
pairs: pairs(),
text: text(),
markdown: markdown(),
};
export function getLanguageExtension({
@@ -138,21 +140,25 @@ export const baseExtensions = [
keymap.of([...historyKeymap, ...completionKeymap]),
];
export const multiLineExtensions = [
lineNumbers(),
foldGutter({
markerDOM: (open) => {
const el = document.createElement('div');
el.classList.add('fold-gutter-icon');
el.tabIndex = -1;
if (open) {
el.setAttribute('data-open', '');
}
return el;
},
}),
export const multiLineExtensions = ({ hideGutter }: { hideGutter?: boolean }) => [
hideGutter
? []
: [
lineNumbers(),
foldGutter({
markerDOM: (open) => {
const el = document.createElement('div');
el.classList.add('fold-gutter-icon');
el.tabIndex = -1;
if (open) {
el.setAttribute('data-open', '');
}
return el;
},
}),
],
codeFolding({
placeholderDOM(view, onclick, prepared) {
placeholderDOM(_view, onclick, prepared) {
const el = document.createElement('span');
el.onclick = onclick;
el.className = 'cm-foldPlaceholder';

View File

@@ -30,6 +30,7 @@ const icons = {
circle_alert: lucide.CircleAlertIcon,
clock: lucide.ClockIcon,
code: lucide.CodeIcon,
columns_2: lucide.Columns2Icon,
cookie: lucide.CookieIcon,
copy: lucide.CopyIcon,
copy_check: lucide.CopyCheck,

View File

@@ -1,4 +1,4 @@
import useResizeObserver from '@react-hook/resize-observer';
import useSize from '@react-hook/size';
import classNames from 'classnames';
import type { CSSProperties, MouseEvent as ReactMouseEvent, ReactNode } from 'react';
import React, { useCallback, useMemo, useRef, useState } from 'react';
@@ -43,7 +43,6 @@ export function SplitLayout({
}: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const activeWorkspace = useActiveWorkspace();
const [verticalBasedOnSize, setVerticalBasedOnSize] = useState<boolean>(false);
const [widthRaw, setWidth] = useLocalStorage<number>(
`${name}_width::${activeWorkspace?.id ?? 'n/a'}`,
);
@@ -62,10 +61,7 @@ export function SplitLayout({
minHeightPx = 0;
}
useResizeObserver(containerRef.current, ({ contentRect }) => {
setVerticalBasedOnSize(contentRect.width < STACK_VERTICAL_WIDTH);
});
const verticalBasedOnSize = useSize(containerRef.current)[0] < STACK_VERTICAL_WIDTH;
const vertical = layout !== 'horizontal' && (layout === 'vertical' || verticalBasedOnSize);
const styles = useMemo<CSSProperties>(() => {

View File

@@ -40,6 +40,8 @@ export function Tabs({
}: Props) {
const ref = useRef<HTMLDivElement | null>(null);
value = value ?? tabs[0]?.value;
// Update tabs when value changes
useEffect(() => {
const tabs = ref.current?.querySelectorAll<HTMLDivElement>(`[data-tab]`);
@@ -61,7 +63,7 @@ export function Tabs({
return (
<div
ref={ref}
className={classNames(className, 'h-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1')}
className={classNames(className, 'h-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1 overflow-x-hidden')}
>
<div
aria-label={label}

View File

@@ -1,4 +1,3 @@
import useResizeObserver from '@react-hook/resize-observer';
import 'react-pdf/dist/Page/TextLayer.css';
import 'react-pdf/dist/Page/AnnotationLayer.css';
import { convertFileSrc } from '@tauri-apps/api/core';
@@ -6,7 +5,7 @@ import './PdfViewer.css';
import type { PDFDocumentProxy } from 'pdfjs-dist';
import React, { useRef, useState } from 'react';
import { Document, Page } from 'react-pdf';
import { useDebouncedState } from '../../hooks/useDebouncedState';
import useSize from '@react-hook/size';
interface Props {
bodyPath: string;
@@ -19,12 +18,9 @@ const options = {
export function PdfViewer({ bodyPath }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const [containerWidth, setContainerWidth] = useDebouncedState<number>(0, 100);
const [numPages, setNumPages] = useState<number>();
useResizeObserver(containerRef.current ?? null, (v) => {
setContainerWidth(v.contentRect.width);
});
const [containerWidth] = useSize(containerRef.current);
const onDocumentLoadSuccess = ({ numPages: nextNumPages }: PDFDocumentProxy): void => {
setNumPages(nextNumPages);

View File

@@ -69,7 +69,7 @@ function themeVariables(theme?: Partial<YaakColors>, base?: CSSVariables): CSSVa
theme?.shadow ??
YaakColor.black().translucify(isThemeDark(theme ?? ({} as Partial<YaakColors>)) ? 0.7 : 0.93),
primary: theme?.primary,
secondary: theme?.primary,
secondary: theme?.secondary,
info: theme?.info,
success: theme?.success,
notice: theme?.notice,

View File

@@ -12,12 +12,13 @@
"@codemirror/commands": "6.7.0",
"@codemirror/lang-javascript": "^6.2.2",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-markdown": "^6.3.1",
"@codemirror/lang-xml": "^6.0.2",
"@codemirror/language": "^6.6.0",
"@codemirror/search": "^6.2.3",
"@lezer/highlight": "^1.1.3",
"@lezer/lr": "^1.3.3",
"@react-hook/resize-observer": "^2.0.2",
"@react-hook/size": "^2.1.2",
"@tailwindcss/container-queries": "^0.1.1",
"@tanstack/react-query": "^5.59.16",
"@tanstack/react-virtual": "^3.10.8",
@@ -49,9 +50,11 @@
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "^18.3.1",
"react-helmet-async": "^2.0.5",
"react-markdown": "^9.0.1",
"react-pdf": "^9.1.0",
"react-router-dom": "^6.26.2",
"react-use": "^17.5.1",
"remark-gfm": "^4.0.0",
"slugify": "^1.6.6",
"uuid": "^10.0.0",
"whatwg-mimetype": "^4.0.0",

View File

@@ -4,7 +4,7 @@ const sizes = {
'2xs': '1.4rem',
xs: '1.8rem',
sm: '2.0rem',
md: '2.5rem',
md: '2.3rem',
};
/** @type {import("tailwindcss").Config} */