mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-03-17 23:13:51 +01:00
Git support (#143)
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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'}
|
||||
|
||||
312
src-web/components/GitCommitDialog.tsx
Normal file
312
src-web/components/GitCommitDialog.tsx
Normal 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));
|
||||
}
|
||||
406
src-web/components/GitDropdown.tsx
Normal file
406
src-web/components/GitDropdown.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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',
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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' : '',
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
64
src-web/components/core/Table.tsx
Normal file
64
src-web/components/core/Table.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 })}
|
||||
|
||||
43
src-web/components/git/BranchSelectionDialog.tsx
Normal file
43
src-web/components/git/BranchSelectionDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
src-web/components/git/HistoryDialog.tsx
Normal file
40
src-web/components/git/HistoryDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
)}
|
||||
|
||||
@@ -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]',
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user