mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
fix: unify connection failure screens and rebuild stale local plugins
This commit is contained in:
1 parent
a72aa5b57b
commit
49fc241e06
9 files changed
+262
-29
No files matched your search
+9
-1
@@ -3118,7 +3118,15 @@ export function AppShell({
|
||||
? paneId === focusedPaneId
|
||||
: activeInline;
|
||||
return createPortal(
|
||||
<SurfaceScope kind="tab" className="h-full w-full">
|
||||
<SurfaceScope
|
||||
kind="tab"
|
||||
className="h-full w-full"
|
||||
onClose={
|
||||
tab.id === "dashboard"
|
||||
? undefined
|
||||
: () => closeTab(tab.id)
|
||||
}
|
||||
>
|
||||
{renderTabContent(tab, {
|
||||
shell: shellCallbacks,
|
||||
settings: settingsTabProps,
|
||||
|
||||
@@ -11,6 +11,7 @@ interface ConnectionLogPanelProps {
|
||||
isConnecting: boolean;
|
||||
isConnected: boolean;
|
||||
hasConnectionError: boolean;
|
||||
/** Kept for older callers. The log always sits at the bottom. */
|
||||
position?: "top" | "bottom";
|
||||
className?: string;
|
||||
}
|
||||
@@ -37,7 +38,6 @@ export function ConnectionLogPanel({
|
||||
isConnecting,
|
||||
isConnected,
|
||||
hasConnectionError,
|
||||
position = "bottom",
|
||||
className,
|
||||
}: ConnectionLogPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -109,15 +109,13 @@ export function ConnectionLogPanel({
|
||||
<div
|
||||
className={cn(
|
||||
"relative z-10 flex shrink-0 flex-col bg-surface-dim/60",
|
||||
position === "bottom"
|
||||
? "border-t border-border"
|
||||
: "border-b border-border",
|
||||
"border-t border-border",
|
||||
expanded ? EXPANDED_HEIGHT : COLLAPSED_HEIGHT,
|
||||
"transition-[height] duration-200",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex h-9 shrink-0 items-center gap-2 border-b border-border/60 px-2 pr-1.5">
|
||||
<div className="flex h-9 shrink-0 items-center gap-2 border-b border-border px-2 pr-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggle}
|
||||
|
||||
@@ -2,8 +2,10 @@ import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/utils.ts";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { RefreshCw, X } from "lucide-react";
|
||||
import { ConnectionLogPanel } from "@/components/connection/ConnectionLogPanel.tsx";
|
||||
import { useOptionalConnectionLog } from "@/ssh/connection-log/ConnectionLogContext.tsx";
|
||||
import { useSurfaceClose } from "@/components/surface/surface-scope.tsx";
|
||||
import type { ConnectionStatus } from "@/components/connection/connection-status.ts";
|
||||
|
||||
export interface ConnectionUnavailable {
|
||||
@@ -19,9 +21,7 @@ export interface ConnectionScreenProps {
|
||||
message?: string;
|
||||
/** Second line under the headline, usually user@host:port. */
|
||||
detail?: string;
|
||||
/** Headline once it has failed and is not retrying on its own. */
|
||||
errorMessage?: string;
|
||||
/** The reason it failed, shown under the headline. */
|
||||
/** The reason it failed. Falls back to the last error in the log. */
|
||||
errorDetail?: string | null;
|
||||
/** Shown instead of a failure when the feature cannot run here. */
|
||||
unavailable?: ConnectionUnavailable | null;
|
||||
@@ -30,8 +30,10 @@ export interface ConnectionScreenProps {
|
||||
maxAttempts?: number;
|
||||
nextRetryInMs?: number | null;
|
||||
onManualRetry?: () => void;
|
||||
retryLabel?: string;
|
||||
/** Headline when it was up and dropped. */
|
||||
disconnectedMessage?: string;
|
||||
/** Overrides closing the tab this sits in. False hides the button. */
|
||||
onClose?: (() => void) | false;
|
||||
extraActions?: React.ReactNode;
|
||||
logPosition?: "top" | "bottom";
|
||||
emptyState?: React.ReactNode;
|
||||
@@ -43,7 +45,6 @@ export function ConnectionScreen({
|
||||
status,
|
||||
message,
|
||||
detail,
|
||||
errorMessage,
|
||||
errorDetail,
|
||||
unavailable,
|
||||
backgroundColor,
|
||||
@@ -51,14 +52,17 @@ export function ConnectionScreen({
|
||||
maxAttempts = 0,
|
||||
nextRetryInMs = null,
|
||||
onManualRetry,
|
||||
retryLabel,
|
||||
disconnectedMessage,
|
||||
onClose,
|
||||
extraActions,
|
||||
logPosition = "bottom",
|
||||
emptyState,
|
||||
className,
|
||||
}: ConnectionScreenProps) {
|
||||
const { t } = useTranslation();
|
||||
const surfaceClose = useSurfaceClose();
|
||||
const logs = useOptionalConnectionLog()?.logs;
|
||||
const close = onClose === false ? null : (onClose ?? surfaceClose);
|
||||
|
||||
if (status === "connected" && !emptyState && !unavailable) {
|
||||
return null;
|
||||
@@ -68,16 +72,22 @@ export function ConnectionScreen({
|
||||
const failed = status === "error" || status === "disconnected";
|
||||
const retrying =
|
||||
status === "error" && attempt > 0 && !!nextRetryInMs && nextRetryInMs > 0;
|
||||
const showRetryButton = (failed || !!unavailable) && !!onManualRetry;
|
||||
const stopped = failed || !!unavailable;
|
||||
const showRetryButton = stopped && !!onManualRetry;
|
||||
const showClose = stopped && !!close;
|
||||
const reason =
|
||||
errorDetail ||
|
||||
[...(logs ?? [])].reverse().find((entry) => entry.type === "error")
|
||||
?.message ||
|
||||
null;
|
||||
const showLog = !emptyState && (status !== "connected" || !!unavailable);
|
||||
|
||||
let headline = message;
|
||||
if (unavailable) headline = unavailable.title;
|
||||
else if (status === "disconnected")
|
||||
headline = disconnectedMessage || t("connection.disconnected");
|
||||
else if (retrying) headline = t("connection.failedRetrying");
|
||||
else if (status === "error")
|
||||
headline = errorMessage || t("connection.failed");
|
||||
else if (status === "disconnected" && disconnectedMessage)
|
||||
headline = disconnectedMessage;
|
||||
else if (failed) headline = t("connection.failed");
|
||||
|
||||
const showCountdown = attempt > 0 && (connecting || retrying);
|
||||
|
||||
@@ -123,9 +133,9 @@ export function ConnectionScreen({
|
||||
{unavailable.hint}
|
||||
</p>
|
||||
)}
|
||||
{failed && !unavailable && errorDetail && (
|
||||
{failed && !unavailable && reason && (
|
||||
<p className="line-clamp-3 text-xs leading-snug text-destructive/80">
|
||||
{errorDetail}
|
||||
{reason}
|
||||
</p>
|
||||
)}
|
||||
{showCountdown && (
|
||||
@@ -145,7 +155,8 @@ export function ConnectionScreen({
|
||||
</div>
|
||||
|
||||
{(showRetryButton ||
|
||||
((failed || unavailable) && extraActions) ||
|
||||
showClose ||
|
||||
(stopped && extraActions) ||
|
||||
unavailable?.action) && (
|
||||
<div className="flex flex-wrap justify-center gap-2 pt-0.5">
|
||||
{showRetryButton && (
|
||||
@@ -158,11 +169,22 @@ export function ConnectionScreen({
|
||||
<RefreshCw className="size-3.5" />
|
||||
{retrying
|
||||
? t("connection.retryNow")
|
||||
: retryLabel || t("connection.reconnect")}
|
||||
: t("connection.reconnect")}
|
||||
</Button>
|
||||
)}
|
||||
{unavailable?.action}
|
||||
{(failed || unavailable) && extraActions}
|
||||
{stopped && extraActions}
|
||||
{showClose && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={close}
|
||||
className="gap-2 font-semibold"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
{t("connection.close")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -173,7 +195,7 @@ export function ConnectionScreen({
|
||||
<ConnectionLogPanel
|
||||
isConnecting={connecting}
|
||||
isConnected={false}
|
||||
hasConnectionError={failed || !!unavailable}
|
||||
hasConnectionError={stopped}
|
||||
position={logPosition}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -50,6 +50,7 @@ interface ScopeValue {
|
||||
closeView: (id: string) => void;
|
||||
topView: string | null;
|
||||
confirm: (options: ConfirmOptions) => Promise<boolean>;
|
||||
close: (() => void) | null;
|
||||
}
|
||||
|
||||
const ScopeContext = createContext<ScopeValue | null>(null);
|
||||
@@ -65,10 +66,13 @@ const keyOwners: number[] = [];
|
||||
export function SurfaceScope({
|
||||
kind = "panel",
|
||||
onEditingChange,
|
||||
onClose,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
kind?: SurfaceKind;
|
||||
/** Closes whatever this surface is, like the tab it fills. */
|
||||
onClose?: () => void;
|
||||
/** Gets the widest open inline view, so a sidebar can widen for it. */
|
||||
onEditingChange?: (editing: EditingWidth) => void;
|
||||
className?: string;
|
||||
@@ -80,6 +84,13 @@ export function SurfaceScope({
|
||||
const nextConfirm = useRef(0);
|
||||
const editingRef = useRef(onEditingChange);
|
||||
editingRef.current = onEditingChange;
|
||||
const closeRef = useRef(onClose);
|
||||
closeRef.current = onClose;
|
||||
const hasClose = !!onClose;
|
||||
const close = useMemo(
|
||||
() => (hasClose ? () => closeRef.current?.() : null),
|
||||
[hasClose],
|
||||
);
|
||||
|
||||
const openView = useCallback((id: string, width: EditingWidth) => {
|
||||
setViews((prev) => [...prev.filter((v) => v.id !== id), { id, width }]);
|
||||
@@ -126,8 +137,9 @@ export function SurfaceScope({
|
||||
closeView,
|
||||
topView: views.length ? views[views.length - 1].id : null,
|
||||
confirm,
|
||||
close,
|
||||
}),
|
||||
[kind, overlay, openView, closeView, views, confirm],
|
||||
[kind, overlay, openView, closeView, views, confirm, close],
|
||||
);
|
||||
|
||||
const current = confirms[0];
|
||||
@@ -165,6 +177,11 @@ export function useSurfaceKind(): SurfaceKind | null {
|
||||
return useContext(ScopeContext)?.kind ?? null;
|
||||
}
|
||||
|
||||
/** Closes the surface this code renders in, or null when it can't be closed. */
|
||||
export function useSurfaceClose(): (() => void) | null {
|
||||
return useContext(ScopeContext)?.close ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks a yes or no question over the surface that called it. Enter confirms
|
||||
* and Esc cancels, before a terminal can see the key.
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"connection": {
|
||||
"retryingIn": "Retrying in {{seconds}}s ({{attempt}}/{{max}})",
|
||||
"retryingNow": "Retrying... ({{attempt}}/{{max}})",
|
||||
"disconnected": "Unable to connect",
|
||||
"reconnect": "Reconnect",
|
||||
"close": "Close",
|
||||
"failed": "Could not connect",
|
||||
"failedRetrying": "Connection failed, trying again",
|
||||
"retryNow": "Retry now"
|
||||
|
||||
@@ -6,7 +6,12 @@ vi.mock("react-i18next", () => ({
|
||||
}));
|
||||
|
||||
import { ConnectionScreen } from "../../../components/connection/ConnectionScreen";
|
||||
import { ConnectionLogProvider } from "../../../ssh/connection-log/ConnectionLogContext";
|
||||
import {
|
||||
ConnectionLogProvider,
|
||||
useConnectionLog,
|
||||
} from "../../../ssh/connection-log/ConnectionLogContext";
|
||||
import { SurfaceScope } from "../../../components/surface/surface-scope";
|
||||
import { useEffect } from "react";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
@@ -107,6 +112,75 @@ describe("ConnectionScreen", () => {
|
||||
expect(screen.getByText("connection.reconnect")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("uses the same headline when retries run out", () => {
|
||||
render(<ConnectionScreen status="disconnected" message="common.loading" />);
|
||||
expect(screen.getByText("connection.failed")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("closes the tab it sits in once it stops", () => {
|
||||
const close = vi.fn();
|
||||
render(
|
||||
<SurfaceScope kind="tab" onClose={close}>
|
||||
<ConnectionScreen status="error" onManualRetry={() => {}} />
|
||||
</SurfaceScope>,
|
||||
);
|
||||
fireEvent.click(screen.getByText("connection.close"));
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("prefers its own close and can hide it", () => {
|
||||
const own = vi.fn();
|
||||
const { rerender } = render(
|
||||
<SurfaceScope kind="tab" onClose={() => {}}>
|
||||
<ConnectionScreen status="error" onClose={own} />
|
||||
</SurfaceScope>,
|
||||
);
|
||||
fireEvent.click(screen.getByText("connection.close"));
|
||||
expect(own).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender(
|
||||
<SurfaceScope kind="tab" onClose={() => {}}>
|
||||
<ConnectionScreen status="error" onClose={false} />
|
||||
</SurfaceScope>,
|
||||
);
|
||||
expect(screen.queryByText("connection.close")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows no close while connecting or outside a tab", () => {
|
||||
render(
|
||||
<SurfaceScope kind="tab" onClose={() => {}}>
|
||||
<ConnectionScreen status="connecting" />
|
||||
</SurfaceScope>,
|
||||
);
|
||||
expect(screen.queryByText("connection.close")).toBeNull();
|
||||
cleanup();
|
||||
render(<ConnectionScreen status="error" />);
|
||||
expect(screen.queryByText("connection.close")).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to the last logged error for the reason", () => {
|
||||
function Seed() {
|
||||
const { addLog } = useConnectionLog();
|
||||
useEffect(() => {
|
||||
addLog({ type: "error", stage: "connection", message: "old" });
|
||||
addLog({ type: "info", stage: "connection", message: "trying" });
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "connection",
|
||||
message: "Docker not found",
|
||||
});
|
||||
}, [addLog]);
|
||||
return null;
|
||||
}
|
||||
render(
|
||||
<ConnectionLogProvider>
|
||||
<Seed />
|
||||
<ConnectionScreen status="error" />
|
||||
</ConnectionLogProvider>,
|
||||
);
|
||||
expect(screen.getAllByText("Docker not found").length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("renders nothing once connected", () => {
|
||||
const { container } = render(<ConnectionScreen status="connected" />);
|
||||
expect(container.innerHTML).toBe("");
|
||||
|
||||
Reference in new issue
Block a user