feat: convert file manager into a plugin

This commit is contained in:
LukeGus committed 2026-09-23 16:27:29 -05:00
1 parent b76da9ab52
commit c85c1fdb86
259 files changed
+54155 -16791

No files matched your search

+19 -7
View File
@@ -1067,9 +1067,15 @@ connect pipeline instead of importing ssh2 helpers from core:
a chain has no single resolved host of its own.
- `prepare(host, { client, purpose })`, `openTransport`,
`classifyKeyboardInteractive` and `autoResponses` are the lower level for a
transport with its own prompt flow (docker's console, host metrics).
transport with its own prompt flow (docker's console, host metrics, **B6**'s
interactive file-manager connect with its TOTP/Warpgate parking flow).
- `requiresSecret(authType)` and `supportsBackground(authType)` ask the
provider.
- **B6** added `"file-manager"` and `"file-transfer"` to `PluginSshPurpose`
(core's `SshConnectPurpose` already had them; the SDK type had lagged),
since the file-manager plugin's interactive connect route and its dedicated
transfer sessions both need their own keepalive/timeout defaults rather than
falling back to the generic `"plugin"` purpose.
Each new connection is audited and runs as the current actor; pooled reuse is
not. Connections and pool entries are closed on deactivate.
@@ -1608,8 +1614,8 @@ What the lint fence enforces today, in `eslint.config.mjs`:
| Core importing a plugin backend | **Error** | 0 | - |
| A plugin backend importing frontend code or `@/` | **Error** | 0 | - |
| The shell importing plugin code | **Error** | 0 | - |
| A plugin frontend importing core through `@/` | Warning | 108 files | D1 |
| A plugin importing core by relative path | Warning | 67 files | D1 |
| A plugin frontend importing core through `@/` | Warning | 138 files | D1 |
| A plugin importing core by relative path | Warning | 68 files | D1 |
| A plugin importing another plugin's source | Warning | 3 files | B18 |
A warning does not fail a build, so the counts are held by
@@ -1639,11 +1645,17 @@ Known specifics:
an `optionalDependency` (added in A2; it previously declared nothing, and
the loader had no reason to order the two).
- Core feature servers that are not plugins yet still own ports: tunnel 30003,
file-manager 30004, dashboard 30006, tmux 30010, serial 30011 and homepage 30012. Each keeps its nginx block until its own Phase B step. No plugin owns
a port any more (A4).
dashboard 30006, tmux 30010, serial 30011 and homepage 30012. Each keeps its
nginx block until its own Phase B step. No plugin owns a port any more (A4).
**B6** moved file-manager off port 30004 onto `/plugin-api/file-manager/`.
- The terminal component and `TerminalTabContent` stay in core until the
terminal's Phase B step (session manager, split view and file-manager
callbacks). ssh-terminal registers them through the legacy alias.
terminal's Phase B step (session manager, split view). ssh-terminal
registers them through the legacy alias; **B6** switched every core caller
of the file manager over to `invokeAction("files.openHost"/"files.openEditor")`
instead of the `openFileManager`/`openFileInEditor` shell callbacks it
removed, so `TerminalWindow.tsx`'s embedding of the core terminal component
is now the only file-manager-adjacent legacy-core import left, tracked the
same way as any other plugin depending on an unconverted core component.
- Host data columns (`enableDocker`, `enableRdp`, `guacamoleConfig` and so on)
stay in core types until Phase B moves them. Only UI branches on them left
the shell.
+48 -8
View File
@@ -23,14 +23,16 @@ it's done.
is the SDK copy fleets now uses; core's copy was left in place rather than
turned into a re-export, since host-metrics's own conversion step is where
its imports should move wholesale. Owner: host-metrics's Phase B step.
- **B5 (proxmox):** `useConnectionRetry`, `runAdaptivePolling` and `cn` are
duplicated in `plugins/proxmox/src/frontend/stats/` rather than shared with
core's `src/ui/lib/` originals, matching the same "add to the SDK once a
second plugin needs it" call B3 made for `ManagerCardShell`. host-metrics's
own conversion needs the same two hooks for its stats tab, so promote both
into `@termix/plugin-sdk/frontend` (and `cn` into `@termix/plugin-sdk/ui`)
the next time either is touched. Owner: D1, or host-metrics's Phase B step
if it lands first.
- **B5 (proxmox):** `runAdaptivePolling` and `cn` are duplicated in
`plugins/proxmox/src/frontend/stats/` rather than shared with core's
`src/ui/lib/` originals. `useConnectionRetry` (the third duplicate B5 left
here) is resolved: **B6** promoted it into `@termix/plugin-sdk/frontend`
once file-manager became its fourth caller (after proxmox, docker,
host-metrics, remote-desktop) and switched proxmox's own copy over too.
host-metrics's own conversion needs `runAdaptivePolling` for its stats tab,
so promote it into `@termix/plugin-sdk/frontend` (and `cn` into
`@termix/plugin-sdk/ui`) the next time either is touched. Owner: D1, or
host-metrics's Phase B step if it lands first.
- **B5 (proxmox):** `runDueProxmoxAutoSyncs` in
`plugins/proxmox/src/backend/routes.ts` still reaches
`createCurrentPluginSettingsRepository`/`createCurrentHostRepository` by
@@ -40,3 +42,41 @@ it's done.
bulk read today. A `ctx.settings.listHostsWithKey` or similar SDK addition
would close this, if a second plugin's background scan ever needs the same
shape. Owner: D1.
- **B6 (file-manager):** `plugins/file-manager/src/frontend/components/PdfPreview.tsx`
runs pdfjs without a worker (main-thread parsing) because `termix-plugin
build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
build resolves a `?url` import to a hashed `/assets/` file. Fine for the
small files this preview usually opens; restoring a real worker needs the
CLI (`packages/plugin-sdk/cli/commands/build.mjs`) to gain a way to emit an
extra file into `dist/` and have the frontend reference it by its served
`/plugin-assets/<id>/` path. Owner: D1, or whichever step first needs a
worker or other static asset from a plugin bundle.
- **B6 (file-manager):** `transfer-tuning.ts`'s adaptive-tuning profile still
persists to a JSON file under `DATA_DIR` rather than `ctx.kv`, and
`HostFilesTab.tsx`'s `t()` calls plus the manifest's `contributes.settings.host`
`labelKey`s deliberately point at two different key paths (`hosts.*` and
`settings.host.*`) for the same three fields, both carrying the same English
text - a minor duplication from keeping the host editor's exact existing
section verbatim (`registerHostEditorSection`) instead of switching it to
the schema-driven settings form. Neither blocks anything; flagging for
whoever next touches this plugin's settings UI.
- **B6 (file-manager):** `TabContext.tsx`'s `computeUniqueTitle` compares
`tabType === "file_manager"` (an activity-log type string, not a tab type -
the real tab type has always been `"files"`), so that branch has never
matched and silently falls through to the terminal title. Pre-existing,
unrelated to the conversion; noted rather than fixed per the lean-mode
rules. Owner: whoever next touches tab title logic.
- **B6 (file-manager):** `enable_file_manager`, `default_path` and
`scp_legacy` are still live `ssh_data` columns; only the copy-into-plugin-
settings migration shipped (`file-manager-settings-migration.ts`). Every
core read site the earlier exploration found (`host.ts`,
`host-normalizers.ts`, `host-bulk-routes.ts`, `credentials.ts`,
`host-internal-routes.ts`, `database.ts`'s encrypt/decrypt round trip,
`plugins/host-view.ts`, `ctx-hosts.ts`, plus the frontend's
`HostEditorData.ts`/`HostManagerData.ts`/`SidebarTree.tsx`/quick-connect
mapping) still reads the column directly rather than
`ctx.settings.getHost`. Switching every one of those and then dropping the
column in lockstep (`schema.ts`, `db/index.ts`, a drizzle migration per
dialect, `schema:generate`) is real, separate work this step didn't
attempt, since none of those reads block the plugin conversion itself.
Owner: a dedicated follow-up step, or D0.
+3 -1
View File
@@ -577,7 +577,9 @@ export type PluginSshPurpose =
| "proxmox"
| "fleet"
| "remote-desktop"
| "tunnel";
| "tunnel"
| "file-manager"
| "file-transfer";
export type PluginSshAuthOutcome =
| { status: "ready" }
+201 -2
View File
@@ -13,6 +13,7 @@
* and every plugin bundle.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import type { ComponentType, ReactNode, Ref } from "react";
import type { PluginManifest } from "./manifest.js";
@@ -75,8 +76,6 @@ export interface ShellApi {
openSingletonTab: (type: string, options?: OpenTabOptions) => void;
closeTab: (tabId: string) => void;
renameTab: (tabId: string, label: string) => void;
openFileInEditor: (host: PluginHostRecord, path: string) => void;
openFileManager: (host: PluginHostRecord, path?: string) => void;
openTerminalTab: (host: PluginHostRecord, path?: string) => void;
/** Opens a rail view in the left sidebar. */
openRailView: (id: string) => void;
@@ -733,5 +732,205 @@ export function invokeAction(id: string, ...args: unknown[]): Promise<unknown> {
return requireHost().invokeAction(id, ...args);
}
// ---------------------------------------------------------------------------
// Connection retry
// ---------------------------------------------------------------------------
export type ConnectionRetryStatus =
"connecting" | "connected" | "error" | "disconnected";
export interface UseConnectionRetryOptions {
connect: () => void | Promise<void>;
maxAttempts?: number;
baseDelayMs?: number;
maxDelayMs?: number;
enabled?: boolean;
autoStart?: boolean;
}
export interface UseConnectionRetryResult {
status: ConnectionRetryStatus;
attempt: number;
maxAttempts: number;
nextRetryInMs: number | null;
markConnected: () => void;
markFailed: () => void;
retryNow: () => void;
reset: () => void;
}
const COUNTDOWN_TICK_MS = 250;
const RETRY_JITTER_RATIO = 0.1;
export function computeReconnectDelay(
attempt: number,
baseDelayMs: number,
maxDelayMs: number,
random = Math.random,
): number {
const base = Math.min(
baseDelayMs * Math.pow(2, Math.max(0, attempt - 1)),
maxDelayMs,
);
const jitter = base * RETRY_JITTER_RATIO * (random() * 2 - 1);
return Math.max(baseDelayMs, Math.min(maxDelayMs, Math.round(base + jitter)));
}
/**
* Exponential-backoff reconnect with jitter, for a plugin's own connect flow
* (docker's console, host-metrics polling, remote desktop tokens, the file
* manager). Promoted here once a fourth plugin needed its own copy of the
* same hook.
*/
export function useConnectionRetry({
connect,
maxAttempts = 8,
baseDelayMs = 2000,
maxDelayMs = 8000,
enabled = true,
autoStart = true,
}: UseConnectionRetryOptions): UseConnectionRetryResult {
const [status, setStatus] = useState<ConnectionRetryStatus>("connecting");
const [attempt, setAttempt] = useState(0);
const [nextRetryInMs, setNextRetryInMs] = useState<number | null>(null);
const connectRef = useRef(connect);
connectRef.current = connect;
const enabledRef = useRef(enabled);
enabledRef.current = enabled;
const attemptRef = useRef(0);
const retryTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const countdownIntervalRef = useRef<ReturnType<typeof setInterval> | null>(
null,
);
const isMountedRef = useRef(true);
const markFailedRef = useRef<() => void>(() => {});
const clearTimers = useCallback(() => {
if (retryTimeoutRef.current) {
clearTimeout(retryTimeoutRef.current);
retryTimeoutRef.current = null;
}
if (countdownIntervalRef.current) {
clearInterval(countdownIntervalRef.current);
countdownIntervalRef.current = null;
}
}, []);
const runConnect = useCallback(() => {
if (!isMountedRef.current) return;
setStatus("connecting");
try {
void Promise.resolve(connectRef.current()).catch(() => {
markFailedRef.current();
});
} catch {
markFailedRef.current();
}
}, []);
const scheduleRetry = useCallback(() => {
if (attemptRef.current >= maxAttempts) {
setStatus("disconnected");
setNextRetryInMs(null);
return;
}
attemptRef.current += 1;
setAttempt(attemptRef.current);
const delay = computeReconnectDelay(
attemptRef.current,
baseDelayMs,
maxDelayMs,
);
let remaining = delay;
setNextRetryInMs(remaining);
countdownIntervalRef.current = setInterval(() => {
remaining = Math.max(0, remaining - COUNTDOWN_TICK_MS);
setNextRetryInMs(remaining);
}, COUNTDOWN_TICK_MS);
retryTimeoutRef.current = setTimeout(() => {
clearTimers();
if (!isMountedRef.current || !enabledRef.current) return;
runConnect();
}, delay);
}, [baseDelayMs, maxDelayMs, maxAttempts, clearTimers, runConnect]);
const markConnected = useCallback(() => {
clearTimers();
attemptRef.current = 0;
setAttempt(0);
setNextRetryInMs(null);
if (isMountedRef.current) setStatus("connected");
}, [clearTimers]);
const markFailed = useCallback(() => {
clearTimers();
if (!isMountedRef.current) return;
setStatus("error");
if (enabledRef.current) {
scheduleRetry();
} else {
setNextRetryInMs(null);
}
}, [clearTimers, scheduleRetry]);
markFailedRef.current = markFailed;
const retryNow = useCallback(() => {
clearTimers();
attemptRef.current = 0;
setAttempt(0);
setNextRetryInMs(null);
runConnect();
}, [clearTimers, runConnect]);
const reset = useCallback(() => {
clearTimers();
attemptRef.current = 0;
setAttempt(0);
setNextRetryInMs(null);
if (isMountedRef.current) setStatus("connecting");
}, [clearTimers]);
useEffect(() => {
isMountedRef.current = true;
if (autoStart) {
runConnect();
}
return () => {
isMountedRef.current = false;
clearTimers();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (enabled && status === "error" && !retryTimeoutRef.current) {
scheduleRetry();
}
if (!enabled) {
clearTimers();
setNextRetryInMs(null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enabled]);
return {
status,
attempt,
maxAttempts,
nextRetryInMs,
markConnected,
markFailed,
retryNow,
reset,
};
}
export type { PluginManifest } from "./manifest.js";
export type { ReactNode };