mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 22:55: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
@@ -7,10 +7,12 @@
|
|||||||
* pinned sha256 and unpacked.
|
* pinned sha256 and unpacked.
|
||||||
*
|
*
|
||||||
* For development, `--local <dir>` (or TERMIX_LOCAL_PLUGINS) points at a
|
* For development, `--local <dir>` (or TERMIX_LOCAL_PLUGINS) points at a
|
||||||
* folder of plugin repos like ../Termix-Plugins. Any plugin built there (has
|
* folder of plugin repos like ../Termix-Plugins. A bundled plugin there whose
|
||||||
* dist/) is copied in place of its pin, so local plugin changes show up.
|
* sources changed since its last build is rebuilt first, then any built one
|
||||||
|
* (has dist/) is copied in place of its pin, so local plugin changes show up.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
const { execSync } = require("node:child_process");
|
||||||
const fs = require("node:fs");
|
const fs = require("node:fs");
|
||||||
const path = require("node:path");
|
const path = require("node:path");
|
||||||
const {
|
const {
|
||||||
@@ -18,6 +20,7 @@ const {
|
|||||||
fetchArtifact,
|
fetchArtifact,
|
||||||
extractArtifact,
|
extractArtifact,
|
||||||
findLocalPluginBuilds,
|
findLocalPluginBuilds,
|
||||||
|
rebuildLocalPlugins,
|
||||||
copyLocalBuild,
|
copyLocalBuild,
|
||||||
} = require("./lib/bundled-plugins.cjs");
|
} = require("./lib/bundled-plugins.cjs");
|
||||||
|
|
||||||
@@ -30,6 +33,14 @@ async function main() {
|
|||||||
const localArg =
|
const localArg =
|
||||||
flag !== -1 ? process.argv[flag + 1] : process.env.TERMIX_LOCAL_PLUGINS;
|
flag !== -1 ? process.argv[flag + 1] : process.env.TERMIX_LOCAL_PLUGINS;
|
||||||
const localDir = localArg ? path.resolve(root, localArg) : null;
|
const localDir = localArg ? path.resolve(root, localArg) : null;
|
||||||
|
rebuildLocalPlugins(
|
||||||
|
localDir,
|
||||||
|
new Set(plugins.map((p) => p.id)),
|
||||||
|
(repo, id) => {
|
||||||
|
console.log(`building ${id}`);
|
||||||
|
execSync("npm run build", { cwd: repo, stdio: "inherit" });
|
||||||
|
},
|
||||||
|
);
|
||||||
const local = findLocalPluginBuilds(localDir);
|
const local = findLocalPluginBuilds(localDir);
|
||||||
|
|
||||||
fs.rmSync(destination, { recursive: true, force: true });
|
fs.rmSync(destination, { recursive: true, force: true });
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import {
|
|||||||
extractArtifact,
|
extractArtifact,
|
||||||
pinsFromIndex,
|
pinsFromIndex,
|
||||||
findLocalPluginBuilds,
|
findLocalPluginBuilds,
|
||||||
|
isLocalBuildStale,
|
||||||
|
rebuildLocalPlugins,
|
||||||
copyLocalBuild,
|
copyLocalBuild,
|
||||||
} from "./lib/bundled-plugins.cjs";
|
} from "./lib/bundled-plugins.cjs";
|
||||||
|
|
||||||
@@ -230,3 +232,52 @@ describe("local plugin builds", () => {
|
|||||||
expect(fs.existsSync(path.join(out, "src"))).toBe(false);
|
expect(fs.existsSync(path.join(out, "src"))).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("rebuilding local plugins", () => {
|
||||||
|
function repo(dir: string, name: string, id: string): string {
|
||||||
|
const root = path.join(dir, name);
|
||||||
|
fs.mkdirSync(path.join(root, "src"), { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(root, "node_modules"));
|
||||||
|
fs.writeFileSync(path.join(root, "manifest.json"), JSON.stringify({ id }));
|
||||||
|
fs.writeFileSync(path.join(root, "src", "index.ts"), "x");
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
function touch(file: string, secondsAgo: number) {
|
||||||
|
const time = new Date(Date.now() - secondsAgo * 1000);
|
||||||
|
fs.utimesSync(file, time, time);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("is stale with no dist or with a source newer than the build", () => {
|
||||||
|
const root = repo(tempDir(), "Plugin-A", "a");
|
||||||
|
expect(isLocalBuildStale(root)).toBe(true);
|
||||||
|
|
||||||
|
fs.mkdirSync(path.join(root, "dist"));
|
||||||
|
fs.writeFileSync(path.join(root, "dist", "frontend.js"), "x");
|
||||||
|
touch(path.join(root, "manifest.json"), 60);
|
||||||
|
touch(path.join(root, "src", "index.ts"), 60);
|
||||||
|
expect(isLocalBuildStale(root)).toBe(false);
|
||||||
|
|
||||||
|
touch(path.join(root, "src", "index.ts"), 0);
|
||||||
|
touch(path.join(root, "dist", "frontend.js"), 30);
|
||||||
|
expect(isLocalBuildStale(root)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds only stale repos for bundled ids that have node_modules", () => {
|
||||||
|
const dir = tempDir();
|
||||||
|
repo(dir, "Plugin-A", "a");
|
||||||
|
repo(dir, "Plugin-B", "b");
|
||||||
|
const noDeps = repo(dir, "Plugin-C", "c");
|
||||||
|
fs.rmSync(path.join(noDeps, "node_modules"), { recursive: true });
|
||||||
|
|
||||||
|
const run: string[] = [];
|
||||||
|
const rebuilt = rebuildLocalPlugins(
|
||||||
|
dir,
|
||||||
|
new Set(["a", "c"]),
|
||||||
|
(_repo: string, id: string) => run.push(id),
|
||||||
|
);
|
||||||
|
expect(rebuilt).toEqual(["a"]);
|
||||||
|
expect(run).toEqual(["a"]);
|
||||||
|
expect(rebuildLocalPlugins(null, new Set(["a"]), () => {})).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -203,6 +203,56 @@ function findLocalPluginBuilds(dir) {
|
|||||||
return builds;
|
return builds;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const BUILD_INPUTS = [
|
||||||
|
"src",
|
||||||
|
"locales",
|
||||||
|
"migrations",
|
||||||
|
"manifest.json",
|
||||||
|
"package.json",
|
||||||
|
];
|
||||||
|
|
||||||
|
function newestMtime(target) {
|
||||||
|
if (!fs.existsSync(target)) return 0;
|
||||||
|
const stat = fs.statSync(target);
|
||||||
|
if (!stat.isDirectory()) return stat.mtimeMs;
|
||||||
|
let newest = 0;
|
||||||
|
for (const name of fs.readdirSync(target)) {
|
||||||
|
newest = Math.max(newest, newestMtime(path.join(target, name)));
|
||||||
|
}
|
||||||
|
return newest;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when a plugin repo has never been built or its sources changed since. */
|
||||||
|
function isLocalBuildStale(repo) {
|
||||||
|
const built = newestMtime(path.join(repo, "dist"));
|
||||||
|
if (!built) return true;
|
||||||
|
return BUILD_INPUTS.some(
|
||||||
|
(entry) => newestMtime(path.join(repo, entry)) > built,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds each listed plugin repo whose build is missing or out of date. */
|
||||||
|
function rebuildLocalPlugins(dir, ids, run) {
|
||||||
|
const rebuilt = [];
|
||||||
|
if (!dir || !fs.existsSync(dir)) return rebuilt;
|
||||||
|
for (const name of fs.readdirSync(dir)) {
|
||||||
|
const repo = path.join(dir, name);
|
||||||
|
const manifestPath = path.join(repo, "manifest.json");
|
||||||
|
if (!fs.existsSync(manifestPath)) continue;
|
||||||
|
if (!fs.existsSync(path.join(repo, "node_modules"))) continue;
|
||||||
|
let id;
|
||||||
|
try {
|
||||||
|
({ id } = JSON.parse(fs.readFileSync(manifestPath, "utf8")));
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!ids.has(id) || !isLocalBuildStale(repo)) continue;
|
||||||
|
run(repo, id);
|
||||||
|
rebuilt.push(id);
|
||||||
|
}
|
||||||
|
return rebuilt;
|
||||||
|
}
|
||||||
|
|
||||||
/** Copies a local plugin build into the staging folder. */
|
/** Copies a local plugin build into the staging folder. */
|
||||||
function copyLocalBuild(repo, destination) {
|
function copyLocalBuild(repo, destination) {
|
||||||
fs.mkdirSync(destination, { recursive: true });
|
fs.mkdirSync(destination, { recursive: true });
|
||||||
@@ -216,6 +266,8 @@ function copyLocalBuild(repo, destination) {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
findLocalPluginBuilds,
|
findLocalPluginBuilds,
|
||||||
|
isLocalBuildStale,
|
||||||
|
rebuildLocalPlugins,
|
||||||
copyLocalBuild,
|
copyLocalBuild,
|
||||||
parseBundledPlugins,
|
parseBundledPlugins,
|
||||||
pinsFromIndex,
|
pinsFromIndex,
|
||||||
|
|||||||
+9
-1
@@ -3118,7 +3118,15 @@ export function AppShell({
|
|||||||
? paneId === focusedPaneId
|
? paneId === focusedPaneId
|
||||||
: activeInline;
|
: activeInline;
|
||||||
return createPortal(
|
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, {
|
{renderTabContent(tab, {
|
||||||
shell: shellCallbacks,
|
shell: shellCallbacks,
|
||||||
settings: settingsTabProps,
|
settings: settingsTabProps,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ interface ConnectionLogPanelProps {
|
|||||||
isConnecting: boolean;
|
isConnecting: boolean;
|
||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
hasConnectionError: boolean;
|
hasConnectionError: boolean;
|
||||||
|
/** Kept for older callers. The log always sits at the bottom. */
|
||||||
position?: "top" | "bottom";
|
position?: "top" | "bottom";
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
@@ -37,7 +38,6 @@ export function ConnectionLogPanel({
|
|||||||
isConnecting,
|
isConnecting,
|
||||||
isConnected,
|
isConnected,
|
||||||
hasConnectionError,
|
hasConnectionError,
|
||||||
position = "bottom",
|
|
||||||
className,
|
className,
|
||||||
}: ConnectionLogPanelProps) {
|
}: ConnectionLogPanelProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -109,15 +109,13 @@ export function ConnectionLogPanel({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative z-10 flex shrink-0 flex-col bg-surface-dim/60",
|
"relative z-10 flex shrink-0 flex-col bg-surface-dim/60",
|
||||||
position === "bottom"
|
"border-t border-border",
|
||||||
? "border-t border-border"
|
|
||||||
: "border-b border-border",
|
|
||||||
expanded ? EXPANDED_HEIGHT : COLLAPSED_HEIGHT,
|
expanded ? EXPANDED_HEIGHT : COLLAPSED_HEIGHT,
|
||||||
"transition-[height] duration-200",
|
"transition-[height] duration-200",
|
||||||
className,
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleToggle}
|
onClick={handleToggle}
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import React from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { cn } from "@/lib/utils.ts";
|
import { cn } from "@/lib/utils.ts";
|
||||||
import { Button } from "@/components/button.tsx";
|
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 { 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";
|
import type { ConnectionStatus } from "@/components/connection/connection-status.ts";
|
||||||
|
|
||||||
export interface ConnectionUnavailable {
|
export interface ConnectionUnavailable {
|
||||||
@@ -19,9 +21,7 @@ export interface ConnectionScreenProps {
|
|||||||
message?: string;
|
message?: string;
|
||||||
/** Second line under the headline, usually user@host:port. */
|
/** Second line under the headline, usually user@host:port. */
|
||||||
detail?: string;
|
detail?: string;
|
||||||
/** Headline once it has failed and is not retrying on its own. */
|
/** The reason it failed. Falls back to the last error in the log. */
|
||||||
errorMessage?: string;
|
|
||||||
/** The reason it failed, shown under the headline. */
|
|
||||||
errorDetail?: string | null;
|
errorDetail?: string | null;
|
||||||
/** Shown instead of a failure when the feature cannot run here. */
|
/** Shown instead of a failure when the feature cannot run here. */
|
||||||
unavailable?: ConnectionUnavailable | null;
|
unavailable?: ConnectionUnavailable | null;
|
||||||
@@ -30,8 +30,10 @@ export interface ConnectionScreenProps {
|
|||||||
maxAttempts?: number;
|
maxAttempts?: number;
|
||||||
nextRetryInMs?: number | null;
|
nextRetryInMs?: number | null;
|
||||||
onManualRetry?: () => void;
|
onManualRetry?: () => void;
|
||||||
retryLabel?: string;
|
/** Headline when it was up and dropped. */
|
||||||
disconnectedMessage?: string;
|
disconnectedMessage?: string;
|
||||||
|
/** Overrides closing the tab this sits in. False hides the button. */
|
||||||
|
onClose?: (() => void) | false;
|
||||||
extraActions?: React.ReactNode;
|
extraActions?: React.ReactNode;
|
||||||
logPosition?: "top" | "bottom";
|
logPosition?: "top" | "bottom";
|
||||||
emptyState?: React.ReactNode;
|
emptyState?: React.ReactNode;
|
||||||
@@ -43,7 +45,6 @@ export function ConnectionScreen({
|
|||||||
status,
|
status,
|
||||||
message,
|
message,
|
||||||
detail,
|
detail,
|
||||||
errorMessage,
|
|
||||||
errorDetail,
|
errorDetail,
|
||||||
unavailable,
|
unavailable,
|
||||||
backgroundColor,
|
backgroundColor,
|
||||||
@@ -51,14 +52,17 @@ export function ConnectionScreen({
|
|||||||
maxAttempts = 0,
|
maxAttempts = 0,
|
||||||
nextRetryInMs = null,
|
nextRetryInMs = null,
|
||||||
onManualRetry,
|
onManualRetry,
|
||||||
retryLabel,
|
|
||||||
disconnectedMessage,
|
disconnectedMessage,
|
||||||
|
onClose,
|
||||||
extraActions,
|
extraActions,
|
||||||
logPosition = "bottom",
|
logPosition = "bottom",
|
||||||
emptyState,
|
emptyState,
|
||||||
className,
|
className,
|
||||||
}: ConnectionScreenProps) {
|
}: ConnectionScreenProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const surfaceClose = useSurfaceClose();
|
||||||
|
const logs = useOptionalConnectionLog()?.logs;
|
||||||
|
const close = onClose === false ? null : (onClose ?? surfaceClose);
|
||||||
|
|
||||||
if (status === "connected" && !emptyState && !unavailable) {
|
if (status === "connected" && !emptyState && !unavailable) {
|
||||||
return null;
|
return null;
|
||||||
@@ -68,16 +72,22 @@ export function ConnectionScreen({
|
|||||||
const failed = status === "error" || status === "disconnected";
|
const failed = status === "error" || status === "disconnected";
|
||||||
const retrying =
|
const retrying =
|
||||||
status === "error" && attempt > 0 && !!nextRetryInMs && nextRetryInMs > 0;
|
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);
|
const showLog = !emptyState && (status !== "connected" || !!unavailable);
|
||||||
|
|
||||||
let headline = message;
|
let headline = message;
|
||||||
if (unavailable) headline = unavailable.title;
|
if (unavailable) headline = unavailable.title;
|
||||||
else if (status === "disconnected")
|
|
||||||
headline = disconnectedMessage || t("connection.disconnected");
|
|
||||||
else if (retrying) headline = t("connection.failedRetrying");
|
else if (retrying) headline = t("connection.failedRetrying");
|
||||||
else if (status === "error")
|
else if (status === "disconnected" && disconnectedMessage)
|
||||||
headline = errorMessage || t("connection.failed");
|
headline = disconnectedMessage;
|
||||||
|
else if (failed) headline = t("connection.failed");
|
||||||
|
|
||||||
const showCountdown = attempt > 0 && (connecting || retrying);
|
const showCountdown = attempt > 0 && (connecting || retrying);
|
||||||
|
|
||||||
@@ -123,9 +133,9 @@ export function ConnectionScreen({
|
|||||||
{unavailable.hint}
|
{unavailable.hint}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{failed && !unavailable && errorDetail && (
|
{failed && !unavailable && reason && (
|
||||||
<p className="line-clamp-3 text-xs leading-snug text-destructive/80">
|
<p className="line-clamp-3 text-xs leading-snug text-destructive/80">
|
||||||
{errorDetail}
|
{reason}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{showCountdown && (
|
{showCountdown && (
|
||||||
@@ -145,7 +155,8 @@ export function ConnectionScreen({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(showRetryButton ||
|
{(showRetryButton ||
|
||||||
((failed || unavailable) && extraActions) ||
|
showClose ||
|
||||||
|
(stopped && extraActions) ||
|
||||||
unavailable?.action) && (
|
unavailable?.action) && (
|
||||||
<div className="flex flex-wrap justify-center gap-2 pt-0.5">
|
<div className="flex flex-wrap justify-center gap-2 pt-0.5">
|
||||||
{showRetryButton && (
|
{showRetryButton && (
|
||||||
@@ -158,11 +169,22 @@ export function ConnectionScreen({
|
|||||||
<RefreshCw className="size-3.5" />
|
<RefreshCw className="size-3.5" />
|
||||||
{retrying
|
{retrying
|
||||||
? t("connection.retryNow")
|
? t("connection.retryNow")
|
||||||
: retryLabel || t("connection.reconnect")}
|
: t("connection.reconnect")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{unavailable?.action}
|
{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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -173,7 +195,7 @@ export function ConnectionScreen({
|
|||||||
<ConnectionLogPanel
|
<ConnectionLogPanel
|
||||||
isConnecting={connecting}
|
isConnecting={connecting}
|
||||||
isConnected={false}
|
isConnected={false}
|
||||||
hasConnectionError={failed || !!unavailable}
|
hasConnectionError={stopped}
|
||||||
position={logPosition}
|
position={logPosition}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ interface ScopeValue {
|
|||||||
closeView: (id: string) => void;
|
closeView: (id: string) => void;
|
||||||
topView: string | null;
|
topView: string | null;
|
||||||
confirm: (options: ConfirmOptions) => Promise<boolean>;
|
confirm: (options: ConfirmOptions) => Promise<boolean>;
|
||||||
|
close: (() => void) | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ScopeContext = createContext<ScopeValue | null>(null);
|
const ScopeContext = createContext<ScopeValue | null>(null);
|
||||||
@@ -65,10 +66,13 @@ const keyOwners: number[] = [];
|
|||||||
export function SurfaceScope({
|
export function SurfaceScope({
|
||||||
kind = "panel",
|
kind = "panel",
|
||||||
onEditingChange,
|
onEditingChange,
|
||||||
|
onClose,
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
kind?: SurfaceKind;
|
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. */
|
/** Gets the widest open inline view, so a sidebar can widen for it. */
|
||||||
onEditingChange?: (editing: EditingWidth) => void;
|
onEditingChange?: (editing: EditingWidth) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -80,6 +84,13 @@ export function SurfaceScope({
|
|||||||
const nextConfirm = useRef(0);
|
const nextConfirm = useRef(0);
|
||||||
const editingRef = useRef(onEditingChange);
|
const editingRef = useRef(onEditingChange);
|
||||||
editingRef.current = 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) => {
|
const openView = useCallback((id: string, width: EditingWidth) => {
|
||||||
setViews((prev) => [...prev.filter((v) => v.id !== id), { id, width }]);
|
setViews((prev) => [...prev.filter((v) => v.id !== id), { id, width }]);
|
||||||
@@ -126,8 +137,9 @@ export function SurfaceScope({
|
|||||||
closeView,
|
closeView,
|
||||||
topView: views.length ? views[views.length - 1].id : null,
|
topView: views.length ? views[views.length - 1].id : null,
|
||||||
confirm,
|
confirm,
|
||||||
|
close,
|
||||||
}),
|
}),
|
||||||
[kind, overlay, openView, closeView, views, confirm],
|
[kind, overlay, openView, closeView, views, confirm, close],
|
||||||
);
|
);
|
||||||
|
|
||||||
const current = confirms[0];
|
const current = confirms[0];
|
||||||
@@ -165,6 +177,11 @@ export function useSurfaceKind(): SurfaceKind | null {
|
|||||||
return useContext(ScopeContext)?.kind ?? 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
|
* Asks a yes or no question over the surface that called it. Enter confirms
|
||||||
* and Esc cancels, before a terminal can see the key.
|
* and Esc cancels, before a terminal can see the key.
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
"connection": {
|
"connection": {
|
||||||
"retryingIn": "Retrying in {{seconds}}s ({{attempt}}/{{max}})",
|
"retryingIn": "Retrying in {{seconds}}s ({{attempt}}/{{max}})",
|
||||||
"retryingNow": "Retrying... ({{attempt}}/{{max}})",
|
"retryingNow": "Retrying... ({{attempt}}/{{max}})",
|
||||||
"disconnected": "Unable to connect",
|
|
||||||
"reconnect": "Reconnect",
|
"reconnect": "Reconnect",
|
||||||
|
"close": "Close",
|
||||||
"failed": "Could not connect",
|
"failed": "Could not connect",
|
||||||
"failedRetrying": "Connection failed, trying again",
|
"failedRetrying": "Connection failed, trying again",
|
||||||
"retryNow": "Retry now"
|
"retryNow": "Retry now"
|
||||||
|
|||||||
@@ -6,7 +6,12 @@ vi.mock("react-i18next", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import { ConnectionScreen } from "../../../components/connection/ConnectionScreen";
|
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);
|
afterEach(cleanup);
|
||||||
|
|
||||||
@@ -107,6 +112,75 @@ describe("ConnectionScreen", () => {
|
|||||||
expect(screen.getByText("connection.reconnect")).toBeTruthy();
|
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", () => {
|
it("renders nothing once connected", () => {
|
||||||
const { container } = render(<ConnectionScreen status="connected" />);
|
const { container } = render(<ConnectionScreen status="connected" />);
|
||||||
expect(container.innerHTML).toBe("");
|
expect(container.innerHTML).toBe("");
|
||||||
|
|||||||
Reference in new issue
Block a user