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:
Luke Gustafsonanddependabot[bot] authored and GitHub committed 2026-06-21 15:55:41 -05:00
1 parent 8072b4ede9
commit d1a8dcf3c6
165 files changed
+87244 -60766

No files matched your search

+518 -19
View File
@@ -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}
/>
</>
);
}