mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15: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.
|
||||
*
|
||||
* For development, `--local <dir>` (or TERMIX_LOCAL_PLUGINS) points at a
|
||||
* folder of plugin repos like ../Termix-Plugins. Any plugin built there (has
|
||||
* dist/) is copied in place of its pin, so local plugin changes show up.
|
||||
* folder of plugin repos like ../Termix-Plugins. A bundled plugin there whose
|
||||
* 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 path = require("node:path");
|
||||
const {
|
||||
@@ -18,6 +20,7 @@ const {
|
||||
fetchArtifact,
|
||||
extractArtifact,
|
||||
findLocalPluginBuilds,
|
||||
rebuildLocalPlugins,
|
||||
copyLocalBuild,
|
||||
} = require("./lib/bundled-plugins.cjs");
|
||||
|
||||
@@ -30,6 +33,14 @@ async function main() {
|
||||
const localArg =
|
||||
flag !== -1 ? process.argv[flag + 1] : process.env.TERMIX_LOCAL_PLUGINS;
|
||||
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);
|
||||
|
||||
fs.rmSync(destination, { recursive: true, force: true });
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
extractArtifact,
|
||||
pinsFromIndex,
|
||||
findLocalPluginBuilds,
|
||||
isLocalBuildStale,
|
||||
rebuildLocalPlugins,
|
||||
copyLocalBuild,
|
||||
} from "./lib/bundled-plugins.cjs";
|
||||
|
||||
@@ -230,3 +232,52 @@ describe("local plugin builds", () => {
|
||||
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;
|
||||
}
|
||||
|
||||
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. */
|
||||
function copyLocalBuild(repo, destination) {
|
||||
fs.mkdirSync(destination, { recursive: true });
|
||||
@@ -216,6 +266,8 @@ function copyLocalBuild(repo, destination) {
|
||||
|
||||
module.exports = {
|
||||
findLocalPluginBuilds,
|
||||
isLocalBuildStale,
|
||||
rebuildLocalPlugins,
|
||||
copyLocalBuild,
|
||||
parseBundledPlugins,
|
||||
pinsFromIndex,
|
||||
|
||||
+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