chore: lock the plugin boundary and remove 2.9.0 leftovers

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

No files matched your search

@@ -0,0 +1,119 @@
import type { PluginLogger } from "@termix/plugin-sdk/backend";
export type ConnectionStage =
| "dns"
| "tcp"
| "handshake"
| "auth"
| "connected"
| "connection"
| "error"
| "proxy"
| "jump"
| "sftp_connecting"
| "sftp_auth"
| "sftp_connected";
export type LogEntry = {
id: string;
timestamp: Date;
type: "info" | "success" | "warning" | "error";
stage: ConnectionStage;
message: string;
details?: Record<string, unknown> | string;
};
export function createConnectionLog(
type: LogEntry["type"],
stage: ConnectionStage,
message: string,
details?: Record<string, unknown>,
): Omit<LogEntry, "id" | "timestamp"> {
return { type, stage, message, details };
}
function normalizeHostAddress(value: unknown): string {
if (typeof value !== "string") return "";
return value
.replace(/^\[|\]$/g, "")
.trim()
.toLowerCase();
}
/**
* Whether a numeric host id resolved to a different machine than the client
* meant. Ids from the desktop app and a sync server drift apart, and the
* resolved row supplies the credentials, so a mismatch must be refused.
*/
export function hostAddressMismatch(
clientAddress: unknown,
resolvedAddress: unknown,
): boolean {
const resolved = normalizeHostAddress(resolvedAddress);
if (!resolved) return false;
return resolved !== normalizeHostAddress(clientAddress);
}
export function resolveServerHostId(
clientHostId: number | null,
resolvedHost: { id?: unknown } | null,
): number | null {
return typeof resolvedHost?.id === "number" ? resolvedHost.id : clientHostId;
}
export class HostAddressMismatchError extends Error {
constructor() {
super(
"Host mismatch: this server resolved the selected host to a different machine, so the connection was refused. " +
"The host ids on this device and on the sync server have drifted apart. " +
'Set the connection origin to "This device" for this host, or re-run a full sync, then try again.',
);
this.name = "HostAddressMismatchError";
}
}
export class HostNotOnThisServerError extends Error {
constructor() {
super(
"This host does not exist on the sync server, so the connection was refused. " +
'Run a sync so the server knows about it, or set the connection origin to "This device" for this host.',
);
this.name = "HostNotOnThisServerError";
}
}
type Meta = Record<string, unknown>;
export interface FileLogger {
info: (message: string, meta?: Meta) => void;
success: (message: string, meta?: Meta) => void;
warn: (message: string, meta?: Meta) => void;
error: (message: string, error?: unknown, meta?: Meta) => void;
}
function withMeta(message: string, meta?: Meta): string {
if (!meta || Object.keys(meta).length === 0) return message;
try {
return `${message} ${JSON.stringify(meta)}`;
} catch {
return message;
}
}
/** ctx.log takes a plain message; this keeps the structured fields readable. */
export function createFileLogger(log: PluginLogger): FileLogger {
return {
info: (message, meta) => log.info(withMeta(message, meta)),
success: (message, meta) => log.info(withMeta(message, meta)),
warn: (message, meta) => log.warn(withMeta(message, meta)),
error: (message, error, meta) => {
if (error instanceof Error) {
log.error(withMeta(message, meta), error);
} else if (error && typeof error === "object" && !meta) {
log.error(withMeta(message, error as Meta));
} else {
log.error(withMeta(message, meta));
}
},
};
}
+40 -60
View File
@@ -1,9 +1,5 @@
// Core imports below point at TypeScript source so tsc can type-check them.
// The plugin build rewrites the prefix to the compiled output path; see
// packages/plugin-sdk/cli/lib/legacy-core-imports.mjs.
import express, { type Router } from "express";
import cookieParser from "cookie-parser";
import axios from "axios";
import { Client as SSHClient } from "ssh2";
import type { PluginContext, PluginSshHost } from "@termix/plugin-sdk/backend";
import { getErrorMessage } from "./error-message.js";
@@ -15,30 +11,17 @@ import {
} from "./transfer-tuning.js";
const TRANSFER_PROFILES_KEY = "transfer-profiles";
import { AuthManager } from "../../../../src/backend/utils/auth-manager.js";
import { logger } from "../../../../src/backend/utils/logger.js";
import {
logAudit,
getAuditUsername,
getRequestMeta,
} from "../../../../src/backend/utils/audit-logger.js";
import { createCurrentHostResolutionRepository } from "../../../../src/backend/database/repositories/factory.js";
import {
resolveHostById,
resolveHostBySyncId,
} from "../../../../src/backend/hosts/host-resolver.js";
import { createConnectionLog } from "../../../../src/backend/hosts/connection-log.js";
import { JumpHostChainError } from "../../../../src/backend/hosts/jump-host-chain.js";
import {
createConnectionLog,
createFileLogger,
hostAddressMismatch,
HostAddressMismatchError,
HostNotOnThisServerError,
resolveServerHostId,
} from "../../../../src/backend/hosts/host-identity.js";
import type {
LogEntry,
ConnectionStage,
} from "../../../../src/types/connection-log.js";
type ConnectionStage,
type FileLogger,
type LogEntry,
} from "./host-identity.js";
import {
ChannelOpenSerializer,
execChannel,
@@ -66,7 +49,17 @@ import {
import { createFilesService } from "./service.js";
import { tables } from "./tables.js";
const fileLogger = logger;
let fileLogger: FileLogger = createFileLogger(console);
// Shape of a resolved host as this route reads it; core fills more fields
// than PluginSshHost names.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type LooseHost = PluginSshHost & Record<string, any>;
// Core's jump chain failure, matched by name so no core class is imported.
function isJumpHostChainError(error: unknown): boolean {
return error instanceof Error && error.name === "JumpHostChainError";
}
/** Cosmetic only: whether the proxy step is worth a connection-log line. */
function hasSocks5Config(host: PluginSshHost): boolean {
@@ -115,6 +108,7 @@ function assertResolvedHost(
}
export async function activate(ctx: PluginContext) {
fileLogger = createFileLogger(ctx.log);
setPluginSsh(ctx.ssh);
setPluginCtx(ctx);
ctx.disposables.add(() => setPluginSsh(null));
@@ -276,7 +270,7 @@ export async function activate(ctx: PluginContext) {
throw new Error("Cannot open transfer connection: unknown host");
}
const host = await resolveHostById(hostId, userId);
const host = await ctx.asUser(userId, () => ctx.ssh.resolveHost(hostId));
if (!host) {
throw new Error("Host not found for transfer connection");
}
@@ -304,6 +298,7 @@ export async function activate(ctx: PluginContext) {
host as unknown as PluginSshHost,
{
purpose: "file-transfer",
profile: "stream",
timeoutMs: 60000,
overrides: { tcpKeepAliveInitialDelay: 5000 },
},
@@ -600,9 +595,9 @@ export async function activate(ctx: PluginContext) {
let resolvedSocks5ProxyChain = socks5ProxyChain;
const resolveFrom = async () => {
const resolvedHost = hostSyncId
? await resolveHostBySyncId(hostSyncId, userId)
: await resolveHostById(hostId, userId);
const resolvedHost = (await ctx.ssh.resolveHost(hostId, {
syncId: hostSyncId,
})) as LooseHost | null;
assertResolvedHost(ip, hostSyncId, resolvedHost, hostId, userId);
if (!resolvedHost) return;
resolvedIp = resolvedHost.ip;
@@ -690,7 +685,9 @@ export async function activate(ctx: PluginContext) {
let serverHostId = hostId;
if (hostSyncId && userId) {
const resolvedHost = await resolveHostBySyncId(hostSyncId, userId);
const resolvedHost = await ctx.ssh.resolveHost(hostId, {
syncId: hostSyncId,
});
assertResolvedHost(ip, hostSyncId, resolvedHost, hostId, userId);
serverHostId = resolveServerHostId(hostId, resolvedHost) ?? hostId;
}
@@ -744,6 +741,7 @@ export async function activate(ctx: PluginContext) {
const ssh = pluginSsh();
const prepared = await ssh.prepare(connectTarget, {
purpose: "file-manager",
profile: "session",
client,
serverHostId,
log: (level, message) =>
@@ -850,17 +848,13 @@ export async function activate(ctx: PluginContext) {
scheduleSessionCleanup(sessionId);
if (userId) {
const { ipAddress, userAgent } = getRequestMeta(req);
void (async () => {
await logAudit({
userId,
username: await getAuditUsername(userId),
await ctx.audit.record({
request: req,
action: "file_manager_connect",
resourceType: "host",
resourceId: serverHostId ? String(serverHostId) : undefined,
resourceName: `${username}@${ip}:${port}`,
ipAddress,
userAgent,
success: true,
});
})();
@@ -1169,17 +1163,14 @@ export async function activate(ctx: PluginContext) {
hostId,
});
const stage =
error instanceof JumpHostChainError || jumpHostCount > 0
? "jump"
: "proxy";
isJumpHostChainError(error) || jumpHostCount > 0 ? "jump" : "proxy";
connectionLogs.push(
createConnectionLog("error", stage, getErrorMessage(error)),
);
return res.status(500).json({
error:
error instanceof JumpHostChainError
? `Failed to connect through jump hosts: ${error.message}`
: getErrorMessage(error),
error: isJumpHostChainError(error)
? `Failed to connect through jump hosts: ${error.message}`
: getErrorMessage(error),
connectionLogs,
});
}
@@ -1195,24 +1186,13 @@ export async function activate(ctx: PluginContext) {
port: number,
): Promise<void> {
try {
const host = await createCurrentHostResolutionRepository().findHostById(
hostId,
userId,
);
const hostName =
host?.userId === userId && host.name
? host.name
: `${username}@${ip}:${port}`;
const authManager = AuthManager.getInstance();
await axios.post(
"http://localhost:30006/activity/log",
{ type: "file_manager", hostId, hostName },
{
headers: {
Authorization: `Bearer ${await authManager.generateJWTToken(userId)}`,
},
},
const host = await ctx.hosts.get(hostId);
await ctx.asUser(userId, () =>
ctx.hosts.recordActivity(
hostId,
"file_manager",
host?.name || `${username}@${ip}:${port}`,
),
);
} catch (error) {
fileLogger.warn("Failed to log file manager activity", {
@@ -15,7 +15,6 @@ import {
permanentlyDeleteTrashItem,
restoreTrashItem,
} from "./trash-service.js";
import { PermissionManager } from "../../../../src/backend/utils/permission-manager.js";
type FileOperationRoutesDeps = {
ctx: PluginContext;
@@ -29,10 +28,8 @@ export function registerFileOperationRoutes(
app: Express,
{ ctx, sshSessions, verifySessionOwnership }: FileOperationRoutesDeps,
): void {
// NOTE: trash retention is an install-wide admin setting, not a per-host
// access check, so it stays on PermissionManager.isAdmin rather than
// ctx.hosts.checkAccess (see rule 5 scope: host resolution/access only).
const permissionManager = PermissionManager.getInstance();
// Trash retention is an install-wide admin setting, not a per-host check.
const canManageRetention = () => ctx.rbac.has("admin.settings.manage");
const getTrashRetentionDays = async (): Promise<number> => {
try {
const value = Number(await ctx.settings.get(TRASH_RETENTION_DAYS_KEY));
@@ -68,9 +65,7 @@ export function registerFileOperationRoutes(
res.json({
items: await listTrash(await getSessionSftp(session), retentionDays),
retentionDays,
canManageRetention: await permissionManager.isAdmin(
ctx.currentActor()!,
),
canManageRetention: await canManageRetention(),
});
} catch (error) {
ctx.log.error("Failed to list trash", error as Error);
@@ -121,8 +116,7 @@ export function registerFileOperationRoutes(
});
app.put("/trash-retention", async (req, res) => {
const userId = ctx.currentActor()!;
if (!(await permissionManager.isAdmin(userId))) {
if (!(await canManageRetention())) {
return res.status(403).json({ error: "Admin access required" });
}
const retentionDays = Number(req.body?.retentionDays);
+5 -1
View File
@@ -40,7 +40,11 @@ async function withServiceSftp<T>(
): Promise<T> {
return ctx.ssh.withConnection<T, import("ssh2").Client>(
hostId,
{ pool: "file-manager-service", purpose: "file-manager" },
{
pool: "file-manager-service",
purpose: "file-manager",
profile: "session",
},
(client) =>
new Promise<T>((resolve, reject) => {
client.sftp(async (err, sftp) => {
@@ -1,5 +1,5 @@
import { fileManagerHostSetting } from "./host-settings";
import { getErrorMessage } from "@/lib/error-message.js";
import { getErrorMessage } from "./lib/error-message";
/* eslint-disable react-hooks/exhaustive-deps */
import React, {
useState,
@@ -8,8 +8,8 @@ import React, {
useCallback,
useMemo,
} from "react";
import { asHttpError } from "@/lib/http-error";
import { cn } from "@/lib/utils.ts";
import { asHttpError } from "./lib/http-error";
import { cn } from "@termix/plugin-sdk/ui";
import { FileManagerGrid } from "./FileManagerGrid.tsx";
import { FileManagerSidebar, type SidebarItem } from "./FileManagerSidebar.tsx";
import { FileManagerContextMenu } from "./FileManagerContextMenu.tsx";
@@ -24,33 +24,33 @@ import { DownloadProgressToast } from "./components/DownloadProgressToast.tsx";
import { DiffWindow } from "./components/DiffWindow.tsx";
import { useDragToDesktop } from "./hooks/useDragToDesktop";
import { useDragToSystemDesktop } from "./hooks/useDragToSystemDesktop";
import { useConfirmation } from "@/hooks/use-confirmation.ts";
import { useConfirmation } from "@termix/plugin-sdk/ui";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { FileManagerDialogs } from "./FileManagerDialogs.tsx";
import { PassphraseDialog } from "@/ssh/dialogs/PassphraseDialog.tsx";
import { PassphraseDialog } from "@termix/plugin-sdk/ui";
import { FileManagerToolbar } from "./FileManagerToolbar.tsx";
import { LocalFilePane } from "./LocalFilePane.tsx";
import { useLocalTransfers } from "./hooks/useLocalTransfers.ts";
import { usePaneWidth } from "./hooks/usePaneWidth.ts";
import { PaneResizeHandle } from "./components/PaneResizeHandle.tsx";
import { isLocalFileBrowserAvailable } from "@/lib/local-files.ts";
import { isLocalFileBrowserAvailable } from "./lib/local-files";
import { TransferToHostDialog } from "./components/TransferToHostDialog.tsx";
import { FileManagerTrashDialog } from "./FileManagerTrashDialog.tsx";
import { TerminalWindow } from "./components/TerminalWindow.tsx";
import type { SSHHost, FileItem } from "@/types/index";
import type { SSHHost, FileItem } from "./host-types";
import {
ConnectionLogProvider,
useConnectionLog,
ConnectionScreen,
} from "@termix/plugin-sdk/ui";
import { ConnectionLogPanel } from "@/components/connection/ConnectionLogPanel.tsx";
import { ConnectionLogPanel } from "@termix/plugin-sdk/ui";
import {
invokeAction,
useConnectionRetry,
logActivity,
} from "@termix/plugin-sdk/frontend";
import { copyToClipboard } from "@/lib/clipboard.ts";
import { copyToClipboard } from "@termix/plugin-sdk/ui";
import {
listSSHFiles,
readSSHFile,
@@ -85,13 +85,13 @@ import {
transferToHost,
addTransferRecent,
type TransferMethodPreference,
} from "@/main-axios.ts";
} from "./api/transfer-api";
import type { DiskFilesystem, HostDiskInfo } from "./disk-info";
import {
getAdaptiveResourceBudget,
markAdaptiveResourceUsed,
runAdaptiveBackgroundTask,
} from "@/lib/adaptive-resource-budget";
} from "@termix/plugin-sdk/ui";
import { shouldPrefetchFileContent } from "./lib/file-content-request-cache";
import {
markFilePreviewUsed,
@@ -1,5 +1,5 @@
import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils.ts";
import { cn } from "@termix/plugin-sdk/ui";
import {
Download,
Edit3,
@@ -22,7 +22,7 @@ import {
Link,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { Kbd, KbdKey, KbdSeparator } from "@/components/kbd.tsx";
import { Kbd, KbdKey, KbdSeparator } from "@termix/plugin-sdk/ui";
const VIEWPORT_PADDING = 16;
@@ -1,4 +1,4 @@
import type { FileItem, SSHHost } from "@/types/index";
import type { FileItem, SSHHost } from "./host-types";
import {
TOTPDialog,
SSHAuthDialog,
@@ -9,7 +9,7 @@ import React, {
} from "react";
import { createPortal } from "react-dom";
import { useVirtualizer } from "@tanstack/react-virtual";
import { cn } from "@/lib/utils.ts";
import { cn } from "@termix/plugin-sdk/ui";
import {
Folder,
File,
@@ -30,7 +30,7 @@ import {
GitCompare,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import type { FileItem } from "@/types/index";
import type { FileItem } from "./host-types";
import type { CreateIntent } from "./file-manager-types.ts";
import { formatFileSize } from "./file-manager-utils.ts";
import {
@@ -5,7 +5,7 @@ import React, {
useCallback,
useMemo,
} from "react";
import { cn } from "@/lib/utils.ts";
import { cn } from "@termix/plugin-sdk/ui";
import {
Star,
Clock,
@@ -17,7 +17,7 @@ import {
Check,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import type { SSHHost } from "@/types";
import type { SSHHost } from "./host-types";
import {
getRecentFiles,
getPinnedFiles,
@@ -29,7 +29,7 @@ import {
import { listSSHFiles } from "./api/ssh-file-operations-api";
import type { DiskFilesystem } from "./disk-info";
import { toast } from "sonner";
import FolderTree from "@/components/folder.tsx";
import FolderTree from "./components/FolderTree";
import {
DropdownMenu,
DropdownMenuTrigger,
@@ -30,7 +30,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@termix/plugin-sdk/ui";
import type { FileItem } from "@/types/index";
import type { FileItem } from "./host-types";
type SortBy = "name" | "modified" | "size";
type SortOrder = "asc" | "desc";
@@ -1,5 +1,5 @@
import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils.ts";
import { cn } from "@termix/plugin-sdk/ui";
import {
Clipboard,
Edit3,
@@ -14,8 +14,8 @@ import {
Upload,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { Kbd, KbdKey, KbdSeparator } from "@/components/kbd.tsx";
import type { LocalFileEntry } from "@/types/electron";
import { Kbd, KbdKey, KbdSeparator } from "@termix/plugin-sdk/ui";
import type { LocalFileEntry } from "@termix/plugin-sdk/ui";
const VIEWPORT_PADDING = 16;
@@ -26,9 +26,9 @@ import {
RefreshCw,
X,
} from "lucide-react";
import { cn } from "@/lib/utils.ts";
import { cn } from "@termix/plugin-sdk/ui";
import { Button, Input } from "@termix/plugin-sdk/ui";
import type { LocalFileEntry } from "@/types/electron";
import type { LocalFileEntry } from "@termix/plugin-sdk/ui";
import {
createLocalFile,
createLocalFolder,
@@ -38,9 +38,9 @@ import {
renameLocalEntry,
revealLocalPath,
trashLocalPaths,
} from "@/lib/local-files.ts";
import { copyToClipboard } from "@/lib/clipboard.ts";
import { useConfirmation } from "@/hooks/use-confirmation.ts";
} from "./lib/local-files";
import { copyToClipboard } from "@termix/plugin-sdk/ui";
import { useConfirmation } from "@termix/plugin-sdk/ui";
import { LocalFileContextMenu } from "./LocalFileContextMenu.tsx";
import {
useResizableColumns,
@@ -38,10 +38,10 @@ import {
} from "@termix/plugin-sdk/ui";
import {
addTransferRecent,
getSSHHosts,
transferToHost,
type TransferProgressResponse,
} from "@/main-axios";
} from "./api/transfer-api";
import { getSSHHosts } from "./api/hosts";
import {
browseSSHDirectory,
changeSSHPermissions,
@@ -51,7 +51,7 @@ import {
renameSSHItem,
type HostConnectionState,
} from "./api/ssh-file-operations-api";
import type { SSHHost } from "@/types";
import type { SSHHost } from "./host-types";
import { PermissionsDialog } from "./components/PermissionsDialog";
import { formatFileSize } from "./file-manager-utils";
import { beginTransferProgressMonitoring } from "./transferProgressMonitor";
@@ -581,7 +581,7 @@ export function SftpTransferTab() {
useEffect(() => {
setHostsLoading(true);
getSSHHosts({ includeStatus: false })
getSSHHosts()
.then((data) =>
setHosts(
data.filter(
@@ -1,6 +1,6 @@
import type { TranslateFn } from "@termix/plugin-sdk/frontend";
import type { TFunction } from "i18next";
import { getTransferStatus, listActiveTransfers } from "@/main-axios.ts";
import { getTransferStatus, listActiveTransfers } from "./api/transfer-api";
import { createFormatTransferMetrics } from "./transferMetricsFormat.ts";
import {
beginTransferProgressMonitoring,
@@ -12,7 +12,7 @@ import {
getPendingTransferIds,
isTransferNotified,
} from "./transferNotificationStore.ts";
import { runAdaptivePolling } from "@/lib/adaptive-polling.ts";
import { runAdaptivePolling } from "@termix/plugin-sdk/ui";
const POLL_INTERVAL_MS = 2000;
@@ -0,0 +1,121 @@
import type { TermixApp } from "@termix/plugin-sdk/frontend";
// Response bodies are typed at each call site's return type.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Body = any;
interface ClientResponse<T> {
data: T;
status: number;
headers: Record<string, unknown>;
}
/** The plugin client is axios underneath; these are the parts used here. */
export interface FileManagerClient {
get<T = Body>(url: string, config?: unknown): Promise<ClientResponse<T>>;
delete<T = Body>(url: string, config?: unknown): Promise<ClientResponse<T>>;
post<T = Body>(
url: string,
body?: unknown,
config?: unknown,
): Promise<ClientResponse<T>>;
put<T = Body>(
url: string,
body?: unknown,
config?: unknown,
): Promise<ClientResponse<T>>;
patch<T = Body>(
url: string,
body?: unknown,
config?: unknown,
): Promise<ClientResponse<T>>;
postForm<T = Body>(
url: string,
body?: unknown,
config?: unknown,
): Promise<ClientResponse<T>>;
}
let current: TermixApp | null = null;
/** Set in activate, cleared on deactivate. */
export function setFileManagerApp(app: TermixApp | null): void {
current = app;
}
function app(): TermixApp {
if (!current) throw new Error("The file manager plugin is not active");
return current;
}
export function fileManagerApi(): FileManagerClient {
return app().api as unknown as FileManagerClient;
}
// A live SSH session (keyed by sessionId) remembers which backend holds it,
// so every later call for that session reaches the same server.
const sessionOrigins = new Map<string, "local" | "remote">();
export function setSessionOrigin(
sessionId: string,
origin: "local" | "remote",
): void {
sessionOrigins.set(sessionId, origin);
}
export function clearSessionOrigin(sessionId: string): void {
sessionOrigins.delete(sessionId);
}
export function getSessionOrigin(sessionId: string): "local" | "remote" {
return sessionOrigins.get(sessionId) === "remote" ? "remote" : "local";
}
export function getFileManagerApiForSession(
sessionId: string,
): FileManagerClient {
return app().apiFor(
getSessionOrigin(sessionId),
) as unknown as FileManagerClient;
}
export function fileManagerApiFor(
origin: "local" | "remote" | undefined,
): FileManagerClient {
return app().apiFor(origin ?? "local") as unknown as FileManagerClient;
}
class ApiError extends Error {
constructor(
message: string,
public status?: number,
public code?: string,
) {
super(message);
this.name = "ApiError";
}
}
interface AxiosLikeError {
message?: string;
response?: {
status?: number;
data?: { message?: string; error?: string; code?: string };
};
}
/** Turns an axios failure into an Error carrying the server's message. */
export function handleApiError(error: unknown, operation: string): never {
const e = error as AxiosLikeError;
if (e?.response) {
const status = e.response.status;
const data = e.response.data ?? {};
const message =
data.message || data.error || e.message || `Failed to ${operation}`;
const apiError = new ApiError(message, status, data.code || data.error);
(apiError as ApiError & { response?: unknown }).response = e.response;
throw apiError;
}
if (error instanceof Error) throw error;
throw new ApiError(`Failed to ${operation}`);
}
@@ -1,4 +1,4 @@
import { fileManagerApi, handleApiError } from "@/main-axios";
import { fileManagerApi, handleApiError } from "./client";
/** Row shape shared by the recent / pinned / shortcut list endpoints. */
export interface FileManagerEntry {
@@ -16,7 +16,7 @@ export async function getRecentFiles(
hostId: number,
): Promise<FileManagerEntry[]> {
try {
const response = await fileManagerApi.get("/recent", {
const response = await fileManagerApi().get("/recent", {
params: { hostId },
});
return response.data;
@@ -32,7 +32,7 @@ export async function addRecentFile(
name?: string,
): Promise<Record<string, unknown>> {
try {
const response = await fileManagerApi.post("/recent", {
const response = await fileManagerApi().post("/recent", {
hostId,
path,
name,
@@ -49,7 +49,7 @@ export async function removeRecentFile(
path: string,
): Promise<Record<string, unknown>> {
try {
const response = await fileManagerApi.delete("/recent", {
const response = await fileManagerApi().delete("/recent", {
data: { hostId, path },
});
return response.data;
@@ -63,7 +63,7 @@ export async function getPinnedFiles(
hostId: number,
): Promise<FileManagerEntry[]> {
try {
const response = await fileManagerApi.get("/pinned", {
const response = await fileManagerApi().get("/pinned", {
params: { hostId },
});
return response.data;
@@ -79,7 +79,7 @@ export async function addPinnedFile(
name?: string,
): Promise<Record<string, unknown>> {
try {
const response = await fileManagerApi.post("/pinned", {
const response = await fileManagerApi().post("/pinned", {
hostId,
path,
name,
@@ -96,7 +96,7 @@ export async function removePinnedFile(
path: string,
): Promise<Record<string, unknown>> {
try {
const response = await fileManagerApi.delete("/pinned", {
const response = await fileManagerApi().delete("/pinned", {
data: { hostId, path },
});
return response.data;
@@ -110,7 +110,7 @@ export async function getFolderShortcuts(
hostId: number,
): Promise<FileManagerEntry[]> {
try {
const response = await fileManagerApi.get("/shortcuts", {
const response = await fileManagerApi().get("/shortcuts", {
params: { hostId },
});
return response.data;
@@ -126,7 +126,7 @@ export async function addFolderShortcut(
name?: string,
): Promise<Record<string, unknown>> {
try {
const response = await fileManagerApi.post("/shortcuts", {
const response = await fileManagerApi().post("/shortcuts", {
hostId,
path,
name,
@@ -143,7 +143,7 @@ export async function removeFolderShortcut(
path: string,
): Promise<Record<string, unknown>> {
try {
const response = await fileManagerApi.delete("/shortcuts", {
const response = await fileManagerApi().delete("/shortcuts", {
data: { hostId, path },
});
return response.data;
@@ -0,0 +1,6 @@
import { listHosts } from "@termix/plugin-sdk/frontend";
import type { SSHHost } from "../host-types";
export async function getSSHHosts(): Promise<SSHHost[]> {
return (await listHosts()) as unknown as SSHHost[];
}
@@ -12,9 +12,9 @@
import type {
LocalTransferOrigin,
LocalTransferProgress,
} from "@/types/electron";
import { getSessionOrigin } from "@/main-axios";
import { getDeviceId } from "@/lib/device-id";
} from "@termix/plugin-sdk/ui";
import { getSessionOrigin } from "./client";
import { getDeviceId } from "@termix/plugin-sdk/ui";
export interface LocalTransferProgressEvent {
transferred: number;
@@ -1,16 +1,17 @@
import { getErrorMessage } from "@/lib/error-message.js";
import { getErrorMessage } from "../lib/error-message";
import axios from "axios";
import { asHttpError } from "@/lib/http-error";
import { asHttpError } from "../lib/http-error";
import {
authApi,
handleApiError,
getFileManagerApiForSession,
setSessionOrigin,
clearSessionOrigin,
} from "@/main-axios";
import { resolveConnectionOrigin } from "@/lib/connection-origin";
import { fileLogger } from "@/lib/frontend-logger";
import type { FileItem, SSHHost } from "@/types/index";
} from "./client";
import {
createFrontendLogger,
resolveConnectionOrigin,
} from "@termix/plugin-sdk/ui";
import type { FileItem, SSHHost } from "../host-types";
import { getCachedFileList } from "../lib/file-list-request-cache";
import {
getCachedFileContent,
@@ -18,6 +19,8 @@ import {
type FileContentResult,
} from "../lib/file-content-request-cache";
const fileLogger = createFrontendLogger("FILE");
type ApiConnectionLog = {
type: "info" | "success" | "warning" | "error";
stage: string;
@@ -193,83 +196,6 @@ export async function verifySSHBrowserSignIn(
}
}
/**
* @openapi
* /ssh/quick-connect:
* post:
* summary: Create a temporary SSH connection without saving to database
* description: Returns a temporary host configuration for immediate use
* tags:
* - SSH
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - ip
* - port
* - username
* - authType
* properties:
* ip:
* type: string
* description: SSH server IP or hostname
* port:
* type: number
* description: SSH server port
* username:
* type: string
* description: SSH username
* authType:
* type: string
* enum: [password, key, credential]
* description: Authentication method
* password:
* type: string
* description: Password (required if authType is password)
* key:
* type: string
* description: SSH private key (required if authType is key)
* keyPassword:
* type: string
* description: SSH key password (optional)
* keyType:
* type: string
* description: SSH key type
* credentialId:
* type: number
* description: Credential ID (required if authType is credential)
* overrideCredentialUsername:
* type: boolean
* description: Use provided username instead of credential username
* responses:
* 200:
* description: Temporary host configuration created successfully
* content:
* application/json:
* schema:
* type: object
* description: SSHHost object
* 400:
* description: Invalid request data
* 401:
* description: Unauthorized
* 500:
* description: Server error
*/
export async function quickConnect(
data: Record<string, unknown>,
): Promise<SSHHost> {
try {
const response = await authApi.post("/host/quick-connect", data);
return response.data;
} catch (error) {
throw handleApiError(error, "quick connect");
}
}
export async function getSSHStatus(
sessionId: string,
): Promise<{ connected: boolean }> {
@@ -0,0 +1,384 @@
import { createFrontendLogger } from "@termix/plugin-sdk/ui";
import { fileManagerApi, handleApiError } from "./client";
const fileLogger = createFrontendLogger("FILE");
// ============================================================================
// HOST-TO-HOST TRANSFER
// ============================================================================
export type TransferMethodPreference = "auto" | "tar" | "item_sftp";
export interface TransferScanSummary {
fileCount: number;
totalBytes: number;
largestFileBytes: number;
incompressibleRatio: number;
}
export interface TransferMethodPreview {
methodPreference: TransferMethodPreference;
resolvedMethod: "tar" | "item_sftp";
reasonKey: string;
sourcePlatform: "unix" | "windows";
destPlatform: "unix" | "windows";
sourceHasTar: boolean;
destHasTar: boolean;
summary: TransferScanSummary;
}
export async function getTransferMethodPreview(
sourceSessionId: string,
sourcePaths: string[],
destSessionId: string,
destPath: string,
methodPreference?: TransferMethodPreference,
): Promise<TransferMethodPreview> {
try {
const response = await fileManagerApi().post("/transferMethodPreview", {
sourceSessionId,
sourcePaths,
destSessionId,
destPath,
methodPreference: methodPreference ?? "auto",
});
return response.data;
} catch (error) {
handleApiError(error, "preview transfer method");
throw error;
}
}
export interface TransferHopMetrics {
id: string;
mbPerSec?: number;
}
export interface TransferTimings {
prepareDestMs?: number;
compressMs?: number;
transferMs?: number;
extractMs?: number;
verifyMs?: number;
directBenchmarkMs?: number;
relayBenchmarkMs?: number;
sourceDeleteMs?: number;
totalMs?: number;
transferBytes?: number;
endToEndMbPerSec?: number;
hops?: TransferHopMetrics[];
}
export function getTransferProgressPercent(
status: TransferProgressResponse,
): number | undefined {
if (
status.bytesTransferred !== undefined &&
status.totalBytes !== undefined &&
status.totalBytes > 0
) {
return Math.min(
100,
Math.round((status.bytesTransferred / status.totalBytes) * 100),
);
}
if (
status.itemsCompleted !== undefined &&
status.totalItems !== undefined &&
status.totalItems > 0
) {
return Math.min(
100,
Math.round((status.itemsCompleted / status.totalItems) * 100),
);
}
return undefined;
}
export function formatTransferMbPerSec(
mbPerSec?: number,
_bytes?: number,
_ms?: number,
): string {
if (mbPerSec === undefined || mbPerSec <= 0) return "";
if (mbPerSec < 1) return `${(mbPerSec * 1024).toFixed(0)} KB/s`;
return `${mbPerSec.toFixed(1)} MB/s`;
}
export function formatDurationMs(ms?: number): string {
if (ms === undefined) return "";
if (ms < 1000) return `${ms}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
const minutes = Math.floor(ms / 60000);
const seconds = Math.round((ms % 60000) / 1000);
return `${minutes}m ${seconds}s`;
}
interface TransferProgressTracker {
update(status: TransferProgressResponse): {
rate: number | undefined;
stalled: boolean;
};
}
export function createTransferProgressTracker(): TransferProgressTracker {
let lastBytes: number | undefined;
let lastTime: number | undefined;
let lastRate: number | undefined;
let lastActivityTime: number | undefined;
const STALL_THRESHOLD_MS = 5000;
return {
update(status) {
const now = Date.now();
const bytes = status.bytesTransferred;
if (
bytes !== undefined &&
lastBytes !== undefined &&
lastTime !== undefined
) {
const deltaBytes = bytes - lastBytes;
const deltaMs = now - lastTime;
if (deltaMs > 0 && deltaBytes >= 0) {
lastRate = (deltaBytes / deltaMs / 1024 / 1024) * 1000;
if (deltaBytes > 0) lastActivityTime = now;
}
} else if (bytes !== undefined) {
lastActivityTime = now;
}
lastBytes = bytes;
lastTime = now;
const stalled =
lastActivityTime !== undefined &&
now - lastActivityTime > STALL_THRESHOLD_MS &&
status.status === "running" &&
status.phase === "transferring";
return { rate: lastRate, stalled };
},
};
}
export interface TransferProgressResponse {
transferId: string;
status: "running" | "success" | "partial" | "error" | "cancelled";
phase:
| "compressing"
| "transferring"
| "benchmarking"
| "verifying"
| "extracting"
| "reconnecting";
bytesTransferred?: number;
totalBytes?: number;
itemsCompleted?: number;
totalItems?: number;
failedPaths?: string[];
message?: string;
method?: "stream" | "tar" | "item_sftp" | "direct_rsync";
sourcePaths?: string[];
destPath?: string;
sourceSessionId?: string;
destSessionId?: string;
startedAt?: number;
timings?: TransferTimings;
sourceDeleted?: boolean;
moveRequested?: boolean;
partialDestRemaining?: boolean;
cleanupCompleted?: boolean;
retryable?: boolean;
integrityVerified?: boolean;
parallelSegmentCount?: number;
}
export async function transferToHost(
sourceSessionId: string,
sourcePaths: string[],
destSessionId: string,
destPath: string,
move?: boolean,
methodPreference?: TransferMethodPreference,
parallelSegmentCount?: number,
): Promise<{ transferId: string }> {
try {
fileLogger.info("Starting host transfer", {
operation: "host_transfer",
sourceSessionId,
destSessionId,
sourcePaths,
destPath,
move,
methodPreference,
parallelSegmentCount,
});
const response = await fileManagerApi().post("/transferToHost", {
sourceSessionId,
sourcePaths,
destSessionId,
destPath,
move,
methodPreference: methodPreference ?? "auto",
parallelSegmentCount,
});
return response.data;
} catch (error) {
fileLogger.error("Failed to start host transfer", error, {
operation: "host_transfer",
sourceSessionId,
destSessionId,
sourcePaths,
});
handleApiError(error, "transfer to host");
throw error;
}
}
export async function getTransferStatus(
transferId: string,
): Promise<TransferProgressResponse> {
try {
const response = await fileManagerApi().get(
`/transferStatus/${transferId}`,
);
return response.data;
} catch (error) {
handleApiError(error, "get transfer status");
throw error;
}
}
export async function listActiveTransfers(): Promise<{
transfers: TransferProgressResponse[];
}> {
try {
const response = await fileManagerApi().get("/activeTransfers");
return response.data;
} catch (error) {
handleApiError(error, "list active transfers");
throw error;
}
}
export async function cancelTransferToHost(transferId: string): Promise<void> {
try {
await fileManagerApi().post(`/transferCancel/${transferId}`);
} catch (error) {
fileLogger.warn("Transfer cancel request failed (non-fatal)", {
operation: "host_transfer",
transferId,
error,
});
}
}
export async function cleanupCancelledTransfer(
transferId: string,
): Promise<{ removedPaths: string[]; failedPaths: string[] }> {
try {
const response = await fileManagerApi().post(
`/transferCleanup/${transferId}`,
);
return response.data;
} catch (error) {
handleApiError(error, "clean up cancelled transfer");
throw error;
}
}
export async function retryTransferToHost(
transferId: string,
): Promise<{ ok: boolean; transferId: string }> {
try {
const response = await fileManagerApi().post(
`/transferRetry/${transferId}`,
);
return response.data;
} catch (error) {
handleApiError(error, "retry transfer");
throw error;
}
}
export async function pollTransferUntilComplete(
transferId: string,
onProgress?: (status: TransferProgressResponse) => void,
intervalMs = 500,
): Promise<TransferProgressResponse> {
return new Promise((resolve, reject) => {
const poll = async () => {
try {
const status = await getTransferStatus(transferId);
onProgress?.(status);
if (
status.status === "success" ||
status.status === "partial" ||
status.status === "error" ||
status.status === "cancelled"
) {
resolve(status);
return;
}
setTimeout(poll, intervalMs);
} catch (err) {
reject(err);
}
};
void poll();
});
}
// ============================================================================
// FILE MANAGER DATA
// ============================================================================
export interface TransferDestination {
id: number;
userId: string;
sourceHostId: number;
destHostId: number;
destPath: string;
destPathLabel?: string;
lastUsed?: string;
}
export async function getTransferRecent(
sourceHostId: number,
): Promise<TransferDestination[]> {
try {
const response = await fileManagerApi().get("/transfer-recent", {
params: { sourceHostId },
});
return response.data;
} catch (error) {
handleApiError(error, "get transfer recent destinations");
throw error;
}
}
export async function addTransferRecent(
sourceHostId: number,
destHostId: number,
destPath: string,
destPathLabel?: string,
): Promise<Record<string, unknown>> {
try {
const response = await fileManagerApi().post("/transfer-recent", {
sourceHostId,
destHostId,
destPath,
destPathLabel,
});
return response.data;
} catch (error) {
handleApiError(error, "add transfer recent destination");
throw error;
}
}
@@ -2,7 +2,7 @@ import React from "react";
import AudioPlayer from "react-h5-audio-player";
import "react-h5-audio-player/lib/styles.css";
import { Music } from "lucide-react";
import { cn } from "@/lib/utils.ts";
import { cn } from "@termix/plugin-sdk/ui";
import { useTranslation } from "react-i18next";
interface FileItem {
@@ -1,5 +1,5 @@
import type React from "react";
import { cn } from "@/lib/utils.ts";
import { cn } from "@termix/plugin-sdk/ui";
interface ColumnResizeHandleProps {
onMouseDown: (event: React.MouseEvent) => void;
@@ -1,7 +1,7 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { Check } from "lucide-react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils.ts";
import { cn } from "@termix/plugin-sdk/ui";
import type { ResizableColumns } from "../hooks/useResizableColumns.ts";
interface ColumnVisibilityMenuProps {
@@ -18,7 +18,7 @@ import {
getSSHStatus,
connectSSH,
} from "../api/ssh-file-operations-api";
import type { FileItem, SSHHost } from "@/types";
import type { FileItem, SSHHost } from "../host-types";
interface DiffViewerProps {
file1: FileItem;
@@ -3,7 +3,7 @@ import { DraggableWindow } from "./DraggableWindow.tsx";
import { DiffViewer } from "./DiffViewer.tsx";
import { useWindowManager } from "./WindowManager.tsx";
import { useTranslation } from "react-i18next";
import type { FileItem, SSHHost } from "@/types/index";
import type { FileItem, SSHHost } from "../host-types";
interface DiffWindowProps {
windowId: string;
@@ -1,4 +1,4 @@
import { formatTransferMbPerSec } from "@/main-axios.ts";
import { formatTransferMbPerSec } from "../api/transfer-api";
import { useTranslation } from "react-i18next";
import { ArrowDownToLine } from "lucide-react";
import { TransferProgressBar } from "./TransferProgressBar";
@@ -1,5 +1,5 @@
import React, { useState, useRef, useCallback, useEffect } from "react";
import { cn } from "@/lib/utils.ts";
import { cn } from "@termix/plugin-sdk/ui";
import { Minus, X, Maximize2, Minimize2 } from "lucide-react";
import { useTranslation } from "react-i18next";
@@ -1,6 +1,6 @@
/* eslint-disable react-hooks/exhaustive-deps */
import React, { Suspense, lazy, useState, useEffect, useRef } from "react";
import { cn } from "@/lib/utils.ts";
import { cn } from "@termix/plugin-sdk/ui";
import { useTranslation } from "react-i18next";
import {
FileText,
@@ -43,7 +43,7 @@ import {
SiDocker,
} from "react-icons/si";
import { Button } from "@termix/plugin-sdk/ui";
import { Kbd, KbdKey } from "@/components/kbd.tsx";
import { Kbd, KbdKey } from "@termix/plugin-sdk/ui";
import type { CodeEditorHandle } from "./CodeEditor.tsx";
import {
loadAudioPreview,
@@ -11,7 +11,7 @@ import {
connectSSH,
} from "../api/ssh-file-operations-api";
import { toast } from "sonner";
import type { SSHHost } from "@/types/index";
import type { SSHHost } from "../host-types";
import { useTranslation } from "react-i18next";
interface FileItem {
@@ -0,0 +1,668 @@
"use client";
import React, {
useState,
useCallback,
createContext,
useContext,
useRef,
useEffect,
} from "react";
import {
motion,
AnimatePresence,
easeInOut,
type Variants,
} from "motion/react";
import {
ChevronRight,
Folder,
FolderOpen,
File,
type LucideIcon,
} from "lucide-react";
import { cn } from "@termix/plugin-sdk/ui";
const animationVariants: Variants = {
rootInitial: { opacity: 0, y: 20 },
rootAnimate: { opacity: 1, y: 0 },
itemInitial: { opacity: 0, x: -10 },
itemAnimate: { opacity: 1, x: 0 },
contentHidden: { opacity: 0, height: 0 },
contentVisible: { opacity: 1, height: "auto" },
chevronClosed: { rotate: 0 },
chevronOpen: { rotate: 90 },
};
const transitions = {
root: { duration: 0.4 },
item: { duration: 0.2 },
content: { duration: 0.3, ease: easeInOut },
chevron: { duration: 0.2 },
};
interface ExpansionContextType {
expandedIds: Set<string>;
toggleExpanded: (id: string) => void;
}
interface SelectionContextType {
selectedId: string | null;
setSelected: (id: string) => void;
onSelect?: (id: string, label: string) => void;
}
interface TreeContextType {
focusedId: string | null;
setFocusedId: (id: string | null) => void;
treeId: string;
setKeyboardMode: (mode: boolean) => void;
keyboardMode: boolean;
}
interface LevelContextType {
level: number;
}
const ExpansionContext = createContext<ExpansionContextType | null>(null);
const SelectionContext = createContext<SelectionContextType | null>(null);
const TreeContext = createContext<TreeContextType | null>(null);
const LevelContext = createContext<LevelContextType>({ level: 0 });
const useExpansion = () => {
const context = useContext(ExpansionContext);
if (!context) {
throw new Error(
"FolderTree components must be used within FolderTree.Root",
);
}
return context;
};
const useSelection = () => {
const context = useContext(SelectionContext);
if (!context) {
throw new Error(
"FolderTree components must be used within FolderTree.Root",
);
}
return context;
};
const useTree = () => {
const context = useContext(TreeContext);
if (!context) {
throw new Error(
"FolderTree components must be used within FolderTree.Root",
);
}
return context;
};
const useLevel = () => {
return useContext(LevelContext);
};
const getPaddingClass = (level: number): string => {
const paddingMap: Record<number, string> = {
0: "pl-3",
1: "pl-8",
2: "pl-12",
3: "pl-16",
4: "pl-20",
5: "pl-24",
6: "pl-28",
7: "pl-32",
};
return paddingMap[level] || `pl-[${Math.min(level * 4 + 12, 48)}px]`;
};
interface CustomBadge {
content: React.ReactNode;
className?: string;
ariaLabel?: string;
}
interface RootProps {
defaultExpanded?: string[];
defaultSelected?: string;
selectedId?: string | null;
expandedIds?: Set<string>;
onSelect?: (id: string, label: string) => void;
className?: string;
children: React.ReactNode;
id?: string;
}
interface ItemProps {
id: string;
label: string;
icon?: LucideIcon;
badge?: string | number;
modified?: boolean | CustomBadge;
untracked?: boolean | CustomBadge;
className?: string;
children?: React.ReactNode;
}
interface TriggerProps {
className?: string;
}
interface ContentProps {
children: React.ReactNode;
className?: string;
}
const Root: React.FC<RootProps> = ({
defaultExpanded = [],
defaultSelected,
selectedId: controlledSelectedId,
expandedIds: additionalExpandedIds,
onSelect,
className = "",
children,
id = "folder-tree",
}) => {
const [expandedIds, setExpandedIds] = useState<Set<string>>(
new Set(defaultExpanded),
);
const [internalSelectedId, setInternalSelectedId] = useState<string | null>(
defaultSelected || null,
);
const selectedId =
controlledSelectedId !== undefined
? controlledSelectedId
: internalSelectedId;
const [focusedId, setFocusedId] = useState<string | null>(null);
const [keyboardMode, setKeyboardMode] = useState(false);
const treeRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!additionalExpandedIds || additionalExpandedIds.size === 0) return;
setExpandedIds((prev) => {
const merged = new Set(prev);
let changed = false;
for (const id of additionalExpandedIds) {
if (!merged.has(id)) {
merged.add(id);
changed = true;
}
}
return changed ? merged : prev;
});
}, [additionalExpandedIds]);
const toggleExpanded = useCallback((id: string) => {
setExpandedIds((prev) => {
const newSet = new Set(prev);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
return newSet;
});
}, []);
const setSelected = useCallback((id: string) => {
setInternalSelectedId(id);
}, []);
const getVisibleItemIds = useCallback(() => {
const items = Array.from(
treeRef.current?.querySelectorAll('[role="treeitem"]') || [],
);
return items
.filter((item) => {
const element = item as HTMLElement;
return element.offsetHeight > 0 && element.offsetWidth > 0;
})
.map((item) => item.getAttribute("data-id"))
.filter(Boolean) as string[];
}, []);
const [treeHasFocus, setTreeHasFocus] = useState(false);
const handleTreeFocus = useCallback(() => {
if (!treeHasFocus) {
setTreeHasFocus(true);
setKeyboardMode(true);
}
}, [treeHasFocus]);
const handleTreeBlur = useCallback((e: React.FocusEvent) => {
if (!treeRef.current?.contains(e.relatedTarget as Node)) {
setTreeHasFocus(false);
setFocusedId(null);
setKeyboardMode(false);
}
}, []);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
const getVisibleItems = () => {
return Array.from(
treeRef.current?.querySelectorAll('[role="treeitem"]') || [],
).filter((item) => {
const element = item as HTMLElement;
return element.offsetHeight > 0 && element.offsetWidth > 0;
});
};
if (e.key === "Tab") {
if (treeHasFocus && !focusedId) {
const visibleItemIds = getVisibleItemIds();
if (visibleItemIds.length > 0) {
setFocusedId(visibleItemIds[0]);
e.preventDefault();
return;
}
}
if (focusedId) {
const visibleItems = getVisibleItems();
const currentIndex = visibleItems.findIndex(
(item) => item.getAttribute("data-id") === focusedId,
);
if (e.shiftKey) {
if (currentIndex === 0) {
setFocusedId(null);
setTreeHasFocus(false);
setKeyboardMode(false);
return;
}
const nextIndex = Math.max(0, currentIndex - 1);
const nextItem = visibleItems[nextIndex] as HTMLElement;
const nextId = nextItem?.getAttribute("data-id");
if (nextId) {
setFocusedId(nextId);
e.preventDefault();
}
} else {
if (currentIndex === visibleItems.length - 1) {
setFocusedId(null);
setTreeHasFocus(false);
setKeyboardMode(false);
return;
}
const nextIndex = Math.min(
visibleItems.length - 1,
currentIndex + 1,
);
const nextItem = visibleItems[nextIndex] as HTMLElement;
const nextId = nextItem?.getAttribute("data-id");
if (nextId) {
setFocusedId(nextId);
e.preventDefault();
}
}
}
return;
}
if (!keyboardMode || !focusedId) return;
const visibleItems = getVisibleItems();
const currentIndex = visibleItems.findIndex(
(item) => item.getAttribute("data-id") === focusedId,
);
switch (e.key) {
case "ArrowDown":
e.preventDefault();
if (currentIndex < visibleItems.length - 1) {
const nextItem = visibleItems[currentIndex + 1] as HTMLElement;
const nextId = nextItem.getAttribute("data-id");
if (nextId) setFocusedId(nextId);
}
break;
case "ArrowUp":
e.preventDefault();
if (currentIndex > 0) {
const prevItem = visibleItems[currentIndex - 1] as HTMLElement;
const prevId = prevItem.getAttribute("data-id");
if (prevId) setFocusedId(prevId);
}
break;
case "ArrowRight":
e.preventDefault();
if (!expandedIds.has(focusedId)) {
toggleExpanded(focusedId);
}
break;
case "ArrowLeft":
e.preventDefault();
if (expandedIds.has(focusedId)) {
toggleExpanded(focusedId);
}
break;
case "Enter":
case " ":
e.preventDefault();
setSelected(focusedId);
if (onSelect) {
const currentItem = visibleItems[currentIndex] as HTMLElement;
const label =
currentItem.querySelector("span:nth-of-type(2)")?.textContent ||
"";
onSelect(focusedId, label);
}
break;
}
},
[
focusedId,
keyboardMode,
expandedIds,
toggleExpanded,
setSelected,
onSelect,
getVisibleItemIds,
treeHasFocus,
],
);
useEffect(() => {
const handleMouseDown = () => setKeyboardMode(false);
document.addEventListener("mousedown", handleMouseDown);
return () => {
document.removeEventListener("mousedown", handleMouseDown);
};
}, []);
const expansionValue: ExpansionContextType = {
expandedIds,
toggleExpanded,
};
const selectionValue: SelectionContextType = {
selectedId,
setSelected,
onSelect,
};
const treeValue: TreeContextType = {
focusedId,
setFocusedId,
treeId: id,
setKeyboardMode,
keyboardMode,
};
return (
<ExpansionContext.Provider value={expansionValue}>
<SelectionContext.Provider value={selectionValue}>
<TreeContext.Provider value={treeValue}>
<LevelContext.Provider value={{ level: 0 }}>
<motion.div
ref={treeRef}
variants={animationVariants}
initial="rootInitial"
animate="rootAnimate"
transition={transitions.root}
className={cn(
"bg-canvas border border-edge rounded-lg overflow-hidden",
className,
)}
role="tree"
aria-labelledby={`${id}-label`}
tabIndex={0}
onKeyDown={handleKeyDown}
onFocus={handleTreeFocus}
onBlur={handleTreeBlur}
>
<div className="w-full overflow-y-auto bg-canvas text-sm">
{children}
</div>
</motion.div>
</LevelContext.Provider>
</TreeContext.Provider>
</SelectionContext.Provider>
</ExpansionContext.Provider>
);
};
const ItemContext = createContext<{
itemId: string;
hasChildren: boolean;
isExpanded: boolean;
toggleExpanded: () => void;
} | null>(null);
const Item: React.FC<ItemProps> = ({
id,
label,
icon,
badge,
modified,
untracked,
className = "",
children,
}) => {
const expansionContext = useExpansion();
const selectionContext = useSelection();
const treeContext = useTree();
const { level } = useLevel();
const itemRef = useRef<HTMLDivElement>(null);
const keyboardMode = treeContext.keyboardMode;
const hasChildren = React.Children.count(children) > 0;
const isExpanded = expansionContext.expandedIds.has(id);
const isSelected = selectionContext.selectedId === id;
const isFocused = treeContext.focusedId === id;
const handleItemClick = useCallback(() => {
treeContext.setKeyboardMode(false);
selectionContext.setSelected(id);
treeContext.setFocusedId(id);
if (selectionContext.onSelect) {
selectionContext.onSelect(id, label);
}
}, [id, label, selectionContext, treeContext]);
const toggleExpanded = useCallback(() => {
if (hasChildren) {
expansionContext.toggleExpanded(id);
}
}, [id, hasChildren, expansionContext]);
const handleFocus = useCallback(() => {
treeContext.setFocusedId(id);
}, [id, treeContext]);
useEffect(() => {
if (isFocused && itemRef.current) {
itemRef.current.focus();
}
}, [isFocused]);
const IconComponent =
icon || (hasChildren ? (isExpanded ? FolderOpen : Folder) : File);
const itemContextValue = {
itemId: id,
hasChildren,
isExpanded,
toggleExpanded,
};
const renderBadge = (
badgeData: boolean | CustomBadge | undefined,
defaultContent: string,
defaultClassName: string,
) => {
if (!badgeData) return null;
if (typeof badgeData === "boolean") {
return (
<span
className={defaultClassName}
aria-label={`${defaultContent} status`}
>
{defaultContent}
</span>
);
}
return (
<span
className={cn(
"ml-auto text-xs px-2 py-0.5 rounded-full",
badgeData.className,
)}
aria-label={badgeData.ariaLabel || `Custom badge: ${badgeData.content}`}
>
{badgeData.content}
</span>
);
};
return (
<ItemContext.Provider value={itemContextValue}>
<LevelContext.Provider value={{ level: level + 1 }}>
<div>
<motion.div
ref={itemRef}
variants={animationVariants}
initial="itemInitial"
animate="itemAnimate"
transition={{ ...transitions.item, delay: level * 0.05 }}
data-selected={isSelected ? "true" : "false"}
data-id={id}
className={cn(
"flex items-center gap-2 py-1.5 text-sm transition-colors cursor-pointer select-none",
getPaddingClass(level),
className,
isSelected
? "bg-accent text-accent-foreground border-r-2 border-ring"
: "",
!isSelected && "hover:bg-hover",
keyboardMode && isFocused
? "focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-inset"
: "focus:outline-hidden",
)}
onClick={(e: React.MouseEvent) => {
handleItemClick();
e.stopPropagation();
toggleExpanded();
}}
onFocus={handleFocus}
role="treeitem"
tabIndex={isFocused ? 0 : -1}
aria-expanded={hasChildren ? isExpanded : undefined}
aria-selected={isSelected}
aria-label={`${hasChildren ? "Folder" : "File"}: ${label}`}
aria-level={level + 1}
>
{hasChildren && (
<motion.span
className="shrink-0 cursor-pointer"
variants={animationVariants}
animate={isExpanded ? "chevronOpen" : "chevronClosed"}
transition={transitions.chevron}
aria-hidden="true"
>
<ChevronRight size={14} className="text-muted-foreground" />
</motion.span>
)}
{!hasChildren && <span className="w-3 mr-2" aria-hidden="true" />}
{IconComponent && (
<IconComponent
size={16}
data-selected={isSelected ? "true" : "false"}
data-child={hasChildren ? "true" : "false"}
className={cn(
"mr-1 shrink-0 text-muted-foreground data-[child=true]:text-primary data-[selected=true]:text-accent-foreground",
)}
aria-hidden="true"
/>
)}
<span className="flex-1">{label}</span>
{badge && (
<span
className="ml-auto text-xs bg-muted text-muted-foreground px-2 py-0.5 rounded-full"
aria-label={`Badge: ${badge}`}
>
{badge}
</span>
)}
{renderBadge(
modified,
"M",
"ml-auto text-xs bg-yellow-200 dark:bg-yellow-700 text-yellow-800 dark:text-yellow-200 px-2 py-0.5 rounded-full",
)}
{renderBadge(
untracked,
"U",
"ml-auto text-xs bg-green-200 dark:bg-green-700 text-green-800 dark:text-green-200 px-2 py-0.5 rounded-full",
)}
</motion.div>
{children}
</div>
</LevelContext.Provider>
</ItemContext.Provider>
);
};
const Trigger: React.FC<TriggerProps> = ({ className = "" }) => {
const itemContext = useContext(ItemContext);
if (!itemContext || !itemContext.hasChildren) {
return null;
}
return (
<motion.span
className={cn("mr-2 shrink-0 cursor-pointer", className)}
variants={animationVariants}
animate={itemContext.isExpanded ? "chevronOpen" : "chevronClosed"}
transition={transitions.chevron}
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
itemContext.toggleExpanded();
}}
role="button"
aria-label={itemContext.isExpanded ? "Collapse" : "Expand"}
tabIndex={-1}
>
<ChevronRight size={14} className="text-muted-foreground" />
</motion.span>
);
};
const Content: React.FC<ContentProps> = ({ children, className = "" }) => {
const itemContext = useContext(ItemContext);
if (!itemContext) {
return <>{children}</>;
}
const hasContent = React.Children.count(children) > 0;
return (
<AnimatePresence>
{hasContent && itemContext.isExpanded && (
<motion.div
variants={animationVariants}
initial="contentHidden"
animate="contentVisible"
exit="contentHidden"
transition={transitions.content}
style={{ overflow: "hidden" }}
className={className}
role="group"
>
{children}
</motion.div>
)}
</AnimatePresence>
);
};
const FolderTree = {
Root,
Item,
Trigger,
Content,
};
export default FolderTree;
@@ -1,5 +1,5 @@
import type React from "react";
import { cn } from "@/lib/utils.ts";
import { cn } from "@termix/plugin-sdk/ui";
interface PaneResizeHandleProps {
label: string;
@@ -4,7 +4,7 @@ import type { TabHandle as TerminalHandle } from "@termix/plugin-sdk/frontend";
import { PluginComponent } from "@termix/plugin-sdk/ui";
import { useWindowManager } from "./WindowManager.tsx";
import { useTranslation } from "react-i18next";
import type { SSHHost } from "@/types/index.ts";
import type { SSHHost } from "../host-types";
import { ExternalLink } from "lucide-react";
interface TerminalWindowProps {
@@ -1,6 +1,6 @@
import type { CSSProperties } from "react";
import { cn } from "@/lib/utils";
import { cn } from "@termix/plugin-sdk/ui";
export function TransferProgressBar({
value,
@@ -3,7 +3,7 @@ import {
formatTransferMbPerSec,
getTransferProgressPercent,
type TransferProgressResponse,
} from "@/main-axios.ts";
} from "../api/transfer-api";
import { useTranslation } from "react-i18next";
import {
Archive,
@@ -31,13 +31,13 @@ import {
Trash2,
} from "lucide-react";
import {
getSSHHosts,
getTransferRecent,
getTransferMethodPreview,
type TransferDestination,
type TransferMethodPreference,
type TransferMethodPreview,
} from "@/main-axios.ts";
} from "../api/transfer-api";
import { getSSHHosts } from "../api/hosts";
import {
getSSHStatus,
browseSSHDirectory,
@@ -49,7 +49,7 @@ import {
addFolderShortcut,
removeFolderShortcut,
} from "../api/file-manager-data-api";
import type { SSHHost } from "@/types";
import type { SSHHost } from "../host-types";
interface FileItem {
name: string;
@@ -1,5 +1,5 @@
import type { FileItem, SSHHost } from "@/types/index";
import type { LogEntry } from "@/types/connection-log.ts";
import type { FileItem, SSHHost } from "./host-types";
import type { LogEntry } from "@termix/plugin-sdk/ui";
export interface FileManagerProps {
initialHost?: SSHHost | null;
@@ -1,7 +1,7 @@
import {
markAdaptiveResourceUsed,
runAdaptiveBackgroundTask,
} from "@/lib/adaptive-resource-budget";
} from "@termix/plugin-sdk/ui";
export type FilePreviewKind =
| "image"
@@ -6,7 +6,7 @@ import type {
FileManagerWidgetConfig,
WidgetComponentProps,
} from "./homepage.js";
import type { SSHHost } from "@/types/index";
import type { SSHHost } from "../host-types";
import { FileManager } from "../FileManager";
export function FileManagerWidget({
@@ -1,7 +1,7 @@
import { useState, useCallback } from "react";
import { toast } from "sonner";
import { downloadSSHFile } from "../api/ssh-file-operations-api";
import type { FileItem, SSHHost } from "@/types/index.js";
import type { FileItem, SSHHost } from "../host-types";
interface DragToDesktopState {
isDragging: boolean;
@@ -2,7 +2,7 @@
import { useState, useCallback, useRef } from "react";
import { toast } from "sonner";
import { downloadSSHFile } from "../api/ssh-file-operations-api";
import type { FileItem, SSHHost } from "@/types/index.js";
import type { FileItem, SSHHost } from "../host-types";
interface DragToSystemState {
isDragging: boolean;
@@ -2,8 +2,8 @@ import { useCallback, useRef } from "react";
import { createElement } from "react";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import type { FileItem } from "@/types/index";
import type { LocalWalkResult } from "@/types/electron";
import type { FileItem } from "../host-types";
import type { LocalWalkResult } from "@termix/plugin-sdk/ui";
import {
cancelLocalTransfer,
createLocalTransferId,
@@ -16,7 +16,7 @@ import {
getLocalHome,
localPathsExist,
walkLocalPaths,
} from "@/lib/local-files.ts";
} from "../lib/local-files";
import {
UnsafeLocalNameError,
buildLocalDestination,
@@ -0,0 +1,57 @@
/** A host as the shell hands it to the file manager. */
export interface SSHHost {
id: number;
name: string;
ip: string;
port: number;
username: string;
folder: string;
tags: string[];
pin: boolean;
authType: string;
password?: string;
key?: string;
keyPassword?: string;
keyType?: string;
sudoPassword?: string;
forceKeyboardInteractive?: boolean;
credentialId?: number;
overrideCredentialUsername?: boolean;
userId?: string;
jumpHosts?: Array<{ hostId: number }>;
terminalConfig?: Record<string, unknown>;
notes?: string;
useSocks5?: boolean;
socks5Host?: string;
socks5Port?: number;
socks5Username?: string;
socks5Password?: string;
socks5ProxyChain?: unknown[];
connectionType?: string;
enableSsh?: boolean;
syncId?: string | null;
createdAt: string;
updatedAt: string;
connectionOrigin?: "local" | "remote" | null;
instanceId?: string;
isShared?: boolean;
permissionLevel?: "connect" | "view" | "edit" | "manage";
/** Enabled plugins' host-scope settings, keyed by plugin id. */
pluginSettings?: Record<string, Record<string, unknown>>;
}
export interface FileItem {
name: string;
path: string;
isPinned?: boolean;
type: "file" | "directory" | "link";
sshSessionId?: string;
size?: number;
modified?: string;
modifiedTimestamp?: number;
permissions?: string;
owner?: string;
group?: string;
linkTarget?: string;
executable?: boolean;
}
+9 -4
View File
@@ -15,6 +15,7 @@ import { SftpTransferTab } from "./SftpTransferTab.tsx";
import { FileManagerWidget } from "./homepage/FileManagerWidget.tsx";
import { FileManagerWidgetEditForm } from "./homepage/FileManagerWidgetEditForm.tsx";
import { startTransferMonitor } from "./TransferMonitor.tsx";
import { setFileManagerApp } from "./api/client";
function FilesTab({ tab, host, sshHost, isVisible }: TabProps) {
const data = tab.data as
@@ -66,6 +67,8 @@ function openEditorAction(
}
export function activate(app: TermixApp): void {
setFileManagerApp(app);
app.onDispose(() => setFileManagerApp(null));
app.registerTab("files", FilesTab as unknown as ComponentType<TabProps>, {
icon: FolderSearch,
titleKey: "nav.files",
@@ -104,6 +107,7 @@ export function activate(app: TermixApp): void {
order: 20,
tabType: "files",
copyUrlView: "file-manager",
quickConnect: true,
when: (host) =>
!!host.enableSsh &&
fileManagerHostSetting(host, "enableFileManager", true),
@@ -122,10 +126,11 @@ export function activate(app: TermixApp): void {
editFormComponent: FileManagerWidgetEditForm as never,
});
app.registerAction("files.openHost", ((
host: PluginHostRecord | null,
path?: string,
) => openHostAction(app, host, path)) as never);
const openHost = ((host: PluginHostRecord | null, path?: string) =>
openHostAction(app, host, path)) as never;
app.registerAction("files.openHost", openHost);
// The shell's "open in file manager" tab button asks for this.
app.registerAction("host.openFiles", openHost);
app.registerAction("files.openEditor", ((
host: PluginHostRecord | null,
filePath: string,
@@ -0,0 +1,14 @@
/**
* Extract a stable, human-readable message from an unknown thrown value.
*
* A thrown value is not guaranteed to be an `Error` — libraries may throw
* strings or plain objects, which carry no useful `.message`. When that
* happens the caller gets `fallback` instead, so error paths never surface a
* raw `[object Object]` in logs or UI.
*/
export function getErrorMessage(
error: unknown,
fallback = "Unknown error",
): string {
return error instanceof Error ? error.message : fallback;
}
@@ -1,5 +1,5 @@
import type { FileItem } from "@/types/index";
import { createKeyedRequestCache } from "@/lib/keyed-request-cache";
import type { FileItem } from "../host-types";
import { createKeyedRequestCache } from "./keyed-request-cache";
export interface FileContentResult {
content: string;
@@ -1,5 +1,5 @@
import type { FileItem } from "@/types/index";
import { createKeyedRequestCache } from "@/lib/keyed-request-cache";
import type { FileItem } from "../host-types";
import { createKeyedRequestCache } from "./keyed-request-cache";
export interface FileListResult {
files: FileItem[];
@@ -0,0 +1,22 @@
/**
* The shape an axios rejection actually has at the call sites that inspect it.
* A `catch` binding is `unknown`, and every one of these sites was reaching
* into it directly; this gives them one narrowing helper instead.
*/
export interface HttpLikeError {
message?: string;
code?: string;
response?: {
status?: number;
data?: {
error?: string;
message?: string;
code?: string;
fileNotFound?: boolean;
};
};
}
export function asHttpError(error: unknown): HttpLikeError {
return (error ?? {}) as HttpLikeError;
}
@@ -0,0 +1,82 @@
export interface KeyedRequestCache<T> {
get(
key: string,
loader: () => Promise<T>,
options?: { force?: boolean },
): Promise<T>;
peek(key: string, maxAgeMs?: number): T | null;
set(key: string, value: T): void;
invalidate(key?: string): void;
}
/** Bounded per-key cache with in-flight request coalescing. */
export function createKeyedRequestCache<T>(
ttlMs: number,
maxEntries = 100,
): KeyedRequestCache<T> {
const values = new Map<string, { value: T; storedAt: number }>();
const inflight = new Map<string, Promise<T>>();
const versions = new Map<string, number>();
const store = (key: string, value: T) => {
values.delete(key);
values.set(key, { value, storedAt: Date.now() });
while (values.size > Math.max(1, maxEntries)) {
const oldest = values.keys().next().value;
if (oldest === undefined) break;
values.delete(oldest);
}
};
return {
get(key, loader, options = {}) {
const cached = values.get(key);
if (!options.force && cached && Date.now() - cached.storedAt < ttlMs) {
return Promise.resolve(cached.value);
}
const pending = inflight.get(key);
if (pending) return pending;
const version = versions.get(key) ?? 0;
const request = loader()
.then((value) => {
if ((versions.get(key) ?? 0) === version) store(key, value);
return value;
})
.finally(() => {
if (inflight.get(key) === request) inflight.delete(key);
});
inflight.set(key, request);
return request;
},
peek(key, maxAgeMs = Number.POSITIVE_INFINITY) {
const cached = values.get(key);
if (!cached || Date.now() - cached.storedAt > maxAgeMs) return null;
values.delete(key);
values.set(key, cached);
return cached.value;
},
set(key, value) {
versions.set(key, (versions.get(key) ?? 0) + 1);
inflight.delete(key);
store(key, value);
},
invalidate(key) {
if (key === undefined) {
for (const activeKey of inflight.keys()) {
versions.set(activeKey, (versions.get(activeKey) ?? 0) + 1);
}
values.clear();
inflight.clear();
} else {
versions.set(key, (versions.get(key) ?? 0) + 1);
values.delete(key);
inflight.delete(key);
}
},
};
}
@@ -0,0 +1,105 @@
// Thin, typed access to the desktop app's local filesystem bridge
// (electron/local-files.cjs via preload.js). Everything here resolves to
// "unavailable" outside Electron so callers can feature-detect cheaply.
import type {
LocalDirectoryListing,
LocalFsHomeInfo,
LocalFsResult,
LocalTrashResult,
LocalWalkResult,
} from "@termix/plugin-sdk/ui";
import { isElectron } from "@termix/plugin-sdk/ui";
export function isLocalFileBrowserAvailable(): boolean {
if (!isElectron()) return false;
const api = window.electronAPI;
return !!api?.localFs && !!api?.localTransfer;
}
function requireLocalFs() {
const api = window.electronAPI?.localFs;
if (!api) {
throw new Error("Local file access is only available in the desktop app");
}
return api;
}
function unwrap<T>(result: LocalFsResult<T>): T {
if (!result || result.success !== true) {
const message =
result && "error" in result && result.error
? result.error
: "Local file operation failed";
const error = new Error(message) as Error & { code?: string };
if (result && "code" in result) error.code = result.code;
throw error;
}
return result;
}
export async function getLocalHome(): Promise<LocalFsHomeInfo> {
return unwrap(await requireLocalFs().home());
}
export async function listLocalDirectory(
dirPath: string,
): Promise<LocalDirectoryListing> {
return unwrap(await requireLocalFs().list(dirPath));
}
export async function createLocalFolder(
parentPath: string,
name: string,
): Promise<string> {
return unwrap(await requireLocalFs().mkdir(parentPath, name)).path;
}
export async function createLocalFile(
parentPath: string,
name: string,
): Promise<string> {
return unwrap(await requireLocalFs().createFile(parentPath, name)).path;
}
export async function renameLocalEntry(
oldPath: string,
newName: string,
): Promise<string> {
return unwrap(await requireLocalFs().rename(oldPath, newName)).path;
}
/** Moves entries to the OS trash; resolves with per-path failures, if any. */
export async function trashLocalPaths(
paths: string[],
): Promise<LocalTrashResult> {
return unwrap(await requireLocalFs().trash(paths));
}
/** Subset of `paths` that already exist on disk. */
export async function localPathsExist(paths: string[]): Promise<string[]> {
if (paths.length === 0) return [];
return unwrap(await requireLocalFs().exists(paths)).existing;
}
/** Creates `dirPath` (and parents). With `rootPath`, refuses anything outside it. */
export async function ensureLocalDirectory(
dirPath: string,
rootPath?: string,
): Promise<string> {
return unwrap(await requireLocalFs().ensureDir(dirPath, rootPath)).path;
}
export async function walkLocalPaths(
paths: string[],
): Promise<LocalWalkResult> {
return unwrap(await requireLocalFs().walk(paths));
}
export async function revealLocalPath(targetPath: string): Promise<void> {
unwrap(await requireLocalFs().reveal(targetPath));
}
export async function openLocalPath(targetPath: string): Promise<void> {
unwrap(await requireLocalFs().open(targetPath));
}
@@ -1,7 +1,7 @@
// Pure helpers shared by the dual-pane (local <-> remote) file manager code.
// Kept free of React/DOM so they can be unit tested directly.
import type { LocalFileEntry } from "@/types/electron";
import type { LocalFileEntry } from "@termix/plugin-sdk/ui";
/** Custom MIME type carried by drags that originate in the local pane. */
export const LOCAL_FILES_DRAG_MIME = "application/x-termix-local-files";
@@ -1,4 +1,4 @@
import type { FileItem } from "@/types/index";
import type { FileItem } from "./host-types";
export function childPath(parent: string, name: string): string {
return parent === "/" ? `/${name}` : `${parent.replace(/\/$/, "")}/${name}`;
@@ -3,7 +3,7 @@ import {
formatDurationMs,
formatTransferMbPerSec,
type TransferTimings,
} from "@/main-axios.ts";
} from "./api/transfer-api";
export function createFormatTransferMetrics(t: TFunction) {
return (timings?: TransferTimings): string => {
@@ -1,4 +1,4 @@
import { getErrorMessage } from "@/lib/error-message.js";
import { getErrorMessage } from "./lib/error-message";
import { toast } from "sonner";
import type { TFunction } from "i18next";
import {
@@ -9,7 +9,7 @@ import {
createTransferProgressTracker,
type TransferProgressResponse,
type TransferTimings,
} from "@/main-axios.ts";
} from "./api/transfer-api";
import { TransferProgressToast } from "./components/TransferProgressToast.tsx";
import {
markTransferNotified,