v1.10.0 #471

Merged
LukeGus merged 106 commits from dev-1.10.0 into main 2026-01-01 04:20:12 +00:00
9 changed files with 422 additions and 187 deletions
Showing only changes of commit c27280d037 - Show all commits

View File

@@ -1600,6 +1600,15 @@
"commandAutocompleteDesc": "Enable Tab key autocomplete suggestions for terminal commands based on your command history",
"defaultSnippetFoldersCollapsed": "Collapse Snippet Folders by Default",
"defaultSnippetFoldersCollapsedDesc": "When enabled, all snippet folders will be collapsed when you open the snippets tab",
"showHostTags": "Show Host Tags",
"showHostTagsDesc": "Display tags under each host in the sidebar. Disable to hide all tags.",
"account": "Account",
"appearance": "Appearance",
"languageLocalization": "Language & Localization",
"fileManagerSettings": "File Manager",
"terminalSettings": "Terminal",
"hostSidebarSettings": "Host & Sidebar",
"snippetsSettings": "Snippets",
"currentPassword": "Current Password",
"passwordChangedSuccess": "Password changed successfully! Please log in again.",
"failedToChangePassword": "Failed to change password. Please check your current password and try again."
@@ -1642,7 +1651,7 @@
"searchHosts": "Search hosts by name, username, IP, folder, tags...",
"enterPassword": "Enter your password",
"totpCode": "6-digit TOTP code",
"searchHostsAny": "Search hosts by any info...",
"searchHostsAny": "Search hosts (try: tag:prod, user:root, ip:192.168)...",
"confirmPassword": "Enter your password to confirm",
"typeHere": "Type here",
"fileName": "Enter file name (e.g., example.txt)",

View File

@@ -616,23 +616,35 @@ export function Dashboard({
{t("dashboard.noRecentActivity")}
</p>
) : (
recentActivity.map((item) => (
<Button
key={item.id}
variant="outline"
className="border-2 !border-dark-border bg-dark-bg min-w-0"
onClick={() => handleActivityClick(item)}
>
{item.type === "terminal" ? (
<Terminal size={20} className="shrink-0" />
) : (
<FolderOpen size={20} className="shrink-0" />
)}
<p className="truncate ml-2 font-semibold">
{item.hostName}
</p>
</Button>
))
recentActivity
.filter((item, index, array) => {
// Always show the first item
if (index === 0) return true;
// Show if different from previous item (by hostId and type)
const prevItem = array[index - 1];
return !(
item.hostId === prevItem.hostId &&
item.type === prevItem.type
);
})
.map((item) => (
<Button
key={item.id}
variant="outline"
className="border-2 !border-dark-border bg-dark-bg min-w-0"
onClick={() => handleActivityClick(item)}
>
{item.type === "terminal" ? (
<Terminal size={20} className="shrink-0" />
) : (
<FolderOpen size={20} className="shrink-0" />
)}
<p className="truncate ml-2 font-semibold">
{item.hostName}
</p>
</Button>
))
)}
</div>
</div>

View File

@@ -336,6 +336,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
credentialId: currentHost.credentialId,
userId: currentHost.userId,
forceKeyboardInteractive: currentHost.forceKeyboardInteractive,
jumpHosts: currentHost.jumpHosts,
useSocks5: currentHost.useSocks5,
socks5Host: currentHost.socks5Host,
socks5Port: currentHost.socks5Port,
@@ -774,6 +775,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
sshKey: currentHost.key,
keyPassword: currentHost.keyPassword,
credentialId: currentHost.credentialId,
jumpHosts: currentHost.jumpHosts,
useSocks5: currentHost.useSocks5,
socks5Host: currentHost.socks5Host,
socks5Port: currentHost.socks5Port,
@@ -1325,6 +1327,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
authType: currentHost.authType,
credentialId: currentHost.credentialId,
userId: currentHost.userId,
jumpHosts: currentHost.jumpHosts,
useSocks5: currentHost.useSocks5,
socks5Host: currentHost.socks5Host,
socks5Port: currentHost.socks5Port,
@@ -1482,6 +1485,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
authType: credentials.password ? "password" : "key",
credentialId: currentHost.credentialId,
userId: currentHost.userId,
jumpHosts: currentHost.jumpHosts,
useSocks5: currentHost.useSocks5,
socks5Host: currentHost.socks5Host,
socks5Port: currentHost.socks5Port,

