Git support (#143)

This commit is contained in:
Gregory Schier
2025-02-07 07:59:48 -08:00
committed by GitHub
parent cffc7714c1
commit 1a7c27663a
111 changed files with 4264 additions and 372 deletions

View File

@@ -3,6 +3,7 @@ import { fuzzyFilter } from 'fuzzbunny';
import type { KeyboardEvent, ReactNode } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createFolder } from '../commands/commands';
import { openSettings } from '../commands/openSettings';
import { switchWorkspace } from '../commands/switchWorkspace';
import { useActiveCookieJar } from '../hooks/useActiveCookieJar';
import { useActiveEnvironment } from '../hooks/useActiveEnvironment';
@@ -17,7 +18,6 @@ import { useEnvironments } from '../hooks/useEnvironments';
import type { HotkeyAction } from '../hooks/useHotKey';
import { useHotKey } from '../hooks/useHotKey';
import { useHttpRequestActions } from '../hooks/useHttpRequestActions';
import { useOpenSettings } from '../hooks/useOpenSettings';
import { useRecentEnvironments } from '../hooks/useRecentEnvironments';
import { useRecentRequests } from '../hooks/useRecentRequests';
import { useRecentWorkspaces } from '../hooks/useRecentWorkspaces';
@@ -71,7 +71,6 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
const [recentRequests] = useRecentRequests();
const [, setSidebarHidden] = useSidebarHidden();
const { baseEnvironment } = useEnvironments();
const { mutate: openSettings } = useOpenSettings();
const { mutate: createHttpRequest } = useCreateHttpRequest();
const { mutate: createGrpcRequest } = useCreateGrpcRequest();
const { mutate: createEnvironment } = useCreateEnvironment();
@@ -85,7 +84,7 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
key: 'settings.open',
label: 'Open Settings',
action: 'settings.show',
onSelect: openSettings,
onSelect: () => openSettings.mutate(null),
},
{
key: 'app.create',
@@ -193,7 +192,6 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
createWorkspace,
deleteRequest,
httpRequestActions,
openSettings,
renameRequest,
sendRequest,
setSidebarHidden,
@@ -406,7 +404,7 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
hideLabel
leftSlot={
<div className="h-md w-10 flex justify-center items-center">
<Icon icon="search" className="text-text-subtle" />
<Icon icon="search" color="secondary" />
</div>
}
name="command"

View File

@@ -7,7 +7,7 @@ import { useDeleteCookieJar } from '../hooks/useDeleteCookieJar';
import { useUpdateCookieJar } from '../hooks/useUpdateCookieJar';
import { showDialog } from '../lib/dialog';
import { showPrompt } from '../lib/prompt';
import {setWorkspaceSearchParams} from "../lib/setWorkspaceSearchParams";
import { setWorkspaceSearchParams } from '../lib/setWorkspaceSearchParams';
import { CookieDialog } from './CookieDialog';
import { Dropdown, type DropdownItem } from './core/Dropdown';
import { Icon } from './core/Icon';
@@ -76,7 +76,7 @@ export const CookieDropdown = memo(function CookieDropdown() {
label: 'Delete',
leftSlot: <Icon icon="trash" />,
color: 'danger',
onSelect: () => deleteCookieJar.mutateAsync(),
onSelect: deleteCookieJar.mutate,
},
]
: []) as DropdownItem[]),
@@ -94,7 +94,7 @@ export const CookieDropdown = memo(function CookieDropdown() {
return (
<Dropdown items={items}>
<IconButton size="sm" icon="cookie" title="Cookie Jar" />
<IconButton size="sm" icon="cookie" iconColor="secondary" title="Cookie Jar" />
</Dropdown>
);
});

View File

