Quick action tweaks (#218)

This commit is contained in:
Brendan Kellam 2025-02-28 10:12:32 -08:00 committed by GitHub
parent cdfcb5a88b
commit 7685d9cf66
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 317 additions and 69 deletions

View file

@ -14,18 +14,20 @@ import {
jsonSchemaLinter, jsonSchemaLinter,
stateExtensions stateExtensions
} from "codemirror-json-schema"; } from "codemirror-json-schema";
import { useRef, forwardRef, useImperativeHandle, Ref, ReactNode } from "react"; import { useRef, forwardRef, useImperativeHandle, Ref, ReactNode, useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { Schema } from "ajv"; import { Schema } from "ajv";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import useCaptureEvent from "@/hooks/useCaptureEvent"; import useCaptureEvent from "@/hooks/useCaptureEvent";
import { CodeHostType } from "@/lib/utils"; import { CodeHostType } from "@/lib/utils";
export type QuickActionFn<T> = (previous: T) => T; export type QuickActionFn<T> = (previous: T) => T;
export type QuickAction<T> = { export type QuickAction<T> = {
name: string; name: string;
fn: QuickActionFn<T>; fn: QuickActionFn<T>;
description?: string | ReactNode; description?: string | ReactNode;
selectionText?: string;
}; };
interface ConfigEditorProps<T> { interface ConfigEditorProps<T> {
@ -57,11 +59,13 @@ export function onQuickAction<T>(
options?: { options?: {
focusEditor?: boolean; focusEditor?: boolean;
moveCursor?: boolean; moveCursor?: boolean;
selectionText?: string;
} }
) { ) {
const { const {
focusEditor = false, focusEditor = false,
moveCursor = true, moveCursor = true,
selectionText = `""`,
} = options ?? {}; } = options ?? {};
let previousConfig: T; let previousConfig: T;
@ -78,7 +82,6 @@ export function onQuickAction<T>(
view.focus(); view.focus();
} }
const cursorPos = next.lastIndexOf(`""`) + 1;
view.dispatch({ view.dispatch({
changes: { changes: {
from: 0, from: 0,
@ -87,10 +90,16 @@ export function onQuickAction<T>(
} }
}); });
if (moveCursor) { if (moveCursor && selectionText) {
view.dispatch({ const cursorPos = next.lastIndexOf(selectionText);
selection: { anchor: cursorPos, head: cursorPos } if (cursorPos >= 0) {
}); view.dispatch({
selection: {
anchor: cursorPos,
head: cursorPos + selectionText.length
}
});
}
} }
} }
@ -103,10 +112,15 @@ export const isConfigValidJson = (config: string) => {
} }
} }
const DEFAULT_ACTIONS_VISIBLE = 4;
const ConfigEditor = <T,>(props: ConfigEditorProps<T>, forwardedRef: Ref<ReactCodeMirrorRef>) => { const ConfigEditor = <T,>(props: ConfigEditorProps<T>, forwardedRef: Ref<ReactCodeMirrorRef>) => {
const { value, type, onChange, actions, schema } = props; const { value, type, onChange, actions, schema } = props;
const captureEvent = useCaptureEvent(); const captureEvent = useCaptureEvent();
const editorRef = useRef<ReactCodeMirrorRef>(null); const editorRef = useRef<ReactCodeMirrorRef>(null);
const [isViewMoreActionsEnabled, setIsViewMoreActionsEnabled] = useState(false);
const [height, setHeight] = useState(224);
useImperativeHandle( useImperativeHandle(
forwardedRef, forwardedRef,
() => editorRef.current as ReactCodeMirrorRef () => editorRef.current as ReactCodeMirrorRef
@ -117,7 +131,79 @@ const ConfigEditor = <T,>(props: ConfigEditorProps<T>, forwardedRef: Ref<ReactCo
return ( return (
<div className="border rounded-md"> <div className="border rounded-md">
<ScrollArea className="p-1 overflow-auto flex-1 h-56"> <div className="flex flex-row items-center flex-wrap p-1">
<TooltipProvider>
{actions
.slice(0, isViewMoreActionsEnabled ? actions.length : DEFAULT_ACTIONS_VISIBLE)
.map(({ name, fn, description, selectionText }, index, truncatedActions) => (
<div
key={index}
className="flex flex-row items-center"
>
<Tooltip
delayDuration={100}
>
<TooltipTrigger asChild>
<Button
variant="ghost"
className="disabled:opacity-100 disabled:pointer-events-auto disabled:cursor-not-allowed text-sm font-mono tracking-tight"
size="sm"
disabled={!isConfigValidJson(value)}
onClick={(e) => {
e.preventDefault();
captureEvent('wa_config_editor_quick_action_pressed', {
name,
type,
});
if (editorRef.current?.view) {
onQuickAction(fn, value, editorRef.current.view, {
focusEditor: true,
selectionText,
});
}
}}
>
{name}
</Button>
</TooltipTrigger>
<TooltipContent
hidden={!description}
className="max-w-xs"
>
{description}
</TooltipContent>
</Tooltip>
{index !== truncatedActions.length - 1 && (
<Separator
orientation="vertical" className="h-4 mx-1"
/>
)}
{index === truncatedActions.length - 1 && truncatedActions.length < actions.length && (
<>
<Separator
orientation="vertical" className="h-4 mx-1"
/>
<Button
variant="link"
size="sm"
className="text-xs text-muted-foreground"
onClick={(e) => {
e.preventDefault();
setIsViewMoreActionsEnabled(!isViewMoreActionsEnabled);
}}
>
+{actions.length - truncatedActions.length} more
</Button>
</>
)}
</div>
))}
</TooltipProvider>
</div>
<Separator />
<ScrollArea className="p-1 overflow-auto flex-1" style={{ height }}>
<CodeMirror <CodeMirror
ref={editorRef} ref={editorRef}
value={value} value={value}
@ -142,55 +228,27 @@ const ConfigEditor = <T,>(props: ConfigEditorProps<T>, forwardedRef: Ref<ReactCo
theme={theme === "dark" ? "dark" : "light"} theme={theme === "dark" ? "dark" : "light"}
/> />
</ScrollArea> </ScrollArea>
<Separator /> <div
<div className="flex flex-row items-center flex-wrap w-full p-1"> className="h-1 cursor-ns-resize bg-border rounded-md hover:bg-primary/50 transition-colors"
<TooltipProvider> onMouseDown={(e) => {
{actions.map(({ name, fn, description }, index) => ( e.preventDefault();
<div const startY = e.clientY;
key={index} const startHeight = height;
className="flex flex-row items-center"
> function onMouseMove(e: MouseEvent) {
<Tooltip const delta = e.clientY - startY;
delayDuration={100} setHeight(Math.max(112, startHeight + delta));
> }
<TooltipTrigger asChild>
<Button function onMouseUp() {
variant="ghost" document.removeEventListener('mousemove', onMouseMove);
className="disabled:opacity-100 disabled:pointer-events-auto disabled:cursor-not-allowed text-sm font-mono tracking-tight" document.removeEventListener('mouseup', onMouseUp);
size="sm" }
disabled={!isConfigValidJson(value)}
onClick={(e) => { document.addEventListener('mousemove', onMouseMove);
e.preventDefault(); document.addEventListener('mouseup', onMouseUp);
captureEvent('wa_config_editor_quick_action_pressed', { }}
name, />
type,
});
if (editorRef.current?.view) {
onQuickAction(fn, value, editorRef.current.view, {
focusEditor: true,
});
}
}}
>
{name}
</Button>
</TooltipTrigger>
<TooltipContent
hidden={!description}
className="max-w-xs"
>
{description}
</TooltipContent>
</Tooltip>
{index !== actions.length - 1 && (
<Separator
orientation="vertical" className="h-4 mx-1"
/>
)}
</div>
))}
</TooltipProvider>
</div>
</div> </div>
) )
}; };

View file

@ -12,7 +12,7 @@ export default function Layout({
<div className="min-h-screen flex flex-col"> <div className="min-h-screen flex flex-col">
<NavigationMenu domain={domain} /> <NavigationMenu domain={domain} />
<main className="flex-grow flex justify-center p-4 bg-backgroundSecondary relative"> <main className="flex-grow flex justify-center p-4 bg-backgroundSecondary relative">
<div className="w-full max-w-6xl rounded-lg p-6">{children}</div> <div className="w-full max-w-6xl p-6">{children}</div>
</main> </main>
</div> </div>
) )

View file

@ -22,10 +22,11 @@ export const githubQuickActions: QuickAction<GithubConnectionConfig>[] = [
...previous, ...previous,
repos: [ repos: [
...(previous.repos ?? []), ...(previous.repos ?? []),
"" "<owner>/<repo name>"
] ]
}), }),
name: "Add a repo", name: "Add a single repo",
selectionText: "<owner>/<repo name>",
description: ( description: (
<div className="flex flex-col"> <div className="flex flex-col">
<span>Add a individual repository to sync with. Ensure the repository is visible to the provided <Code>token</Code> (if any).</span> <span>Add a individual repository to sync with. Ensure the repository is visible to the provided <Code>token</Code> (if any).</span>
@ -47,10 +48,11 @@ export const githubQuickActions: QuickAction<GithubConnectionConfig>[] = [
...previous, ...previous,
orgs: [ orgs: [
...(previous.orgs ?? []), ...(previous.orgs ?? []),
"" "<organization name>"
] ]
}), }),
name: "Add an organization", name: "Add an organization",
selectionText: "<organization name>",
description: ( description: (
<div className="flex flex-col"> <div className="flex flex-col">
<span>Add an organization to sync with. All repositories in the organization visible to the provided <Code>token</Code> (if any) will be synced.</span> <span>Add an organization to sync with. All repositories in the organization visible to the provided <Code>token</Code> (if any) will be synced.</span>
@ -72,19 +74,138 @@ export const githubQuickActions: QuickAction<GithubConnectionConfig>[] = [
...previous, ...previous,
users: [ users: [
...(previous.users ?? []), ...(previous.users ?? []),
"" "<username>"
] ]
}), }),
name: "Add a user", name: "Add a user",
description: <span>Add a user to sync with. All repositories that the user owns visible to the provided <Code>token</Code> (if any) will be synced.</span> selectionText: "<username>",
description: (
<div className="flex flex-col">
<span>Add a user to sync with. All repositories that the user owns visible to the provided <Code>token</Code> (if any) will be synced.</span>
<span className="text-sm mt-2 mb-1">Examples:</span>
<div className="flex flex-row gap-1 items-center">
{[
"jane-doe",
"torvalds",
"octocat"
].map((org) => (
<Code key={org}>{org}</Code>
))}
</div>
</div>
)
}, },
{ {
fn: (previous: GithubConnectionConfig) => ({ fn: (previous: GithubConnectionConfig) => ({
...previous, ...previous,
url: previous.url ?? "", url: previous.url ?? "https://github.example.com",
}), }),
name: "Set a custom url", name: "Set a custom url",
selectionText: "https://github.example.com",
description: <span>Set a custom GitHub host. Defaults to <Code>https://github.com</Code>.</span> description: <span>Set a custom GitHub host. Defaults to <Code>https://github.com</Code>.</span>
},
{
fn: (previous: GithubConnectionConfig) => ({
...previous,
exclude: {
...previous.exclude,
repos: [
...(previous.exclude?.repos ?? []),
"<glob pattern>"
]
}
}),
name: "Exclude by repo name",
selectionText: "<glob pattern>",
description: (
<div className="flex flex-col">
<span>Exclude repositories from syncing by name. Glob patterns are supported.</span>
<span className="text-sm mt-2 mb-1">Examples:</span>
<div className="flex flex-col gap-1">
{[
"my-org/docs*",
"my-org/test*"
].map((repo) => (
<Code key={repo}>{repo}</Code>
))}
</div>
</div>
)
},
{
fn: (previous: GithubConnectionConfig) => ({
...previous,
exclude: {
...previous.exclude,
topics: [
...(previous.exclude?.topics ?? []),
"<topic>"
]
}
}),
name: "Exclude by topic",
selectionText: "<topic>",
description: (
<div className="flex flex-col">
<span>Exclude topics from syncing. Only repos that do not match any of the provided topics will be synced. Glob patterns are supported.</span>
<span className="text-sm mt-2 mb-1">Examples:</span>
<div className="flex flex-col gap-1">
{[
"docs",
"ci"
].map((repo) => (
<Code key={repo}>{repo}</Code>
))}
</div>
</div>
)
},
{
fn: (previous: GithubConnectionConfig) => ({
...previous,
topics: [
...(previous.topics ?? []),
"<topic>"
]
}),
name: "Include by topic",
selectionText: "<topic>",
description: (
<div className="flex flex-col">
<span>Include repositories by topic. Only repos that match at least one of the provided topics will be synced. Glob patterns are supported.</span>
<span className="text-sm mt-2 mb-1">Examples:</span>
<div className="flex flex-col gap-1">
{[
"docs",
"ci"
].map((repo) => (
<Code key={repo}>{repo}</Code>
))}
</div>
</div>
)
},
{
fn: (previous: GithubConnectionConfig) => ({
...previous,
exclude: {
...previous.exclude,
archived: true,
}
}),
name: "Exclude archived repos",
description: <span>Exclude archived repositories from syncing.</span>
},
{
fn: (previous: GithubConnectionConfig) => ({
...previous,
exclude: {
...previous.exclude,
forks: true,
}
}),
name: "Exclude forked repos",
description: <span>Exclude forked repositories from syncing.</span>
} }
]; ];
@ -98,6 +219,20 @@ export const gitlabQuickActions: QuickAction<GitlabConnectionConfig>[] = [
] ]
}), }),
name: "Add a project", name: "Add a project",
description: (
<div className="flex flex-col">
<span>Add a individual project to sync with. Ensure the project is visible to the provided <Code>token</Code> (if any).</span>
<span className="text-sm mt-2 mb-1">Examples:</span>
<div className="flex flex-col gap-1">
{[
"gitlab-org/gitlab",
"corp/team-project",
].map((repo) => (
<Code key={repo}>{repo}</Code>
))}
</div>
</div>
)
}, },
{ {
fn: (previous: GitlabConnectionConfig) => ({ fn: (previous: GitlabConnectionConfig) => ({
@ -108,6 +243,20 @@ export const gitlabQuickActions: QuickAction<GitlabConnectionConfig>[] = [
] ]
}), }),
name: "Add a user", name: "Add a user",
description: (
<div className="flex flex-col">
<span>Add a user to sync with. All projects that the user owns visible to the provided <Code>token</Code> (if any) will be synced.</span>
<span className="text-sm mt-2 mb-1">Examples:</span>
<div className="flex flex-row gap-1 items-center">
{[
"jane-doe",
"torvalds"
].map((org) => (
<Code key={org}>{org}</Code>
))}
</div>
</div>
)
}, },
{ {
fn: (previous: GitlabConnectionConfig) => ({ fn: (previous: GitlabConnectionConfig) => ({
@ -118,6 +267,20 @@ export const gitlabQuickActions: QuickAction<GitlabConnectionConfig>[] = [
] ]
}), }),
name: "Add a group", name: "Add a group",
description: (
<div className="flex flex-col">
<span>Add a group to sync with. All projects in the group (and recursive subgroups) visible to the provided <Code>token</Code> (if any) will be synced.</span>
<span className="text-sm mt-2 mb-1">Examples:</span>
<div className="flex flex-col gap-1">
{[
"my-group",
"path/to/subgroup"
].map((org) => (
<Code key={org}>{org}</Code>
))}
</div>
</div>
)
}, },
{ {
fn: (previous: GitlabConnectionConfig) => ({ fn: (previous: GitlabConnectionConfig) => ({
@ -125,16 +288,43 @@ export const gitlabQuickActions: QuickAction<GitlabConnectionConfig>[] = [
url: previous.url ?? "", url: previous.url ?? "",
}), }),
name: "Set a custom url", name: "Set a custom url",
description: <span>Set a custom GitLab host. Defaults to <Code>https://gitlab.com</Code>.</span>
}, },
{ {
fn: (previous: GitlabConnectionConfig) => ({ fn: (previous: GitlabConnectionConfig) => ({
...previous, ...previous,
token: previous.token ?? { all: true,
secret: "",
},
}), }),
name: "Add a secret", name: "Sync all projects",
description: <span>Sync all projects visible to the provided <Code>token</Code> (if any). Only available when using a self-hosted GitLab instance.</span>
}, },
{
fn: (previous: GitlabConnectionConfig) => ({
...previous,
exclude: {
...previous.exclude,
projects: [
...(previous.exclude?.projects ?? []),
""
]
}
}),
name: "Exclude a project",
description: (
<div className="flex flex-col">
<span>List of projects to exclude from syncing. Glob patterns are supported.</span>
<span className="text-sm mt-2 mb-1">Examples:</span>
<div className="flex flex-col gap-1">
{[
"docs/**",
"**/tests/**",
].map((repo) => (
<Code key={repo}>{repo}</Code>
))}
</div>
</div>
)
}
] ]
export const giteaQuickActions: QuickAction<GiteaConnectionConfig>[] = [ export const giteaQuickActions: QuickAction<GiteaConnectionConfig>[] = [

View file

@ -36,8 +36,8 @@ export default function SettingsLayout({
return ( return (
<div className="min-h-screen flex flex-col"> <div className="min-h-screen flex flex-col">
<NavigationMenu domain={domain} /> <NavigationMenu domain={domain} />
<div className="flex-grow flex justify-center p-4 bg-[#fafafa] dark:bg-background relative"> <div className="flex-grow flex justify-center p-4 bg-backgroundSecondary relative">
<div className="w-full max-w-6xl"> <div className="w-full max-w-6xl p-6">
<Header className="w-full"> <Header className="w-full">
<h1 className="text-3xl">Settings</h1> <h1 className="text-3xl">Settings</h1>
</Header> </Header>