View File

@@ -60,6 +60,15 @@ export function TerminalWindow({
const handleMaximize = () => {
maximizeWindow(windowId);
// Trigger resize after maximize/restore
if (resizeTimeoutRef.current) {
clearTimeout(resizeTimeoutRef.current);
}
resizeTimeoutRef.current = setTimeout(() => {
if (terminalRef.current?.fit) {
terminalRef.current.fit();
}
}, 150);
};
const handleFocus = () => {

View File

@@ -219,6 +219,7 @@ function QuickActionItem({
placeholder={t("hosts.quickActionName")}
value={quickAction.name}
onChange={(e) => onUpdate(e.target.value, quickAction.snippetId)}
onBlur={(e) => onUpdate(e.target.value.trim(), quickAction.snippetId)}
className="flex-1"
/>
</div>
@@ -1196,6 +1197,10 @@ export function HostManagerEditor({
field.ref(e);
ipInputRef.current = e;
}}
onBlur={(e) => {
field.onChange(e.target.value.trim());
field.onBlur();
}}
/>
</FormControl>
</FormItem>
@@ -1238,6 +1243,10 @@ export function HostManagerEditor({
placeholder={t("placeholders.username")}
disabled={shouldDisable}
{...field}
onBlur={(e) => {
field.onChange(e.target.value.trim());
field.onBlur();
}}
/>
</FormControl>
</FormItem>
@@ -1259,6 +1268,10 @@ export function HostManagerEditor({
<Input
placeholder={t("placeholders.hostname")}
{...field}
onBlur={(e) => {
field.onChange(e.target.value.trim());
field.onBlur();
}}
/>
</FormControl>
</FormItem>
@@ -1283,6 +1296,10 @@ export function HostManagerEditor({
field.onChange(e);
setFolderDropdownOpen(true);
}}
onBlur={(e) => {
field.onChange(e.target.value.trim());
field.onBlur();
}}
/>
</FormControl>
{folderDropdownOpen && filteredFolders.length > 0 && (
@@ -1878,6 +1895,10 @@ export function HostManagerEditor({
<Input
placeholder="proxy.example.com"
{...field}
onBlur={(e) => {
field.onChange(e.target.value.trim());
field.onBlur();
}}
/>
</FormControl>
<FormDescription>
@@ -1927,6 +1948,10 @@ export function HostManagerEditor({
<Input
placeholder={t("hosts.username")}
{...field}
onBlur={(e) => {
field.onChange(e.target.value.trim());
field.onBlur();
}}
/>
</FormControl>
</FormItem>
@@ -2047,6 +2072,23 @@ export function HostManagerEditor({
newChain,
);
}}
onBlur={(e) => {
const currentChain =
form.watch(
"socks5ProxyChain",
) || [];
const newChain = [
...currentChain,
];
newChain[index] = {
...newChain[index],
host: e.target.value.trim(),
};
form.setValue(
"socks5ProxyChain",
newChain,
);
}}
/>
</div>
@@ -2142,6 +2184,23 @@ export function HostManagerEditor({
newChain,
);
}}
onBlur={(e) => {
const currentChain =
form.watch(
"socks5ProxyChain",
) || [];
const newChain = [
...currentChain,
];
newChain[index] = {
...newChain[index],
username: e.target.value.trim(),
};
form.setValue(
"socks5ProxyChain",
newChain,
);
}}
/>
</div>
@@ -2694,6 +2753,10 @@ export function HostManagerEditor({
<Input
placeholder="mosh user@server"
{...field}
onBlur={(e) => {
field.onChange(e.target.value.trim());
field.onBlur();
}}
/>
</FormControl>
<FormDescription>
@@ -2769,6 +2832,10 @@ export function HostManagerEditor({
<Input
placeholder="Variable name"
{...field}
onBlur={(e) => {
field.onChange(e.target.value.trim());
field.onBlur();
}}
/>
</FormControl>
</FormItem>
@@ -2780,7 +2847,14 @@ export function HostManagerEditor({
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<Input placeholder="Value" {...field} />
<Input
placeholder="Value"
{...field}
onBlur={(e) => {
field.onChange(e.target.value.trim());
field.onBlur();
}}
/>
</FormControl>
</FormItem>
)}
@@ -3057,6 +3131,10 @@ export function HostManagerEditor({
}),
);
}}
onBlur={(e) => {
endpointHostField.onChange(e.target.value.trim());
endpointHostField.onBlur();
}}
/>
</FormControl>
{sshConfigDropdownOpen[index] &&
@@ -3244,6 +3322,10 @@ export function HostManagerEditor({
<Input
placeholder={t("placeholders.homePath")}
{...field}
onBlur={(e) => {
field.onChange(e.target.value.trim());
field.onBlur();
}}
/>
</FormControl>
<FormDescription>

View File

@@ -48,8 +48,6 @@ import {
Pencil,
FolderMinus,
Copy,
Activity,
Clock,
Palette,
Trash,
Cloud,
@@ -63,6 +61,8 @@ import {
FolderOpen,
Share2,
Users,
ArrowDownUp,
Container,
} from "lucide-react";
import type {
SSHHost,
@@ -583,46 +583,6 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
}
};
const getMonitoringStatus = (host: SSHHost) => {
try {
const statsConfig = host.statsConfig
? JSON.parse(host.statsConfig)
: DEFAULT_STATS_CONFIG;
const formatInterval = (seconds: number): string => {
if (seconds >= 60) {
const minutes = Math.round(seconds / 60);
return `${minutes}m`;
}
return `${seconds}s`;
};
const statusEnabled = statsConfig.statusCheckEnabled !== false;
const metricsEnabled = statsConfig.metricsEnabled !== false;
const statusInterval = statusEnabled
? formatInterval(statsConfig.statusCheckInterval || 30)
: null;
const metricsInterval = metricsEnabled
? formatInterval(statsConfig.metricsInterval || 30)
: null;
return {
statusEnabled,
metricsEnabled,
statusInterval,
metricsInterval,
bothDisabled: !statusEnabled && !metricsEnabled,
};
} catch {
return {
statusEnabled: true,
metricsEnabled: true,
statusInterval: "30s",
metricsInterval: "30s",
bothDisabled: false,
};
}
};
const filteredAndSortedHosts = useMemo(() => {
let filtered = hosts;
@@ -1419,48 +1379,15 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
{t("hosts.fileManagerBadge")}
</Badge>
)}
{(() => {
const monitoringStatus =
getMonitoringStatus(host);
if (monitoringStatus.bothDisabled) {
return (
<Badge
variant="outline"
className="text-xs px-1 py-0 text-muted-foreground"
>
<Activity className="h-2 w-2 mr-0.5" />
{t("hosts.monitoringDisabledBadge")}
</Badge>
);
}
return (
<>
{monitoringStatus.statusEnabled && (
<Badge
variant="outline"
className="text-xs px-1 py-0"
>
<Activity className="h-2 w-2 mr-0.5" />
{t("hosts.statusMonitoring")}:{" "}
{monitoringStatus.statusInterval}
</Badge>
)}
{monitoringStatus.metricsEnabled && (
<Badge
variant="outline"
className="text-xs px-1 py-0"
>
<Clock className="h-2 w-2 mr-0.5" />
{t("hosts.metricsMonitoring")}:{" "}
{monitoringStatus.metricsInterval}
</Badge>
)}
</>
);
})()}
{host.enableDocker && (
<Badge
variant="outline"
className="text-xs px-1 py-0"
>
<Container className="h-2 w-2 mr-0.5" />
Docker
</Badge>
)}
</div>
</div>
@@ -1519,6 +1446,60 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
</TooltipContent>
</Tooltip>
)}
{host.enableTunnel && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
onClick={(e) => {
e.stopPropagation();
const title = host.name?.trim()
? host.name
: `${host.username}@${host.ip}:${host.port}`;
addTab({
type: "tunnel",
title,
hostConfig: host,
});
}}
className="h-7 px-2 hover:bg-orange-500/10 hover:border-orange-500/50 flex-1"
>
<ArrowDownUp className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Open Tunnels</p>
</TooltipContent>
</Tooltip>
)}
{host.enableDocker && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
onClick={(e) => {
e.stopPropagation();
const title = host.name?.trim()
? host.name
: `${host.username}@${host.ip}:${host.port}`;
addTab({
type: "docker",
title,
hostConfig: host,
});
}}
className="h-7 px-2 hover:bg-cyan-500/10 hover:border-cyan-500/50 flex-1"
>
<Container className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Open Docker</p>
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button

View File

@@ -363,20 +363,78 @@ export function LeftSidebar({
const filteredHosts = React.useMemo(() => {
if (!debouncedSearch.trim()) return hosts;
const q = debouncedSearch.trim().toLowerCase();
const searchQuery = debouncedSearch.trim().toLowerCase();
return hosts.filter((h) => {
const searchableText = [
h.name || "",
h.username,
h.ip,
h.folder || "",
...(h.tags || []),
h.authType,
h.defaultPath || "",
]
.join(" ")
.toLowerCase();
return searchableText.includes(q);
// Check for field-specific search patterns
const fieldMatches: Record<string, string> = {};
let remainingQuery = searchQuery;
// Extract field-specific queries (e.g., "tag:production", "user:root", "ip:192.168")
const fieldPattern = /(\w+):([^\s]+)/g;
let match;
while ((match = fieldPattern.exec(searchQuery)) !== null) {
const [fullMatch, field, value] = match;
fieldMatches[field] = value;
remainingQuery = remainingQuery.replace(fullMatch, "").trim();
}
// Handle field-specific searches
for (const [field, value] of Object.entries(fieldMatches)) {
switch (field) {
case "tag":
case "tags":
const tags = Array.isArray(h.tags) ? h.tags : [];
const hasMatchingTag = tags.some((tag) =>
tag.toLowerCase().includes(value)
);
if (!hasMatchingTag) return false;
break;
case "name":
if (!(h.name || "").toLowerCase().includes(value)) return false;
break;
case "user":
case "username":
if (!h.username.toLowerCase().includes(value)) return false;
break;
case "ip":
case "host":
if (!h.ip.toLowerCase().includes(value)) return false;
break;
case "port":
if (!String(h.port).includes(value)) return false;
break;
case "folder":
if (!(h.folder || "").toLowerCase().includes(value)) return false;
break;
case "auth":
case "authtype":
if (!h.authType.toLowerCase().includes(value)) return false;
break;
case "path":
if (!(h.defaultPath || "").toLowerCase().includes(value))
return false;
break;
}
}
// If there's remaining query text (not field-specific), search across all fields
if (remainingQuery) {
const searchableText = [
h.name || "",
h.username,
h.ip,
h.folder || "",
...(h.tags || []),
h.authType,
h.defaultPath || "",
]
.join(" ")
.toLowerCase();
if (!searchableText.includes(remainingQuery)) return false;
}
return true;
});
}, [hosts, debouncedSearch]);

View File

@@ -28,6 +28,10 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
const [serverStatus, setServerStatus] = useState<
"online" | "offline" | "degraded"
>("degraded");
const [showTags, setShowTags] = useState<boolean>(() => {
const saved = localStorage.getItem("showHostTags");
return saved !== null ? saved === "true" : true;
});
const tags = Array.isArray(host.tags) ? host.tags : [];
const hasTags = tags.length > 0;
@@ -54,6 +58,17 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
window.removeEventListener("ssh-hosts:changed", handleHostsChanged);
}, [host.id]);
useEffect(() => {
const handleShowTagsChanged = () => {
const saved = localStorage.getItem("showHostTags");
setShowTags(saved !== null ? saved === "true" : true);
};
window.addEventListener("showHostTagsChanged", handleShowTagsChanged);
return () =>
window.removeEventListener("showHostTagsChanged", handleShowTagsChanged);
}, []);
const statsConfig = useMemo(() => {
try {
return host.statsConfig
@@ -216,8 +231,7 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
</DropdownMenu>
</ButtonGroup>
</div>
{hasTags && (
{showTags && hasTags && (
<div className="flex flex-wrap items-center gap-2 mt-1">
{tags.map((tag: string) => (
<div

View File

@@ -11,7 +11,7 @@ import {
} from "@/components/ui/tabs.tsx";
import { Separator } from "@/components/ui/separator.tsx";
import { Switch } from "@/components/ui/switch.tsx";
import { User, Shield, AlertCircle } from "lucide-react";
import { User, Shield, AlertCircle, Palette } from "lucide-react";
import { TOTPSetup } from "@/ui/desktop/user/TOTPSetup.tsx";
import {
getUserInfo,
@@ -107,6 +107,10 @@ export function UserProfile({
useState<boolean>(
localStorage.getItem("defaultSnippetFoldersCollapsed") !== "false",
);
const [showHostTags, setShowHostTags] = useState<boolean>(() => {
const saved = localStorage.getItem("showHostTags");
return saved !== null ? saved === "true" : true;
});
const [userRoles, setUserRoles] = useState<UserRole[]>([]);
useEffect(() => {
@@ -176,6 +180,12 @@ export function UserProfile({
window.dispatchEvent(new Event("defaultSnippetFoldersCollapsedChanged"));
};
const handleShowHostTagsToggle = (enabled: boolean) => {
setShowHostTags(enabled);
localStorage.setItem("showHostTags", enabled.toString());
window.dispatchEvent(new Event("showHostTagsChanged"));
};
const handleDeleteAccount = async (e: React.FormEvent) => {
e.preventDefault();
setDeleteLoading(true);
@@ -285,7 +295,14 @@ export function UserProfile({
className="flex items-center gap-2 data-[state=active]:bg-dark-bg-button"
>
<User className="w-4 h-4" />
{t("nav.userProfile")}
{t("profile.account")}
</TabsTrigger>
<TabsTrigger
value="appearance"
className="flex items-center gap-2 data-[state=active]:bg-dark-bg-button"
>
<Palette className="w-4 h-4" />
{t("profile.appearance")}
</TabsTrigger>
{(!userInfo.is_oidc || userInfo.is_dual_auth) && (
<TabsTrigger
@@ -380,73 +397,6 @@ export function UserProfile({
</div>
</div>
<div className="mt-6 pt-6 border-t border-dark-border">
<div className="flex items-center justify-between">
<div>
<Label className="text-gray-300">
{t("common.language")}
</Label>
<p className="text-sm text-gray-400 mt-1">
{t("profile.selectPreferredLanguage")}
</p>
</div>
<LanguageSwitcher />
</div>
</div>
<div className="mt-6 pt-6 border-t border-dark-border">
<div className="flex items-center justify-between">
<div>
<Label className="text-gray-300">
{t("profile.fileColorCoding")}
</Label>
<p className="text-sm text-gray-400 mt-1">
{t("profile.fileColorCodingDesc")}
</p>
</div>
<Switch
checked={fileColorCoding}
onCheckedChange={handleFileColorCodingToggle}
/>
</div>
</div>
<div className="mt-6 pt-6 border-t border-dark-border">
<div className="flex items-center justify-between">
<div>
<Label className="text-gray-300">
{t("profile.commandAutocomplete")}
</Label>
<p className="text-sm text-gray-400 mt-1">
{t("profile.commandAutocompleteDesc")}
</p>
</div>
<Switch
checked={commandAutocomplete}
onCheckedChange={handleCommandAutocompleteToggle}
/>
</div>
</div>
<div className="mt-6 pt-6 border-t border-dark-border">
<div className="flex items-center justify-between">
<div>
<Label className="text-gray-300">
{t("profile.defaultSnippetFoldersCollapsed")}
</Label>
<p className="text-sm text-gray-400 mt-1">
{t("profile.defaultSnippetFoldersCollapsedDesc")}
</p>
</div>
<Switch
checked={defaultSnippetFoldersCollapsed}
onCheckedChange={
handleDefaultSnippetFoldersCollapsedToggle
}
/>
</div>
</div>
<div className="mt-6 pt-6 border-t border-dark-border">
<div className="flex items-center justify-between">
<div>
@@ -471,6 +421,122 @@ export function UserProfile({
</div>
</TabsContent>
<TabsContent value="appearance" className="space-y-4">
{/* Language & Localization Section */}
<div className="rounded-lg border-2 border-dark-border bg-dark-bg-darker p-4">
<h3 className="text-lg font-semibold mb-4">
{t("profile.languageLocalization")}
</h3>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label className="text-gray-300">
{t("common.language")}
</Label>
<p className="text-sm text-gray-400 mt-1">
{t("profile.selectPreferredLanguage")}
</p>
</div>
<LanguageSwitcher />
</div>
</div>
</div>
{/* File Manager Section */}
<div className="rounded-lg border-2 border-dark-border bg-dark-bg-darker p-4">
<h3 className="text-lg font-semibold mb-4">
{t("profile.fileManagerSettings")}
</h3>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label className="text-gray-300">
{t("profile.fileColorCoding")}
</Label>
<p className="text-sm text-gray-400 mt-1">
{t("profile.fileColorCodingDesc")}
</p>
</div>
<Switch
checked={fileColorCoding}
onCheckedChange={handleFileColorCodingToggle}
/>
</div>
</div>
</div>
{/* Terminal Section */}
<div className="rounded-lg border-2 border-dark-border bg-dark-bg-darker p-4">
<h3 className="text-lg font-semibold mb-4">
{t("profile.terminalSettings")}
</h3>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label className="text-gray-300">
{t("profile.commandAutocomplete")}
</Label>
<p className="text-sm text-gray-400 mt-1">
{t("profile.commandAutocompleteDesc")}
</p>
</div>
<Switch
checked={commandAutocomplete}
onCheckedChange={handleCommandAutocompleteToggle}
/>
</div>
</div>
</div>
{/* Host & Sidebar Section */}
<div className="rounded-lg border-2 border-dark-border bg-dark-bg-darker p-4">
<h3 className="text-lg font-semibold mb-4">
{t("profile.hostSidebarSettings")}
</h3>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label className="text-gray-300">
{t("profile.showHostTags")}
</Label>
<p className="text-sm text-gray-400 mt-1">
{t("profile.showHostTagsDesc")}
</p>
</div>
<Switch
checked={showHostTags}
onCheckedChange={handleShowHostTagsToggle}
/>
</div>
</div>
</div>
{/* Snippets Section */}
<div className="rounded-lg border-2 border-dark-border bg-dark-bg-darker p-4">
<h3 className="text-lg font-semibold mb-4">
{t("profile.snippetsSettings")}
</h3>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label className="text-gray-300">
{t("profile.defaultSnippetFoldersCollapsed")}
</Label>
<p className="text-sm text-gray-400 mt-1">
{t("profile.defaultSnippetFoldersCollapsedDesc")}
</p>
</div>
<Switch
checked={defaultSnippetFoldersCollapsed}
onCheckedChange={
handleDefaultSnippetFoldersCollapsedToggle
}
/>
</div>
</div>
</div>
</TabsContent>
<TabsContent value="security" className="space-y-4">
<TOTPSetup
isEnabled={userInfo.totp_enabled}