fix: finish 2.9.0 plugin moves and get every check green

This commit is contained in:
LukeGus committed 2026-09-24 23:35:18 -05:00
1 parent 8c1e02af89
commit 6489b62ece
335 files changed
+29878 -17749

No files matched your search

-7
View File
@@ -401,13 +401,6 @@
},
"hosts": {
"fileManager": "File Manager",
"tabFiles": "Files",
"enableFileManagerMonitor": "Enable File Manager",
"enableFileManagerMonitorDesc": "Browse and manage files on this host over SFTP",
"scpLegacyLabel": "SCP Legacy Mode",
"scpLegacyDesc": "Use legacy file transfer for servers that do not support the SFTP subsystem (e.g. embedded or minimal SSH servers).",
"defaultPathLabel": "Default Path",
"fileManagerPathHint": "The directory to open when the file manager launches for this host.",
"fileManagerUrlCopied": "File Manager URL copied",
"copyFileManagerUrlAction": "Copy File Manager URL"
}
+5 -2
View File
@@ -20,7 +20,9 @@
"ssh:connect",
"db:own",
"network:serve",
"ui:surface"
"ui:surface",
"events:core",
"kv:own"
],
"provides": [
{
@@ -70,6 +72,7 @@
"host": {
"enableKey": "enableFileManager",
"enableLabelKey": "settings.host.enable.label",
"enableDefault": true,
"fields": [
{
"key": "defaultPath",
@@ -89,7 +92,7 @@
"hostCapability": {
"key": "enableFileManager",
"labelKey": "hostCapability.label",
"editorTab": "files"
"editorTab": "plugins"
}
},
"locales": "locales",
+1 -1
View File
@@ -6,7 +6,7 @@
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -p tsconfig.json",
"typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json",
"validate": "termix-plugin validate"
},
"devDependencies": {
+19 -1
View File
@@ -9,6 +9,12 @@ import type { PluginContext, PluginSshHost } from "@termix/plugin-sdk/backend";
import { getErrorMessage } from "./error-message.js";
import { setPluginSsh, pluginSsh } from "./ssh.js";
import { setPluginCtx } from "./plugin-ctx.js";
import {
flushTransferProfiles,
setTransferProfileStore,
} from "./transfer-tuning.js";
const TRANSFER_PROFILES_KEY = "transfer-profiles";
import { AuthManager } from "../../../../src/backend/utils/auth-manager.js";
import { logger } from "../../../../src/backend/utils/logger.js";
import {
@@ -113,6 +119,14 @@ export async function activate(ctx: PluginContext) {
setPluginCtx(ctx);
ctx.disposables.add(() => setPluginSsh(null));
ctx.disposables.add(() => setPluginCtx(null));
setTransferProfileStore({
read: () => ctx.kv.get(TRANSFER_PROFILES_KEY),
write: (value) => ctx.kv.set(TRANSFER_PROFILES_KEY, value),
});
ctx.disposables.add(async () => {
await flushTransferProfiles();
setTransferProfileStore(null);
});
/* eslint-disable @typescript-eslint/no-explicit-any */
// ctx.db.define hands back an untyped table object; typed at this module's
@@ -605,7 +619,11 @@ export async function activate(ctx: PluginContext) {
};
resolvedTerminalConfig = resolvedHost.terminalConfig as unknown as
Record<string, unknown> | undefined;
resolvedScpLegacy = resolvedHost.scpLegacy ?? false;
resolvedScpLegacy =
(await ctx.settings.getHost<boolean>(
Number(resolvedHost.id),
"scpLegacy",
)) === true;
if (resolvedHost.useSocks5) {
resolvedUseSocks5 = resolvedHost.useSocks5;
resolvedSocks5Host = resolvedHost.socks5Host;
@@ -1,7 +1,4 @@
// TODO(file-manager): move this to ctx.kv, see FINISH-LIST.md
import { createHash } from "node:crypto";
import { promises as fs } from "node:fs";
import path from "node:path";
export interface TransferPerformanceProfile {
throughputBps: number;
@@ -32,15 +29,32 @@ const MB = 1024 * 1024;
const PROFILE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
const MAX_PROFILES = 128;
const STORE_VERSION = 1;
const STORE_FILENAME = "adaptive-transfer-profiles.json";
const profiles = new Map<string, TransferPerformanceProfile>();
const directRoutes = new Map<string, DirectRouteProfile>();
let loadedPath: string | undefined;
/** Where the learned profiles live: ctx.kv, set on activate. */
export interface TransferProfileStore {
read: () => Promise<unknown>;
write: (value: PersistedProfiles) => Promise<void>;
}
let store: TransferProfileStore | null = null;
let loadedStore: TransferProfileStore | null = null;
let loadPromise: Promise<void> | undefined;
export function setTransferProfileStore(
next: TransferProfileStore | null,
): void {
store = next;
loadedStore = null;
loadPromise = undefined;
profiles.clear();
directRoutes.clear();
}
let persistTimer: ReturnType<typeof setTimeout> | undefined;
let persistPromise = Promise.resolve();
interface PersistedProfiles {
export interface PersistedProfiles {
version: typeof STORE_VERSION;
profiles: Record<string, TransferPerformanceProfile>;
directRoutes: Record<string, DirectRouteProfile>;
@@ -68,12 +82,6 @@ function validDirectRoute(value: unknown): value is DirectRouteProfile {
);
}
function storePath(): string {
const dataDir =
process.env.DATA_DIR || path.join(process.cwd(), "db", "data");
return path.join(dataDir, STORE_FILENAME);
}
function profileId(key: string): string {
return createHash("sha256").update(key).digest("hex");
}
@@ -117,22 +125,15 @@ function trimProfiles(now = Date.now()): void {
async function persistProfiles(): Promise<void> {
trimProfiles();
const target = storePath();
const temporary = `${target}.${process.pid}.tmp`;
const payload: PersistedProfiles = {
version: STORE_VERSION,
profiles: Object.fromEntries(profiles),
directRoutes: Object.fromEntries(directRoutes),
};
if (!store) return;
try {
await fs.mkdir(path.dirname(target), { recursive: true });
await fs.writeFile(temporary, JSON.stringify(payload), {
encoding: "utf8",
mode: 0o600,
await store.write({
version: STORE_VERSION,
profiles: Object.fromEntries(profiles),
directRoutes: Object.fromEntries(directRoutes),
});
await fs.rename(temporary, target);
} catch {
await fs.rm(temporary, { force: true }).catch(() => {});
// Learning data is a nicety; a failed write must not affect transfers.
}
}
@@ -146,16 +147,16 @@ function queuePersist(): void {
}
export async function initializeTransferProfiles(): Promise<void> {
const target = storePath();
if (loadedPath === target) return loadPromise;
loadedPath = target;
if (loadPromise && loadedStore === store) return loadPromise;
loadedStore = store;
const current = store;
loadPromise = (async () => {
profiles.clear();
directRoutes.clear();
if (!current) return;
try {
const parsed = JSON.parse(
await fs.readFile(target, "utf8"),
) as Partial<PersistedProfiles>;
const parsed = ((await current.read()) ??
{}) as Partial<PersistedProfiles>;
if (parsed.version !== STORE_VERSION || !parsed.profiles) return;
for (const [key, profile] of Object.entries(parsed.profiles)) {
if (/^[a-f0-9]{64}$/.test(key) && validProfile(profile)) {
@@ -381,7 +382,7 @@ export function getRecentDirectRouteDecision(
export function clearTransferProfiles(): void {
if (persistTimer) clearTimeout(persistTimer);
persistTimer = undefined;
loadedPath = undefined;
loadedStore = null;
loadPromise = undefined;
profiles.clear();
directRoutes.clear();
@@ -1,3 +1,4 @@
import { fileManagerHostSetting } from "./host-settings";
import { getErrorMessage } from "@/lib/error-message.js";
/* eslint-disable react-hooks/exhaustive-deps */
import React, {
@@ -44,7 +45,11 @@ import {
ConnectionScreen,
} from "@termix/plugin-sdk/ui";
import { ConnectionLogPanel } from "@/components/connection/ConnectionLogPanel.tsx";
import { invokeAction, useConnectionRetry } from "@termix/plugin-sdk/frontend";
import {
invokeAction,
useConnectionRetry,
logActivity,
} from "@termix/plugin-sdk/frontend";
import { copyToClipboard } from "@/lib/clipboard.ts";
import {
listSSHFiles,
@@ -77,7 +82,6 @@ import {
getPinnedFiles,
} from "./api/file-manager-data-api";
import {
logActivity,
transferToHost,
addTransferRecent,
type TransferMethodPreference,
@@ -145,15 +149,21 @@ function FileManagerContent({
const [currentHost] = useState<SSHHost | null>(initialHost || null);
const [currentPath, setCurrentPath] = useState(
initialPath || initialHost?.defaultPath || "/",
initialPath ||
fileManagerHostSetting(initialHost, "defaultPath", "") ||
"/",
);
const currentPathRef = useRef(currentPath);
currentPathRef.current = currentPath;
const lastSuccessfulPathRef = useRef(
initialPath || initialHost?.defaultPath || "/",
initialPath ||
fileManagerHostSetting(initialHost, "defaultPath", "") ||
"/",
);
const [navHistory, setNavHistory] = useState<string[]>([
initialPath || initialHost?.defaultPath || "/",
initialPath ||
fileManagerHostSetting(initialHost, "defaultPath", "") ||
"/",
]);
const [navIndex, setNavIndex] = useState(0);
const [files, setFiles] = useState<FileItem[]>([]);
@@ -1,65 +0,0 @@
import { useTranslation } from "@termix/plugin-sdk/frontend";
import {
Input,
SectionCard,
SettingRow,
FakeSwitch,
} from "@termix/plugin-sdk/ui";
import { FolderSearch } from "lucide-react";
import type { HostEditorForm } from "@/sidebar/HostEditorData";
type SetHostField = <K extends keyof HostEditorForm>(
key: K,
value: HostEditorForm[K],
) => void;
export function HostFilesTab({
form,
setField,
}: {
form: HostEditorForm;
setField: SetHostField;
}) {
const { t } = useTranslation();
return (
<SectionCard
title={t("hosts.fileManager")}
icon={<FolderSearch className="size-3.5" />}
>
<div className="flex flex-col gap-4 py-3">
<SettingRow
label={t("hosts.enableFileManagerMonitor")}
description={t("hosts.enableFileManagerMonitorDesc")}
>
<FakeSwitch
checked={form.enableFileManager}
onChange={(v) => setField("enableFileManager", v)}
/>
</SettingRow>
<SettingRow
label={t("hosts.scpLegacyLabel")}
description={t("hosts.scpLegacyDesc")}
>
<FakeSwitch
checked={form.scpLegacy}
onChange={(v) => setField("scpLegacy", v)}
/>
</SettingRow>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.defaultPathLabel")}
</label>
<Input
placeholder="/"
value={form.defaultPath}
onChange={(e) => setField("defaultPath", e.target.value)}
/>
<span className="text-[10px] text-muted-foreground">
{t("hosts.fileManagerPathHint")}
</span>
</div>
</div>
</SectionCard>
);
}
@@ -1,3 +1,4 @@
import { fileManagerHostSetting } from "./host-settings";
import {
useCallback,
useEffect,
@@ -372,11 +373,11 @@ function RemotePane({
...current,
connectionState: "ready",
sessionId: result.sessionId || String(host.id),
path: host.defaultPath || "/",
path: fileManagerHostSetting(host, "defaultPath", "") || "/",
}));
await loadRemotePath(
result.sessionId || String(host.id),
host.defaultPath || "/",
fileManagerHostSetting(host, "defaultPath", "") || "/",
);
},
[loadRemotePath, setPane],
@@ -585,7 +586,7 @@ export function SftpTransferTab() {
setHosts(
data.filter(
(host) =>
host.enableFileManager !== false &&
fileManagerHostSetting(host, "enableFileManager", true) &&
host.connectionType !== "rdp" &&
host.connectionType !== "vnc",
),
@@ -1,14 +1,11 @@
import React, { useState } from "react";
import { Document, Page } from "react-pdf";
import { Document, Page, pdfjs } from "react-pdf";
import { AlertCircle, Download } from "lucide-react";
import { Button } from "@termix/plugin-sdk/ui";
import { useTranslation } from "react-i18next";
import pdfjsWorkerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url";
// No workerSrc: the termix-plugin CLI's esbuild step has no asset-copy
// pipeline for a worker file the way core's Vite build does with `?url`, so
// pdfjs falls back to parsing on the main thread instead. Fine for the
// config/log-sized files this preview usually opens; a real worker needs the
// CLI to gain static-asset output first (see FINISH-LIST.md).
pdfjs.GlobalWorkerOptions.workerSrc = pdfjsWorkerUrl;
interface PdfPreviewProps {
content: string;
@@ -1,3 +1,4 @@
import { fileManagerHostSetting } from "../host-settings";
import React, {
useCallback,
useEffect,
@@ -236,7 +237,7 @@ export function TransferToHostDialog({
const candidates = hosts.filter(
(h) =>
h.id !== sourceHost.id &&
h.enableFileManager !== false &&
fileManagerHostSetting(h, "enableFileManager", true) &&
h.connectionType !== "rdp" &&
h.connectionType !== "vnc",
);
@@ -1,3 +1,4 @@
import { fileManagerHostSetting } from "../host-settings";
import { useHosts, useTranslation } from "@termix/plugin-sdk/frontend";
import { Select2 } from "@termix/plugin-sdk/ui";
import type {
@@ -12,7 +13,9 @@ export function FileManagerWidgetEditForm({
const { t } = useTranslation();
const { hosts } = useHosts();
const options = hosts.filter(
(host) => host.enableSsh !== false && host.enableFileManager !== false,
(host) =>
host.enableSsh !== false &&
fileManagerHostSetting(host, "enableFileManager", true),
);
return (
@@ -0,0 +1,14 @@
/** One of this plugin's host settings, from the host payload. */
export function fileManagerHostSetting<T>(
host: object | null | undefined,
key: "enableFileManager" | "defaultPath" | "scpLegacy",
fallback: T,
): T {
const settings = (
host as { pluginSettings?: Record<string, Record<string, unknown>> } | null
)?.pluginSettings;
const value = settings?.["file-manager"]?.[key];
return value === undefined || value === null || value === ""
? fallback
: (value as T);
}
+4 -11
View File
@@ -1,3 +1,4 @@
import { fileManagerHostSetting } from "./host-settings";
import type { ComponentType } from "react";
import { FolderSearch, ArrowLeftRight } from "lucide-react";
import {
@@ -11,7 +12,6 @@ import { GRID_SIZE } from "./homepage/homepage.js";
import { FileManager } from "./FileManager.tsx";
import FileManagerApp from "./FileManagerApp.tsx";
import { SftpTransferTab } from "./SftpTransferTab.tsx";
import { HostFilesTab } from "./HostFilesTab.tsx";
import { FileManagerWidget } from "./homepage/FileManagerWidget.tsx";
import { FileManagerWidgetEditForm } from "./homepage/FileManagerWidgetEditForm.tsx";
import { startTransferMonitor } from "./TransferMonitor.tsx";
@@ -104,16 +104,9 @@ export function activate(app: TermixApp): void {
order: 20,
tabType: "files",
copyUrlView: "file-manager",
when: (host) => !!host.enableSsh && host.enableFileManager !== false,
});
app.registerHostEditorSection({
id: "files",
group: "ssh",
titleKey: "hosts.tabFiles",
icon: FolderSearch,
order: 60,
component: HostFilesTab as never,
when: (host) =>
!!host.enableSsh &&
fileManagerHostSetting(host, "enableFileManager", true),
});
app.registerHomepageWidget({
@@ -0,0 +1,57 @@
import { afterEach, describe, expect, it } from "vitest";
import {
clearTransferProfiles,
flushTransferProfiles,
getTransferProfile,
initializeTransferProfiles,
recordTransferProfile,
setTransferProfileStore,
type PersistedProfiles,
} from "../../src/backend/transfer-tuning.js";
afterEach(() => {
setTransferProfileStore(null);
clearTransferProfiles();
});
function memoryStore() {
let saved: PersistedProfiles | undefined;
return {
store: {
read: async () => saved,
write: async (value: PersistedProfiles) => {
saved = JSON.parse(JSON.stringify(value));
},
},
saved: () => saved,
};
}
describe("transfer profile store", () => {
it("saves learned profiles and reads them back after a restart", async () => {
const memory = memoryStore();
setTransferProfileStore(memory.store);
await initializeTransferProfiles();
recordTransferProfile("host-a:host-b", {
bytes: 50 * 1024 * 1024,
durationMs: 5000,
lanes: 4,
pipelineConcurrency: 8,
failed: false,
now: Date.now(),
});
await flushTransferProfiles();
expect(Object.keys(memory.saved()?.profiles ?? {})).toHaveLength(1);
// A fresh activation starts from what the store holds.
setTransferProfileStore(memory.store);
await initializeTransferProfiles();
expect(getTransferProfile("host-a:host-b")).toBeDefined();
});
it("works without a store", async () => {
await initializeTransferProfiles();
expect(getTransferProfile("nothing")).toBeUndefined();
});
});
@@ -59,17 +59,19 @@ beforeEach(() => {
id: 1,
name: "prod",
ip: "10.0.0.1",
enableFileManager: true,
connectionType: "ssh",
defaultPath: "/srv",
pluginSettings: {
"file-manager": { enableFileManager: true, defaultPath: "/srv" },
},
},
{
id: 2,
name: "backup",
ip: "10.0.0.2",
enableFileManager: true,
connectionType: "ssh",
defaultPath: "/srv",
pluginSettings: {
"file-manager": { enableFileManager: true, defaultPath: "/srv" },
},
},
]);
api.ensureSSHSessionForHost.mockImplementation(async (host) => ({
@@ -52,9 +52,9 @@ describe(`${manifest.id} activate`, () => {
);
});
it("registers the files host editor section", async () => {
it("leaves its host fields to the schema-driven Plugins tab", async () => {
rendered = await renderWithApp(plugin, { manifest, locales });
expect(rendered.registered.hostEditorSections()).toContain("files");
expect(rendered.registered.hostEditorSections()).toEqual([]);
});
it("registers files.openHost and files.openEditor actions", async () => {
@@ -8,7 +8,7 @@ vi.mock("sonner", () => ({
}));
vi.mock("@/main-axios.ts", () => ({}));
vi.mock("../../../features/file-manager/transferNotificationStore.ts", () => ({
vi.mock("../../src/frontend/transferNotificationStore.ts", () => ({
markTransferNotified: vi.fn(),
}));