mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 22:25:24 +00:00
release-2.7.1 (#1296)
* Add Helm and GitOps deployment setup * fix: build better-sqlite3 from source in Docker (#1267) * fix: preserve runtime SSL settings (#1268) * fix: support forwarding from the memory SSH agent (#1269) * fix: support forwarding from the memory agent * style: format memory agent test * fix: prompt for encrypted SFTP key passphrases (#1270) * fix: prompt for SFTP key passphrases * style: format SSH key utility test * fix: include host context in automation notifications (#1271) * fix: include host context in automation notifications * style: format automation notification changes * fix: reserve sidebar height for host tags (#1272) * fix: keep host action rows stable at large font sizes (#1273) * fix: honor certificate setting during server probe (#1274) * fix: package standard Linux icon sizes (#1275) * fix: avoid duplicate Docker HTTPS listener (#1276) * Fix host status without metrics collection (#1277) * fix: allow eight-digit secure auth codes (#1263) Allow TOTP prompts to accept secure auth codes longer than six digits without blocking valid authentication attempts. Generated with Codebuff 🤖 Co-authored-by: Chetan <chetan.development@gmail.com> Co-authored-by: Codebuff <noreply@codebuff.com> * Harden Helm deployment defaults * Update Helm workflow action * Exclude Helm templates from Prettier * Fix browser RDP file drops (#1279) * Fix Proxmox guest credential usernames (#1280) * Add WSL local terminal option (#1281) * refactor: split the transfer engine into focused modules (#1282) * refactor: extract SFTP promisify helpers into sftp-promisify module * refactor: extract transfer timing and rate stats into transfer-stats module * refactor: extract transfer error classes and recovery checks into transfer-errors module * refactor: extract host/path utility helpers into transfer-host-utils module * refactor: extract SFTP directory tree helpers into transfer-sftp-dir module * refactor: extract segment copy job builder into transfer-segment-copy module * refactor: extract file scan and sample helpers into transfer-scan module * refactor: move throttled progress helper into transfer-stats module * style: format transfer modules * perf: optimize tmux monitor aggregation (#1283) * fix: reserve credential tag row height (#1284) * feat: edit AI provider model settings (#1285) * fix: clarify click-to-expand host setting (#1286) * fix: allow portable imports on remote databases (#1287) * fix: allow HTTPS to share the configured port (#1288) * fix: resolve synced jump hosts on the server (#1289) * fix: make terminal clipboard shortcuts layout independent (#1290) * fix: use compatible fetch dispatcher for Tailscale (#1291) * fix: add OIDC environment recovery override (#1292) * fix: coalesce rapid mobile terminal input (#1293) * fix: coalesce rapid mobile terminal input * fix: support clean xterm patch installs * fix: resolve synced remote desktop host IDs (#1295) * feat: make the SFTP file manager path bar editable (#1294) Co-authored-by: Maxime Bonillo <257463937+dropafterfree@users.noreply.github.com> Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> * feat: add passkey sign in to the login screen * fix: remove rounded corners from the host list search bar * fix: stop image storage settings text wrapping to one word per line * fix: prevent malformed websocket messages from crashing the server * chore: increment version * fix: remove gaps between host rows in the sidebar list Keep sub-pixel row measurements and stop wiping the size cache on hover. * fix: Failed to connect through jump hosts (#1180) https://github.com/Termix-SSH/Support/issues/1180 * feat: Progress bar for file downloads in the file manager (#1158) https://github.com/Termix-SSH/Support/issues/1158 * feat: Allow setting Silent OIDC Login via ENV var (#1174) https://github.com/Termix-SSH/Support/issues/1174 * feat: `IdentityFile` to limit the number of attempts by agents (#1165) https://github.com/Termix-SSH/Support/issues/1165 * feat: Credentials clone (#1159) https://github.com/Termix-SSH/Support/issues/1159 * chore: update release notes * docs: move helm setup guide to the docs site * fix: type errors in FilteredAgent agent identity handling * fix: remove stale better-sqlite3 prebuilds so the source build is used * fix: actually build better-sqlite3 from source so arm64 docker images work * fix: credential edit pencil in host editor and add clone action to credential list * fix: clear editingHost so the credential pencil actually opens the editor * chore: run format and lint * fix: folder drag and drop upload failing in the file manager * chore: sync Crowdin translations for 2.7.1 --------- Co-authored-by: alex-ctms <alex-ctms@users.noreply.github.com> Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> Co-authored-by: Chetan Kumar <74929596+ckloop@users.noreply.github.com> Co-authored-by: Chetan <chetan.development@gmail.com> Co-authored-by: Codebuff <noreply@codebuff.com> Co-authored-by: ZacharyZcR <payasonorahc@protonmail.com> Co-authored-by: dropafterfree <maxime.bonillo@gmail.com> Co-authored-by: Maxime Bonillo <257463937+dropafterfree@users.noreply.github.com>
This commit is contained in:
165 files changed
+6778
-2090
No files matched your search
@@ -2,7 +2,6 @@ import { useTranslation } from "react-i18next";
|
||||
import { ImageIcon } from "lucide-react";
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
import { SettingRow } from "@/components/section-card";
|
||||
import type {
|
||||
TerminalImageStorageMode,
|
||||
TerminalImageStorageSettings,
|
||||
@@ -68,12 +67,17 @@ export function AdminImageStorageSection({
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-0 pt-2">
|
||||
<SettingRow
|
||||
label={t("admin.imageStorageMode")}
|
||||
description={t("admin.imageStorageModeDesc")}
|
||||
>
|
||||
<div className="flex gap-1">
|
||||
<div className="@container flex flex-col gap-0 pt-2">
|
||||
<div className="flex flex-col gap-2 py-3 border-b border-border">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-sm font-medium leading-snug">
|
||||
{t("admin.imageStorageMode")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground leading-snug">
|
||||
{t("admin.imageStorageModeDesc")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(Object.keys(MODE_LABEL_KEYS) as TerminalImageStorageMode[]).map(
|
||||
(mode) => (
|
||||
<button
|
||||
@@ -81,16 +85,16 @@ export function AdminImageStorageSection({
|
||||
type="button"
|
||||
aria-pressed={settings.mode === mode}
|
||||
onClick={() => setSettings({ ...settings, mode })}
|
||||
className={`px-2 py-1 text-[10px] font-semibold border transition-colors ${settings.mode === mode ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
|
||||
className={`whitespace-nowrap px-2 py-1 text-[10px] font-semibold border transition-colors ${settings.mode === mode ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
|
||||
>
|
||||
{t(MODE_LABEL_KEYS[mode])}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</SettingRow>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 py-3 sm:grid-cols-2 border-b border-border">
|
||||
<div className="grid grid-cols-1 gap-x-4 gap-y-3 py-3 @md:grid-cols-2 border-b border-border">
|
||||
<label className="flex flex-col gap-1 text-xs">
|
||||
<span className="font-medium text-foreground">
|
||||
{t("admin.imageStorageLocalDir")}
|
||||
@@ -178,17 +182,22 @@ export function AdminImageStorageSection({
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<SettingRow
|
||||
label={t("admin.imageStorageInstanceId")}
|
||||
description={t("admin.imageStorageInstanceIdDesc")}
|
||||
>
|
||||
<div className="flex flex-col gap-2 py-3 border-b border-border">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-sm font-medium leading-snug">
|
||||
{t("admin.imageStorageInstanceId")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground leading-snug">
|
||||
{t("admin.imageStorageInstanceIdDesc")}
|
||||
</span>
|
||||
</div>
|
||||
<Input
|
||||
aria-label={t("admin.imageStorageInstanceId")}
|
||||
value={instanceId}
|
||||
onChange={(event) => setInstanceId(event.target.value)}
|
||||
className="h-8 w-48 text-xs"
|
||||
className="h-8 w-full @md:w-64 text-xs"
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
|
||||
{testResult && (
|
||||
<div className="py-2 text-xs text-muted-foreground border-b border-border">
|
||||
|
||||
@@ -163,6 +163,8 @@ export function AdminSettingsPanel({
|
||||
const [commandHistoryEnabled, setCommandHistoryEnabled] = useState(true);
|
||||
const [analyticsEnabled, setAnalyticsEnabled] = useState(true);
|
||||
const [analyticsLocked, setAnalyticsLocked] = useState(false);
|
||||
const [oidcSilentLoginDefaultLocked, setOidcSilentLoginDefaultLocked] =
|
||||
useState(false);
|
||||
const [sessionSharingGloballyEnabled, setSessionSharingGloballyEnabled] =
|
||||
useState(true);
|
||||
const [aiGloballyEnabled, setAiGloballyEnabled] = useState(false);
|
||||
@@ -389,8 +391,10 @@ export function AdminSettingsPanel({
|
||||
setAllowPasswordLogin(pwLogin.value.allowed);
|
||||
if (oidcProv.status === "fulfilled")
|
||||
setOidcAutoProvision(oidcProv.value.enabled);
|
||||
if (oidcSilent.status === "fulfilled")
|
||||
if (oidcSilent.status === "fulfilled") {
|
||||
setOidcSilentLoginDefault(oidcSilent.value.enabled);
|
||||
setOidcSilentLoginDefaultLocked(oidcSilent.value.locked ?? false);
|
||||
}
|
||||
if (pwReset.status === "fulfilled") setAllowPasswordReset(pwReset.value);
|
||||
if (timeout.status === "fulfilled")
|
||||
setSessionTimeout(String(timeout.value.timeoutHours));
|
||||
@@ -509,6 +513,7 @@ export function AdminSettingsPanel({
|
||||
}
|
||||
|
||||
async function handleToggleOidcSilentLoginDefault() {
|
||||
if (oidcSilentLoginDefaultLocked) return;
|
||||
const newVal = !oidcSilentLoginDefault;
|
||||
setOidcSilentLoginDefault(newVal);
|
||||
try {
|
||||
@@ -1124,6 +1129,7 @@ export function AdminSettingsPanel({
|
||||
oidcAutoProvision={oidcAutoProvision}
|
||||
handleToggleOidcAutoProvision={handleToggleOidcAutoProvision}
|
||||
oidcSilentLoginDefault={oidcSilentLoginDefault}
|
||||
oidcSilentLoginDefaultLocked={oidcSilentLoginDefaultLocked}
|
||||
handleToggleOidcSilentLoginDefault={handleToggleOidcSilentLoginDefault}
|
||||
allowPasswordReset={allowPasswordReset}
|
||||
handleTogglePasswordReset={handleTogglePasswordReset}
|
||||
|
||||
@@ -39,6 +39,7 @@ type GeneralSettingsSectionProps = {
|
||||
oidcAutoProvision: boolean;
|
||||
handleToggleOidcAutoProvision: () => void;
|
||||
oidcSilentLoginDefault: boolean;
|
||||
oidcSilentLoginDefaultLocked: boolean;
|
||||
handleToggleOidcSilentLoginDefault: () => void;
|
||||
allowPasswordReset: boolean;
|
||||
handleTogglePasswordReset: () => void;
|
||||
@@ -87,6 +88,7 @@ export function AdminGeneralSettingsSection({
|
||||
oidcAutoProvision,
|
||||
handleToggleOidcAutoProvision,
|
||||
oidcSilentLoginDefault,
|
||||
oidcSilentLoginDefaultLocked,
|
||||
handleToggleOidcSilentLoginDefault,
|
||||
allowPasswordReset,
|
||||
handleTogglePasswordReset,
|
||||
@@ -211,11 +213,16 @@ export function AdminGeneralSettingsSection({
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("admin.oidcSilentLoginDefault")}
|
||||
description={t("admin.oidcSilentLoginDefaultDesc")}
|
||||
description={
|
||||
oidcSilentLoginDefaultLocked
|
||||
? t("admin.oidcSilentLoginDefaultLockedDesc")
|
||||
: t("admin.oidcSilentLoginDefaultDesc")
|
||||
}
|
||||
>
|
||||
<AdminToggle
|
||||
on={oidcSilentLoginDefault}
|
||||
onToggle={handleToggleOidcSilentLoginDefault}
|
||||
disabled={oidcSilentLoginDefaultLocked}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
|
||||
@@ -13,10 +13,11 @@ import {
|
||||
generateKeyPair,
|
||||
generatePublicKeyFromPrivate,
|
||||
updateCredential,
|
||||
duplicateCredential,
|
||||
adminCreateUserCredential,
|
||||
adminUpdateUserCredential,
|
||||
} from "@/main-axios";
|
||||
import type { Credential } from "@/types/ui-types";
|
||||
import type { Credential, Host } from "@/types/ui-types";
|
||||
import { FolderPathPicker } from "./FolderPathPicker";
|
||||
|
||||
export function CredentialEditorView({
|
||||
@@ -26,13 +27,20 @@ export function CredentialEditorView({
|
||||
onSave,
|
||||
adminTargetUserId,
|
||||
existingFolders = [],
|
||||
saveAsNewHost,
|
||||
}: {
|
||||
credential: Credential | null;
|
||||
activeTab: string;
|
||||
onBack: () => void;
|
||||
onSave: (saved: Record<string, unknown>) => void;
|
||||
onSave: (
|
||||
saved: Record<string, unknown>,
|
||||
options?: { assignToHost?: boolean },
|
||||
) => void;
|
||||
adminTargetUserId?: string;
|
||||
existingFolders?: string[];
|
||||
// When set, this credential is being edited from within a host's editor;
|
||||
// shows a "Save as New" action that clones it and reassigns the host.
|
||||
saveAsNewHost?: Host | "new";
|
||||
}) {
|
||||
const [credForm, setCredForm] = useState(() => ({
|
||||
name: credential?.name ?? "",
|
||||
@@ -61,21 +69,14 @@ export function CredentialEditorView({
|
||||
v: (typeof credForm)[K],
|
||||
) => setCredForm((p) => ({ ...p, [k]: v }));
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [savingAsNew, setSavingAsNew] = useState(false);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!credForm.name.trim()) {
|
||||
toast.error(t("hosts.credentialNameRequired"));
|
||||
return;
|
||||
}
|
||||
const buildCredentialData = () => {
|
||||
const hasKey =
|
||||
credForm.value === "existing_key" || credForm.value.trim() !== "";
|
||||
if (!hasKey && !credForm.password) {
|
||||
toast.error(t("hosts.credentialAuthRequired"));
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const data = {
|
||||
return {
|
||||
hasKey,
|
||||
data: {
|
||||
name: credForm.name,
|
||||
username: credForm.username,
|
||||
folder: credForm.folder || null,
|
||||
@@ -95,7 +96,29 @@ export function CredentialEditorView({
|
||||
? undefined
|
||||
: credForm.passphrase || null
|
||||
: null,
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
if (!credForm.name.trim()) {
|
||||
toast.error(t("hosts.credentialNameRequired"));
|
||||
return false;
|
||||
}
|
||||
const hasKey =
|
||||
credForm.value === "existing_key" || credForm.value.trim() !== "";
|
||||
if (!hasKey && !credForm.password) {
|
||||
toast.error(t("hosts.credentialAuthRequired"));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!validateForm()) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const { data } = buildCredentialData();
|
||||
let saved: Record<string, unknown>;
|
||||
if (adminTargetUserId) {
|
||||
saved = credential
|
||||
@@ -127,6 +150,24 @@ export function CredentialEditorView({
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveAsNew = async () => {
|
||||
if (!credential || adminTargetUserId) return;
|
||||
if (!validateForm()) return;
|
||||
setSavingAsNew(true);
|
||||
try {
|
||||
const { data } = buildCredentialData();
|
||||
const saved = await duplicateCredential(Number(credential.id), data);
|
||||
toast.success(t("hosts.credentialSavedAsNew"));
|
||||
window.dispatchEvent(new CustomEvent("termix:credentials-changed"));
|
||||
onSave(saved, { assignToHost: true });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : null;
|
||||
toast.error(msg || t("hosts.failedToSaveCredentialAsNew"));
|
||||
} finally {
|
||||
setSavingAsNew(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{activeTab === "general" && (
|
||||
@@ -494,14 +535,31 @@ export function CredentialEditorView({
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3 mt-3">
|
||||
<Button variant="ghost" onClick={onBack} disabled={saving}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onBack}
|
||||
disabled={saving || savingAsNew}
|
||||
>
|
||||
{t("hosts.cancelBtn")}
|
||||
</Button>
|
||||
{saveAsNewHost && credential && !adminTargetUserId && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="px-6"
|
||||
onClick={handleSaveAsNew}
|
||||
disabled={saving || savingAsNew}
|
||||
title={t("hosts.saveCredentialAsNewDesc")}
|
||||
>
|
||||
{savingAsNew
|
||||
? t("hosts.savingBtn")
|
||||
: t("hosts.saveCredentialAsNewBtn")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand px-8"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
disabled={saving || savingAsNew}
|
||||
>
|
||||
{saving
|
||||
? t("hosts.savingBtn")
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
LayoutGrid,
|
||||
Network,
|
||||
Palette,
|
||||
Pencil,
|
||||
Plus,
|
||||
Settings,
|
||||
Shield,
|
||||
@@ -114,6 +115,7 @@ export function HostEditor({
|
||||
credentials,
|
||||
adminTargetUserId,
|
||||
simpleMode = false,
|
||||
onEditCredential,
|
||||
}: {
|
||||
host: Host | null;
|
||||
activeTab: string;
|
||||
@@ -130,6 +132,9 @@ export function HostEditor({
|
||||
// When set, the editor works on another user's host through the admin
|
||||
// impersonation endpoints instead of the signed-in user's own data.
|
||||
adminTargetUserId?: string;
|
||||
// Opens the currently-selected credential for editing (from within the
|
||||
// host editor), so the user can tweak/rotate it without leaving the flow.
|
||||
onEditCredential?: (credentialId: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { setPreviewTerminalTheme } = useTabsSafe();
|
||||
@@ -355,7 +360,7 @@ export function HostEditor({
|
||||
? [...credentials, quickCreatedCredential]
|
||||
: credentials;
|
||||
const selectedCredential = availableCredentials.find(
|
||||
(c) => c.id === form.credentialId,
|
||||
(c) => String(c.id) === String(form.credentialId),
|
||||
);
|
||||
|
||||
const canQuickCreateCredential =
|
||||
@@ -899,38 +904,54 @@ export function HostEditor({
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.storedCredential")}
|
||||
</label>
|
||||
<select
|
||||
value={form.credentialId}
|
||||
disabled={lockAuthReferences}
|
||||
title={
|
||||
lockAuthReferences
|
||||
? t("hosts.sharing.ownerOnlyControl")
|
||||
: undefined
|
||||
}
|
||||
onChange={(e) => {
|
||||
const newId = e.target.value;
|
||||
setField("credentialId", newId);
|
||||
if (!form.overrideCredentialUsername) {
|
||||
const cred = availableCredentials.find(
|
||||
(c) => c.id === newId,
|
||||
);
|
||||
if (cred?.username)
|
||||
setField("username", cred.username);
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={form.credentialId}
|
||||
disabled={lockAuthReferences}
|
||||
title={
|
||||
lockAuthReferences
|
||||
? t("hosts.sharing.ownerOnlyControl")
|
||||
: undefined
|
||||
}
|
||||
}}
|
||||
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<option value="">
|
||||
{t("hosts.selectACredential")}
|
||||
</option>
|
||||
{availableCredentials.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.username
|
||||
? `${c.name} (${c.username})`
|
||||
: c.name}
|
||||
onChange={(e) => {
|
||||
const newId = e.target.value;
|
||||
setField("credentialId", newId);
|
||||
if (!form.overrideCredentialUsername) {
|
||||
const cred = availableCredentials.find(
|
||||
(c) => c.id === newId,
|
||||
);
|
||||
if (cred?.username)
|
||||
setField("username", cred.username);
|
||||
}
|
||||
}}
|
||||
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<option value="">
|
||||
{t("hosts.selectACredential")}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{availableCredentials.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.username
|
||||
? `${c.name} (${c.username})`
|
||||
: c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{onEditCredential && form.credentialId && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9 px-2.5 shrink-0"
|
||||
title={t("hosts.editCredentialAction")}
|
||||
onClick={() =>
|
||||
onEditCredential(form.credentialId)
|
||||
}
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{selectedCredential?.username && (
|
||||
<div className="flex items-center justify-between col-span-2 pt-1">
|
||||
@@ -1104,6 +1125,21 @@ export function HostEditor({
|
||||
{t("hosts.agentSocketPathHint")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.agentIdentityLabel")}
|
||||
</label>
|
||||
<Input
|
||||
placeholder={t("hosts.agentIdentityPlaceholder")}
|
||||
value={form.agentIdentity}
|
||||
onChange={(e) =>
|
||||
setField("agentIdentity", e.target.value)
|
||||
}
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t("hosts.agentIdentityHint")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<SettingRow
|
||||
|
||||
@@ -140,8 +140,11 @@ export function createHostEditorForm(
|
||||
keyType: host?.keyType ?? "auto",
|
||||
keySubTab: "paste" as "paste" | "upload",
|
||||
credentialId:
|
||||
host?.credentialId ??
|
||||
(d?.credentialId != null ? String(d.credentialId) : ""),
|
||||
host?.credentialId != null
|
||||
? String(host.credentialId)
|
||||
: d?.credentialId != null
|
||||
? String(d.credentialId)
|
||||
: "",
|
||||
vaultProfileId: host?.vaultProfileId ?? "",
|
||||
overrideCredentialUsername: host?.overrideCredentialUsername ?? false,
|
||||
folder: host?.folder ?? "",
|
||||
@@ -243,6 +246,7 @@ export function createHostEditorForm(
|
||||
linkClickBehavior: (host?.terminalConfig?.linkClickBehavior ??
|
||||
"default") as "default" | "confirm" | "direct",
|
||||
agentSocketPath: host?.terminalConfig?.agentSocketPath ?? "",
|
||||
agentIdentity: host?.terminalConfig?.agentIdentity ?? "",
|
||||
useSSHTitle: host?.terminalConfig?.useSSHTitle ?? false,
|
||||
syntaxHighlighting: host?.terminalConfig?.syntaxHighlighting ?? true,
|
||||
syntaxHighlightingOptions: {
|
||||
@@ -409,6 +413,7 @@ export function buildHostEditorPayload(
|
||||
form.linkClickBehavior !== "default" ? form.linkClickBehavior : undefined,
|
||||
localEcho: form.localEcho !== "default" ? form.localEcho : undefined,
|
||||
agentSocketPath: usesAgent ? form.agentSocketPath || null : null,
|
||||
agentIdentity: usesAgent ? form.agentIdentity || null : null,
|
||||
};
|
||||
const terminalOverrides = form.inheritTerminalAppearance
|
||||
? stripKeys(terminalConfig, terminalAppearanceKeys)
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
getCredentials,
|
||||
getCredentialDetails,
|
||||
deleteCredential,
|
||||
duplicateCredential,
|
||||
updateCredential,
|
||||
deployCredentialToHost,
|
||||
renameCredentialFolder,
|
||||
@@ -111,6 +112,11 @@ export function HostManager({
|
||||
string | null
|
||||
>(null);
|
||||
const [editingCredFolderValue, setEditingCredFolderValue] = useState("");
|
||||
// Remembers the host being edited when its credential is opened for
|
||||
// editing, so "back" can return to that host instead of the list.
|
||||
const [credentialReturnHost, setCredentialReturnHost] = useState<
|
||||
Host | "new" | null
|
||||
>(null);
|
||||
const [termixIdLinkedIds, setTermixIdLinkedIds] = useState<Set<number>>(
|
||||
new Set(),
|
||||
);
|
||||
@@ -312,6 +318,21 @@ export function HostManager({
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloneCredential = async (cred: Credential) => {
|
||||
try {
|
||||
await duplicateCredential(Number(cred.id), {
|
||||
name: t("credentials.clonedCredentialName", { name: cred.name }),
|
||||
});
|
||||
const res = await getCredentials();
|
||||
setCredentials(mapCredentials(res));
|
||||
window.dispatchEvent(new CustomEvent("termix:credentials-changed"));
|
||||
toast.success(t("credentials.clonedCredential", { name: cred.name }));
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : null;
|
||||
toast.error(msg || t("credentials.failedToCloneCredential"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteCredential = async (cred: Credential) => {
|
||||
await deleteCredential(Number(cred.id));
|
||||
setCredentials((prev) => prev.filter((c) => c.id !== cred.id));
|
||||
@@ -355,6 +376,18 @@ export function HostManager({
|
||||
}
|
||||
}
|
||||
|
||||
// Opens a host's credential for editing from within the host editor,
|
||||
// remembering the host so "back" returns to it instead of the list.
|
||||
async function handleEditCredentialFromHost(credentialId: string) {
|
||||
const cred = credentials.find((c) => String(c.id) === String(credentialId));
|
||||
if (!cred) return;
|
||||
setCredentialReturnHost(editingHost);
|
||||
await handleEditCredential(cred);
|
||||
// The editor view keys off editingHost, so it has to be cleared or the
|
||||
// host editor keeps rendering over the credential we just opened.
|
||||
setEditingHost(null);
|
||||
}
|
||||
|
||||
// Editor view: full-width with top tab bar instead of side nav
|
||||
const closeHostEditor = () => {
|
||||
setHostEditorDirty(false);
|
||||
@@ -423,6 +456,11 @@ export function HostManager({
|
||||
onClick={() => {
|
||||
if (isHost) {
|
||||
requestCloseHostEditor();
|
||||
} else if (credentialReturnHost) {
|
||||
setEditingHost(credentialReturnHost);
|
||||
setCredentialReturnHost(null);
|
||||
setEditingCredential(null);
|
||||
setActiveCredentialTab("general");
|
||||
} else {
|
||||
setEditingCredential(null);
|
||||
setActiveCredentialTab("general");
|
||||
@@ -434,7 +472,9 @@ export function HostManager({
|
||||
<span>
|
||||
{isHost
|
||||
? t("hosts.backToHosts")
|
||||
: t("credentials.backToCredentials")}
|
||||
: credentialReturnHost
|
||||
? t("hosts.backToHost")
|
||||
: t("credentials.backToCredentials")}
|
||||
</span>
|
||||
{isHost && editingHost !== "new" && (
|
||||
<span
|
||||
@@ -525,6 +565,7 @@ export function HostManager({
|
||||
onTabChange={setActiveHostTab}
|
||||
hosts={hosts}
|
||||
credentials={credentials}
|
||||
onEditCredential={handleEditCredentialFromHost}
|
||||
/>
|
||||
) : (
|
||||
<CredentialEditorView
|
||||
@@ -546,11 +587,21 @@ export function HostManager({
|
||||
.filter((f): f is string => !!f),
|
||||
),
|
||||
).sort()}
|
||||
saveAsNewHost={
|
||||
credentialReturnHost ? credentialReturnHost : undefined
|
||||
}
|
||||
onBack={() => {
|
||||
if (credentialReturnHost) {
|
||||
setEditingHost(credentialReturnHost);
|
||||
setCredentialReturnHost(null);
|
||||
setEditingCredential(null);
|
||||
setActiveCredentialTab("general");
|
||||
return;
|
||||
}
|
||||
setEditingCredential(null);
|
||||
setActiveCredentialTab("general");
|
||||
}}
|
||||
onSave={(saved) => {
|
||||
onSave={(saved, options) => {
|
||||
// The save endpoints are typed as an untyped record; these are
|
||||
// the fields this view reads back off the response.
|
||||
const result = saved as Partial<Credential> & {
|
||||
@@ -579,6 +630,27 @@ export function HostManager({
|
||||
}
|
||||
return [...prev, updated];
|
||||
});
|
||||
if (options?.assignToHost && credentialReturnHost) {
|
||||
const returnHost = credentialReturnHost;
|
||||
setCredentialReturnHost(null);
|
||||
if (returnHost !== "new") {
|
||||
setHosts((prev) =>
|
||||
prev.map((h) =>
|
||||
h.id === returnHost.id
|
||||
? { ...h, credentialId: String(result.id) }
|
||||
: h,
|
||||
),
|
||||
);
|
||||
}
|
||||
setEditingHost(
|
||||
returnHost === "new"
|
||||
? "new"
|
||||
: { ...returnHost, credentialId: String(result.id) },
|
||||
);
|
||||
setActiveHostTab("ssh");
|
||||
setEditingCredential(null);
|
||||
return;
|
||||
}
|
||||
setEditingCredential(null);
|
||||
setActiveCredentialTab("general");
|
||||
}}
|
||||
@@ -662,6 +734,7 @@ export function HostManager({
|
||||
setDeployDialog({ cred, hostId: "" })
|
||||
}
|
||||
onEditCredential={handleEditCredential}
|
||||
onCloneCredential={handleCloneCredential}
|
||||
onDeleteCredential={handleConfirmDeleteCredential}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -405,7 +405,7 @@ export function HostsPanel({
|
||||
<div className="relative flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||
{!managerEditing && (
|
||||
<div className="flex flex-col px-2 py-1.5 shrink-0 border-b border-border/60 gap-1.5">
|
||||
<div className="flex items-center gap-2 px-2.5 h-7 bg-muted/60 border border-border/60 rounded-sm">
|
||||
<div className="flex items-center gap-2 px-2.5 h-7 bg-muted/60 border border-border/60 rounded-none">
|
||||
<Search className="size-3 text-muted-foreground/60 shrink-0" />
|
||||
<input
|
||||
value={hostSearch}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ChevronRight,
|
||||
Copy,
|
||||
CopyPlus,
|
||||
GripVertical,
|
||||
Pencil,
|
||||
Pin,
|
||||
@@ -68,6 +69,7 @@ export function CredentialItem({
|
||||
onReorderHoverChange,
|
||||
onDeploy,
|
||||
onEdit,
|
||||
onClone,
|
||||
onDelete,
|
||||
}: {
|
||||
cred: Credential;
|
||||
@@ -100,6 +102,7 @@ export function CredentialItem({
|
||||
onReorderHoverChange?: (edge: "before" | "after" | null) => void;
|
||||
onDeploy: () => void;
|
||||
onEdit: () => void;
|
||||
onClone: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
@@ -167,6 +170,16 @@ export function CredentialItem({
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
title={t("credentials.cloneCredentialAction")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClone();
|
||||
}}
|
||||
className={trayButtonClass}
|
||||
>
|
||||
<CopyPlus className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
title={t("credentials.deleteCredentialAction")}
|
||||
onClick={(e) => {
|
||||
@@ -181,12 +194,16 @@ export function CredentialItem({
|
||||
);
|
||||
|
||||
const trayOpenState = isTrayOpen || isMenuOpen;
|
||||
// A collapsed tray clipped to max-h-0 is still a flex item, so it earns the
|
||||
// text column's gap-1 and leaves a gap under every closed row. The negative
|
||||
// margin cancels it while keeping the element in flow to animate open.
|
||||
const trayCollapsed = `max-h-0 opacity-0 ${isCompact ? "" : "-mt-1"}`;
|
||||
const trayVisibilityClass =
|
||||
alwaysShowTray || actionsOnly
|
||||
? `overflow-hidden transition-all duration-150 ease-out ${trayOpenState || alwaysShowTray ? "max-h-[60px] opacity-100" : "max-h-0 opacity-0"}`
|
||||
? `overflow-hidden transition-all duration-150 ease-out ${trayOpenState || alwaysShowTray ? "max-h-[60px] opacity-100" : trayCollapsed}`
|
||||
: shouldUseClickTray
|
||||
? `overflow-hidden transition-all duration-150 ease-out ${trayOpenState ? "max-h-[60px] opacity-100" : "max-h-0 opacity-0"}`
|
||||
: `overflow-hidden transition-all duration-150 ease-out max-h-0 opacity-0 group-hover:max-h-[60px] group-hover:opacity-100 ${isMenuOpen ? "!max-h-[60px] !opacity-100" : ""}`;
|
||||
? `overflow-hidden transition-all duration-150 ease-out ${trayOpenState ? "max-h-[60px] opacity-100" : trayCollapsed}`
|
||||
: `overflow-hidden transition-all duration-150 ease-out ${trayCollapsed} group-hover:max-h-[60px] group-hover:opacity-100 ${isCompact ? "" : "group-hover:mt-0"} ${isMenuOpen ? `!max-h-[60px] !opacity-100 ${isCompact ? "" : "!mt-0"}` : ""}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -345,7 +362,7 @@ export function CredentialItem({
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`flex items-center gap-0.5 border-t border-border/30 ${alwaysShowTray || actionsOnly || shouldUseClickTray ? "pt-1.5" : "pt-1 mt-0.5"}`}
|
||||
className={`flex items-center gap-0.5 ${connectionButtons ? "border-t border-border/30" : ""} ${alwaysShowTray || actionsOnly || shouldUseClickTray ? "pt-1.5" : "pt-1 mt-0.5"}`}
|
||||
>
|
||||
{managementButtons}
|
||||
</div>
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
CredentialTrayTrigger,
|
||||
} from "@/types/credential-sidebar-preferences";
|
||||
import { CredentialItem } from "./CredentialItem/CredentialItem";
|
||||
import { getCredentialRowHeight } from "./credential-row-height";
|
||||
import { CredentialFolderItem } from "./FolderItem/CredentialFolderItem";
|
||||
import {
|
||||
isFolder,
|
||||
@@ -30,6 +31,7 @@ export function CredentialSidebarTree({
|
||||
folders,
|
||||
onDeployCredential,
|
||||
onEditCredential,
|
||||
onCloneCredential,
|
||||
onDeleteCredential,
|
||||
usedByCounts,
|
||||
termixIdLinkedIds,
|
||||
@@ -50,6 +52,7 @@ export function CredentialSidebarTree({
|
||||
folders: CredentialFolder[];
|
||||
onDeployCredential: (cred: Credential) => void;
|
||||
onEditCredential: (cred: Credential) => void;
|
||||
onCloneCredential: (cred: Credential) => void;
|
||||
onDeleteCredential: (cred: Credential) => void;
|
||||
usedByCounts?: Map<string, number>;
|
||||
termixIdLinkedIds?: Set<number>;
|
||||
@@ -130,8 +133,6 @@ export function CredentialSidebarTree({
|
||||
!alwaysShowActions &&
|
||||
!actionsOnly &&
|
||||
(trayTrigger === "click" || isTouchOnly);
|
||||
const isCompactDensity = density === "compact";
|
||||
|
||||
const visibleRows = collectVisibleRows(folders, query, openFolders);
|
||||
|
||||
// Fixed, exactly-computed row heights rather than estimate-then-measure,
|
||||
@@ -146,53 +147,25 @@ export function CredentialSidebarTree({
|
||||
// buttons at all, so a password row is shorter than a key row whenever
|
||||
// that row is showing (actionsOnly closed, or always mode).
|
||||
const FOLDER_ROW_HEIGHT = 31.5;
|
||||
// Base closed height with no persistent extras (hover/click modes,
|
||||
// collapsed) -- identical for both credential types since neither the
|
||||
// connection row nor the management row is rendered. Re-measured live
|
||||
// after fixing the comfortable-density row's top/bottom padding asymmetry
|
||||
// (was pt-2.5 pb-2, now the symmetric py-2, 2px shorter).
|
||||
const BASE_ROW_HEIGHT = isCompactDensity ? 23 : 45;
|
||||
// Fully opened (management row revealed, plus connection row for key
|
||||
// creds) -- differs by type since only key creds have a connection row;
|
||||
// measured identical for click-mode-open and actionsOnly-mode-open.
|
||||
const OPEN_KEY_ROW_HEIGHT = isCompactDensity ? 79.25 : 104.75;
|
||||
const OPEN_PASSWORD_ROW_HEIGHT = isCompactDensity ? 56.5 : 78.5;
|
||||
// actionsOnly closed (connection row shown permanently, management row
|
||||
// still collapsed) -- differs by type since only key creds have a
|
||||
// connection row to show.
|
||||
const ACTIONS_ONLY_KEY_ROW_HEIGHT = isCompactDensity ? 50.25 : 75.75;
|
||||
const ACTIONS_ONLY_PASSWORD_ROW_HEIGHT = isCompactDensity ? 27.5 : 49.5;
|
||||
// always mode (both rows permanently shown) -- measured slightly shorter
|
||||
// than a click-opened row since the chevron toggle button isn't rendered.
|
||||
const ALWAYS_KEY_ROW_HEIGHT = isCompactDensity ? 74.75 : 100.25;
|
||||
const ALWAYS_PASSWORD_ROW_HEIGHT = isCompactDensity ? 52 : 74;
|
||||
|
||||
const rowHeight = useCallback(
|
||||
(index: number) => {
|
||||
const row = visibleRows[index];
|
||||
if (!row) return FOLDER_ROW_HEIGHT;
|
||||
if (isFolder(row.item)) return FOLDER_ROW_HEIGHT;
|
||||
const isKey = row.item.type === "key";
|
||||
|
||||
if (alwaysShowActions) {
|
||||
return isKey ? ALWAYS_KEY_ROW_HEIGHT : ALWAYS_PASSWORD_ROW_HEIGHT;
|
||||
}
|
||||
|
||||
const isOpen =
|
||||
(openTrayCredentialId === row.item.id ||
|
||||
openMenuCredentialId === row.item.id) &&
|
||||
(clickTrayActive || actionsOnly);
|
||||
|
||||
if (actionsOnly) {
|
||||
if (isOpen)
|
||||
return isKey ? OPEN_KEY_ROW_HEIGHT : OPEN_PASSWORD_ROW_HEIGHT;
|
||||
return isKey
|
||||
? ACTIONS_ONLY_KEY_ROW_HEIGHT
|
||||
: ACTIONS_ONLY_PASSWORD_ROW_HEIGHT;
|
||||
}
|
||||
|
||||
if (isOpen) return isKey ? OPEN_KEY_ROW_HEIGHT : OPEN_PASSWORD_ROW_HEIGHT;
|
||||
return BASE_ROW_HEIGHT;
|
||||
return getCredentialRowHeight({
|
||||
density,
|
||||
isKey,
|
||||
alwaysShowActions,
|
||||
actionsOnly,
|
||||
isOpen,
|
||||
showTags,
|
||||
tagCount: row.item.tags?.length ?? 0,
|
||||
});
|
||||
},
|
||||
[
|
||||
visibleRows,
|
||||
@@ -201,13 +174,8 @@ export function CredentialSidebarTree({
|
||||
clickTrayActive,
|
||||
alwaysShowActions,
|
||||
actionsOnly,
|
||||
BASE_ROW_HEIGHT,
|
||||
OPEN_KEY_ROW_HEIGHT,
|
||||
OPEN_PASSWORD_ROW_HEIGHT,
|
||||
ACTIONS_ONLY_KEY_ROW_HEIGHT,
|
||||
ACTIONS_ONLY_PASSWORD_ROW_HEIGHT,
|
||||
ALWAYS_KEY_ROW_HEIGHT,
|
||||
ALWAYS_PASSWORD_ROW_HEIGHT,
|
||||
density,
|
||||
showTags,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -254,6 +222,7 @@ export function CredentialSidebarTree({
|
||||
visibleRows.length,
|
||||
density,
|
||||
trayTrigger,
|
||||
showTags,
|
||||
]);
|
||||
|
||||
function toggleFolder(name: string) {
|
||||
@@ -467,6 +436,7 @@ export function CredentialSidebarTree({
|
||||
}}
|
||||
onDeploy={() => onDeployCredential(item)}
|
||||
onEdit={() => onEditCredential(item)}
|
||||
onClone={() => onCloneCredential(item)}
|
||||
onDelete={() => onDeleteCredential(item)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { CredentialDensity } from "@/types/credential-sidebar-preferences";
|
||||
|
||||
const HEIGHTS = {
|
||||
comfortable: {
|
||||
base: 45,
|
||||
openKey: 104.75,
|
||||
openPassword: 78.5,
|
||||
actionsOnlyKey: 75.75,
|
||||
actionsOnlyPassword: 49.5,
|
||||
alwaysKey: 100.25,
|
||||
alwaysPassword: 74,
|
||||
tags: 18.5,
|
||||
},
|
||||
compact: {
|
||||
base: 23,
|
||||
openKey: 79.25,
|
||||
openPassword: 56.5,
|
||||
actionsOnlyKey: 50.25,
|
||||
actionsOnlyPassword: 27.5,
|
||||
alwaysKey: 74.75,
|
||||
alwaysPassword: 52,
|
||||
tags: 12.5,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export function getCredentialRowHeight({
|
||||
density,
|
||||
isKey,
|
||||
alwaysShowActions,
|
||||
actionsOnly,
|
||||
isOpen,
|
||||
showTags,
|
||||
tagCount,
|
||||
}: {
|
||||
density: CredentialDensity;
|
||||
isKey: boolean;
|
||||
alwaysShowActions: boolean;
|
||||
actionsOnly: boolean;
|
||||
isOpen: boolean;
|
||||
showTags: boolean;
|
||||
tagCount: number;
|
||||
}): number {
|
||||
const heights = HEIGHTS[density];
|
||||
let height: number = heights.base;
|
||||
|
||||
if (alwaysShowActions) {
|
||||
height = isKey ? heights.alwaysKey : heights.alwaysPassword;
|
||||
} else if (isOpen) {
|
||||
height = isKey ? heights.openKey : heights.openPassword;
|
||||
} else if (actionsOnly) {
|
||||
height = isKey ? heights.actionsOnlyKey : heights.actionsOnlyPassword;
|
||||
}
|
||||
|
||||
return height + (showTags && tagCount > 0 ? heights.tags : 0);
|
||||
}
|
||||
@@ -183,14 +183,14 @@ export function canCopyHostSudoPassword(host: Host): boolean {
|
||||
*/
|
||||
const HOST_ITEM_DENSITY_TOKENS = {
|
||||
comfortable: {
|
||||
rowPadding: "pl-2.5 pr-2 py-2",
|
||||
rowPadding: "pl-[8.75px] pr-[7px] py-[7px]",
|
||||
nameTextSize: "text-[13px]",
|
||||
showAddressRow: true,
|
||||
showTagsRow: true,
|
||||
showResourceRow: true,
|
||||
},
|
||||
compact: {
|
||||
rowPadding: "pl-2 pr-1.5 py-[5px]",
|
||||
rowPadding: "pl-[7px] pr-[5.25px] py-[5px]",
|
||||
nameTextSize: "text-[12px]",
|
||||
showAddressRow: false,
|
||||
showTagsRow: false,
|
||||
@@ -404,7 +404,7 @@ export function HostItem({
|
||||
depth > 0 ? ({ paddingLeft: depth * 12 } as const) : undefined;
|
||||
|
||||
const trayButtonClass =
|
||||
"flex items-center justify-center size-6.5 text-muted-foreground/60 hover:text-foreground hover:bg-muted transition-colors";
|
||||
"flex items-center justify-center size-[22.75px] text-muted-foreground/60 hover:text-foreground hover:bg-muted transition-colors";
|
||||
|
||||
const sshActions = getSshActions(host);
|
||||
const availableActions: TabType[] = [
|
||||
@@ -822,16 +822,22 @@ export function HostItem({
|
||||
!shouldUseClickTray &&
|
||||
!selectionMode &&
|
||||
(isHovered || isMenuOpen);
|
||||
// A collapsed tray must not earn the text column's gap-[3.5px]. Clipping to
|
||||
// max-h-0 leaves it a flex item, so every closed row measured ~3.5px taller
|
||||
// than its slot and the virtualizer spread the rows apart to match. The
|
||||
// negative margin cancels the gap while keeping the element in flow, so the
|
||||
// modes that animate open still have something to transition.
|
||||
const trayCollapsedClass = `max-h-0 opacity-0 ${isCompact ? "" : "-mt-[3.5px]"}`;
|
||||
const trayVisibilityClass =
|
||||
alwaysShowTray || actionsOnly
|
||||
? `overflow-hidden transition-all duration-150 ease-out ${trayOpenState || alwaysShowTray ? "max-h-[130px] opacity-100" : "max-h-0 opacity-0"}`
|
||||
? `overflow-hidden transition-all duration-150 ease-out ${trayOpenState || alwaysShowTray ? "max-h-[130px] opacity-100" : trayCollapsedClass}`
|
||||
: shouldUseClickTray
|
||||
? `overflow-hidden transition-all duration-150 ease-out ${trayOpenState ? "max-h-[130px] opacity-100" : "max-h-0 opacity-0"}`
|
||||
? `overflow-hidden transition-all duration-150 ease-out ${trayOpenState ? "max-h-[130px] opacity-100" : trayCollapsedClass}`
|
||||
: // No transition in hover mode: the row's height is set by the
|
||||
// virtualizer and snaps in a single frame, so animating the tray
|
||||
// against it leaves the open tray overflowing its shortened row for
|
||||
// the length of the animation. Both change together instead.
|
||||
`overflow-hidden ${hoverTrayOpen ? "max-h-[130px] opacity-100" : "max-h-0 opacity-0"}`;
|
||||
`overflow-hidden ${hoverTrayOpen ? "max-h-[130px] opacity-100" : trayCollapsedClass}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -980,7 +986,7 @@ export function HostItem({
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`flex flex-col flex-1 min-w-0 ${tokens.rowPadding} ${isCompact ? "" : "gap-1"}`}
|
||||
className={`flex flex-col flex-1 min-w-0 ${tokens.rowPadding} ${isCompact ? "" : "gap-[3.5px]"}`}
|
||||
>
|
||||
{/* Name row */}
|
||||
<div data-drag-label className="flex items-center gap-1.5 min-w-0">
|
||||
@@ -1073,10 +1079,10 @@ export function HostItem({
|
||||
e.stopPropagation();
|
||||
onTrayOpenChange?.(!isTrayOpen);
|
||||
}}
|
||||
className="ml-auto flex items-center justify-center size-5 rounded text-muted-foreground/30 hover:text-muted-foreground hover:bg-muted-foreground/10 transition-colors shrink-0"
|
||||
className="ml-auto flex items-center justify-center size-[17.5px] rounded text-muted-foreground/30 hover:text-muted-foreground hover:bg-muted-foreground/10 transition-colors shrink-0"
|
||||
>
|
||||
<ChevronRight
|
||||
className={`size-3 transition-transform duration-150 ${isTrayOpen ? "rotate-90" : ""}`}
|
||||
className={`size-[10.5px] transition-transform duration-150 ${isTrayOpen ? "rotate-90" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
@@ -1115,7 +1121,7 @@ export function HostItem({
|
||||
(alwaysShowTray ||
|
||||
actionsOnly ||
|
||||
(shouldUseClickTray && isTrayOpen)) && (
|
||||
<div className="flex items-center flex-wrap gap-1">
|
||||
<div className="flex items-center flex-wrap gap-[3.5px]">
|
||||
{connectionButtons}
|
||||
</div>
|
||||
)}
|
||||
@@ -1126,7 +1132,7 @@ export function HostItem({
|
||||
isOnline &&
|
||||
((host.cpu != null && host.cpu > 0) ||
|
||||
(host.ram != null && host.ram > 0)) && (
|
||||
<div className="flex items-center gap-3 pt-1.5">
|
||||
<div className="flex items-center gap-[10.5px] pt-[5.25px]">
|
||||
{host.cpu != null && host.cpu > 0 && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Cpu className="size-2.5 shrink-0 text-muted-foreground/40" />
|
||||
@@ -1163,14 +1169,14 @@ export function HostItem({
|
||||
>
|
||||
{/* Connection buttons — only shown here when not already shown above */}
|
||||
{!alwaysShowTray && !actionsOnly && !shouldUseClickTray && (
|
||||
<div className="flex items-center flex-wrap gap-0.5">
|
||||
<div className="flex items-center flex-wrap gap-[1.75px]">
|
||||
{connectionButtons}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Management buttons row */}
|
||||
<div
|
||||
className={`flex items-center gap-0.5 border-t border-border/30 ${alwaysShowTray || actionsOnly || shouldUseClickTray ? "pt-1.5" : "pt-1 mt-0.5"}`}
|
||||
className={`flex items-center gap-[1.75px] border-t border-border/30 ${alwaysShowTray || actionsOnly || shouldUseClickTray ? "pt-[5.25px]" : "pt-[3.5px] mt-[1.75px]"}`}
|
||||
>
|
||||
{managementButtons}
|
||||
</div>
|
||||
|
||||
@@ -66,6 +66,7 @@ import {
|
||||
rowKind,
|
||||
ROOT_PARENT,
|
||||
} from "./visible-rows";
|
||||
import { RESOURCE_ROW_EXTRA, rendersResourceRow } from "./row-metrics";
|
||||
import { ReorderIndicator } from "@/sidebar/ReorderIndicator";
|
||||
import { useSidebarSelection } from "./hooks/useSidebarSelection";
|
||||
import { useSidebarDragState } from "./hooks/useSidebarDragState";
|
||||
@@ -699,34 +700,47 @@ export function SidebarTree({
|
||||
const CLICK_CHEVRON_EXTRA = clickTrayActive ? 4.5 : 0;
|
||||
const HOST_ROW_HEIGHT = (isCompactDensity ? 27.5 : 45) + CLICK_CHEVRON_EXTRA;
|
||||
const FOLDER_ROW_HEIGHT = 31.5;
|
||||
// "always" mode permanently renders the connection-buttons row (plus the
|
||||
// management row/resource bars for online hosts) -- measured directly
|
||||
// rather than derived, since it has its own fixed shape.
|
||||
// "always" mode permanently renders the connection-buttons row plus the
|
||||
// management row -- measured directly rather than derived, since it has its
|
||||
// own fixed shape. The resource bars are NOT included: they only render for
|
||||
// an online host with CPU/RAM, so rowHeight adds RESOURCE_ROW_EXTRA per row.
|
||||
// Carries the click-mode chevron so the open row lands at its measured
|
||||
// 104.75 rather than always mode's 100.25. The term cancels out of
|
||||
// OPEN_TRAY_EXTRA below, leaving HOST_ROW_HEIGHT to supply it once.
|
||||
const ALWAYS_ROW_HEIGHT =
|
||||
(isCompactDensity ? 73.75 : 100.25) + CLICK_CHEVRON_EXTRA;
|
||||
(isCompactDensity ? 73.75 : 83) + CLICK_CHEVRON_EXTRA;
|
||||
const OPEN_TRAY_EXTRA = ALWAYS_ROW_HEIGHT - HOST_ROW_HEIGHT;
|
||||
// "actionsOnly" permanently renders just the connection-buttons row above
|
||||
// the tray; the management row/resource bars stay collapsed until toggled.
|
||||
const ACTIONS_ONLY_ROW_HEIGHT = isCompactDensity ? 50.25 : 75.75;
|
||||
// Opening the management row from actionsOnly's closed state (which
|
||||
// already includes the connection row).
|
||||
const ACTIONS_ONLY_OPEN_ROW_HEIGHT = isCompactDensity ? 79.25 : 104.75;
|
||||
// Hiding the resource bars only ever makes a row shorter than the height
|
||||
// reserved for it, which the existing shapes already tolerate: the bars are
|
||||
// conditional on the host being online with CPU/RAM data, so an offline host
|
||||
// has always rendered short of ALWAYS_ROW_HEIGHT. Simple therefore pairs
|
||||
// showResourceBars:false with trayTrigger:"actionsOnly", whose measured
|
||||
// height excludes the resource/management row to begin with.
|
||||
// already includes the connection row). Excludes the resource bars for the
|
||||
// same reason as ALWAYS_ROW_HEIGHT -- rowHeight adds them per row.
|
||||
const ACTIONS_ONLY_OPEN_ROW_HEIGHT = isCompactDensity ? 79.25 : 87.5;
|
||||
// Tag pills are a separate flex row. Comfortable density adds the row plus
|
||||
// its gap; compact density pulls it upward by 2px but still needs a slot.
|
||||
const TAG_ROW_EXTRA = isCompactDensity ? 12.5 : 18.5;
|
||||
// showResourceBars:false simply drops RESOURCE_ROW_EXTRA from every row.
|
||||
|
||||
const rowHeight = useCallback(
|
||||
(index: number) => {
|
||||
const row = visibleRows[index];
|
||||
if (!row) return FOLDER_ROW_HEIGHT;
|
||||
if (isFolder(row.item)) return FOLDER_ROW_HEIGHT;
|
||||
if (alwaysShowActions) return ALWAYS_ROW_HEIGHT;
|
||||
const tagExtra = showTags && row.item.tags?.length ? TAG_ROW_EXTRA : 0;
|
||||
// The resource bars only render for an online host that reported CPU/RAM.
|
||||
// Reserving their height unconditionally left a gap under every offline
|
||||
// row. measureElement corrects any drift from live status this estimate
|
||||
// can't see.
|
||||
const resourceExtra = rendersResourceRow(
|
||||
row.item,
|
||||
showResourceBars,
|
||||
isCompactDensity,
|
||||
)
|
||||
? RESOURCE_ROW_EXTRA
|
||||
: 0;
|
||||
if (alwaysShowActions)
|
||||
return ALWAYS_ROW_HEIGHT + tagExtra + resourceExtra;
|
||||
const toggledOpen =
|
||||
(openTrayHostId === row.item.id || openMenuHostId === row.item.id) &&
|
||||
(clickTrayActive || actionsOnly);
|
||||
@@ -738,10 +752,20 @@ export function SidebarTree({
|
||||
!selectionMode &&
|
||||
(hoveredHostId === row.item.id || openMenuHostId === row.item.id);
|
||||
const isOpen = toggledOpen || hoverOpen;
|
||||
// The bars live inside the tray, so they only take space once it opens.
|
||||
const openResourceExtra = isOpen ? resourceExtra : 0;
|
||||
if (actionsOnly) {
|
||||
return isOpen ? ACTIONS_ONLY_OPEN_ROW_HEIGHT : ACTIONS_ONLY_ROW_HEIGHT;
|
||||
return (
|
||||
(isOpen ? ACTIONS_ONLY_OPEN_ROW_HEIGHT : ACTIONS_ONLY_ROW_HEIGHT) +
|
||||
tagExtra +
|
||||
openResourceExtra
|
||||
);
|
||||
}
|
||||
return isOpen ? HOST_ROW_HEIGHT + OPEN_TRAY_EXTRA : HOST_ROW_HEIGHT;
|
||||
return (
|
||||
(isOpen ? HOST_ROW_HEIGHT + OPEN_TRAY_EXTRA : HOST_ROW_HEIGHT) +
|
||||
tagExtra +
|
||||
openResourceExtra
|
||||
);
|
||||
},
|
||||
[
|
||||
visibleRows,
|
||||
@@ -752,12 +776,16 @@ export function SidebarTree({
|
||||
clickTrayActive,
|
||||
alwaysShowActions,
|
||||
actionsOnly,
|
||||
showTags,
|
||||
HOST_ROW_HEIGHT,
|
||||
CLICK_CHEVRON_EXTRA,
|
||||
OPEN_TRAY_EXTRA,
|
||||
ALWAYS_ROW_HEIGHT,
|
||||
ACTIONS_ONLY_ROW_HEIGHT,
|
||||
ACTIONS_ONLY_OPEN_ROW_HEIGHT,
|
||||
TAG_ROW_EXTRA,
|
||||
showResourceBars,
|
||||
isCompactDensity,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -766,6 +794,15 @@ export function SidebarTree({
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: rowHeight,
|
||||
overscan: 12,
|
||||
// The library's default rounds every measurement to a whole pixel. Rows
|
||||
// here land on fractions (a 63.84px row rounds to 64), and the rounded-up
|
||||
// size becomes the slot pitch -- leaving a visible sliver under every
|
||||
// single row, which stacks into the gaps the list is judged by. Keep the
|
||||
// sub-pixel size so slots sit flush against the row above.
|
||||
measureElement: (element, entry) => {
|
||||
const box = entry?.borderBoxSize?.[0];
|
||||
return box ? box.blockSize : element.getBoundingClientRect().height;
|
||||
},
|
||||
getItemKey: (index) => {
|
||||
const row = visibleRows[index];
|
||||
if (!row) return index;
|
||||
@@ -792,28 +829,21 @@ export function SidebarTree({
|
||||
return reorderHoverEdge === "before" ? slot.start : slot.start + slot.size;
|
||||
})();
|
||||
|
||||
// Fixed heights mean estimateSize already IS the real size. Do not attach
|
||||
// measureElement here: its index-based ResizeObserver measurements can be
|
||||
// reused for a different row after a deletion and override the fixed size.
|
||||
// The virtualizer still needs telling when a row's height classification
|
||||
// changes (tray opened/closed, tree reshaped, density/trigger changed).
|
||||
// rowHeight is only an estimate: a row's real height also depends on live
|
||||
// state the tree can't cheaply know per row (an offline host drops the
|
||||
// resource bars "always" mode reserves space for). measureElement corrects
|
||||
// it from the DOM, keyed by getItemKey rather than index so a deleted row's
|
||||
// size is never reused for whatever shifts into its slot.
|
||||
//
|
||||
// measure() wipes the ENTIRE measurement cache, so it may only run when
|
||||
// every row's shape changes at once -- a density/trigger/tag switch. Hover
|
||||
// and tray state are deliberately absent: they change one row, which
|
||||
// re-renders and re-measures itself through the observer anyway, whereas
|
||||
// calling measure() for them threw all 5000 rows back to their estimates
|
||||
// and made the list visibly jump apart on every pointer move.
|
||||
useLayoutEffect(() => {
|
||||
virtualizer.measure();
|
||||
}, [
|
||||
virtualizer,
|
||||
openFolders,
|
||||
closedHostParents,
|
||||
openTrayHostId,
|
||||
openMenuHostId,
|
||||
hoveredHostId,
|
||||
selectionMode,
|
||||
query,
|
||||
visibleRows.length,
|
||||
density,
|
||||
trayTrigger,
|
||||
showTags,
|
||||
showResourceBars,
|
||||
]);
|
||||
}, [virtualizer, density, trayTrigger, showTags, showResourceBars]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -909,6 +939,8 @@ export function SidebarTree({
|
||||
return (
|
||||
<div
|
||||
key={vItem.key}
|
||||
data-index={vItem.index}
|
||||
ref={virtualizer.measureElement}
|
||||
className="absolute top-0 left-0 w-full"
|
||||
style={{
|
||||
transform: `translateY(${vItem.start}px)`,
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Host } from "@/types/ui-types";
|
||||
|
||||
/** The CPU/RAM bar row, including its pt-[5.25px]. */
|
||||
export const RESOURCE_ROW_EXTRA = 17.25;
|
||||
|
||||
/**
|
||||
* Whether a host row will actually render the resource bars.
|
||||
*
|
||||
* Mirrors HostItem's own condition. The virtualizer used to reserve their
|
||||
* height for every row in "always" mode, which left a gap under each offline
|
||||
* host -- most obvious down a long list where nothing breaks up the rows.
|
||||
*/
|
||||
export function rendersResourceRow(
|
||||
host: Pick<Host, "online" | "cpu" | "ram">,
|
||||
showResourceBars: boolean,
|
||||
isCompactDensity: boolean,
|
||||
): boolean {
|
||||
if (!showResourceBars || isCompactDensity) return false;
|
||||
if (!host.online) return false;
|
||||
return (host.cpu ?? 0) > 0 || (host.ram ?? 0) > 0;
|
||||
}
|
||||
Reference in new issue
Block a user