fix: unify connection failure screens and rebuild stale local plugins

This commit is contained in:
LukeGus committed 2026-10-06 15:21:44 -05:00
1 parent a72aa5b57b
commit 49fc241e06
9 files changed
+262 -29

No files matched your search

+13 -2
View File
@@ -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 });
+51
View File
@@ -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([]);
});
});
+52
View File
@@ -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
View File
@@ -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}
/>
)}
+18 -1
View File
@@ -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.
+1 -1
View File
@@ -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("");