@@ -1,9 +1,11 @@
import { gitInit } from '@yaakapp-internal/git';
import type { WorkspaceMeta } from '@yaakapp-internal/models';
import { useState } from 'react';
import { upsertWorkspace } from '../commands/upsertWorkspace';
import { upsertWorkspaceMeta } from '../commands/upsertWorkspaceMeta';
import { router } from '../lib/router';
import { invokeCmd } from '../lib/tauri';
import { showErrorToast } from '../lib/toast';
import { Button } from './core/Button';
import { PlainInput } from './core/PlainInput';
import { VStack } from './core/Stacks';
@@ -15,7 +17,10 @@ interface Props {
export function CreateWorkspaceDialog({ hide }: Props) {
const [name, setName] = useState<string>('');
const [settingSyncDir, setSettingSyncDir] = useState<string | null>(null);
const [syncConfig, setSyncConfig] = useState<{
filePath: string | null;
initGit?: boolean;
}>({ filePath: null, initGit: true });
return (
<VStack
@@ -33,7 +38,16 @@ export function CreateWorkspaceDialog({ hide }: Props) {
const workspaceMeta = await invokeCmd<WorkspaceMeta>('cmd_get_workspace_meta', {
workspaceId: workspace.id,
});
upsertWorkspaceMeta.mutate({ ...workspaceMeta, settingSyncDir });
await upsertWorkspaceMeta.mutateAsync({
...workspaceMeta,
settingSyncDir: syncConfig.filePath,
});
if (syncConfig.initGit && syncConfig.filePath) {
gitInit(syncConfig.filePath).catch((err) => {
showErrorToast('git-init-error', String(err));
});
}
// Navigate to workspace
await router.navigate({
@@ -47,8 +61,8 @@ export function CreateWorkspaceDialog({ hide }: Props) {
<PlainInput required label="Name" defaultValue={name} onChange={setName} />
<SyncToFilesystemSetting
onChange={setSettingSyncDir}
value={settingSyncDir}
onChange={setSyncConfig}
value={syncConfig}
allowNonEmptyDirectory // Will do initial import when the workspace is created
/>
<Button type="submit" color="primary" className="ml-auto mt-3">

View File

@@ -61,7 +61,6 @@ export const EnvironmentEditDialog = function ({ initialEnvironment }: Props) {
<IconButton
size="sm"
iconSize="md"
color="custom"
title="Add sub environment"
icon="plus_circle"
iconClassName="text-text-subtlest group-hover:text-text-subtle"
@@ -166,7 +165,6 @@ const EnvironmentEditor = function ({
<Heading className="w-full flex items-center gap-1">
<div>{environment?.name}</div>
<IconButton
iconClassName="text-text-subtlest"
size="sm"
icon={valueVisibility.value ? 'eye' : 'eye_closed'}
title={valueVisibility.value ? 'Hide Values' : 'Reveal Values'}

View File

@@ -0,0 +1,312 @@
import type { GitStatusEntry } from '@yaakapp-internal/git';
import { useGit } from '@yaakapp-internal/git';
import type {
Environment,
Folder,
GrpcRequest,
HttpRequest,
WebsocketRequest,
Workspace,
} from '@yaakapp-internal/models';
import classNames from 'classnames';
import { useMemo, useState } from 'react';
import { fallbackRequestName } from '../lib/fallbackRequestName';
import { Banner } from './core/Banner';
import { Button } from './core/Button';
import type { CheckboxProps } from './core/Checkbox';
import { Checkbox } from './core/Checkbox';
import { Icon } from './core/Icon';
import { InlineCode } from './core/InlineCode';
import { Input } from './core/Input';
import { SplitLayout } from './core/SplitLayout';
import { HStack } from './core/Stacks';
import { EmptyStateText } from './EmptyStateText';
interface Props {
syncDir: string;
onDone: () => void;
workspace: Workspace;
}
interface TreeNode {
model: HttpRequest | GrpcRequest | WebsocketRequest | Folder | Environment | Workspace;
status: GitStatusEntry;
children: TreeNode[];
ancestors: TreeNode[];
}
export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
const [{ status }, { commit, add, unstage, push }] = useGit(syncDir);
const [message, setMessage] = useState<string>('');
const handleCreateCommit = async () => {
await commit.mutateAsync({ message });
onDone();
};
const handleCreateCommitAndPush = async () => {
await handleCreateCommit();
await push.mutateAsync();
onDone();
};
const entries = status.data?.entries ?? null;
const hasAddedAnything = entries?.find((s) => s.staged) != null;
const hasAnythingToAdd = entries?.find((s) => s.status !== 'current') != null;
const tree: TreeNode | null = useMemo(() => {
if (entries == null) {
return null;
}
const next = (model: TreeNode['model'], ancestors: TreeNode[]): TreeNode | null => {
const statusEntry = entries?.find((s) => s.relaPath.includes(model.id));
if (statusEntry == null) {
return null;
}
const node: TreeNode = {
model,
status: statusEntry,
children: [],
ancestors,
};
for (const entry of entries) {
const childModel = entry.next ?? entry.prev;
if (childModel == null) return null; // TODO: Is this right?
// TODO: Figure out why not all of these show up
if ('folderId' in childModel && childModel.folderId != null) {
if (childModel.folderId === model.id) {
const c = next(childModel, [...ancestors, node]);
if (c != null) node.children.push(c);
}
} else if ('workspaceId' in childModel && childModel.workspaceId === model.id) {
const c = next(childModel, [...ancestors, node]);
if (c != null) node.children.push(c);
} else {
// Do nothing
}
}
return node;
};
return next(workspace, []);
}, [entries, workspace]);
if (tree == null) {
return null;
}
if (!hasAnythingToAdd) {
return <EmptyStateText>No changes since last commit</EmptyStateText>;
}
const checkNode = (treeNode: TreeNode) => {
const checked = nodeCheckedStatus(treeNode);
const newChecked = checked === 'indeterminate' ? true : !checked;
setCheckedAndChildren(treeNode, newChecked, unstage.mutate, add.mutate);
// TODO: Also ensure parents are added properly
};
return (
<div className="grid grid-rows-1 h-full">
<SplitLayout
name="commit"
layout="vertical"
defaultRatio={0.3}
firstSlot={({ style }) => (
<div style={style} className="h-full overflow-y-auto -ml-1 pb-3">
<TreeNodeChildren node={tree} depth={0} onCheck={checkNode} />
</div>
)}
secondSlot={({ style }) => (
<div style={style} className="grid grid-rows-[minmax(0,1fr)_auto] gap-3 pb-2">
<Input
className="!text-base font-sans rounded-md"
placeholder="Commit message..."
onChange={setMessage}
stateKey={null}
label="Commit message"
fullHeight
multiLine
hideLabel
/>
{commit.error && <Banner color="danger">{commit.error}</Banner>}
<HStack alignItems="center">
<InlineCode>{status.data?.headRefShorthand}</InlineCode>
<HStack space={2} className="ml-auto">
<Button
color="secondary"
size="sm"
onClick={handleCreateCommit}
disabled={!hasAddedAnything}
isLoading={push.isPending || commit.isPending}
>
Commit
</Button>
<Button
color="primary"
size="sm"
disabled={!hasAddedAnything}
onClick={handleCreateCommitAndPush}
isLoading={push.isPending || commit.isPending}
>
Commit and Push
</Button>
</HStack>
</HStack>
</div>
)}
/>
</div>
);
}
function TreeNodeChildren({
node,
depth,
onCheck,
}: {
node: TreeNode | null;
depth: number;
onCheck: (node: TreeNode, checked: boolean) => void;
}) {
if (node === null) return null;
if (!isNodeRelevant(node)) return null;
const checked = nodeCheckedStatus(node);
return (
<div
className={classNames(
depth > 0 && 'pl-1 ml-[10px] border-l border-dashed border-border-subtle',
)}
>
<div className="flex gap-3 w-full h-xs">
<Checkbox
fullWidth
className="w-full hover:bg-surface-highlight rounded px-1 group"
checked={checked}
onChange={(checked) => onCheck(node, checked)}
title={
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto] gap-1 w-full items-center">
{node.model.model !== 'http_request' &&
node.model.model !== 'grpc_request' &&
node.model.model !== 'websocket_request' ? (
<Icon
color="secondary"
icon={
node.model.model === 'folder'
? 'folder'
: node.model.model === 'environment'
? 'variable'
: 'house'
}
/>
) : (
<span aria-hidden />
)}
<div className="truncate">
{fallbackRequestName(node.model)}
{/*({node.model.model})*/}
{/*({node.status.staged ? 'Y' : 'N'})*/}
</div>
{node.status.status !== 'current' && (
<InlineCode
className={classNames(
'py-0 ml-auto bg-transparent w-[6rem] text-center',
node.status.status === 'modified' && 'text-info',
node.status.status === 'added' && 'text-success',
node.status.status === 'removed' && 'text-danger',
)}
>
{node.status.status}
</InlineCode>
)}
</div>
}
/>
</div>
{node.children.map((childNode, i) => {
return (
<TreeNodeChildren
key={childNode.status.relaPath + i}
node={childNode}
depth={depth + 1}
onCheck={onCheck}
/>
);
})}
</div>
);
}
function nodeCheckedStatus(root: TreeNode): CheckboxProps['checked'] {
let numVisited = 0;
let numChecked = 0;
let numCurrent = 0;
const visitChildren = (n: TreeNode) => {
numVisited += 1;
if (n.status.status === 'current') {
numCurrent += 1;
} else if (n.status.staged) {
numChecked += 1;
}
for (const child of n.children) {
visitChildren(child);
}
};
visitChildren(root);
if (numVisited === numChecked + numCurrent) {
return true;
} else if (numChecked === 0) {
return false;
} else {
return 'indeterminate';
}
}
function setCheckedAndChildren(
node: TreeNode,
checked: boolean,
unstage: (args: { relaPaths: string[] }) => void,
add: (args: { relaPaths: string[] }) => void,
) {
const toAdd: string[] = [];
const toUnstage: string[] = [];
const next = (node: TreeNode) => {
for (const child of node.children) {
next(child);
}
if (node.status.status === 'current') {
// Nothing required
} else if (checked && !node.status.staged) {
toAdd.push(node.status.relaPath);
} else if (!checked && node.status.staged) {
toUnstage.push(node.status.relaPath);
}
};
next(node);
if (toAdd.length > 0) add({ relaPaths: toAdd });
if (toUnstage.length > 0) unstage({ relaPaths: toUnstage });
}
function isNodeRelevant(node: TreeNode): boolean {
if (node.status.status !== 'current') {
return true;
}
// Recursively check children
return node.children.some((c) => isNodeRelevant(c));
}

View File

@@ -0,0 +1,406 @@
import { gitInit, useGit } from '@yaakapp-internal/git';
import type { WorkspaceMeta } from '@yaakapp-internal/models';
import classNames from 'classnames';
import type { HTMLAttributes } from 'react';
import { forwardRef } from 'react';
import { openWorkspaceSettings } from '../commands/openWorkspaceSettings';
import { useActiveWorkspace } from '../hooks/useActiveWorkspace';
import { useKeyValue } from '../hooks/useKeyValue';
import { useWorkspaceMeta } from '../hooks/useWorkspaceMeta';
import { sync } from '../init/sync';
import { showConfirm, showConfirmDelete } from '../lib/confirm';
import { showDialog } from '../lib/dialog';
import { showPrompt } from '../lib/prompt';
import { showErrorToast, showToast } from '../lib/toast';
import { Banner } from './core/Banner';
import type { DropdownItem } from './core/Dropdown';
import { Dropdown } from './core/Dropdown';
import { Icon } from './core/Icon';
import { InlineCode } from './core/InlineCode';
import { BranchSelectionDialog } from './git/BranchSelectionDialog';
import { HistoryDialog } from './git/HistoryDialog';
import { GitCommitDialog } from './GitCommitDialog';
export function GitDropdown() {
const workspaceMeta = useWorkspaceMeta();
if (workspaceMeta == null) return null;
if (workspaceMeta.settingSyncDir == null) {
return <SetupSyncDropdown workspaceMeta={workspaceMeta} />;
}
return <SyncDropdownWithSyncDir syncDir={workspaceMeta.settingSyncDir} />;
}
function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
const workspace = useActiveWorkspace();
const [{ status, log }, { branch, deleteBranch, mergeBranch, push, pull, checkout }] =
useGit(syncDir);
if (workspace == null) {
return null;
}
const noRepo = status.error?.includes('not found');
if (noRepo) {
return <SetupGitDropdown workspaceId={workspace.id} initRepo={() => gitInit(syncDir)} />;
}
const tryCheckout = (branch: string, force: boolean) => {
checkout.mutate(
{ branch, force },
{
async onError(err) {
if (!force) {
// Checkout failed so ask user if they want to force it
const forceCheckout = await showConfirm({
id: 'git-force-checkout',
title: 'Conflicts Detected',
description:
'Your branch has conflicts. Either make a commit or force checkout to discard changes.',
confirmText: 'Force Checkout',
color: 'warning',
});
if (forceCheckout) {
tryCheckout(branch, true);
}
} else {
// Checkout failed
showErrorToast('git-checkout-error', String(err));
}
},
async onSuccess() {
showToast({
id: 'git-checkout-success',
message: (
<>
Switched branch <InlineCode>{branch}</InlineCode>
</>
),
color: 'success',
});
await sync({ force: true });
},
},
);
};
const items: DropdownItem[] = [
{
label: 'View History',
hidden: (log.data ?? []).length === 0,
leftSlot: <Icon icon="history" />,
onSelect: async () => {
showDialog({
id: 'git-history',
size: 'md',
title: 'Commit History',
render: () => <HistoryDialog log={log.data ?? []} />,
});
},
},
{
label: 'New Branch',
leftSlot: <Icon icon="git_branch_plus" />,
async onSelect() {
const name = await showPrompt({
id: 'git-branch-name',
title: 'Create Branch',
label: 'Branch Name',
});
if (name) {
await branch.mutateAsync(
{ branch: name },
{
onError: (err) => {
showErrorToast('git-branch-error', String(err));
},
},
);
tryCheckout(name, false);
}
},
},
{
label: 'Merge Branch',
leftSlot: <Icon icon="merge" />,
hidden: (status.data?.branches ?? []).length <= 1,
async onSelect() {
showDialog({
id: 'git-merge',
title: 'Merge Branch',
size: 'sm',
description: (
<>
Select a branch to merge into <InlineCode>{status.data?.headRefShorthand}</InlineCode>
</>
),
render: ({ hide }) => (
<BranchSelectionDialog
selectText="Merge"
branches={(status.data?.branches ?? []).filter(
(b) => b !== status.data?.headRefShorthand,
)}
onCancel={hide}
onSelect={async (branch) => {
await mergeBranch.mutateAsync(
{ branch, force: false },
{
onSettled: hide,
onSuccess() {
showToast({
id: 'git-merged-branch',
message: (
<>
Merged <InlineCode>{branch}</InlineCode> into{' '}
<InlineCode>{status.data?.headRefShorthand}</InlineCode>
</>
),
});
sync({ force: true });
},
onError(err) {
showErrorToast('git-merged-branch-error', String(err));
},
},
);
}}
/>
),
});
},
},
{
label: 'Delete Branch',
leftSlot: <Icon icon="trash" />,
hidden: (status.data?.branches ?? []).length <= 1,
color: 'danger',
async onSelect() {
const currentBranch = status.data?.headRefShorthand;
if (currentBranch == null) return;
const confirmed = await showConfirmDelete({
id: 'git-delete-branch',
title: 'Delete Branch',
description: (
<>
Permanently delete <InlineCode>{currentBranch}</InlineCode>?
</>
),
});
if (confirmed) {
await deleteBranch.mutateAsync(
{ branch: currentBranch },
{
onError(err) {
showErrorToast('git-delete-branch-error', String(err));
},
async onSuccess() {
await sync({ force: true });
},
},
);
}
},
},
{ type: 'separator' },
{
label: 'Push',
hidden: (status.data?.origins ?? []).length === 0,
leftSlot: <Icon icon="arrow_up_from_line" />,
waitForOnSelect: true,
async onSelect() {
const message = await push.mutateAsync();
if (message === 'nothing_to_push') {
showToast({ id: 'push-success', message: 'Nothing to push', color: 'info' });
} else {
showToast({ id: 'push-success', message: 'Push successful', color: 'success' });
}
},
},
{
label: 'Pull',
hidden: (status.data?.origins ?? []).length === 0,
leftSlot: <Icon icon="arrow_down_to_line" />,
waitForOnSelect: true,
async onSelect() {
const result = await pull.mutateAsync(undefined, {
onError(err) {
showErrorToast('git-pull-error', String(err));
},
});
if (result.receivedObjects > 0) {
showToast({
id: 'git-pull-success',
message: `Pulled ${result.receivedObjects} objects`,
color: 'success',
});
await sync({ force: true });
} else {
showToast({ id: 'git-pull-success', message: 'Already up to date', color: 'info' });
}
},
},
{
label: 'Commit',
leftSlot: <Icon icon="git_branch" />,
onSelect() {
showDialog({
id: 'commit',
title: 'Commit Changes',
size: 'full',
className: '!max-h-[min(80vh,40rem)] !max-w-[min(50rem,90vw)]',
render: ({ hide }) => (
<GitCommitDialog syncDir={syncDir} onDone={hide} workspace={workspace} />
),
});
},
},
{ type: 'separator', label: 'Branches', hidden: (status.data?.branches ?? []).length < 1 },
...(status.data?.branches ?? []).map((branch) => {
const isCurrent = status.data?.headRefShorthand === branch;
return {
label: branch,
leftSlot: <Icon icon={isCurrent ? 'check' : 'empty'} />,
onSelect: isCurrent ? undefined : () => tryCheckout(branch, false),
};
}),
];
return (
<Dropdown fullWidth items={items}>
<GitMenuButton>
{noRepo ? 'Configure Git' : <InlineCode>{status.data?.headRefShorthand}</InlineCode>}
<Icon icon="git_branch" size="sm" />
</GitMenuButton>
</Dropdown>
);
}
const GitMenuButton = forwardRef<HTMLButtonElement, HTMLAttributes<HTMLButtonElement>>(
function GitMenuButton({ className, ...props }: HTMLAttributes<HTMLButtonElement>, ref) {
return (
<button
ref={ref}
className={classNames(
className,
'px-3 h-md border-t border-border flex items-center justify-between text-text-subtle',
)}
{...props}
/>
);
},
);
function SetupSyncDropdown({ workspaceMeta }: { workspaceMeta: WorkspaceMeta }) {
const { value: hidden, set: setHidden } = useKeyValue<Record<string, boolean>>({
key: 'setup_sync',
fallback: {},
});
if (hidden == null || hidden[workspaceMeta.workspaceId]) {
return null;
}
const banner = (
<Banner color="info">
When enabled, workspace data syncs to the chosen folder as text files, ideal for backup and
Git collaboration.
</Banner>
);
return (
<Dropdown
fullWidth
items={[
{
type: 'content',
label: banner,
},
{
label: 'Open Workspace Settings',
leftSlot: <Icon icon="settings" />,
onSelect() {
openWorkspaceSettings.mutate({ openSyncMenu: true });
},
},
{
label: 'Hide This Message',
leftSlot: <Icon icon="eye_closed" />,
async onSelect() {
const confirmed = await showConfirm({
id: 'hide-sync-menu-prompt',
title: 'Hide Setup Message',
description: 'You can configure filesystem sync or Git it in the workspace settings',
});
if (confirmed) {
await setHidden((prev) => ({ ...prev, [workspaceMeta.workspaceId]: true }));
}
},
},
]}
>
<GitMenuButton>
<div className="text-sm text-text-subtle grid grid-cols-[auto_minmax(0,1fr)] items-center gap-2">
<Icon icon="wrench" />
<div className="truncate">Setup FS Sync or Git</div>
</div>
</GitMenuButton>
</Dropdown>
);
}
function SetupGitDropdown({
workspaceId,
initRepo,
}: {
workspaceId: string;
initRepo: () => void;
}) {
const { value: hidden, set: setHidden } = useKeyValue<Record<string, boolean>>({
key: 'setup_git_repo',
fallback: {},
});
if (hidden == null || hidden[workspaceId]) {
return null;
}
const banner = <Banner color="info">Initialize local repo to start versioning with Git</Banner>;
return (
<Dropdown
fullWidth
items={[
{ type: 'content', label: banner },
{
label: 'Initialize Git Repo',
leftSlot: <Icon icon="magic_wand" />,
onSelect: initRepo,
},
{
color: 'warning',
label: 'Hide This Message',
leftSlot: <Icon icon="eye_closed" />,
async onSelect() {
const confirmed = await showConfirm({
id: 'hide-git-init-prompt',
title: 'Hide Git Setup',
description: 'You can initialize a git repo outside of Yaak to bring this back',
});
if (confirmed) {
await setHidden((prev) => ({ ...prev, [workspaceId]: true }));
}
},
},
]}
>
<GitMenuButton>
<div className="text-sm text-text-subtle grid grid-cols-[auto_minmax(0,1fr)] items-center gap-2">
<Icon icon="folder_git" />
<div className="truncate">Setup Git</div>
</div>
</GitMenuButton>
</Dropdown>
);
}

View File

@@ -214,16 +214,16 @@ function EventRow({
)}
>
<Icon
className={
color={
eventType === 'server_message'
? 'text-info'
? 'info'
: eventType === 'client_message'
? 'text-primary'
? 'primary'
: eventType === 'error' || (status != null && status > 0)
? 'text-danger'
? 'danger'
: eventType === 'connection_end'
? 'text-success'
: 'text-text-subtle'
? 'success'
: undefined
}
title={
eventType === 'server_message'

View File

@@ -237,14 +237,14 @@ export function GrpcConnectionSetupPane({
{
label: 'Refresh',
type: 'default',
leftSlot: <Icon className="text-text-subtlest" size="sm" icon="refresh" />,
leftSlot: <Icon size="sm" icon="refresh" />,
},
]}
>
<Button
size="sm"
variant="border"
rightSlot={<Icon className="text-text-subtlest" size="sm" icon="chevron_down" />}
rightSlot={<Icon size="sm" icon="chevron_down" />}
disabled={isStreaming || services == null}
className={classNames(
'font-mono text-editor min-w-[5rem] !ring-0',

View File

@@ -3,18 +3,26 @@ import type { LicenseCheckStatus } from '@yaakapp-internal/license';
import { useLicense } from '@yaakapp-internal/license';
import type { ReactNode } from 'react';
import { appInfo } from '../hooks/useAppInfo';
import { useOpenSettings } from '../hooks/useOpenSettings';
import type { ButtonProps } from './core/Button';
import { Button } from './core/Button';
import {HStack} from "./core/Stacks";
import { SettingsTab } from './Settings/SettingsTab';
import { Icon } from './core/Icon';
import { HStack } from './core/Stacks';
import { openSettings } from '../commands/openSettings';
import {SettingsTab} from "./Settings/SettingsTab";
const details: Record<
LicenseCheckStatus['type'] | 'dev' | 'beta',
{ label: ReactNode; color: ButtonProps['color'] } | null
> = {
beta: { label: <HStack space={1}><span>Beta Feedback</span><Icon size="xs" icon='external_link'/></HStack>, color: 'info' },
beta: {
label: (
<HStack space={1}>
<span>Beta Feedback</span>
<Icon size="xs" icon="external_link" />
</HStack>
),
color: 'info',
},
dev: { label: 'Develop', color: 'secondary' },
commercial_use: null,
invalid_license: { label: 'License Error', color: 'danger' },
@@ -23,7 +31,6 @@ const details: Record<
};
export function LicenseBadge() {
const openSettings = useOpenSettings(SettingsTab.License);
const { check } = useLicense();
if (check.data == null) {
@@ -49,7 +56,7 @@ export function LicenseBadge() {
if (checkType === 'beta') {
await openUrl('https://feedback.yaak.app');
} else {
openSettings.mutate();
openSettings.mutate(SettingsTab.License);
}
}}
color={detail.color}

View File

@@ -177,7 +177,7 @@ export function SettingsAppearance() {
className="mt-3 w-full bg-surface p-3 border border-dashed border-border-subtle rounded overflow-x-auto"
>
<HStack className="text" space={1.5}>
<Icon icon={appearance === 'dark' ? 'moon' : 'sun'} className="text-text-subtle" />
<Icon icon={appearance === 'dark' ? 'moon' : 'sun'} />
<strong>{activeTheme.active.name}</strong>
<em>(preview)</em>
</HStack>

View File

@@ -107,7 +107,6 @@ function PluginInfo({ plugin }: { plugin: Plugin }) {
size="sm"
icon="trash"
title="Uninstall plugin"
className="text-text-subtlest"
event="plugin.delete"
onClick={() => deletePlugin.mutate()}
/>

View File

@@ -1,11 +1,11 @@
import { openUrl } from '@tauri-apps/plugin-opener';
import { useRef } from 'react';
import { openSettings } from '../commands/openSettings';
import { useAppInfo } from '../hooks/useAppInfo';
import { useCheckForUpdates } from '../hooks/useCheckForUpdates';
import { useExportData } from '../hooks/useExportData';
import { useImportData } from '../hooks/useImportData';
import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent';
import { useOpenSettings } from '../hooks/useOpenSettings';
import { showDialog } from '../lib/dialog';
import type { DropdownRef } from './core/Dropdown';
import { Dropdown } from './core/Dropdown';
@@ -19,9 +19,8 @@ export function SettingsDropdown() {
const appInfo = useAppInfo();
const dropdownRef = useRef<DropdownRef>(null);
const checkForUpdates = useCheckForUpdates();
const openSettings = useOpenSettings();
useListenToTauriEvent('settings', () => openSettings.mutate());
useListenToTauriEvent('settings', () => openSettings.mutate(null));
return (
<Dropdown
@@ -31,7 +30,7 @@ export function SettingsDropdown() {
label: 'Settings',
hotKeyAction: 'settings.show',
leftSlot: <Icon icon="settings" />,
onSelect: openSettings.mutate,
onSelect: () => openSettings.mutate(null),
},
{
label: 'Keyboard shortcuts',
@@ -76,7 +75,13 @@ export function SettingsDropdown() {
},
]}
>
<IconButton size="sm" title="Main Menu" icon="settings" className="pointer-events-auto" />
<IconButton
size="sm"
title="Main Menu"
icon="settings"
iconColor="secondary"
className="pointer-events-auto"
/>
</Dropdown>
);
}

View File

@@ -1,38 +1,38 @@
import { readDir } from '@tauri-apps/plugin-fs';
import { useState } from 'react';
import { Banner } from './core/Banner';
import { Checkbox } from './core/Checkbox';
import { VStack } from './core/Stacks';
import { SelectFile } from './SelectFile';
export interface SyncToFilesystemSettingProps {
onChange: (filePath: string | null) => void;
value: string | null;
onChange: (args: { filePath: string | null; initGit?: boolean }) => void;
value: { filePath: string | null; initGit?: boolean };
allowNonEmptyDirectory?: boolean;
forceOpen?: boolean;
}
export function SyncToFilesystemSetting({
onChange,
value,
allowNonEmptyDirectory,
forceOpen,
}: SyncToFilesystemSettingProps) {
const [error, setError] = useState<string | null>(null);
return (
<details open={value != null} className="w-full">
<summary>Sync to filesystem</summary>
<details open={forceOpen || value != null} className="w-full">
<summary>Data directory {typeof value.initGit === 'boolean' && ' and Git'}</summary>
<VStack className="my-2" space={3}>
<Banner color="info">
When enabled, workspace data syncs to the chosen folder as text files, ideal for backup
and Git collaboration.
Sync workspace data to folder as plain text files, ideal for backup and Git collaboration.
</Banner>
{error && <div className="text-danger">{error}</div>}
<SelectFile
directory
color="primary"
size="xs"
noun="Directory"
filePath={value}
filePath={value.filePath}
onChange={async ({ filePath }) => {
if (filePath != null) {
const files = await readDir(filePath);
@@ -42,9 +42,17 @@ export function SyncToFilesystemSetting({
}
}
onChange(filePath);
onChange({ ...value, filePath });
}}
/>
{value.filePath && typeof value.initGit === 'boolean' && (
<Checkbox
checked={value.initGit}
onChange={(initGit) => onChange({ ...value, initGit })}
title="Initialize Git Repo"
/>
)}
</VStack>
</details>
);

View File

@@ -7,6 +7,7 @@ import { Portal } from './Portal';
export type ToastInstance = {
id: string;
uniqueKey: string;
message: ReactNode;
timeout: 3000 | 5000 | 8000 | null;
onClose?: ToastProps['onClose'];
@@ -18,18 +19,21 @@ export const Toasts = () => {
<Portal name="toasts">
<div className="absolute right-0 bottom-0 z-50">
<AnimatePresence>
{toasts.map(({ message, ...props }: ToastInstance) => (
<Toast
key={props.id}
open
{...props}
// We call onClose inside actions.hide instead of passing to toast so that
// it gets called from external close calls as well
onClose={() => hideToast(props.id)}
>
{message}
</Toast>
))}
{toasts.map((toast: ToastInstance) => {
const { message, uniqueKey, ...props } = toast;
return (
<Toast
key={uniqueKey}
open
{...props}
// We call onClose inside actions.hide instead of passing to toast so that
// it gets called from external close calls as well
onClose={() => hideToast(toast)}
>
{message}
</Toast>
);
})}
</AnimatePresence>
</div>
</Portal>

View File

@@ -110,6 +110,7 @@ export const UrlBar = memo(function UrlBar({
title="Send Request"
type="submit"
className="w-8 mr-0.5 !h-full"
iconColor="secondary"
icon={isLoading ? 'x' : submitIcon}
hotkeyAction="http_request.send"
/>

View File

@@ -235,6 +235,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
size="xs"
title="Close connection"
icon="x"
iconColor="secondary"
className="w-8 mr-0.5 !h-full"
onClick={handleCancel}
/>

View File

@@ -212,9 +212,7 @@ function EventRow({
)}
>
<Icon
className={classNames(
messageType === 'close' ? 'text-secondary' : isServer ? 'text-info' : 'text-primary',
)}
color={messageType === 'close' ? 'secondary' : isServer ? 'info' : 'primary'}
icon={
messageType === 'close'
? 'info'

View File

@@ -2,6 +2,7 @@ import { revealItemInDir } from '@tauri-apps/plugin-opener';
import classNames from 'classnames';
import { memo, useCallback, useMemo } from 'react';
import { openWorkspaceFromSyncDir } from '../commands/openWorkspaceFromSyncDir';
import { openWorkspaceSettings } from '../commands/openWorkspaceSettings';
import { switchWorkspace } from '../commands/switchWorkspace';
import { useActiveWorkspace } from '../hooks/useActiveWorkspace';
import { useCreateWorkspace } from '../hooks/useCreateWorkspace';
@@ -19,7 +20,6 @@ import { Icon } from './core/Icon';
import type { RadioDropdownItem } from './core/RadioDropdown';
import { RadioDropdown } from './core/RadioDropdown';
import { SwitchWorkspaceDialog } from './SwitchWorkspaceDialog';
import { WorkspaceSettingsDialog } from './WorkspaceSettingsDialog';
type Props = Pick<ButtonProps, 'className' | 'justify' | 'forDropdown' | 'leftSlot'>;
@@ -49,21 +49,12 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
label: 'Workspace Settings',
leftSlot: <Icon icon="settings" />,
hotKeyAction: 'workspace_settings.show',
onSelect: async () => {
showDialog({
id: 'workspace-settings',
title: 'Workspace Settings',
size: 'md',
render: ({ hide }) => (
<WorkspaceSettingsDialog workspaceId={workspace?.id ?? null} hide={hide} />
),
});
},
onSelect: () => openWorkspaceSettings.mutate({}),
},
{
label: revealInFinderText,
hidden: workspaceMeta == null || workspaceMeta.settingSyncDir == null,
leftSlot: <Icon icon="folder_open" />,
leftSlot: <Icon icon="folder_symlink" />,
onSelect: async () => {
if (workspaceMeta?.settingSyncDir == null) return;
await revealItemInDir(workspaceMeta.settingSyncDir);
@@ -82,8 +73,8 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
onSelect: createWorkspace,
},
{
label: 'Open Workspace',
leftSlot: <Icon icon="folder" />,
label: 'Open Existing Workspace',
leftSlot: <Icon icon="folder_open" />,
onSelect: openWorkspaceFromSyncDir.mutate,
},
];

View File

@@ -32,7 +32,7 @@ export const WorkspaceHeader = memo(function WorkspaceHeader({ className }: Prop
<CookieDropdown />
<HStack className="min-w-0">
<WorkspaceActionsDropdown />
<Icon icon="chevron_right" className="text-text-subtle" />
<Icon icon="chevron_right" color="secondary" />
<EnvironmentActionsDropdown className="w-auto pointer-events-auto" />
</HStack>
</HStack>
@@ -47,6 +47,7 @@ export const WorkspaceHeader = memo(function WorkspaceHeader({ className }: Prop
title="Search or execute a command"
size="sm"
event="search"
iconColor="secondary"
onClick={togglePalette}
/>
<SettingsDropdown />

View File

@@ -15,9 +15,10 @@ import { SyncToFilesystemSetting } from './SyncToFilesystemSetting';
interface Props {
workspaceId: string | null;
hide: () => void;
openSyncMenu?: boolean;
}
export function WorkspaceSettingsDialog({ workspaceId, hide }: Props) {
export function WorkspaceSettingsDialog({ workspaceId, hide, openSyncMenu }: Props) {
const workspaces = useWorkspaces();
const workspace = workspaces.find((w) => w.id === workspaceId);
const workspaceMeta = useWorkspaceMeta();
@@ -60,10 +61,11 @@ export function WorkspaceSettingsDialog({ workspaceId, hide }: Props) {
<VStack space={6} className="mt-3 w-full" alignItems="start">
<SyncToFilesystemSetting
value={workspaceMeta.settingSyncDir}
onChange={(settingSyncDir) =>
upsertWorkspaceMeta.mutate({ ...workspaceMeta, settingSyncDir })
}
value={{ filePath: workspaceMeta.settingSyncDir }}
forceOpen={openSyncMenu}
onChange={({ filePath }) => {
upsertWorkspaceMeta.mutate({ ...workspaceMeta, settingSyncDir: filePath });
}}
/>
<Separator />
<Button

View File

@@ -1,3 +1,4 @@
import type { Color } from '@yaakapp-internal/plugins';
import classNames from 'classnames';
import type { HTMLAttributes, ReactNode } from 'react';
import { forwardRef, useImperativeHandle, useRef } from 'react';
@@ -9,16 +10,7 @@ import { LoadingIcon } from './LoadingIcon';
export type ButtonProps = Omit<HTMLAttributes<HTMLButtonElement>, 'color' | 'onChange'> & {
innerClassName?: string;
color?:
| 'custom'
| 'default'
| 'secondary'
| 'primary'
| 'info'
| 'success'
| 'notice'
| 'warning'
| 'danger';
color?: Color | 'custom' | 'default';
variant?: 'border' | 'solid';
isLoading?: boolean;
size?: '2xs' | 'xs' | 'sm' | 'md';
@@ -59,6 +51,10 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
const hotkeyTrigger = useFormattedHotkey(hotkeyAction ?? null)?.join('');
const fullTitle = hotkeyTrigger ? `${title ?? ''} ${hotkeyTrigger}`.trim() : title;
if (isLoading) {
disabled = true;
}
const classes = classNames(
className,
'x-theme-button',
@@ -110,7 +106,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
ref={buttonRef}
type={type}
className={classes}
disabled={disabled || isLoading}
disabled={disabled}
onClick={(e) => {
onClick?.(e);
if (event != null) {

View File

@@ -12,6 +12,7 @@ export interface CheckboxProps {
disabled?: boolean;
inputWrapperClassName?: string;
hideLabel?: boolean;
fullWidth?: boolean;
event?: string;
}
@@ -23,6 +24,7 @@ export function Checkbox({
disabled,
title,
hideLabel,
fullWidth,
event,
}: CheckboxProps) {
return (
@@ -52,7 +54,9 @@ export function Checkbox({
/>
</div>
</div>
<span className={classNames(disabled && 'opacity-disabled')}>{!hideLabel && title}</span>
<div className={classNames(fullWidth && 'w-full', disabled && 'opacity-disabled')}>
{!hideLabel && title}
</div>
</HStack>
);
}

View File

@@ -1,25 +1,15 @@
import type { ButtonProps } from './Button';
import type { Color } from '@yaakapp-internal/plugins';
import { Button } from './Button';
import { HStack } from './Stacks';
export interface ConfirmProps {
onHide: () => void;
onResult: (result: boolean) => void;
variant?: 'delete' | 'confirm';
confirmText?: string;
color?: Color;
}
const colors: Record<NonNullable<ConfirmProps['variant']>, ButtonProps['color']> = {
delete: 'danger',
confirm: 'primary',
};
const confirmButtonTexts: Record<NonNullable<ConfirmProps['variant']>, string> = {
delete: 'Delete',
confirm: 'Confirm',
};
export function Confirm({ onHide, onResult, confirmText, variant = 'confirm' }: ConfirmProps) {
export function Confirm({ onHide, onResult, confirmText, color = 'primary' }: ConfirmProps) {
const handleHide = () => {
onResult(false);
onHide();
@@ -32,8 +22,8 @@ export function Confirm({ onHide, onResult, confirmText, variant = 'confirm' }:
return (
<HStack space={2} justifyContent="start" className="mt-2 mb-4 flex-row-reverse">
<Button color={colors[variant]} onClick={handleSuccess}>
{confirmText ?? confirmButtonTexts[variant]}
<Button color={color} onClick={handleSuccess}>
{confirmText ?? 'Confirm'}
</Button>
<Button onClick={handleHide} variant="border">
Cancel

View File

@@ -36,17 +36,23 @@ import { HotKey } from './HotKey';
import { Icon } from './Icon';
import { Separator } from './Separator';
import { HStack, VStack } from './Stacks';
import { LoadingIcon } from './LoadingIcon';
export type DropdownItemSeparator = {
type: 'separator';
label?: string;
label?: ReactNode;
hidden?: boolean;
};
export type DropdownItemContent = {
type: 'content';
label?: ReactNode;
hidden?: boolean;
};
export type DropdownItemDefault = {
type?: 'default';
label: ReactNode;
keepOpen?: boolean;
hotKeyAction?: HotkeyAction;
hotKeyLabelOnly?: boolean;
color?: 'default' | 'danger' | 'info' | 'warning' | 'notice';
@@ -54,10 +60,11 @@ export type DropdownItemDefault = {
hidden?: boolean;
leftSlot?: ReactNode;
rightSlot?: ReactNode;
onSelect?: () => void;
waitForOnSelect?: boolean;
onSelect?: () => void | Promise<void>;
};
export type DropdownItem = DropdownItemDefault | DropdownItemSeparator;
export type DropdownItem = DropdownItemDefault | DropdownItemSeparator | DropdownItemContent;
export interface DropdownProps {
children: ReactElement<HTMLAttributes<HTMLButtonElement>>;
@@ -374,14 +381,20 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
);
const handleSelect = useCallback(
(i: DropdownItem) => {
if (i.type !== 'separator' && !i.keepOpen) {
handleClose();
}
async (item: DropdownItem) => {
if (!('onSelect' in item) || !item.onSelect) return;
setSelectedIndex(null);
if (i.type !== 'separator' && typeof i.onSelect === 'function') {
i.onSelect();
const promise = item.onSelect();
if (item.waitForOnSelect) {
try {
await promise;
} catch {
// Nothing
}
}
handleClose();
},
[handleClose, setSelectedIndex],
);
@@ -391,10 +404,10 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
close: handleClose,
prev: handlePrev,
next: handleNext,
select() {
async select() {
const item = items[selectedIndexRef.current ?? -1] ?? null;
if (!item) return;
handleSelect(item);
await handleSelect(item);
},
};
}, [handleClose, handleNext, handlePrev, handleSelect, items]);
@@ -466,6 +479,7 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
{items.map(
(item, i) =>
item.type !== 'separator' &&
item.type !== 'content' &&
!item.hotKeyLabelOnly &&
item.hotKeyAction && (
<MenuItemHotKey
@@ -519,7 +533,7 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
space={2}
className="pb-0.5 px-1.5 mb-2 text-sm border border-border-subtle mx-2 rounded font-mono h-xs"
>
<Icon icon="search" size="xs" className="text-text-subtle" />
<Icon icon="search" size="xs" />
<div className="text">{filter}</div>
</HStack>
)}
@@ -537,6 +551,13 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
</Separator>
);
}
if (item.type === 'content') {
return (
<div key={i} className={classNames('my-1.5 mx-2 max-w-xs')}>
{item.label}
</div>
);
}
return (
<MenuItem
focused={i === selectedIndex}
@@ -559,13 +580,19 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
interface MenuItemProps {
className?: string;
item: DropdownItemDefault;
onSelect: (item: DropdownItemDefault) => void;
onSelect: (item: DropdownItemDefault) => Promise<void>;
onFocus: (item: DropdownItemDefault) => void;
focused: boolean;
}
function MenuItem({ className, focused, onFocus, item, onSelect, ...props }: MenuItemProps) {
const handleClick = useCallback(() => onSelect?.(item), [item, onSelect]);
const [isLoading, setIsLoading] = useState(false);
const handleClick = useCallback(async () => {
if (item.waitForOnSelect) setIsLoading(true);
await onSelect?.(item);
if (item.waitForOnSelect) setIsLoading(false);
}, [item, onSelect]);
const handleFocus = useCallback(
(e: ReactFocusEvent<HTMLButtonElement>) => {
e.stopPropagation(); // Don't trigger focus on any parents
@@ -598,7 +625,11 @@ function MenuItem({ className, focused, onFocus, item, onSelect, ...props }: Men
onClick={handleClick}
justify="start"
leftSlot={
item.leftSlot && <div className="pr-2 flex justify-start opacity-70">{item.leftSlot}</div>
(isLoading || item.leftSlot) && (
<div className={classNames('pr-2 flex justify-start opacity-70')}>
{isLoading ? <LoadingIcon /> : item.leftSlot}
</div>
)
}
rightSlot={rightSlot && <div className="ml-auto pl-3">{rightSlot}</div>}
innerClassName="!text-left"

View File

@@ -1,3 +1,4 @@
import type { Color } from '@yaakapp-internal/plugins';
import classNames from 'classnames';
import * as lucide from 'lucide-react';
import type { HTMLAttributes } from 'react';
@@ -14,9 +15,11 @@ const icons = {
arrow_big_up_dash: lucide.ArrowBigUpDashIcon,
arrow_down: lucide.ArrowDownIcon,
arrow_down_to_dot: lucide.ArrowDownToDotIcon,
arrow_down_to_line: lucide.ArrowDownToLineIcon,
arrow_up: lucide.ArrowUpIcon,
arrow_up_down: lucide.ArrowUpDownIcon,
arrow_up_from_dot: lucide.ArrowUpFromDotIcon,
arrow_up_from_line: lucide.ArrowUpFromLineIcon,
badge_check: lucide.BadgeCheckIcon,
box: lucide.BoxIcon,
cake: lucide.CakeIcon,
@@ -42,11 +45,14 @@ const icons = {
filter: lucide.FilterIcon,
flask: lucide.FlaskConicalIcon,
folder: lucide.FolderIcon,
folder_git: lucide.FolderGitIcon,
folder_input: lucide.FolderInputIcon,
folder_open: lucide.FolderOpenIcon,
folder_output: lucide.FolderOutputIcon,
folder_symlink: lucide.FolderSymlinkIcon,
folder_sync: lucide.FolderSyncIcon,
git_branch: lucide.GitBranchIcon,
git_branch_plus: lucide.GitBranchPlusIcon,
git_commit: lucide.GitCommitIcon,
git_commit_vertical: lucide.GitCommitVerticalIcon,
git_pull_request: lucide.GitPullRequestIcon,
@@ -63,6 +69,7 @@ const icons = {
left_panel_visible: lucide.PanelLeftCloseIcon,
lock: lucide.LockIcon,
magic_wand: lucide.Wand2Icon,
merge: lucide.MergeIcon,
minus: lucide.MinusIcon,
minus_circle: lucide.MinusCircleIcon,
moon: lucide.MoonIcon,
@@ -86,6 +93,8 @@ const icons = {
unpin: lucide.PinOffIcon,
update: lucide.RefreshCcwIcon,
upload: lucide.UploadIcon,
variable: lucide.VariableIcon,
wrench: lucide.WrenchIcon,
x: lucide.XIcon,
_unknown: lucide.ShieldAlertIcon,
@@ -98,22 +107,38 @@ export interface IconProps {
size?: '2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl';
spin?: boolean;
title?: string;
color?: Color | 'custom' | 'default';
}
export const Icon = memo(function Icon({ icon, spin, size = 'md', className, title }: IconProps) {
export const Icon = memo(function Icon({
icon,
color = 'default',
spin,
size = 'md',
className,
title,
}: IconProps) {
const Component = icons[icon] ?? icons._unknown;
return (
<Component
title={title}
className={classNames(
className,
'text-inherit flex-shrink-0',
'flex-shrink-0',
size === 'xl' && 'h-6 w-6',
size === 'lg' && 'h-5 w-5',
size === 'md' && 'h-4 w-4',
size === 'sm' && 'h-3.5 w-3.5',
size === 'xs' && 'h-3 w-3',
size === '2xs' && 'h-2.5 w-2.5',
color === 'default' && 'inherit',
color === 'danger' && 'text-danger',
color === 'warning' && 'text-warning',
color === 'notice' && 'text-notice',
color === 'info' && 'text-info',
color === 'success' && 'text-success',
color === 'primary' && 'text-primary',
color === 'secondary' && 'text-secondary',
spin && 'animate-spin',
)}
/>

View File

@@ -12,6 +12,7 @@ export type IconButtonProps = IconProps &
showConfirm?: boolean;
iconClassName?: string;
iconSize?: IconProps['size'];
iconColor?: IconProps['color'];
title: string;
showBadge?: boolean;
};
@@ -29,6 +30,7 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
size = 'md',
iconSize,
showBadge,
iconColor,
...props
}: IconButtonProps,
ref,
@@ -47,7 +49,7 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
ref={ref}
aria-hidden={icon === 'empty'}
disabled={icon === 'empty'}
tabIndex={tabIndex ?? icon === 'empty' ? -1 : undefined}
tabIndex={(tabIndex ?? icon === 'empty') ? -1 : undefined}
onClick={handleClick}
innerClassName="flex items-center justify-center"
size={size}
@@ -56,8 +58,6 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
className,
'group/button relative flex-shrink-0',
'!px-0',
color === 'custom' && 'text-text-subtle',
color === 'default' && 'text-text-subtle',
size === 'md' && 'w-md',
size === 'sm' && 'w-sm',
size === 'xs' && 'w-xs',
@@ -74,11 +74,11 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
size={iconSize}
icon={confirmed ? 'check' : icon}
spin={spin}
color={confirmed ? 'success' : iconColor}
className={classNames(
iconClassName,
'group-hover/button:text',
'group-hover/button:text-text',
props.disabled && 'opacity-70',
confirmed && 'text-green-600',
)}
/>
</Button>

View File

@@ -46,6 +46,7 @@ export type InputProps = Pick<
required?: boolean;
wrapLines?: boolean;
multiLine?: boolean;
fullHeight?: boolean;
stateKey: EditorProps['stateKey'];
};
@@ -56,6 +57,7 @@ export const Input = forwardRef<EditorView, InputProps>(function Input(
inputWrapperClassName,
defaultValue,
forceUpdateKey,
fullHeight,
hideLabel,
label,
labelClassName,
@@ -148,8 +150,9 @@ export const Input = forwardRef<EditorView, InputProps>(function Input(
<div
ref={wrapperRef}
className={classNames(
'w-full',
'pointer-events-auto', // Just in case we're placing in disabled parent
'w-full',
fullHeight && 'h-full',
labelPosition === 'left' && 'flex items-center gap-2',
labelPosition === 'top' && 'flex-row gap-0.5',
)}
@@ -166,6 +169,7 @@ export const Input = forwardRef<EditorView, InputProps>(function Input(
alignItems="stretch"
className={classNames(
containerClassName,
fullHeight && 'h-full',
'x-theme-input',
'relative w-full rounded-md text',
'border',
@@ -182,6 +186,7 @@ export const Input = forwardRef<EditorView, InputProps>(function Input(
className={classNames(
inputWrapperClassName,
'w-full min-w-0 px-2',
fullHeight && 'h-full',
leftSlot && 'pl-0.5 -ml-2',
rightSlot && 'pr-0.5 -mr-2',
)}
@@ -218,8 +223,11 @@ export const Input = forwardRef<EditorView, InputProps>(function Input(
<IconButton
title={obscured ? `Show ${label}` : `Obscure ${label}`}
size="xs"
className={classNames("mr-0.5 group/obscure !h-auto my-0.5", disabled && 'opacity-disabled')}
iconClassName="text-text-subtle group-hover/obscure:text"
className={classNames(
'mr-0.5 group/obscure !h-auto my-0.5',
disabled && 'opacity-disabled',
)}
iconClassName="group-hover/obscure:text"
iconSize="sm"
icon={obscured ? 'eye' : 'eye_closed'}
onClick={() => setObscured((o) => !o)}

View File

@@ -104,7 +104,7 @@ export const JsonAttributeTree = ({
icon="chevron_right"
className={classNames(
'left-0 absolute transition-transform flex items-center',
'text-text-subtlest group-hover:text-text-subtle',
'group-hover:text-text-subtle',
isExpanded ? 'rotate-90' : '',
)}
/>

View File

@@ -30,7 +30,7 @@ export const PairOrBulkEditor = forwardRef<PairEditorRef, Props>(function PairOr
title={useBulk ? 'Enable form edit' : 'Enable bulk edit'}
className={classNames(
'transition-opacity opacity-0 group-hover:opacity-80 hover:!opacity-100 shadow',
'bg-surface text-text-subtle hover:text group-hover/wrapper:opacity-100',
'bg-surface hover:text group-hover/wrapper:opacity-100',
)}
onClick={() => setUseBulk((b) => !b)}
icon={useBulk ? 'table' : 'file_code'}

View File

@@ -148,7 +148,7 @@ export function PlainInput({
title={obscured ? `Show ${label}` : `Obscure ${label}`}
size="xs"
className="mr-0.5 group/obscure !h-auto my-0.5"
iconClassName="text-text-subtle group-hover/obscure:text"
iconClassName="group-hover/obscure:text"
iconSize="sm"
icon={obscured ? 'eye' : 'eye_closed'}
onClick={() => setObscured((o) => !o)}

View File

@@ -45,7 +45,7 @@ export function SegmentedControl<T extends string>({ value, onChange, options, n
<IconButton
size="xs"
variant="solid"
color={isActive ? "secondary" : "default"}
color={isActive ? "secondary" : undefined}
role="radio"
event={{ id: name, value: String(o.value) }}
tabIndex={isSelected ? 0 : -1}

View File

@@ -0,0 +1,64 @@
import classNames from 'classnames';
import type { ReactNode } from 'react';
import React from 'react';
export function Table({ children }: { children: ReactNode }) {
return (
<table className="w-full text-sm mb-auto min-w-full max-w-full divide-y divide-surface-highlight">
{children}
</table>
);
}
export function TableBody({ children }: { children: ReactNode }) {
return <tbody className="divide-y divide-surface-highlight">{children}</tbody>;
}
export function TableHead({ children }: { children: ReactNode }) {
return <thead>{children}</thead>;
}
export function TableRow({ children }: { children: ReactNode }) {
return <tr>{children}</tr>;
}
export function TableCell({ children, className }: { children: ReactNode; className?: string }) {
return (
<td
className={classNames(
className,
'py-2 [&:not(:first-child)]:pl-4 text-left w-0 whitespace-nowrap',
)}
>
{children}
</td>
);
}
export function TruncatedWideTableCell({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<TableCell className={classNames(className, 'w-full relative')}>
<div className="absolute inset-0 py-2 truncate">{children}</div>
</TableCell>
);
}
export function TableHeaderCell({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<th className={classNames(className, 'py-2 [&:not(:first-child)]:pl-4 text-left w-0')}>
{children}
</th>
);
}

View File

@@ -121,7 +121,7 @@ export function Tabs({
<Icon
size="sm"
icon="chevron_down"
className={classNames('ml-1', isActive ? 'text-text-subtle' : 'opacity-50')}
className={classNames('ml-1', !isActive && 'opacity-50')}
/>
</button>
</RadioDropdown>

View File

@@ -55,14 +55,14 @@ export function Toast({ children, open, onClose, timeout, action, icon, color }:
<div
className={classNames(
`x-theme-toast x-theme-toast--${color}`,
'pointer-events-auto overflow-hidden break-all',
'pointer-events-auto overflow-hidden',
'relative pointer-events-auto bg-surface text-text rounded-lg',
'border border-border shadow-lg w-[25rem]',
'grid grid-cols-[1fr_auto]',
)}
>
<div className="px-3 py-3 flex items-start gap-2 w-full">
{toastIcon && <Icon icon={toastIcon} className="mt-1 text-text-subtle" />}
{toastIcon && <Icon icon={toastIcon} color={color} className="mt-1" />}
<VStack space={2} className="w-full">
<div>{children}</div>
{action?.({ hide: onClose })}

View File

@@ -0,0 +1,43 @@
import { useState } from 'react';
import { Button } from '../core/Button';
import { Select } from '../core/Select';
import { HStack, VStack } from '../core/Stacks';
interface Props {
branches: string[];
onCancel: () => void;
onSelect: (branch: string) => void;
selectText: string;
}
export function BranchSelectionDialog({ branches, onCancel, onSelect, selectText }: Props) {
const [branch, setBranch] = useState<string>('__NONE__');
return (
<VStack
className="mb-4"
as="form"
space={4}
onSubmit={(e) => {
e.preventDefault();
onSelect(branch);
}}
>
<Select
name="branch"
hideLabel
label="Branch"
value={branch}
options={branches.map((b) => ({ label: b, value: b }))}
onChange={setBranch}
/>
<HStack space={2} justifyContent="end">
<Button onClick={onCancel} variant="border" color="secondary">
Cancel
</Button>
<Button type="submit" color="primary">
{selectText}
</Button>
</HStack>
</VStack>
);
}

View File

@@ -0,0 +1,40 @@
import type { GitCommit } from '@yaakapp-internal/git';
import { formatDistanceToNowStrict } from 'date-fns';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeaderCell,
TableRow,
TruncatedWideTableCell,
} from '../core/Table';
interface Props {
log: GitCommit[];
}
export function HistoryDialog({ log }: Props) {
return (
<Table>
<TableHead>
<TableRow>
<TableHeaderCell>Message</TableHeaderCell>
<TableHeaderCell>Author</TableHeaderCell>
<TableHeaderCell>When</TableHeaderCell>
</TableRow>
</TableHead>
<TableBody>
{log.map((l, i) => (
<TableRow key={i}>
<TruncatedWideTableCell>{l.message}</TruncatedWideTableCell>
<TableCell className="font-bold">{l.author.name ?? 'Unknown'}</TableCell>
<TableCell className="text-text-subtle">
<span title={l.when}>{formatDistanceToNowStrict(l.when)} ago</span>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}

View File

@@ -153,7 +153,7 @@ function EventRow({
'text-text-subtle hover:text',
)}
>
<Icon className={classNames('text-info')} title="Server Message" icon="arrow_big_down_dash" />
<Icon color="info" title="Server Message" icon="arrow_big_down_dash" />
<EventLabels className="text-sm" event={event} isActive={isActive} index={index} />
<div className={classNames('w-full truncate text-xs')}>{event.data.slice(0, 1000)}</div>
</button>

View File

@@ -27,6 +27,7 @@ import { ContextMenu } from '../core/Dropdown';
import { sidebarSelectedIdAtom, sidebarTreeAtom } from './SidebarAtoms';
import type { SidebarItemProps } from './SidebarItem';
import { SidebarItems } from './SidebarItems';
import { GitDropdown } from '../GitDropdown';
interface Props {
className?: string;
@@ -378,6 +379,7 @@ export function Sidebar({ className }: Props) {
handleDragStart={handleDragStart}
/>
</div>
<GitDropdown />
</aside>
);
}

View File

@@ -32,9 +32,10 @@ export function SidebarActions() {
size="sm"
title="Show sidebar"
icon={hidden ? 'left_panel_hidden' : 'left_panel_visible'}
iconColor="secondary"
/>
<CreateDropdown hotKeyAction="http_request.create">
<IconButton size="sm" icon="plus_circle" title="Add Resource" />
<IconButton size="sm" icon="plus_circle" iconColor="secondary" title="Add Resource" />
</CreateDropdown>
</HStack>
);

View File

@@ -267,8 +267,8 @@ export const SidebarItem = memo(function SidebarItem({
<Icon
size="sm"
icon="chevron_right"
color="secondary"
className={classNames(
'text-text-subtlest',
'transition-transform',
!collapsed && 'transform rotate-90',
)}

View File

@@ -44,7 +44,7 @@ export const SidebarItems = memo(function SidebarItems({
aria-orientation="vertical"
dir="ltr"
className={classNames(
tree.depth > 0 && 'border-l border-border-subtle',
tree.depth > 0 && 'border-l border-border',
tree.depth === 0 && 'ml-0',
tree.depth >= 1 && 'ml-[1.2rem]',
)}