mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45: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
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user