-
+
{(showRetryButton ||
- ((failed || unavailable) && extraActions) ||
+ showClose ||
+ (stopped && extraActions) ||
unavailable?.action) && (
{showRetryButton && (
@@ -158,11 +169,22 @@ export function ConnectionScreen({
{retrying
? t("connection.retryNow")
- : retryLabel || t("connection.reconnect")}
+ : t("connection.reconnect")}
)}
{unavailable?.action}
- {(failed || unavailable) && extraActions}
+ {stopped && extraActions}
+ {showClose && (
+
+ )}
)}
@@ -173,7 +195,7 @@ export function ConnectionScreen({
)}
diff --git a/src/ui/components/surface/surface-scope.tsx b/src/ui/components/surface/surface-scope.tsx
index 40919c222..48f14ec01 100644
--- a/src/ui/components/surface/surface-scope.tsx
+++ b/src/ui/components/surface/surface-scope.tsx
@@ -50,6 +50,7 @@ interface ScopeValue {
closeView: (id: string) => void;
topView: string | null;
confirm: (options: ConfirmOptions) => Promise
;
+ close: (() => void) | null;
}
const ScopeContext = createContext(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.
diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json
index cfa2db017..c2aa22226 100644
--- a/src/ui/locales/en.json
+++ b/src/ui/locales/en.json
@@ -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"
diff --git a/src/ui/tests/components/connection/ConnectionScreen.test.tsx b/src/ui/tests/components/connection/ConnectionScreen.test.tsx
index 41faaf9d7..d431e7b23 100644
--- a/src/ui/tests/components/connection/ConnectionScreen.test.tsx
+++ b/src/ui/tests/components/connection/ConnectionScreen.test.tsx
@@ -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();
+ expect(screen.getByText("connection.failed")).toBeTruthy();
+ });
+
+ it("closes the tab it sits in once it stops", () => {
+ const close = vi.fn();
+ render(
+
+ {}} />
+ ,
+ );
+ 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(
+ {}}>
+
+ ,
+ );
+ fireEvent.click(screen.getByText("connection.close"));
+ expect(own).toHaveBeenCalledTimes(1);
+
+ rerender(
+ {}}>
+
+ ,
+ );
+ expect(screen.queryByText("connection.close")).toBeNull();
+ });
+
+ it("shows no close while connecting or outside a tab", () => {
+ render(
+ {}}>
+
+ ,
+ );
+ expect(screen.queryByText("connection.close")).toBeNull();
+ cleanup();
+ render();
+ 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(
+
+
+
+ ,
+ );
+ expect(screen.getAllByText("Docker not found").length).toBeGreaterThan(1);
+ });
+
it("renders nothing once connected", () => {
const { container } = render();
expect(container.innerHTML).toBe("");