diff --git a/scripts/build-plugins.cjs b/scripts/build-plugins.cjs index ccb520a01..53d85afb1 100644 --- a/scripts/build-plugins.cjs +++ b/scripts/build-plugins.cjs @@ -7,10 +7,12 @@ * pinned sha256 and unpacked. * * For development, `--local ` (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 }); diff --git a/scripts/bundled-plugins.test.ts b/scripts/bundled-plugins.test.ts index e4f8d25a8..744f2a7a1 100644 --- a/scripts/bundled-plugins.test.ts +++ b/scripts/bundled-plugins.test.ts @@ -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([]); + }); +}); diff --git a/scripts/lib/bundled-plugins.cjs b/scripts/lib/bundled-plugins.cjs index 8d47924b5..3056bfe14 100644 --- a/scripts/lib/bundled-plugins.cjs +++ b/scripts/lib/bundled-plugins.cjs @@ -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, diff --git a/src/ui/AppShell.tsx b/src/ui/AppShell.tsx index 6e414a309..1ccd2f8e4 100644 --- a/src/ui/AppShell.tsx +++ b/src/ui/AppShell.tsx @@ -3118,7 +3118,15 @@ export function AppShell({ ? paneId === focusedPaneId : activeInline; return createPortal( - + closeTab(tab.id) + } + > {renderTabContent(tab, { shell: shellCallbacks, settings: settingsTabProps, diff --git a/src/ui/components/connection/ConnectionLogPanel.tsx b/src/ui/components/connection/ConnectionLogPanel.tsx index 55ba209d5..527973795 100644 --- a/src/ui/components/connection/ConnectionLogPanel.tsx +++ b/src/ui/components/connection/ConnectionLogPanel.tsx @@ -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({
-
+
{(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("");