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

+25 -16
View File
@@ -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">
+7 -1
View File
@@ -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}
+8 -1
View File
@@ -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
+75 -17
View File
@@ -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")
+67 -31
View File
@@ -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
+7 -2
View File
@@ -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)
+75 -2
View File
@@ -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}
/>
)}
+1 -1
View File
@@ -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);
}
+19 -13
View File
@@ -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>
+67 -35
View File
@@ -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)`,
+21
View File
@@ -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;
}