mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
chore: lock the plugin boundary and remove 2.9.0 leftovers
This commit is contained in:
1 parent
6489b62ece
commit
2961297989
399 files changed
+3964
-5627
No files matched your search
@@ -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,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,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,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"]
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -250,6 +250,7 @@ export async function startConnect(
|
||||
ctx.ssh
|
||||
.connect<Client>(host, {
|
||||
purpose: "docker",
|
||||
profile: "session",
|
||||
timeoutMs: CONNECT_TIMEOUT_MS,
|
||||
prompt: { ask },
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(),
|
||||
}));
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,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"]
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,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,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,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"]
|
||||
}
|
||||
@@ -44,6 +44,14 @@
|
||||
"default": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"http": {
|
||||
"legacyRedirects": [
|
||||
{
|
||||
"from": "/host/opkssh-callback",
|
||||
"to": "/callback"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"locales": "locales",
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,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();
|
||||
});
|
||||
|
||||
@@ -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,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,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": {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user