feat: support packer config

This commit is contained in:
ReaJason
2026-02-26 22:55:45 +08:00
parent 0d3b56de63
commit 9243070126
85 changed files with 2410 additions and 658 deletions
@@ -1,11 +1,18 @@
import { PackageIcon } from "lucide-react";
import { useMemo } from "react";
import { useEffect, useMemo } from "react";
import { Controller, type UseFormReturn, useWatch } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { PackerCombobox } from "@/components/packer/packer-combobox";
import { PackerCustomConfigFields } from "@/components/packer/packer-custom-config-fields";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { FieldLabel, FieldSet } from "@/components/ui/field";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Field, FieldLabel, FieldSet } from "@/components/ui/field";
import { Spinner } from "@/components/ui/spinner";
import {
findPackerEntry,
getPackerDefaultConfig,
getPackerSchemaFields,
normalizePackerCategories,
} from "@/lib/packer-schema";
import type { PackerConfig } from "@/types/memshell";
import type { MemShellFormSchema } from "@/types/schema";
@@ -28,25 +35,77 @@ export default function PackageConfigCard({
name: "server",
});
const options = useMemo(() => {
const filteredOptions = (packerConfig ?? []).filter((name) => {
if (!shellType || shellType === " ") {
return true;
const packingMethod = useWatch({
control: form.control,
name: "packingMethod",
});
const categories = useMemo(
() => normalizePackerCategories(packerConfig),
[packerConfig],
);
const filteredCategories = useMemo(() => {
return categories
.map((group) => ({
...group,
packers: group.packers.filter((packer) => {
if (packer.categoryAnchor) {
return false;
}
const name = packer.name;
if (!shellType || shellType === " ") {
return true;
}
if (shellType.startsWith("Agent")) {
return name.startsWith("Agent");
}
if ((server ?? "").startsWith("XXL")) {
return !name.startsWith("Agent");
}
return (
!name.startsWith("Agent") && !name.toLowerCase().startsWith("xxl")
);
}),
}))
.filter((group) => group.packers.length > 0);
}, [categories, shellType, server]);
const allOptionNames = useMemo(
() =>
filteredCategories.flatMap((group) =>
group.packers.map((packer) => packer.name),
),
[filteredCategories],
);
const selectedPackerEntry = useMemo(
() =>
findPackerEntry(filteredCategories, packingMethod) ??
findPackerEntry(categories, packingMethod),
[categories, filteredCategories, packingMethod],
);
const selectedSchemaFields = useMemo(
() => getPackerSchemaFields(selectedPackerEntry),
[selectedPackerEntry],
);
useEffect(() => {
if (allOptionNames.length > 0) {
const current = form.getValues("packingMethod");
if (!current || !allOptionNames.includes(current)) {
form.setValue("packingMethod", allOptionNames[0]);
}
if (shellType.startsWith("Agent")) {
return name.startsWith("Agent");
}
if (server.startsWith("XXL")) {
return !name.startsWith("Agent");
}
return !name.startsWith("Agent") && !name.toLowerCase().startsWith("xxl");
});
form.setValue("packingMethod", filteredOptions[0]);
return filteredOptions.map((name) => ({
name: t(name),
value: name,
}));
}, [packerConfig, shellType, server, t, form]);
}
}, [allOptionNames, form]);
useEffect(() => {
form.setValue(
"packerCustomConfig",
getPackerDefaultConfig(selectedPackerEntry) as any,
);
}, [form, selectedPackerEntry, packingMethod]);
return (
<Card className="w-full">
@@ -57,32 +116,30 @@ export default function PackageConfigCard({
</CardTitle>
</CardHeader>
<CardContent>
{options.length > 0 ? (
<Controller
control={form.control}
name="packingMethod"
render={({ field }) => (
<FieldSet>
<FieldLabel>{t("packerMethod")}</FieldLabel>
<RadioGroup
name={field.name}
value={field.value}
defaultValue={options[0].value}
onValueChange={field.onChange}
className="grid grid-cols-2 md:grid-cols-3"
>
{options.map(({ name, value }) => (
<div key={value} className="flex items-center space-x-3">
<RadioGroupItem value={value} id={value} />
<FieldLabel className="text-xs" htmlFor={value}>
{name}
</FieldLabel>
</div>
))}
</RadioGroup>
</FieldSet>
)}
/>
{allOptionNames.length > 0 ? (
<>
<Controller
control={form.control}
name="packingMethod"
render={({ field }) => (
<Field className="gap-1">
<FieldLabel>{t("packerMethod")}</FieldLabel>
<PackerCombobox
categories={filteredCategories}
value={field.value}
onValueChange={field.onChange}
placeholder={t("selectPacker", {
defaultValue: "Select packer",
})}
/>
</Field>
)}
/>
<PackerCustomConfigFields
form={form}
fields={selectedSchemaFields}
/>
</>
) : (
<div className="flex items-center justify-center p-4 gap-4 h-50">
<Spinner />
@@ -1,135 +0,0 @@
import { DownloadIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import CodeViewer from "@/components/code-viewer";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { base64ToBytes, downloadBytes, downloadContent } from "@/lib/utils";
export function MultiPackResult({
allPackResults,
packMethod,
shellClassName,
height = 350,
}: Readonly<{
allPackResults: object | undefined;
packMethod: string;
shellClassName?: string;
height?: number;
}>) {
const showCode = packMethod === "JSP";
const { t } = useTranslation();
const packResults = allPackResults as Record<string, string> | undefined;
const packMethods = useMemo(
() => Object.keys(packResults ?? {}),
[packResults],
);
const [selectedMethod, setSelectedMethod] = useState(
() => packMethods[0] ?? "",
);
const packResult = useMemo(() => {
if (!selectedMethod) {
return "";
}
return packResults?.[selectedMethod] ?? "";
}, [packResults, selectedMethod]);
useEffect(() => {
if (packMethods.length === 0) {
if (selectedMethod !== "") {
setSelectedMethod("");
}
return;
}
if (!packMethods.includes(selectedMethod)) {
setSelectedMethod(packMethods[0]);
}
}, [packMethods, selectedMethod]);
const handleDownload = useCallback(() => {
const fileName =
shellClassName?.substring(shellClassName?.lastIndexOf(".") ?? 0) ?? "";
if (packMethod === "JSP") {
const fileExtension = selectedMethod.includes("JSPX") ? ".jspx" : ".jsp";
const content = new Blob([packResult], { type: "text/plain" });
return downloadContent(content, fileName, fileExtension);
} else if (
packMethod === "JavaDeserialize" ||
packMethod.includes("Hessian")
) {
const content = new Blob([base64ToBytes(packResult)], {
type: "application/octet-stream",
});
return downloadContent(content, fileName, ".data");
} else if (packMethod === "Base64") {
const base64Content = packResults?.[packMethods[0]] ?? "";
return downloadBytes(base64Content, shellClassName);
}
}, [
packMethod,
packMethods,
packResult,
packResults,
selectedMethod,
shellClassName,
]);
return (
<CodeViewer
code={packResult ?? ""}
header={
<div className="flex items-center justify-between text-xs gap-2">
<Select
onValueChange={(value) => {
setSelectedMethod(value as string);
}}
value={selectedMethod}
>
<SelectTrigger className="h-7 text-xs [&_svg]:h-4 [&_svg]:w-4">
<span className="text-muted-foreground">
{t("common:packerMethod")}:&nbsp;
</span>
<SelectValue data-placeholder={t("common:placeholders.select")} />
</SelectTrigger>
<SelectContent>
{packMethods.map((method) => (
<SelectItem key={method} value={method} className="text-xs">
{method}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-muted-foreground">({packResult?.length})</span>
</div>
}
button={
packMethod === "JSP" ||
packMethod === "Base64" ||
packMethod === "JavaDeserialize" ||
packMethod.includes("Hessian") ? (
<Button
variant="ghost"
size="icon"
type="button"
className="h-7 w-7 [&_svg]:h-4 [&_svg]:w-4"
onClick={handleDownload}
>
<DownloadIcon className="h-4 w-4" />
</Button>
) : null
}
wrapLongLines={!showCode}
showLineNumbers={showCode}
language={showCode ? "java" : "text"}
height={height}
/>
);
}
@@ -3,32 +3,19 @@ import CodeViewer from "@/components/code-viewer";
import type { MemShellResult } from "@/types/memshell";
import { AgentResult } from "./agent";
import { JarResult } from "./jar-result";
import { MultiPackResult } from "./multi-packer";
export function ResultComponent({
packResult,
allPackResults,
packMethod,
generateResult,
}: Readonly<{
packResult: string | undefined;
allPackResults: Map<string, string> | undefined;
packMethod: string;
generateResult?: MemShellResult;
}>) {
const showCode = packMethod === "JSP";
const isAgent = packMethod.startsWith("Agent");
const isJar = packMethod.endsWith("Jar");
const { t } = useTranslation();
if (allPackResults) {
return (
<MultiPackResult
allPackResults={allPackResults}
packMethod={packMethod}
shellClassName={generateResult?.injectorClassName}
/>
);
}
if (isAgent) {
return (
<AgentResult
@@ -52,16 +39,16 @@ export function ResultComponent({
<CodeViewer
code={packResult ?? ""}
header={
<div className="flex items-center justify-between text-xs gap-2">
<div className="flex items-center justify-between text-sm gap-2">
<span>
{t("common:packerMethod")}{packMethod}
</span>
<span className="text-muted-foreground">({packResult?.length})</span>
</div>
}
wrapLongLines={!showCode}
showLineNumbers={showCode}
language={showCode ? "java" : "text"}
wrapLongLines={true}
showLineNumbers={false}
language={"text"}
height={350}
/>
);
+1 -4
View File
@@ -13,12 +13,10 @@ import { ResultComponent } from "./results/result-component";
export default function ShellResult({
packResult,
allPackResults,
packMethod,
generateResult,
}: Readonly<{
packResult: string | undefined;
allPackResults: Map<string, string> | undefined;
packMethod: string;
generateResult?: MemShellResult;
}>) {
@@ -26,7 +24,7 @@ export default function ShellResult({
if (!generateResult) {
return <QuickUsage />;
}
const height = 800;
const height = 600;
return (
<Tabs defaultValue="packResult">
<TabsList className="grid w-full grid-cols-3">
@@ -42,7 +40,6 @@ export default function ShellResult({
<BasicInfo generateResult={generateResult} />
<ResultComponent
packResult={packResult}
allPackResults={allPackResults}
packMethod={packMethod}
generateResult={generateResult}
/>
@@ -0,0 +1,147 @@
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxGroup,
ComboboxInput,
ComboboxItem,
ComboboxLabel,
ComboboxList,
} from "@/components/ui/combobox";
import type { NormalizedPackerCategory } from "@/lib/packer-schema";
type PackerComboboxProps = {
categories: NormalizedPackerCategory[];
value?: string;
onValueChange: (value: string) => void;
placeholder?: string;
emptyText?: string;
disabled?: boolean;
};
type PreparedPacker = {
name: string;
label: string;
searchText: string;
};
type PreparedCategory = {
name: string;
label: string;
packers: PreparedPacker[];
};
export function PackerCombobox({
categories,
value,
onValueChange,
placeholder,
emptyText,
disabled,
}: Readonly<PackerComboboxProps>) {
const { t } = useTranslation("common");
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const preparedCategories = useMemo<PreparedCategory[]>(
() =>
categories.map((category) => {
const categoryLabel = t(category.name, { defaultValue: category.name });
return {
name: category.name,
label: categoryLabel,
packers: category.packers.map((packer) => {
const packerLabel = t(packer.name, { defaultValue: packer.name });
return {
name: packer.name,
label: packerLabel,
searchText:
`${packer.name} ${packerLabel} ${category.name} ${categoryLabel}`.toLowerCase(),
};
}),
};
}),
[categories, t],
);
const selectedLabel = useMemo(() => {
if (!value) {
return "";
}
for (const category of preparedCategories) {
const found = category.packers.find((packer) => packer.name === value);
if (found) {
return found.label;
}
}
return value;
}, [preparedCategories, value]);
const filteredCategories = useMemo(() => {
const normalizedQuery = query.trim().toLowerCase();
if (!normalizedQuery) {
return preparedCategories;
}
return preparedCategories
.map((category) => ({
...category,
packers: category.packers.filter((packer) =>
packer.searchText.includes(normalizedQuery),
),
}))
.filter((category) => category.packers.length > 0);
}, [preparedCategories, query]);
const resolvedPlaceholder =
placeholder ?? t("selectPacker", { defaultValue: "Select packer" });
return (
<Combobox
value={value ?? null}
open={open}
onOpenChange={(nextOpen) => {
setOpen(nextOpen);
setQuery("");
}}
inputValue={open ? query : selectedLabel}
onInputValueChange={(nextValue) => {
if (open) {
setQuery(nextValue);
}
}}
onValueChange={(nextValue) => {
if (typeof nextValue === "string") {
onValueChange(nextValue);
}
setOpen(false);
setQuery("");
}}
autoComplete="none"
autoHighlight={true}
>
<ComboboxInput
className="w-full"
placeholder={resolvedPlaceholder}
disabled={disabled}
showClear={false}
/>
<ComboboxContent>
<ComboboxList>
{filteredCategories.map((category) => (
<ComboboxGroup key={category.name}>
<ComboboxLabel>{category.label}</ComboboxLabel>
{category.packers.map((packer) => (
<ComboboxItem key={packer.name} value={packer.name}>
<span className="truncate" title={packer.label}>
{packer.label}
</span>
</ComboboxItem>
))}
</ComboboxGroup>
))}
</ComboboxList>
</ComboboxContent>
</Combobox>
);
}
@@ -0,0 +1,205 @@
import {
Controller,
type FieldValues,
type UseFormReturn,
} from "react-hook-form";
import { useTranslation } from "react-i18next";
import {
Field,
FieldContent,
FieldDescription,
FieldLabel,
FieldSet,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import type { PackerSchemaField } from "@/types/memshell";
type Props<T extends FieldValues> = {
form: UseFormReturn<T>;
fields: PackerSchemaField[];
baseName?: string;
};
export function PackerCustomConfigFields<T extends FieldValues>({
form,
fields,
baseName = "packerCustomConfig",
}: Readonly<Props<T>>) {
const { t } = useTranslation("common");
if (fields.length === 0) {
return null;
}
const supportedFields = fields.filter((field) =>
["BOOLEAN", "STRING", "ENUM", "INTEGER"].includes(field.type),
);
if (supportedFields.length === 0) {
return null;
}
const getFieldDescription = (schemaField: PackerSchemaField) => {
if (!schemaField.description && !schemaField.descriptionI18nKey) {
return undefined;
}
if (!schemaField.descriptionI18nKey) {
return schemaField.description;
}
return t(schemaField.descriptionI18nKey, {
defaultValue: schemaField.description ?? schemaField.descriptionI18nKey,
});
};
return (
<Field className="mt-2 gap-1">
<FieldLabel>
{t("packerParams", { defaultValue: "Packer Params" })}
</FieldLabel>
{supportedFields.map((schemaField) => {
const fieldName = `${baseName}.${schemaField.key}` as any;
const fieldDescription = getFieldDescription(schemaField);
return (
<Controller
key={schemaField.key}
control={form.control}
name={fieldName}
render={({ field }) => {
switch (schemaField.type) {
case "BOOLEAN":
return (
<Field orientation="horizontal">
<Switch
id={fieldName}
checked={Boolean(field.value)}
onCheckedChange={field.onChange}
/>
<FieldContent>
<FieldLabel htmlFor={fieldName}>
{schemaField.key}
</FieldLabel>
{fieldDescription ? (
<FieldDescription>
{fieldDescription}
</FieldDescription>
) : null}
</FieldContent>
</Field>
);
case "ENUM":
return (
<Field orientation="vertical">
<FieldContent>
<FieldLabel htmlFor={fieldName}>
{schemaField.key}
</FieldLabel>
<Select
value={
typeof field.value === "string"
? field.value
: undefined
}
onValueChange={field.onChange}
>
<SelectTrigger id={fieldName}>
<SelectValue
data-placeholder={t("placeholders.select")}
/>
</SelectTrigger>
<SelectContent>
{(schemaField.options ?? []).map((option) => (
<SelectItem
key={option.value}
value={option.value}
>
{option.label || option.value}
</SelectItem>
))}
</SelectContent>
</Select>
{fieldDescription ? (
<FieldDescription>
{fieldDescription}
</FieldDescription>
) : null}
</FieldContent>
</Field>
);
case "INTEGER":
return (
<Field orientation="vertical">
<FieldContent>
<FieldLabel htmlFor={fieldName}>
{schemaField.key}
</FieldLabel>
<Input
id={fieldName}
type="number"
step={1}
value={
typeof field.value === "number"
? String(field.value)
: ""
}
onChange={(event) => {
const raw = event.target.value;
if (raw === "") {
field.onChange(undefined);
return;
}
const parsed = Number.parseInt(raw, 10);
field.onChange(
Number.isFinite(parsed) ? parsed : undefined,
);
}}
/>
{fieldDescription ? (
<FieldDescription>
{fieldDescription}
</FieldDescription>
) : null}
</FieldContent>
</Field>
);
case "STRING":
return (
<Field orientation="vertical">
<FieldContent>
<FieldLabel htmlFor={fieldName}>
{schemaField.key}
</FieldLabel>
<Input
id={fieldName}
type="text"
value={
typeof field.value === "string" ? field.value : ""
}
onChange={field.onChange}
/>
{fieldDescription ? (
<FieldDescription>
{fieldDescription}
</FieldDescription>
) : null}
</FieldContent>
</Field>
);
default:
return <></>;
}
}}
/>
);
})}
</Field>
);
}
@@ -1,18 +1,20 @@
import { PackageIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { Controller, type UseFormReturn } from "react-hook-form";
import { useEffect, useMemo } from "react";
import { Controller, type UseFormReturn, useWatch } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { PackerCombobox } from "@/components/packer/packer-combobox";
import { PackerCustomConfigFields } from "@/components/packer/packer-custom-config-fields";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { FieldLabel } from "@/components/ui/field";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Field, FieldLabel, FieldSet } from "@/components/ui/field";
import {
findPackerEntry,
getPackerDefaultConfig,
getPackerSchemaFields,
normalizePackerCategories,
} from "@/lib/packer-schema";
import type { PackerConfig } from "@/types/memshell";
import type { ProbeShellFormSchema } from "@/types/schema";
type Option = {
name: string;
value: string;
};
export default function PackageConfigCard({
packerConfig,
form,
@@ -20,34 +22,73 @@ export default function PackageConfigCard({
packerConfig: PackerConfig | undefined;
form: UseFormReturn<ProbeShellFormSchema>;
}>) {
const [options, setOptions] = useState<Array<Option>>([]);
const { t } = useTranslation("common");
const packingMethod = useWatch({
control: form.control,
name: "packingMethod",
});
const categories = useMemo(
() => normalizePackerCategories(packerConfig),
[packerConfig],
);
const filteredCategories = useMemo(() => {
return categories
.map((category) => ({
...category,
packers: category.packers.filter((packer) => {
if (packer.categoryAnchor) {
return false;
}
const name = packer.name;
return (
!name.startsWith("Agent") &&
!name.toLowerCase().startsWith("xxl") &&
!name.toLowerCase().endsWith("jar")
);
}),
}))
.filter((category) => category.packers.length > 0);
}, [categories]);
const allOptionNames = useMemo(
() =>
filteredCategories.flatMap((category) =>
category.packers.map((packer) => packer.name),
),
[filteredCategories],
);
const selectedPackerEntry = useMemo(
() =>
findPackerEntry(filteredCategories, packingMethod) ??
findPackerEntry(categories, packingMethod),
[categories, filteredCategories, packingMethod],
);
const selectedSchemaFields = useMemo(
() => getPackerSchemaFields(selectedPackerEntry),
[selectedPackerEntry],
);
useEffect(() => {
const filteredOptions = (packerConfig ?? []).filter((name) => {
return (
!name.startsWith("Agent") &&
!name.toLowerCase().startsWith("xxl") &&
!name.toLowerCase().endsWith("jar")
);
});
const mappedOptions = filteredOptions.map((name) => {
return {
name: name,
value: name,
};
});
setOptions(mappedOptions);
const currentValue = form.getValues("packingMethod");
if (
filteredOptions.length > 0 &&
(!currentValue || !filteredOptions.includes(currentValue))
allOptionNames.length > 0 &&
(!currentValue ||
!allOptionNames.some((option) => option === currentValue))
) {
form.setValue("packingMethod", filteredOptions[0]);
form.setValue("packingMethod", allOptionNames[0]);
}
}, [form, packerConfig]);
}, [allOptionNames, form]);
useEffect(() => {
form.setValue(
"packerCustomConfig",
getPackerDefaultConfig(selectedPackerEntry) as any,
);
}, [form, selectedPackerEntry, packingMethod]);
return (
<Card className="w-full">
@@ -58,34 +99,30 @@ export default function PackageConfigCard({
</CardTitle>
</CardHeader>
<CardContent>
{options.length > 0 ? (
<Controller
control={form.control}
name="packingMethod"
render={({ field }) => (
<div className="space-y-3">
<FieldLabel>{t("packerMethod")}</FieldLabel>
<div>
<RadioGroup
onValueChange={field.onChange}
{allOptionNames.length > 0 ? (
<>
<Controller
control={form.control}
name="packingMethod"
render={({ field }) => (
<Field className="gap-1">
<FieldLabel>{t("packerMethod")}</FieldLabel>
<PackerCombobox
categories={filteredCategories}
value={field.value}
className="grid grid-cols-2 md:grid-cols-3"
>
{options.map(({ name, value }) => (
<div key={value} className="flex items-center space-x-3">
<div>
<RadioGroupItem value={value} id={value} />
</div>
<FieldLabel className="text-xs" htmlFor={value}>
{name}
</FieldLabel>
</div>
))}
</RadioGroup>
</div>
</div>
)}
/>
onValueChange={field.onChange}
placeholder={t("selectPacker", {
defaultValue: "Select packer",
})}
/>
</Field>
)}
/>
<PackerCustomConfigFields
form={form}
fields={selectedSchemaFields}
/>
</>
) : (
<div className="flex items-center justify-center p-4">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
+3 -15
View File
@@ -3,17 +3,14 @@ import { QuickUsage } from "@/components/probeshell/quick-usage";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type { ProbeShellResult } from "@/types/probeshell";
import CodeViewer from "../code-viewer";
import { MultiPackResult } from "../memshell/results/multi-packer";
import { BasicInfo } from "./basic-info";
export default function ShellResult({
packResult,
allPackResults,
packMethod,
generateResult,
}: Readonly<{
packResult: string | undefined;
allPackResults: Map<string, string> | undefined;
packMethod: string;
generateResult?: ProbeShellResult;
}>) {
@@ -21,7 +18,6 @@ export default function ShellResult({
if (!generateResult) {
return <QuickUsage />;
}
const showCode = packMethod === "JSP";
const height = 600;
return (
<Tabs defaultValue="packResult">
@@ -32,14 +28,6 @@ export default function ShellResult({
</TabsList>
<TabsContent value="packResult" className="space-y-2">
<BasicInfo generateResult={generateResult} />
{allPackResults && (
<MultiPackResult
allPackResults={allPackResults}
shellClassName={generateResult?.shellClassName}
packMethod={packMethod}
height={height}
/>
)}
{packResult && (
<CodeViewer
code={packResult}
@@ -53,9 +41,9 @@ export default function ShellResult({
</span>
</div>
}
wrapLongLines={!showCode}
showLineNumbers={showCode}
language={showCode ? "java" : "text"}
wrapLongLines={true}
showLineNumbers={false}
language={"text"}
height={height}
/>
)}
+300
View File
@@ -0,0 +1,300 @@
"use client";
import * as React from "react";
import { Combobox as ComboboxPrimitive } from "@base-ui/react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@/components/ui/input-group";
import { ChevronDownIcon, XIcon, CheckIcon } from "lucide-react";
const Combobox = ComboboxPrimitive.Root;
function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />;
}
function ComboboxTrigger({
className,
children,
...props
}: ComboboxPrimitive.Trigger.Props) {
return (
<ComboboxPrimitive.Trigger
data-slot="combobox-trigger"
className={cn("[&_svg:not([class*='size-'])]:size-4", className)}
{...props}
>
{children}
<ChevronDownIcon className="text-muted-foreground size-4 pointer-events-none" />
</ComboboxPrimitive.Trigger>
);
}
function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
return (
<ComboboxPrimitive.Clear
data-slot="combobox-clear"
render={<InputGroupButton variant="ghost" size="icon-xs" />}
className={cn(className)}
{...props}
>
<XIcon className="pointer-events-none" />
</ComboboxPrimitive.Clear>
);
}
function ComboboxInput({
className,
children,
disabled = false,
showTrigger = true,
showClear = false,
...props
}: ComboboxPrimitive.Input.Props & {
showTrigger?: boolean;
showClear?: boolean;
}) {
return (
<InputGroup className={cn("w-auto", className)}>
<ComboboxPrimitive.Input
render={<InputGroupInput disabled={disabled} />}
{...props}
/>
<InputGroupAddon align="inline-end">
{showTrigger && (
<InputGroupButton
size="icon-xs"
variant="ghost"
render={<ComboboxTrigger />}
data-slot="input-group-button"
className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent"
disabled={disabled}
/>
)}
{showClear && <ComboboxClear disabled={disabled} />}
</InputGroupAddon>
{children}
</InputGroup>
);
}
function ComboboxContent({
className,
side = "bottom",
sideOffset = 6,
align = "start",
alignOffset = 0,
anchor,
...props
}: ComboboxPrimitive.Popup.Props &
Pick<
ComboboxPrimitive.Positioner.Props,
"side" | "align" | "sideOffset" | "alignOffset" | "anchor"
>) {
return (
<ComboboxPrimitive.Portal>
<ComboboxPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
anchor={anchor}
className="isolate z-50"
>
<ComboboxPrimitive.Popup
data-slot="combobox-content"
data-chips={!!anchor}
className={cn(
"bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:border-input/30 overflow-hidden rounded-md shadow-md ring-1 duration-100 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:shadow-none data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 group/combobox-content relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) data-[chips=true]:min-w-(--anchor-width)",
className,
)}
{...props}
/>
</ComboboxPrimitive.Positioner>
</ComboboxPrimitive.Portal>
);
}
function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
return (
<ComboboxPrimitive.List
data-slot="combobox-list"
className={cn(
"no-scrollbar max-h-[min(calc(--spacing(72)---spacing(9)),calc(var(--available-height)---spacing(9)))] scroll-py-1 p-1 data-empty:p-0 overflow-y-auto overscroll-contain",
className,
)}
{...props}
/>
);
}
function ComboboxItem({
className,
children,
...props
}: ComboboxPrimitive.Item.Props) {
return (
<ComboboxPrimitive.Item
data-slot="combobox-item"
className={cn(
"data-highlighted:bg-accent data-highlighted:text-accent-foreground not-data-[variant=destructive]:data-highlighted:**:text-accent-foreground gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm [&_svg:not([class*='size-'])]:size-4 relative flex w-full cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
{...props}
>
{children}
<ComboboxPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</ComboboxPrimitive.ItemIndicator>
</ComboboxPrimitive.Item>
);
}
function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
return (
<ComboboxPrimitive.Group
data-slot="combobox-group"
className={cn(className)}
{...props}
/>
);
}
function ComboboxLabel({
className,
...props
}: ComboboxPrimitive.GroupLabel.Props) {
return (
<ComboboxPrimitive.GroupLabel
data-slot="combobox-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
);
}
function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) {
return (
<ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />
);
}
function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
return (
<ComboboxPrimitive.Empty
data-slot="combobox-empty"
className={cn(
"text-muted-foreground hidden w-full justify-center py-2 text-center text-sm group-data-empty/combobox-content:flex",
className,
)}
{...props}
/>
);
}
function ComboboxSeparator({
className,
...props
}: ComboboxPrimitive.Separator.Props) {
return (
<ComboboxPrimitive.Separator
data-slot="combobox-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function ComboboxChips({
className,
...props
}: React.ComponentPropsWithRef<typeof ComboboxPrimitive.Chips> &
ComboboxPrimitive.Chips.Props) {
return (
<ComboboxPrimitive.Chips
data-slot="combobox-chips"
className={cn(
"dark:bg-input/30 border-input focus-within:border-ring focus-within:ring-ring/50 has-aria-invalid:ring-destructive/20 dark:has-aria-invalid:ring-destructive/40 has-aria-invalid:border-destructive dark:has-aria-invalid:border-destructive/50 flex min-h-9 flex-wrap items-center gap-1.5 rounded-md border bg-transparent bg-clip-padding px-2.5 py-1.5 text-sm shadow-xs transition-[color,box-shadow] focus-within:ring-3 has-aria-invalid:ring-3 has-data-[slot=combobox-chip]:px-1.5",
className,
)}
{...props}
/>
);
}
function ComboboxChip({
className,
children,
showRemove = true,
...props
}: ComboboxPrimitive.Chip.Props & {
showRemove?: boolean;
}) {
return (
<ComboboxPrimitive.Chip
data-slot="combobox-chip"
className={cn(
"bg-muted text-foreground flex h-[calc(--spacing(5.5))] w-fit items-center justify-center gap-1 rounded-sm px-1.5 text-xs font-medium whitespace-nowrap has-data-[slot=combobox-chip-remove]:pr-0 has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50",
className,
)}
{...props}
>
{children}
{showRemove && (
<ComboboxPrimitive.ChipRemove
render={<Button variant="ghost" size="icon-xs" />}
className="-ml-1 opacity-50 hover:opacity-100"
data-slot="combobox-chip-remove"
>
<XIcon className="pointer-events-none" />
</ComboboxPrimitive.ChipRemove>
)}
</ComboboxPrimitive.Chip>
);
}
function ComboboxChipsInput({
className,
...props
}: ComboboxPrimitive.Input.Props) {
return (
<ComboboxPrimitive.Input
data-slot="combobox-chip-input"
className={cn("min-w-16 flex-1 outline-none", className)}
{...props}
/>
);
}
function useComboboxAnchor() {
return React.useRef<HTMLDivElement | null>(null);
}
export {
Combobox,
ComboboxInput,
ComboboxContent,
ComboboxList,
ComboboxItem,
ComboboxGroup,
ComboboxLabel,
ComboboxCollection,
ComboboxEmpty,
ComboboxSeparator,
ComboboxChips,
ComboboxChip,
ComboboxChipsInput,
ComboboxTrigger,
ComboboxValue,
useComboboxAnchor,
};
+191
View File
@@ -0,0 +1,191 @@
import * as React from "react";
import { Command as CommandPrimitive } from "cmdk";
import { cn } from "@/lib/utils";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { InputGroup, InputGroupAddon } from "@/components/ui/input-group";
import { SearchIcon, CheckIcon } from "lucide-react";
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"bg-popover text-popover-foreground rounded-xl! p-1 flex size-full flex-col overflow-hidden",
className,
)}
{...props}
/>
);
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = false,
...props
}: Omit<React.ComponentProps<typeof Dialog>, "children"> & {
title?: string;
description?: string;
className?: string;
showCloseButton?: boolean;
children: React.ReactNode;
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn(
"rounded-xl! top-1/3 translate-y-0 overflow-hidden p-0",
className,
)}
showCloseButton={showCloseButton}
>
{children}
</DialogContent>
</Dialog>
);
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div data-slot="command-input-wrapper" className="p-1 pb-0">
<InputGroup className="bg-input/30 border-input/30 h-8! rounded-lg! shadow-none! *:data-[slot=input-group-addon]:pl-2!">
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
<InputGroupAddon>
<SearchIcon className="size-4 shrink-0 opacity-50" />
</InputGroupAddon>
</InputGroup>
</div>
);
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"no-scrollbar max-h-72 scroll-py-1 outline-none overflow-x-hidden overflow-y-auto",
className,
)}
{...props}
/>
);
}
function CommandEmpty({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className={cn("py-6 text-center text-sm", className)}
{...props}
/>
);
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"text-foreground **:[[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium",
className,
)}
{...props}
/>
);
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("bg-border -mx-1 h-px w-auto", className)}
{...props}
/>
);
}
function CommandItem({
className,
children,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-selected:bg-muted data-selected:text-foreground data-selected:**:[svg]:text-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! [&_svg:not([class*='size-'])]:size-4 group/command-item data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
{...props}
>
{children}
<CheckIcon className="ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
</CommandPrimitive.Item>
);
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"text-muted-foreground group-data-selected/command-item:text-foreground ml-auto text-xs tracking-widest",
className,
)}
{...props}
/>
);
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};
+156
View File
@@ -0,0 +1,156 @@
"use client";
import * as React from "react";
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { XIcon } from "lucide-react";
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
className,
...props
}: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 isolate z-50",
className,
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean;
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"bg-background data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 ring-foreground/10 grid max-w-[calc(100%-2rem)] gap-6 rounded-xl p-6 text-sm ring-1 duration-100 sm:max-w-md fixed top-1/2 left-1/2 z-50 w-full -translate-x-1/2 -translate-y-1/2 outline-none",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={
<Button
variant="ghost"
className="absolute top-4 right-4"
size="icon-sm"
/>
}
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("gap-2 flex flex-col", className)}
{...props}
/>
);
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean;
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close render={<Button variant="outline" />}>
Close
</DialogPrimitive.Close>
)}
</div>
);
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("leading-none font-medium", className)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-muted-foreground *:[a]:hover:text-foreground text-sm *:[a]:underline *:[a]:underline-offset-3",
className,
)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};
+156
View File
@@ -0,0 +1,156 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
"border-input dark:bg-input/30 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 h-8 rounded-md border shadow-xs transition-[color,box-shadow] in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-3 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5 group/input-group relative flex w-full min-w-0 items-center outline-none has-[>textarea]:h-auto",
className,
)}
{...props}
/>
);
}
const inputGroupAddonVariants = cva(
"text-muted-foreground h-auto gap-2 py-1.5 text-sm font-medium group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4 flex cursor-text items-center justify-center select-none",
{
variants: {
align: {
"inline-start":
"pl-2 has-[>button]:-ml-1 has-[>kbd]:ml-[-0.15rem] order-first",
"inline-end":
"pr-2 has-[>button]:-mr-1 has-[>kbd]:mr-[-0.15rem] order-last",
"block-start":
"px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2 order-first w-full justify-start",
"block-end":
"px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2 order-last w-full justify-start",
},
},
defaultVariants: {
align: "inline-start",
},
},
);
function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest("button")) {
return;
}
e.currentTarget.parentElement?.querySelector("input")?.focus();
}}
{...props}
/>
);
}
const inputGroupButtonVariants = cva(
"gap-2 text-sm shadow-none flex items-center",
{
variants: {
size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
sm: "",
"icon-xs":
"size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
},
);
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size" | "type"> &
VariantProps<typeof inputGroupButtonVariants> & {
type?: "button" | "submit" | "reset";
}) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
);
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"text-muted-foreground gap-2 text-sm [&_svg:not([class*='size-'])]:size-4 flex items-center [&_svg]:pointer-events-none",
className,
)}
{...props}
/>
);
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
"rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 aria-invalid:ring-0 dark:bg-transparent flex-1",
className,
)}
{...props}
/>
);
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
"rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 aria-invalid:ring-0 dark:bg-transparent flex-1 resize-none",
className,
)}
{...props}
/>
);
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
};