mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35:24 +00:00
chore: lock the plugin boundary and remove 2.9.0 leftovers
This commit is contained in:
1 parent
6489b62ece
commit
2961297989
399 files changed
+3964
-5627
No files matched your search
+1
-1
@@ -2744,7 +2744,7 @@ export function AppShell({
|
||||
onOpenFileManager={(tabId) => {
|
||||
const targetTab = tabs.find((t) => t.id === tabId);
|
||||
if (targetTab?.host) {
|
||||
void invokeAction("files.openHost", targetTab.host);
|
||||
void invokeAction("host.openFiles", targetTab.host);
|
||||
}
|
||||
}}
|
||||
isAppFullscreen={isAppFullscreen}
|
||||
|
||||
@@ -190,63 +190,3 @@ export async function adminDeleteUserCredential(
|
||||
throw handleApiError(error, "delete user's credential");
|
||||
}
|
||||
}
|
||||
|
||||
export async function adminGetUserSnippets(
|
||||
targetUserId: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.get("/plugin-api/snippets", {
|
||||
headers: adminHeaders(targetUserId),
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "fetch user's snippets");
|
||||
}
|
||||
}
|
||||
|
||||
export async function adminCreateUserSnippet(
|
||||
targetUserId: string,
|
||||
snippetData: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.post("/plugin-api/snippets", snippetData, {
|
||||
headers: adminHeaders(targetUserId),
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "create snippet for user");
|
||||
}
|
||||
}
|
||||
|
||||
export async function adminUpdateUserSnippet(
|
||||
targetUserId: string,
|
||||
snippetId: number,
|
||||
snippetData: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.put(
|
||||
`/plugin-api/snippets/${snippetId}`,
|
||||
snippetData,
|
||||
{
|
||||
headers: adminHeaders(targetUserId),
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "update user's snippet");
|
||||
}
|
||||
}
|
||||
|
||||
export async function adminDeleteUserSnippet(
|
||||
targetUserId: string,
|
||||
snippetId: number,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.delete(`/plugin-api/snippets/${snippetId}`, {
|
||||
headers: adminHeaders(targetUserId),
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "delete user's snippet");
|
||||
}
|
||||
}
|
||||
@@ -153,6 +153,8 @@ export interface SshAuthProviderSummary {
|
||||
credentialType: boolean;
|
||||
needsUserInteraction: boolean;
|
||||
supportsBackground: boolean;
|
||||
/** Offered in Quick Connect, for a host that is never saved. */
|
||||
quickConnect?: boolean;
|
||||
available: boolean;
|
||||
missingPlugin?: { id: string; name: string };
|
||||
}
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import { authApi, handleApiError } from "@/main-axios";
|
||||
|
||||
/**
|
||||
* A snippet row as the list endpoint returns it. Callers that need the full
|
||||
* shape narrow it themselves; only the id is relied on across the app.
|
||||
*
|
||||
* Snippets themselves are owned by the snippets plugin
|
||||
* (/plugin-api/snippets); this is the thin read/run surface used by a few
|
||||
* core and plugin callers (the host editor's startup snippet, the command
|
||||
* palette, custom keybindings, host-metrics quick actions) that cannot
|
||||
* reach into the snippets plugin's own frontend code.
|
||||
*/
|
||||
export interface SnippetRow {
|
||||
id: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export async function getSnippets(): Promise<SnippetRow[]> {
|
||||
try {
|
||||
const response = await authApi.get("/plugin-api/snippets");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "fetch snippets");
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeSnippet(
|
||||
snippetId: number,
|
||||
hostId: number,
|
||||
inputValues?: Record<string, string>,
|
||||
): Promise<{ success: boolean; output: string; error?: string }> {
|
||||
try {
|
||||
const response = await authApi.post("/plugin-api/snippets/execute", {
|
||||
snippetId,
|
||||
hostId,
|
||||
...(inputValues ? { inputValues } : {}),
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "execute snippet");
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,7 @@
|
||||
import { AxiosError } from "axios";
|
||||
import {
|
||||
authApi,
|
||||
getAllServerStatuses,
|
||||
handleApiError,
|
||||
sshHostApi,
|
||||
} from "@/main-axios";
|
||||
import { getAllServerStatuses, handleApiError, sshHostApi } from "@/main-axios";
|
||||
import type { SSHHost, SSHHostData, ProxyNode } from "@/types/index";
|
||||
import type { ServerStatus, SSHHostWithStatus } from "@/main-axios";
|
||||
import type { ProxmoxDiscoverResult, ProxmoxSyncResult } from "@/types/proxmox";
|
||||
import {
|
||||
getCachedSSHHosts,
|
||||
invalidateHostsAndStatusCaches,
|
||||
@@ -174,90 +168,6 @@ export async function importSSHConfigHosts(
|
||||
}
|
||||
}
|
||||
|
||||
export async function discoverProxmoxGuests(
|
||||
hostId: number,
|
||||
): Promise<ProxmoxDiscoverResult> {
|
||||
try {
|
||||
const response = await authApi.post(
|
||||
"/plugin-api/proxmox/discover",
|
||||
{ hostId },
|
||||
{ timeout: 120000 },
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "discover Proxmox guests");
|
||||
}
|
||||
}
|
||||
|
||||
export function discoverProxmoxGuestsStream(
|
||||
hostId: number,
|
||||
handlers: {
|
||||
onProgress?: (done: number, total: number) => void;
|
||||
onResult: (result: ProxmoxDiscoverResult) => void;
|
||||
onError: (message: string) => void;
|
||||
},
|
||||
): () => void {
|
||||
const baseURL = (authApi.defaults.baseURL || "").replace(/\/$/, "");
|
||||
const source = new EventSource(
|
||||
`${baseURL}/plugin-api/proxmox/discover/stream?hostId=${encodeURIComponent(
|
||||
String(hostId),
|
||||
)}`,
|
||||
{ withCredentials: true },
|
||||
);
|
||||
let settled = false;
|
||||
const close = () => {
|
||||
settled = true;
|
||||
source.close();
|
||||
};
|
||||
source.addEventListener("progress", (event) => {
|
||||
try {
|
||||
const data = JSON.parse((event as MessageEvent).data);
|
||||
handlers.onProgress?.(data.done, data.total);
|
||||
} catch {
|
||||
// ignore malformed progress frames
|
||||
}
|
||||
});
|
||||
source.addEventListener("result", (event) => {
|
||||
close();
|
||||
try {
|
||||
handlers.onResult(JSON.parse((event as MessageEvent).data));
|
||||
} catch {
|
||||
handlers.onError("Failed to parse discovery result");
|
||||
}
|
||||
});
|
||||
source.addEventListener("fail", (event) => {
|
||||
close();
|
||||
let message = "Discovery failed";
|
||||
try {
|
||||
message = JSON.parse((event as MessageEvent).data).message || message;
|
||||
} catch {
|
||||
// keep default message
|
||||
}
|
||||
handlers.onError(message);
|
||||
});
|
||||
source.onerror = () => {
|
||||
if (settled) return;
|
||||
close();
|
||||
handlers.onError("Discovery connection lost");
|
||||
};
|
||||
return close;
|
||||
}
|
||||
|
||||
export async function syncProxmoxGuests(
|
||||
hostId: number,
|
||||
): Promise<ProxmoxSyncResult> {
|
||||
try {
|
||||
const response = await authApi.post(
|
||||
"/plugin-api/proxmox/sync",
|
||||
{ hostId },
|
||||
{ timeout: 120000 },
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "sync Proxmox guests");
|
||||
}
|
||||
}
|
||||
|
||||
export async function bulkUpdateSSHHosts(
|
||||
hostIds: number[],
|
||||
updates: Record<string, unknown>,
|
||||
|
||||
@@ -58,7 +58,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { Input } from "@/components/input";
|
||||
|
||||
export const FOLDER_ICON_MAP: Record<string, LucideIcon> = {
|
||||
const FOLDER_ICON_MAP: Record<string, LucideIcon> = {
|
||||
folder: Folder,
|
||||
"folder-open": FolderOpen,
|
||||
"folder-git": FolderGit2,
|
||||
@@ -113,12 +113,12 @@ export const FOLDER_ICON_MAP: Record<string, LucideIcon> = {
|
||||
smartphone: Smartphone,
|
||||
};
|
||||
|
||||
export const FOLDER_ICON_IDS = Object.keys(FOLDER_ICON_MAP);
|
||||
const FOLDER_ICON_IDS = Object.keys(FOLDER_ICON_MAP);
|
||||
|
||||
export const DEFAULT_FOLDER_ICON = "folder";
|
||||
export const DEFAULT_FOLDER_COLOR = "#f59145";
|
||||
|
||||
export const SUGGESTED_FOLDER_COLORS = [
|
||||
const SUGGESTED_FOLDER_COLORS = [
|
||||
"#f59145",
|
||||
"#ef4444",
|
||||
"#eab308",
|
||||
@@ -146,7 +146,7 @@ export function FolderIconEl({
|
||||
|
||||
const HEX_RE = /^#([0-9a-fA-F]{6})$/;
|
||||
|
||||
export function isValidHex(value: string): boolean {
|
||||
function isValidHex(value: string): boolean {
|
||||
return HEX_RE.test(value.trim());
|
||||
}
|
||||
|
||||
|
||||
@@ -1,668 +0,0 @@
|
||||
"use client";
|
||||
import React, {
|
||||
useState,
|
||||
useCallback,
|
||||
createContext,
|
||||
useContext,
|
||||
useRef,
|
||||
useEffect,
|
||||
} from "react";
|
||||
import {
|
||||
motion,
|
||||
AnimatePresence,
|
||||
easeInOut,
|
||||
type Variants,
|
||||
} from "motion/react";
|
||||
import {
|
||||
ChevronRight,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
File,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const animationVariants: Variants = {
|
||||
rootInitial: { opacity: 0, y: 20 },
|
||||
rootAnimate: { opacity: 1, y: 0 },
|
||||
itemInitial: { opacity: 0, x: -10 },
|
||||
itemAnimate: { opacity: 1, x: 0 },
|
||||
contentHidden: { opacity: 0, height: 0 },
|
||||
contentVisible: { opacity: 1, height: "auto" },
|
||||
chevronClosed: { rotate: 0 },
|
||||
chevronOpen: { rotate: 90 },
|
||||
};
|
||||
|
||||
const transitions = {
|
||||
root: { duration: 0.4 },
|
||||
item: { duration: 0.2 },
|
||||
content: { duration: 0.3, ease: easeInOut },
|
||||
chevron: { duration: 0.2 },
|
||||
};
|
||||
|
||||
interface ExpansionContextType {
|
||||
expandedIds: Set<string>;
|
||||
toggleExpanded: (id: string) => void;
|
||||
}
|
||||
|
||||
interface SelectionContextType {
|
||||
selectedId: string | null;
|
||||
setSelected: (id: string) => void;
|
||||
onSelect?: (id: string, label: string) => void;
|
||||
}
|
||||
|
||||
interface TreeContextType {
|
||||
focusedId: string | null;
|
||||
setFocusedId: (id: string | null) => void;
|
||||
treeId: string;
|
||||
setKeyboardMode: (mode: boolean) => void;
|
||||
keyboardMode: boolean;
|
||||
}
|
||||
|
||||
interface LevelContextType {
|
||||
level: number;
|
||||
}
|
||||
|
||||
const ExpansionContext = createContext<ExpansionContextType | null>(null);
|
||||
const SelectionContext = createContext<SelectionContextType | null>(null);
|
||||
const TreeContext = createContext<TreeContextType | null>(null);
|
||||
const LevelContext = createContext<LevelContextType>({ level: 0 });
|
||||
|
||||
const useExpansion = () => {
|
||||
const context = useContext(ExpansionContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"FolderTree components must be used within FolderTree.Root",
|
||||
);
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
const useSelection = () => {
|
||||
const context = useContext(SelectionContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"FolderTree components must be used within FolderTree.Root",
|
||||
);
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
const useTree = () => {
|
||||
const context = useContext(TreeContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"FolderTree components must be used within FolderTree.Root",
|
||||
);
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
const useLevel = () => {
|
||||
return useContext(LevelContext);
|
||||
};
|
||||
|
||||
const getPaddingClass = (level: number): string => {
|
||||
const paddingMap: Record<number, string> = {
|
||||
0: "pl-3",
|
||||
1: "pl-8",
|
||||
2: "pl-12",
|
||||
3: "pl-16",
|
||||
4: "pl-20",
|
||||
5: "pl-24",
|
||||
6: "pl-28",
|
||||
7: "pl-32",
|
||||
};
|
||||
return paddingMap[level] || `pl-[${Math.min(level * 4 + 12, 48)}px]`;
|
||||
};
|
||||
|
||||
interface CustomBadge {
|
||||
content: React.ReactNode;
|
||||
className?: string;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
interface RootProps {
|
||||
defaultExpanded?: string[];
|
||||
defaultSelected?: string;
|
||||
selectedId?: string | null;
|
||||
expandedIds?: Set<string>;
|
||||
onSelect?: (id: string, label: string) => void;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
interface ItemProps {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: LucideIcon;
|
||||
badge?: string | number;
|
||||
modified?: boolean | CustomBadge;
|
||||
untracked?: boolean | CustomBadge;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
interface TriggerProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface ContentProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Root: React.FC<RootProps> = ({
|
||||
defaultExpanded = [],
|
||||
defaultSelected,
|
||||
selectedId: controlledSelectedId,
|
||||
expandedIds: additionalExpandedIds,
|
||||
onSelect,
|
||||
className = "",
|
||||
children,
|
||||
id = "folder-tree",
|
||||
}) => {
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(
|
||||
new Set(defaultExpanded),
|
||||
);
|
||||
const [internalSelectedId, setInternalSelectedId] = useState<string | null>(
|
||||
defaultSelected || null,
|
||||
);
|
||||
const selectedId =
|
||||
controlledSelectedId !== undefined
|
||||
? controlledSelectedId
|
||||
: internalSelectedId;
|
||||
const [focusedId, setFocusedId] = useState<string | null>(null);
|
||||
const [keyboardMode, setKeyboardMode] = useState(false);
|
||||
const treeRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!additionalExpandedIds || additionalExpandedIds.size === 0) return;
|
||||
setExpandedIds((prev) => {
|
||||
const merged = new Set(prev);
|
||||
let changed = false;
|
||||
for (const id of additionalExpandedIds) {
|
||||
if (!merged.has(id)) {
|
||||
merged.add(id);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed ? merged : prev;
|
||||
});
|
||||
}, [additionalExpandedIds]);
|
||||
|
||||
const toggleExpanded = useCallback((id: string) => {
|
||||
setExpandedIds((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(id)) {
|
||||
newSet.delete(id);
|
||||
} else {
|
||||
newSet.add(id);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setSelected = useCallback((id: string) => {
|
||||
setInternalSelectedId(id);
|
||||
}, []);
|
||||
|
||||
const getVisibleItemIds = useCallback(() => {
|
||||
const items = Array.from(
|
||||
treeRef.current?.querySelectorAll('[role="treeitem"]') || [],
|
||||
);
|
||||
return items
|
||||
.filter((item) => {
|
||||
const element = item as HTMLElement;
|
||||
return element.offsetHeight > 0 && element.offsetWidth > 0;
|
||||
})
|
||||
.map((item) => item.getAttribute("data-id"))
|
||||
.filter(Boolean) as string[];
|
||||
}, []);
|
||||
|
||||
const [treeHasFocus, setTreeHasFocus] = useState(false);
|
||||
|
||||
const handleTreeFocus = useCallback(() => {
|
||||
if (!treeHasFocus) {
|
||||
setTreeHasFocus(true);
|
||||
setKeyboardMode(true);
|
||||
}
|
||||
}, [treeHasFocus]);
|
||||
|
||||
const handleTreeBlur = useCallback((e: React.FocusEvent) => {
|
||||
if (!treeRef.current?.contains(e.relatedTarget as Node)) {
|
||||
setTreeHasFocus(false);
|
||||
setFocusedId(null);
|
||||
setKeyboardMode(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
const getVisibleItems = () => {
|
||||
return Array.from(
|
||||
treeRef.current?.querySelectorAll('[role="treeitem"]') || [],
|
||||
).filter((item) => {
|
||||
const element = item as HTMLElement;
|
||||
return element.offsetHeight > 0 && element.offsetWidth > 0;
|
||||
});
|
||||
};
|
||||
|
||||
if (e.key === "Tab") {
|
||||
if (treeHasFocus && !focusedId) {
|
||||
const visibleItemIds = getVisibleItemIds();
|
||||
if (visibleItemIds.length > 0) {
|
||||
setFocusedId(visibleItemIds[0]);
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (focusedId) {
|
||||
const visibleItems = getVisibleItems();
|
||||
const currentIndex = visibleItems.findIndex(
|
||||
(item) => item.getAttribute("data-id") === focusedId,
|
||||
);
|
||||
|
||||
if (e.shiftKey) {
|
||||
if (currentIndex === 0) {
|
||||
setFocusedId(null);
|
||||
setTreeHasFocus(false);
|
||||
setKeyboardMode(false);
|
||||
return;
|
||||
}
|
||||
const nextIndex = Math.max(0, currentIndex - 1);
|
||||
const nextItem = visibleItems[nextIndex] as HTMLElement;
|
||||
const nextId = nextItem?.getAttribute("data-id");
|
||||
if (nextId) {
|
||||
setFocusedId(nextId);
|
||||
e.preventDefault();
|
||||
}
|
||||
} else {
|
||||
if (currentIndex === visibleItems.length - 1) {
|
||||
setFocusedId(null);
|
||||
setTreeHasFocus(false);
|
||||
setKeyboardMode(false);
|
||||
return;
|
||||
}
|
||||
const nextIndex = Math.min(
|
||||
visibleItems.length - 1,
|
||||
currentIndex + 1,
|
||||
);
|
||||
const nextItem = visibleItems[nextIndex] as HTMLElement;
|
||||
const nextId = nextItem?.getAttribute("data-id");
|
||||
if (nextId) {
|
||||
setFocusedId(nextId);
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!keyboardMode || !focusedId) return;
|
||||
|
||||
const visibleItems = getVisibleItems();
|
||||
const currentIndex = visibleItems.findIndex(
|
||||
(item) => item.getAttribute("data-id") === focusedId,
|
||||
);
|
||||
|
||||
switch (e.key) {
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
if (currentIndex < visibleItems.length - 1) {
|
||||
const nextItem = visibleItems[currentIndex + 1] as HTMLElement;
|
||||
const nextId = nextItem.getAttribute("data-id");
|
||||
if (nextId) setFocusedId(nextId);
|
||||
}
|
||||
break;
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
if (currentIndex > 0) {
|
||||
const prevItem = visibleItems[currentIndex - 1] as HTMLElement;
|
||||
const prevId = prevItem.getAttribute("data-id");
|
||||
if (prevId) setFocusedId(prevId);
|
||||
}
|
||||
break;
|
||||
case "ArrowRight":
|
||||
e.preventDefault();
|
||||
if (!expandedIds.has(focusedId)) {
|
||||
toggleExpanded(focusedId);
|
||||
}
|
||||
break;
|
||||
case "ArrowLeft":
|
||||
e.preventDefault();
|
||||
if (expandedIds.has(focusedId)) {
|
||||
toggleExpanded(focusedId);
|
||||
}
|
||||
break;
|
||||
case "Enter":
|
||||
case " ":
|
||||
e.preventDefault();
|
||||
setSelected(focusedId);
|
||||
if (onSelect) {
|
||||
const currentItem = visibleItems[currentIndex] as HTMLElement;
|
||||
const label =
|
||||
currentItem.querySelector("span:nth-of-type(2)")?.textContent ||
|
||||
"";
|
||||
onSelect(focusedId, label);
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
[
|
||||
focusedId,
|
||||
keyboardMode,
|
||||
expandedIds,
|
||||
toggleExpanded,
|
||||
setSelected,
|
||||
onSelect,
|
||||
getVisibleItemIds,
|
||||
treeHasFocus,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseDown = () => setKeyboardMode(false);
|
||||
document.addEventListener("mousedown", handleMouseDown);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleMouseDown);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const expansionValue: ExpansionContextType = {
|
||||
expandedIds,
|
||||
toggleExpanded,
|
||||
};
|
||||
|
||||
const selectionValue: SelectionContextType = {
|
||||
selectedId,
|
||||
setSelected,
|
||||
onSelect,
|
||||
};
|
||||
|
||||
const treeValue: TreeContextType = {
|
||||
focusedId,
|
||||
setFocusedId,
|
||||
treeId: id,
|
||||
setKeyboardMode,
|
||||
keyboardMode,
|
||||
};
|
||||
|
||||
return (
|
||||
<ExpansionContext.Provider value={expansionValue}>
|
||||
<SelectionContext.Provider value={selectionValue}>
|
||||
<TreeContext.Provider value={treeValue}>
|
||||
<LevelContext.Provider value={{ level: 0 }}>
|
||||
<motion.div
|
||||
ref={treeRef}
|
||||
variants={animationVariants}
|
||||
initial="rootInitial"
|
||||
animate="rootAnimate"
|
||||
transition={transitions.root}
|
||||
className={cn(
|
||||
"bg-canvas border border-edge rounded-lg overflow-hidden",
|
||||
className,
|
||||
)}
|
||||
role="tree"
|
||||
aria-labelledby={`${id}-label`}
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={handleTreeFocus}
|
||||
onBlur={handleTreeBlur}
|
||||
>
|
||||
<div className="w-full overflow-y-auto bg-canvas text-sm">
|
||||
{children}
|
||||
</div>
|
||||
</motion.div>
|
||||
</LevelContext.Provider>
|
||||
</TreeContext.Provider>
|
||||
</SelectionContext.Provider>
|
||||
</ExpansionContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const ItemContext = createContext<{
|
||||
itemId: string;
|
||||
hasChildren: boolean;
|
||||
isExpanded: boolean;
|
||||
toggleExpanded: () => void;
|
||||
} | null>(null);
|
||||
|
||||
const Item: React.FC<ItemProps> = ({
|
||||
id,
|
||||
label,
|
||||
icon,
|
||||
badge,
|
||||
modified,
|
||||
untracked,
|
||||
className = "",
|
||||
children,
|
||||
}) => {
|
||||
const expansionContext = useExpansion();
|
||||
const selectionContext = useSelection();
|
||||
const treeContext = useTree();
|
||||
const { level } = useLevel();
|
||||
const itemRef = useRef<HTMLDivElement>(null);
|
||||
const keyboardMode = treeContext.keyboardMode;
|
||||
|
||||
const hasChildren = React.Children.count(children) > 0;
|
||||
const isExpanded = expansionContext.expandedIds.has(id);
|
||||
const isSelected = selectionContext.selectedId === id;
|
||||
const isFocused = treeContext.focusedId === id;
|
||||
|
||||
const handleItemClick = useCallback(() => {
|
||||
treeContext.setKeyboardMode(false);
|
||||
selectionContext.setSelected(id);
|
||||
treeContext.setFocusedId(id);
|
||||
if (selectionContext.onSelect) {
|
||||
selectionContext.onSelect(id, label);
|
||||
}
|
||||
}, [id, label, selectionContext, treeContext]);
|
||||
|
||||
const toggleExpanded = useCallback(() => {
|
||||
if (hasChildren) {
|
||||
expansionContext.toggleExpanded(id);
|
||||
}
|
||||
}, [id, hasChildren, expansionContext]);
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
treeContext.setFocusedId(id);
|
||||
}, [id, treeContext]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isFocused && itemRef.current) {
|
||||
itemRef.current.focus();
|
||||
}
|
||||
}, [isFocused]);
|
||||
|
||||
const IconComponent =
|
||||
icon || (hasChildren ? (isExpanded ? FolderOpen : Folder) : File);
|
||||
|
||||
const itemContextValue = {
|
||||
itemId: id,
|
||||
hasChildren,
|
||||
isExpanded,
|
||||
toggleExpanded,
|
||||
};
|
||||
|
||||
const renderBadge = (
|
||||
badgeData: boolean | CustomBadge | undefined,
|
||||
defaultContent: string,
|
||||
defaultClassName: string,
|
||||
) => {
|
||||
if (!badgeData) return null;
|
||||
|
||||
if (typeof badgeData === "boolean") {
|
||||
return (
|
||||
<span
|
||||
className={defaultClassName}
|
||||
aria-label={`${defaultContent} status`}
|
||||
>
|
||||
{defaultContent}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto text-xs px-2 py-0.5 rounded-full",
|
||||
badgeData.className,
|
||||
)}
|
||||
aria-label={badgeData.ariaLabel || `Custom badge: ${badgeData.content}`}
|
||||
>
|
||||
{badgeData.content}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ItemContext.Provider value={itemContextValue}>
|
||||
<LevelContext.Provider value={{ level: level + 1 }}>
|
||||
<div>
|
||||
<motion.div
|
||||
ref={itemRef}
|
||||
variants={animationVariants}
|
||||
initial="itemInitial"
|
||||
animate="itemAnimate"
|
||||
transition={{ ...transitions.item, delay: level * 0.05 }}
|
||||
data-selected={isSelected ? "true" : "false"}
|
||||
data-id={id}
|
||||
className={cn(
|
||||
"flex items-center gap-2 py-1.5 text-sm transition-colors cursor-pointer select-none",
|
||||
getPaddingClass(level),
|
||||
className,
|
||||
isSelected
|
||||
? "bg-accent text-accent-foreground border-r-2 border-ring"
|
||||
: "",
|
||||
!isSelected && "hover:bg-hover",
|
||||
keyboardMode && isFocused
|
||||
? "focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-inset"
|
||||
: "focus:outline-hidden",
|
||||
)}
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
handleItemClick();
|
||||
e.stopPropagation();
|
||||
toggleExpanded();
|
||||
}}
|
||||
onFocus={handleFocus}
|
||||
role="treeitem"
|
||||
tabIndex={isFocused ? 0 : -1}
|
||||
aria-expanded={hasChildren ? isExpanded : undefined}
|
||||
aria-selected={isSelected}
|
||||
aria-label={`${hasChildren ? "Folder" : "File"}: ${label}`}
|
||||
aria-level={level + 1}
|
||||
>
|
||||
{hasChildren && (
|
||||
<motion.span
|
||||
className="shrink-0 cursor-pointer"
|
||||
variants={animationVariants}
|
||||
animate={isExpanded ? "chevronOpen" : "chevronClosed"}
|
||||
transition={transitions.chevron}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<ChevronRight size={14} className="text-muted-foreground" />
|
||||
</motion.span>
|
||||
)}
|
||||
{!hasChildren && <span className="w-3 mr-2" aria-hidden="true" />}
|
||||
{IconComponent && (
|
||||
<IconComponent
|
||||
size={16}
|
||||
data-selected={isSelected ? "true" : "false"}
|
||||
data-child={hasChildren ? "true" : "false"}
|
||||
className={cn(
|
||||
"mr-1 shrink-0 text-muted-foreground data-[child=true]:text-primary data-[selected=true]:text-accent-foreground",
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<span className="flex-1">{label}</span>
|
||||
{badge && (
|
||||
<span
|
||||
className="ml-auto text-xs bg-muted text-muted-foreground px-2 py-0.5 rounded-full"
|
||||
aria-label={`Badge: ${badge}`}
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
{renderBadge(
|
||||
modified,
|
||||
"M",
|
||||
"ml-auto text-xs bg-yellow-200 dark:bg-yellow-700 text-yellow-800 dark:text-yellow-200 px-2 py-0.5 rounded-full",
|
||||
)}
|
||||
{renderBadge(
|
||||
untracked,
|
||||
"U",
|
||||
"ml-auto text-xs bg-green-200 dark:bg-green-700 text-green-800 dark:text-green-200 px-2 py-0.5 rounded-full",
|
||||
)}
|
||||
</motion.div>
|
||||
{children}
|
||||
</div>
|
||||
</LevelContext.Provider>
|
||||
</ItemContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const Trigger: React.FC<TriggerProps> = ({ className = "" }) => {
|
||||
const itemContext = useContext(ItemContext);
|
||||
if (!itemContext || !itemContext.hasChildren) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.span
|
||||
className={cn("mr-2 shrink-0 cursor-pointer", className)}
|
||||
variants={animationVariants}
|
||||
animate={itemContext.isExpanded ? "chevronOpen" : "chevronClosed"}
|
||||
transition={transitions.chevron}
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
itemContext.toggleExpanded();
|
||||
}}
|
||||
role="button"
|
||||
aria-label={itemContext.isExpanded ? "Collapse" : "Expand"}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<ChevronRight size={14} className="text-muted-foreground" />
|
||||
</motion.span>
|
||||
);
|
||||
};
|
||||
|
||||
const Content: React.FC<ContentProps> = ({ children, className = "" }) => {
|
||||
const itemContext = useContext(ItemContext);
|
||||
if (!itemContext) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
const hasContent = React.Children.count(children) > 0;
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{hasContent && itemContext.isExpanded && (
|
||||
<motion.div
|
||||
variants={animationVariants}
|
||||
initial="contentHidden"
|
||||
animate="contentVisible"
|
||||
exit="contentHidden"
|
||||
transition={transitions.content}
|
||||
style={{ overflow: "hidden" }}
|
||||
className={className}
|
||||
role="group"
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
|
||||
const FolderTree = {
|
||||
Root,
|
||||
Item,
|
||||
Trigger,
|
||||
Content,
|
||||
};
|
||||
|
||||
export default FolderTree;
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
GripVertical,
|
||||
KeyRound,
|
||||
LayoutDashboard,
|
||||
Network,
|
||||
Plus,
|
||||
Server,
|
||||
Settings,
|
||||
@@ -25,8 +24,6 @@ import {
|
||||
import { Kbd } from "@/components/kbd";
|
||||
import { VersionBadge } from "@/components/version-badge";
|
||||
import { DASHBOARD_CARDS } from "@/lib/theme";
|
||||
import { CONNECTION_STATES } from "@/types/index";
|
||||
import { invokeAction } from "@/shell/action-registry";
|
||||
import type { DashboardCardId, TabType, Host } from "@/types/ui-types";
|
||||
import {
|
||||
getSSHHosts,
|
||||
@@ -232,17 +229,15 @@ function StatsBarCard({
|
||||
function CountersBarCard({
|
||||
hosts,
|
||||
credentialCount,
|
||||
activeTunnelCount,
|
||||
onOpenSingletonTab,
|
||||
}: {
|
||||
hosts: Host[];
|
||||
credentialCount: number;
|
||||
activeTunnelCount: number;
|
||||
onOpenSingletonTab: (type: TabType, pendingEvent?: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Card className="grid grid-cols-3 divide-x divide-border overflow-hidden w-full h-full py-0 gap-0">
|
||||
<Card className="grid grid-flow-col auto-cols-fr divide-x divide-border overflow-hidden w-full h-full py-0 gap-0">
|
||||
<button
|
||||
onClick={() => onOpenSingletonTab("host-manager")}
|
||||
className="flex items-center gap-2.5 px-4 py-2.5 hover:bg-muted transition-colors cursor-pointer text-left"
|
||||
@@ -265,16 +260,7 @@ function CountersBarCard({
|
||||
{t("dashboard.totalCredentials")}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void invokeAction("tunnels.open")}
|
||||
className="flex items-center gap-2.5 px-4 py-2.5 hover:bg-muted transition-colors cursor-pointer text-left"
|
||||
>
|
||||
<Network className="size-3.5 text-muted-foreground shrink-0" />
|
||||
<span className="text-base font-bold">{activeTunnelCount}</span>
|
||||
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
|
||||
{t("dashboardTab.activeTunnels")}
|
||||
</span>
|
||||
</button>
|
||||
<ComponentSlot slotId="dashboard.counters" />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -645,7 +631,6 @@ function CardItem({
|
||||
releaseUrl,
|
||||
dbHealth,
|
||||
credentialCount,
|
||||
activeTunnelCount,
|
||||
activity,
|
||||
onClearActivity,
|
||||
isAdmin,
|
||||
@@ -669,7 +654,6 @@ function CardItem({
|
||||
releaseUrl: string;
|
||||
dbHealth: "healthy" | "error";
|
||||
credentialCount: number;
|
||||
activeTunnelCount: number;
|
||||
activity: RecentActivityItem[];
|
||||
onClearActivity: () => void;
|
||||
isAdmin: boolean;
|
||||
@@ -740,7 +724,6 @@ function CardItem({
|
||||
<CountersBarCard
|
||||
hosts={hosts}
|
||||
credentialCount={credentialCount}
|
||||
activeTunnelCount={activeTunnelCount}
|
||||
onOpenSingletonTab={onOpenSingletonTab}
|
||||
/>
|
||||
)}
|
||||
@@ -881,7 +864,6 @@ type PanelColumnProps = {
|
||||
releaseUrl: string;
|
||||
dbHealth: "healthy" | "error";
|
||||
credentialCount: number;
|
||||
activeTunnelCount: number;
|
||||
activity: RecentActivityItem[];
|
||||
onClearActivity: () => void;
|
||||
cardLabels: Record<DashboardCardId, string>;
|
||||
@@ -910,7 +892,6 @@ function PanelColumn({
|
||||
releaseUrl,
|
||||
dbHealth,
|
||||
credentialCount,
|
||||
activeTunnelCount,
|
||||
activity,
|
||||
onClearActivity,
|
||||
cardLabels,
|
||||
@@ -965,7 +946,6 @@ function PanelColumn({
|
||||
releaseUrl={releaseUrl}
|
||||
dbHealth={dbHealth}
|
||||
credentialCount={credentialCount}
|
||||
activeTunnelCount={activeTunnelCount}
|
||||
activity={activity}
|
||||
onClearActivity={onClearActivity}
|
||||
isAdmin={isAdmin}
|
||||
@@ -1130,7 +1110,6 @@ export function DashboardTab({
|
||||
const [releaseUrl, setReleaseUrl] = useState("");
|
||||
const [dbHealth, setDbHealth] = useState<"healthy" | "error">("healthy");
|
||||
const [credentialCount, setCredentialCount] = useState(0);
|
||||
const [activeTunnelCount, setActiveTunnelCount] = useState(0);
|
||||
const [activity, setActivity] = useState<RecentActivityItem[]>([]);
|
||||
const statusCheckHosts = hosts.filter(isStatusCheckEnabled);
|
||||
|
||||
@@ -1186,15 +1165,6 @@ export function DashboardTab({
|
||||
),
|
||||
)
|
||||
.catch(() => {});
|
||||
// Undefined while the tunnels plugin is off, which reads as zero.
|
||||
invokeAction("tunnels.statuses")
|
||||
.then((statuses) => {
|
||||
const active = Object.values(
|
||||
(statuses ?? {}) as Record<string, { status?: string }>,
|
||||
).filter((s) => s?.status === CONNECTION_STATES.CONNECTED).length;
|
||||
setActiveTunnelCount(active);
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
if (!isVisible) {
|
||||
return () => {
|
||||
@@ -1351,7 +1321,6 @@ export function DashboardTab({
|
||||
releaseUrl,
|
||||
dbHealth,
|
||||
credentialCount,
|
||||
activeTunnelCount,
|
||||
activity,
|
||||
onClearActivity: handleClearActivity,
|
||||
onOpenSingletonTab,
|
||||
@@ -1468,7 +1437,6 @@ export function DashboardTab({
|
||||
<CountersBarCard
|
||||
hosts={hosts}
|
||||
credentialCount={credentialCount}
|
||||
activeTunnelCount={activeTunnelCount}
|
||||
onOpenSingletonTab={onOpenSingletonTab}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -55,8 +55,8 @@ export function AlertManager({
|
||||
setAlerts(sortedAlerts);
|
||||
setCurrentAlertIndex(0);
|
||||
} catch {
|
||||
toast.error(t("homepage.failedToLoadAlerts"));
|
||||
setError(t("homepage.failedToLoadAlerts"));
|
||||
toast.error(t("alerts.failedToLoad"));
|
||||
setError(t("alerts.failedToLoad"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -79,7 +79,7 @@ export function AlertManager({
|
||||
return prevIndex;
|
||||
});
|
||||
} catch {
|
||||
setError(t("homepage.failedToDismissAlert"));
|
||||
setError(t("alerts.failedToDismiss"));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ export interface PermissionsState {
|
||||
}
|
||||
|
||||
/** Fired when a user's grants may have changed, so every surface re-reads them. */
|
||||
export const PERMISSIONS_CHANGED_EVENT = "permissionsChanged";
|
||||
const PERMISSIONS_CHANGED_EVENT = "permissionsChanged";
|
||||
|
||||
interface CachedPermissions {
|
||||
permissions: string[];
|
||||
|
||||
@@ -16,7 +16,7 @@ interface ResolvedForTerminal {
|
||||
* Shared "run this snippet against these terminal tabs" flow, for the few
|
||||
* core surfaces (the command palette) that still reach for a snippet
|
||||
* directly rather than through the snippets plugin's own panel. Variable
|
||||
* resolution runs through the snippets plugin's "snippets.resolveForTerminal"
|
||||
* resolution runs through the snippets plugin's "snippet.resolveForTerminal"
|
||||
* action, and sending runs through ssh-terminal's "terminal.sendToSession",
|
||||
* since core no longer owns snippet content or a terminal ref.
|
||||
*/
|
||||
@@ -57,7 +57,7 @@ export function useSnippetRunner() {
|
||||
inputValues: Record<string, string>,
|
||||
) {
|
||||
const resolved = (await invokeAction(
|
||||
"snippets.resolveForTerminal",
|
||||
"snippet.resolveForTerminal",
|
||||
snippet.id,
|
||||
tab.host ?? null,
|
||||
inputValues,
|
||||
@@ -95,7 +95,7 @@ export function useSnippetRunner() {
|
||||
};
|
||||
|
||||
void invokeAction(
|
||||
"snippets.resolveForTerminal",
|
||||
"snippet.resolveForTerminal",
|
||||
snippet.id,
|
||||
targets[0]?.host ?? null,
|
||||
).then((result) => {
|
||||
|
||||
@@ -17,6 +17,7 @@ function builtinSummary(type: string): SshAuthProviderSummary {
|
||||
credentialType: type === "password" || type === "key",
|
||||
needsUserInteraction: false,
|
||||
supportsBackground: type !== "none",
|
||||
quickConnect: true,
|
||||
available: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import React from "react";
|
||||
import { cn } from "@/lib/utils.ts";
|
||||
|
||||
interface SimpleLoaderProps {
|
||||
visible: boolean;
|
||||
message?: string;
|
||||
className?: string;
|
||||
backgroundColor?: string;
|
||||
}
|
||||
|
||||
export function SimpleLoader({
|
||||
visible,
|
||||
message,
|
||||
className,
|
||||
backgroundColor,
|
||||
}: SimpleLoaderProps) {
|
||||
if (!visible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>
|
||||
{`
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.simple-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid var(--border-base);
|
||||
border-top-color: var(--foreground);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 flex items-center justify-center z-[100]",
|
||||
className,
|
||||
)}
|
||||
style={{ backgroundColor: backgroundColor || "var(--bg-base)" }}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="simple-spinner"></div>
|
||||
{message && (
|
||||
<p className="text-sm text-foreground-secondary font-medium">
|
||||
{message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,15 @@ import {
|
||||
getCredentialSidebarPreferences,
|
||||
saveCredentialSidebarPreferences,
|
||||
} from "@/main-axios";
|
||||
import { PRESETS, type UiPreset } from "@/types/ui-preferences";
|
||||
import {
|
||||
PRESETS,
|
||||
type UiPreset,
|
||||
type UiRailPreferences,
|
||||
} from "@/types/ui-preferences";
|
||||
import {
|
||||
listRegisteredRailItems,
|
||||
type RailItemDef,
|
||||
} from "@/sidebar/rail-items";
|
||||
import { sanitizeHostSidebarPreferences } from "@/types/host-sidebar-preferences";
|
||||
import { sanitizeCredentialSidebarPreferences } from "@/types/credential-sidebar-preferences";
|
||||
import { getRegisteredDashboardCard } from "@/dashboard/dashboard-cards-registry";
|
||||
@@ -68,6 +76,23 @@ function writeLocal(key: string, value: string) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The rail views a preset hides: its own list, plus every plugin item that
|
||||
* does not ask to stay when the preset hides plugin items.
|
||||
*/
|
||||
export function presetHiddenRailTabs(
|
||||
rail: UiRailPreferences,
|
||||
pluginItems: Pick<RailItemDef, "id" | "simplePreset" | "hideable">[],
|
||||
): string[] {
|
||||
if (!rail.hidePluginItems) return [...rail.hiddenTabs];
|
||||
return [
|
||||
...rail.hiddenTabs,
|
||||
...pluginItems
|
||||
.filter((item) => !item.simplePreset && item.hideable !== false)
|
||||
.map((item) => item.id),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a preset's values into the stores that own them. Safe to call when
|
||||
* nothing changed; every write is idempotent.
|
||||
@@ -87,7 +112,9 @@ export async function applyPresetSideEffects(
|
||||
|
||||
// Rail visibility lives on user_preferences and is mirrored to localStorage
|
||||
// with a change event, the same way the settings toggles write it.
|
||||
const hiddenRailTabs = JSON.stringify(target.rail.hiddenTabs);
|
||||
const hiddenRailTabs = JSON.stringify(
|
||||
presetHiddenRailTabs(target.rail, listRegisteredRailItems()),
|
||||
);
|
||||
writeLocal("hiddenRailTabs", hiddenRailTabs);
|
||||
window.dispatchEvent(new Event("hiddenRailTabsChanged"));
|
||||
if (isCloud) {
|
||||
|
||||
@@ -373,7 +373,14 @@ export const authLogger = new FrontendLogger("AUTH", "🔐", "#dc2626");
|
||||
export const sshLogger = new FrontendLogger("SSH", "🖥️", "#1e3a8a");
|
||||
export const fileLogger = new FrontendLogger("FILE", "📁", "#1e3a8a");
|
||||
export const statsLogger = new FrontendLogger("STATS", "📊", "#22c55e");
|
||||
export const systemLogger = new FrontendLogger("SYSTEM", "🚀", "#1e3a8a");
|
||||
const systemLogger = new FrontendLogger("SYSTEM", "🚀", "#1e3a8a");
|
||||
export const dashboardLogger = new FrontendLogger("DASHBOARD", "📊", "#ec4899");
|
||||
|
||||
export const logger = systemLogger;
|
||||
/** A named console logger, for plugins that want the same log format. */
|
||||
export function createFrontendLogger(
|
||||
serviceName: string,
|
||||
serviceIcon = "🧩",
|
||||
serviceColor = "#6b7280",
|
||||
) {
|
||||
return new FrontendLogger(serviceName, serviceIcon, serviceColor);
|
||||
}
|
||||
@@ -70,10 +70,6 @@ export function getCachedServerStatuses(
|
||||
return statusCache.get(loader);
|
||||
}
|
||||
|
||||
export function invalidateSSHHostsCache(): void {
|
||||
hostsCache.invalidate();
|
||||
}
|
||||
|
||||
export function invalidateServerStatusCache(): void {
|
||||
statusCache.invalidate();
|
||||
}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
/**
|
||||
* The shape an axios rejection actually has at the call sites that inspect it.
|
||||
* A `catch` binding is `unknown`, and every one of these sites was reaching
|
||||
* into it directly; this gives them one narrowing helper instead.
|
||||
*/
|
||||
export interface HttpLikeError {
|
||||
message?: string;
|
||||
code?: string;
|
||||
response?: {
|
||||
status?: number;
|
||||
data?: {
|
||||
error?: string;
|
||||
message?: string;
|
||||
code?: string;
|
||||
fileNotFound?: boolean;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export function asHttpError(error: unknown): HttpLikeError {
|
||||
return (error ?? {}) as HttpLikeError;
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
export interface KeyedRequestCache<T> {
|
||||
get(
|
||||
key: string,
|
||||
loader: () => Promise<T>,
|
||||
options?: { force?: boolean },
|
||||
): Promise<T>;
|
||||
peek(key: string, maxAgeMs?: number): T | null;
|
||||
set(key: string, value: T): void;
|
||||
invalidate(key?: string): void;
|
||||
}
|
||||
|
||||
/** Bounded per-key cache with in-flight request coalescing. */
|
||||
export function createKeyedRequestCache<T>(
|
||||
ttlMs: number,
|
||||
maxEntries = 100,
|
||||
): KeyedRequestCache<T> {
|
||||
const values = new Map<string, { value: T; storedAt: number }>();
|
||||
const inflight = new Map<string, Promise<T>>();
|
||||
const versions = new Map<string, number>();
|
||||
|
||||
const store = (key: string, value: T) => {
|
||||
values.delete(key);
|
||||
values.set(key, { value, storedAt: Date.now() });
|
||||
while (values.size > Math.max(1, maxEntries)) {
|
||||
const oldest = values.keys().next().value;
|
||||
if (oldest === undefined) break;
|
||||
values.delete(oldest);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
get(key, loader, options = {}) {
|
||||
const cached = values.get(key);
|
||||
if (!options.force && cached && Date.now() - cached.storedAt < ttlMs) {
|
||||
return Promise.resolve(cached.value);
|
||||
}
|
||||
|
||||
const pending = inflight.get(key);
|
||||
if (pending) return pending;
|
||||
|
||||
const version = versions.get(key) ?? 0;
|
||||
const request = loader()
|
||||
.then((value) => {
|
||||
if ((versions.get(key) ?? 0) === version) store(key, value);
|
||||
return value;
|
||||
})
|
||||
.finally(() => {
|
||||
if (inflight.get(key) === request) inflight.delete(key);
|
||||
});
|
||||
inflight.set(key, request);
|
||||
return request;
|
||||
},
|
||||
|
||||
peek(key, maxAgeMs = Number.POSITIVE_INFINITY) {
|
||||
const cached = values.get(key);
|
||||
if (!cached || Date.now() - cached.storedAt > maxAgeMs) return null;
|
||||
values.delete(key);
|
||||
values.set(key, cached);
|
||||
return cached.value;
|
||||
},
|
||||
|
||||
set(key, value) {
|
||||
versions.set(key, (versions.get(key) ?? 0) + 1);
|
||||
inflight.delete(key);
|
||||
store(key, value);
|
||||
},
|
||||
|
||||
invalidate(key) {
|
||||
if (key === undefined) {
|
||||
for (const activeKey of inflight.keys()) {
|
||||
versions.set(activeKey, (versions.get(activeKey) ?? 0) + 1);
|
||||
}
|
||||
values.clear();
|
||||
inflight.clear();
|
||||
} else {
|
||||
versions.set(key, (versions.get(key) ?? 0) + 1);
|
||||
values.delete(key);
|
||||
inflight.delete(key);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
// Thin, typed access to the desktop app's local filesystem bridge
|
||||
// (electron/local-files.cjs via preload.js). Everything here resolves to
|
||||
// "unavailable" outside Electron so callers can feature-detect cheaply.
|
||||
|
||||
import type {
|
||||
LocalDirectoryListing,
|
||||
LocalFsHomeInfo,
|
||||
LocalFsResult,
|
||||
LocalTrashResult,
|
||||
LocalWalkResult,
|
||||
} from "@/types/electron";
|
||||
import { isElectron } from "./electron";
|
||||
|
||||
export function isLocalFileBrowserAvailable(): boolean {
|
||||
if (!isElectron()) return false;
|
||||
const api = window.electronAPI;
|
||||
return !!api?.localFs && !!api?.localTransfer;
|
||||
}
|
||||
|
||||
function requireLocalFs() {
|
||||
const api = window.electronAPI?.localFs;
|
||||
if (!api) {
|
||||
throw new Error("Local file access is only available in the desktop app");
|
||||
}
|
||||
return api;
|
||||
}
|
||||
|
||||
function unwrap<T>(result: LocalFsResult<T>): T {
|
||||
if (!result || result.success !== true) {
|
||||
const message =
|
||||
result && "error" in result && result.error
|
||||
? result.error
|
||||
: "Local file operation failed";
|
||||
const error = new Error(message) as Error & { code?: string };
|
||||
if (result && "code" in result) error.code = result.code;
|
||||
throw error;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function getLocalHome(): Promise<LocalFsHomeInfo> {
|
||||
return unwrap(await requireLocalFs().home());
|
||||
}
|
||||
|
||||
export async function listLocalDirectory(
|
||||
dirPath: string,
|
||||
): Promise<LocalDirectoryListing> {
|
||||
return unwrap(await requireLocalFs().list(dirPath));
|
||||
}
|
||||
|
||||
export async function createLocalFolder(
|
||||
parentPath: string,
|
||||
name: string,
|
||||
): Promise<string> {
|
||||
return unwrap(await requireLocalFs().mkdir(parentPath, name)).path;
|
||||
}
|
||||
|
||||
export async function createLocalFile(
|
||||
parentPath: string,
|
||||
name: string,
|
||||
): Promise<string> {
|
||||
return unwrap(await requireLocalFs().createFile(parentPath, name)).path;
|
||||
}
|
||||
|
||||
export async function renameLocalEntry(
|
||||
oldPath: string,
|
||||
newName: string,
|
||||
): Promise<string> {
|
||||
return unwrap(await requireLocalFs().rename(oldPath, newName)).path;
|
||||
}
|
||||
|
||||
/** Moves entries to the OS trash; resolves with per-path failures, if any. */
|
||||
export async function trashLocalPaths(
|
||||
paths: string[],
|
||||
): Promise<LocalTrashResult> {
|
||||
return unwrap(await requireLocalFs().trash(paths));
|
||||
}
|
||||
|
||||
/** Subset of `paths` that already exist on disk. */
|
||||
export async function localPathsExist(paths: string[]): Promise<string[]> {
|
||||
if (paths.length === 0) return [];
|
||||
return unwrap(await requireLocalFs().exists(paths)).existing;
|
||||
}
|
||||
|
||||
/** Creates `dirPath` (and parents). With `rootPath`, refuses anything outside it. */
|
||||
export async function ensureLocalDirectory(
|
||||
dirPath: string,
|
||||
rootPath?: string,
|
||||
): Promise<string> {
|
||||
return unwrap(await requireLocalFs().ensureDir(dirPath, rootPath)).path;
|
||||
}
|
||||
|
||||
export async function walkLocalPaths(
|
||||
paths: string[],
|
||||
): Promise<LocalWalkResult> {
|
||||
return unwrap(await requireLocalFs().walk(paths));
|
||||
}
|
||||
|
||||
export async function revealLocalPath(targetPath: string): Promise<void> {
|
||||
unwrap(await requireLocalFs().reveal(targetPath));
|
||||
}
|
||||
|
||||
export async function openLocalPath(targetPath: string): Promise<void> {
|
||||
unwrap(await requireLocalFs().open(targetPath));
|
||||
}
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
/** The backend port. Everything plugin-facing rides the main server. */
|
||||
const BACKEND_PORT = 30001;
|
||||
|
||||
export function pluginApiPath(pluginId: string, path = ""): string {
|
||||
function pluginApiPath(pluginId: string, path = ""): string {
|
||||
const suffix = path && !path.startsWith("/") ? `/${path}` : path;
|
||||
return `/plugin-api/${pluginId}${suffix}`;
|
||||
}
|
||||
@@ -123,23 +123,6 @@ export function pluginApiFor(
|
||||
return client;
|
||||
}
|
||||
|
||||
/**
|
||||
* pluginWsUrl for a path a backend handed out ("/plugin-ws/<id>/<path>?q"),
|
||||
* so a caller can dial a plugin's socket without naming the plugin.
|
||||
*/
|
||||
export async function pluginWsUrlForPath(
|
||||
wsPath: string,
|
||||
options: { origin?: ConnectionOrigin } = {},
|
||||
): Promise<WebSocketConnectionTarget | null> {
|
||||
const [pathname, query] = wsPath.split("?", 2);
|
||||
const match = /^\/plugin-ws\/([^/]+)(\/.*)$/.exec(pathname);
|
||||
if (!match) return null;
|
||||
const target = await pluginWsUrl(match[1], match[2], options);
|
||||
if (!target || !query) return target;
|
||||
const separator = target.url.includes("?") ? "&" : "?";
|
||||
return { ...target, url: `${target.url}${separator}${query}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* The WebSocket URL for /plugin-ws/<id>/<path>, plus the subprotocols that
|
||||
* carry the JWT.
|
||||
|
||||
@@ -23,7 +23,7 @@ export interface SharedHostConnectionAuth {
|
||||
keyType?: string | null;
|
||||
}
|
||||
|
||||
export async function getRemoteSharedHostConnectionAuth(
|
||||
async function getRemoteSharedHostConnectionAuth(
|
||||
localHostId: number,
|
||||
): Promise<SharedHostConnectionAuth> {
|
||||
if (localHostId >= 0) {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { invokeAction } from "@/shell/action-registry";
|
||||
|
||||
/** A snippet as core's pickers show it. */
|
||||
export interface SnippetRow {
|
||||
id: number;
|
||||
name: string;
|
||||
content: string;
|
||||
folder?: string | null;
|
||||
isNote?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The user's snippets, from whichever plugin provides them through the
|
||||
* "snippet.list" action. Empty while no plugin does. An admin editing
|
||||
* another user's host passes that user's id.
|
||||
*/
|
||||
export async function listSnippets(options?: {
|
||||
targetUserId?: string;
|
||||
}): Promise<SnippetRow[]> {
|
||||
const rows = await invokeAction("snippet.list", options);
|
||||
return Array.isArray(rows) ? (rows as SnippetRow[]) : [];
|
||||
}
|
||||
@@ -27,11 +27,6 @@ export function extractSnippetInputs(content: string): SnippetInput[] {
|
||||
return Array.from(seen.values());
|
||||
}
|
||||
|
||||
export function hasSnippetInputs(content: string): boolean {
|
||||
INPUT_PATTERN.lastIndex = 0;
|
||||
return INPUT_PATTERN.test(content);
|
||||
}
|
||||
|
||||
function replaceVar(
|
||||
content: string,
|
||||
name: string,
|
||||
|
||||
@@ -5,11 +5,6 @@ export const splitDragState = { active: false };
|
||||
// Callbacks registered by terminal instances to trigger a fit after drag ends.
|
||||
const fitCallbacks = new Set<() => void>();
|
||||
|
||||
export function registerFitCallback(fn: () => void) {
|
||||
fitCallbacks.add(fn);
|
||||
return () => fitCallbacks.delete(fn);
|
||||
}
|
||||
|
||||
export function notifyDragEnd() {
|
||||
for (const fn of fitCallbacks) fn();
|
||||
}
|
||||
+5
-18
@@ -1500,7 +1500,6 @@
|
||||
"dashboardTab": {
|
||||
"stable": "STABLE",
|
||||
"hostsOnline": "Hosts Online",
|
||||
"activeTunnels": "Active Tunnels",
|
||||
"registerNewServer": "Register a new server",
|
||||
"storeSshKeysOrPasswords": "Store SSH keys or passwords",
|
||||
"manageUsersAndRoles": "Manage users and roles",
|
||||
@@ -1742,7 +1741,6 @@
|
||||
"manageTabAccount": "Account",
|
||||
"manageTabHosts": "Hosts",
|
||||
"manageTabCredentials": "Credentials",
|
||||
"manageTabSnippets": "Snippets",
|
||||
"manageTabSessions": "Sessions",
|
||||
"manageTabDanger": "Danger",
|
||||
"manageEditorBack": "Back to {{username}}'s data",
|
||||
@@ -1779,18 +1777,6 @@
|
||||
"deleteCredentialConfirm": "Delete credential \"{{name}}\" belonging to {{username}}?",
|
||||
"credentialDeletedSuccess": "Credential deleted",
|
||||
"credentialDeleteFailed": "Failed to delete credential",
|
||||
"snippetsCount": "{{count}} snippets",
|
||||
"addSnippetForUser": "Add Snippet",
|
||||
"noSnippetsForUser": "This user has no snippets",
|
||||
"snippetRequiredFields": "Snippet name and content are required",
|
||||
"snippetNamePlaceholder": "Snippet name",
|
||||
"snippetContentPlaceholder": "Command content",
|
||||
"snippetFolderPlaceholder": "Folder (optional)",
|
||||
"snippetSaved": "Snippet saved",
|
||||
"snippetSaveFailed": "Failed to save snippet",
|
||||
"deleteSnippetConfirm": "Delete snippet \"{{name}}\" belonging to {{username}}?",
|
||||
"snippetDeletedSuccess": "Snippet deleted",
|
||||
"snippetDeleteFailed": "Failed to delete snippet",
|
||||
"noSessionsForUser": "No active sessions",
|
||||
"deleteSuccessorLabel": "Hand over hosts and credentials to",
|
||||
"deleteSuccessorMe": "Me (the deleting admin)",
|
||||
@@ -1895,8 +1881,8 @@
|
||||
"domainLabel": "Domain",
|
||||
"domainPlaceholder": "optional",
|
||||
"connectToTerminal": "Connect to Terminal",
|
||||
"connectToFiles": "Connect to Files",
|
||||
"connectWith": "Connect via {{protocol}}"
|
||||
"connectWith": "Connect via {{protocol}}",
|
||||
"connectToAction": "Connect to {{name}}"
|
||||
},
|
||||
"sshTools": {
|
||||
"keyRecordingTitle": "Key Recording",
|
||||
@@ -2150,7 +2136,6 @@
|
||||
"uiArea_terminal": "Terminal",
|
||||
"uiArea_fileManager": "File manager",
|
||||
"uiArea_hostEditor": "Host editor",
|
||||
"uiArea_homepage": "Homepage",
|
||||
"sectionAppearance": "Appearance",
|
||||
"sectionSecurity": "Security",
|
||||
"sectionApiKeys": "API Keys",
|
||||
@@ -2328,7 +2313,9 @@
|
||||
"discordUrlInvalid": "Discord URL must be a valid Discord webhook URL",
|
||||
"discordWebhookDesc": "POST a Discord embed to this webhook URL on each alert firing",
|
||||
"discordUsername": "Display name (optional)",
|
||||
"discordAvatar": "Avatar URL (optional)"
|
||||
"discordAvatar": "Avatar URL (optional)",
|
||||
"failedToLoad": "Failed to load alerts",
|
||||
"failedToDismiss": "Failed to dismiss alert"
|
||||
},
|
||||
"macros": {
|
||||
"title": "Macros",
|
||||
|
||||
+4
-445
@@ -646,12 +646,10 @@ function getApiUrl(path: string, defaultPort: number): string {
|
||||
// PER-HOST ORIGIN ROUTING (Electron desktop only)
|
||||
// ============================================================================
|
||||
//
|
||||
// hostApi/fileManagerApi above always point at the
|
||||
// embedded local backend -- they're the shared, always-on instances. When a
|
||||
// host's connection origin resolves to "remote" (see
|
||||
// src/ui/lib/connection-origin.ts), the backend that actually holds that
|
||||
// host's live SSH session is the connected remote server instead, so file
|
||||
// manager and stats calls for that host must follow it there.
|
||||
// hostApi above always points at the embedded local backend. When a host's
|
||||
// connection origin resolves to "remote" (see src/ui/lib/connection-origin.ts),
|
||||
// the backend that holds that host's live session is the connected remote
|
||||
// server instead, so calls for that host must follow it there.
|
||||
//
|
||||
// These dynamically-baseURL'd instances resolve the remote server's URL and
|
||||
// JWT fresh on every request (cheap, and correct even if the user
|
||||
@@ -695,18 +693,8 @@ export function createRemoteOriginApiInstance(path: string): AxiosInstance {
|
||||
return instance;
|
||||
}
|
||||
|
||||
let remoteFileManagerApi: AxiosInstance | null = null;
|
||||
let remoteCoreApi: AxiosInstance | null = null;
|
||||
|
||||
export function getRemoteFileManagerApi(): AxiosInstance {
|
||||
if (!remoteFileManagerApi) {
|
||||
remoteFileManagerApi = createRemoteOriginApiInstance(
|
||||
"/plugin-api/file-manager",
|
||||
);
|
||||
}
|
||||
return remoteFileManagerApi;
|
||||
}
|
||||
|
||||
/**
|
||||
* The remote server's core routes, unprefixed.
|
||||
*
|
||||
@@ -719,46 +707,11 @@ export function getRemoteCoreApi(): AxiosInstance {
|
||||
return remoteCoreApi;
|
||||
}
|
||||
|
||||
// Maps a live SSH session (keyed by sessionId, which today is the host's
|
||||
// numeric id as a string -- see ensureSSHSessionForHost) to the resolved
|
||||
// origin it was connected through, so every subsequent file-manager call
|
||||
// for that session reaches the backend that actually holds it.
|
||||
const sessionOrigins = new Map<string, "local" | "remote">();
|
||||
|
||||
export function setSessionOrigin(
|
||||
sessionId: string,
|
||||
origin: "local" | "remote",
|
||||
): void {
|
||||
sessionOrigins.set(sessionId, origin);
|
||||
}
|
||||
|
||||
export function clearSessionOrigin(sessionId: string): void {
|
||||
sessionOrigins.delete(sessionId);
|
||||
}
|
||||
|
||||
export function getFileManagerApiForSession(sessionId: string): AxiosInstance {
|
||||
return sessionOrigins.get(sessionId) === "remote"
|
||||
? getRemoteFileManagerApi()
|
||||
: fileManagerApi;
|
||||
}
|
||||
|
||||
/** Which backend currently holds a live SSH session (see setSessionOrigin). */
|
||||
export function getSessionOrigin(sessionId: string): "local" | "remote" {
|
||||
return sessionOrigins.get(sessionId) === "remote" ? "remote" : "local";
|
||||
}
|
||||
|
||||
function initializeApiInstances() {
|
||||
// Host Management API (port 30001) - supports SSH, RDP, VNC, Telnet
|
||||
hostApi = createApiInstance(getApiUrl("/host", 30001), "HOST");
|
||||
sshHostApi = hostApi;
|
||||
|
||||
// File Manager Operations API - the file-manager plugin, mounted on the
|
||||
// main backend.
|
||||
fileManagerApi = createApiInstance(
|
||||
getApiUrl("/plugin-api/file-manager", 30001),
|
||||
"FILE_MANAGER",
|
||||
);
|
||||
|
||||
// Authentication API (port 30001)
|
||||
authApi = createApiInstance(getApiUrl("", 30001), "AUTH");
|
||||
|
||||
@@ -771,9 +724,6 @@ export let hostApi: AxiosInstance;
|
||||
// Backward compatibility
|
||||
export let sshHostApi: AxiosInstance;
|
||||
|
||||
// File Manager Operations API (file-manager plugin)
|
||||
export let fileManagerApi: AxiosInstance;
|
||||
|
||||
// Authentication API (port 30001)
|
||||
export let authApi: AxiosInstance;
|
||||
|
||||
@@ -949,391 +899,12 @@ export function handleApiError(error: unknown, operation: string): never {
|
||||
|
||||
// ============================================================================
|
||||
|
||||
// ============================================================================
|
||||
// HOST-TO-HOST TRANSFER
|
||||
// ============================================================================
|
||||
|
||||
export type TransferMethodPreference = "auto" | "tar" | "item_sftp";
|
||||
|
||||
export interface TransferScanSummary {
|
||||
fileCount: number;
|
||||
totalBytes: number;
|
||||
largestFileBytes: number;
|
||||
incompressibleRatio: number;
|
||||
}
|
||||
|
||||
export interface TransferMethodPreview {
|
||||
methodPreference: TransferMethodPreference;
|
||||
resolvedMethod: "tar" | "item_sftp";
|
||||
reasonKey: string;
|
||||
sourcePlatform: "unix" | "windows";
|
||||
destPlatform: "unix" | "windows";
|
||||
sourceHasTar: boolean;
|
||||
destHasTar: boolean;
|
||||
summary: TransferScanSummary;
|
||||
}
|
||||
|
||||
export async function getTransferMethodPreview(
|
||||
sourceSessionId: string,
|
||||
sourcePaths: string[],
|
||||
destSessionId: string,
|
||||
destPath: string,
|
||||
methodPreference?: TransferMethodPreference,
|
||||
): Promise<TransferMethodPreview> {
|
||||
try {
|
||||
const response = await fileManagerApi.post("/transferMethodPreview", {
|
||||
sourceSessionId,
|
||||
sourcePaths,
|
||||
destSessionId,
|
||||
destPath,
|
||||
methodPreference: methodPreference ?? "auto",
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "preview transfer method");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export interface TransferHopMetrics {
|
||||
id: string;
|
||||
mbPerSec?: number;
|
||||
}
|
||||
|
||||
export interface TransferTimings {
|
||||
prepareDestMs?: number;
|
||||
compressMs?: number;
|
||||
transferMs?: number;
|
||||
extractMs?: number;
|
||||
verifyMs?: number;
|
||||
directBenchmarkMs?: number;
|
||||
relayBenchmarkMs?: number;
|
||||
sourceDeleteMs?: number;
|
||||
totalMs?: number;
|
||||
transferBytes?: number;
|
||||
endToEndMbPerSec?: number;
|
||||
hops?: TransferHopMetrics[];
|
||||
}
|
||||
|
||||
export function getTransferProgressPercent(
|
||||
status: TransferProgressResponse,
|
||||
): number | undefined {
|
||||
if (
|
||||
status.bytesTransferred !== undefined &&
|
||||
status.totalBytes !== undefined &&
|
||||
status.totalBytes > 0
|
||||
) {
|
||||
return Math.min(
|
||||
100,
|
||||
Math.round((status.bytesTransferred / status.totalBytes) * 100),
|
||||
);
|
||||
}
|
||||
if (
|
||||
status.itemsCompleted !== undefined &&
|
||||
status.totalItems !== undefined &&
|
||||
status.totalItems > 0
|
||||
) {
|
||||
return Math.min(
|
||||
100,
|
||||
Math.round((status.itemsCompleted / status.totalItems) * 100),
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function formatTransferMbPerSec(
|
||||
mbPerSec?: number,
|
||||
_bytes?: number,
|
||||
_ms?: number,
|
||||
): string {
|
||||
if (mbPerSec === undefined || mbPerSec <= 0) return "";
|
||||
if (mbPerSec < 1) return `${(mbPerSec * 1024).toFixed(0)} KB/s`;
|
||||
return `${mbPerSec.toFixed(1)} MB/s`;
|
||||
}
|
||||
|
||||
export function formatDurationMs(ms?: number): string {
|
||||
if (ms === undefined) return "";
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
||||
const minutes = Math.floor(ms / 60000);
|
||||
const seconds = Math.round((ms % 60000) / 1000);
|
||||
return `${minutes}m ${seconds}s`;
|
||||
}
|
||||
|
||||
interface TransferProgressTracker {
|
||||
update(status: TransferProgressResponse): {
|
||||
rate: number | undefined;
|
||||
stalled: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export function createTransferProgressTracker(): TransferProgressTracker {
|
||||
let lastBytes: number | undefined;
|
||||
let lastTime: number | undefined;
|
||||
let lastRate: number | undefined;
|
||||
let lastActivityTime: number | undefined;
|
||||
const STALL_THRESHOLD_MS = 5000;
|
||||
|
||||
return {
|
||||
update(status) {
|
||||
const now = Date.now();
|
||||
const bytes = status.bytesTransferred;
|
||||
|
||||
if (
|
||||
bytes !== undefined &&
|
||||
lastBytes !== undefined &&
|
||||
lastTime !== undefined
|
||||
) {
|
||||
const deltaBytes = bytes - lastBytes;
|
||||
const deltaMs = now - lastTime;
|
||||
if (deltaMs > 0 && deltaBytes >= 0) {
|
||||
lastRate = (deltaBytes / deltaMs / 1024 / 1024) * 1000;
|
||||
if (deltaBytes > 0) lastActivityTime = now;
|
||||
}
|
||||
} else if (bytes !== undefined) {
|
||||
lastActivityTime = now;
|
||||
}
|
||||
|
||||
lastBytes = bytes;
|
||||
lastTime = now;
|
||||
|
||||
const stalled =
|
||||
lastActivityTime !== undefined &&
|
||||
now - lastActivityTime > STALL_THRESHOLD_MS &&
|
||||
status.status === "running" &&
|
||||
status.phase === "transferring";
|
||||
|
||||
return { rate: lastRate, stalled };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface TransferProgressResponse {
|
||||
transferId: string;
|
||||
status: "running" | "success" | "partial" | "error" | "cancelled";
|
||||
phase:
|
||||
| "compressing"
|
||||
| "transferring"
|
||||
| "benchmarking"
|
||||
| "verifying"
|
||||
| "extracting"
|
||||
| "reconnecting";
|
||||
bytesTransferred?: number;
|
||||
totalBytes?: number;
|
||||
itemsCompleted?: number;
|
||||
totalItems?: number;
|
||||
failedPaths?: string[];
|
||||
message?: string;
|
||||
method?: "stream" | "tar" | "item_sftp" | "direct_rsync";
|
||||
sourcePaths?: string[];
|
||||
destPath?: string;
|
||||
sourceSessionId?: string;
|
||||
destSessionId?: string;
|
||||
startedAt?: number;
|
||||
timings?: TransferTimings;
|
||||
sourceDeleted?: boolean;
|
||||
moveRequested?: boolean;
|
||||
partialDestRemaining?: boolean;
|
||||
cleanupCompleted?: boolean;
|
||||
retryable?: boolean;
|
||||
integrityVerified?: boolean;
|
||||
parallelSegmentCount?: number;
|
||||
}
|
||||
|
||||
export async function transferToHost(
|
||||
sourceSessionId: string,
|
||||
sourcePaths: string[],
|
||||
destSessionId: string,
|
||||
destPath: string,
|
||||
move?: boolean,
|
||||
methodPreference?: TransferMethodPreference,
|
||||
parallelSegmentCount?: number,
|
||||
): Promise<{ transferId: string }> {
|
||||
try {
|
||||
fileLogger.info("Starting host transfer", {
|
||||
operation: "host_transfer",
|
||||
sourceSessionId,
|
||||
destSessionId,
|
||||
sourcePaths,
|
||||
destPath,
|
||||
move,
|
||||
methodPreference,
|
||||
parallelSegmentCount,
|
||||
});
|
||||
|
||||
const response = await fileManagerApi.post("/transferToHost", {
|
||||
sourceSessionId,
|
||||
sourcePaths,
|
||||
destSessionId,
|
||||
destPath,
|
||||
move,
|
||||
methodPreference: methodPreference ?? "auto",
|
||||
parallelSegmentCount,
|
||||
});
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
fileLogger.error("Failed to start host transfer", error, {
|
||||
operation: "host_transfer",
|
||||
sourceSessionId,
|
||||
destSessionId,
|
||||
sourcePaths,
|
||||
});
|
||||
handleApiError(error, "transfer to host");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTransferStatus(
|
||||
transferId: string,
|
||||
): Promise<TransferProgressResponse> {
|
||||
try {
|
||||
const response = await fileManagerApi.get(`/transferStatus/${transferId}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "get transfer status");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function listActiveTransfers(): Promise<{
|
||||
transfers: TransferProgressResponse[];
|
||||
}> {
|
||||
try {
|
||||
const response = await fileManagerApi.get("/activeTransfers");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "list active transfers");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function cancelTransferToHost(transferId: string): Promise<void> {
|
||||
try {
|
||||
await fileManagerApi.post(`/transferCancel/${transferId}`);
|
||||
} catch (error) {
|
||||
fileLogger.warn("Transfer cancel request failed (non-fatal)", {
|
||||
operation: "host_transfer",
|
||||
transferId,
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function cleanupCancelledTransfer(
|
||||
transferId: string,
|
||||
): Promise<{ removedPaths: string[]; failedPaths: string[] }> {
|
||||
try {
|
||||
const response = await fileManagerApi.post(
|
||||
`/transferCleanup/${transferId}`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "clean up cancelled transfer");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function retryTransferToHost(
|
||||
transferId: string,
|
||||
): Promise<{ ok: boolean; transferId: string }> {
|
||||
try {
|
||||
const response = await fileManagerApi.post(`/transferRetry/${transferId}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "retry transfer");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function pollTransferUntilComplete(
|
||||
transferId: string,
|
||||
onProgress?: (status: TransferProgressResponse) => void,
|
||||
intervalMs = 500,
|
||||
): Promise<TransferProgressResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const poll = async () => {
|
||||
try {
|
||||
const status = await getTransferStatus(transferId);
|
||||
onProgress?.(status);
|
||||
|
||||
if (
|
||||
status.status === "success" ||
|
||||
status.status === "partial" ||
|
||||
status.status === "error" ||
|
||||
status.status === "cancelled"
|
||||
) {
|
||||
resolve(status);
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(poll, intervalMs);
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
};
|
||||
|
||||
void poll();
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// FILE MANAGER DATA
|
||||
// ============================================================================
|
||||
|
||||
export interface TransferDestination {
|
||||
id: number;
|
||||
userId: string;
|
||||
sourceHostId: number;
|
||||
destHostId: number;
|
||||
destPath: string;
|
||||
destPathLabel?: string;
|
||||
lastUsed?: string;
|
||||
}
|
||||
|
||||
export async function getTransferRecent(
|
||||
sourceHostId: number,
|
||||
): Promise<TransferDestination[]> {
|
||||
try {
|
||||
const response = await authApi.get("/host/transfer/recent", {
|
||||
params: { sourceHostId },
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "get transfer recent destinations");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function addTransferRecent(
|
||||
sourceHostId: number,
|
||||
destHostId: number,
|
||||
destPath: string,
|
||||
destPathLabel?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.post("/host/transfer/recent", {
|
||||
sourceHostId,
|
||||
destHostId,
|
||||
destPath,
|
||||
destPathLabel,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "add transfer recent destination");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
getSSHHosts,
|
||||
createSSHHost,
|
||||
updateSSHHost,
|
||||
bulkImportSSHHosts,
|
||||
importSSHConfigHosts,
|
||||
discoverProxmoxGuests,
|
||||
discoverProxmoxGuestsStream,
|
||||
syncProxmoxGuests,
|
||||
bulkUpdateSSHHosts,
|
||||
reorderSSHHosts,
|
||||
deleteSSHHost,
|
||||
@@ -1723,10 +1294,6 @@ export {
|
||||
adminCreateUserCredential,
|
||||
adminUpdateUserCredential,
|
||||
adminDeleteUserCredential,
|
||||
adminGetUserSnippets,
|
||||
adminCreateUserSnippet,
|
||||
adminUpdateUserSnippet,
|
||||
adminDeleteUserSnippet,
|
||||
} from "@/api/admin-user-data-api";
|
||||
|
||||
export {
|
||||
@@ -1771,14 +1338,6 @@ export {
|
||||
deployCredentialToHost,
|
||||
} from "@/api/credentials-api";
|
||||
|
||||
// ============================================================================
|
||||
// SNIPPETS API
|
||||
// ============================================================================
|
||||
// Snippets themselves are owned by the snippets plugin. This is the thin
|
||||
// list read a few core pickers still need (see src/ui/api/snippets-api.ts).
|
||||
|
||||
export { getSnippets, executeSnippet } from "@/api/snippets-api";
|
||||
|
||||
// ============================================================================
|
||||
export type { UptimeInfo, RecentActivityItem } from "@/api/dashboard-api";
|
||||
export {
|
||||
|
||||
@@ -118,6 +118,7 @@ export function createPluginApp(
|
||||
labelKey: key(item.titleKey),
|
||||
kind: item.kind === "tab" ? "tab" : undefined,
|
||||
hideable: item.hideable,
|
||||
simplePreset: item.simplePreset,
|
||||
promotable: item.promotable,
|
||||
rightDockable: item.rightDockable,
|
||||
mobilePrimary: item.mobilePrimary,
|
||||
|
||||
@@ -50,7 +50,6 @@ const loginMethods = createRegistry<LoginMethodDef>();
|
||||
const secondFactors = createRegistry<SecondFactorDef>();
|
||||
|
||||
export const registerSshAuthEditor = sshAuthEditors.register;
|
||||
export const getSshAuthEditor = sshAuthEditors.get;
|
||||
export const useSshAuthEditors = sshAuthEditors.useList;
|
||||
|
||||
export const registerLoginMethod = loginMethods.register;
|
||||
@@ -62,9 +61,3 @@ export const registerSecondFactor = secondFactors.register;
|
||||
export const getSecondFactorUI = secondFactors.get;
|
||||
export const useSecondFactors = secondFactors.useList;
|
||||
export const listSecondFactorUIs = secondFactors.list;
|
||||
|
||||
export function resetAuthRegistries(): void {
|
||||
sshAuthEditors.reset();
|
||||
loginMethods.reset();
|
||||
secondFactors.reset();
|
||||
}
|
||||
@@ -30,7 +30,8 @@ import {
|
||||
} from "@/api/plugins-api";
|
||||
import { getCookie, getUserInfo } from "@/main-axios";
|
||||
import { logActivity } from "@/api/dashboard-api";
|
||||
import { getHostPassword } from "@/api/credentials-api";
|
||||
import { getCredentials, getHostPassword } from "@/api/credentials-api";
|
||||
import { getSSHHosts } from "@/api/ssh-host-management-api";
|
||||
import {
|
||||
getUserPreferences,
|
||||
parseCustomKeybindings,
|
||||
@@ -51,8 +52,8 @@ import { useOptionalHostStatusEntry } from "@/lib/ServerStatusContext";
|
||||
/**
|
||||
* Core permission groups, mirroring RESERVED_PERMISSION_PREFIXES in the SDK
|
||||
* manifest module, which is not imported here because it pulls semver into
|
||||
* the browser bundle. A group that is itself a plugin id (snippets, once)
|
||||
* does not need an entry here: knownPluginIds() below already covers it.
|
||||
* the browser bundle. A plugin's own namespace is covered by knownPluginIds()
|
||||
* below.
|
||||
*/
|
||||
const CORE_PERMISSION_GROUPS = ["hosts", "credentials", "admin"];
|
||||
|
||||
@@ -119,11 +120,6 @@ function loadCurrentUser() {
|
||||
return currentUser;
|
||||
}
|
||||
|
||||
/** Forgets the cached user, on logout. */
|
||||
export function resetBridgeUser(): void {
|
||||
currentUser = null;
|
||||
}
|
||||
|
||||
const SETTINGS_READERS: Record<
|
||||
SettingsScope,
|
||||
(pluginId: string, hostId?: number) => Promise<Record<string, unknown>>
|
||||
@@ -295,6 +291,7 @@ export const pluginHostBridge: PluginHostBridge = {
|
||||
pluginId: option.pluginId,
|
||||
credentialType: option.credentialType,
|
||||
supportsBackground: option.supportsBackground,
|
||||
quickConnect: option.quickConnect === true,
|
||||
})),
|
||||
};
|
||||
},
|
||||
@@ -367,6 +364,20 @@ export const pluginHostBridge: PluginHostBridge = {
|
||||
parseCustomKeybindings((await getUserPreferences()).customKeybindings),
|
||||
setHostAutoTmux: (hostId, autoTmux) => setHostAutoTmux(hostId, autoTmux),
|
||||
getClientPreference: (name) => getCookie(name),
|
||||
listHosts: async () =>
|
||||
(await getSSHHosts({ includeStatus: false })).map(toHostRecord),
|
||||
listCredentials: async () => {
|
||||
const raw = await getCredentials();
|
||||
const list = Array.isArray(raw)
|
||||
? raw
|
||||
: ((raw as { credentials?: unknown[] }).credentials ?? []);
|
||||
return (list as Record<string, unknown>[]).map((c) => ({
|
||||
id: Number(c.id),
|
||||
name: String(c.name ?? ""),
|
||||
username: typeof c.username === "string" ? c.username : undefined,
|
||||
authType: typeof c.authType === "string" ? c.authType : undefined,
|
||||
}));
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -40,10 +40,6 @@ export function registerPluginComponent(
|
||||
};
|
||||
}
|
||||
|
||||
export function getPluginComponent(id: string): AnyComponent | undefined {
|
||||
return components.get(id);
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
@@ -72,9 +68,3 @@ export function PluginComponent({
|
||||
if (!Component) return fallback as ReactNode;
|
||||
return createElement(Component, props);
|
||||
}
|
||||
|
||||
/** Test seam. */
|
||||
export function resetPluginComponents(): void {
|
||||
components.clear();
|
||||
notify();
|
||||
}
|
||||
@@ -26,8 +26,6 @@ export interface RegisteredHomepageWidget {
|
||||
const registry = createRegistry<RegisteredHomepageWidget>();
|
||||
|
||||
export const registerHomepageWidgetType = registry.register;
|
||||
export const unregisterHomepageWidgetType = registry.unregister;
|
||||
export const getHomepageWidgetType = registry.get;
|
||||
export const listHomepageWidgetTypes = registry.list;
|
||||
export const useHomepageWidgetTypes = registry.useList;
|
||||
export const resetHomepageWidgetTypes = registry.reset;
|
||||
@@ -327,7 +327,7 @@ export function syncPlugins(summaries?: PluginSummary[]): Promise<void> {
|
||||
}
|
||||
|
||||
/** Fired by anything that changes plugin state, e.g. the admin toggle. */
|
||||
export const PLUGINS_CHANGED_EVENT = "termix:plugins-changed";
|
||||
const PLUGINS_CHANGED_EVENT = "termix:plugins-changed";
|
||||
|
||||
let lastFocusSync = 0;
|
||||
const FOCUS_SYNC_INTERVAL_MS = 30_000;
|
||||
|
||||
@@ -38,7 +38,7 @@ function commit(next: StoreState): void {
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
export function subscribePluginStore(listener: () => void): () => void {
|
||||
function subscribePluginStore(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
|
||||
@@ -75,7 +75,7 @@ class PluginErrorBoundary extends Component<BoundaryProps, { error: boolean }> {
|
||||
}
|
||||
}
|
||||
|
||||
export function PluginScope({
|
||||
function PluginScope({
|
||||
pluginId,
|
||||
children,
|
||||
}: {
|
||||
|
||||
@@ -159,3 +159,40 @@ export {
|
||||
testNotificationChannel,
|
||||
type NotificationChannel,
|
||||
} from "@/api/notification-channels-api";
|
||||
|
||||
// Keyboard hints, the connection log panel and a named console logger.
|
||||
export { Kbd, KbdKey, KbdSeparator } from "@/components/kbd";
|
||||
export { ConnectionLogPanel } from "@/components/connection/ConnectionLogPanel";
|
||||
export type { LogEntry } from "@/types/connection-log";
|
||||
export { createFrontendLogger, type LogContext } from "@/lib/frontend-logger";
|
||||
|
||||
// One budget for background work across the whole app (preloads, prefetches).
|
||||
export {
|
||||
getAdaptiveResourceBudget,
|
||||
markAdaptiveResourceUsed,
|
||||
runAdaptiveBackgroundTask,
|
||||
} from "@/lib/adaptive-resource-budget";
|
||||
|
||||
// The id this browser sends as X-Termix-Device-ID.
|
||||
export { getDeviceId } from "@/lib/device-id";
|
||||
|
||||
// A host that is never saved, for connecting to an address straight away.
|
||||
export {
|
||||
createQuickConnectHost,
|
||||
isQuickConnectHost,
|
||||
} from "@/sidebar/quick-connect-host";
|
||||
|
||||
// The desktop app's local file bridge (window.electronAPI.localFs and
|
||||
// localTransfer), for plugins that browse or transfer local files.
|
||||
export type {
|
||||
LocalCollectedFile,
|
||||
LocalDirectoryListing,
|
||||
LocalFileEntry,
|
||||
LocalFsHomeInfo,
|
||||
LocalFsResult,
|
||||
LocalTransferOrigin,
|
||||
LocalTransferProgress,
|
||||
LocalTrashResult,
|
||||
LocalWalkFile,
|
||||
LocalWalkResult,
|
||||
} from "@/types/electron";
|
||||
@@ -59,10 +59,6 @@ export function useShellHosts(): { hosts: Host[]; loaded: boolean } {
|
||||
);
|
||||
}
|
||||
|
||||
export function getShellHosts(): Host[] {
|
||||
return hosts;
|
||||
}
|
||||
|
||||
export function notifyTabsChanged(): void {
|
||||
for (const listener of changeListeners) listener();
|
||||
}
|
||||
|
||||
@@ -9,7 +9,10 @@
|
||||
|
||||
import type { ReactElement } from "react";
|
||||
import { render } from "@testing-library/react";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/frontend";
|
||||
import type {
|
||||
HostActionContribution,
|
||||
PluginManifest,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import type {
|
||||
FrontendPluginModule,
|
||||
RenderWithAppOptions,
|
||||
@@ -27,7 +30,7 @@ import {
|
||||
} from "@/shell/tab-registry";
|
||||
import { getPanel, listPanels } from "@/shell/panel-registry";
|
||||
import { listRegisteredRailItems } from "@/sidebar/rail-items";
|
||||
import { listHostActions } from "@/sidebar/host-contributions";
|
||||
import { hostActionsFor, listHostActions } from "@/sidebar/host-contributions";
|
||||
import { listHostProtocols } from "@/sidebar/host-protocols";
|
||||
import {
|
||||
getHostEditorSection,
|
||||
@@ -214,6 +217,10 @@ export async function renderPlugin(
|
||||
id: action.id,
|
||||
tabType: action.tabType,
|
||||
})),
|
||||
hostActionsFor: (host) =>
|
||||
mine(
|
||||
hostActionsFor(listHostActions(), host as Host),
|
||||
) as unknown as HostActionContribution[],
|
||||
hostProtocols: () =>
|
||||
mine(listHostProtocols()).map((protocol) => protocol.id),
|
||||
hostEditorSections: () =>
|
||||
|
||||
@@ -61,12 +61,6 @@ export function listSettingsComponents(): string[] {
|
||||
/** Re-renders when components are registered, e.g. after a plugin loads. */
|
||||
export const useSettingsComponents = registry.useList;
|
||||
|
||||
export function unregisterSettingsComponents(pluginId: string): void {
|
||||
for (const entry of registry.list()) {
|
||||
if (entry.id.startsWith(`${pluginId}:`)) registry.unregister(entry.id);
|
||||
}
|
||||
}
|
||||
|
||||
/** Test helper. */
|
||||
export function resetSettingsComponents(): void {
|
||||
registry.reset();
|
||||
|
||||
@@ -26,11 +26,8 @@ import {
|
||||
Play,
|
||||
Clipboard,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
getRecentActivity,
|
||||
getSnippets,
|
||||
type RecentActivityItem,
|
||||
} from "@/main-axios";
|
||||
import { getRecentActivity, type RecentActivityItem } from "@/main-axios";
|
||||
import { listSnippets } from "@/lib/snippet-provider";
|
||||
import type { Host, TabType, Tab, Snippet } from "@/types/ui-types";
|
||||
import { canEditHost } from "@/sidebar/host-permissions";
|
||||
import { RAIL_UTILITY_ITEMS, useRailItems } from "@/sidebar/rail-items";
|
||||
@@ -85,8 +82,8 @@ export function CommandPalette({
|
||||
getRecentActivity(5)
|
||||
.then(setRecentActivity)
|
||||
.catch(() => {});
|
||||
getSnippets()
|
||||
.then((data) => setSnippets((data ?? []) as unknown as Snippet[]))
|
||||
listSnippets()
|
||||
.then((data) => setSnippets(data as unknown as Snippet[]))
|
||||
.catch(() => {});
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
@@ -104,10 +104,6 @@ export function registerAction(
|
||||
};
|
||||
}
|
||||
|
||||
export function unregisterAction(id: string): void {
|
||||
if (actions.delete(id)) emit();
|
||||
}
|
||||
|
||||
/** Every registered action. */
|
||||
export function listActions(): RegisteredAction[] {
|
||||
return [...actions.values()];
|
||||
@@ -151,15 +147,6 @@ export function declareActionSlot(slot: ActionSlotDefinition): () => void {
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether a slot is declared, i.e. its owner is running. */
|
||||
export function isActionSlotDeclared(slotId: string): boolean {
|
||||
return slots.has(slotId);
|
||||
}
|
||||
|
||||
export function undeclareActionSlot(slotId: string): void {
|
||||
if (slots.delete(slotId)) emit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a contribution to a slot.
|
||||
*
|
||||
|
||||
@@ -24,7 +24,6 @@ export interface PanelDef {
|
||||
const registry = createRegistry<PanelDef>();
|
||||
|
||||
export const registerPanel = registry.register;
|
||||
export const unregisterPanel = registry.unregister;
|
||||
export const getPanel = registry.get;
|
||||
export const listPanels = registry.list;
|
||||
export const usePanels = registry.useList;
|
||||
|
||||
@@ -37,7 +37,7 @@ const CORE_UNSAVED = new Set([
|
||||
* failed, still loading or not installed. The shell shows a placeholder for
|
||||
* it, and a layout keeps it so nothing is lost when the plugin comes back.
|
||||
*/
|
||||
export function isUnregisteredPluginTabType(type: string): boolean {
|
||||
function isUnregisteredPluginTabType(type: string): boolean {
|
||||
return (
|
||||
!CORE_UNSAVED.has(type) && !CORE_CAPTURABLE.has(type) && !getTabType(type)
|
||||
);
|
||||
|
||||
@@ -81,10 +81,8 @@ export interface TabTypeDef {
|
||||
const registry = createRegistry<TabTypeDef>();
|
||||
|
||||
export const registerTabType = registry.register;
|
||||
export const unregisterTabType = registry.unregister;
|
||||
export const getTabType = registry.get;
|
||||
export const listTabTypes = registry.list;
|
||||
export const subscribeToTabTypes = registry.subscribe;
|
||||
export const useTabTypes = registry.useList;
|
||||
export const resetTabTypes = registry.reset;
|
||||
|
||||
@@ -115,7 +113,7 @@ export function isSessionTabType(type: string): boolean {
|
||||
return CORE_SESSION.has(type) || !!registry.get(type)?.session;
|
||||
}
|
||||
|
||||
export function isHostlessTabType(type: string): boolean {
|
||||
function isHostlessTabType(type: string): boolean {
|
||||
return CORE_HOSTLESS.has(type) || !!registry.get(type)?.hostless;
|
||||
}
|
||||
|
||||
|
||||
@@ -768,7 +768,7 @@ export function AdminSettingsPanel({
|
||||
const total =
|
||||
(s.sshHostsImported || 0) +
|
||||
(s.sshCredentialsImported || 0) +
|
||||
(s.fileManagerItemsImported || 0) +
|
||||
(s.pluginItemsImported || 0) +
|
||||
(s.dismissedAlertsImported || 0) +
|
||||
(s.settingsImported || 0);
|
||||
toast.success(
|
||||
|
||||
@@ -24,10 +24,6 @@ import {
|
||||
adminDeleteUserHost,
|
||||
adminGetUserCredentials,
|
||||
adminDeleteUserCredential,
|
||||
adminGetUserSnippets,
|
||||
adminCreateUserSnippet,
|
||||
adminUpdateUserSnippet,
|
||||
adminDeleteUserSnippet,
|
||||
adminResetUserPassword,
|
||||
adminExportUserData,
|
||||
getSessions,
|
||||
@@ -55,6 +51,7 @@ import { HostEditor } from "./HostEditor";
|
||||
import { mapCredentials, sshHostToHost } from "./HostManagerData";
|
||||
import type { AdminSession, AdminUser } from "./AdminManagementSections";
|
||||
import { makeCredentialTabs, makeHostTabs, TabStrip } from "./HostManagerTabs";
|
||||
import { useActionSlot } from "@/hooks/use-action-slot";
|
||||
|
||||
type ApiErrorLike = {
|
||||
response?: {
|
||||
@@ -65,18 +62,9 @@ type ApiErrorLike = {
|
||||
};
|
||||
};
|
||||
|
||||
// user-snippets rather than the snippets plugin's own id: this is a tab key
|
||||
// in this admin panel's own local state, not a lookup into the plugin registry.
|
||||
type ManageTabId =
|
||||
"account" | "hosts" | "credentials" | "user-snippets" | "sessions" | "danger";
|
||||
|
||||
type ManagedSnippet = {
|
||||
id: number;
|
||||
name: string;
|
||||
content: string;
|
||||
description?: string | null;
|
||||
folder?: string | null;
|
||||
};
|
||||
// Core tabs, or "plugin:<actionId>" for a tab a plugin contributes to the
|
||||
// admin.userTabs slot.
|
||||
type ManageTabId = string;
|
||||
|
||||
type EditorState =
|
||||
| { kind: "host"; host: Host | null }
|
||||
@@ -91,19 +79,6 @@ function apiErrorMessage(error: unknown, fallback: string) {
|
||||
return (error as ApiErrorLike).response?.data?.error || fallback;
|
||||
}
|
||||
|
||||
function mapSnippets(res: unknown): ManagedSnippet[] {
|
||||
const list = Array.isArray(res)
|
||||
? res
|
||||
: ((res as { snippets?: unknown[] })?.snippets ?? []);
|
||||
return (list as Record<string, unknown>[]).map((s) => ({
|
||||
id: Number(s.id),
|
||||
name: String(s.name ?? ""),
|
||||
content: String(s.content ?? ""),
|
||||
description: (s.description as string | null) ?? null,
|
||||
folder: (s.folder as string | null) ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
export function AdminUserManagePanel({
|
||||
user,
|
||||
roles,
|
||||
@@ -150,23 +125,11 @@ export function AdminUserManagePanel({
|
||||
|
||||
const [hosts, setHosts] = useState<Host[]>([]);
|
||||
const [credentials, setCredentials] = useState<Credential[]>([]);
|
||||
const [snippets, setSnippets] = useState<ManagedSnippet[]>([]);
|
||||
const [sessions, setSessions] = useState<AdminSession[]>([]);
|
||||
const [apiKeys, setApiKeys] = useState<ApiKey[]>([]);
|
||||
const [userRoles, setUserRoles] = useState<UserRole[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Snippet inline editor
|
||||
const [editingSnippet, setEditingSnippet] = useState<
|
||||
ManagedSnippet | "new" | null
|
||||
>(null);
|
||||
const [snippetForm, setSnippetForm] = useState({
|
||||
name: "",
|
||||
content: "",
|
||||
folder: "",
|
||||
});
|
||||
const [snippetSaving, setSnippetSaving] = useState(false);
|
||||
|
||||
// Account tab state
|
||||
const [resetPassword, setResetPassword] = useState("");
|
||||
const [resetLoading, setResetLoading] = useState(false);
|
||||
@@ -194,13 +157,6 @@ export function AdminUserManagePanel({
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
const reloadSnippets = () => {
|
||||
if (!dataUnlocked) return;
|
||||
adminGetUserSnippets(user.id)
|
||||
.then((res) => setSnippets(mapSnippets(res)))
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
const reloadSessions = () => {
|
||||
getSessions()
|
||||
.then(({ sessions: s }) =>
|
||||
@@ -214,12 +170,11 @@ export function AdminUserManagePanel({
|
||||
Promise.allSettled([
|
||||
dataUnlocked ? adminGetUserHosts(user.id) : Promise.resolve([]),
|
||||
dataUnlocked ? adminGetUserCredentials(user.id) : Promise.resolve([]),
|
||||
dataUnlocked ? adminGetUserSnippets(user.id) : Promise.resolve([]),
|
||||
getSessions(),
|
||||
getApiKeys(),
|
||||
getUserRoles(user.id),
|
||||
])
|
||||
.then(([h, c, s, sess, keys, ur]) => {
|
||||
.then(([h, c, sess, keys, ur]) => {
|
||||
if (h.status === "fulfilled" && dataUnlocked) {
|
||||
setHosts(
|
||||
(h.value as SSHHostWithStatus[]).map((raw) => sshHostToHost(raw)),
|
||||
@@ -228,9 +183,6 @@ export function AdminUserManagePanel({
|
||||
if (c.status === "fulfilled" && dataUnlocked) {
|
||||
setCredentials(mapCredentials(c.value));
|
||||
}
|
||||
if (s.status === "fulfilled" && dataUnlocked) {
|
||||
setSnippets(mapSnippets(s.value));
|
||||
}
|
||||
if (sess.status === "fulfilled") {
|
||||
setSessions(
|
||||
sess.value.sessions.filter((session) => session.userId === user.id),
|
||||
@@ -246,11 +198,21 @@ export function AdminUserManagePanel({
|
||||
.finally(() => setLoading(false));
|
||||
}, [user.id, dataUnlocked]);
|
||||
|
||||
const pluginTabs = useActionSlot("admin.userTabs").filter(
|
||||
(contribution) =>
|
||||
contribution.kind === "component" && contribution.component,
|
||||
);
|
||||
const activePluginTab = pluginTabs.find(
|
||||
(contribution) => `plugin:${contribution.actionId}` === activeTab,
|
||||
);
|
||||
const manageTabs: { id: ManageTabId; label: string }[] = [
|
||||
{ id: "account", label: t("admin.manageTabAccount") },
|
||||
{ id: "hosts", label: t("admin.manageTabHosts") },
|
||||
{ id: "credentials", label: t("admin.manageTabCredentials") },
|
||||
{ id: "user-snippets", label: t("admin.manageTabSnippets") },
|
||||
...pluginTabs.map((contribution) => ({
|
||||
id: `plugin:${contribution.actionId}`,
|
||||
label: t(contribution.titleKey),
|
||||
})),
|
||||
{ id: "sessions", label: t("admin.manageTabSessions") },
|
||||
{ id: "danger", label: t("admin.manageTabDanger") },
|
||||
];
|
||||
@@ -348,33 +310,6 @@ export function AdminUserManagePanel({
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveSnippet() {
|
||||
if (!snippetForm.name.trim() || !snippetForm.content.trim()) {
|
||||
toast.error(t("admin.snippetRequiredFields"));
|
||||
return;
|
||||
}
|
||||
setSnippetSaving(true);
|
||||
try {
|
||||
const payload = {
|
||||
name: snippetForm.name.trim(),
|
||||
content: snippetForm.content,
|
||||
folder: snippetForm.folder.trim() || null,
|
||||
};
|
||||
if (editingSnippet !== "new" && editingSnippet) {
|
||||
await adminUpdateUserSnippet(user.id, editingSnippet.id, payload);
|
||||
} else {
|
||||
await adminCreateUserSnippet(user.id, payload);
|
||||
}
|
||||
setEditingSnippet(null);
|
||||
reloadSnippets();
|
||||
toast.success(t("admin.snippetSaved"));
|
||||
} catch (e) {
|
||||
toast.error(apiErrorMessage(e, t("admin.snippetSaveFailed")));
|
||||
} finally {
|
||||
setSnippetSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const lockedNotice = (
|
||||
<div className="flex items-start gap-2.5 p-3 mt-2 border border-border bg-muted/20 text-xs text-muted-foreground">
|
||||
<Lock className="size-3.5 shrink-0 mt-0.5" />
|
||||
@@ -939,161 +874,11 @@ export function AdminUserManagePanel({
|
||||
</div>
|
||||
))}
|
||||
|
||||
{activeTab === "user-snippets" &&
|
||||
{activePluginTab &&
|
||||
(!dataUnlocked ? (
|
||||
lockedNotice
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center justify-between py-2 border-b border-border">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("admin.snippetsCount", { count: snippets.length })}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6 text-muted-foreground hover:text-foreground"
|
||||
onClick={reloadSnippets}
|
||||
>
|
||||
<RefreshCw className="size-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 text-[10px] border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
onClick={() => {
|
||||
setSnippetForm({ name: "", content: "", folder: "" });
|
||||
setEditingSnippet("new");
|
||||
}}
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
{t("admin.addSnippetForUser")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{editingSnippet && (
|
||||
<div className="flex flex-col gap-2.5 py-3 border-b border-border">
|
||||
<Input
|
||||
className="h-8 text-xs"
|
||||
placeholder={t("admin.snippetNamePlaceholder")}
|
||||
value={snippetForm.name}
|
||||
onChange={(e) =>
|
||||
setSnippetForm((p) => ({ ...p, name: e.target.value }))
|
||||
}
|
||||
/>
|
||||
<textarea
|
||||
rows={4}
|
||||
placeholder={t("admin.snippetContentPlaceholder")}
|
||||
value={snippetForm.content}
|
||||
onChange={(e) =>
|
||||
setSnippetForm((p) => ({
|
||||
...p,
|
||||
content: e.target.value,
|
||||
}))
|
||||
}
|
||||
className="w-full px-3 py-2 text-[10px] bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring font-mono"
|
||||
/>
|
||||
<Input
|
||||
className="h-8 text-xs"
|
||||
placeholder={t("admin.snippetFolderPlaceholder")}
|
||||
value={snippetForm.folder}
|
||||
onChange={(e) =>
|
||||
setSnippetForm((p) => ({
|
||||
...p,
|
||||
folder: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 text-[10px]"
|
||||
onClick={() => setEditingSnippet(null)}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 text-[10px] border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
disabled={snippetSaving}
|
||||
onClick={handleSaveSnippet}
|
||||
>
|
||||
{snippetSaving ? t("hosts.savingBtn") : t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!loading && snippets.length === 0 && !editingSnippet && (
|
||||
<span className="text-xs text-muted-foreground py-3">
|
||||
{t("admin.noSnippetsForUser")}
|
||||
</span>
|
||||
)}
|
||||
{snippets.map((snippet) => (
|
||||
<div
|
||||
key={snippet.id}
|
||||
className="flex items-center justify-between py-2.5 border-b border-border last:border-0"
|
||||
>
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<span className="text-xs font-semibold truncate max-w-[180px]">
|
||||
{snippet.name}
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-muted-foreground truncate max-w-[220px]">
|
||||
{snippet.content}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => {
|
||||
setSnippetForm({
|
||||
name: snippet.name,
|
||||
content: snippet.content,
|
||||
folder: snippet.folder ?? "",
|
||||
});
|
||||
setEditingSnippet(snippet);
|
||||
}}
|
||||
>
|
||||
<Pencil className="size-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6 text-muted-foreground hover:text-destructive"
|
||||
onClick={() =>
|
||||
setConfirmDialog({
|
||||
message: t("admin.deleteSnippetConfirm", {
|
||||
name: snippet.name,
|
||||
username: user.username,
|
||||
}),
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await adminDeleteUserSnippet(user.id, snippet.id);
|
||||
setSnippets((prev) =>
|
||||
prev.filter((s) => s.id !== snippet.id),
|
||||
);
|
||||
toast.success(t("admin.snippetDeletedSuccess"));
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
apiErrorMessage(
|
||||
e,
|
||||
t("admin.snippetDeleteFailed"),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<activePluginTab.component user={user} />
|
||||
))}
|
||||
|
||||
{activeTab === "sessions" && (
|
||||
|
||||
@@ -294,13 +294,13 @@ export function CredentialEditorView({
|
||||
onChange={(e) => setCredField("password", e.target.value)}
|
||||
/>
|
||||
<PluginComponent
|
||||
id="secret-sources.hint"
|
||||
id="credentials.secretHint"
|
||||
onManage={() => setShowSecretSources((v) => !v)}
|
||||
/>
|
||||
</div>
|
||||
{showSecretSources && (
|
||||
<PluginComponent
|
||||
id="secret-sources.manager"
|
||||
id="credentials.secretManager"
|
||||
onClose={() => setShowSecretSources(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -34,18 +34,17 @@ import {
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { listSnippets } from "@/lib/snippet-provider";
|
||||
import { SectionCard, SettingRow, FakeSwitch } from "@/components/section-card";
|
||||
import { TerminalPreview } from "@/components/terminal-preview/TerminalPreview";
|
||||
import {
|
||||
createSSHHost,
|
||||
updateSSHHost,
|
||||
getSnippets,
|
||||
getUserInfo,
|
||||
getHostPassword,
|
||||
adminCreateUserHost,
|
||||
adminUpdateUserHost,
|
||||
adminGetHostPassword,
|
||||
adminGetUserSnippets,
|
||||
createCredential,
|
||||
adminCreateUserCredential,
|
||||
} from "@/main-axios";
|
||||
@@ -240,10 +239,9 @@ export function HostEditor({
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const loadSnippets = adminTargetUserId
|
||||
? adminGetUserSnippets(adminTargetUserId)
|
||||
: getSnippets();
|
||||
loadSnippets
|
||||
listSnippets(
|
||||
adminTargetUserId ? { targetUserId: adminTargetUserId } : undefined,
|
||||
)
|
||||
.then((res) => setSnippets(mapSnippetResponse(res)))
|
||||
.catch(() => {});
|
||||
}, [adminTargetUserId]);
|
||||
@@ -690,7 +688,7 @@ export function HostEditor({
|
||||
onChange={(e) => setField("password", e.target.value)}
|
||||
/>
|
||||
<PluginComponent
|
||||
id="secret-sources.hint"
|
||||
id="credentials.secretHint"
|
||||
onManage={() => setShowSecretSources((v) => !v)}
|
||||
/>
|
||||
</div>
|
||||
@@ -698,7 +696,7 @@ export function HostEditor({
|
||||
{(authMethod === "password" || authMethod === "key") &&
|
||||
showSecretSources && (
|
||||
<PluginComponent
|
||||
id="secret-sources.manager"
|
||||
id="credentials.secretManager"
|
||||
onClose={() => setShowSecretSources(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -21,7 +21,6 @@ import { useUiPreferencesContext } from "@/contexts/UiPreferencesContext";
|
||||
import { applyPresetSideEffects } from "@/lib/apply-ui-preset";
|
||||
import { usePluginStore } from "@/plugin-host/plugin-store";
|
||||
import {
|
||||
HOMEPAGE_AREA_KEY,
|
||||
UI_AREA_KEYS,
|
||||
type UiAreaKey,
|
||||
type UiPreset,
|
||||
@@ -39,9 +38,6 @@ const PRESET_ICONS = {
|
||||
advanced: Wrench,
|
||||
} as const;
|
||||
|
||||
/** Areas that have nothing worth listing as a per-area override. */
|
||||
const HIDDEN_OVERRIDE_AREAS = new Set<UiAreaKey>([HOMEPAGE_AREA_KEY]);
|
||||
|
||||
export function InterfacePresetSettings({
|
||||
onRunSetupAgain,
|
||||
}: {
|
||||
@@ -67,9 +63,7 @@ export function InterfacePresetSettings({
|
||||
Record<string, unknown> | undefined
|
||||
>;
|
||||
const core = UI_AREA_KEYS.filter(
|
||||
(area) =>
|
||||
!HIDDEN_OVERRIDE_AREAS.has(area) &&
|
||||
Object.keys(overrides[area] ?? {}).length > 0,
|
||||
(area) => Object.keys(overrides[area] ?? {}).length > 0,
|
||||
).map((area) => ({
|
||||
key: area as string,
|
||||
label: t(`newUi.sidebar.userProfile.uiArea_${area}`),
|
||||
|
||||
@@ -5,7 +5,8 @@ import {
|
||||
getUserPreferences,
|
||||
parseCustomKeybindings,
|
||||
} from "@/api/open-tabs-api";
|
||||
import { saveUserPreferences, getSnippets } from "@/main-axios";
|
||||
import { saveUserPreferences } from "@/main-axios";
|
||||
import { listSnippets } from "@/lib/snippet-provider";
|
||||
import { BUILT_IN_DEFAULTS } from "@/lib/default-keybindings";
|
||||
import type {
|
||||
CustomKeybinding,
|
||||
@@ -87,7 +88,7 @@ export function KeybindingsDialog({
|
||||
setLoading(true);
|
||||
Promise.all([
|
||||
getUserPreferences().catch(() => null),
|
||||
getSnippets().catch(() => []),
|
||||
listSnippets().catch(() => []),
|
||||
])
|
||||
.then(([prefs, snippetList]) => {
|
||||
setBindings(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Eye, EyeOff, FolderSearch, Terminal } from "lucide-react";
|
||||
import { Eye, EyeOff, Terminal } from "lucide-react";
|
||||
import { Input } from "@/components/input";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
import { getCredentials } from "@/api/credentials-api";
|
||||
@@ -9,6 +9,12 @@ import { createQuickConnectHost } from "./quick-connect-host";
|
||||
import { useHostProtocols } from "./host-protocols";
|
||||
import { Select2 } from "@/components/select2";
|
||||
import { resolveHostTabType } from "@/lib/host-connection-tabs";
|
||||
import { useSshAuthProviders } from "@/hooks/useSshAuthProviders";
|
||||
import { useSshAuthEditors } from "@/plugin-host/auth-registry";
|
||||
import { useHostActions } from "./host-contributions";
|
||||
|
||||
// Core types Quick Connect draws its own fields for.
|
||||
const INLINE_AUTH_TYPES = new Set(["password", "key", "credential"]);
|
||||
|
||||
interface QuickConnectPanelProps {
|
||||
onConnect: (host: Host, type: string) => void;
|
||||
@@ -22,9 +28,8 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
|
||||
const [port, setPort] = useState("22");
|
||||
const [domain, setDomain] = useState("");
|
||||
const [username, setUsername] = useState("root");
|
||||
const [authType, setAuthType] = useState<"password" | "key" | "credential">(
|
||||
"password",
|
||||
);
|
||||
const [authType, setAuthType] = useState("password");
|
||||
const [authFields, setAuthFields] = useState<Record<string, unknown>>({});
|
||||
const [password, setPassword] = useState("");
|
||||
const [privateKey, setPrivateKey] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
@@ -39,6 +44,21 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Every registered SSH auth type that works for a host that is never saved.
|
||||
const { providers } = useSshAuthProviders();
|
||||
const authOptions = providers.filter(
|
||||
(option) => option.available && option.quickConnect,
|
||||
);
|
||||
const authEditor = useSshAuthEditors().find(
|
||||
(editor) => editor.id === authType,
|
||||
);
|
||||
const AuthEditor = INLINE_AUTH_TYPES.has(authType)
|
||||
? undefined
|
||||
: authEditor?.component;
|
||||
const quickActions = useHostActions().filter(
|
||||
(action) => action.quickConnect && action.tabType,
|
||||
);
|
||||
|
||||
const pluginProtocols = useHostProtocols().filter(
|
||||
(entry) => entry.quickConnect,
|
||||
);
|
||||
@@ -67,6 +87,7 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
|
||||
credentialId,
|
||||
protocol: selected,
|
||||
domain: domain || undefined,
|
||||
authFields: INLINE_AUTH_TYPES.has(authType) ? undefined : authFields,
|
||||
});
|
||||
const target = type ?? resolveHostTabType(hostConfig);
|
||||
if (target) onConnect(hostConfig, target);
|
||||
@@ -152,18 +173,21 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
|
||||
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("newUi.sidebar.quickConnect.authLabel")}
|
||||
</label>
|
||||
<div className="flex gap-1">
|
||||
{(["password", "key", "credential"] as const).map((type) => (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{authOptions.map((option) => (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => setAuthType(type)}
|
||||
className={`flex-1 py-1 text-[10px] font-semibold border transition-colors capitalize ${
|
||||
authType === type
|
||||
key={option.type}
|
||||
onClick={() => {
|
||||
setAuthType(option.type);
|
||||
setAuthFields({});
|
||||
}}
|
||||
className={`flex-1 py-1 px-1.5 text-[10px] font-semibold border transition-colors ${
|
||||
authType === option.type
|
||||
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand"
|
||||
: "border-border text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{type}
|
||||
{t(option.editorTitleKey ?? option.labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -257,6 +281,14 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
|
||||
</Select2>
|
||||
</div>
|
||||
)}
|
||||
{!isDesktop && AuthEditor && (
|
||||
<AuthEditor
|
||||
form={authFields}
|
||||
setField={(key, value) =>
|
||||
setAuthFields((current) => ({ ...current, [key]: value }))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-col gap-1.5 pt-1">
|
||||
{selected ? (
|
||||
<button
|
||||
@@ -277,13 +309,18 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
|
||||
<Terminal className="size-3.5" />
|
||||
{t("newUi.sidebar.quickConnect.connectToTerminal")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => connect("files")}
|
||||
className="flex items-center justify-center gap-1.5 h-7 w-full border border-accent-brand/40 bg-accent-brand/10 text-accent-brand text-xs font-semibold hover:bg-accent-brand/20 transition-colors"
|
||||
>
|
||||
<FolderSearch className="size-3.5" />
|
||||
{t("newUi.sidebar.quickConnect.connectToFiles")}
|
||||
</button>
|
||||
{quickActions.map((action) => (
|
||||
<button
|
||||
key={action.id}
|
||||
onClick={() => connect(action.tabType)}
|
||||
className="flex items-center justify-center gap-1.5 h-7 w-full border border-accent-brand/40 bg-accent-brand/10 text-accent-brand text-xs font-semibold hover:bg-accent-brand/20 transition-colors"
|
||||
>
|
||||
<action.icon className="size-3.5" />
|
||||
{t("newUi.sidebar.quickConnect.connectToAction", {
|
||||
name: t(action.titleKey),
|
||||
})}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1256,7 +1256,7 @@ export function UserProfilePanel({
|
||||
const total =
|
||||
(s.sshHostsImported || 0) +
|
||||
(s.sshCredentialsImported || 0) +
|
||||
(s.fileManagerItemsImported || 0) +
|
||||
(s.pluginItemsImported || 0) +
|
||||
(s.dismissedAlertsImported || 0) +
|
||||
(s.settingsImported || 0);
|
||||
toast.success(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export const BRANDING_LOGO_MAX_BYTES = 750 * 1024;
|
||||
export const BRANDING_LOGO_ACCEPT = "image/png,image/jpeg,image/webp";
|
||||
export const BRANDING_LOGO_ACCEPTED_TYPES = new Set([
|
||||
const BRANDING_LOGO_ACCEPTED_TYPES = new Set([
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/webp",
|
||||
|
||||
@@ -19,14 +19,6 @@ import type {
|
||||
CredentialTrayTrigger,
|
||||
} from "@/types/credential-sidebar-preferences";
|
||||
|
||||
export function credentialMatchesQuery(cred: Credential, query: string) {
|
||||
return (
|
||||
cred.name.toLowerCase().includes(query) ||
|
||||
cred.username.toLowerCase().includes(query) ||
|
||||
cred.tags?.some((t) => t.toLowerCase().includes(query))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-density layout knobs, mirroring HOST_ITEM_DENSITY_TOKENS in
|
||||
* HostItem.tsx -- one implementation with a token lookup instead of two
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export { CredentialSidebarTree } from "./CredentialSidebarTree";
|
||||
export { isFolder, type CredentialFolder } from "./visible-rows";
|
||||
export { type CredentialFolder } from "./visible-rows";
|
||||
@@ -17,7 +17,7 @@ export function isFolder(
|
||||
return "children" in item;
|
||||
}
|
||||
|
||||
export function credentialMatchesQuery(cred: Credential, query: string) {
|
||||
function credentialMatchesQuery(cred: Credential, query: string) {
|
||||
return (
|
||||
cred.name.toLowerCase().includes(query) ||
|
||||
cred.username.toLowerCase().includes(query) ||
|
||||
|
||||
@@ -24,6 +24,8 @@ export interface HostActionDef {
|
||||
copyUrlView?: string;
|
||||
/** Where the dashboard's host status list sends a click. */
|
||||
overview?: boolean;
|
||||
/** Offered as a button in Quick Connect, opening tabType. */
|
||||
quickConnect?: boolean;
|
||||
order?: number;
|
||||
/** A label worked out per host, e.g. a single endpoint's name. */
|
||||
label?: (host: Host) => string | undefined;
|
||||
|
||||
@@ -15,7 +15,7 @@ export interface ExportPayload {
|
||||
hosts: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
export const FIELD_GROUP_KEYS: Record<FieldGroup, string[]> = {
|
||||
const FIELD_GROUP_KEYS: Record<FieldGroup, string[]> = {
|
||||
connection: [
|
||||
"connectionType",
|
||||
"name",
|
||||
|
||||
@@ -9,9 +9,11 @@ type QuickConnectInput = Pick<
|
||||
/** A plugin protocol; SSH when omitted. */
|
||||
protocol?: HostProtocolDef;
|
||||
domain?: string;
|
||||
/** Fields a plugin's SSH auth editor filled in. */
|
||||
authFields?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export const QUICK_CONNECT_ID_PREFIX = "quick-connect-";
|
||||
const QUICK_CONNECT_ID_PREFIX = "quick-connect-";
|
||||
|
||||
export function isQuickConnectHost(host: Pick<Host, "id">): boolean {
|
||||
return host.id.startsWith(QUICK_CONNECT_ID_PREFIX);
|
||||
@@ -60,6 +62,7 @@ export function createQuickConnectHost(input: QuickConnectInput): Host {
|
||||
quickActions: [],
|
||||
enableSsh: true,
|
||||
sshPort: input.port,
|
||||
...input.authFields,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,8 @@ export interface RailItemDef {
|
||||
electronOnly?: boolean;
|
||||
/** False keeps it out of the Navigation visibility toggles. */
|
||||
hideable?: boolean;
|
||||
/** A plugin item that stays visible in the Simple preset. */
|
||||
simplePreset?: boolean;
|
||||
/** Registered but not shown, e.g. while its feature is switched off. */
|
||||
hidden?: boolean;
|
||||
/** Places a registered item after this id instead of at the end. */
|
||||
@@ -123,12 +125,6 @@ export function registerRailItem(def: RailItemDef): () => void {
|
||||
return registeredRailItems.register(def);
|
||||
}
|
||||
|
||||
export function unregisterRailItem(id: string): void {
|
||||
registeredRailItems.unregister(id);
|
||||
}
|
||||
|
||||
export const subscribeToRailItems = registeredRailItems.subscribe;
|
||||
|
||||
/** Registered items, hidden ones included. */
|
||||
export const listRegisteredRailItems = registeredRailItems.list;
|
||||
|
||||
|
||||
@@ -117,11 +117,11 @@ function actionOrder(action: HostActionDef): number {
|
||||
return action.order ?? (action.kind === "connect" ? 100 : 50);
|
||||
}
|
||||
|
||||
export async function writeClipboardText(value: string): Promise<void> {
|
||||
async function writeClipboardText(value: string): Promise<void> {
|
||||
await copyToClipboard(value);
|
||||
}
|
||||
|
||||
export function canCopyHostPassword(host: Host): boolean {
|
||||
function canCopyHostPassword(host: Host): boolean {
|
||||
return (
|
||||
host.authType === "password" ||
|
||||
host.authType === "credential" ||
|
||||
@@ -130,7 +130,7 @@ export function canCopyHostPassword(host: Host): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
export function canCopyHostSudoPassword(host: Host): boolean {
|
||||
function canCopyHostSudoPassword(host: Host): boolean {
|
||||
return (
|
||||
!!host.hasSudoPassword ||
|
||||
!!host.sudoPassword ||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export { isFolder } from "./visible-rows";
|
||||
export { HostItem } from "./HostItem/HostItem";
|
||||
export { FolderItem } from "./FolderItem/FolderItem";
|
||||
|
||||
export { SidebarTree } from "./SidebarTree";
|
||||
@@ -11,7 +11,8 @@ vi.mock("@/dashboard/dashboard-cards-registry", () => ({
|
||||
getRegisteredDashboardCard: (id: string) => registered.cards.get(id),
|
||||
}));
|
||||
|
||||
const { buildDashboardSlots } = await import("../../lib/apply-ui-preset.js");
|
||||
const { buildDashboardSlots, presetHiddenRailTabs } =
|
||||
await import("../../lib/apply-ui-preset.js");
|
||||
|
||||
afterEach(() => {
|
||||
registered.cards.clear();
|
||||
@@ -69,3 +70,24 @@ describe("buildDashboardSlots", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("presetHiddenRailTabs", () => {
|
||||
const items = [
|
||||
{ id: "snippets", simplePreset: true },
|
||||
{ id: "docker-like" },
|
||||
{ id: "pinned", hideable: false },
|
||||
];
|
||||
|
||||
it("hides plugin items that do not opt in when the preset asks", () => {
|
||||
expect(
|
||||
presetHiddenRailTabs(
|
||||
{ hiddenTabs: ["macros"], hidePluginItems: true },
|
||||
items,
|
||||
),
|
||||
).toEqual(["macros", "docker-like"]);
|
||||
});
|
||||
|
||||
it("leaves plugin items alone otherwise", () => {
|
||||
expect(presetHiddenRailTabs({ hiddenTabs: [] }, items)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,84 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createKeyedRequestCache } from "../../lib/keyed-request-cache";
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe("createKeyedRequestCache", () => {
|
||||
it("coalesces requests per key without mixing keys", async () => {
|
||||
let resolve!: (value: string) => void;
|
||||
const loader = vi.fn(() => new Promise<string>((done) => (resolve = done)));
|
||||
const cache = createKeyedRequestCache<string>(5_000);
|
||||
|
||||
const first = cache.get("host:1", loader);
|
||||
const duplicate = cache.get("host:1", loader);
|
||||
const other = cache.get("host:2", async () => "other");
|
||||
resolve("shared");
|
||||
|
||||
await expect(Promise.all([first, duplicate, other])).resolves.toEqual([
|
||||
"shared",
|
||||
"shared",
|
||||
"other",
|
||||
]);
|
||||
expect(loader).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("can show stale data immediately while forcing one refresh", async () => {
|
||||
vi.useFakeTimers();
|
||||
const cache = createKeyedRequestCache<string>(1_000);
|
||||
await cache.get("directory", async () => "old");
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
|
||||
expect(cache.peek("directory", 30_000)).toBe("old");
|
||||
|
||||
const loader = vi.fn(async () => "fresh");
|
||||
const [a, b] = await Promise.all([
|
||||
cache.get("directory", loader, { force: true }),
|
||||
cache.get("directory", loader, { force: true }),
|
||||
]);
|
||||
expect([a, b]).toEqual(["fresh", "fresh"]);
|
||||
expect(loader).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("evicts the least recently used entry when bounded", async () => {
|
||||
const cache = createKeyedRequestCache<number>(10_000, 2);
|
||||
await cache.get("a", async () => 1);
|
||||
await cache.get("b", async () => 2);
|
||||
expect(cache.peek("a")).toBe(1);
|
||||
await cache.get("c", async () => 3);
|
||||
|
||||
expect(cache.peek("a")).toBe(1);
|
||||
expect(cache.peek("b")).toBeNull();
|
||||
expect(cache.peek("c")).toBe(3);
|
||||
});
|
||||
|
||||
it("keeps stale data when a refresh fails", async () => {
|
||||
const cache = createKeyedRequestCache<string>(1_000);
|
||||
await cache.get("key", async () => "safe");
|
||||
|
||||
await expect(
|
||||
cache.get(
|
||||
"key",
|
||||
async () => {
|
||||
throw new Error("offline");
|
||||
},
|
||||
{ force: true },
|
||||
),
|
||||
).rejects.toThrow("offline");
|
||||
expect(cache.peek("key")).toBe("safe");
|
||||
});
|
||||
|
||||
it("does not let an invalidated request overwrite newer data", async () => {
|
||||
let resolveOld!: (value: string) => void;
|
||||
const cache = createKeyedRequestCache<string>(10_000);
|
||||
const oldRequest = cache.get(
|
||||
"key",
|
||||
() => new Promise<string>((resolve) => (resolveOld = resolve)),
|
||||
);
|
||||
|
||||
cache.set("key", "optimistic");
|
||||
resolveOld("old");
|
||||
await oldRequest;
|
||||
|
||||
expect(cache.peek("key")).toBe("optimistic");
|
||||
});
|
||||
});
|
||||
@@ -10,10 +10,6 @@ const api = vi.hoisted(() => ({
|
||||
adminDeleteUserHost: vi.fn(async () => ({})),
|
||||
adminGetUserCredentials: vi.fn(async () => [] as unknown[]),
|
||||
adminDeleteUserCredential: vi.fn(async () => ({})),
|
||||
adminGetUserSnippets: vi.fn(async () => [] as unknown[]),
|
||||
adminCreateUserSnippet: vi.fn(async () => ({})),
|
||||
adminUpdateUserSnippet: vi.fn(async () => ({})),
|
||||
adminDeleteUserSnippet: vi.fn(async () => ({})),
|
||||
adminResetUserPassword: vi.fn(async () => ({ dataWiped: false })),
|
||||
adminExportUserData: vi.fn(async () => ({})),
|
||||
getSessions: vi.fn(async () => ({ sessions: [] as unknown[] })),
|
||||
@@ -87,7 +83,6 @@ describe("AdminUserManagePanel", () => {
|
||||
"admin.manageTabAccount",
|
||||
"admin.manageTabHosts",
|
||||
"admin.manageTabCredentials",
|
||||
"admin.manageTabSnippets",
|
||||
"admin.manageTabSessions",
|
||||
"admin.manageTabDanger",
|
||||
]) {
|
||||
@@ -97,7 +92,6 @@ describe("AdminUserManagePanel", () => {
|
||||
await waitFor(() => {
|
||||
expect(api.adminGetUserHosts).toHaveBeenCalledWith("u2");
|
||||
expect(api.adminGetUserCredentials).toHaveBeenCalledWith("u2");
|
||||
expect(api.adminGetUserSnippets).toHaveBeenCalledWith("u2");
|
||||
expect(api.getUserRoles).toHaveBeenCalledWith("u2");
|
||||
});
|
||||
});
|
||||
@@ -113,7 +107,6 @@ describe("AdminUserManagePanel", () => {
|
||||
|
||||
expect(api.adminGetUserHosts).not.toHaveBeenCalled();
|
||||
expect(api.adminGetUserCredentials).not.toHaveBeenCalled();
|
||||
expect(api.adminGetUserSnippets).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lists the user's hosts and opens a tab via the connect button", async () => {
|
||||
@@ -142,28 +135,43 @@ describe("AdminUserManagePanel", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("creates a snippet for the target user", async () => {
|
||||
renderPanel(makeUser());
|
||||
|
||||
await userEvent.click(screen.getByText("admin.manageTabSnippets"));
|
||||
await userEvent.click(screen.getByText("admin.addSnippetForUser"));
|
||||
await userEvent.type(
|
||||
screen.getByPlaceholderText("admin.snippetNamePlaceholder"),
|
||||
"restart",
|
||||
);
|
||||
await userEvent.type(
|
||||
screen.getByPlaceholderText("admin.snippetContentPlaceholder"),
|
||||
"systemctl restart nginx",
|
||||
);
|
||||
await userEvent.click(screen.getByText("common.save"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.adminCreateUserSnippet).toHaveBeenCalledWith("u2", {
|
||||
name: "restart",
|
||||
content: "systemctl restart nginx",
|
||||
folder: null,
|
||||
});
|
||||
it("shows a tab a plugin contributes, with the user it manages", async () => {
|
||||
const { registerSlotContribution } =
|
||||
await import("../../shell/action-registry");
|
||||
const dispose = registerSlotContribution("admin.userTabs", {
|
||||
actionId: "fixture.adminTab",
|
||||
titleKey: "fixture.tabTitle",
|
||||
kind: "component",
|
||||
component: ((props: { user: AdminUser }) => (
|
||||
<div>managing {props.user.username}</div>
|
||||
)) as never,
|
||||
});
|
||||
try {
|
||||
renderPanel(makeUser());
|
||||
await userEvent.click(screen.getByText("fixture.tabTitle"));
|
||||
expect(screen.getByText("managing bob")).toBeTruthy();
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("shows the locked notice instead of a plugin tab while data is locked", async () => {
|
||||
const { registerSlotContribution } =
|
||||
await import("../../shell/action-registry");
|
||||
const dispose = registerSlotContribution("admin.userTabs", {
|
||||
actionId: "fixture.adminTab",
|
||||
titleKey: "fixture.tabTitle",
|
||||
kind: "component",
|
||||
component: (() => <div>plugin content</div>) as never,
|
||||
});
|
||||
try {
|
||||
renderPanel(makeUser({ dataUnlocked: false }));
|
||||
await userEvent.click(screen.getByText("fixture.tabTitle"));
|
||||
expect(screen.queryByText("plugin content")).toBeNull();
|
||||
expect(screen.getByText("admin.dataLockedNotice")).toBeTruthy();
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("deletes the user from the danger tab after confirmation", async () => {
|
||||
|
||||
@@ -97,4 +97,19 @@ describe("createQuickConnectHost for remote desktop protocols", () => {
|
||||
"demo-desktopPassword": "pw",
|
||||
});
|
||||
});
|
||||
|
||||
it("carries the fields a plugin's SSH auth editor filled in", () => {
|
||||
const host = createQuickConnectHost({
|
||||
ip: "100.64.0.9",
|
||||
port: 22,
|
||||
username: "root",
|
||||
authType: "tailnet-login",
|
||||
authFields: { pluginSettings: { demo: { profileId: 3 } } },
|
||||
});
|
||||
expect(host.authType).toBe("tailnet-login");
|
||||
expect(host.password).toBeUndefined();
|
||||
expect(host).toMatchObject({
|
||||
pluginSettings: { demo: { profileId: 3 } },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -94,20 +94,18 @@ describe("sanitizeUiOverrides", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("drops the retired homepage area", () => {
|
||||
expect(sanitizeUiOverrides({ homepage: { enabledWidgets: null } })).toEqual(
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it("filters non-strings out of string arrays instead of rejecting them", () => {
|
||||
const result = sanitizeUiOverrides({
|
||||
rail: { hiddenTabs: ["serial", 7, null, "history"] },
|
||||
});
|
||||
expect(result.rail?.hiddenTabs).toEqual(["serial", "history"]);
|
||||
});
|
||||
|
||||
it("keeps an explicit null for nullable array knobs", () => {
|
||||
expect(sanitizeUiOverrides({ homepage: { enabledWidgets: null } })).toEqual(
|
||||
{
|
||||
homepage: { enabledWidgets: null },
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveArea", () => {
|
||||
@@ -155,12 +153,13 @@ describe("PRESETS.balanced", () => {
|
||||
expect(PRESETS.balanced.terminal.toolbarDensity).toBe("labeled");
|
||||
expect(PRESETS.balanced.fileManager.viewMode).toBe("grid");
|
||||
expect(PRESETS.balanced.hostEditor.mode).toBe("full");
|
||||
expect(PRESETS.balanced.homepage.enabledWidgets).toBeNull();
|
||||
});
|
||||
|
||||
it("never lets a preset drive the homepage canvas", () => {
|
||||
expect(PRESETS.simple.homepage.enabledWidgets).toBeNull();
|
||||
expect(PRESETS.advanced.homepage.enabledWidgets).toBeNull();
|
||||
it("keeps core's basics in Simple and hides plugin rail items that do not opt in", () => {
|
||||
expect(PRESETS.simple.rail.hiddenTabs).not.toContain("hosts");
|
||||
expect(PRESETS.simple.rail.hiddenTabs).not.toContain("connections");
|
||||
expect(PRESETS.simple.rail.hidePluginItems).toBe(true);
|
||||
expect(PRESETS.balanced.rail.hidePluginItems).toBeFalsy();
|
||||
});
|
||||
|
||||
it("leaves the wide dashboard cards off by default in every preset", () => {
|
||||
|
||||
Reference in new issue
Block a user