chore: lock the plugin boundary and remove 2.9.0 leftovers

This commit is contained in:
LukeGus committed 2026-09-25 07:24:46 -05:00
1 parent 6489b62ece
commit 2961297989
399 files changed
+3964 -5627

No files matched your search

+1 -1
View File
@@ -6,7 +6,7 @@
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json",
"typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json",
"validate": "termix-plugin validate"
},
"dependencies": {
+1 -1
View File
@@ -6,7 +6,7 @@
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json",
"typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json",
"validate": "termix-plugin validate"
},
"devDependencies": {
+1 -1
View File
@@ -6,7 +6,7 @@
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json",
"typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json",
"validate": "termix-plugin validate"
},
"devDependencies": {
-6
View File
@@ -1,10 +1,4 @@
{
"extends": "@termix/plugin-sdk/tsconfig.plugin.json",
"compilerOptions": {
"paths": {
"@/types/*": ["../../src/types/*"],
"@/*": ["../../src/ui/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"]
}
+1 -1
View File
@@ -6,7 +6,7 @@
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json",
"typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json",
"validate": "termix-plugin validate"
},
"devDependencies": {
+1
View File
@@ -250,6 +250,7 @@ export async function startConnect(
ctx.ssh
.connect<Client>(host, {
purpose: "docker",
profile: "session",
timeoutMs: CONNECT_TIMEOUT_MS,
prompt: { ask },
})
+1
View File
@@ -200,6 +200,7 @@ export function registerConsole(ctx: PluginContext, log: DockerLogger): void {
const { client, dispose } = await ctx.ssh.connect<Client>(host, {
purpose: "docker-console",
profile: "stream",
timeoutMs: 65_000,
});
if (ws.readyState !== WebSocket.OPEN) {
+2 -2
View File
@@ -92,7 +92,7 @@ export function registerServices(ctx: PluginContext, log: DockerLogger): void {
const { runtime } = await readDockerHostSettings(ctx, hostId);
const output = await ctx.ssh.withConnection<string, Client>(
hostId,
{ pool: POOL, purpose: "docker" },
{ pool: POOL, purpose: "docker", profile: "session" },
(client) => execOnClient(client, command(runtime)),
);
return handle(output);
@@ -161,7 +161,7 @@ export function registerServices(ctx: PluginContext, log: DockerLogger): void {
const { runtime } = await readDockerHostSettings(ctx, watch.hostId);
const output = await ctx.ssh.withConnection<string, Client>(
host,
{ pool: POOL, purpose: "docker" },
{ pool: POOL, purpose: "docker", profile: "session" },
(client) =>
execOnClient(
client,
+1 -1
View File
@@ -6,7 +6,7 @@
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json",
"typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json",
"validate": "termix-plugin validate"
},
"devDependencies": {
@@ -0,0 +1,119 @@
import type { PluginLogger } from "@termix/plugin-sdk/backend";
export type ConnectionStage =
| "dns"
| "tcp"
| "handshake"
| "auth"
| "connected"
| "connection"
| "error"
| "proxy"
| "jump"
| "sftp_connecting"
| "sftp_auth"
| "sftp_connected";
export type LogEntry = {
id: string;
timestamp: Date;
type: "info" | "success" | "warning" | "error";
stage: ConnectionStage;
message: string;
details?: Record<string, unknown> | string;
};
export function createConnectionLog(
type: LogEntry["type"],
stage: ConnectionStage,
message: string,
details?: Record<string, unknown>,
): Omit<LogEntry, "id" | "timestamp"> {
return { type, stage, message, details };
}
function normalizeHostAddress(value: unknown): string {
if (typeof value !== "string") return "";
return value
.replace(/^\[|\]$/g, "")
.trim()
.toLowerCase();
}
/**
* Whether a numeric host id resolved to a different machine than the client
* meant. Ids from the desktop app and a sync server drift apart, and the
* resolved row supplies the credentials, so a mismatch must be refused.
*/
export function hostAddressMismatch(
clientAddress: unknown,
resolvedAddress: unknown,
): boolean {
const resolved = normalizeHostAddress(resolvedAddress);
if (!resolved) return false;
return resolved !== normalizeHostAddress(clientAddress);
}
export function resolveServerHostId(
clientHostId: number | null,
resolvedHost: { id?: unknown } | null,
): number | null {
return typeof resolvedHost?.id === "number" ? resolvedHost.id : clientHostId;
}
export class HostAddressMismatchError extends Error {
constructor() {
super(
"Host mismatch: this server resolved the selected host to a different machine, so the connection was refused. " +
"The host ids on this device and on the sync server have drifted apart. " +
'Set the connection origin to "This device" for this host, or re-run a full sync, then try again.',
);
this.name = "HostAddressMismatchError";
}
}
export class HostNotOnThisServerError extends Error {
constructor() {
super(
"This host does not exist on the sync server, so the connection was refused. " +
'Run a sync so the server knows about it, or set the connection origin to "This device" for this host.',
);
this.name = "HostNotOnThisServerError";
}
}
type Meta = Record<string, unknown>;
export interface FileLogger {
info: (message: string, meta?: Meta) => void;
success: (message: string, meta?: Meta) => void;
warn: (message: string, meta?: Meta) => void;
error: (message: string, error?: unknown, meta?: Meta) => void;
}
function withMeta(message: string, meta?: Meta): string {
if (!meta || Object.keys(meta).length === 0) return message;
try {
return `${message} ${JSON.stringify(meta)}`;
} catch {
return message;
}
}
/** ctx.log takes a plain message; this keeps the structured fields readable. */
export function createFileLogger(log: PluginLogger): FileLogger {
return {
info: (message, meta) => log.info(withMeta(message, meta)),
success: (message, meta) => log.info(withMeta(message, meta)),
warn: (message, meta) => log.warn(withMeta(message, meta)),
error: (message, error, meta) => {
if (error instanceof Error) {
log.error(withMeta(message, meta), error);
} else if (error && typeof error === "object" && !meta) {
log.error(withMeta(message, error as Meta));
} else {
log.error(withMeta(message, meta));
}
},
};
}
+40 -60
View File
@@ -1,9 +1,5 @@
// Core imports below point at TypeScript source so tsc can type-check them.
// The plugin build rewrites the prefix to the compiled output path; see
// packages/plugin-sdk/cli/lib/legacy-core-imports.mjs.
import express, { type Router } from "express";
import cookieParser from "cookie-parser";
import axios from "axios";
import { Client as SSHClient } from "ssh2";
import type { PluginContext, PluginSshHost } from "@termix/plugin-sdk/backend";
import { getErrorMessage } from "./error-message.js";
@@ -15,30 +11,17 @@ import {
} 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 {
logAudit,
getAuditUsername,
getRequestMeta,
} from "../../../../src/backend/utils/audit-logger.js";
import { createCurrentHostResolutionRepository } from "../../../../src/backend/database/repositories/factory.js";
import {
resolveHostById,
resolveHostBySyncId,
} from "../../../../src/backend/hosts/host-resolver.js";
import { createConnectionLog } from "../../../../src/backend/hosts/connection-log.js";
import { JumpHostChainError } from "../../../../src/backend/hosts/jump-host-chain.js";
import {
createConnectionLog,
createFileLogger,
hostAddressMismatch,
HostAddressMismatchError,
HostNotOnThisServerError,
resolveServerHostId,
} from "../../../../src/backend/hosts/host-identity.js";
import type {
LogEntry,
ConnectionStage,
} from "../../../../src/types/connection-log.js";
type ConnectionStage,
type FileLogger,
type LogEntry,
} from "./host-identity.js";
import {
ChannelOpenSerializer,
execChannel,
@@ -66,7 +49,17 @@ import {
import { createFilesService } from "./service.js";
import { tables } from "./tables.js";
const fileLogger = logger;
let fileLogger: FileLogger = createFileLogger(console);
// Shape of a resolved host as this route reads it; core fills more fields
// than PluginSshHost names.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type LooseHost = PluginSshHost & Record<string, any>;
// Core's jump chain failure, matched by name so no core class is imported.
function isJumpHostChainError(error: unknown): boolean {
return error instanceof Error && error.name === "JumpHostChainError";
}
/** Cosmetic only: whether the proxy step is worth a connection-log line. */
function hasSocks5Config(host: PluginSshHost): boolean {
@@ -115,6 +108,7 @@ function assertResolvedHost(
}
export async function activate(ctx: PluginContext) {
fileLogger = createFileLogger(ctx.log);
setPluginSsh(ctx.ssh);
setPluginCtx(ctx);
ctx.disposables.add(() => setPluginSsh(null));
@@ -276,7 +270,7 @@ export async function activate(ctx: PluginContext) {
throw new Error("Cannot open transfer connection: unknown host");
}
const host = await resolveHostById(hostId, userId);
const host = await ctx.asUser(userId, () => ctx.ssh.resolveHost(hostId));
if (!host) {
throw new Error("Host not found for transfer connection");
}
@@ -304,6 +298,7 @@ export async function activate(ctx: PluginContext) {
host as unknown as PluginSshHost,
{
purpose: "file-transfer",
profile: "stream",
timeoutMs: 60000,
overrides: { tcpKeepAliveInitialDelay: 5000 },
},
@@ -600,9 +595,9 @@ export async function activate(ctx: PluginContext) {
let resolvedSocks5ProxyChain = socks5ProxyChain;
const resolveFrom = async () => {
const resolvedHost = hostSyncId
? await resolveHostBySyncId(hostSyncId, userId)
: await resolveHostById(hostId, userId);
const resolvedHost = (await ctx.ssh.resolveHost(hostId, {
syncId: hostSyncId,
})) as LooseHost | null;
assertResolvedHost(ip, hostSyncId, resolvedHost, hostId, userId);
if (!resolvedHost) return;
resolvedIp = resolvedHost.ip;
@@ -690,7 +685,9 @@ export async function activate(ctx: PluginContext) {
let serverHostId = hostId;
if (hostSyncId && userId) {
const resolvedHost = await resolveHostBySyncId(hostSyncId, userId);
const resolvedHost = await ctx.ssh.resolveHost(hostId, {
syncId: hostSyncId,
});
assertResolvedHost(ip, hostSyncId, resolvedHost, hostId, userId);
serverHostId = resolveServerHostId(hostId, resolvedHost) ?? hostId;
}
@@ -744,6 +741,7 @@ export async function activate(ctx: PluginContext) {
const ssh = pluginSsh();
const prepared = await ssh.prepare(connectTarget, {
purpose: "file-manager",
profile: "session",
client,
serverHostId,
log: (level, message) =>
@@ -850,17 +848,13 @@ export async function activate(ctx: PluginContext) {
scheduleSessionCleanup(sessionId);
if (userId) {
const { ipAddress, userAgent } = getRequestMeta(req);
void (async () => {
await logAudit({
userId,
username: await getAuditUsername(userId),
await ctx.audit.record({
request: req,
action: "file_manager_connect",
resourceType: "host",
resourceId: serverHostId ? String(serverHostId) : undefined,
resourceName: `${username}@${ip}:${port}`,
ipAddress,
userAgent,
success: true,
});
})();
@@ -1169,17 +1163,14 @@ export async function activate(ctx: PluginContext) {
hostId,
});
const stage =
error instanceof JumpHostChainError || jumpHostCount > 0
? "jump"
: "proxy";
isJumpHostChainError(error) || jumpHostCount > 0 ? "jump" : "proxy";
connectionLogs.push(
createConnectionLog("error", stage, getErrorMessage(error)),
);
return res.status(500).json({
error:
error instanceof JumpHostChainError
? `Failed to connect through jump hosts: ${error.message}`
: getErrorMessage(error),
error: isJumpHostChainError(error)
? `Failed to connect through jump hosts: ${error.message}`
: getErrorMessage(error),
connectionLogs,
});
}
@@ -1195,24 +1186,13 @@ export async function activate(ctx: PluginContext) {
port: number,
): Promise<void> {
try {
const host = await createCurrentHostResolutionRepository().findHostById(
hostId,
userId,
);
const hostName =
host?.userId === userId && host.name
? host.name
: `${username}@${ip}:${port}`;
const authManager = AuthManager.getInstance();
await axios.post(
"http://localhost:30006/activity/log",
{ type: "file_manager", hostId, hostName },
{
headers: {
Authorization: `Bearer ${await authManager.generateJWTToken(userId)}`,
},
},
const host = await ctx.hosts.get(hostId);
await ctx.asUser(userId, () =>
ctx.hosts.recordActivity(
hostId,
"file_manager",
host?.name || `${username}@${ip}:${port}`,
),
);
} catch (error) {
fileLogger.warn("Failed to log file manager activity", {
@@ -15,7 +15,6 @@ import {
permanentlyDeleteTrashItem,
restoreTrashItem,
} from "./trash-service.js";
import { PermissionManager } from "../../../../src/backend/utils/permission-manager.js";
type FileOperationRoutesDeps = {
ctx: PluginContext;
@@ -29,10 +28,8 @@ export function registerFileOperationRoutes(
app: Express,
{ ctx, sshSessions, verifySessionOwnership }: FileOperationRoutesDeps,
): void {
// NOTE: trash retention is an install-wide admin setting, not a per-host
// access check, so it stays on PermissionManager.isAdmin rather than
// ctx.hosts.checkAccess (see rule 5 scope: host resolution/access only).
const permissionManager = PermissionManager.getInstance();
// Trash retention is an install-wide admin setting, not a per-host check.
const canManageRetention = () => ctx.rbac.has("admin.settings.manage");
const getTrashRetentionDays = async (): Promise<number> => {
try {
const value = Number(await ctx.settings.get(TRASH_RETENTION_DAYS_KEY));
@@ -68,9 +65,7 @@ export function registerFileOperationRoutes(
res.json({
items: await listTrash(await getSessionSftp(session), retentionDays),
retentionDays,
canManageRetention: await permissionManager.isAdmin(
ctx.currentActor()!,
),
canManageRetention: await canManageRetention(),
});
} catch (error) {
ctx.log.error("Failed to list trash", error as Error);
@@ -121,8 +116,7 @@ export function registerFileOperationRoutes(
});
app.put("/trash-retention", async (req, res) => {
const userId = ctx.currentActor()!;
if (!(await permissionManager.isAdmin(userId))) {
if (!(await canManageRetention())) {
return res.status(403).json({ error: "Admin access required" });
}
const retentionDays = Number(req.body?.retentionDays);
+5 -1
View File
@@ -40,7 +40,11 @@ async function withServiceSftp<T>(
): Promise<T> {
return ctx.ssh.withConnection<T, import("ssh2").Client>(
hostId,
{ pool: "file-manager-service", purpose: "file-manager" },
{
pool: "file-manager-service",
purpose: "file-manager",
profile: "session",
},
(client) =>
new Promise<T>((resolve, reject) => {
client.sftp(async (err, sftp) => {
@@ -1,5 +1,5 @@
import { fileManagerHostSetting } from "./host-settings";
import { getErrorMessage } from "@/lib/error-message.js";
import { getErrorMessage } from "./lib/error-message";
/* eslint-disable react-hooks/exhaustive-deps */
import React, {
useState,
@@ -8,8 +8,8 @@ import React, {
useCallback,
useMemo,
} from "react";
import { asHttpError } from "@/lib/http-error";
import { cn } from "@/lib/utils.ts";
import { asHttpError } from "./lib/http-error";
import { cn } from "@termix/plugin-sdk/ui";
import { FileManagerGrid } from "./FileManagerGrid.tsx";
import { FileManagerSidebar, type SidebarItem } from "./FileManagerSidebar.tsx";
import { FileManagerContextMenu } from "./FileManagerContextMenu.tsx";
@@ -24,33 +24,33 @@ import { DownloadProgressToast } from "./components/DownloadProgressToast.tsx";
import { DiffWindow } from "./components/DiffWindow.tsx";
import { useDragToDesktop } from "./hooks/useDragToDesktop";
import { useDragToSystemDesktop } from "./hooks/useDragToSystemDesktop";
import { useConfirmation } from "@/hooks/use-confirmation.ts";
import { useConfirmation } from "@termix/plugin-sdk/ui";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { FileManagerDialogs } from "./FileManagerDialogs.tsx";
import { PassphraseDialog } from "@/ssh/dialogs/PassphraseDialog.tsx";
import { PassphraseDialog } from "@termix/plugin-sdk/ui";
import { FileManagerToolbar } from "./FileManagerToolbar.tsx";
import { LocalFilePane } from "./LocalFilePane.tsx";
import { useLocalTransfers } from "./hooks/useLocalTransfers.ts";
import { usePaneWidth } from "./hooks/usePaneWidth.ts";
import { PaneResizeHandle } from "./components/PaneResizeHandle.tsx";
import { isLocalFileBrowserAvailable } from "@/lib/local-files.ts";
import { isLocalFileBrowserAvailable } from "./lib/local-files";
import { TransferToHostDialog } from "./components/TransferToHostDialog.tsx";
import { FileManagerTrashDialog } from "./FileManagerTrashDialog.tsx";
import { TerminalWindow } from "./components/TerminalWindow.tsx";
import type { SSHHost, FileItem } from "@/types/index";
import type { SSHHost, FileItem } from "./host-types";
import {
ConnectionLogProvider,
useConnectionLog,
ConnectionScreen,
} from "@termix/plugin-sdk/ui";
import { ConnectionLogPanel } from "@/components/connection/ConnectionLogPanel.tsx";
import { ConnectionLogPanel } from "@termix/plugin-sdk/ui";
import {
invokeAction,
useConnectionRetry,
logActivity,
} from "@termix/plugin-sdk/frontend";
import { copyToClipboard } from "@/lib/clipboard.ts";
import { copyToClipboard } from "@termix/plugin-sdk/ui";
import {
listSSHFiles,
readSSHFile,
@@ -85,13 +85,13 @@ import {
transferToHost,
addTransferRecent,
type TransferMethodPreference,
} from "@/main-axios.ts";
} from "./api/transfer-api";
import type { DiskFilesystem, HostDiskInfo } from "./disk-info";
import {
getAdaptiveResourceBudget,
markAdaptiveResourceUsed,
runAdaptiveBackgroundTask,
} from "@/lib/adaptive-resource-budget";
} from "@termix/plugin-sdk/ui";
import { shouldPrefetchFileContent } from "./lib/file-content-request-cache";
import {
markFilePreviewUsed,
@@ -1,5 +1,5 @@
import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils.ts";
import { cn } from "@termix/plugin-sdk/ui";
import {
Download,
Edit3,
@@ -22,7 +22,7 @@ import {
Link,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { Kbd, KbdKey, KbdSeparator } from "@/components/kbd.tsx";
import { Kbd, KbdKey, KbdSeparator } from "@termix/plugin-sdk/ui";
const VIEWPORT_PADDING = 16;
@@ -1,4 +1,4 @@
import type { FileItem, SSHHost } from "@/types/index";
import type { FileItem, SSHHost } from "./host-types";
import {
TOTPDialog,
SSHAuthDialog,
@@ -9,7 +9,7 @@ import React, {
} from "react";
import { createPortal } from "react-dom";
import { useVirtualizer } from "@tanstack/react-virtual";
import { cn } from "@/lib/utils.ts";
import { cn } from "@termix/plugin-sdk/ui";
import {
Folder,
File,
@@ -30,7 +30,7 @@ import {
GitCompare,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import type { FileItem } from "@/types/index";
import type { FileItem } from "./host-types";
import type { CreateIntent } from "./file-manager-types.ts";
import { formatFileSize } from "./file-manager-utils.ts";
import {
@@ -5,7 +5,7 @@ import React, {
useCallback,
useMemo,
} from "react";
import { cn } from "@/lib/utils.ts";
import { cn } from "@termix/plugin-sdk/ui";
import {
Star,
Clock,
@@ -17,7 +17,7 @@ import {
Check,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import type { SSHHost } from "@/types";
import type { SSHHost } from "./host-types";
import {
getRecentFiles,
getPinnedFiles,
@@ -29,7 +29,7 @@ import {
import { listSSHFiles } from "./api/ssh-file-operations-api";
import type { DiskFilesystem } from "./disk-info";
import { toast } from "sonner";
import FolderTree from "@/components/folder.tsx";
import FolderTree from "./components/FolderTree";
import {
DropdownMenu,
DropdownMenuTrigger,
@@ -30,7 +30,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@termix/plugin-sdk/ui";
import type { FileItem } from "@/types/index";
import type { FileItem } from "./host-types";
type SortBy = "name" | "modified" | "size";
type SortOrder = "asc" | "desc";
@@ -1,5 +1,5 @@
import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils.ts";
import { cn } from "@termix/plugin-sdk/ui";
import {
Clipboard,
Edit3,
@@ -14,8 +14,8 @@ import {
Upload,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { Kbd, KbdKey, KbdSeparator } from "@/components/kbd.tsx";
import type { LocalFileEntry } from "@/types/electron";
import { Kbd, KbdKey, KbdSeparator } from "@termix/plugin-sdk/ui";
import type { LocalFileEntry } from "@termix/plugin-sdk/ui";
const VIEWPORT_PADDING = 16;
@@ -26,9 +26,9 @@ import {
RefreshCw,
X,
} from "lucide-react";
import { cn } from "@/lib/utils.ts";
import { cn } from "@termix/plugin-sdk/ui";
import { Button, Input } from "@termix/plugin-sdk/ui";
import type { LocalFileEntry } from "@/types/electron";
import type { LocalFileEntry } from "@termix/plugin-sdk/ui";
import {
createLocalFile,
createLocalFolder,
@@ -38,9 +38,9 @@ import {
renameLocalEntry,
revealLocalPath,
trashLocalPaths,
} from "@/lib/local-files.ts";
import { copyToClipboard } from "@/lib/clipboard.ts";
import { useConfirmation } from "@/hooks/use-confirmation.ts";
} from "./lib/local-files";
import { copyToClipboard } from "@termix/plugin-sdk/ui";
import { useConfirmation } from "@termix/plugin-sdk/ui";
import { LocalFileContextMenu } from "./LocalFileContextMenu.tsx";
import {
useResizableColumns,
@@ -38,10 +38,10 @@ import {
} from "@termix/plugin-sdk/ui";
import {
addTransferRecent,
getSSHHosts,
transferToHost,
type TransferProgressResponse,
} from "@/main-axios";
} from "./api/transfer-api";
import { getSSHHosts } from "./api/hosts";
import {
browseSSHDirectory,
changeSSHPermissions,
@@ -51,7 +51,7 @@ import {
renameSSHItem,
type HostConnectionState,
} from "./api/ssh-file-operations-api";
import type { SSHHost } from "@/types";
import type { SSHHost } from "./host-types";
import { PermissionsDialog } from "./components/PermissionsDialog";
import { formatFileSize } from "./file-manager-utils";
import { beginTransferProgressMonitoring } from "./transferProgressMonitor";
@@ -581,7 +581,7 @@ export function SftpTransferTab() {
useEffect(() => {
setHostsLoading(true);
getSSHHosts({ includeStatus: false })
getSSHHosts()
.then((data) =>
setHosts(
data.filter(
@@ -1,6 +1,6 @@
import type { TranslateFn } from "@termix/plugin-sdk/frontend";
import type { TFunction } from "i18next";
import { getTransferStatus, listActiveTransfers } from "@/main-axios.ts";
import { getTransferStatus, listActiveTransfers } from "./api/transfer-api";
import { createFormatTransferMetrics } from "./transferMetricsFormat.ts";
import {
beginTransferProgressMonitoring,
@@ -12,7 +12,7 @@ import {
getPendingTransferIds,
isTransferNotified,
} from "./transferNotificationStore.ts";
import { runAdaptivePolling } from "@/lib/adaptive-polling.ts";
import { runAdaptivePolling } from "@termix/plugin-sdk/ui";
const POLL_INTERVAL_MS = 2000;
@@ -0,0 +1,121 @@
import type { TermixApp } from "@termix/plugin-sdk/frontend";
// Response bodies are typed at each call site's return type.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Body = any;
interface ClientResponse<T> {
data: T;
status: number;
headers: Record<string, unknown>;
}
/** The plugin client is axios underneath; these are the parts used here. */
export interface FileManagerClient {
get<T = Body>(url: string, config?: unknown): Promise<ClientResponse<T>>;
delete<T = Body>(url: string, config?: unknown): Promise<ClientResponse<T>>;
post<T = Body>(
url: string,
body?: unknown,
config?: unknown,
): Promise<ClientResponse<T>>;
put<T = Body>(
url: string,
body?: unknown,
config?: unknown,
): Promise<ClientResponse<T>>;
patch<T = Body>(
url: string,
body?: unknown,
config?: unknown,
): Promise<ClientResponse<T>>;
postForm<T = Body>(
url: string,
body?: unknown,
config?: unknown,
): Promise<ClientResponse<T>>;
}
let current: TermixApp | null = null;
/** Set in activate, cleared on deactivate. */
export function setFileManagerApp(app: TermixApp | null): void {
current = app;
}
function app(): TermixApp {
if (!current) throw new Error("The file manager plugin is not active");
return current;
}
export function fileManagerApi(): FileManagerClient {
return app().api as unknown as FileManagerClient;
}
// A live SSH session (keyed by sessionId) remembers which backend holds it,
// so every later call for that session reaches the same server.
const sessionOrigins = new Map<string, "local" | "remote">();
export function setSessionOrigin(
sessionId: string,
origin: "local" | "remote",
): void {
sessionOrigins.set(sessionId, origin);
}
export function clearSessionOrigin(sessionId: string): void {
sessionOrigins.delete(sessionId);
}
export function getSessionOrigin(sessionId: string): "local" | "remote" {
return sessionOrigins.get(sessionId) === "remote" ? "remote" : "local";
}
export function getFileManagerApiForSession(
sessionId: string,
): FileManagerClient {
return app().apiFor(
getSessionOrigin(sessionId),
) as unknown as FileManagerClient;
}
export function fileManagerApiFor(
origin: "local" | "remote" | undefined,
): FileManagerClient {
return app().apiFor(origin ?? "local") as unknown as FileManagerClient;
}
class ApiError extends Error {
constructor(
message: string,
public status?: number,
public code?: string,
) {
super(message);
this.name = "ApiError";
}
}
interface AxiosLikeError {
message?: string;
response?: {
status?: number;
data?: { message?: string; error?: string; code?: string };
};
}
/** Turns an axios failure into an Error carrying the server's message. */
export function handleApiError(error: unknown, operation: string): never {
const e = error as AxiosLikeError;
if (e?.response) {
const status = e.response.status;
const data = e.response.data ?? {};
const message =
data.message || data.error || e.message || `Failed to ${operation}`;
const apiError = new ApiError(message, status, data.code || data.error);
(apiError as ApiError & { response?: unknown }).response = e.response;
throw apiError;
}
if (error instanceof Error) throw error;
throw new ApiError(`Failed to ${operation}`);
}
@@ -1,4 +1,4 @@
import { fileManagerApi, handleApiError } from "@/main-axios";
import { fileManagerApi, handleApiError } from "./client";
/** Row shape shared by the recent / pinned / shortcut list endpoints. */
export interface FileManagerEntry {
@@ -16,7 +16,7 @@ export async function getRecentFiles(
hostId: number,
): Promise<FileManagerEntry[]> {
try {
const response = await fileManagerApi.get("/recent", {
const response = await fileManagerApi().get("/recent", {
params: { hostId },
});
return response.data;
@@ -32,7 +32,7 @@ export async function addRecentFile(
name?: string,
): Promise<Record<string, unknown>> {
try {
const response = await fileManagerApi.post("/recent", {
const response = await fileManagerApi().post("/recent", {
hostId,
path,
name,
@@ -49,7 +49,7 @@ export async function removeRecentFile(
path: string,
): Promise<Record<string, unknown>> {
try {
const response = await fileManagerApi.delete("/recent", {
const response = await fileManagerApi().delete("/recent", {
data: { hostId, path },
});
return response.data;
@@ -63,7 +63,7 @@ export async function getPinnedFiles(
hostId: number,
): Promise<FileManagerEntry[]> {
try {
const response = await fileManagerApi.get("/pinned", {
const response = await fileManagerApi().get("/pinned", {
params: { hostId },
});
return response.data;
@@ -79,7 +79,7 @@ export async function addPinnedFile(
name?: string,
): Promise<Record<string, unknown>> {
try {
const response = await fileManagerApi.post("/pinned", {
const response = await fileManagerApi().post("/pinned", {
hostId,
path,
name,
@@ -96,7 +96,7 @@ export async function removePinnedFile(
path: string,
): Promise<Record<string, unknown>> {
try {
const response = await fileManagerApi.delete("/pinned", {
const response = await fileManagerApi().delete("/pinned", {
data: { hostId, path },
});
return response.data;
@@ -110,7 +110,7 @@ export async function getFolderShortcuts(
hostId: number,
): Promise<FileManagerEntry[]> {
try {
const response = await fileManagerApi.get("/shortcuts", {
const response = await fileManagerApi().get("/shortcuts", {
params: { hostId },
});
return response.data;
@@ -126,7 +126,7 @@ export async function addFolderShortcut(
name?: string,
): Promise<Record<string, unknown>> {
try {
const response = await fileManagerApi.post("/shortcuts", {
const response = await fileManagerApi().post("/shortcuts", {
hostId,
path,
name,
@@ -143,7 +143,7 @@ export async function removeFolderShortcut(
path: string,
): Promise<Record<string, unknown>> {
try {
const response = await fileManagerApi.delete("/shortcuts", {
const response = await fileManagerApi().delete("/shortcuts", {
data: { hostId, path },
});
return response.data;
@@ -0,0 +1,6 @@
import { listHosts } from "@termix/plugin-sdk/frontend";
import type { SSHHost } from "../host-types";
export async function getSSHHosts(): Promise<SSHHost[]> {
return (await listHosts()) as unknown as SSHHost[];
}
@@ -12,9 +12,9 @@
import type {
LocalTransferOrigin,
LocalTransferProgress,
} from "@/types/electron";
import { getSessionOrigin } from "@/main-axios";
import { getDeviceId } from "@/lib/device-id";
} from "@termix/plugin-sdk/ui";
import { getSessionOrigin } from "./client";
import { getDeviceId } from "@termix/plugin-sdk/ui";
export interface LocalTransferProgressEvent {
transferred: number;
@@ -1,16 +1,17 @@
import { getErrorMessage } from "@/lib/error-message.js";
import { getErrorMessage } from "../lib/error-message";
import axios from "axios";
import { asHttpError } from "@/lib/http-error";
import { asHttpError } from "../lib/http-error";
import {
authApi,
handleApiError,
getFileManagerApiForSession,
setSessionOrigin,
clearSessionOrigin,
} from "@/main-axios";
import { resolveConnectionOrigin } from "@/lib/connection-origin";
import { fileLogger } from "@/lib/frontend-logger";
import type { FileItem, SSHHost } from "@/types/index";
} from "./client";
import {
createFrontendLogger,
resolveConnectionOrigin,
} from "@termix/plugin-sdk/ui";
import type { FileItem, SSHHost } from "../host-types";
import { getCachedFileList } from "../lib/file-list-request-cache";
import {
getCachedFileContent,
@@ -18,6 +19,8 @@ import {
type FileContentResult,
} from "../lib/file-content-request-cache";
const fileLogger = createFrontendLogger("FILE");
type ApiConnectionLog = {
type: "info" | "success" | "warning" | "error";
stage: string;
@@ -193,83 +196,6 @@ export async function verifySSHBrowserSignIn(
}
}
/**
* @openapi
* /ssh/quick-connect:
* post:
* summary: Create a temporary SSH connection without saving to database
* description: Returns a temporary host configuration for immediate use
* tags:
* - SSH
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - ip
* - port
* - username
* - authType
* properties:
* ip:
* type: string
* description: SSH server IP or hostname
* port:
* type: number
* description: SSH server port
* username:
* type: string
* description: SSH username
* authType:
* type: string
* enum: [password, key, credential]
* description: Authentication method
* password:
* type: string
* description: Password (required if authType is password)
* key:
* type: string
* description: SSH private key (required if authType is key)
* keyPassword:
* type: string
* description: SSH key password (optional)
* keyType:
* type: string
* description: SSH key type
* credentialId:
* type: number
* description: Credential ID (required if authType is credential)
* overrideCredentialUsername:
* type: boolean
* description: Use provided username instead of credential username
* responses:
* 200:
* description: Temporary host configuration created successfully
* content:
* application/json:
* schema:
* type: object
* description: SSHHost object
* 400:
* description: Invalid request data
* 401:
* description: Unauthorized
* 500:
* description: Server error
*/
export async function quickConnect(
data: Record<string, unknown>,
): Promise<SSHHost> {
try {
const response = await authApi.post("/host/quick-connect", data);
return response.data;
} catch (error) {
throw handleApiError(error, "quick connect");
}
}
export async function getSSHStatus(
sessionId: string,
): Promise<{ connected: boolean }> {
@@ -0,0 +1,384 @@
import { createFrontendLogger } from "@termix/plugin-sdk/ui";
import { fileManagerApi, handleApiError } from "./client";
const fileLogger = createFrontendLogger("FILE");
// ============================================================================
// HOST-TO-HOST TRANSFER
// ============================================================================
export type TransferMethodPreference = "auto" | "tar" | "item_sftp";
export interface TransferScanSummary {
fileCount: number;
totalBytes: number;
largestFileBytes: number;
incompressibleRatio: number;
}
export interface TransferMethodPreview {
methodPreference: TransferMethodPreference;
resolvedMethod: "tar" | "item_sftp";
reasonKey: string;
sourcePlatform: "unix" | "windows";
destPlatform: "unix" | "windows";
sourceHasTar: boolean;
destHasTar: boolean;
summary: TransferScanSummary;
}
export async function getTransferMethodPreview(
sourceSessionId: string,
sourcePaths: string[],
destSessionId: string,
destPath: string,
methodPreference?: TransferMethodPreference,
): Promise<TransferMethodPreview> {
try {
const response = await fileManagerApi().post("/transferMethodPreview", {
sourceSessionId,
sourcePaths,
destSessionId,
destPath,
methodPreference: methodPreference ?? "auto",
});
return response.data;
} catch (error) {
handleApiError(error, "preview transfer method");
throw error;
}
}
export interface TransferHopMetrics {
id: string;
mbPerSec?: number;
}
export interface TransferTimings {
prepareDestMs?: number;
compressMs?: number;
transferMs?: number;
extractMs?: number;
verifyMs?: number;
directBenchmarkMs?: number;
relayBenchmarkMs?: number;
sourceDeleteMs?: number;
totalMs?: number;
transferBytes?: number;
endToEndMbPerSec?: number;
hops?: TransferHopMetrics[];
}
export function getTransferProgressPercent(
status: TransferProgressResponse,
): number | undefined {
if (
status.bytesTransferred !== undefined &&
status.totalBytes !== undefined &&
status.totalBytes > 0
) {
return Math.min(
100,
Math.round((status.bytesTransferred / status.totalBytes) * 100),
);
}
if (
status.itemsCompleted !== undefined &&
status.totalItems !== undefined &&
status.totalItems > 0
) {
return Math.min(
100,
Math.round((status.itemsCompleted / status.totalItems) * 100),
);
}
return undefined;
}
export function formatTransferMbPerSec(
mbPerSec?: number,
_bytes?: number,
_ms?: number,
): string {
if (mbPerSec === undefined || mbPerSec <= 0) return "";
if (mbPerSec < 1) return `${(mbPerSec * 1024).toFixed(0)} KB/s`;
return `${mbPerSec.toFixed(1)} MB/s`;
}
export function formatDurationMs(ms?: number): string {
if (ms === undefined) return "";
if (ms < 1000) return `${ms}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
const minutes = Math.floor(ms / 60000);
const seconds = Math.round((ms % 60000) / 1000);
return `${minutes}m ${seconds}s`;
}
interface TransferProgressTracker {
update(status: TransferProgressResponse): {
rate: number | undefined;
stalled: boolean;
};
}
export function createTransferProgressTracker(): TransferProgressTracker {
let lastBytes: number | undefined;
let lastTime: number | undefined;
let lastRate: number | undefined;
let lastActivityTime: number | undefined;
const STALL_THRESHOLD_MS = 5000;
return {
update(status) {
const now = Date.now();
const bytes = status.bytesTransferred;
if (
bytes !== undefined &&
lastBytes !== undefined &&
lastTime !== undefined
) {
const deltaBytes = bytes - lastBytes;
const deltaMs = now - lastTime;
if (deltaMs > 0 && deltaBytes >= 0) {
lastRate = (deltaBytes / deltaMs / 1024 / 1024) * 1000;
if (deltaBytes > 0) lastActivityTime = now;
}
} else if (bytes !== undefined) {
lastActivityTime = now;
}
lastBytes = bytes;
lastTime = now;
const stalled =
lastActivityTime !== undefined &&
now - lastActivityTime > STALL_THRESHOLD_MS &&
status.status === "running" &&
status.phase === "transferring";
return { rate: lastRate, stalled };
},
};
}
export interface TransferProgressResponse {
transferId: string;
status: "running" | "success" | "partial" | "error" | "cancelled";
phase:
| "compressing"
| "transferring"
| "benchmarking"
| "verifying"
| "extracting"
| "reconnecting";
bytesTransferred?: number;
totalBytes?: number;
itemsCompleted?: number;
totalItems?: number;
failedPaths?: string[];
message?: string;
method?: "stream" | "tar" | "item_sftp" | "direct_rsync";
sourcePaths?: string[];
destPath?: string;
sourceSessionId?: string;
destSessionId?: string;
startedAt?: number;
timings?: TransferTimings;
sourceDeleted?: boolean;
moveRequested?: boolean;
partialDestRemaining?: boolean;
cleanupCompleted?: boolean;
retryable?: boolean;
integrityVerified?: boolean;
parallelSegmentCount?: number;
}
export async function transferToHost(
sourceSessionId: string,
sourcePaths: string[],
destSessionId: string,
destPath: string,
move?: boolean,
methodPreference?: TransferMethodPreference,
parallelSegmentCount?: number,
): Promise<{ transferId: string }> {
try {
fileLogger.info("Starting host transfer", {
operation: "host_transfer",
sourceSessionId,
destSessionId,
sourcePaths,
destPath,
move,
methodPreference,
parallelSegmentCount,
});
const response = await fileManagerApi().post("/transferToHost", {
sourceSessionId,
sourcePaths,
destSessionId,
destPath,
move,
methodPreference: methodPreference ?? "auto",
parallelSegmentCount,
});
return response.data;
} catch (error) {
fileLogger.error("Failed to start host transfer", error, {
operation: "host_transfer",
sourceSessionId,
destSessionId,
sourcePaths,
});
handleApiError(error, "transfer to host");
throw error;
}
}
export async function getTransferStatus(
transferId: string,
): Promise<TransferProgressResponse> {
try {
const response = await fileManagerApi().get(
`/transferStatus/${transferId}`,
);
return response.data;
} catch (error) {
handleApiError(error, "get transfer status");
throw error;
}
}
export async function listActiveTransfers(): Promise<{
transfers: TransferProgressResponse[];
}> {
try {
const response = await fileManagerApi().get("/activeTransfers");
return response.data;
} catch (error) {
handleApiError(error, "list active transfers");
throw error;
}
}
export async function cancelTransferToHost(transferId: string): Promise<void> {
try {
await fileManagerApi().post(`/transferCancel/${transferId}`);
} catch (error) {
fileLogger.warn("Transfer cancel request failed (non-fatal)", {
operation: "host_transfer",
transferId,
error,
});
}
}
export async function cleanupCancelledTransfer(
transferId: string,
): Promise<{ removedPaths: string[]; failedPaths: string[] }> {
try {
const response = await fileManagerApi().post(
`/transferCleanup/${transferId}`,
);
return response.data;
} catch (error) {
handleApiError(error, "clean up cancelled transfer");
throw error;
}
}
export async function retryTransferToHost(
transferId: string,
): Promise<{ ok: boolean; transferId: string }> {
try {
const response = await fileManagerApi().post(
`/transferRetry/${transferId}`,
);
return response.data;
} catch (error) {
handleApiError(error, "retry transfer");
throw error;
}
}
export async function pollTransferUntilComplete(
transferId: string,
onProgress?: (status: TransferProgressResponse) => void,
intervalMs = 500,
): Promise<TransferProgressResponse> {
return new Promise((resolve, reject) => {
const poll = async () => {
try {
const status = await getTransferStatus(transferId);
onProgress?.(status);
if (
status.status === "success" ||
status.status === "partial" ||
status.status === "error" ||
status.status === "cancelled"
) {
resolve(status);
return;
}
setTimeout(poll, intervalMs);
} catch (err) {
reject(err);
}
};
void poll();
});
}
// ============================================================================
// FILE MANAGER DATA
// ============================================================================
export interface TransferDestination {
id: number;
userId: string;
sourceHostId: number;
destHostId: number;
destPath: string;
destPathLabel?: string;
lastUsed?: string;
}
export async function getTransferRecent(
sourceHostId: number,
): Promise<TransferDestination[]> {
try {
const response = await fileManagerApi().get("/transfer-recent", {
params: { sourceHostId },
});
return response.data;
} catch (error) {
handleApiError(error, "get transfer recent destinations");
throw error;
}
}
export async function addTransferRecent(
sourceHostId: number,
destHostId: number,
destPath: string,
destPathLabel?: string,
): Promise<Record<string, unknown>> {
try {
const response = await fileManagerApi().post("/transfer-recent", {
sourceHostId,
destHostId,
destPath,
destPathLabel,
});
return response.data;
} catch (error) {
handleApiError(error, "add transfer recent destination");
throw error;
}
}
@@ -2,7 +2,7 @@ import React from "react";
import AudioPlayer from "react-h5-audio-player";
import "react-h5-audio-player/lib/styles.css";
import { Music } from "lucide-react";
import { cn } from "@/lib/utils.ts";
import { cn } from "@termix/plugin-sdk/ui";
import { useTranslation } from "react-i18next";
interface FileItem {
@@ -1,5 +1,5 @@
import type React from "react";
import { cn } from "@/lib/utils.ts";
import { cn } from "@termix/plugin-sdk/ui";
interface ColumnResizeHandleProps {
onMouseDown: (event: React.MouseEvent) => void;
@@ -1,7 +1,7 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { Check } from "lucide-react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils.ts";
import { cn } from "@termix/plugin-sdk/ui";
import type { ResizableColumns } from "../hooks/useResizableColumns.ts";
interface ColumnVisibilityMenuProps {
@@ -18,7 +18,7 @@ import {
getSSHStatus,
connectSSH,
} from "../api/ssh-file-operations-api";
import type { FileItem, SSHHost } from "@/types";
import type { FileItem, SSHHost } from "../host-types";
interface DiffViewerProps {
file1: FileItem;
@@ -3,7 +3,7 @@ import { DraggableWindow } from "./DraggableWindow.tsx";
import { DiffViewer } from "./DiffViewer.tsx";
import { useWindowManager } from "./WindowManager.tsx";
import { useTranslation } from "react-i18next";
import type { FileItem, SSHHost } from "@/types/index";
import type { FileItem, SSHHost } from "../host-types";
interface DiffWindowProps {
windowId: string;
@@ -1,4 +1,4 @@
import { formatTransferMbPerSec } from "@/main-axios.ts";
import { formatTransferMbPerSec } from "../api/transfer-api";
import { useTranslation } from "react-i18next";
import { ArrowDownToLine } from "lucide-react";
import { TransferProgressBar } from "./TransferProgressBar";
@@ -1,5 +1,5 @@
import React, { useState, useRef, useCallback, useEffect } from "react";
import { cn } from "@/lib/utils.ts";
import { cn } from "@termix/plugin-sdk/ui";
import { Minus, X, Maximize2, Minimize2 } from "lucide-react";
import { useTranslation } from "react-i18next";
@@ -1,6 +1,6 @@
/* eslint-disable react-hooks/exhaustive-deps */
import React, { Suspense, lazy, useState, useEffect, useRef } from "react";
import { cn } from "@/lib/utils.ts";
import { cn } from "@termix/plugin-sdk/ui";
import { useTranslation } from "react-i18next";
import {
FileText,
@@ -43,7 +43,7 @@ import {
SiDocker,
} from "react-icons/si";
import { Button } from "@termix/plugin-sdk/ui";
import { Kbd, KbdKey } from "@/components/kbd.tsx";
import { Kbd, KbdKey } from "@termix/plugin-sdk/ui";
import type { CodeEditorHandle } from "./CodeEditor.tsx";
import {
loadAudioPreview,
@@ -11,7 +11,7 @@ import {
connectSSH,
} from "../api/ssh-file-operations-api";
import { toast } from "sonner";
import type { SSHHost } from "@/types/index";
import type { SSHHost } from "../host-types";
import { useTranslation } from "react-i18next";
interface FileItem {
@@ -0,0 +1,668 @@
"use client";
import React, {
useState,
useCallback,
createContext,
useContext,
useRef,
useEffect,
} from "react";
import {
motion,
AnimatePresence,
easeInOut,
type Variants,
} from "motion/react";
import {
ChevronRight,
Folder,
FolderOpen,
File,
type LucideIcon,
} from "lucide-react";
import { cn } from "@termix/plugin-sdk/ui";
const animationVariants: Variants = {
rootInitial: { opacity: 0, y: 20 },
rootAnimate: { opacity: 1, y: 0 },
itemInitial: { opacity: 0, x: -10 },
itemAnimate: { opacity: 1, x: 0 },
contentHidden: { opacity: 0, height: 0 },
contentVisible: { opacity: 1, height: "auto" },
chevronClosed: { rotate: 0 },
chevronOpen: { rotate: 90 },
};
const transitions = {
root: { duration: 0.4 },
item: { duration: 0.2 },
content: { duration: 0.3, ease: easeInOut },
chevron: { duration: 0.2 },
};
interface ExpansionContextType {
expandedIds: Set<string>;
toggleExpanded: (id: string) => void;
}
interface SelectionContextType {
selectedId: string | null;
setSelected: (id: string) => void;
onSelect?: (id: string, label: string) => void;
}
interface TreeContextType {
focusedId: string | null;
setFocusedId: (id: string | null) => void;
treeId: string;
setKeyboardMode: (mode: boolean) => void;
keyboardMode: boolean;
}
interface LevelContextType {
level: number;
}
const ExpansionContext = createContext<ExpansionContextType | null>(null);
const SelectionContext = createContext<SelectionContextType | null>(null);
const TreeContext = createContext<TreeContextType | null>(null);
const LevelContext = createContext<LevelContextType>({ level: 0 });
const useExpansion = () => {
const context = useContext(ExpansionContext);
if (!context) {
throw new Error(
"FolderTree components must be used within FolderTree.Root",
);
}
return context;
};
const useSelection = () => {
const context = useContext(SelectionContext);
if (!context) {
throw new Error(
"FolderTree components must be used within FolderTree.Root",
);
}
return context;
};
const useTree = () => {
const context = useContext(TreeContext);
if (!context) {
throw new Error(
"FolderTree components must be used within FolderTree.Root",
);
}
return context;
};
const useLevel = () => {
return useContext(LevelContext);
};
const getPaddingClass = (level: number): string => {
const paddingMap: Record<number, string> = {
0: "pl-3",
1: "pl-8",
2: "pl-12",
3: "pl-16",
4: "pl-20",
5: "pl-24",
6: "pl-28",
7: "pl-32",
};
return paddingMap[level] || `pl-[${Math.min(level * 4 + 12, 48)}px]`;
};
interface CustomBadge {
content: React.ReactNode;
className?: string;
ariaLabel?: string;
}
interface RootProps {
defaultExpanded?: string[];
defaultSelected?: string;
selectedId?: string | null;
expandedIds?: Set<string>;
onSelect?: (id: string, label: string) => void;
className?: string;
children: React.ReactNode;
id?: string;
}
interface ItemProps {
id: string;
label: string;
icon?: LucideIcon;
badge?: string | number;
modified?: boolean | CustomBadge;
untracked?: boolean | CustomBadge;
className?: string;
children?: React.ReactNode;
}
interface TriggerProps {
className?: string;
}
interface ContentProps {
children: React.ReactNode;
className?: string;
}
const Root: React.FC<RootProps> = ({
defaultExpanded = [],
defaultSelected,
selectedId: controlledSelectedId,
expandedIds: additionalExpandedIds,
onSelect,
className = "",
children,
id = "folder-tree",
}) => {
const [expandedIds, setExpandedIds] = useState<Set<string>>(
new Set(defaultExpanded),
);
const [internalSelectedId, setInternalSelectedId] = useState<string | null>(
defaultSelected || null,
);
const selectedId =
controlledSelectedId !== undefined
? controlledSelectedId
: internalSelectedId;
const [focusedId, setFocusedId] = useState<string | null>(null);
const [keyboardMode, setKeyboardMode] = useState(false);
const treeRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!additionalExpandedIds || additionalExpandedIds.size === 0) return;
setExpandedIds((prev) => {
const merged = new Set(prev);
let changed = false;
for (const id of additionalExpandedIds) {
if (!merged.has(id)) {
merged.add(id);
changed = true;
}
}
return changed ? merged : prev;
});
}, [additionalExpandedIds]);
const toggleExpanded = useCallback((id: string) => {
setExpandedIds((prev) => {
const newSet = new Set(prev);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
return newSet;
});
}, []);
const setSelected = useCallback((id: string) => {
setInternalSelectedId(id);
}, []);
const getVisibleItemIds = useCallback(() => {
const items = Array.from(
treeRef.current?.querySelectorAll('[role="treeitem"]') || [],
);
return items
.filter((item) => {
const element = item as HTMLElement;
return element.offsetHeight > 0 && element.offsetWidth > 0;
})
.map((item) => item.getAttribute("data-id"))
.filter(Boolean) as string[];
}, []);
const [treeHasFocus, setTreeHasFocus] = useState(false);
const handleTreeFocus = useCallback(() => {
if (!treeHasFocus) {
setTreeHasFocus(true);
setKeyboardMode(true);
}
}, [treeHasFocus]);
const handleTreeBlur = useCallback((e: React.FocusEvent) => {
if (!treeRef.current?.contains(e.relatedTarget as Node)) {
setTreeHasFocus(false);
setFocusedId(null);
setKeyboardMode(false);
}
}, []);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
const getVisibleItems = () => {
return Array.from(
treeRef.current?.querySelectorAll('[role="treeitem"]') || [],
).filter((item) => {
const element = item as HTMLElement;
return element.offsetHeight > 0 && element.offsetWidth > 0;
});
};
if (e.key === "Tab") {
if (treeHasFocus && !focusedId) {
const visibleItemIds = getVisibleItemIds();
if (visibleItemIds.length > 0) {
setFocusedId(visibleItemIds[0]);
e.preventDefault();
return;
}
}
if (focusedId) {
const visibleItems = getVisibleItems();
const currentIndex = visibleItems.findIndex(
(item) => item.getAttribute("data-id") === focusedId,
);
if (e.shiftKey) {
if (currentIndex === 0) {
setFocusedId(null);
setTreeHasFocus(false);
setKeyboardMode(false);
return;
}
const nextIndex = Math.max(0, currentIndex - 1);
const nextItem = visibleItems[nextIndex] as HTMLElement;
const nextId = nextItem?.getAttribute("data-id");
if (nextId) {
setFocusedId(nextId);
e.preventDefault();
}
} else {
if (currentIndex === visibleItems.length - 1) {
setFocusedId(null);
setTreeHasFocus(false);
setKeyboardMode(false);
return;
}
const nextIndex = Math.min(
visibleItems.length - 1,
currentIndex + 1,
);
const nextItem = visibleItems[nextIndex] as HTMLElement;
const nextId = nextItem?.getAttribute("data-id");
if (nextId) {
setFocusedId(nextId);
e.preventDefault();
}
}
}
return;
}
if (!keyboardMode || !focusedId) return;
const visibleItems = getVisibleItems();
const currentIndex = visibleItems.findIndex(
(item) => item.getAttribute("data-id") === focusedId,
);
switch (e.key) {
case "ArrowDown":
e.preventDefault();
if (currentIndex < visibleItems.length - 1) {
const nextItem = visibleItems[currentIndex + 1] as HTMLElement;
const nextId = nextItem.getAttribute("data-id");
if (nextId) setFocusedId(nextId);
}
break;
case "ArrowUp":
e.preventDefault();
if (currentIndex > 0) {
const prevItem = visibleItems[currentIndex - 1] as HTMLElement;
const prevId = prevItem.getAttribute("data-id");
if (prevId) setFocusedId(prevId);
}
break;
case "ArrowRight":
e.preventDefault();
if (!expandedIds.has(focusedId)) {
toggleExpanded(focusedId);
}
break;
case "ArrowLeft":
e.preventDefault();
if (expandedIds.has(focusedId)) {
toggleExpanded(focusedId);
}
break;
case "Enter":
case " ":
e.preventDefault();
setSelected(focusedId);
if (onSelect) {
const currentItem = visibleItems[currentIndex] as HTMLElement;
const label =
currentItem.querySelector("span:nth-of-type(2)")?.textContent ||
"";
onSelect(focusedId, label);
}
break;
}
},
[
focusedId,
keyboardMode,
expandedIds,
toggleExpanded,
setSelected,
onSelect,
getVisibleItemIds,
treeHasFocus,
],
);
useEffect(() => {
const handleMouseDown = () => setKeyboardMode(false);
document.addEventListener("mousedown", handleMouseDown);
return () => {
document.removeEventListener("mousedown", handleMouseDown);
};
}, []);
const expansionValue: ExpansionContextType = {
expandedIds,
toggleExpanded,
};
const selectionValue: SelectionContextType = {
selectedId,
setSelected,
onSelect,
};
const treeValue: TreeContextType = {
focusedId,
setFocusedId,
treeId: id,
setKeyboardMode,
keyboardMode,
};
return (
<ExpansionContext.Provider value={expansionValue}>
<SelectionContext.Provider value={selectionValue}>
<TreeContext.Provider value={treeValue}>
<LevelContext.Provider value={{ level: 0 }}>
<motion.div
ref={treeRef}
variants={animationVariants}
initial="rootInitial"
animate="rootAnimate"
transition={transitions.root}
className={cn(
"bg-canvas border border-edge rounded-lg overflow-hidden",
className,
)}
role="tree"
aria-labelledby={`${id}-label`}
tabIndex={0}
onKeyDown={handleKeyDown}
onFocus={handleTreeFocus}
onBlur={handleTreeBlur}
>
<div className="w-full overflow-y-auto bg-canvas text-sm">
{children}
</div>
</motion.div>
</LevelContext.Provider>
</TreeContext.Provider>
</SelectionContext.Provider>
</ExpansionContext.Provider>
);
};
const ItemContext = createContext<{
itemId: string;
hasChildren: boolean;
isExpanded: boolean;
toggleExpanded: () => void;
} | null>(null);
const Item: React.FC<ItemProps> = ({
id,
label,
icon,
badge,
modified,
untracked,
className = "",
children,
}) => {
const expansionContext = useExpansion();
const selectionContext = useSelection();
const treeContext = useTree();
const { level } = useLevel();
const itemRef = useRef<HTMLDivElement>(null);
const keyboardMode = treeContext.keyboardMode;
const hasChildren = React.Children.count(children) > 0;
const isExpanded = expansionContext.expandedIds.has(id);
const isSelected = selectionContext.selectedId === id;
const isFocused = treeContext.focusedId === id;
const handleItemClick = useCallback(() => {
treeContext.setKeyboardMode(false);
selectionContext.setSelected(id);
treeContext.setFocusedId(id);
if (selectionContext.onSelect) {
selectionContext.onSelect(id, label);
}
}, [id, label, selectionContext, treeContext]);
const toggleExpanded = useCallback(() => {
if (hasChildren) {
expansionContext.toggleExpanded(id);
}
}, [id, hasChildren, expansionContext]);
const handleFocus = useCallback(() => {
treeContext.setFocusedId(id);
}, [id, treeContext]);
useEffect(() => {
if (isFocused && itemRef.current) {
itemRef.current.focus();
}
}, [isFocused]);
const IconComponent =
icon || (hasChildren ? (isExpanded ? FolderOpen : Folder) : File);
const itemContextValue = {
itemId: id,
hasChildren,
isExpanded,
toggleExpanded,
};
const renderBadge = (
badgeData: boolean | CustomBadge | undefined,
defaultContent: string,
defaultClassName: string,
) => {
if (!badgeData) return null;
if (typeof badgeData === "boolean") {
return (
<span
className={defaultClassName}
aria-label={`${defaultContent} status`}
>
{defaultContent}
</span>
);
}
return (
<span
className={cn(
"ml-auto text-xs px-2 py-0.5 rounded-full",
badgeData.className,
)}
aria-label={badgeData.ariaLabel || `Custom badge: ${badgeData.content}`}
>
{badgeData.content}
</span>
);
};
return (
<ItemContext.Provider value={itemContextValue}>
<LevelContext.Provider value={{ level: level + 1 }}>
<div>
<motion.div
ref={itemRef}
variants={animationVariants}
initial="itemInitial"
animate="itemAnimate"
transition={{ ...transitions.item, delay: level * 0.05 }}
data-selected={isSelected ? "true" : "false"}
data-id={id}
className={cn(
"flex items-center gap-2 py-1.5 text-sm transition-colors cursor-pointer select-none",
getPaddingClass(level),
className,
isSelected
? "bg-accent text-accent-foreground border-r-2 border-ring"
: "",
!isSelected && "hover:bg-hover",
keyboardMode && isFocused
? "focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-inset"
: "focus:outline-hidden",
)}
onClick={(e: React.MouseEvent) => {
handleItemClick();
e.stopPropagation();
toggleExpanded();
}}
onFocus={handleFocus}
role="treeitem"
tabIndex={isFocused ? 0 : -1}
aria-expanded={hasChildren ? isExpanded : undefined}
aria-selected={isSelected}
aria-label={`${hasChildren ? "Folder" : "File"}: ${label}`}
aria-level={level + 1}
>
{hasChildren && (
<motion.span
className="shrink-0 cursor-pointer"
variants={animationVariants}
animate={isExpanded ? "chevronOpen" : "chevronClosed"}
transition={transitions.chevron}
aria-hidden="true"
>
<ChevronRight size={14} className="text-muted-foreground" />
</motion.span>
)}
{!hasChildren && <span className="w-3 mr-2" aria-hidden="true" />}
{IconComponent && (
<IconComponent
size={16}
data-selected={isSelected ? "true" : "false"}
data-child={hasChildren ? "true" : "false"}
className={cn(
"mr-1 shrink-0 text-muted-foreground data-[child=true]:text-primary data-[selected=true]:text-accent-foreground",
)}
aria-hidden="true"
/>
)}
<span className="flex-1">{label}</span>
{badge && (
<span
className="ml-auto text-xs bg-muted text-muted-foreground px-2 py-0.5 rounded-full"
aria-label={`Badge: ${badge}`}
>
{badge}
</span>
)}
{renderBadge(
modified,
"M",
"ml-auto text-xs bg-yellow-200 dark:bg-yellow-700 text-yellow-800 dark:text-yellow-200 px-2 py-0.5 rounded-full",
)}
{renderBadge(
untracked,
"U",
"ml-auto text-xs bg-green-200 dark:bg-green-700 text-green-800 dark:text-green-200 px-2 py-0.5 rounded-full",
)}
</motion.div>
{children}
</div>
</LevelContext.Provider>
</ItemContext.Provider>
);
};
const Trigger: React.FC<TriggerProps> = ({ className = "" }) => {
const itemContext = useContext(ItemContext);
if (!itemContext || !itemContext.hasChildren) {
return null;
}
return (
<motion.span
className={cn("mr-2 shrink-0 cursor-pointer", className)}
variants={animationVariants}
animate={itemContext.isExpanded ? "chevronOpen" : "chevronClosed"}
transition={transitions.chevron}
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
itemContext.toggleExpanded();
}}
role="button"
aria-label={itemContext.isExpanded ? "Collapse" : "Expand"}
tabIndex={-1}
>
<ChevronRight size={14} className="text-muted-foreground" />
</motion.span>
);
};
const Content: React.FC<ContentProps> = ({ children, className = "" }) => {
const itemContext = useContext(ItemContext);
if (!itemContext) {
return <>{children}</>;
}
const hasContent = React.Children.count(children) > 0;
return (
<AnimatePresence>
{hasContent && itemContext.isExpanded && (
<motion.div
variants={animationVariants}
initial="contentHidden"
animate="contentVisible"
exit="contentHidden"
transition={transitions.content}
style={{ overflow: "hidden" }}
className={className}
role="group"
>
{children}
</motion.div>
)}
</AnimatePresence>
);
};
const FolderTree = {
Root,
Item,
Trigger,
Content,
};
export default FolderTree;
@@ -1,5 +1,5 @@
import type React from "react";
import { cn } from "@/lib/utils.ts";
import { cn } from "@termix/plugin-sdk/ui";
interface PaneResizeHandleProps {
label: string;
@@ -4,7 +4,7 @@ import type { TabHandle as TerminalHandle } from "@termix/plugin-sdk/frontend";
import { PluginComponent } from "@termix/plugin-sdk/ui";
import { useWindowManager } from "./WindowManager.tsx";
import { useTranslation } from "react-i18next";
import type { SSHHost } from "@/types/index.ts";
import type { SSHHost } from "../host-types";
import { ExternalLink } from "lucide-react";
interface TerminalWindowProps {
@@ -1,6 +1,6 @@
import type { CSSProperties } from "react";
import { cn } from "@/lib/utils";
import { cn } from "@termix/plugin-sdk/ui";
export function TransferProgressBar({
value,
@@ -3,7 +3,7 @@ import {
formatTransferMbPerSec,
getTransferProgressPercent,
type TransferProgressResponse,
} from "@/main-axios.ts";
} from "../api/transfer-api";
import { useTranslation } from "react-i18next";
import {
Archive,
@@ -31,13 +31,13 @@ import {
Trash2,
} from "lucide-react";
import {
getSSHHosts,
getTransferRecent,
getTransferMethodPreview,
type TransferDestination,
type TransferMethodPreference,
type TransferMethodPreview,
} from "@/main-axios.ts";
} from "../api/transfer-api";
import { getSSHHosts } from "../api/hosts";
import {
getSSHStatus,
browseSSHDirectory,
@@ -49,7 +49,7 @@ import {
addFolderShortcut,
removeFolderShortcut,
} from "../api/file-manager-data-api";
import type { SSHHost } from "@/types";
import type { SSHHost } from "../host-types";
interface FileItem {
name: string;
@@ -1,5 +1,5 @@
import type { FileItem, SSHHost } from "@/types/index";
import type { LogEntry } from "@/types/connection-log.ts";
import type { FileItem, SSHHost } from "./host-types";
import type { LogEntry } from "@termix/plugin-sdk/ui";
export interface FileManagerProps {
initialHost?: SSHHost | null;
@@ -1,7 +1,7 @@
import {
markAdaptiveResourceUsed,
runAdaptiveBackgroundTask,
} from "@/lib/adaptive-resource-budget";
} from "@termix/plugin-sdk/ui";
export type FilePreviewKind =
| "image"
@@ -6,7 +6,7 @@ import type {
FileManagerWidgetConfig,
WidgetComponentProps,
} from "./homepage.js";
import type { SSHHost } from "@/types/index";
import type { SSHHost } from "../host-types";
import { FileManager } from "../FileManager";
export function FileManagerWidget({
@@ -1,7 +1,7 @@
import { useState, useCallback } from "react";
import { toast } from "sonner";
import { downloadSSHFile } from "../api/ssh-file-operations-api";
import type { FileItem, SSHHost } from "@/types/index.js";
import type { FileItem, SSHHost } from "../host-types";
interface DragToDesktopState {
isDragging: boolean;
@@ -2,7 +2,7 @@
import { useState, useCallback, useRef } from "react";
import { toast } from "sonner";
import { downloadSSHFile } from "../api/ssh-file-operations-api";
import type { FileItem, SSHHost } from "@/types/index.js";
import type { FileItem, SSHHost } from "../host-types";
interface DragToSystemState {
isDragging: boolean;
@@ -2,8 +2,8 @@ import { useCallback, useRef } from "react";
import { createElement } from "react";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import type { FileItem } from "@/types/index";
import type { LocalWalkResult } from "@/types/electron";
import type { FileItem } from "../host-types";
import type { LocalWalkResult } from "@termix/plugin-sdk/ui";
import {
cancelLocalTransfer,
createLocalTransferId,
@@ -16,7 +16,7 @@ import {
getLocalHome,
localPathsExist,
walkLocalPaths,
} from "@/lib/local-files.ts";
} from "../lib/local-files";
import {
UnsafeLocalNameError,
buildLocalDestination,
@@ -0,0 +1,57 @@
/** A host as the shell hands it to the file manager. */
export interface SSHHost {
id: number;
name: string;
ip: string;
port: number;
username: string;
folder: string;
tags: string[];
pin: boolean;
authType: string;
password?: string;
key?: string;
keyPassword?: string;
keyType?: string;
sudoPassword?: string;
forceKeyboardInteractive?: boolean;
credentialId?: number;
overrideCredentialUsername?: boolean;
userId?: string;
jumpHosts?: Array<{ hostId: number }>;
terminalConfig?: Record<string, unknown>;
notes?: string;
useSocks5?: boolean;
socks5Host?: string;
socks5Port?: number;
socks5Username?: string;
socks5Password?: string;
socks5ProxyChain?: unknown[];
connectionType?: string;
enableSsh?: boolean;
syncId?: string | null;
createdAt: string;
updatedAt: string;
connectionOrigin?: "local" | "remote" | null;
instanceId?: string;
isShared?: boolean;
permissionLevel?: "connect" | "view" | "edit" | "manage";
/** Enabled plugins' host-scope settings, keyed by plugin id. */
pluginSettings?: Record<string, Record<string, unknown>>;
}
export interface FileItem {
name: string;
path: string;
isPinned?: boolean;
type: "file" | "directory" | "link";
sshSessionId?: string;
size?: number;
modified?: string;
modifiedTimestamp?: number;
permissions?: string;
owner?: string;
group?: string;
linkTarget?: string;
executable?: boolean;
}
+9 -4
View File
@@ -15,6 +15,7 @@ import { SftpTransferTab } from "./SftpTransferTab.tsx";
import { FileManagerWidget } from "./homepage/FileManagerWidget.tsx";
import { FileManagerWidgetEditForm } from "./homepage/FileManagerWidgetEditForm.tsx";
import { startTransferMonitor } from "./TransferMonitor.tsx";
import { setFileManagerApp } from "./api/client";
function FilesTab({ tab, host, sshHost, isVisible }: TabProps) {
const data = tab.data as
@@ -66,6 +67,8 @@ function openEditorAction(
}
export function activate(app: TermixApp): void {
setFileManagerApp(app);
app.onDispose(() => setFileManagerApp(null));
app.registerTab("files", FilesTab as unknown as ComponentType<TabProps>, {
icon: FolderSearch,
titleKey: "nav.files",
@@ -104,6 +107,7 @@ export function activate(app: TermixApp): void {
order: 20,
tabType: "files",
copyUrlView: "file-manager",
quickConnect: true,
when: (host) =>
!!host.enableSsh &&
fileManagerHostSetting(host, "enableFileManager", true),
@@ -122,10 +126,11 @@ export function activate(app: TermixApp): void {
editFormComponent: FileManagerWidgetEditForm as never,
});
app.registerAction("files.openHost", ((
host: PluginHostRecord | null,
path?: string,
) => openHostAction(app, host, path)) as never);
const openHost = ((host: PluginHostRecord | null, path?: string) =>
openHostAction(app, host, path)) as never;
app.registerAction("files.openHost", openHost);
// The shell's "open in file manager" tab button asks for this.
app.registerAction("host.openFiles", openHost);
app.registerAction("files.openEditor", ((
host: PluginHostRecord | null,
filePath: string,
@@ -0,0 +1,14 @@
/**
* Extract a stable, human-readable message from an unknown thrown value.
*
* A thrown value is not guaranteed to be an `Error` — libraries may throw
* strings or plain objects, which carry no useful `.message`. When that
* happens the caller gets `fallback` instead, so error paths never surface a
* raw `[object Object]` in logs or UI.
*/
export function getErrorMessage(
error: unknown,
fallback = "Unknown error",
): string {
return error instanceof Error ? error.message : fallback;
}
@@ -1,5 +1,5 @@
import type { FileItem } from "@/types/index";
import { createKeyedRequestCache } from "@/lib/keyed-request-cache";
import type { FileItem } from "../host-types";
import { createKeyedRequestCache } from "./keyed-request-cache";
export interface FileContentResult {
content: string;
@@ -1,5 +1,5 @@
import type { FileItem } from "@/types/index";
import { createKeyedRequestCache } from "@/lib/keyed-request-cache";
import type { FileItem } from "../host-types";
import { createKeyedRequestCache } from "./keyed-request-cache";
export interface FileListResult {
files: FileItem[];
@@ -0,0 +1,22 @@
/**
* The shape an axios rejection actually has at the call sites that inspect it.
* A `catch` binding is `unknown`, and every one of these sites was reaching
* into it directly; this gives them one narrowing helper instead.
*/
export interface HttpLikeError {
message?: string;
code?: string;
response?: {
status?: number;
data?: {
error?: string;
message?: string;
code?: string;
fileNotFound?: boolean;
};
};
}
export function asHttpError(error: unknown): HttpLikeError {
return (error ?? {}) as HttpLikeError;
}
@@ -0,0 +1,82 @@
export interface KeyedRequestCache<T> {
get(
key: string,
loader: () => Promise<T>,
options?: { force?: boolean },
): Promise<T>;
peek(key: string, maxAgeMs?: number): T | null;
set(key: string, value: T): void;
invalidate(key?: string): void;
}
/** Bounded per-key cache with in-flight request coalescing. */
export function createKeyedRequestCache<T>(
ttlMs: number,
maxEntries = 100,
): KeyedRequestCache<T> {
const values = new Map<string, { value: T; storedAt: number }>();
const inflight = new Map<string, Promise<T>>();
const versions = new Map<string, number>();
const store = (key: string, value: T) => {
values.delete(key);
values.set(key, { value, storedAt: Date.now() });
while (values.size > Math.max(1, maxEntries)) {
const oldest = values.keys().next().value;
if (oldest === undefined) break;
values.delete(oldest);
}
};
return {
get(key, loader, options = {}) {
const cached = values.get(key);
if (!options.force && cached && Date.now() - cached.storedAt < ttlMs) {
return Promise.resolve(cached.value);
}
const pending = inflight.get(key);
if (pending) return pending;
const version = versions.get(key) ?? 0;
const request = loader()
.then((value) => {
if ((versions.get(key) ?? 0) === version) store(key, value);
return value;
})
.finally(() => {
if (inflight.get(key) === request) inflight.delete(key);
});
inflight.set(key, request);
return request;
},
peek(key, maxAgeMs = Number.POSITIVE_INFINITY) {
const cached = values.get(key);
if (!cached || Date.now() - cached.storedAt > maxAgeMs) return null;
values.delete(key);
values.set(key, cached);
return cached.value;
},
set(key, value) {
versions.set(key, (versions.get(key) ?? 0) + 1);
inflight.delete(key);
store(key, value);
},
invalidate(key) {
if (key === undefined) {
for (const activeKey of inflight.keys()) {
versions.set(activeKey, (versions.get(activeKey) ?? 0) + 1);
}
values.clear();
inflight.clear();
} else {
versions.set(key, (versions.get(key) ?? 0) + 1);
values.delete(key);
inflight.delete(key);
}
},
};
}
@@ -0,0 +1,105 @@
// Thin, typed access to the desktop app's local filesystem bridge
// (electron/local-files.cjs via preload.js). Everything here resolves to
// "unavailable" outside Electron so callers can feature-detect cheaply.
import type {
LocalDirectoryListing,
LocalFsHomeInfo,
LocalFsResult,
LocalTrashResult,
LocalWalkResult,
} from "@termix/plugin-sdk/ui";
import { isElectron } from "@termix/plugin-sdk/ui";
export function isLocalFileBrowserAvailable(): boolean {
if (!isElectron()) return false;
const api = window.electronAPI;
return !!api?.localFs && !!api?.localTransfer;
}
function requireLocalFs() {
const api = window.electronAPI?.localFs;
if (!api) {
throw new Error("Local file access is only available in the desktop app");
}
return api;
}
function unwrap<T>(result: LocalFsResult<T>): T {
if (!result || result.success !== true) {
const message =
result && "error" in result && result.error
? result.error
: "Local file operation failed";
const error = new Error(message) as Error & { code?: string };
if (result && "code" in result) error.code = result.code;
throw error;
}
return result;
}
export async function getLocalHome(): Promise<LocalFsHomeInfo> {
return unwrap(await requireLocalFs().home());
}
export async function listLocalDirectory(
dirPath: string,
): Promise<LocalDirectoryListing> {
return unwrap(await requireLocalFs().list(dirPath));
}
export async function createLocalFolder(
parentPath: string,
name: string,
): Promise<string> {
return unwrap(await requireLocalFs().mkdir(parentPath, name)).path;
}
export async function createLocalFile(
parentPath: string,
name: string,
): Promise<string> {
return unwrap(await requireLocalFs().createFile(parentPath, name)).path;
}
export async function renameLocalEntry(
oldPath: string,
newName: string,
): Promise<string> {
return unwrap(await requireLocalFs().rename(oldPath, newName)).path;
}
/** Moves entries to the OS trash; resolves with per-path failures, if any. */
export async function trashLocalPaths(
paths: string[],
): Promise<LocalTrashResult> {
return unwrap(await requireLocalFs().trash(paths));
}
/** Subset of `paths` that already exist on disk. */
export async function localPathsExist(paths: string[]): Promise<string[]> {
if (paths.length === 0) return [];
return unwrap(await requireLocalFs().exists(paths)).existing;
}
/** Creates `dirPath` (and parents). With `rootPath`, refuses anything outside it. */
export async function ensureLocalDirectory(
dirPath: string,
rootPath?: string,
): Promise<string> {
return unwrap(await requireLocalFs().ensureDir(dirPath, rootPath)).path;
}
export async function walkLocalPaths(
paths: string[],
): Promise<LocalWalkResult> {
return unwrap(await requireLocalFs().walk(paths));
}
export async function revealLocalPath(targetPath: string): Promise<void> {
unwrap(await requireLocalFs().reveal(targetPath));
}
export async function openLocalPath(targetPath: string): Promise<void> {
unwrap(await requireLocalFs().open(targetPath));
}
@@ -1,7 +1,7 @@
// Pure helpers shared by the dual-pane (local <-> remote) file manager code.
// Kept free of React/DOM so they can be unit tested directly.
import type { LocalFileEntry } from "@/types/electron";
import type { LocalFileEntry } from "@termix/plugin-sdk/ui";
/** Custom MIME type carried by drags that originate in the local pane. */
export const LOCAL_FILES_DRAG_MIME = "application/x-termix-local-files";
@@ -1,4 +1,4 @@
import type { FileItem } from "@/types/index";
import type { FileItem } from "./host-types";
export function childPath(parent: string, name: string): string {
return parent === "/" ? `/${name}` : `${parent.replace(/\/$/, "")}/${name}`;
@@ -3,7 +3,7 @@ import {
formatDurationMs,
formatTransferMbPerSec,
type TransferTimings,
} from "@/main-axios.ts";
} from "./api/transfer-api";
export function createFormatTransferMetrics(t: TFunction) {
return (timings?: TransferTimings): string => {
@@ -1,4 +1,4 @@
import { getErrorMessage } from "@/lib/error-message.js";
import { getErrorMessage } from "./lib/error-message";
import { toast } from "sonner";
import type { TFunction } from "i18next";
import {
@@ -9,7 +9,7 @@ import {
createTransferProgressTracker,
type TransferProgressResponse,
type TransferTimings,
} from "@/main-axios.ts";
} from "./api/transfer-api";
import { TransferProgressToast } from "./components/TransferProgressToast.tsx";
import {
markTransferNotified,
@@ -6,7 +6,7 @@ import {
LOCAL_FILES_DRAG_MIME,
REMOTE_FILES_DRAG_MIME,
} from "../../src/frontend/local-transfer-utils";
import type { LocalFileEntry } from "@/types/electron";
import type { LocalFileEntry } from "@termix/plugin-sdk/ui";
// jsdom has no layout, so render every row instead of a virtual window.
vi.mock("@tanstack/react-virtual", () => ({
@@ -19,13 +19,16 @@ const api = vi.hoisted(() => ({
beginTransferProgressMonitoring: vi.fn(),
}));
vi.mock("@/main-axios", () => ({
vi.mock("../../src/frontend/api/transfer-api", () => ({
addTransferRecent: api.addTransferRecent,
getSSHHosts: api.getSSHHosts,
getTransferProgressPercent: api.getTransferProgressPercent,
transferToHost: api.transferToHost,
}));
vi.mock("../../src/frontend/api/hosts", () => ({
getSSHHosts: api.getSSHHosts,
}));
vi.mock("../../src/frontend/api/ssh-file-operations-api", () => ({
browseSSHDirectory: api.browseSSHDirectory,
changeSSHPermissions: api.changeSSHPermissions,
@@ -57,11 +57,12 @@ describe(`${manifest.id} activate`, () => {
expect(rendered.registered.hostEditorSections()).toEqual([]);
});
it("registers files.openHost and files.openEditor actions", async () => {
it("registers its open actions, including the shell's host.openFiles", async () => {
rendered = await renderWithApp(plugin, { manifest, locales });
expect(rendered.registered.actions().sort()).toEqual([
"files.openEditor",
"files.openHost",
"host.openFiles",
]);
});
@@ -4,9 +4,8 @@ const fileManagerApiMock = vi.hoisted(() => ({
post: vi.fn(async () => ({ data: { complete: false } })),
}));
vi.mock("@/main-axios", () => ({
authApi: { defaults: {} },
fileManagerApi: { defaults: {} },
vi.mock("../../../src/frontend/api/client", () => ({
fileManagerApi: () => ({ defaults: {} }),
getFileManagerApiForSession: () => fileManagerApiMock,
handleApiError: (error: unknown) => {
throw error;
@@ -14,19 +13,20 @@ vi.mock("@/main-axios", () => ({
setSessionOrigin: vi.fn(),
clearSessionOrigin: vi.fn(),
}));
vi.mock("@/lib/connection-origin", () => ({
vi.mock("@termix/plugin-sdk/ui", async (importOriginal) => ({
...(await importOriginal<object>()),
resolveConnectionOrigin: vi.fn(),
}));
vi.mock("@/lib/frontend-logger", () => ({
fileLogger: {
createFrontendLogger: () => ({
info: vi.fn(),
success: vi.fn(),
},
warn: vi.fn(),
error: vi.fn(),
}),
}));
vi.mock("@/lib/file-list-request-cache", () => ({
vi.mock("../../../src/frontend/lib/file-list-request-cache", () => ({
getCachedFileList: vi.fn(),
}));
vi.mock("@/lib/file-content-request-cache", () => ({
vi.mock("../../../src/frontend/lib/file-content-request-cache", () => ({
getCachedFileContent: vi.fn(),
invalidateCachedFileContent: vi.fn(),
}));
@@ -0,0 +1,84 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createKeyedRequestCache } from "../../../src/frontend/lib/keyed-request-cache";
afterEach(() => vi.useRealTimers());
describe("createKeyedRequestCache", () => {
it("coalesces requests per key without mixing keys", async () => {
let resolve!: (value: string) => void;
const loader = vi.fn(() => new Promise<string>((done) => (resolve = done)));
const cache = createKeyedRequestCache<string>(5_000);
const first = cache.get("host:1", loader);
const duplicate = cache.get("host:1", loader);
const other = cache.get("host:2", async () => "other");
resolve("shared");
await expect(Promise.all([first, duplicate, other])).resolves.toEqual([
"shared",
"shared",
"other",
]);
expect(loader).toHaveBeenCalledOnce();
});
it("can show stale data immediately while forcing one refresh", async () => {
vi.useFakeTimers();
const cache = createKeyedRequestCache<string>(1_000);
await cache.get("directory", async () => "old");
await vi.advanceTimersByTimeAsync(2_000);
expect(cache.peek("directory", 30_000)).toBe("old");
const loader = vi.fn(async () => "fresh");
const [a, b] = await Promise.all([
cache.get("directory", loader, { force: true }),
cache.get("directory", loader, { force: true }),
]);
expect([a, b]).toEqual(["fresh", "fresh"]);
expect(loader).toHaveBeenCalledOnce();
});
it("evicts the least recently used entry when bounded", async () => {
const cache = createKeyedRequestCache<number>(10_000, 2);
await cache.get("a", async () => 1);
await cache.get("b", async () => 2);
expect(cache.peek("a")).toBe(1);
await cache.get("c", async () => 3);
expect(cache.peek("a")).toBe(1);
expect(cache.peek("b")).toBeNull();
expect(cache.peek("c")).toBe(3);
});
it("keeps stale data when a refresh fails", async () => {
const cache = createKeyedRequestCache<string>(1_000);
await cache.get("key", async () => "safe");
await expect(
cache.get(
"key",
async () => {
throw new Error("offline");
},
{ force: true },
),
).rejects.toThrow("offline");
expect(cache.peek("key")).toBe("safe");
});
it("does not let an invalidated request overwrite newer data", async () => {
let resolveOld!: (value: string) => void;
const cache = createKeyedRequestCache<string>(10_000);
const oldRequest = cache.get(
"key",
() => new Promise<string>((resolve) => (resolveOld = resolve)),
);
cache.set("key", "optimistic");
resolveOld("old");
await oldRequest;
expect(cache.peek("key")).toBe("optimistic");
});
});
@@ -26,7 +26,7 @@ import {
serializeLocalFilesDragPayload,
sortLocalEntries,
} from "../../src/frontend/local-transfer-utils";
import type { LocalFileEntry } from "@/types/electron";
import type { LocalFileEntry } from "@termix/plugin-sdk/ui";
describe("drag payloads", () => {
it("round-trips local file payloads", () => {
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import type { FileItem } from "@/types/index";
import type { FileItem } from "../../src/frontend/host-types";
import {
addOptimisticItem,
childPath,
@@ -7,7 +7,7 @@ vi.mock("sonner", () => ({
},
}));
vi.mock("@/main-axios.ts", () => ({}));
vi.mock("../../src/frontend/api/transfer-api", () => ({}));
vi.mock("../../src/frontend/transferNotificationStore.ts", () => ({
markTransferNotified: vi.fn(),
}));
-6
View File
@@ -1,10 +1,4 @@
{
"extends": "@termix/plugin-sdk/tsconfig.plugin.json",
"compilerOptions": {
"paths": {
"@/types/*": ["../../src/types/*"],
"@/*": ["../../src/ui/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"]
}
+1 -1
View File
@@ -6,7 +6,7 @@
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json",
"typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json",
"validate": "termix-plugin validate"
},
"devDependencies": {
-6
View File
@@ -1,10 +1,4 @@
{
"extends": "@termix/plugin-sdk/tsconfig.plugin.json",
"compilerOptions": {
"paths": {
"@/types/*": ["../../src/types/*"],
"@/*": ["../../src/ui/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"]
}
+1 -1
View File
@@ -6,7 +6,7 @@
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json",
"typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json",
"validate": "termix-plugin validate"
},
"devDependencies": {
@@ -1,12 +0,0 @@
import React from "react";
import { HomepageCanvas } from "./HomepageCanvas";
const HomepageApp: React.FC = () => {
return (
<div className="h-screen w-screen overflow-hidden">
<HomepageCanvas fitOnLoad={true} />
</div>
);
};
export default HomepageApp;
-77
View File
@@ -1,77 +0,0 @@
import { useSyncExternalStore } from "react";
/**
* A keyed, reactive list of contributions.
*
* Widgets register on import, so anything rendered from a registry has to
* re-render when it changes. The snapshot array is cached until the next
* change because useSyncExternalStore compares by identity and loops forever
* on a fresh array.
*/
export interface Registry<T extends { id: string }> {
/** Adds or replaces by id. The disposer only removes this exact entry. */
register: (item: T) => () => void;
unregister: (id: string) => void;
get: (id: string) => T | undefined;
list: () => T[];
subscribe: (listener: () => void) => () => void;
/** React hook over list(). */
useList: () => T[];
reset: () => void;
}
export function createRegistry<T extends { id: string }>(
sort?: (a: T, b: T) => number,
): Registry<T> {
const items = new Map<string, T>();
const listeners = new Set<() => void>();
let snapshot: T[] | null = null;
const emit = () => {
snapshot = null;
for (const listener of listeners) listener();
};
const list = () => {
if (!snapshot) {
snapshot = [...items.values()];
if (sort) snapshot.sort(sort);
}
return snapshot;
};
const subscribe = (listener: () => void) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
};
return {
register(item) {
items.set(item.id, item);
emit();
return () => {
if (items.get(item.id) === item) {
items.delete(item.id);
emit();
}
};
},
unregister(id) {
if (items.delete(id)) emit();
},
get(id) {
return items.get(id);
},
list,
subscribe,
useList() {
return useSyncExternalStore(subscribe, list, list);
},
reset() {
items.clear();
emit();
},
};
}
+1 -1
View File
@@ -6,7 +6,7 @@
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json",
"typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json",
"validate": "termix-plugin validate"
},
"devDependencies": {
+1 -1
View File
@@ -6,7 +6,7 @@
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json",
"typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json",
"validate": "termix-plugin validate"
},
"dependencies": {
+1 -1
View File
@@ -6,7 +6,7 @@
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json",
"typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json",
"validate": "termix-plugin validate"
},
"dependencies": {
-6
View File
@@ -1,10 +1,4 @@
{
"extends": "@termix/plugin-sdk/tsconfig.plugin.json",
"compilerOptions": {
"paths": {
"@/types/*": ["../../src/types/*"],
"@/*": ["../../src/ui/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"]
}
+8
View File
@@ -44,6 +44,14 @@
"default": false
}
]
},
"http": {
"legacyRedirects": [
{
"from": "/host/opkssh-callback",
"to": "/callback"
}
]
}
},
"locales": "locales",
+1 -1
View File
@@ -6,7 +6,7 @@
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json",
"typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json",
"validate": "termix-plugin validate"
},
"dependencies": {
@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { validateManifest } from "@termix/plugin-sdk/manifest";
import manifest from "../../manifest.json";
// Old URLs something outside Termix still calls keep reaching the plugin.
const redirects = manifest.contributes.http.legacyRedirects as unknown[];
describe("legacy URLs", () => {
it("declares them in a manifest that validates", () => {
expect(validateManifest(manifest)).toEqual([]);
});
it("redirects /host/opkssh-callback, which OPKSSH identity providers were set up with before 2.9", () => {
expect(redirects).toContainEqual({
from: "/host/opkssh-callback",
to: "/callback",
});
});
});
+1 -1
View File
@@ -6,7 +6,7 @@
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json",
"typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json",
"validate": "termix-plugin validate"
},
"devDependencies": {
+57
View File
@@ -1004,6 +1004,63 @@ router.post("/discover", async (req, res) => {
}
});
/**
* @openapi
* /plugin-api/proxmox/import:
* post:
* summary: Import discovered Proxmox guests as hosts
* description: >
* Creates one host per entry, owned by the caller, and stores each
* entry's proxmox host settings (the guest's source) on the new host.
* tags:
* - Proxmox
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - hosts
* properties:
* hosts:
* type: array
* items:
* type: object
* responses:
* 200:
* description: How many hosts were created and which failed.
* 400:
* description: No hosts given.
*/
router.post("/import", async (req, res) => {
const ctx = pluginCtx();
const { hosts } = req.body as { hosts?: unknown };
if (!Array.isArray(hosts) || hosts.length === 0) {
return res.status(400).json({ error: "No hosts to import" });
}
const result = { success: 0, failed: 0, errors: [] as string[] };
for (const entry of hosts as Array<Record<string, unknown>>) {
const { pluginSettings, ...input } = entry;
try {
const created = await ctx.hosts.create(
input as unknown as PluginHostCreateInput,
);
const own = (pluginSettings as Record<string, unknown> | undefined)?.[
ctx.pluginId
] as Record<string, unknown> | undefined;
for (const [key, value] of Object.entries(own ?? {})) {
await ctx.settings.setHost(Number(created.id), key, value);
}
result.success++;
} catch (err: unknown) {
result.failed++;
result.errors.push(`${String(input.name)}: ${getErrorMessage(err)}`);
}
}
return res.json(result);
});
/** Called from activate(). Mounts this router at /proxmox via the shared
* dispatcher, and starts the background auto-sync scan (a 60s interval plus
* a one-off 30s-delayed startup run, both unref'd so they never keep the
+18 -17
View File
@@ -1,10 +1,14 @@
import { useEffect, useState } from "react";
import { useSshAuthTypes, useTranslation } from "@termix/plugin-sdk/frontend";
import { Server } from "lucide-react";
import { Input } from "@/components/input";
import { SectionCard, SettingRow, FakeSwitch } from "@/components/section-card";
import { getCredentials } from "@/main-axios";
import { Select2 } from "@/components/select2";
import {
FakeSwitch,
Input,
SectionCard,
Select2,
SettingRow,
} from "@termix/plugin-sdk/ui";
import { listCredentials } from "@termix/plugin-sdk/frontend";
import type { ProxmoxHostConfig } from "./types";
interface HostProxmoxForm {
@@ -28,19 +32,16 @@ export function HostProxmoxTab({
>([]);
useEffect(() => {
getCredentials()
.then((res: unknown) => {
const raw =
(res as { credentials?: unknown })?.credentials ?? res ?? [];
const list = (Array.isArray(raw) ? raw : []).map(
(c: Record<string, unknown>) => ({
id: c.id as number,
name: c.name as string,
username: (c.username as string | null) ?? null,
}),
);
setCredentials(list);
})
listCredentials()
.then((list) =>
setCredentials(
list.map((c) => ({
id: c.id,
name: c.name,
username: c.username ?? null,
})),
),
)
.catch(() => {});
}, []);
@@ -1,31 +1,26 @@
import { useRef, useState } from "react";
import type { HostData } from "@/types/index";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { Server, RefreshCw, CheckSquare, Square, Download } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/button";
import { Button } from "@termix/plugin-sdk/ui";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/dialog";
import { Select2 } from "@/components/select2";
import {
discoverProxmoxGuestsStream,
bulkImportSSHHosts,
getSSHHosts,
} from "@/main-axios";
import type { SSHHostWithStatus } from "@/main-axios";
import type { ProxmoxGuest } from "@/types/proxmox";
} from "@termix/plugin-sdk/ui";
import { Select2 } from "@termix/plugin-sdk/ui";
import { discoverProxmoxGuestsStream, importProxmoxHosts } from "./proxmox-api";
import { listHosts, type PluginHostRecord } from "@termix/plugin-sdk/frontend";
import type { ProxmoxGuest } from "./types";
import { resolveProxmoxImportAuth } from "./proxmox-import-auth";
interface ProxmoxDiscoverDialogProps {
open: boolean;
onClose: () => void;
hosts: SSHHostWithStatus[];
onHostsChanged: (hosts: SSHHostWithStatus[]) => void;
hosts: PluginHostRecord[];
onHostsChanged: (hosts: PluginHostRecord[]) => void;
/** Pre-select a specific host and skip the host picker */
preselectedHostId?: number;
/** Credential to use for imported hosts */
@@ -69,7 +64,11 @@ export function ProxmoxDiscoverDialog({
// When opened from the dropdown (no preselectedHostId), only show Proxmox-enabled hosts
const sshHosts = hosts.filter(
(h) =>
!("isFolder" in h) && h.pluginSettings?.proxmox?.enableProxmox === true,
!("isFolder" in h) &&
(
h.pluginSettings as
Record<string, Record<string, unknown> | undefined> | undefined
)?.proxmox?.enableProxmox === true,
);
// The Proxmox host the discovery runs against — imported guests are grouped
@@ -189,11 +188,11 @@ export function ProxmoxDiscoverDialog({
}));
const result = toImport.length
? await bulkImportSSHHosts(toImport as unknown as HostData[], false)
: { success: 0, updated: 0, skipped: 0, failed: 0 };
? await importProxmoxHosts(toImport)
: { success: 0, failed: 0, errors: [] };
if (toImport.length) {
const updated = await getSSHHosts();
const updated = await listHosts();
onHostsChanged(updated);
window.dispatchEvent(new CustomEvent("termix:hosts-changed"));
}
@@ -202,9 +201,6 @@ export function ProxmoxDiscoverDialog({
result.success
? t("hosts.proxmoxResultImported", { count: result.success })
: null,
result.updated
? t("hosts.proxmoxResultUpdated", { count: result.updated })
: null,
result.failed
? t("hosts.proxmoxResultFailed", { count: result.failed })
: null,
+7 -5
View File
@@ -9,13 +9,13 @@ import {
type TermixApp,
} from "@termix/plugin-sdk/frontend";
import { ComponentSlot, DropdownMenuItem } from "@termix/plugin-sdk/ui";
import type { SSHHostWithStatus } from "@/main-axios";
import type { PluginHostRecord } from "@termix/plugin-sdk/frontend";
import { setProxmoxApp } from "./proxmox-api";
import { ProxmoxDiscoverDialog } from "./ProxmoxDiscoverDialog";
import { HostProxmoxTab } from "./HostProxmoxTab";
import { ProxmoxStatsTab } from "./stats/ProxmoxStatsTab";
import ProxmoxStatsApp from "./stats/ProxmoxStatsApp";
import { HostProxmoxStatsTab } from "./stats/HostProxmoxStatsTab";
import type { SSHHost } from "@/types";
interface DiscoverRequest {
hostId?: number;
@@ -79,7 +79,7 @@ function ProxmoxHostSection({ form, updateForm }: HostEditorSectionProps) {
function ProxmoxStatsTabView({ sshHost, label, isVisible }: TabProps) {
return (
<ProxmoxStatsTab
hostConfig={sshHost as unknown as SSHHost}
hostConfig={sshHost as never}
title={label}
isVisible={isVisible}
isTopbarOpen={false}
@@ -93,6 +93,8 @@ function ProxmoxStatsStandalone({ hostId }: StandaloneViewProps) {
}
export function activate(app: TermixApp): void {
setProxmoxApp(app);
app.onDispose(() => setProxmoxApp(null));
// The dialog lives in the hosts panel; the menu item and host action open
// it through this, created per activation.
const openers = new Set<Opener>();
@@ -104,8 +106,8 @@ export function activate(app: TermixApp): void {
hosts,
onHostsChanged,
}: {
hosts: SSHHostWithStatus[];
onHostsChanged: (hosts: SSHHostWithStatus[]) => void;
hosts: PluginHostRecord[];
onHostsChanged: (hosts: PluginHostRecord[]) => void;
}) {
const [request, setRequest] = useState<DiscoverRequest | null>(null);
useEffect(() => {
+117
View File
@@ -0,0 +1,117 @@
import type { TermixApp } from "@termix/plugin-sdk/frontend";
import type { ProxmoxDiscoverResult } from "./types";
let current: TermixApp | null = null;
/** Set in activate, cleared on deactivate. */
export function setProxmoxApp(app: TermixApp | null): void {
current = app;
}
function app(): TermixApp {
if (!current) throw new Error("The Proxmox plugin is not active");
return current;
}
/**
* Runs a guest discovery over the server-sent event stream and reports
* progress. Returns a function that stops listening.
*/
export function discoverProxmoxGuestsStream(
hostId: number,
handlers: {
onProgress?: (done: number, total: number) => void;
onResult: (result: ProxmoxDiscoverResult) => void;
onError: (message: string) => void;
},
): () => void {
const controller = new AbortController();
let settled = false;
const finish = () => {
settled = true;
controller.abort();
};
const handleEvent = (event: string, data: string) => {
if (event === "progress") {
try {
const parsed = JSON.parse(data);
handlers.onProgress?.(parsed.done, parsed.total);
} catch {
// ignore malformed progress frames
}
} else if (event === "result") {
finish();
try {
handlers.onResult(JSON.parse(data));
} catch {
handlers.onError("Failed to parse discovery result");
}
} else if (event === "fail") {
finish();
let message = "Discovery failed";
try {
message = JSON.parse(data).message || message;
} catch {
// keep default message
}
handlers.onError(message);
}
};
void (async () => {
try {
const response = await app().fetch(
`/discover/stream?hostId=${encodeURIComponent(String(hostId))}`,
{ signal: controller.signal },
);
if (!response.ok || !response.body) throw new Error("stream failed");
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let split = buffer.indexOf("\n\n");
while (split !== -1) {
const frame = buffer.slice(0, split);
buffer = buffer.slice(split + 2);
let event = "message";
const data: string[] = [];
for (const line of frame.split("\n")) {
if (line.startsWith("event:")) event = line.slice(6).trim();
else if (line.startsWith("data:")) data.push(line.slice(5).trim());
}
if (data.length) handleEvent(event, data.join("\n"));
split = buffer.indexOf("\n\n");
}
}
if (!settled) {
settled = true;
handlers.onError("Discovery connection lost");
}
} catch {
if (settled) return;
settled = true;
handlers.onError("Discovery connection lost");
}
})();
return finish;
}
export interface ProxmoxImportResult {
success: number;
failed: number;
errors: string[];
}
export async function importProxmoxHosts(
hosts: Record<string, unknown>[],
): Promise<ProxmoxImportResult> {
const response = await app().api.post<ProxmoxImportResult>("/import", {
hosts,
});
return response.data;
}
+18
View File
@@ -133,3 +133,21 @@ export interface ProxmoxHostConfig {
errors: string[];
};
}
export interface ProxmoxGuest {
name: string;
vmid: number;
type: "qemu" | "lxc";
node: string;
status: string;
ip: string | null;
connectionType: "ssh" | "rdp";
enableDocker: boolean;
}
export interface ProxmoxDiscoverResult {
guests: ProxmoxGuest[];
credentialId: number | null;
defaultCredentialId: number | null;
jumpHosts?: unknown[] | null;
}
-6
View File
@@ -1,10 +1,4 @@
{
"extends": "@termix/plugin-sdk/tsconfig.plugin.json",
"compilerOptions": {
"paths": {
"@/types/*": ["../../src/types/*"],
"@/*": ["../../src/ui/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"]
}
+1 -1
View File
@@ -6,7 +6,7 @@
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json",
"typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json",
"validate": "termix-plugin validate"
},
"devDependencies": {
+1 -1
View File
@@ -6,7 +6,7 @@
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json",
"typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json",
"validate": "termix-plugin validate"
},
"devDependencies": {
@@ -3,6 +3,6 @@ import { SecretSourceManager } from "./SecretSourceManager";
import { SecretReferenceHint } from "./SecretReferenceHint";
export function activate(app: TermixApp): void {
app.registerComponent("secret-sources.hint", SecretReferenceHint);
app.registerComponent("secret-sources.manager", SecretSourceManager);
app.registerComponent("credentials.secretHint", SecretReferenceHint);
app.registerComponent("credentials.secretManager", SecretSourceManager);
}
@@ -24,7 +24,7 @@ describe(`${manifest.id} activate`, () => {
rendered = await renderWithApp(plugin, { manifest, locales });
render(
<PluginComponent
id="secret-sources.hint"
id="credentials.secretHint"
onManage={vi.fn()}
fallback={<span>fallback</span>}
/>,
@@ -41,7 +41,9 @@ describe(`${manifest.id} activate`, () => {
} as never,
});
const onClose = vi.fn();
render(<PluginComponent id="secret-sources.manager" onClose={onClose} />);
render(
<PluginComponent id="credentials.secretManager" onClose={onClose} />,
);
expect(await screen.findByText(locales.title)).toBeTruthy();
});
@@ -49,7 +51,10 @@ describe(`${manifest.id} activate`, () => {
const app = await renderWithApp(plugin, { manifest, locales });
await app.deactivate();
render(
<PluginComponent id="secret-sources.hint" fallback={<span>gone</span>} />,
<PluginComponent
id="credentials.secretHint"
fallback={<span>gone</span>}
/>,
);
expect(await screen.findByText("gone")).toBeTruthy();
});
+1 -1
View File
@@ -6,7 +6,7 @@
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json",
"typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json",
"validate": "termix-plugin validate"
},
"dependencies": {
+1 -1
View File
@@ -6,7 +6,7 @@
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json",
"typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json",
"validate": "termix-plugin validate"
},
"devDependencies": {
+1 -1
View File
@@ -6,7 +6,7 @@
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json",
"typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json",
"validate": "termix-plugin validate"
},
"dependencies": {
+18 -7
View File
@@ -48,7 +48,6 @@
"shareTitle": "Shared",
"importExport": "Import / Export",
"importFailed": "Failed to import snippets",
"nameLabel": "Name",
"namePlaceholder": "Snippet name",
"descriptionLabel": "Description",
@@ -61,20 +60,17 @@
"commandPlaceholder": "Command to run, e.g. systemctl status $HOST",
"noteLabel": "Note",
"notePlaceholder": "Text to paste into the terminal",
"createSnippetTitle": "Create snippet",
"createSnippetDescription": "Save a command or note you can run or paste into a terminal.",
"createSnippetButton": "Create snippet",
"createSuccess": "Snippet created",
"createFailed": "Failed to create snippet",
"editSnippetTitle": "Edit snippet",
"editSnippetDescription": "Change this snippet's name, content or folder.",
"saveSnippetButton": "Save snippet",
"updateSuccess": "Snippet updated",
"updateFailed": "Failed to update snippet",
"deleteFailed": "Failed to delete snippet",
"createFolderTitle": "Create folder",
"createFolderButton": "Create folder",
"folderCreateSuccess": "Folder created",
@@ -88,11 +84,26 @@
"folderIconLabel": "Icon",
"folderDeleteSuccess": "Folder deleted",
"folderDeleteFailed": "Failed to delete folder",
"confirmRunMessage": "Run \"{{name}}\"?",
"confirmRunButton": "Run",
"runSuccess": "Ran \"{{name}}\" on {{count}} terminal(s)",
"pasteSuccess": "Pasted \"{{name}}\" into {{count}} terminal(s)",
"foldersCollapsedLabel": "Collapse folders by default"
"foldersCollapsedLabel": "Collapse folders by default",
"admin": {
"tabTitle": "Snippets",
"count": "{{count}} snippets",
"add": "Add Snippet",
"namePlaceholder": "Snippet name",
"contentPlaceholder": "Command content",
"folderPlaceholder": "Folder (optional)",
"empty": "This user has no snippets",
"deleteConfirm": "Delete snippet \"{{name}}\" belonging to {{username}}?",
"deleted": "Snippet deleted",
"deleteFailed": "Failed to delete snippet",
"requiredFields": "Snippet name and content are required",
"saved": "Snippet saved",
"saveFailed": "Failed to save snippet",
"saving": "Saving...",
"save": "Save"
}
}
+1 -1
View File
@@ -6,7 +6,7 @@
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json",
"typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json",
"validate": "termix-plugin validate"
},
"devDependencies": {
Loaded 100 of 399 files, more files were not shown because too many files have changed in this diff. Show more