mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 15:15:24 +00:00
release-2.4.1 (#921)
* chore(deps-dev): bump the dev-minor-updates group across 1 directory with 12 updates (#910) Bumps the dev-minor-updates group with 12 updates in the / directory: | Package | From | To | | --- | --- | --- | | [@radix-ui/react-select](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/select) | `2.2.6` | `2.3.1` | | [@radix-ui/react-slider](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/slider) | `1.3.6` | `1.4.1` | | [@radix-ui/react-slot](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/slot) | `1.2.5` | `1.3.0` | | [@radix-ui/react-switch](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/switch) | `1.2.6` | `1.3.1` | | [cytoscape](https://github.com/cytoscape/cytoscape.js) | `3.33.4` | `3.34.0` | | [electron](https://github.com/electron/electron) | `42.2.0` | `42.4.1` | | [electron-builder](https://github.com/electron-userland/electron-builder/tree/HEAD/packages/electron-builder) | `26.8.1` | `26.15.3` | | [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `1.16.0` | `1.20.0` | | [radix-ui](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/radix-ui) | `1.4.3` | `1.6.0` | | [react-hook-form](https://github.com/react-hook-form/react-hook-form) | `7.76.1` | `7.79.0` | | [sharp](https://github.com/lovell/sharp) | `0.34.5` | `0.35.1` | | [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.60.0` | `8.61.1` | Updates `@radix-ui/react-select` from 2.2.6 to 2.3.1 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/select/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/select) Updates `@radix-ui/react-slider` from 1.3.6 to 1.4.1 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/slider/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/slider) Updates `@radix-ui/react-slot` from 1.2.5 to 1.3.0 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/slot/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/slot) Updates `@radix-ui/react-switch` from 1.2.6 to 1.3.1 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/switch/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/switch) Updates `cytoscape` from 3.33.4 to 3.34.0 - [Release notes](https://github.com/cytoscape/cytoscape.js/releases) - [Commits](https://github.com/cytoscape/cytoscape.js/compare/v3.33.4...v3.34.0) Updates `electron` from 42.2.0 to 42.4.1 - [Release notes](https://github.com/electron/electron/releases) - [Commits](https://github.com/electron/electron/compare/v42.2.0...v42.4.1) Updates `electron-builder` from 26.8.1 to 26.15.3 - [Release notes](https://github.com/electron-userland/electron-builder/releases) - [Changelog](https://github.com/electron-userland/electron-builder/blob/master/packages/electron-builder/CHANGELOG.md) - [Commits](https://github.com/electron-userland/electron-builder/commits/electron-builder@26.15.3/packages/electron-builder) Updates `lucide-react` from 1.16.0 to 1.20.0 - [Release notes](https://github.com/lucide-icons/lucide/releases) - [Commits](https://github.com/lucide-icons/lucide/commits/1.20.0/packages/lucide-react) Updates `radix-ui` from 1.4.3 to 1.6.0 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/radix-ui/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/radix-ui) Updates `react-hook-form` from 7.76.1 to 7.79.0 - [Release notes](https://github.com/react-hook-form/react-hook-form/releases) - [Changelog](https://github.com/react-hook-form/react-hook-form/blob/master/CHANGELOG.md) - [Commits](https://github.com/react-hook-form/react-hook-form/compare/v7.76.1...v7.79.0) Updates `sharp` from 0.34.5 to 0.35.1 - [Release notes](https://github.com/lovell/sharp/releases) - [Commits](https://github.com/lovell/sharp/compare/v0.34.5...v0.35.1) Updates `typescript-eslint` from 8.60.0 to 8.61.1 - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.61.1/packages/typescript-eslint) --- updated-dependencies: - dependency-name: "@radix-ui/react-select" dependency-version: 2.3.1 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: "@radix-ui/react-slider" dependency-version: 1.4.1 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: "@radix-ui/react-slot" dependency-version: 1.3.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: "@radix-ui/react-switch" dependency-version: 1.3.1 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: cytoscape dependency-version: 3.34.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: electron dependency-version: 42.4.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: electron-builder dependency-version: 26.15.3 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: lucide-react dependency-version: 1.18.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: radix-ui dependency-version: 1.6.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: react-hook-form dependency-version: 7.79.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: sharp dependency-version: 0.35.1 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: typescript-eslint dependency-version: 8.61.1 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump node in /docker in the docker-major-updates group (#914) Bumps the docker-major-updates group in /docker with 1 update: node. Updates `node` from 24-slim to 26-slim --- updated-dependencies: - dependency-name: node dependency-version: 26-slim dependency-type: direct:production dependency-group: docker-major-updates ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * ci(deps): bump the github-actions group with 2 updates (#915) Bumps the github-actions group with 2 updates: [actions/checkout](https://github.com/actions/checkout) and [actions/setup-node](https://github.com/actions/setup-node). Updates `actions/checkout` from 5 to 6 - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v5...v6) Updates `actions/setup-node` from 4 to 6 - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](https://github.com/actions/setup-node/compare/v4...v6) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: actions/setup-node dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump the prod-minor-updates group across 1 directory with 5 updates (#916) Bumps the prod-minor-updates group with 5 updates in the / directory: | Package | From | To | | --- | --- | --- | | [axios](https://github.com/axios/axios) | `1.17.0` | `1.18.0` | | [better-sqlite3](https://github.com/WiseLibs/better-sqlite3) | `12.10.0` | `12.11.1` | | [body-parser](https://github.com/expressjs/body-parser) | `2.2.2` | `2.3.0` | | [multer](https://github.com/expressjs/multer) | `2.1.1` | `2.2.0` | | [undici](https://github.com/nodejs/undici) | `8.4.0` | `8.5.0` | Updates `axios` from 1.17.0 to 1.18.0 - [Release notes](https://github.com/axios/axios/releases) - [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md) - [Commits](https://github.com/axios/axios/compare/v1.17.0...v1.18.0) Updates `better-sqlite3` from 12.10.0 to 12.11.1 - [Release notes](https://github.com/WiseLibs/better-sqlite3/releases) - [Commits](https://github.com/WiseLibs/better-sqlite3/compare/v12.10.0...v12.11.1) Updates `body-parser` from 2.2.2 to 2.3.0 - [Release notes](https://github.com/expressjs/body-parser/releases) - [Changelog](https://github.com/expressjs/body-parser/blob/master/HISTORY.md) - [Commits](https://github.com/expressjs/body-parser/compare/v2.2.2...v2.3.0) Updates `multer` from 2.1.1 to 2.2.0 - [Release notes](https://github.com/expressjs/multer/releases) - [Changelog](https://github.com/expressjs/multer/blob/main/CHANGELOG.md) - [Commits](https://github.com/expressjs/multer/compare/v2.1.1...v2.2.0) Updates `undici` from 8.4.0 to 8.5.0 - [Release notes](https://github.com/nodejs/undici/releases) - [Commits](https://github.com/nodejs/undici/compare/v8.4.0...v8.5.0) --- updated-dependencies: - dependency-name: axios dependency-version: 1.18.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates - dependency-name: better-sqlite3 dependency-version: 12.11.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates - dependency-name: body-parser dependency-version: 2.3.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates - dependency-name: multer dependency-version: 2.2.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates - dependency-name: undici dependency-version: 8.5.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * feat: add unlink button for dual auth * fix: general bug fixes * fix: general qol/small feature additions * fix: 100mb max upload size * fix: terminal syntax not handling carriage returns or shell prompt lines properly * feat: continued general improvements * fix: terminal outputting success right after folder path * chore: update release notes * feat: continued fixes and improvements * chore: lint, format, and bump version to 2.4.1 * chore: sync Crowdin translations for 2.4.1 * fix: rebase dev branch onto main before Crowdin download * fix: use merge instead of rebase to sync main before Crowdin --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
8072b4ede9
commit
d1a8dcf3c6
165 files changed
+87244
-60766
No files matched your search
@@ -19,6 +19,14 @@ import {
|
||||
getRoles,
|
||||
reorderSnippets,
|
||||
} from "@/main-axios";
|
||||
import {
|
||||
exportSnippets,
|
||||
importSnippets,
|
||||
type SnippetExportData,
|
||||
executeSnippet as apiExecuteSnippet,
|
||||
} from "@/api/snippets-api";
|
||||
import { getSSHHosts } from "@/api/ssh-host-management-api";
|
||||
import type { SSHHost } from "@/types/index";
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
import { Separator } from "@/components/separator";
|
||||
@@ -35,6 +43,7 @@ import {
|
||||
Copy,
|
||||
Cpu,
|
||||
Database,
|
||||
Download,
|
||||
Folder,
|
||||
Globe,
|
||||
GripVertical,
|
||||
@@ -48,9 +57,11 @@ import {
|
||||
Share2,
|
||||
Terminal,
|
||||
Trash2,
|
||||
Upload,
|
||||
UserPlus,
|
||||
X,
|
||||
MoreHorizontal,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -121,18 +132,23 @@ function SnippetFormDialog({
|
||||
folders,
|
||||
snippet,
|
||||
onSave,
|
||||
availableHosts,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
folders: SnippetFolder[];
|
||||
snippet: Snippet | null;
|
||||
onSave: (data: Omit<Snippet, "id" | "order">, id?: number) => void;
|
||||
availableHosts: SSHHost[];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [folder, setFolder] = useState<string | null>(null);
|
||||
const [content, setContent] = useState("");
|
||||
const [selectedHostIds, setSelectedHostIds] = useState<Set<number>>(
|
||||
new Set(),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
@@ -140,9 +156,22 @@ function SnippetFormDialog({
|
||||
setDescription(snippet?.description ?? "");
|
||||
setFolder(snippet?.folder ?? null);
|
||||
setContent(snippet?.content ?? "");
|
||||
setSelectedHostIds(new Set(snippet?.hostIds ?? []));
|
||||
}
|
||||
}, [open, snippet]);
|
||||
|
||||
function toggleHost(id: number) {
|
||||
setSelectedHostIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
if (!name.trim() || !content.trim()) return;
|
||||
onSave(
|
||||
@@ -151,6 +180,8 @@ function SnippetFormDialog({
|
||||
description: description.trim() || undefined,
|
||||
content: content.trim(),
|
||||
folder,
|
||||
hostIds:
|
||||
selectedHostIds.size > 0 ? Array.from(selectedHostIds) : undefined,
|
||||
},
|
||||
snippet?.id,
|
||||
);
|
||||
@@ -234,6 +265,67 @@ function SnippetFormDialog({
|
||||
className="w-full h-36 px-3 py-2 text-xs bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="flex items-center gap-2 text-xs font-semibold text-muted-foreground">
|
||||
<Server className="size-3.5" />
|
||||
{t("newUi.sidebar.snippets.targetHostsLabel")}{" "}
|
||||
<span className="font-normal">
|
||||
({t("newUi.sidebar.snippets.optional")})
|
||||
</span>
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground/70">
|
||||
{t("newUi.sidebar.snippets.targetHostsHint")}
|
||||
</p>
|
||||
{availableHosts.length === 0 ? (
|
||||
<span className="text-xs text-muted-foreground/50">
|
||||
{t("newUi.sidebar.snippets.noHostsAvailable")}
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1 max-h-32 overflow-y-auto border border-border p-1.5">
|
||||
{availableHosts.map((host) => {
|
||||
const selected = selectedHostIds.has(host.id);
|
||||
return (
|
||||
<button
|
||||
key={host.id}
|
||||
type="button"
|
||||
onClick={() => toggleHost(host.id)}
|
||||
className={`flex items-center gap-2 px-2 py-1.5 text-left transition-colors ${
|
||||
selected
|
||||
? "bg-accent-brand/10 text-accent-brand"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`size-3 border-2 flex items-center justify-center shrink-0 transition-colors ${
|
||||
selected
|
||||
? "border-accent-brand bg-accent-brand"
|
||||
: "border-border/60"
|
||||
}`}
|
||||
>
|
||||
{selected && <div className="size-1.5 bg-background" />}
|
||||
</div>
|
||||
<Server className="size-3 shrink-0 opacity-60" />
|
||||
<span className="text-xs font-medium truncate flex-1">
|
||||
{host.name || host.ip}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground/60 shrink-0">
|
||||
{host.ip}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{selectedHostIds.size > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedHostIds(new Set())}
|
||||
className="text-xs text-muted-foreground hover:text-foreground self-start"
|
||||
>
|
||||
{t("newUi.sidebar.snippets.clearTargetHosts")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 mt-2">
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
@@ -503,10 +595,36 @@ type AccessRecord = {
|
||||
};
|
||||
|
||||
type SnippetPayload = Omit<Snippet, "id" | "order">;
|
||||
type RawSnippet = SnippetPayload & {
|
||||
type RawSnippet = Omit<SnippetPayload, "hostIds"> & {
|
||||
id: number;
|
||||
order?: number | null;
|
||||
hostFilter?: string | null;
|
||||
};
|
||||
|
||||
function parseHostFilter(raw: string | null | undefined): number[] | undefined {
|
||||
if (!raw) return undefined;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed) && parsed.every((x) => typeof x === "number")) {
|
||||
return parsed.length > 0 ? parsed : undefined;
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed values
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function mapRawSnippet(s: RawSnippet): Snippet {
|
||||
return {
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
description: s.description,
|
||||
content: s.content,
|
||||
folder: s.folder ?? null,
|
||||
order: s.order ?? 0,
|
||||
hostIds: parseHostFilter(s.hostFilter),
|
||||
};
|
||||
}
|
||||
type RawSnippetFolder = {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -694,32 +812,44 @@ function SnippetCard({
|
||||
snippet,
|
||||
selectedTabIds,
|
||||
terminalTabs,
|
||||
activeTabId,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onShare,
|
||||
onConfirmRun,
|
||||
onDirectExecute,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
onDragOver,
|
||||
dropIndicator,
|
||||
isDragging,
|
||||
availableHosts,
|
||||
t,
|
||||
}: {
|
||||
snippet: Snippet;
|
||||
selectedTabIds: Set<string>;
|
||||
terminalTabs: Tab[];
|
||||
activeTabId: string;
|
||||
onDelete: (id: number) => void;
|
||||
onEdit: (snippet: Snippet) => void;
|
||||
onShare: (snippet: Snippet) => void;
|
||||
onConfirmRun: (snippet: Snippet, execute: () => void) => void;
|
||||
onDirectExecute: (snippet: Snippet) => void;
|
||||
onDragStart: () => void;
|
||||
onDragEnd: () => void;
|
||||
onDragOver: (e: React.DragEvent) => void;
|
||||
dropIndicator: "above" | "below" | null;
|
||||
isDragging: boolean;
|
||||
availableHosts: SSHHost[];
|
||||
t: (key: string, opts?: Record<string, unknown>) => string;
|
||||
}) {
|
||||
const hasTargetHosts = (snippet.hostIds?.length ?? 0) > 0;
|
||||
|
||||
function executeRun() {
|
||||
if (hasTargetHosts) {
|
||||
onDirectExecute(snippet);
|
||||
return;
|
||||
}
|
||||
const targets = terminalTabs.filter((tab) => selectedTabIds.has(tab.id));
|
||||
if (targets.length > 0) {
|
||||
targets.forEach((tab) => {
|
||||
@@ -732,7 +862,9 @@ function SnippetCard({
|
||||
}),
|
||||
);
|
||||
} else if (terminalTabs.length > 0) {
|
||||
terminalTabs[0].terminalRef?.current?.sendInput?.(snippet.content + "\r");
|
||||
const activeTab =
|
||||
terminalTabs.find((tab) => tab.id === activeTabId) ?? terminalTabs[0];
|
||||
activeTab.terminalRef?.current?.sendInput?.(snippet.content + "\r");
|
||||
toast.success(
|
||||
t("newUi.sidebar.snippets.runSuccess", {
|
||||
name: snippet.name,
|
||||
@@ -758,6 +890,10 @@ function SnippetCard({
|
||||
);
|
||||
}
|
||||
|
||||
const targetHosts = hasTargetHosts
|
||||
? availableHosts.filter((h) => snippet.hostIds!.includes(h.id))
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="relative" onDragOver={onDragOver}>
|
||||
{dropIndicator === "above" && (
|
||||
@@ -774,7 +910,7 @@ function SnippetCard({
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<GripVertical className="size-3.5 mt-0.5 shrink-0 text-muted-foreground/30 group-hover/card:text-muted-foreground/60 cursor-grab active:cursor-grabbing transition-colors" />
|
||||
<div className="flex flex-col min-w-0">
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<span className="text-xs font-semibold">{snippet.name}</span>
|
||||
{snippet.description && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
@@ -782,19 +918,44 @@ function SnippetCard({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{hasTargetHosts && (
|
||||
<Zap
|
||||
className="size-3 shrink-0 mt-0.5 text-accent-brand/70"
|
||||
title={t("newUi.sidebar.snippets.hasTargetHosts")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground font-mono px-1">
|
||||
{snippet.content}
|
||||
</span>
|
||||
{targetHosts.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 px-1">
|
||||
{targetHosts.map((host) => (
|
||||
<span
|
||||
key={host.id}
|
||||
className="inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 bg-accent-brand/10 text-accent-brand border border-accent-brand/20"
|
||||
>
|
||||
<Server className="size-2.5" />
|
||||
{host.name || host.ip}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1 text-xs h-7 gap-1.5"
|
||||
className={`flex-1 text-xs h-7 gap-1.5 ${hasTargetHosts ? "border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand" : ""}`}
|
||||
onClick={handleRun}
|
||||
>
|
||||
<Play className="size-3" />
|
||||
{t("newUi.sidebar.snippets.run")}
|
||||
{hasTargetHosts ? (
|
||||
<Zap className="size-3" />
|
||||
) : (
|
||||
<Play className="size-3" />
|
||||
)}
|
||||
{hasTargetHosts
|
||||
? t("newUi.sidebar.snippets.runOnTargets")
|
||||
: t("newUi.sidebar.snippets.run")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -837,6 +998,207 @@ function SnippetCard({
|
||||
);
|
||||
}
|
||||
|
||||
function ImportSnippetsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onImportDone,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
onImportDone: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [overwrite, setOverwrite] = useState(false);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setFile(null);
|
||||
setOverwrite(false);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
setFile(e.target.files?.[0] ?? null);
|
||||
}
|
||||
|
||||
function handleDrop(e: React.DragEvent) {
|
||||
e.preventDefault();
|
||||
const dropped = e.dataTransfer.files?.[0];
|
||||
if (dropped?.name.endsWith(".json")) setFile(dropped);
|
||||
}
|
||||
|
||||
async function handleImport() {
|
||||
if (!file) return;
|
||||
setImporting(true);
|
||||
try {
|
||||
const text = await file.text();
|
||||
let parsed: SnippetExportData;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
toast.error(t("newUi.sidebar.snippets.importInvalidFile"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof parsed !== "object" ||
|
||||
parsed === null ||
|
||||
(!Array.isArray(parsed.snippets) && !Array.isArray(parsed.folders))
|
||||
) {
|
||||
toast.error(t("newUi.sidebar.snippets.importInvalidFile"));
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await importSnippets(parsed, overwrite);
|
||||
toast.success(
|
||||
t("newUi.sidebar.snippets.importSuccess", {
|
||||
snippets: result.snippetsImported,
|
||||
updated: result.snippetsUpdated,
|
||||
skipped: result.snippetsSkipped,
|
||||
folders: result.foldersImported,
|
||||
}),
|
||||
);
|
||||
onImportDone();
|
||||
onOpenChange(false);
|
||||
} catch {
|
||||
toast.error(t("newUi.sidebar.snippets.importFailed"));
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg font-bold">
|
||||
{t("newUi.sidebar.snippets.importTitle")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs text-muted-foreground">
|
||||
{t("newUi.sidebar.snippets.importDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4 mt-1">
|
||||
<div
|
||||
className="flex flex-col items-center justify-center gap-2 border-2 border-dashed border-border px-4 py-8 cursor-pointer hover:border-accent-brand/50 transition-colors"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
>
|
||||
<Upload className="size-6 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground text-center">
|
||||
{file
|
||||
? t("newUi.sidebar.snippets.importSelectedFile", {
|
||||
name: file.name,
|
||||
})
|
||||
: t("newUi.sidebar.snippets.importDropOrClick")}
|
||||
</span>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-xs cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={overwrite}
|
||||
onChange={(e) => setOverwrite(e.target.checked)}
|
||||
className="size-3.5"
|
||||
/>
|
||||
{t("newUi.sidebar.snippets.importOverwrite")}
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 mt-2">
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
{t("newUi.sidebar.snippets.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
onClick={handleImport}
|
||||
disabled={!file || importing}
|
||||
>
|
||||
{t("newUi.sidebar.snippets.importStartBtn")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
interface ExecutionResult {
|
||||
hostLabel: string;
|
||||
success: boolean;
|
||||
output: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function ExecutionResultDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
snippetName,
|
||||
results,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
snippetName: string;
|
||||
results: ExecutionResult[];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg font-bold">
|
||||
{t("newUi.sidebar.snippets.executionResultTitle", {
|
||||
name: snippetName,
|
||||
})}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs text-muted-foreground">
|
||||
{t("newUi.sidebar.snippets.executionResultDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-3 max-h-80 overflow-y-auto">
|
||||
{results.map((r, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex flex-col gap-1 border border-border p-2.5"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="size-3 shrink-0 text-muted-foreground" />
|
||||
<span className="text-xs font-semibold">{r.hostLabel}</span>
|
||||
<span
|
||||
className={`ml-auto text-[10px] px-1.5 py-0.5 ${r.success ? "bg-green-500/10 text-green-500" : "bg-destructive/10 text-destructive"}`}
|
||||
>
|
||||
{r.success
|
||||
? t("newUi.sidebar.snippets.executionSuccess")
|
||||
: t("newUi.sidebar.snippets.executionFailed")}
|
||||
</span>
|
||||
</div>
|
||||
{(r.output || r.error) && (
|
||||
<pre className="text-xs bg-muted/30 p-2 overflow-x-auto whitespace-pre-wrap font-mono text-muted-foreground max-h-40">
|
||||
{r.error ? r.error : r.output}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-end mt-2">
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
{t("newUi.sidebar.snippets.close")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function SnippetsPanel({
|
||||
terminalTabs,
|
||||
activeTabId,
|
||||
@@ -856,6 +1218,13 @@ export function SnippetsPanel({
|
||||
const [editFolder, setEditFolder] = useState<SnippetFolder | null>(null);
|
||||
const [editFolderOpen, setEditFolderOpen] = useState(false);
|
||||
const [shareSnippet, setShareSnippet] = useState<Snippet | null>(null);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [availableHosts, setAvailableHosts] = useState<SSHHost[]>([]);
|
||||
const [executionResultOpen, setExecutionResultOpen] = useState(false);
|
||||
const [executionResults, setExecutionResults] = useState<ExecutionResult[]>(
|
||||
[],
|
||||
);
|
||||
const [executionSnippetName, setExecutionSnippetName] = useState("");
|
||||
const [selectedTabIds, setSelectedTabIds] = useState<Set<string>>(
|
||||
() =>
|
||||
new Set(
|
||||
@@ -984,15 +1353,7 @@ export function SnippetsPanel({
|
||||
getSnippets()
|
||||
.then((data) => {
|
||||
const arr: RawSnippet[] = Array.isArray(data) ? data : [];
|
||||
const mapped: Snippet[] = arr.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
description: s.description,
|
||||
content: s.content,
|
||||
folder: s.folder ?? null,
|
||||
order: s.order ?? 0,
|
||||
}));
|
||||
updateSnippets(mapped);
|
||||
updateSnippets(arr.map(mapRawSnippet));
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
@@ -1009,6 +1370,10 @@ export function SnippetsPanel({
|
||||
setFolders(mapped);
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
getSSHHosts()
|
||||
.then((hosts) => setAvailableHosts(hosts))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
function toggleTab(id: string) {
|
||||
@@ -1027,21 +1392,28 @@ export function SnippetsPanel({
|
||||
data: Omit<Snippet, "id" | "order">,
|
||||
id?: number,
|
||||
) {
|
||||
const { hostIds, ...rest } = data;
|
||||
const payload = {
|
||||
...rest,
|
||||
hostFilter: hostIds && hostIds.length > 0 ? hostIds : null,
|
||||
};
|
||||
try {
|
||||
if (id !== undefined) {
|
||||
await apiUpdateSnippet(id, data);
|
||||
await apiUpdateSnippet(id, payload);
|
||||
updateSnippets((prev) =>
|
||||
prev.map((s) => (s.id === id ? { ...s, ...data } : s)),
|
||||
);
|
||||
toast.success(t("newUi.sidebar.snippets.updateSuccess"));
|
||||
} else {
|
||||
const created = await apiCreateSnippet(data);
|
||||
const created = await apiCreateSnippet(payload);
|
||||
updateSnippets((prev) => [
|
||||
...prev,
|
||||
{
|
||||
...data,
|
||||
id: created.id ?? Math.max(0, ...prev.map((x) => x.id)) + 1,
|
||||
order: created.order ?? prev.length,
|
||||
id:
|
||||
(created.id as number) ??
|
||||
Math.max(0, ...prev.map((x) => x.id)) + 1,
|
||||
order: (created.order as number) ?? prev.length,
|
||||
},
|
||||
]);
|
||||
toast.success(t("newUi.sidebar.snippets.createSuccess"));
|
||||
@@ -1125,6 +1497,47 @@ export function SnippetsPanel({
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExport() {
|
||||
try {
|
||||
const data = await exportSnippets();
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], {
|
||||
type: "application/json",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "snippets-export.json";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success(t("newUi.sidebar.snippets.exportSuccess"));
|
||||
} catch {
|
||||
toast.error(t("newUi.sidebar.snippets.exportFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
function reloadData() {
|
||||
const collapsed = getFoldersCollapsed();
|
||||
getSnippets()
|
||||
.then((data) => {
|
||||
const arr: RawSnippet[] = Array.isArray(data) ? data : [];
|
||||
updateSnippets(arr.map(mapRawSnippet));
|
||||
})
|
||||
.catch(() => {});
|
||||
getSnippetFolders()
|
||||
.then((data) => {
|
||||
const arr: RawSnippetFolder[] = Array.isArray(data) ? data : [];
|
||||
const mapped: SnippetFolder[] = arr.map((f) => ({
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
color: f.color ?? FOLDER_COLORS[0],
|
||||
icon: (f.icon as FolderIconId) ?? "folder",
|
||||
open: !collapsed,
|
||||
}));
|
||||
setFolders(mapped);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
async function handleDeleteSnippet(id: number) {
|
||||
try {
|
||||
await apiDeleteSnippet(id);
|
||||
@@ -1139,6 +1552,56 @@ export function SnippetsPanel({
|
||||
setSnippetFormOpen(true);
|
||||
}
|
||||
|
||||
async function handleDirectExecute(snippet: Snippet) {
|
||||
const hostIds = snippet.hostIds ?? [];
|
||||
if (hostIds.length === 0) return;
|
||||
|
||||
setExecutionSnippetName(snippet.name);
|
||||
setExecutionResults([]);
|
||||
setExecutionResultOpen(true);
|
||||
|
||||
const results: ExecutionResult[] = await Promise.all(
|
||||
hostIds.map(async (hostId) => {
|
||||
const host = availableHosts.find((h) => h.id === hostId);
|
||||
const hostLabel = host ? host.name || host.ip : String(hostId);
|
||||
try {
|
||||
const result = await apiExecuteSnippet(snippet.id, hostId);
|
||||
return {
|
||||
hostLabel,
|
||||
success: result.success,
|
||||
output: result.output,
|
||||
error: result.error,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
hostLabel,
|
||||
success: false,
|
||||
output: "",
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
setExecutionResults(results);
|
||||
|
||||
const allOk = results.every((r) => r.success);
|
||||
if (allOk) {
|
||||
toast.success(
|
||||
t("newUi.sidebar.snippets.directRunSuccess", {
|
||||
name: snippet.name,
|
||||
count: results.length,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
toast.error(
|
||||
t("newUi.sidebar.snippets.directRunPartialFail", {
|
||||
name: snippet.name,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const filtered = snippetSearch
|
||||
? snippets.filter(
|
||||
(s) =>
|
||||
@@ -1265,6 +1728,24 @@ export function SnippetsPanel({
|
||||
{t("newUi.sidebar.snippets.newFolder")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex gap-2 min-w-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="flex-1 text-xs min-w-0 overflow-hidden text-muted-foreground hover:text-foreground"
|
||||
onClick={handleExport}
|
||||
>
|
||||
<Download className="size-3.5 shrink-0" />
|
||||
{t("newUi.sidebar.snippets.exportBtn")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="flex-1 text-xs min-w-0 overflow-hidden text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setImportOpen(true)}
|
||||
>
|
||||
<Upload className="size-3.5 shrink-0" />
|
||||
{t("newUi.sidebar.snippets.importBtn")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
{(!snippetSearch || uncategorizedSnippets.length > 0) && (
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -1295,10 +1776,12 @@ export function SnippetsPanel({
|
||||
snippet={snippet}
|
||||
selectedTabIds={selectedTabIds}
|
||||
terminalTabs={terminalTabs}
|
||||
activeTabId={activeTabId}
|
||||
onDelete={handleDeleteSnippet}
|
||||
onEdit={handleEditSnippet}
|
||||
onShare={setShareSnippet}
|
||||
onConfirmRun={handleConfirmRun}
|
||||
onDirectExecute={handleDirectExecute}
|
||||
onDragStart={() => handleDragStart(snippet)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={(e) => handleDragOver(e, snippet.id)}
|
||||
@@ -1308,6 +1791,7 @@ export function SnippetsPanel({
|
||||
: null
|
||||
}
|
||||
isDragging={draggedSnippet?.id === snippet.id}
|
||||
availableHosts={availableHosts}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
@@ -1388,10 +1872,12 @@ export function SnippetsPanel({
|
||||
snippet={snippet}
|
||||
selectedTabIds={selectedTabIds}
|
||||
terminalTabs={terminalTabs}
|
||||
activeTabId={activeTabId}
|
||||
onDelete={handleDeleteSnippet}
|
||||
onEdit={handleEditSnippet}
|
||||
onShare={setShareSnippet}
|
||||
onConfirmRun={handleConfirmRun}
|
||||
onDirectExecute={handleDirectExecute}
|
||||
onDragStart={() => handleDragStart(snippet)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={(e) => handleDragOver(e, snippet.id)}
|
||||
@@ -1401,6 +1887,7 @@ export function SnippetsPanel({
|
||||
: null
|
||||
}
|
||||
isDragging={draggedSnippet?.id === snippet.id}
|
||||
availableHosts={availableHosts}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
@@ -1426,6 +1913,7 @@ export function SnippetsPanel({
|
||||
folders={folders}
|
||||
snippet={editingSnippet}
|
||||
onSave={handleSaveSnippet}
|
||||
availableHosts={availableHosts}
|
||||
/>
|
||||
<CreateFolderDialog
|
||||
open={createFolderOpen}
|
||||
@@ -1445,6 +1933,17 @@ export function SnippetsPanel({
|
||||
snippet={shareSnippet}
|
||||
onClose={() => setShareSnippet(null)}
|
||||
/>
|
||||
<ImportSnippetsDialog
|
||||
open={importOpen}
|
||||
onOpenChange={setImportOpen}
|
||||
onImportDone={reloadData}
|
||||
/>
|
||||
<ExecutionResultDialog
|
||||
open={executionResultOpen}
|
||||
onOpenChange={setExecutionResultOpen}
|
||||
snippetName={executionSnippetName}
|
||||
results={executionResults}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user