feat: convert file manager into a plugin

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

No files matched your search

+117 -71
View File
@@ -48,7 +48,6 @@ import { AutoSSLSetup } from "../utils/auto-ssl-setup.js";
import {
createCurrentCredentialRepository,
createCurrentDismissedAlertRepository,
createCurrentFileManagerBookmarkRepository,
createCurrentHostRepository,
createCurrentSettingsRepository,
createCurrentSshCredentialUsageRepository,
@@ -63,7 +62,8 @@ import type {
GitHubAPIResponse,
AuthenticatedRequest,
} from "../../types/index.js";
import { DatabaseSaveTrigger } from "./db/index.js";
import { DatabaseSaveTrigger, getDb } from "./db/index.js";
import { sql } from "drizzle-orm";
import Database from "better-sqlite3";
import { fileURLToPath } from "url";
@@ -855,7 +855,7 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE file_manager_recent (
CREATE TABLE p_file_manager_recent (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
host_id INTEGER NOT NULL,
@@ -864,7 +864,7 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
last_opened TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE file_manager_pinned (
CREATE TABLE p_file_manager_pinned (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
host_id INTEGER NOT NULL,
@@ -873,7 +873,7 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
pinned_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE file_manager_shortcuts (
CREATE TABLE p_file_manager_shortcuts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
host_id INTEGER NOT NULL,
@@ -882,7 +882,7 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE transfer_recent (
CREATE TABLE p_file_manager_transfer_recent (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
source_host_id INTEGER NOT NULL,
@@ -1031,56 +1031,84 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
);
}
const fileManagerRepository =
createCurrentFileManagerBookmarkRepository();
// The file manager plugin owns these tables now (p_file_manager_*);
// read them by raw SQL rather than importing plugin code into core.
const drizzleDb = getDb();
const [recentFiles, pinnedFiles, shortcuts] = await Promise.all([
fileManagerRepository.listRecentByUserId(userId),
fileManagerRepository.listPinnedByUserId(userId),
fileManagerRepository.listShortcutsByUserId(userId),
drizzleDb.all<{
id: number;
user_id: string;
host_id: number;
name: string;
path: string;
last_opened: string;
}>(
sql`SELECT id, user_id, host_id, name, path, last_opened FROM p_file_manager_recent WHERE user_id = ${userId}`,
),
drizzleDb.all<{
id: number;
user_id: string;
host_id: number;
name: string;
path: string;
pinned_at: string;
}>(
sql`SELECT id, user_id, host_id, name, path, pinned_at FROM p_file_manager_pinned WHERE user_id = ${userId}`,
),
drizzleDb.all<{
id: number;
user_id: string;
host_id: number;
name: string;
path: string;
created_at: string;
}>(
sql`SELECT id, user_id, host_id, name, path, created_at FROM p_file_manager_shortcuts WHERE user_id = ${userId}`,
),
]);
const insertRecent = exportDb.prepare(`
INSERT INTO file_manager_recent (id, user_id, host_id, name, path, last_opened)
INSERT INTO p_file_manager_recent (id, user_id, host_id, name, path, last_opened)
VALUES (?, ?, ?, ?, ?, ?)
`);
for (const item of recentFiles) {
insertRecent.run(
item.id,
item.userId,
item.hostId,
item.user_id,
item.host_id,
item.name,
item.path,
item.lastOpened,
item.last_opened,
);
}
const insertPinned = exportDb.prepare(`
INSERT INTO file_manager_pinned (id, user_id, host_id, name, path, pinned_at)
INSERT INTO p_file_manager_pinned (id, user_id, host_id, name, path, pinned_at)
VALUES (?, ?, ?, ?, ?, ?)
`);
for (const item of pinnedFiles) {
insertPinned.run(
item.id,
item.userId,
item.hostId,
item.user_id,
item.host_id,
item.name,
item.path,
item.pinnedAt,
item.pinned_at,
);
}
const insertShortcut = exportDb.prepare(`
INSERT INTO file_manager_shortcuts (id, user_id, host_id, name, path, created_at)
INSERT INTO p_file_manager_shortcuts (id, user_id, host_id, name, path, created_at)
VALUES (?, ?, ?, ?, ?, ?)
`);
for (const item of shortcuts) {
insertShortcut.run(
item.id,
item.userId,
item.hostId,
item.user_id,
item.host_id,
item.name,
item.path,
item.createdAt,
item.created_at,
);
}
@@ -1445,70 +1473,88 @@ app.post(
);
}
// The file manager plugin owns these tables now (p_file_manager_*).
// Older export files still have the legacy name, so try that too.
const fileManagerTables = [
{
table: "file_manager_recent",
tableNames: ["p_file_manager_recent", "file_manager_recent"],
physicalTable: "p_file_manager_recent",
dateColumn: "last_opened",
key: "fileManagerItemsImported",
},
{
table: "file_manager_pinned",
tableNames: ["p_file_manager_pinned", "file_manager_pinned"],
physicalTable: "p_file_manager_pinned",
dateColumn: "pinned_at",
key: "fileManagerItemsImported",
},
{
table: "file_manager_shortcuts",
tableNames: [
"p_file_manager_shortcuts",
"file_manager_shortcuts",
],
physicalTable: "p_file_manager_shortcuts",
dateColumn: "created_at",
key: "fileManagerItemsImported",
},
];
const fileManagerRepository =
createCurrentFileManagerBookmarkRepository();
const importDrizzleDb = getDb();
for (const { table, key } of fileManagerTables) {
try {
const importedItems = importDb
.prepare(`SELECT * FROM ${table}`)
.all();
for (const item of importedItems) {
try {
const bookmark = {
hostId: item.host_id,
name: item.name,
path: item.path,
};
const created =
table === "file_manager_recent"
? await fileManagerRepository.createRecentForImport(
userId,
bookmark,
item.last_opened,
)
: table === "file_manager_pinned"
? await fileManagerRepository.createPinnedForImport(
userId,
bookmark,
item.pinned_at,
)
: await fileManagerRepository.createShortcutForImport(
userId,
bookmark,
item.created_at,
);
if (created) {
result.summary[key]++;
} else {
result.summary.skippedItems++;
}
} catch (itemError) {
result.summary.errors.push(
`${table} import error: ${itemError.message}`,
);
}
for (const {
tableNames,
physicalTable,
dateColumn,
key,
} of fileManagerTables) {
let importedItems: Array<Record<string, unknown>> | null = null;
let sourceTable = "";
for (const tableName of tableNames) {
try {
importedItems = importDb
.prepare(`SELECT * FROM ${tableName}`)
.all() as Array<Record<string, unknown>>;
sourceTable = tableName;
break;
} catch {
// try the next name
}
} catch {
}
if (importedItems === null) {
apiLogger.info(
`${table} table not found in import file, skipping`,
`${physicalTable} table not found in import file, skipping`,
);
continue;
}
for (const item of importedItems) {
try {
const hostId = item.host_id as number;
const path = item.path as string;
const name =
(item.name as string) || path.split("/").pop() || "Unknown";
const dateValue =
(item[dateColumn] as string) || new Date().toISOString();
const existing = await importDrizzleDb.all<{ id: number }>(
sql`SELECT id FROM ${sql.raw(physicalTable)} WHERE user_id = ${userId} AND host_id = ${hostId} AND path = ${path}`,
);
if (existing.length > 0) {
result.summary.skippedItems++;
continue;
}
await importDrizzleDb.run(
sql`INSERT INTO ${sql.raw(physicalTable)} (user_id, host_id, path, name, ${sql.raw(dateColumn)}) VALUES (${userId}, ${hostId}, ${path}, ${name}, ${dateValue})`,
);
result.summary[key]++;
} catch (itemError) {
result.summary.errors.push(
`${sourceTable} import error: ${itemError.message}`,
);
}
}
}
+4 -45
View File
@@ -350,51 +350,10 @@ async function initializeCompleteDatabase(): Promise<void> {
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS file_manager_recent (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
host_id INTEGER NOT NULL,
name TEXT NOT NULL,
path TEXT NOT NULL,
last_opened TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS file_manager_pinned (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
host_id INTEGER NOT NULL,
name TEXT NOT NULL,
path TEXT NOT NULL,
pinned_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS file_manager_shortcuts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
host_id INTEGER NOT NULL,
name TEXT NOT NULL,
path TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS transfer_recent (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
source_host_id INTEGER NOT NULL,
dest_host_id INTEGER NOT NULL,
dest_path TEXT NOT NULL,
dest_path_label TEXT NOT NULL,
last_used TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (source_host_id) REFERENCES ssh_data (id) ON DELETE CASCADE,
FOREIGN KEY (dest_host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
);
-- file_manager_recent, file_manager_pinned, file_manager_shortcuts and
-- transfer_recent are owned by the file-manager plugin now. Its own
-- migration creates p_file_manager_* on a fresh install and adopts the
-- legacy tables by rename on an upgrade.
CREATE TABLE IF NOT EXISTS dismissed_alerts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
+3 -20
View File
@@ -110,26 +110,9 @@ export const PERFORMANCE_INDEXES: PerformanceIndex[] = [
table: "command_history",
columns: "user_id, host_id",
},
{
name: "idx_file_manager_recent_user",
table: "file_manager_recent",
columns: "user_id, host_id",
},
{
name: "idx_file_manager_pinned_user",
table: "file_manager_pinned",
columns: "user_id, host_id",
},
{
name: "idx_file_manager_shortcuts_user",
table: "file_manager_shortcuts",
columns: "user_id, host_id",
},
{
name: "idx_transfer_recent_user",
table: "transfer_recent",
columns: "user_id",
},
// file_manager_recent, file_manager_pinned, file_manager_shortcuts and
// transfer_recent are owned by the file-manager plugin now; its adopted
// table definitions carry these same indexes across the rename.
{
name: "idx_user_open_tabs_user_id",
table: "user_open_tabs",
+2 -85
View File
@@ -383,91 +383,8 @@ export const hosts = mysqlTable(
],
);
export const fileManagerRecent = mysqlTable(
"file_manager_recent",
{
id: int("id").autoincrement().primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
hostId: int("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(),
path: text("path").notNull(),
lastOpened: text("last_opened")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
// Every file manager surface is read for one user on one host at a time.
(table) => [
index("idx_file_manager_recent_user").on(table.userId, table.hostId),
],
);
export const fileManagerPinned = mysqlTable(
"file_manager_pinned",
{
id: int("id").autoincrement().primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
hostId: int("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(),
path: text("path").notNull(),
pinnedAt: text("pinned_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
(table) => [
index("idx_file_manager_pinned_user").on(table.userId, table.hostId),
],
);
export const fileManagerShortcuts = mysqlTable(
"file_manager_shortcuts",
{
id: int("id").autoincrement().primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
hostId: int("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(),
path: text("path").notNull(),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
(table) => [
index("idx_file_manager_shortcuts_user").on(table.userId, table.hostId),
],
);
export const transferRecent = mysqlTable(
"transfer_recent",
{
id: int("id").autoincrement().primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
sourceHostId: int("source_host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
destHostId: int("dest_host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
destPath: text("dest_path").notNull(),
destPathLabel: text("dest_path_label").notNull(),
lastUsed: text("last_used")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
(table) => [index("idx_transfer_recent_user").on(table.userId)],
);
// file_manager_recent, file_manager_pinned, file_manager_shortcuts and
// transfer_recent moved to the file-manager plugin (p_file_manager_*).
export const dismissedAlerts = mysqlTable(
"dismissed_alerts",
+2 -85
View File
@@ -384,91 +384,8 @@ export const hosts = pgTable(
],
);
export const fileManagerRecent = pgTable(
"file_manager_recent",
{
id: serial("id").primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
hostId: integer("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(),
path: text("path").notNull(),
lastOpened: text("last_opened")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
// Every file manager surface is read for one user on one host at a time.
(table) => [
index("idx_file_manager_recent_user").on(table.userId, table.hostId),
],
);
export const fileManagerPinned = pgTable(
"file_manager_pinned",
{
id: serial("id").primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
hostId: integer("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(),
path: text("path").notNull(),
pinnedAt: text("pinned_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
index("idx_file_manager_pinned_user").on(table.userId, table.hostId),
],
);
export const fileManagerShortcuts = pgTable(
"file_manager_shortcuts",
{
id: serial("id").primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
hostId: integer("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(),
path: text("path").notNull(),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
index("idx_file_manager_shortcuts_user").on(table.userId, table.hostId),
],
);
export const transferRecent = pgTable(
"transfer_recent",
{
id: serial("id").primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
sourceHostId: integer("source_host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
destHostId: integer("dest_host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
destPath: text("dest_path").notNull(),
destPathLabel: text("dest_path_label").notNull(),
lastUsed: text("last_used")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [index("idx_transfer_recent_user").on(table.userId)],
);
// file_manager_recent, file_manager_pinned, file_manager_shortcuts and
// transfer_recent moved to the file-manager plugin (p_file_manager_*).
export const dismissedAlerts = pgTable(
"dismissed_alerts",
+2 -85
View File
@@ -376,91 +376,8 @@ export const hosts = sqliteTable(
],
);
export const fileManagerRecent = sqliteTable(
"file_manager_recent",
{
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
hostId: integer("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
name: text("name").notNull(),
path: text("path").notNull(),
lastOpened: text("last_opened")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
// Every file manager surface is read for one user on one host at a time.
(table) => [
index("idx_file_manager_recent_user").on(table.userId, table.hostId),
],
);
export const fileManagerPinned = sqliteTable(
"file_manager_pinned",
{
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
hostId: integer("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
name: text("name").notNull(),
path: text("path").notNull(),
pinnedAt: text("pinned_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
index("idx_file_manager_pinned_user").on(table.userId, table.hostId),
],
);
export const fileManagerShortcuts = sqliteTable(
"file_manager_shortcuts",
{
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
hostId: integer("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
name: text("name").notNull(),
path: text("path").notNull(),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
index("idx_file_manager_shortcuts_user").on(table.userId, table.hostId),
],
);
export const transferRecent = sqliteTable(
"transfer_recent",
{
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
sourceHostId: integer("source_host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
destHostId: integer("dest_host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
destPath: text("dest_path").notNull(),
destPathLabel: text("dest_path_label").notNull(),
lastUsed: text("last_used")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [index("idx_transfer_recent_user").on(table.userId)],
);
// file_manager_recent, file_manager_pinned, file_manager_shortcuts and
// transfer_recent moved to the file-manager plugin (p_file_manager_*).
export const dismissedAlerts = sqliteTable(
"dismissed_alerts",
+2 -14
View File
@@ -14,7 +14,6 @@ import { CommandHistoryRepository } from "./command-history-repository.js";
import { CredentialRepository } from "./credential-repository.js";
import { DashboardServiceLinkRepository } from "./dashboard-service-link-repository.js";
import { DismissedAlertRepository } from "./dismissed-alert-repository.js";
import { FileManagerBookmarkRepository } from "./file-manager-bookmark-repository.js";
import { HomepageItemRepository } from "./homepage-item-repository.js";
import { HomepageLayoutRepository } from "./homepage-layout-repository.js";
import { HostFolderRepository } from "./host-folder-repository.js";
@@ -57,7 +56,6 @@ import { SsoProviderRepository } from "./sso-provider-repository.js";
import { TermixIdentityCaRepository } from "./termix-identity-ca-repository.js";
import { TermixIdentityRepository } from "./termix-identity-repository.js";
import { TmuxSessionTagRepository } from "./tmux-session-tag-repository.js";
import { TransferRecentRepository } from "./transfer-recent-repository.js";
import { TrustedDeviceRepository } from "./trusted-device-repository.js";
import { UserDataExportRepository } from "./user-data-export-repository.js";
import { UserPreferenceRepository } from "./user-preference-repository.js";
@@ -229,12 +227,7 @@ export function createCurrentDismissedAlertRepository(): DismissedAlertRepositor
);
}
export function createCurrentFileManagerBookmarkRepository(): FileManagerBookmarkRepository {
return new FileManagerBookmarkRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("file_manager_bookmarks_repository_write"),
);
}
// File manager recent/pinned/shortcuts moved to the file-manager plugin.
export function createCurrentHomepageItemRepository(): HomepageItemRepository {
return new HomepageItemRepository(
@@ -512,12 +505,7 @@ export function createCurrentTmuxSessionTagRepository(): TmuxSessionTagRepositor
);
}
export function createCurrentTransferRecentRepository(): TransferRecentRepository {
return new TransferRecentRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("transfer_recent_repository_write"),
);
}
// transfer_recent moved to the file-manager plugin.
export function createCurrentTrustedDeviceRepository(): TrustedDeviceRepository {
return new TrustedDeviceRepository(
@@ -1,522 +0,0 @@
import { and, desc, eq, inArray } from "drizzle-orm";
import {
fileManagerPinned,
fileManagerRecent,
fileManagerShortcuts,
} from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
export type FileManagerRecentRecord = typeof fileManagerRecent.$inferSelect;
export type FileManagerPinnedRecord = typeof fileManagerPinned.$inferSelect;
export type FileManagerShortcutRecord =
typeof fileManagerShortcuts.$inferSelect;
export interface FileManagerBookmarkInput {
hostId: number;
path: string;
name?: string | null;
}
function resolveBookmarkName(path: string, name?: string | null): string {
return name || path.split("/").pop() || "Unknown";
}
export class FileManagerBookmarkRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async listRecentForHost(
userId: string,
hostId: number,
limit = 20,
): Promise<FileManagerRecentRecord[]> {
return this.context.drizzle
.select()
.from(fileManagerRecent)
.where(
and(
eq(fileManagerRecent.userId, userId),
eq(fileManagerRecent.hostId, hostId),
),
)
.orderBy(desc(fileManagerRecent.lastOpened))
.limit(limit);
}
async listRecentByUserId(userId: string): Promise<FileManagerRecentRecord[]> {
return this.context.drizzle
.select()
.from(fileManagerRecent)
.where(eq(fileManagerRecent.userId, userId));
}
async upsertRecent(
userId: string,
input: FileManagerBookmarkInput,
lastOpened = new Date().toISOString(),
): Promise<void> {
const [existing] = await this.context.drizzle
.select({ id: fileManagerRecent.id })
.from(fileManagerRecent)
.where(
and(
eq(fileManagerRecent.userId, userId),
eq(fileManagerRecent.hostId, input.hostId),
eq(fileManagerRecent.path, input.path),
),
)
.limit(1);
if (existing) {
await this.context.drizzle
.update(fileManagerRecent)
.set({ lastOpened })
.where(eq(fileManagerRecent.id, existing.id));
} else {
await this.context.drizzle.insert(fileManagerRecent).values({
userId,
hostId: input.hostId,
path: input.path,
name: resolveBookmarkName(input.path, input.name),
lastOpened,
});
}
await this.afterWrite();
}
async createRecentForImport(
userId: string,
input: FileManagerBookmarkInput,
lastOpened = new Date().toISOString(),
): Promise<boolean> {
const exists = await this.existsRecentImportItem(userId, input);
if (exists) {
return false;
}
await this.context.drizzle.insert(fileManagerRecent).values({
userId,
hostId: input.hostId,
path: input.path,
name: resolveBookmarkName(input.path, input.name),
lastOpened,
});
await this.afterWrite();
return true;
}
async deleteRecentForHostPath(
userId: string,
input: Pick<FileManagerBookmarkInput, "hostId" | "path">,
): Promise<number> {
const result = await this.context.drizzle
.delete(fileManagerRecent)
.where(
and(
eq(fileManagerRecent.userId, userId),
eq(fileManagerRecent.hostId, input.hostId),
eq(fileManagerRecent.path, input.path),
),
);
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result);
}
async listPinnedForHost(
userId: string,
hostId: number,
): Promise<FileManagerPinnedRecord[]> {
return this.context.drizzle
.select()
.from(fileManagerPinned)
.where(
and(
eq(fileManagerPinned.userId, userId),
eq(fileManagerPinned.hostId, hostId),
),
)
.orderBy(desc(fileManagerPinned.pinnedAt));
}
async listPinnedByUserId(userId: string): Promise<FileManagerPinnedRecord[]> {
return this.context.drizzle
.select()
.from(fileManagerPinned)
.where(eq(fileManagerPinned.userId, userId));
}
async createPinned(
userId: string,
input: FileManagerBookmarkInput,
pinnedAt = new Date().toISOString(),
): Promise<boolean> {
const exists = await this.existsPinned(userId, input.hostId, input.path);
if (exists) {
return false;
}
await this.context.drizzle.insert(fileManagerPinned).values({
userId,
hostId: input.hostId,
path: input.path,
name: resolveBookmarkName(input.path, input.name),
pinnedAt,
});
await this.afterWrite();
return true;
}
async createPinnedForImport(
userId: string,
input: FileManagerBookmarkInput,
pinnedAt = new Date().toISOString(),
): Promise<boolean> {
const exists = await this.existsPinnedImportItem(userId, input);
if (exists) {
return false;
}
await this.context.drizzle.insert(fileManagerPinned).values({
userId,
hostId: input.hostId,
path: input.path,
name: resolveBookmarkName(input.path, input.name),
pinnedAt,
});
await this.afterWrite();
return true;
}
async deletePinnedForHostPath(
userId: string,
input: Pick<FileManagerBookmarkInput, "hostId" | "path">,
): Promise<number> {
const result = await this.context.drizzle
.delete(fileManagerPinned)
.where(
and(
eq(fileManagerPinned.userId, userId),
eq(fileManagerPinned.hostId, input.hostId),
eq(fileManagerPinned.path, input.path),
),
);
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result);
}
async listShortcutsForHost(
userId: string,
hostId: number,
): Promise<FileManagerShortcutRecord[]> {
return this.context.drizzle
.select()
.from(fileManagerShortcuts)
.where(
and(
eq(fileManagerShortcuts.userId, userId),
eq(fileManagerShortcuts.hostId, hostId),
),
)
.orderBy(desc(fileManagerShortcuts.createdAt));
}
async listShortcutsByUserId(
userId: string,
): Promise<FileManagerShortcutRecord[]> {
return this.context.drizzle
.select()
.from(fileManagerShortcuts)
.where(eq(fileManagerShortcuts.userId, userId));
}
async createShortcut(
userId: string,
input: FileManagerBookmarkInput,
createdAt = new Date().toISOString(),
): Promise<boolean> {
const exists = await this.existsShortcut(userId, input.hostId, input.path);
if (exists) {
return false;
}
await this.context.drizzle.insert(fileManagerShortcuts).values({
userId,
hostId: input.hostId,
path: input.path,
name: resolveBookmarkName(input.path, input.name),
createdAt,
});
await this.afterWrite();
return true;
}
async createShortcutForImport(
userId: string,
input: FileManagerBookmarkInput,
createdAt = new Date().toISOString(),
): Promise<boolean> {
const exists = await this.existsShortcutImportItem(userId, input);
if (exists) {
return false;
}
await this.context.drizzle.insert(fileManagerShortcuts).values({
userId,
hostId: input.hostId,
path: input.path,
name: resolveBookmarkName(input.path, input.name),
createdAt,
});
await this.afterWrite();
return true;
}
async deleteShortcutForHostPath(
userId: string,
input: Pick<FileManagerBookmarkInput, "hostId" | "path">,
): Promise<number> {
const result = await this.context.drizzle
.delete(fileManagerShortcuts)
.where(
and(
eq(fileManagerShortcuts.userId, userId),
eq(fileManagerShortcuts.hostId, input.hostId),
eq(fileManagerShortcuts.path, input.path),
),
);
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result);
}
async deleteByUserId(userId: string): Promise<number> {
const total =
(await this.deleteRecentByUserId(userId)) +
(await this.deletePinnedByUserId(userId)) +
(await this.deleteShortcutsByUserId(userId));
if (total > 0) {
await this.afterWrite();
}
return total;
}
async deleteByHostId(hostId: number): Promise<number> {
const total =
(await this.deleteRecentByHostId(hostId)) +
(await this.deletePinnedByHostId(hostId)) +
(await this.deleteShortcutsByHostId(hostId));
if (total > 0) {
await this.afterWrite();
}
return total;
}
async deleteByHostIds(hostIds: number[]): Promise<number> {
if (hostIds.length === 0) {
return 0;
}
const total =
(await this.deleteRecentByHostIds(hostIds)) +
(await this.deletePinnedByHostIds(hostIds)) +
(await this.deleteShortcutsByHostIds(hostIds));
if (total > 0) {
await this.afterWrite();
}
return total;
}
private async existsPinned(
userId: string,
hostId: number,
path: string,
): Promise<boolean> {
const rows = await this.context.drizzle
.select({ id: fileManagerPinned.id })
.from(fileManagerPinned)
.where(
and(
eq(fileManagerPinned.userId, userId),
eq(fileManagerPinned.hostId, hostId),
eq(fileManagerPinned.path, path),
),
)
.limit(1);
return rows.length > 0;
}
private async existsRecentImportItem(
userId: string,
input: FileManagerBookmarkInput,
): Promise<boolean> {
const rows = await this.context.drizzle
.select({ id: fileManagerRecent.id })
.from(fileManagerRecent)
.where(
and(
eq(fileManagerRecent.userId, userId),
eq(fileManagerRecent.path, input.path),
eq(
fileManagerRecent.name,
resolveBookmarkName(input.path, input.name),
),
),
)
.limit(1);
return rows.length > 0;
}
private async existsPinnedImportItem(
userId: string,
input: FileManagerBookmarkInput,
): Promise<boolean> {
const rows = await this.context.drizzle
.select({ id: fileManagerPinned.id })
.from(fileManagerPinned)
.where(
and(
eq(fileManagerPinned.userId, userId),
eq(fileManagerPinned.path, input.path),
eq(
fileManagerPinned.name,
resolveBookmarkName(input.path, input.name),
),
),
)
.limit(1);
return rows.length > 0;
}
private async existsShortcutImportItem(
userId: string,
input: FileManagerBookmarkInput,
): Promise<boolean> {
const rows = await this.context.drizzle
.select({ id: fileManagerShortcuts.id })
.from(fileManagerShortcuts)
.where(
and(
eq(fileManagerShortcuts.userId, userId),
eq(fileManagerShortcuts.path, input.path),
eq(
fileManagerShortcuts.name,
resolveBookmarkName(input.path, input.name),
),
),
)
.limit(1);
return rows.length > 0;
}
private async existsShortcut(
userId: string,
hostId: number,
path: string,
): Promise<boolean> {
const rows = await this.context.drizzle
.select({ id: fileManagerShortcuts.id })
.from(fileManagerShortcuts)
.where(
and(
eq(fileManagerShortcuts.userId, userId),
eq(fileManagerShortcuts.hostId, hostId),
eq(fileManagerShortcuts.path, path),
),
)
.limit(1);
return rows.length > 0;
}
private async deleteRecentByUserId(userId: string): Promise<number> {
const result = await this.context.drizzle
.delete(fileManagerRecent)
.where(eq(fileManagerRecent.userId, userId));
return rowsAffected(result);
}
private async deletePinnedByUserId(userId: string): Promise<number> {
const result = await this.context.drizzle
.delete(fileManagerPinned)
.where(eq(fileManagerPinned.userId, userId));
return rowsAffected(result);
}
private async deleteShortcutsByUserId(userId: string): Promise<number> {
const result = await this.context.drizzle
.delete(fileManagerShortcuts)
.where(eq(fileManagerShortcuts.userId, userId));
return rowsAffected(result);
}
private async deleteRecentByHostId(hostId: number): Promise<number> {
const result = await this.context.drizzle
.delete(fileManagerRecent)
.where(eq(fileManagerRecent.hostId, hostId));
return rowsAffected(result);
}
private async deletePinnedByHostId(hostId: number): Promise<number> {
const result = await this.context.drizzle
.delete(fileManagerPinned)
.where(eq(fileManagerPinned.hostId, hostId));
return rowsAffected(result);
}
private async deleteShortcutsByHostId(hostId: number): Promise<number> {
const result = await this.context.drizzle
.delete(fileManagerShortcuts)
.where(eq(fileManagerShortcuts.hostId, hostId));
return rowsAffected(result);
}
private async deleteRecentByHostIds(hostIds: number[]): Promise<number> {
const result = await this.context.drizzle
.delete(fileManagerRecent)
.where(inArray(fileManagerRecent.hostId, hostIds));
return rowsAffected(result);
}
private async deletePinnedByHostIds(hostIds: number[]): Promise<number> {
const result = await this.context.drizzle
.delete(fileManagerPinned)
.where(inArray(fileManagerPinned.hostId, hostIds));
return rowsAffected(result);
}
private async deleteShortcutsByHostIds(hostIds: number[]): Promise<number> {
const result = await this.context.drizzle
.delete(fileManagerShortcuts)
.where(inArray(fileManagerShortcuts.hostId, hostIds));
return rowsAffected(result);
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
@@ -1,168 +0,0 @@
import { and, desc, eq, inArray, or } from "drizzle-orm";
import { transferRecent } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
export type TransferRecentRecord = typeof transferRecent.$inferSelect;
export interface TransferRecentDestinationInput {
sourceHostId: number;
destHostId: number;
destPath: string;
destPathLabel?: string | null;
}
export class TransferRecentRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async listByUserId(userId: string): Promise<TransferRecentRecord[]> {
return this.context.drizzle
.select()
.from(transferRecent)
.where(eq(transferRecent.userId, userId));
}
async listBySourceHost(
userId: string,
sourceHostId: number,
limit = 10,
): Promise<TransferRecentRecord[]> {
return this.context.drizzle
.select()
.from(transferRecent)
.where(
and(
eq(transferRecent.userId, userId),
eq(transferRecent.sourceHostId, sourceHostId),
),
)
.orderBy(desc(transferRecent.lastUsed))
.limit(limit);
}
async upsertForDestination(
userId: string,
input: TransferRecentDestinationInput,
lastUsed = new Date().toISOString(),
): Promise<void> {
const [existing] = await this.context.drizzle
.select({ id: transferRecent.id })
.from(transferRecent)
.where(
and(
eq(transferRecent.userId, userId),
eq(transferRecent.sourceHostId, input.sourceHostId),
eq(transferRecent.destHostId, input.destHostId),
eq(transferRecent.destPath, input.destPath),
),
)
.limit(1);
if (existing) {
await this.context.drizzle
.update(transferRecent)
.set({ lastUsed })
.where(eq(transferRecent.id, existing.id));
} else {
await this.context.drizzle.insert(transferRecent).values({
userId,
sourceHostId: input.sourceHostId,
destHostId: input.destHostId,
destPath: input.destPath,
destPathLabel: input.destPathLabel || input.destPath,
lastUsed,
});
}
await this.afterWrite();
}
async pruneSourceHost(
userId: string,
sourceHostId: number,
keep = 10,
): Promise<number> {
const rows = await this.context.drizzle
.select({ id: transferRecent.id })
.from(transferRecent)
.where(
and(
eq(transferRecent.userId, userId),
eq(transferRecent.sourceHostId, sourceHostId),
),
)
.orderBy(desc(transferRecent.lastUsed));
const idsToDelete = rows.slice(keep).map((row) => row.id);
if (idsToDelete.length === 0) {
return 0;
}
const result = await this.context.drizzle
.delete(transferRecent)
.where(inArray(transferRecent.id, idsToDelete));
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result);
}
async deleteByUserId(userId: string): Promise<number> {
const result = await this.context.drizzle
.delete(transferRecent)
.where(eq(transferRecent.userId, userId));
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result);
}
async deleteByHostId(hostId: number): Promise<number> {
const result = await this.context.drizzle
.delete(transferRecent)
.where(
or(
eq(transferRecent.sourceHostId, hostId),
eq(transferRecent.destHostId, hostId),
),
);
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result);
}
async deleteByHostIds(hostIds: number[]): Promise<number> {
if (hostIds.length === 0) {
return 0;
}
const result = await this.context.drizzle
.delete(transferRecent)
.where(
or(
inArray(transferRecent.sourceHostId, hostIds),
inArray(transferRecent.destHostId, hostIds),
),
);
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result);
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
@@ -9,7 +9,6 @@ import {
createCurrentCredentialRepository,
createCurrentDashboardServiceLinkRepository,
createCurrentDismissedAlertRepository,
createCurrentFileManagerBookmarkRepository,
createCurrentHomepageItemRepository,
createCurrentHomepageLayoutRepository,
createCurrentHostHealthRepository,
@@ -35,7 +34,6 @@ import {
createCurrentTrustedDeviceRepository,
createCurrentUserPreferenceRepository,
createCurrentUserRepository,
createCurrentTransferRecentRepository,
createCurrentVaultProfileRepository,
createCurrentSecretSourceRepository,
createCurrentSharedCredentialSecretsRepository,
@@ -85,9 +83,9 @@ export async function deleteUserAndRelatedData(
await createCurrentSshCredentialUsageRepository().deleteByUserId(userId);
await createCurrentFileManagerBookmarkRepository().deleteByUserId(userId);
await createCurrentTransferRecentRepository().deleteByUserId(userId);
// file manager recent/pinned/shortcuts and transfer_recent cascade on the
// user's refUser() foreign key, as the file-manager plugin's adopted
// tables.
await createCurrentRecentActivityRepository().deleteByUserId(userId);
await createCurrentDismissedAlertRepository().deleteByUserId(userId);
@@ -1,544 +0,0 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { Request, RequestHandler, Response, Router } from "express";
import { sshLogger } from "../../utils/logger.js";
import { createCurrentFileManagerBookmarkRepository } from "../repositories/factory.js";
import { isNonEmptyString } from "./host-normalizers.js";
export function registerHostFileManagerBookmarkRoutes(
router: Router,
authenticateJWT: RequestHandler,
requireViewPermission: RequestHandler,
requireDataAccess: RequestHandler,
): void {
/**
* @openapi
* /host/file_manager/recent:
* get:
* summary: Get recent files
* description: Retrieves a list of recent files for a specific host.
* tags:
* - SSH
* parameters:
* - in: query
* name: hostId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: A list of recent files.
* 400:
* description: Invalid userId or hostId.
* 500:
* description: Failed to fetch recent files.
*/
router.get(
"/file_manager/recent",
authenticateJWT,
requireViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const hostIdQuery = Array.isArray(req.query.hostId)
? req.query.hostId[0]
: req.query.hostId;
const hostId = hostIdQuery ? parseInt(hostIdQuery as string) : null;
if (!isNonEmptyString(userId)) {
sshLogger.warn("Invalid userId for recent files fetch");
return res.status(400).json({ error: "Invalid userId" });
}
if (!hostId) {
sshLogger.warn("Host ID is required for recent files fetch");
return res.status(400).json({ error: "Host ID is required" });
}
try {
const recentFiles =
await createCurrentFileManagerBookmarkRepository().listRecentForHost(
userId,
hostId,
20,
);
res.json(recentFiles);
} catch (err) {
sshLogger.error("Failed to fetch recent files", err);
res.status(500).json({ error: "Failed to fetch recent files" });
}
},
);
/**
* @openapi
* /host/file_manager/recent:
* post:
* summary: Add recent file
* description: Adds a file to the list of recent files for a host.
* tags:
* - SSH
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* hostId:
* type: integer
* path:
* type: string
* name:
* type: string
* responses:
* 200:
* description: Recent file added.
* 400:
* description: Invalid data.
* 500:
* description: Failed to add recent file.
*/
router.post(
"/file_manager/recent",
authenticateJWT,
requireViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { hostId, path, name } = req.body;
if (!isNonEmptyString(userId) || !hostId || !path) {
sshLogger.warn("Invalid data for recent file addition");
return res.status(400).json({ error: "Invalid data" });
}
try {
await createCurrentFileManagerBookmarkRepository().upsertRecent(
userId,
{
hostId,
path,
name,
},
);
res.json({ message: "Recent file added" });
} catch (err) {
sshLogger.error("Failed to add recent file", err);
res.status(500).json({ error: "Failed to add recent file" });
}
},
);
/**
* @openapi
* /host/file_manager/recent:
* delete:
* summary: Remove recent file
* description: Removes a file from the list of recent files for a host.
* tags:
* - SSH
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* hostId:
* type: integer
* path:
* type: string
* responses:
* 200:
* description: Recent file removed.
* 400:
* description: Invalid data.
* 500:
* description: Failed to remove recent file.
*/
router.delete(
"/file_manager/recent",
authenticateJWT,
requireViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { hostId, path } = req.body;
if (!isNonEmptyString(userId) || !hostId || !path) {
sshLogger.warn("Invalid data for recent file deletion");
return res.status(400).json({ error: "Invalid data" });
}
try {
await createCurrentFileManagerBookmarkRepository().deleteRecentForHostPath(
userId,
{ hostId, path },
);
res.json({ message: "Recent file removed" });
} catch (err) {
sshLogger.error("Failed to remove recent file", err);
res.status(500).json({ error: "Failed to remove recent file" });
}
},
);
/**
* @openapi
* /host/file_manager/pinned:
* get:
* summary: Get pinned files
* description: Retrieves a list of pinned files for a specific host.
* tags:
* - SSH
* parameters:
* - in: query
* name: hostId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: A list of pinned files.
* 400:
* description: Invalid userId or hostId.
* 500:
* description: Failed to fetch pinned files.
*/
router.get(
"/file_manager/pinned",
authenticateJWT,
requireViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const hostIdQuery = Array.isArray(req.query.hostId)
? req.query.hostId[0]
: req.query.hostId;
const hostId = hostIdQuery ? parseInt(hostIdQuery as string) : null;
if (!isNonEmptyString(userId)) {
sshLogger.warn("Invalid userId for pinned files fetch");
return res.status(400).json({ error: "Invalid userId" });
}
if (!hostId) {
sshLogger.warn("Host ID is required for pinned files fetch");
return res.status(400).json({ error: "Host ID is required" });
}
try {
const pinnedFiles =
await createCurrentFileManagerBookmarkRepository().listPinnedForHost(
userId,
hostId,
);
res.json(pinnedFiles);
} catch (err) {
sshLogger.error("Failed to fetch pinned files", err);
res.status(500).json({ error: "Failed to fetch pinned files" });
}
},
);
/**
* @openapi
* /host/file_manager/pinned:
* post:
* summary: Add pinned file
* description: Adds a file to the list of pinned files for a host.
* tags:
* - SSH
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* hostId:
* type: integer
* path:
* type: string
* name:
* type: string
* responses:
* 200:
* description: File pinned.
* 400:
* description: Invalid data.
* 409:
* description: File already pinned.
* 500:
* description: Failed to pin file.
*/
router.post(
"/file_manager/pinned",
authenticateJWT,
requireViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { hostId, path, name } = req.body;
if (!isNonEmptyString(userId) || !hostId || !path) {
sshLogger.warn("Invalid data for pinned file addition");
return res.status(400).json({ error: "Invalid data" });
}
try {
const created =
await createCurrentFileManagerBookmarkRepository().createPinned(
userId,
{ hostId, path, name },
);
if (!created) {
return res.status(409).json({ error: "File already pinned" });
}
res.json({ message: "File pinned" });
} catch (err) {
sshLogger.error("Failed to pin file", err);
res.status(500).json({ error: "Failed to pin file" });
}
},
);
/**
* @openapi
* /host/file_manager/pinned:
* delete:
* summary: Remove pinned file
* description: Removes a file from the list of pinned files for a host.
* tags:
* - SSH
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* hostId:
* type: integer
* path:
* type: string
* responses:
* 200:
* description: Pinned file removed.
* 400:
* description: Invalid data.
* 500:
* description: Failed to remove pinned file.
*/
router.delete(
"/file_manager/pinned",
authenticateJWT,
requireViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { hostId, path } = req.body;
if (!isNonEmptyString(userId) || !hostId || !path) {
sshLogger.warn("Invalid data for pinned file deletion");
return res.status(400).json({ error: "Invalid data" });
}
try {
await createCurrentFileManagerBookmarkRepository().deletePinnedForHostPath(
userId,
{ hostId, path },
);
res.json({ message: "Pinned file removed" });
} catch (err) {
sshLogger.error("Failed to remove pinned file", err);
res.status(500).json({ error: "Failed to remove pinned file" });
}
},
);
/**
* @openapi
* /host/file_manager/shortcuts:
* get:
* summary: Get shortcuts
* description: Retrieves a list of shortcuts for a specific host.
* tags:
* - SSH
* parameters:
* - in: query
* name: hostId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: A list of shortcuts.
* 400:
* description: Invalid userId or hostId.
* 500:
* description: Failed to fetch shortcuts.
*/
router.get(
"/file_manager/shortcuts",
authenticateJWT,
requireViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const hostIdQuery = Array.isArray(req.query.hostId)
? req.query.hostId[0]
: req.query.hostId;
const hostId = hostIdQuery ? parseInt(hostIdQuery as string) : null;
if (!isNonEmptyString(userId)) {
sshLogger.warn("Invalid userId for shortcuts fetch");
return res.status(400).json({ error: "Invalid userId" });
}
if (!hostId) {
sshLogger.warn("Host ID is required for shortcuts fetch");
return res.status(400).json({ error: "Host ID is required" });
}
try {
const shortcuts =
await createCurrentFileManagerBookmarkRepository().listShortcutsForHost(
userId,
hostId,
);
res.json(shortcuts);
} catch (err) {
sshLogger.error("Failed to fetch shortcuts", err);
res.status(500).json({ error: "Failed to fetch shortcuts" });
}
},
);
/**
* @openapi
* /host/file_manager/shortcuts:
* post:
* summary: Add shortcut
* description: Adds a shortcut for a specific host.
* tags:
* - SSH
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* hostId:
* type: integer
* path:
* type: string
* name:
* type: string
* responses:
* 200:
* description: Shortcut added.
* 400:
* description: Invalid data.
* 409:
* description: Shortcut already exists.
* 500:
* description: Failed to add shortcut.
*/
router.post(
"/file_manager/shortcuts",
authenticateJWT,
requireViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { hostId, path, name } = req.body;
if (!isNonEmptyString(userId) || !hostId || !path) {
sshLogger.warn("Invalid data for shortcut addition");
return res.status(400).json({ error: "Invalid data" });
}
try {
const created =
await createCurrentFileManagerBookmarkRepository().createShortcut(
userId,
{ hostId, path, name },
);
if (!created) {
return res.status(409).json({ error: "Shortcut already exists" });
}
res.json({ message: "Shortcut added" });
} catch (err) {
sshLogger.error("Failed to add shortcut", err);
res.status(500).json({ error: "Failed to add shortcut" });
}
},
);
/**
* @openapi
* /host/file_manager/shortcuts:
* delete:
* summary: Remove shortcut
* description: Removes a shortcut for a specific host.
* tags:
* - SSH
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* hostId:
* type: integer
* path:
* type: string
* responses:
* 200:
* description: Shortcut removed.
* 400:
* description: Invalid data.
* 500:
* description: Failed to remove shortcut.
*/
router.delete(
"/file_manager/shortcuts",
authenticateJWT,
requireViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { hostId, path } = req.body;
if (!isNonEmptyString(userId) || !hostId || !path) {
sshLogger.warn("Invalid data for shortcut deletion");
return res.status(400).json({ error: "Invalid data" });
}
try {
await createCurrentFileManagerBookmarkRepository().deleteShortcutForHostPath(
userId,
{ hostId, path },
);
res.json({ message: "Shortcut removed" });
} catch (err) {
sshLogger.error("Failed to remove shortcut", err);
res.status(500).json({ error: "Failed to remove shortcut" });
}
},
);
}
@@ -5,14 +5,12 @@ import { pluginEvents, TOPICS } from "../../plugins/events.js";
import {
createCurrentCommandHistoryRepository,
createCurrentCredentialRepository,
createCurrentFileManagerBookmarkRepository,
createCurrentHostFolderRepository,
createCurrentFolderAccessRepository,
createCurrentRecentActivityRepository,
createCurrentRbacAccessRepository,
createCurrentSshCredentialUsageRepository,
createCurrentSessionRecordingRepository,
createCurrentTransferRecentRepository,
createCurrentSyncTombstoneRepository,
} from "../repositories/factory.js";
import { isNonEmptyString } from "./host-normalizers.js";
@@ -400,13 +398,9 @@ export function registerHostFolderRoutes(
const hostIds = hostsToDelete.map((host) => host.id);
if (hostIds.length > 0) {
await createCurrentFileManagerBookmarkRepository().deleteByHostIds(
hostIds,
);
await createCurrentTransferRecentRepository().deleteByHostIds(
hostIds,
);
// file manager recent/pinned/shortcuts and transfer_recent cascade
// on the host's refHost() foreign key, as the file-manager
// plugin's adopted tables.
await createCurrentCommandHistoryRepository().deleteByHostIds(
hostIds,
+5 -91
View File
@@ -18,12 +18,10 @@ import { notifyAutomationInternalEvent } from "../../hosts/automation-events.js"
import {
createCurrentCommandHistoryRepository,
createCurrentCredentialRepository,
createCurrentFileManagerBookmarkRepository,
createCurrentOpksshTokenRepository,
createCurrentRecentActivityRepository,
createCurrentSshCredentialUsageRepository,
createCurrentSessionRecordingRepository,
createCurrentTransferRecentRepository,
createCurrentRbacAccessRepository,
createCurrentRoleRepository,
createCurrentHostResolutionRepository,
@@ -55,7 +53,6 @@ import { validateParentHostId } from "./host-parent-validation.js";
import { registerHostOpksshRoutes } from "./host-opkssh-routes.js";
import { registerHostStepCaRoutes } from "./host-step-ca-routes.js";
import { registerHostFolderRoutes } from "./host-folder-routes.js";
import { registerHostFileManagerBookmarkRoutes } from "./host-file-manager-bookmark-routes.js";
import { registerHostCommandHistoryRoutes } from "./host-command-history-routes.js";
import { registerHostAutostartRoutes } from "./host-autostart-routes.js";
import { registerHostInternalRoutes } from "./host-internal-routes.js";
@@ -2550,13 +2547,9 @@ router.delete(
const numericHostId = Number(hostId);
await createCurrentFileManagerBookmarkRepository().deleteByHostId(
numericHostId,
);
await createCurrentTransferRecentRepository().deleteByHostId(
numericHostId,
);
// file manager recent/pinned/shortcuts and transfer_recent cascade on
// the host's refHost() foreign key, as the file-manager plugin's
// adopted tables.
await createCurrentCommandHistoryRepository().deleteByHostId(
numericHostId,
@@ -2635,87 +2628,8 @@ router.delete(
},
);
registerHostFileManagerBookmarkRoutes(
router,
authenticateJWT,
permissionManager.requirePermission("hosts.view"),
requireDataAccess,
);
router.get(
"/transfer/recent",
authenticateJWT,
permissionManager.requirePermission("hosts.view"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const sourceHostIdQuery = Array.isArray(req.query.sourceHostId)
? req.query.sourceHostId[0]
: req.query.sourceHostId;
const sourceHostId = sourceHostIdQuery
? parseInt(sourceHostIdQuery as string)
: null;
if (!isNonEmptyString(userId)) {
return res.status(400).json({ error: "Invalid userId" });
}
if (!sourceHostId) {
return res.status(400).json({ error: "Source host ID is required" });
}
try {
const recent =
await createCurrentTransferRecentRepository().listBySourceHost(
userId,
sourceHostId,
10,
);
res.json(recent);
} catch (err) {
sshLogger.error("Failed to fetch transfer recent destinations", err);
res.status(500).json({ error: "Failed to fetch recent destinations" });
}
},
);
router.post(
"/transfer/recent",
authenticateJWT,
permissionManager.requirePermission("hosts.view"),
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { sourceHostId, destHostId, destPath, destPathLabel } = req.body;
if (
!isNonEmptyString(userId) ||
!sourceHostId ||
!destHostId ||
!destPath
) {
return res.status(400).json({ error: "Invalid data" });
}
try {
const transferRecentRepository = createCurrentTransferRecentRepository();
await transferRecentRepository.upsertForDestination(userId, {
sourceHostId,
destHostId,
destPath,
destPathLabel,
});
await transferRecentRepository.pruneSourceHost(userId, sourceHostId, 10);
res.json({ message: "Recent destination saved" });
} catch (err) {
sshLogger.error("Failed to save transfer recent destination", err);
res.status(500).json({ error: "Failed to save recent destination" });
}
},
);
// File manager recent/pinned/shortcuts and transfer/recent routes moved to
// the file-manager plugin, under /plugin-api/file-manager/.
registerHostCommandHistoryRoutes(
router,
authenticateJWT,
@@ -8,7 +8,6 @@ import { loginRateLimiter } from "../../utils/login-rate-limiter.js";
import {
createCurrentCredentialRepository,
createCurrentDismissedAlertRepository,
createCurrentFileManagerBookmarkRepository,
createCurrentHostRepository,
createCurrentRecentActivityRepository,
createCurrentSettingsRepository,
@@ -70,7 +69,6 @@ export async function resetUserPassword(
await userRepository.update(userId, { passwordHash });
await createCurrentSshCredentialUsageRepository().deleteByUserId(userId);
await createCurrentFileManagerBookmarkRepository().deleteByUserId(userId);
await createCurrentRecentActivityRepository().deleteByUserId(userId);
await createCurrentDismissedAlertRepository().deleteByUserId(userId);
await createCurrentHostRepository().deleteByUserId(userId);
+2
View File
@@ -53,6 +53,8 @@ const PLUGIN_PURPOSES = new Set<SshConnectPurpose>([
"fleet",
"remote-desktop",
"tunnel",
"file-manager",
"file-transfer",
]);
function purposeOf(options?: PluginSshConnectOptions): SshConnectPurpose {
+9 -5
View File
@@ -302,10 +302,11 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
const { primeKnownPermissions } =
await import("./utils/known-permissions.js");
await primeKnownPermissions();
// Terminal, docker and host-metrics are deliberately absent: the
// ssh-terminal, docker and host-metrics plugins start their own servers,
// so disabling any of them stops its WS/HTTP server. See
// plugins/ssh-terminal, plugins/docker and plugins/host-metrics.
// Terminal, docker, host-metrics and file-manager are deliberately
// absent: the ssh-terminal, docker, host-metrics and file-manager plugins
// start their own servers, so disabling any of them stops its WS/HTTP
// server. See plugins/ssh-terminal, plugins/docker, plugins/host-metrics
// and plugins/file-manager.
// Every other plugin (AI, Proxmox, Remote Desktop, Fleets, Automations,
// Network Topology, Workspaces, Web Endpoint) is absent for the same
// reason: each one serves its routes under /plugin-api/<id>/ through
@@ -313,7 +314,6 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
// dead import here. Automations' scheduler also starts from its own
// activate() rather than here.
await import("./hosts/tunnel/index.js");
await import("./hosts/file-manager/index.js");
await import("./hosts/tmux/index.js");
await import("./hosts/serial.js");
await import("./services/dashboard.js");
@@ -350,6 +350,10 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
const { runProxmoxSettingsMigration } =
await import("./utils/crypto-migration/proxmox-settings-migration.js");
await runProxmoxSettingsMigration();
const { runFileManagerSettingsMigration } =
await import("./utils/crypto-migration/file-manager-settings-migration.js");
await runFileManagerSettingsMigration();
} catch (error) {
systemLogger.warn("Plugin runtime failed to initialize", {
operation: "plugin_init",
@@ -1,193 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { FileManagerBookmarkRepository } from "../../../database/repositories/file-manager-bookmark-repository.js";
describe("FileManagerBookmarkRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): Promise<FileManagerBookmarkRepository> {
adapter = new TestSqliteDatabase();
const context = await adapter.connect();
await adapter.exec(`
INSERT INTO users (id, username, password_hash)
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type)
VALUES (1, 'user-1', 'one', '10.0.0.1', 22, 'root', 'password'), (2, 'user-1', 'two', '10.0.0.1', 22, 'root', 'password'), (3, 'user-2', 'other', '10.0.0.1', 22, 'root', 'password');
`);
return new FileManagerBookmarkRepository(context, onWrite);
}
it("upserts and lists recent files by last-opened time", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await repo.upsertRecent(
"user-1",
{ hostId: 1, path: "/var/log/app.log" },
"2026-01-01T00:00:00.000Z",
);
await repo.upsertRecent(
"user-1",
{ hostId: 1, path: "/opt/app/config.json", name: "Config" },
"2026-01-02T00:00:00.000Z",
);
await repo.upsertRecent(
"user-1",
{ hostId: 1, path: "/var/log/app.log" },
"2026-01-03T00:00:00.000Z",
);
const recent = await repo.listRecentForHost("user-1", 1);
expect(recent.map((entry) => entry.path)).toEqual([
"/var/log/app.log",
"/opt/app/config.json",
]);
expect(recent[0].lastOpened).toBe("2026-01-03T00:00:00.000Z");
expect(recent[0].name).toBe("app.log");
expect(writeCount).toBe(3);
expect(
await repo.deleteRecentForHostPath("user-1", {
hostId: 1,
path: "/var/log/app.log",
}),
).toBe(1);
expect(writeCount).toBe(4);
});
it("creates pinned files and shortcuts without duplicating paths", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
expect(
await repo.createPinned(
"user-1",
{ hostId: 1, path: "/srv/www", name: "Web" },
"2026-01-01T00:00:00.000Z",
),
).toBe(true);
expect(
await repo.createPinned("user-1", { hostId: 1, path: "/srv/www" }),
).toBe(false);
expect((await repo.listPinnedForHost("user-1", 1))[0]).toMatchObject({
name: "Web",
path: "/srv/www",
});
expect(
await repo.createShortcut(
"user-1",
{ hostId: 1, path: "/etc/nginx" },
"2026-01-02T00:00:00.000Z",
),
).toBe(true);
expect(
await repo.createShortcut("user-1", { hostId: 1, path: "/etc/nginx" }),
).toBe(false);
expect((await repo.listShortcutsForHost("user-1", 1))[0]).toMatchObject({
name: "nginx",
path: "/etc/nginx",
});
expect(writeCount).toBe(2);
expect(
await repo.deletePinnedForHostPath("user-1", {
hostId: 1,
path: "/srv/www",
}),
).toBe(1);
expect(
await repo.deleteShortcutForHostPath("user-1", {
hostId: 1,
path: "/etc/nginx",
}),
).toBe(1);
expect(writeCount).toBe(4);
});
it("creates import bookmarks without duplicating user path/name pairs", async () => {
const repo = await createRepository();
await expect(
repo.createRecentForImport(
"user-1",
{ hostId: 1, path: "/tmp/a.txt", name: "A" },
"2026-01-01T00:00:00.000Z",
),
).resolves.toBe(true);
await expect(
repo.createRecentForImport("user-1", {
hostId: 2,
path: "/tmp/a.txt",
name: "A",
}),
).resolves.toBe(false);
await expect(
repo.createPinnedForImport("user-1", {
hostId: 1,
path: "/srv/www",
name: "Web",
}),
).resolves.toBe(true);
await expect(
repo.createPinnedForImport("user-1", {
hostId: 2,
path: "/srv/www",
name: "Web",
}),
).resolves.toBe(false);
await expect(
repo.createShortcutForImport("user-1", {
hostId: 1,
path: "/etc/nginx",
name: "Nginx",
}),
).resolves.toBe(true);
await expect(
repo.createShortcutForImport("user-1", {
hostId: 2,
path: "/etc/nginx",
name: "Nginx",
}),
).resolves.toBe(false);
});
it("deletes bookmarks by user, host, and host list", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await repo.upsertRecent("user-1", { hostId: 1, path: "/one" });
await repo.createPinned("user-1", { hostId: 2, path: "/two" });
await repo.createShortcut("user-2", { hostId: 3, path: "/three" });
expect(writeCount).toBe(3);
expect(await repo.deleteByHostId(1)).toBe(1);
expect(await repo.deleteByHostId(1)).toBe(0);
expect(await repo.deleteByHostIds([])).toBe(0);
expect(await repo.deleteByHostIds([2])).toBe(1);
expect(writeCount).toBe(5);
expect(await repo.deleteByUserId("user-2")).toBe(1);
expect(await repo.deleteByUserId("user-2")).toBe(0);
expect(writeCount).toBe(6);
});
});
@@ -1,119 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { TransferRecentRepository } from "../../../database/repositories/transfer-recent-repository.js";
describe("TransferRecentRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): Promise<TransferRecentRepository> {
adapter = new TestSqliteDatabase();
const context = await adapter.connect();
await adapter.exec(`
INSERT INTO users (id, username, password_hash)
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type)
VALUES (1, 'user-1', 'source', '10.0.0.1', 22, 'root', 'password'), (2, 'user-1', 'dest-a', '10.0.0.1', 22, 'root', 'password'), (3, 'user-1', 'dest-b', '10.0.0.1', 22, 'root', 'password'), (4, 'user-2', 'other', '10.0.0.1', 22, 'root', 'password');
`);
return new TransferRecentRepository(context, onWrite);
}
it("upserts, lists, and prunes recent transfer destinations", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await repo.upsertForDestination(
"user-1",
{ sourceHostId: 1, destHostId: 2, destPath: "/var/www" },
"2026-01-01T00:00:00.000Z",
);
await repo.upsertForDestination(
"user-1",
{
sourceHostId: 1,
destHostId: 3,
destPath: "/opt/app",
destPathLabel: "App",
},
"2026-01-02T00:00:00.000Z",
);
await repo.upsertForDestination(
"user-1",
{ sourceHostId: 1, destHostId: 2, destPath: "/var/www" },
"2026-01-03T00:00:00.000Z",
);
const recent = await repo.listBySourceHost("user-1", 1);
expect(recent).toHaveLength(2);
expect(recent.map((entry) => entry.destPath)).toEqual([
"/var/www",
"/opt/app",
]);
expect(recent[0].lastUsed).toBe("2026-01-03T00:00:00.000Z");
expect(recent[0].destPathLabel).toBe("/var/www");
expect(writeCount).toBe(3);
expect(await repo.pruneSourceHost("user-1", 1, 1)).toBe(1);
expect(await repo.pruneSourceHost("user-1", 1, 1)).toBe(0);
expect(await repo.listBySourceHost("user-1", 1)).toHaveLength(1);
expect(writeCount).toBe(4);
});
it("deletes recent transfer destinations by user and host references", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await repo.upsertForDestination("user-1", {
sourceHostId: 1,
destHostId: 2,
destPath: "/one",
});
await repo.upsertForDestination("user-1", {
sourceHostId: 2,
destHostId: 3,
destPath: "/two",
});
await repo.upsertForDestination("user-2", {
sourceHostId: 4,
destHostId: 1,
destPath: "/other",
});
expect(writeCount).toBe(3);
expect(await repo.deleteByHostId(1)).toBe(2);
expect(await repo.deleteByHostId(1)).toBe(0);
expect(writeCount).toBe(4);
await repo.upsertForDestination("user-1", {
sourceHostId: 1,
destHostId: 2,
destPath: "/three",
});
expect(await repo.deleteByHostIds([])).toBe(0);
expect(await repo.deleteByHostIds([2, 3])).toBe(2);
expect(writeCount).toBe(6);
await repo.upsertForDestination("user-2", {
sourceHostId: 4,
destHostId: 1,
destPath: "/last",
});
expect(await repo.deleteByUserId("user-2")).toBe(1);
expect(await repo.deleteByUserId("user-2")).toBe(0);
expect(writeCount).toBe(8);
});
});
@@ -27,7 +27,6 @@ vi.mock("../../../database/repositories/factory.js", () => ({
}),
createCurrentSettingsRepository: () => ({}),
createCurrentSshCredentialUsageRepository: deletingRepo("usage"),
createCurrentFileManagerBookmarkRepository: deletingRepo("bookmarks"),
createCurrentRecentActivityRepository: deletingRepo("activity"),
createCurrentDismissedAlertRepository: deletingRepo("alerts"),
createCurrentHostRepository: deletingRepo("hosts"),
@@ -117,7 +116,6 @@ describe("resetUserPassword", () => {
expect(outcome).toEqual({ status: "reset", dataWiped: true });
expect(calls.deletedFor).toEqual([
"usage:user-1",
"bookmarks:user-1",
"activity:user-1",
"alerts:user-1",
"hosts:user-1",
@@ -0,0 +1,163 @@
/**
* The file-manager host-columns migration.
*
* An upgrade must be lossless: a host that had file manager disabled, a
* custom default path, or SCP legacy mode on must keep that configuration
* once the ssh_data columns are dropped. Running it twice must not duplicate
* or clobber what it moved.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
interface HostRow {
id: number;
enable_file_manager: boolean;
default_path: string | null;
scp_legacy: boolean;
}
interface SettingsRow {
pluginId: string;
scope: string;
scopeId: string | null;
key: string;
value: string | null;
}
const hostRows: HostRow[] = [];
const settingsRows: SettingsRow[] = [];
const pluginSettingsRepository = {
get: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) =>
settingsRows.find(
(row) =>
row.pluginId === pluginId &&
row.scope === scope &&
row.scopeId === scopeId &&
row.key === key,
) ?? null,
set: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: string | null,
) => {
const existing = settingsRows.find(
(row) =>
row.pluginId === pluginId &&
row.scope === scope &&
row.scopeId === scopeId &&
row.key === key,
);
if (existing) {
existing.value = value;
return;
}
settingsRows.push({ pluginId, scope, scopeId, key, value });
},
};
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginSettingsRepository: () => pluginSettingsRepository,
}));
vi.mock("../../database/db/index.js", () => ({
getDb: () => ({
all: async () =>
hostRows.filter(
(row) =>
row.enable_file_manager === false ||
row.default_path !== null ||
row.scp_legacy,
),
}),
}));
const { runFileManagerSettingsMigration } =
await import("../../utils/crypto-migration/file-manager-settings-migration.js");
function storedValue(hostId: number, key: string): unknown {
const row = settingsRows.find(
(entry) => entry.scopeId === String(hostId) && entry.key === key,
);
return row?.value === null || row?.value === undefined
? undefined
: JSON.parse(row.value);
}
beforeEach(() => {
hostRows.length = 0;
settingsRows.length = 0;
});
describe("runFileManagerSettingsMigration", () => {
it("moves a host with file manager disabled into plugin settings", async () => {
hostRows.push({
id: 1,
enable_file_manager: false,
default_path: "/srv/app",
scp_legacy: true,
});
const result = await runFileManagerSettingsMigration();
expect(result.moved).toBe(1);
expect(storedValue(1, "enableFileManager")).toBe(false);
expect(storedValue(1, "defaultPath")).toBe("/srv/app");
expect(storedValue(1, "scpLegacy")).toBe(true);
});
it("does nothing on a fresh install with no non-default hosts", async () => {
const result = await runFileManagerSettingsMigration();
expect(result.moved).toBe(0);
expect(settingsRows).toEqual([]);
});
it("is idempotent: a second run changes nothing", async () => {
hostRows.push({
id: 2,
enable_file_manager: false,
default_path: null,
scp_legacy: false,
});
await runFileManagerSettingsMigration();
const afterFirst = JSON.parse(JSON.stringify(settingsRows));
const second = await runFileManagerSettingsMigration();
expect(second.moved).toBe(0);
expect(second.skipped).toBe(1);
expect(settingsRows).toEqual(afterFirst);
});
it("moves each host independently", async () => {
hostRows.push(
{
id: 1,
enable_file_manager: false,
default_path: null,
scp_legacy: false,
},
{
id: 2,
enable_file_manager: true,
default_path: "/data",
scp_legacy: false,
},
);
const result = await runFileManagerSettingsMigration();
expect(result.moved).toBe(2);
expect(storedValue(1, "enableFileManager")).toBe(false);
expect(storedValue(2, "defaultPath")).toBe("/data");
});
});
@@ -0,0 +1,100 @@
/**
* Moves the three file-manager host columns into the file-manager plugin's
* host-scope settings, before those columns are dropped from ssh_data.
*
* Idempotent: skips a host that already has a file-manager settings row, so
* it is safe to run on every boot. Lossless: reads straight off the
* still-present ssh_data columns, so it must run and finish before those
* columns are ever dropped from schema.ts.
*/
import { sql } from "drizzle-orm";
import { databaseLogger } from "../logger.js";
import { getDb } from "../../database/db/index.js";
import { createCurrentPluginSettingsRepository } from "../../database/repositories/factory.js";
export interface FileManagerSettingsMigrationResult {
moved: number;
skipped: number;
}
interface LegacyFileManagerRow {
id: number;
enable_file_manager: number | boolean | null;
default_path: string | null;
scp_legacy: number | boolean | null;
}
export async function runFileManagerSettingsMigration(): Promise<FileManagerSettingsMigrationResult> {
const result: FileManagerSettingsMigrationResult = { moved: 0, skipped: 0 };
try {
const drizzleDb = getDb();
// Raw SQL, not the typed schema: this runs once schema.ts has already
// dropped these columns, so the query is the only thing left that still
// knows they used to exist.
const rows = await drizzleDb.all<LegacyFileManagerRow>(sql`
SELECT id, enable_file_manager, default_path, scp_legacy
FROM ssh_data
WHERE enable_file_manager = false OR default_path IS NOT NULL
OR scp_legacy = true
`);
if (rows.length === 0) return result;
const pluginSettingsRepository = createCurrentPluginSettingsRepository();
for (const row of rows) {
const scopeId = String(row.id);
const existing = await pluginSettingsRepository.get(
"file-manager",
"host",
scopeId,
"enableFileManager",
);
if (existing && existing.value !== null) {
result.skipped++;
continue;
}
await pluginSettingsRepository.set(
"file-manager",
"host",
scopeId,
"enableFileManager",
JSON.stringify(row.enable_file_manager !== false),
);
await pluginSettingsRepository.set(
"file-manager",
"host",
scopeId,
"defaultPath",
JSON.stringify(row.default_path ?? null),
);
await pluginSettingsRepository.set(
"file-manager",
"host",
scopeId,
"scpLegacy",
JSON.stringify(!!row.scp_legacy),
);
result.moved++;
}
if (result.moved > 0) {
databaseLogger.info(
`Moved file manager settings for ${result.moved} host(s) into plugin settings`,
{ operation: "file_manager_settings_migration", moved: result.moved },
);
}
} catch (error) {
// A failed migration must not stop the backend. Hosts simply keep
// whatever file-manager settings they already had (none, on a fresh run).
databaseLogger.warn("File manager settings migration failed", {
operation: "file_manager_settings_migration",
error: error instanceof Error ? error.message : String(error),
});
}
return result;
}
+30 -15
View File
@@ -1,13 +1,35 @@
import { sql } from "drizzle-orm";
import {
createCurrentDismissedAlertRepository,
createCurrentFileManagerBookmarkRepository,
createCurrentTransferRecentRepository,
createCurrentUserDataExportRepository,
createCurrentUserRepository,
} from "../database/repositories/factory.js";
import { getDb } from "../database/db/index.js";
import { DataCrypto } from "./data-crypto.js";
import { databaseLogger } from "./logger.js";
// The file manager plugin owns these tables now (p_file_manager_*); read by
// raw SQL rather than importing plugin code into core.
async function listFileManagerExportData(userId: string): Promise<{
recent: unknown[];
pinned: unknown[];
shortcuts: unknown[];
transferRecent: unknown[];
}> {
const db = getDb();
const [recent, pinned, shortcuts, transferRecent] = await Promise.all([
db.all(sql`SELECT * FROM p_file_manager_recent WHERE user_id = ${userId}`),
db.all(sql`SELECT * FROM p_file_manager_pinned WHERE user_id = ${userId}`),
db.all(
sql`SELECT * FROM p_file_manager_shortcuts WHERE user_id = ${userId}`,
),
db.all(
sql`SELECT * FROM p_file_manager_transfer_recent WHERE user_id = ${userId}`,
),
]);
return { recent, pinned, shortcuts, transferRecent };
}
interface UserExportData {
version: string;
exportedAt: string;
@@ -90,19 +112,12 @@ class UserDataExport {
: credentials;
}
const [recentFiles, pinnedFiles, shortcuts, transferRecentData] =
await Promise.all([
createCurrentFileManagerBookmarkRepository().listRecentByUserId(
userId,
),
createCurrentFileManagerBookmarkRepository().listPinnedByUserId(
userId,
),
createCurrentFileManagerBookmarkRepository().listShortcutsByUserId(
userId,
),
createCurrentTransferRecentRepository().listByUserId(userId),
]);
const {
recent: recentFiles,
pinned: pinnedFiles,
shortcuts,
transferRecent: transferRecentData,
} = await listFileManagerExportData(userId);
const alerts =
await createCurrentDismissedAlertRepository().listByUserId(userId);
-13
View File
@@ -34,11 +34,6 @@ const AppShell = lazy(() =>
// Full-screen apps opened via query params (e.g. from external links or
// Electron). Plugin views register a standalone component with their tab.
const FileManagerApp = lazy(() =>
import("@/features/file-manager/FileManagerApp").then((m) => ({
default: m.default,
})),
);
const TunnelApp = lazy(() =>
import("@/features/tunnel/TunnelApp").then((m) => ({ default: m.default })),
);
@@ -80,16 +75,8 @@ function FullscreenApp() {
const searchParams = new URLSearchParams(window.location.search);
const view = searchParams.get("view");
const hostId = searchParams.get("hostId");
const path = searchParams.get("path");
switch (view) {
case "file-manager":
return (
<FileManagerApp
hostId={hostId || undefined}
initialPath={path || undefined}
/>
);
case "tunnel":
return <TunnelApp hostId={hostId || undefined} />;
case "tmux-monitor": // --- tmux-monitor ---
+4 -21
View File
@@ -173,7 +173,6 @@ import { RemoteSyncBanner } from "@/components/RemoteSyncBanner.tsx";
import { MigrationNoticeDialog } from "@/components/MigrationNoticeDialog.tsx";
import { dbHealthMonitor } from "@/lib/db-health-monitor";
import { ServerStatusProvider } from "@/lib/ServerStatusContext";
import { TransferMonitor } from "@/features/file-manager/TransferMonitor.tsx";
import { sshHostToHost } from "@/sidebar/HostManagerData";
import { resolveHostTabType } from "@/lib/host-connection-tabs";
import { changeAppLanguage, consumeLoginLanguage } from "@/i18n/i18n";
@@ -195,6 +194,7 @@ import {
type TabShellCallbacks,
} from "@/shell/tab-registry";
import { getPanel, usePanels } from "@/shell/panel-registry";
import { invokeAction } from "@/shell/action-registry";
import { startPluginRuntime, stopPluginRuntime } from "@/plugin-host/loader";
import { usePluginStore } from "@/plugin-host/plugin-store";
import {
@@ -2207,12 +2207,6 @@ export function AppShell({
}
function handleRailClick(view: RailView) {
if (view === "sftp") {
openSingletonTab("sftp");
if (isMobile) setSidebarOpen(false);
return;
}
if (railView === view && sidebarOpen) {
setSidebarOpen(false);
} else {
@@ -2435,18 +2429,6 @@ export function AppShell({
openSingletonTab(type, undefined, undefined, options?.data),
closeTab: (tabId) => closeTab(tabId),
renameTab: (tabId, label) => renameTab(tabId, label),
openFileInEditor: (host, filePath) =>
openTab(host, "files", {
instanceId: newInstanceId(),
restoredSessionId: null,
initialFilePath: filePath,
}),
openFileManager: (host, path) =>
openTab(host, "files", {
instanceId: newInstanceId(),
restoredSessionId: null,
initialPath: path,
}),
openTerminalTab: (host, path) =>
openTab(host, "terminal", {
instanceId: newInstanceId(),
@@ -3032,7 +3014,9 @@ export function AppShell({
onRenameTab={renameTab}
onOpenFileManager={(tabId) => {
const targetTab = tabs.find((t) => t.id === tabId);
if (targetTab?.host) openTab(targetTab.host, "files");
if (targetTab?.host) {
void invokeAction("files.openHost", targetTab.host);
}
}}
onOpenShare={openShareForTab}
isAppFullscreen={isAppFullscreen}
@@ -3195,7 +3179,6 @@ export function AppShell({
/>
</Suspense>
)}
<TransferMonitor />
<Suspense fallback={null}>
<AlertManager userId={userId} loggedIn={!!username} />
</Suspense>
-156
View File
@@ -1,156 +0,0 @@
import { authApi, handleApiError } from "@/main-axios";
/** Row shape shared by the recent / pinned / shortcut list endpoints. */
export interface FileManagerEntry {
id: number;
name: string;
path: string;
lastOpened?: string;
[key: string]: unknown;
}
// FILE MANAGER DATA
// ============================================================================
export async function getRecentFiles(
hostId: number,
): Promise<FileManagerEntry[]> {
try {
const response = await authApi.get("/host/file_manager/recent", {
params: { hostId },
});
return response.data;
} catch (error) {
handleApiError(error, "get recent files");
throw error;
}
}
export async function addRecentFile(
hostId: number,
path: string,
name?: string,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.post("/host/file_manager/recent", {
hostId,
path,
name,
});
return response.data;
} catch (error) {
handleApiError(error, "add recent file");
throw error;
}
}
export async function removeRecentFile(
hostId: number,
path: string,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.delete("/host/file_manager/recent", {
data: { hostId, path },
});
return response.data;
} catch (error) {
handleApiError(error, "remove recent file");
throw error;
}
}
export async function getPinnedFiles(
hostId: number,
): Promise<FileManagerEntry[]> {
try {
const response = await authApi.get("/host/file_manager/pinned", {
params: { hostId },
});
return response.data;
} catch (error) {
handleApiError(error, "get pinned files");
throw error;
}
}
export async function addPinnedFile(
hostId: number,
path: string,
name?: string,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.post("/host/file_manager/pinned", {
hostId,
path,
name,
});
return response.data;
} catch (error) {
handleApiError(error, "add pinned file");
throw error;
}
}
export async function removePinnedFile(
hostId: number,
path: string,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.delete("/host/file_manager/pinned", {
data: { hostId, path },
});
return response.data;
} catch (error) {
handleApiError(error, "remove pinned file");
throw error;
}
}
export async function getFolderShortcuts(
hostId: number,
): Promise<FileManagerEntry[]> {
try {
const response = await authApi.get("/host/file_manager/shortcuts", {
params: { hostId },
});
return response.data;
} catch (error) {
handleApiError(error, "get folder shortcuts");
throw error;
}
}
export async function addFolderShortcut(
hostId: number,
path: string,
name?: string,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.post("/host/file_manager/shortcuts", {
hostId,
path,
name,
});
return response.data;
} catch (error) {
handleApiError(error, "add folder shortcut");
throw error;
}
}
export async function removeFolderShortcut(
hostId: number,
path: string,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.delete("/host/file_manager/shortcuts", {
data: { hostId, path },
});
return response.data;
} catch (error) {
handleApiError(error, "remove folder shortcut");
throw error;
}
}
// ============================================================================
-126
View File
@@ -1,126 +0,0 @@
import { handleApiError, sshHostApi } from "@/main-axios";
import type { FileManagerFile, FileManagerShortcut } from "@/types/index";
interface FileManagerOperation {
name: string;
path: string;
isSSH: boolean;
sshSessionId?: string;
hostId: number;
}
// FILE MANAGER METADATA (Recent, Pinned, Shortcuts)
// ============================================================================
export async function getFileManagerRecent(
hostId: number,
): Promise<FileManagerFile[]> {
try {
const response = await sshHostApi.get(
`/file_manager/recent?hostId=${hostId}`,
);
return response.data || [];
} catch {
return [];
}
}
export async function addFileManagerRecent(
file: FileManagerOperation,
): Promise<Record<string, unknown>> {
try {
const response = await sshHostApi.post("/file_manager/recent", file);
return response.data;
} catch (error) {
handleApiError(error, "add recent file");
}
}
export async function removeFileManagerRecent(
file: FileManagerOperation,
): Promise<Record<string, unknown>> {
try {
const response = await sshHostApi.delete("/file_manager/recent", {
data: file,
});
return response.data;
} catch (error) {
handleApiError(error, "remove recent file");
}
}
export async function getFileManagerPinned(
hostId: number,
): Promise<FileManagerFile[]> {
try {
const response = await sshHostApi.get(
`/file_manager/pinned?hostId=${hostId}`,
);
return response.data || [];
} catch {
return [];
}
}
export async function addFileManagerPinned(
file: FileManagerOperation,
): Promise<Record<string, unknown>> {
try {
const response = await sshHostApi.post("/file_manager/pinned", file);
return response.data;
} catch (error) {
handleApiError(error, "add pinned file");
}
}
export async function removeFileManagerPinned(
file: FileManagerOperation,
): Promise<Record<string, unknown>> {
try {
const response = await sshHostApi.delete("/file_manager/pinned", {
data: file,
});
return response.data;
} catch (error) {
handleApiError(error, "remove pinned file");
}
}
export async function getFileManagerShortcuts(
hostId: number,
): Promise<FileManagerShortcut[]> {
try {
const response = await sshHostApi.get(
`/file_manager/shortcuts?hostId=${hostId}`,
);
return response.data || [];
} catch {
return [];
}
}
export async function addFileManagerShortcut(
shortcut: FileManagerOperation,
): Promise<Record<string, unknown>> {
try {
const response = await sshHostApi.post("/file_manager/shortcuts", shortcut);
return response.data;
} catch (error) {
handleApiError(error, "add shortcut");
}
}
export async function removeFileManagerShortcut(
shortcut: FileManagerOperation,
): Promise<Record<string, unknown>> {
try {
const response = await sshHostApi.delete("/file_manager/shortcuts", {
data: shortcut,
});
return response.data;
} catch (error) {
handleApiError(error, "remove shortcut");
}
}
// ============================================================================
-184
View File
@@ -1,184 +0,0 @@
// Local disk <-> remote SFTP transfers for the desktop app's dual-pane file
// manager. The bytes never pass through the renderer: the Electron main
// process streams them between disk and the backend's existing
// `uploadFileStream` / `downloadFileStream` routes (see
// electron/local-files.cjs), and we only orchestrate + relay progress here.
//
// The renderer deliberately does not choose the URL or the auth headers. It
// names the origin that owns the SSH session ("local" embedded backend or the
// configured Remote Sync server) and the main process resolves the rest, so a
// compromised renderer cannot turn the transfer bridge into an HTTP client.
import type {
LocalTransferOrigin,
LocalTransferProgress,
} from "@/types/electron";
import { getSessionOrigin } from "@/main-axios";
import { getDeviceId } from "@/lib/device-id";
export interface LocalTransferProgressEvent {
transferred: number;
total?: number;
}
type ProgressListener = (event: LocalTransferProgressEvent) => void;
/** Error from the main process; `code` is e.g. "EEXIST" or "EBUSY". */
export class LocalTransferError extends Error {
code?: string;
constructor(message: string, code?: string) {
super(message);
this.name = "LocalTransferError";
this.code = code;
}
}
const progressListeners = new Map<string, ProgressListener>();
let unsubscribeProgress: (() => void) | null = null;
function ensureProgressSubscription() {
if (unsubscribeProgress) return;
const api = window.electronAPI?.localTransfer;
if (!api) return;
unsubscribeProgress = api.onProgress((payload: LocalTransferProgress) => {
const listener = progressListeners.get(payload.transferId);
listener?.({ transferred: payload.transferred, total: payload.total });
});
}
function requireTransferApi() {
const api = window.electronAPI?.localTransfer;
if (!api) {
throw new Error("Local transfers are only available in the desktop app");
}
return api;
}
function transferOriginFor(sessionId: string): LocalTransferOrigin {
return getSessionOrigin(sessionId);
}
/**
* The token the desktop renderer uses for the embedded backend. The `jwt`
* cookie the main process would otherwise rely on lasts only a day after an
* interactive login, while this token (and every request the app makes with
* it) keeps working, so transfers must carry it too. Remote-origin transfers
* are authenticated by the main process from its own Remote Sync state.
*/
function localAuthTokenFor(origin: LocalTransferOrigin): string | undefined {
if (origin !== "local") return undefined;
try {
return localStorage.getItem("jwt") || undefined;
} catch {
return undefined;
}
}
export function createLocalTransferId(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
}
/**
* Streams a file from the local disk into `remoteDir` on the SSH session,
* using the same multipart route the in-browser upload uses.
*/
export async function uploadLocalFileToSession(options: {
sessionId: string;
remoteDir: string;
localPath: string;
fileName: string;
hostId?: number;
transferId?: string;
onProgress?: ProgressListener;
}): Promise<void> {
const api = requireTransferApi();
ensureProgressSubscription();
const transferId =
options.transferId ?? createLocalTransferId("local-upload");
const fields: Record<string, string> = {
sessionId: options.sessionId,
path: options.remoteDir,
};
if (options.hostId !== undefined) fields.hostId = String(options.hostId);
if (options.onProgress) {
progressListeners.set(transferId, options.onProgress);
}
const origin = transferOriginFor(options.sessionId);
try {
const result = await api.upload({
transferId,
origin,
deviceId: getDeviceId() ?? undefined,
authToken: localAuthTokenFor(origin),
fields,
localPath: options.localPath,
fileName: options.fileName,
});
if (result.success !== true) {
throw new LocalTransferError(
result.error || "Upload failed",
result.code,
);
}
} finally {
progressListeners.delete(transferId);
}
}
/**
* Streams a remote file from the SSH session straight to `destPath` on the
* local disk. Refuses to replace an existing file (code "EEXIST") unless
* `overwrite` is set.
*/
export async function downloadSessionFileToLocal(options: {
sessionId: string;
remotePath: string;
destPath: string;
/** The folder the user picked; the main process refuses any destination outside it. */
rootPath: string;
expectedSize?: number;
overwrite?: boolean;
transferId?: string;
onProgress?: ProgressListener;
}): Promise<void> {
const api = requireTransferApi();
ensureProgressSubscription();
const transferId =
options.transferId ?? createLocalTransferId("local-download");
if (options.onProgress) {
progressListeners.set(transferId, options.onProgress);
}
const origin = transferOriginFor(options.sessionId);
try {
const result = await api.download({
transferId,
origin,
deviceId: getDeviceId() ?? undefined,
authToken: localAuthTokenFor(origin),
body: { sessionId: options.sessionId, path: options.remotePath },
destPath: options.destPath,
rootPath: options.rootPath,
expectedSize: options.expectedSize,
overwrite: options.overwrite === true,
});
if (result.success !== true) {
throw new LocalTransferError(
result.error || "Download failed",
result.code,
);
}
} finally {
progressListeners.delete(transferId);
}
}
export async function cancelLocalTransfer(transferId: string): Promise<void> {
const api = window.electronAPI?.localTransfer;
if (!api) return;
await api.cancel(transferId);
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -1,54 +0,0 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { FileManager } from "@/features/file-manager/FileManager.tsx";
import { FullScreenAppWrapper } from "@/features/FullScreenAppWrapper.tsx";
import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx";
interface FileManagerAppProps {
hostId?: string;
initialPath?: string;
}
const FileManagerApp: React.FC<FileManagerAppProps> = ({
hostId,
initialPath,
}) => {
const { t } = useTranslation();
return (
<FullScreenAppWrapper hostId={hostId}>
{(hostConfig, phase) => {
if (phase === "loading") {
return (
<div className="relative h-full w-full">
<ConnectionScreen
status="connecting"
message={t("hosts.loadingHost")}
/>
</div>
);
}
if (!hostConfig) {
return (
<div className="relative h-full w-full">
<ConnectionScreen
status="disconnected"
message={t("hosts.hostNotFound")}
/>
</div>
);
}
return (
<FileManager
initialHost={hostConfig}
initialPath={initialPath}
onClose={() => {}}
/>
);
}}
</FullScreenAppWrapper>
);
};
export default FileManagerApp;
@@ -1,622 +0,0 @@
import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils.ts";
import {
Download,
Edit3,
Copy,
Scissors,
Trash2,
Info,
Upload,
FolderPlus,
FilePlus,
RefreshCw,
Clipboard,
Eye,
Terminal,
Play,
Star,
Bookmark,
FileArchive,
ArrowRightLeft,
Link,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { Kbd, KbdKey, KbdSeparator } from "@/components/kbd.tsx";
const VIEWPORT_PADDING = 16;
interface FileItem {
name: string;
type: "file" | "directory" | "link";
path: string;
size?: number;
modified?: string;
permissions?: string;
owner?: string;
group?: string;
executable?: boolean;
}
interface ContextMenuProps {
x: number;
y: number;
files: FileItem[];
isVisible: boolean;
onClose: () => void;
onDownload?: (files: FileItem[]) => void;
onRename?: (file: FileItem) => void;
onCopy?: (files: FileItem[]) => void;
onCut?: (files: FileItem[]) => void;
onDelete?: (files: FileItem[]) => void;
onProperties?: (file: FileItem) => void;
onUpload?: () => void;
onNewFolder?: () => void;
onNewFile?: () => void;
onRefresh?: () => void;
onPaste?: () => void;
onPreview?: (file: FileItem) => void;
hasClipboard?: boolean;
onDragToDesktop?: () => void;
onOpenTerminal?: (path: string) => void;
onRunExecutable?: (file: FileItem) => void;
onPinFile?: (file: FileItem) => void;
onUnpinFile?: (file: FileItem) => void;
onAddShortcut?: (path: string) => void;
isPinned?: (file: FileItem) => boolean;
currentPath?: string;
onExtractArchive?: (file: FileItem) => void;
onCompress?: (files: FileItem[]) => void;
onCopyPath?: (files: FileItem[]) => void;
onCopyFolderLink?: (path: string) => void;
onTransferToHost?: (files: FileItem[], move: boolean) => void;
}
interface MenuItem {
icon: React.ReactNode;
label: string;
action: () => void;
shortcut?: string;
separator?: boolean;
disabled?: boolean;
danger?: boolean;
}
export function FileManagerContextMenu({
x,
y,
files,
isVisible,
onClose,
onDownload,
onRename,
onCopy,
onCut,
onDelete,
onProperties,
onUpload,
onNewFolder,
onNewFile,
onRefresh,
onPaste,
onPreview,
hasClipboard = false,
onDragToDesktop,
onOpenTerminal,
onRunExecutable,
onPinFile,
onUnpinFile,
onAddShortcut,
isPinned,
currentPath,
onExtractArchive,
onCompress,
onCopyPath,
onCopyFolderLink,
onTransferToHost,
}: ContextMenuProps) {
const { t } = useTranslation();
const menuRef = useRef<HTMLDivElement>(null);
const [menuPosition, setMenuPosition] = useState({ x, y });
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
if (!isVisible) {
setIsMounted(false);
return;
}
setIsMounted(true);
let cleanupFn: (() => void) | null = null;
const timeoutId = setTimeout(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Element;
const menuElement = document.querySelector("[data-context-menu]");
if (!menuElement?.contains(target)) {
onClose();
}
};
const handleRightClick = (event: MouseEvent) => {
event.preventDefault();
onClose();
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
onClose();
}
};
const handleBlur = () => {
onClose();
};
const handleScroll = () => {
onClose();
};
document.addEventListener("mousedown", handleClickOutside, true);
document.addEventListener("contextmenu", handleRightClick);
document.addEventListener("keydown", handleKeyDown);
window.addEventListener("blur", handleBlur);
window.addEventListener("scroll", handleScroll, true);
cleanupFn = () => {
document.removeEventListener("mousedown", handleClickOutside, true);
document.removeEventListener("contextmenu", handleRightClick);
document.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("blur", handleBlur);
window.removeEventListener("scroll", handleScroll, true);
};
}, 50);
return () => {
clearTimeout(timeoutId);
if (cleanupFn) {
cleanupFn();
}
};
}, [isVisible, x, y, onClose]);
useLayoutEffect(() => {
if (!isVisible || !menuRef.current) return;
const menuWidth = menuRef.current.offsetWidth;
const menuHeight = menuRef.current.offsetHeight;
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let adjustedX = x;
let adjustedY = y;
if (x + menuWidth > viewportWidth)
adjustedX = viewportWidth - menuWidth - 10;
if (y + menuHeight > viewportHeight)
adjustedY = Math.max(10, viewportHeight - menuHeight - 10);
adjustedX = Math.max(8, adjustedX);
adjustedY = Math.max(8, adjustedY);
setMenuPosition({ x: adjustedX, y: adjustedY });
}, [isVisible, x, y, files.length]);
const isFileContext = files.length > 0;
const isSingleFile = files.length === 1;
const isMultipleFiles = files.length > 1;
const hasFiles = files.some((f) => f.type === "file");
const hasExecutableFiles = files.some(
(f) => f.type === "file" && f.executable,
);
const menuItems: MenuItem[] = [];
if (isFileContext) {
if (onOpenTerminal) {
const targetPath = isSingleFile
? files[0].type === "directory"
? files[0].path
: files[0].path.substring(0, files[0].path.lastIndexOf("/"))
: files[0].path.substring(0, files[0].path.lastIndexOf("/"));
menuItems.push({
icon: <Terminal className="size-3.5" />,
label:
files[0].type === "directory"
? t("fileManager.openTerminalInFolder")
: t("fileManager.openTerminalInFileLocation"),
action: () => onOpenTerminal(targetPath),
shortcut: "Ctrl+Shift+T",
});
}
if (isSingleFile && hasExecutableFiles && onRunExecutable) {
menuItems.push({
icon: <Play className="size-3.5" />,
label: t("fileManager.run"),
action: () => onRunExecutable(files[0]),
shortcut: "Enter",
});
}
if (
onOpenTerminal ||
(isSingleFile && hasExecutableFiles && onRunExecutable)
) {
menuItems.push({ separator: true } as MenuItem);
}
if (hasFiles && onPreview) {
menuItems.push({
icon: <Eye className="size-3.5" />,
label: t("fileManager.preview"),
action: () => onPreview(files[0]),
disabled: !isSingleFile || files[0].type !== "file",
});
}
if (hasFiles && onDownload) {
menuItems.push({
icon: <Download className="size-3.5" />,
label: isMultipleFiles
? t("fileManager.downloadFiles", { count: files.length })
: t("fileManager.downloadFile"),
action: () => onDownload(files),
shortcut: "Ctrl+D",
});
}
if (isFileContext && onTransferToHost) {
const isOnlyDirectories =
files.length > 0 && files.every((f) => f.type === "directory");
menuItems.push({
icon: <ArrowRightLeft className="size-3.5" />,
label: isMultipleFiles
? t("transfer.copyItemsToHost", { count: files.length })
: isOnlyDirectories && isSingleFile
? t("transfer.copyFolderToHost")
: t("transfer.copyToHost"),
action: () => onTransferToHost(files, false),
});
menuItems.push({
icon: <ArrowRightLeft className="size-3.5" />,
label: isMultipleFiles
? t("transfer.moveItemsToHost", { count: files.length })
: isOnlyDirectories && isSingleFile
? t("transfer.moveFolderToHost")
: t("transfer.moveToHost"),
action: () => onTransferToHost(files, true),
});
}
if (isSingleFile && files[0].type === "file" && onExtractArchive) {
const fileName = files[0].name.toLowerCase();
const isArchive =
fileName.endsWith(".zip") ||
fileName.endsWith(".tar") ||
fileName.endsWith(".tar.gz") ||
fileName.endsWith(".tgz") ||
fileName.endsWith(".tar.bz2") ||
fileName.endsWith(".tbz2") ||
fileName.endsWith(".tar.xz") ||
fileName.endsWith(".gz") ||
fileName.endsWith(".bz2") ||
fileName.endsWith(".xz") ||
fileName.endsWith(".7z") ||
fileName.endsWith(".rar");
if (isArchive) {
menuItems.push({
icon: <FileArchive className="size-3.5" />,
label: t("fileManager.extractArchive"),
action: () => onExtractArchive(files[0]),
shortcut: "Ctrl+E",
});
}
}
if (isFileContext && onCompress) {
menuItems.push({
icon: <FileArchive className="size-3.5" />,
label: isMultipleFiles
? t("fileManager.compressFiles")
: t("fileManager.compressFile"),
action: () => onCompress(files),
shortcut: "Ctrl+Shift+C",
});
}
if (isSingleFile && files[0].type === "file") {
const isCurrentlyPinned = isPinned ? isPinned(files[0]) : false;
if (isCurrentlyPinned && onUnpinFile) {
menuItems.push({
icon: (
<Star className="size-3.5 fill-accent-brand text-accent-brand" />
),
label: t("fileManager.unpinFile"),
action: () => onUnpinFile(files[0]),
});
} else if (!isCurrentlyPinned && onPinFile) {
menuItems.push({
icon: <Star className="size-3.5" />,
label: t("fileManager.pinFile"),
action: () => onPinFile(files[0]),
});
}
}
if (isSingleFile && files[0].type === "directory" && onAddShortcut) {
menuItems.push({
icon: <Bookmark className="size-3.5" />,
label: t("fileManager.addToShortcuts"),
action: () => onAddShortcut(files[0].path),
});
}
if (isSingleFile && files[0].type === "directory" && onCopyFolderLink) {
menuItems.push({
icon: <Link className="size-3.5" />,
label: t("fileManager.copyFolderLink"),
action: () => onCopyFolderLink(files[0].path),
});
}
if (
(hasFiles && (onPreview || onDragToDesktop)) ||
(isSingleFile &&
files[0].type === "file" &&
(onPinFile || onUnpinFile)) ||
(isSingleFile &&
files[0].type === "directory" &&
(onAddShortcut || onCopyFolderLink))
) {
menuItems.push({ separator: true } as MenuItem);
}
if (isSingleFile && onRename) {
menuItems.push({
icon: <Edit3 className="size-3.5" />,
label: t("fileManager.rename"),
action: () => onRename(files[0]),
shortcut: "F6",
});
}
if (onCopy) {
menuItems.push({
icon: <Copy className="size-3.5" />,
label: isMultipleFiles
? t("fileManager.copyFiles", { count: files.length })
: t("fileManager.copy"),
action: () => onCopy(files),
shortcut: "Ctrl+C",
});
}
if (onCut) {
menuItems.push({
icon: <Scissors className="size-3.5" />,
label: isMultipleFiles
? t("fileManager.cutFiles", { count: files.length })
: t("fileManager.cut"),
action: () => onCut(files),
shortcut: "Ctrl+X",
});
}
if (onCopyPath) {
menuItems.push({
icon: <Clipboard className="size-3.5" />,
label: isMultipleFiles
? t("fileManager.copyPaths")
: t("fileManager.copyPath"),
action: () => onCopyPath(files),
shortcut: "Ctrl+Shift+P",
});
}
if ((isSingleFile && onRename) || onCopy || onCut || onCopyPath) {
menuItems.push({ separator: true } as MenuItem);
}
if (isSingleFile && onProperties) {
menuItems.push({
icon: <Info className="size-3.5" />,
label: t("fileManager.properties"),
action: () => onProperties(files[0]),
});
}
if ((isSingleFile && onProperties) || onDelete) {
menuItems.push({ separator: true } as MenuItem);
}
if (onDelete) {
menuItems.push({
icon: <Trash2 className="size-3.5" />,
label: isMultipleFiles
? t("fileManager.deleteFiles", { count: files.length })
: t("fileManager.delete"),
action: () => onDelete(files),
shortcut: "Delete",
danger: true,
});
}
} else {
if (onOpenTerminal && currentPath) {
menuItems.push({
icon: <Terminal className="size-3.5" />,
label: t("fileManager.openTerminalHere"),
action: () => onOpenTerminal(currentPath),
shortcut: "Ctrl+Shift+T",
});
}
if (onUpload) {
menuItems.push({
icon: <Upload className="size-3.5" />,
label: t("fileManager.uploadFile"),
action: onUpload,
shortcut: "Ctrl+U",
});
}
if ((onOpenTerminal && currentPath) || onUpload) {
menuItems.push({ separator: true } as MenuItem);
}
if (onNewFolder) {
menuItems.push({
icon: <FolderPlus className="size-3.5" />,
label: t("fileManager.newFolder"),
action: onNewFolder,
shortcut: "Ctrl+Shift+N",
});
}
if (onNewFile) {
menuItems.push({
icon: <FilePlus className="size-3.5" />,
label: t("fileManager.newFile"),
action: onNewFile,
shortcut: "Ctrl+N",
});
}
if (onNewFolder || onNewFile) {
menuItems.push({ separator: true } as MenuItem);
}
if (onRefresh) {
menuItems.push({
icon: <RefreshCw className="size-3.5" />,
label: t("fileManager.refresh"),
action: onRefresh,
shortcut: "Ctrl+Y",
});
}
if (hasClipboard && onPaste) {
menuItems.push({
icon: <Clipboard className="size-3.5" />,
label: t("fileManager.paste"),
action: onPaste,
shortcut: "Ctrl+V",
});
}
if (onCopyFolderLink && currentPath) {
menuItems.push({ separator: true } as MenuItem);
menuItems.push({
icon: <Link className="size-3.5" />,
label: t("fileManager.copyCurrentFolderLink"),
action: () => onCopyFolderLink(currentPath),
});
}
}
const filteredMenuItems = menuItems.filter((item, index) => {
if (!item.separator) return true;
const prevItem = index > 0 ? menuItems[index - 1] : null;
const nextItem = index < menuItems.length - 1 ? menuItems[index + 1] : null;
if (prevItem?.separator || nextItem?.separator) {
return false;
}
return true;
});
const finalMenuItems = filteredMenuItems.filter((item, index) => {
if (!item.separator) return true;
return index > 0 && index < filteredMenuItems.length - 1;
});
const renderShortcut = (shortcut: string) => {
const keys = shortcut.split("+");
if (keys.length === 1) {
return <Kbd>{keys[0]}</Kbd>;
}
return (
<Kbd>
{keys.map((key, index) => (
<>
<KbdKey key={`key-${index}`}>{key}</KbdKey>
{index < keys.length - 1 && <KbdSeparator key={`sep-${index}`} />}
</>
))}
</Kbd>
);
};
if (!isVisible && !isMounted) return null;
return (
<>
<div
className={cn(
"fixed inset-0 z-[99990] transition-opacity duration-150",
!isMounted && "opacity-0",
)}
/>
<div
ref={menuRef}
data-context-menu
className={cn(
"fixed bg-card border border-border rounded-none shadow-md min-w-[220px] max-w-[300px] z-[99995] overflow-x-hidden overflow-y-auto py-1",
)}
style={{
left: menuPosition.x,
top: menuPosition.y,
maxHeight: `calc(100vh - ${VIEWPORT_PADDING * 2}px)`,
}}
>
{finalMenuItems.map((item, index) => {
if (item.separator) {
return (
<div
key={`separator-${index}`}
className="my-1 border-t border-border"
/>
);
}
return (
<button
key={index}
className={cn(
"w-full px-3 min-h-8 py-1.5 text-left text-xs font-semibold flex items-center justify-between gap-3 rounded-none transition-colors cursor-pointer",
"hover:bg-accent-brand/10 hover:text-accent-brand",
item.disabled &&
"opacity-40 cursor-not-allowed hover:bg-transparent hover:text-current",
item.danger &&
"text-destructive hover:bg-destructive/10 hover:text-destructive",
)}
onClick={() => {
if (!item.disabled) {
item.action();
onClose();
}
}}
disabled={item.disabled}
>
<div className="flex items-center gap-2 flex-1 min-w-0">
<div className="flex-shrink-0 text-muted-foreground">
{item.icon}
</div>
<span className="flex-1 leading-tight">{item.label}</span>
</div>
{item.shortcut && (
<div className="ml-auto flex-shrink-0 opacity-50">
{renderShortcut(item.shortcut)}
</div>
)}
</button>
);
})}
</div>
</>
);
}
@@ -1,131 +0,0 @@
import type { FileItem, SSHHost } from "@/types/index";
import { TOTPDialog } from "@/ssh/dialogs/TOTPDialog.tsx";
import { SSHAuthDialog } from "@/ssh/dialogs/SSHAuthDialog.tsx";
import { WarpgateDialog } from "@/ssh/dialogs/WarpgateDialog.tsx";
import { PermissionsDialog } from "./components/PermissionsDialog.tsx";
import { CompressDialog } from "./components/CompressDialog.tsx";
import { SudoPasswordDialog } from "./SudoPasswordDialog.tsx";
import type { PendingSudoOperation } from "./file-manager-types.ts";
type FileManagerDialogsProps = {
compressDialogFiles: FileItem[];
setCompressDialogFiles: (files: FileItem[]) => void;
handleCompress: (archiveName: string, format: string) => void | Promise<void>;
totpRequired: boolean;
totpPrompt: string;
handleTotpSubmit: (code: string) => void | Promise<void>;
handleTotpCancel: () => void;
warpgateRequired: boolean;
warpgateUrl: string;
warpgateSecurityKey: string;
handleWarpgateContinue: () => void | Promise<void>;
handleWarpgateCancel: () => void;
handleWarpgateOpenUrl: () => void;
currentHost: SSHHost | null;
showAuthDialog: boolean;
authDialogReason: "no_keyboard" | "auth_failed" | "timeout";
handleAuthDialogSubmit: (credentials: {
password?: string;
sshKey?: string;
keyPassword?: string;
}) => void | Promise<void>;
handleAuthDialogCancel: () => void;
permissionsDialogFile: FileItem | null;
setPermissionsDialogFile: (file: FileItem | null) => void;
handleSavePermissions: (file: FileItem, permissions: string) => Promise<void>;
sudoDialogOpen: boolean;
setSudoDialogOpen: (open: boolean) => void;
setPendingSudoOperation: (operation: PendingSudoOperation | null) => void;
handleSudoPasswordSubmit: (password: string) => void | Promise<void>;
};
export function FileManagerDialogs({
compressDialogFiles,
setCompressDialogFiles,
handleCompress,
totpRequired,
totpPrompt,
handleTotpSubmit,
handleTotpCancel,
warpgateRequired,
warpgateUrl,
warpgateSecurityKey,
handleWarpgateContinue,
handleWarpgateCancel,
handleWarpgateOpenUrl,
currentHost,
showAuthDialog,
authDialogReason,
handleAuthDialogSubmit,
handleAuthDialogCancel,
permissionsDialogFile,
setPermissionsDialogFile,
handleSavePermissions,
sudoDialogOpen,
setSudoDialogOpen,
setPendingSudoOperation,
handleSudoPasswordSubmit,
}: FileManagerDialogsProps) {
return (
<>
<CompressDialog
open={compressDialogFiles.length > 0}
onOpenChange={(open) => !open && setCompressDialogFiles([])}
fileNames={compressDialogFiles.map((f) => f.name)}
onCompress={handleCompress}
/>
<TOTPDialog
isOpen={totpRequired}
prompt={totpPrompt}
onSubmit={handleTotpSubmit}
onCancel={handleTotpCancel}
backgroundColor="var(--bg-canvas)"
/>
<WarpgateDialog
isOpen={warpgateRequired}
url={warpgateUrl}
securityKey={warpgateSecurityKey}
onContinue={handleWarpgateContinue}
onCancel={handleWarpgateCancel}
onOpenUrl={handleWarpgateOpenUrl}
backgroundColor="var(--bg-canvas)"
/>
{currentHost && (
<SSHAuthDialog
isOpen={showAuthDialog}
reason={authDialogReason}
onSubmit={handleAuthDialogSubmit}
onCancel={handleAuthDialogCancel}
hostInfo={{
ip: currentHost.ip,
port: currentHost.port,
username: currentHost.username,
name: currentHost.name,
}}
backgroundColor="var(--bg-canvas)"
/>
)}
<PermissionsDialog
file={permissionsDialogFile}
open={permissionsDialogFile !== null}
onOpenChange={(open) => {
if (!open) setPermissionsDialogFile(null);
}}
onSave={handleSavePermissions}
/>
<SudoPasswordDialog
open={sudoDialogOpen}
onOpenChange={(open) => {
setSudoDialogOpen(open);
if (!open) setPendingSudoOperation(null);
}}
onSubmit={handleSudoPasswordSubmit}
/>
</>
);
}
File diff suppressed because it is too large. Load diff
@@ -1,920 +0,0 @@
import React, {
useState,
useEffect,
useRef,
useCallback,
useMemo,
} from "react";
import { cn } from "@/lib/utils.ts";
import {
Star,
Clock,
Bookmark,
File,
Folder,
Trash2,
ChevronDown,
Check,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import type { SSHHost } from "@/types";
import {
getRecentFiles,
getPinnedFiles,
getFolderShortcuts,
listSSHFiles,
removeRecentFile,
removePinnedFile,
removeFolderShortcut,
type DiskFilesystem,
} from "@/main-axios.ts";
import { toast } from "sonner";
import FolderTree from "@/components/folder.tsx";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@/components/dropdown-menu.tsx";
// ─── Interfaces ────────────────────────────────────────────────────────────────
interface RecentFileData {
id: number;
name: string;
path: string;
lastOpened?: string;
[key: string]: unknown;
}
interface PinnedFileData {
id: number;
name: string;
path: string;
[key: string]: unknown;
}
interface ShortcutData {
id: number;
name: string;
path: string;
[key: string]: unknown;
}
interface DirectoryItemData {
name: string;
path: string;
type: string;
[key: string]: unknown;
}
export interface SidebarItem {
id: string;
name: string;
path: string;
type: "recent" | "pinned" | "shortcut" | "folder";
lastAccessed?: string;
isExpanded?: boolean;
children?: SidebarItem[];
}
interface FileManagerSidebarProps {
currentHost: SSHHost;
currentPath: string;
onPathChange: (path: string) => void;
onFileOpen?: (file: SidebarItem) => void;
/** Full file-manager context menu (same as main grid). */
onItemContextMenu?: (event: React.MouseEvent, item: SidebarItem) => void;
sshSessionId?: string;
refreshTrigger?: number;
onOpenTrash?: () => void;
diskInfo?: {
usedHuman: string;
totalHuman: string;
percent: number;
mount?: string | null;
filesystems?: DiskFilesystem[];
};
}
// ─── Storage meter ─────────────────────────────────────────────────────────────
function StorageMeter({
storage,
filesystems,
selectedMount,
onSelectMount,
}: {
storage: { percent: number; usedHuman: string; totalHuman: string };
filesystems: DiskFilesystem[];
selectedMount: string | null;
onSelectMount: (mount: string) => void;
}) {
const { t } = useTranslation();
const hasPicker = filesystems.length > 1;
return (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-2 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{hasPicker ? (
<DropdownMenu>
<DropdownMenuTrigger className="flex min-w-0 items-center gap-1 transition-colors hover:text-foreground">
<span className="truncate">
{selectedMount ?? t("fileManager.disk")}
</span>
<ChevronDown className="size-3 shrink-0" />
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="w-auto min-w-[220px] max-w-[min(360px,calc(100vw-2rem))]"
>
{filesystems.map((fs) => (
<DropdownMenuItem
key={fs.mount}
onSelect={() => onSelectMount(fs.mount)}
className="gap-3"
>
<Check
className={cn(
"size-3 shrink-0",
fs.mount === selectedMount ? "opacity-100" : "opacity-0",
)}
/>
<span className="min-w-0 flex-1 truncate font-medium">
{fs.mount}
</span>
<span className="ml-auto shrink-0 whitespace-nowrap text-[10px] tabular-nums text-muted-foreground">
{fs.usedHuman && fs.totalHuman
? `${fs.usedHuman}/${fs.totalHuman}`
: "N/A"}
{fs.percent != null ? ` · ${fs.percent}%` : ""}
</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : (
<span className="truncate">
{selectedMount ?? t("fileManager.disk")}
</span>
)}
<span className="shrink-0 text-accent-brand">
{storage.percent}% {t("fileManager.used")}
</span>
</div>
<div className="h-1.5 bg-muted rounded-none overflow-hidden border border-border">
<div
className="h-full bg-accent-brand"
style={{ width: `${storage.percent}%` }}
/>
</div>
<span className="text-[10px] font-bold text-muted-foreground/60 tracking-tight">
{storage.usedHuman} {t("fileManager.of")} {storage.totalHuman}{" "}
{t("fileManager.used").toLowerCase()}
</span>
</div>
);
}
// ─── Component ─────────────────────────────────────────────────────────────────
export function FileManagerSidebar({
currentHost,
currentPath,
onPathChange,
onFileOpen,
onItemContextMenu,
sshSessionId,
refreshTrigger,
onOpenTrash,
diskInfo,
}: FileManagerSidebarProps) {
const { t } = useTranslation();
// ── Quick access state (API-backed) ──────────────────────────────────────────
const [recentItems, setRecentItems] = useState<SidebarItem[]>([]);
const [pinnedItems, setPinnedItems] = useState<SidebarItem[]>([]);
const [shortcuts, setShortcuts] = useState<SidebarItem[]>([]);
// ── Directory tree state ──────────────────────────────────────────────────────
const [directoryTree, setDirectoryTree] = useState<SidebarItem[]>([]);
// ── Storage state ─────────────────────────────────────────────────────────────
const [selectedMount, setSelectedMount] = useState<string | null>(null);
/**
* Tracks which folder paths have already been lazy-loaded so we don't
* re-fetch on every re-selection / collapse-reopen.
*/
const loadedFoldersRef = useRef<Set<string>>(new Set(["/"]));
// ── Context menu state ────────────────────────────────────────────────────────
const [contextMenu, setContextMenu] = useState<{
x: number;
y: number;
isVisible: boolean;
item: SidebarItem | null;
}>({
x: 0,
y: 0,
isVisible: false,
item: null,
});
// ─── Effects ──────────────────────────────────────────────────────────────────
// ─── API: Quick access ────────────────────────────────────────────────────────
const loadQuickAccessData = useCallback(async () => {
if (!currentHost?.id) return;
try {
const recentData = await getRecentFiles(currentHost.id);
const recentItems = (recentData as unknown as RecentFileData[])
.slice(0, 5)
.map((item: RecentFileData) => ({
id: `recent-${item.id}`,
name: item.name,
path: item.path,
type: "recent" as const,
lastAccessed: item.lastOpened,
}));
setRecentItems(recentItems);
const pinnedData = await getPinnedFiles(currentHost.id);
const pinnedItems = (pinnedData as unknown as PinnedFileData[]).map(
(item: PinnedFileData) => ({
id: `pinned-${item.id}`,
name: item.name,
path: item.path,
type: "pinned" as const,
}),
);
setPinnedItems(pinnedItems);
const shortcutData = await getFolderShortcuts(currentHost.id);
const shortcutItems = (shortcutData as unknown as ShortcutData[]).map(
(item: ShortcutData) => ({
id: `shortcut-${item.id}`,
name: item.name,
path: item.path,
type: "shortcut" as const,
}),
);
setShortcuts(shortcutItems);
} catch (error) {
console.error("Failed to load quick access data:", error);
setRecentItems([]);
setPinnedItems([]);
setShortcuts([]);
}
}, [currentHost?.id]);
// ─── API: Directory tree ──────────────────────────────────────────────────────
const loadDirectoryTree = useCallback(
async (attempt = 0) => {
if (!sshSessionId) return;
try {
const response = await listSSHFiles(sshSessionId, "/");
const rootFiles = (response.files || []) as DirectoryItemData[];
const rootFolders = rootFiles
.filter((item: DirectoryItemData) => item.type === "directory")
.sort((a, b) =>
a.name.localeCompare(b.name, undefined, { sensitivity: "base" }),
);
const rootTreeItems = rootFolders.map((folder: DirectoryItemData) => ({
id: `folder-${folder.name}`,
name: folder.name,
path: folder.path,
type: "folder" as const,
isExpanded: false,
children: [],
}));
setDirectoryTree([
{
id: "root",
name: "/",
path: "/",
type: "folder" as const,
isExpanded: true,
children: rootTreeItems,
},
]);
} catch (error: unknown) {
const status =
(error as { status?: number })?.status ||
(error as { response?: { status?: number } })?.response?.status;
if (status === 409 && attempt < 3) {
// Another request was already listing "/" — retry after a short delay
setTimeout(() => loadDirectoryTree(attempt + 1), 600);
return;
}
console.error("Failed to load directory tree:", error);
setDirectoryTree([
{
id: "root",
name: "/",
path: "/",
type: "folder" as const,
isExpanded: false,
children: [],
},
]);
}
},
[sshSessionId],
);
/**
* Lazily fetches subdirectory contents and patches them into the tree state.
* Called the first time a folder is expanded via FolderTree's onSelect.
*/
const loadSubdirectory = useCallback(
async (folderId: string, folderPath: string): Promise<boolean> => {
if (!sshSessionId) return false;
try {
const subResponse = await listSSHFiles(sshSessionId, folderPath);
const subFiles = (subResponse.files || []) as DirectoryItemData[];
const subFolders = subFiles
.filter((item: DirectoryItemData) => item.type === "directory")
.sort((a, b) =>
a.name.localeCompare(b.name, undefined, { sensitivity: "base" }),
);
const subTreeItems = subFolders.map((folder: DirectoryItemData) => ({
id: `folder-${folder.path.replace(/\//g, "-")}`,
name: folder.name,
path: folder.path,
type: "folder" as const,
isExpanded: false,
children: [],
}));
setDirectoryTree((prevTree) => {
const updateChildren = (items: SidebarItem[]): SidebarItem[] =>
items.map((item) => {
if (item.id === folderId) {
return { ...item, children: subTreeItems };
}
if (item.children) {
return { ...item, children: updateChildren(item.children) };
}
return item;
});
return updateChildren(prevTree);
});
loadedFoldersRef.current.add(folderPath);
return true;
} catch (error: unknown) {
const status =
(error as { status?: number })?.status ||
(error as { response?: { status?: number } })?.response?.status;
if (status === 409) {
// Another request was listing this path — retry after the lock clears
setTimeout(() => void loadSubdirectory(folderId, folderPath), 600);
return false;
}
loadedFoldersRef.current.delete(folderPath);
console.error("Failed to load subdirectory:", error);
return false;
}
},
[sshSessionId],
);
useEffect(() => {
loadQuickAccessData();
}, [loadQuickAccessData, refreshTrigger]);
useEffect(() => {
if (sshSessionId) {
loadedFoldersRef.current = new Set(["/"]);
loadDirectoryTree();
}
}, [loadDirectoryTree, sshSessionId]);
// When currentPath changes externally (grid navigation), ensure the parent
// directory is loaded in the tree so the selection highlight can appear.
useEffect(() => {
if (!sshSessionId || currentPath === "/") return;
const parentPath =
currentPath.substring(0, currentPath.lastIndexOf("/")) || "/";
const findByPath = (items: SidebarItem[]): SidebarItem | null => {
for (const item of items) {
if (item.path === parentPath) return item;
if (item.children) {
const found = findByPath(item.children);
if (found) return found;
}
}
return null;
};
const parent = findByPath(directoryTree);
if (parent && !loadedFoldersRef.current.has(parent.path)) {
void loadSubdirectory(parent.id, parent.path);
}
}, [currentPath, directoryTree, loadSubdirectory, sshSessionId]);
// ─── Quick-access mutation handlers ──────────────────────────────────────────
const handleRemoveRecentFile = async (item: SidebarItem) => {
if (!currentHost?.id) return;
try {
await removeRecentFile(currentHost.id, item.path);
loadQuickAccessData();
toast.success(
t("fileManager.removedFromRecentFiles", { name: item.name }),
);
} catch (error) {
console.error("Failed to remove recent file:", error);
toast.error(t("fileManager.removeFailed"));
}
};
const handleUnpinFile = async (item: SidebarItem) => {
if (!currentHost?.id) return;
try {
await removePinnedFile(currentHost.id, item.path);
loadQuickAccessData();
toast.success(t("fileManager.unpinnedSuccessfully", { name: item.name }));
} catch (error) {
console.error("Failed to unpin file:", error);
toast.error(t("fileManager.unpinFailed"));
}
};
const handleRemoveShortcut = async (item: SidebarItem) => {
if (!currentHost?.id) return;
try {
await removeFolderShortcut(currentHost.id, item.path);
loadQuickAccessData();
toast.success(t("fileManager.removedShortcut", { name: item.name }));
} catch (error) {
console.error("Failed to remove shortcut:", error);
toast.error(t("fileManager.removeShortcutFailed"));
}
};
const handleClearAllRecent = async () => {
if (!currentHost?.id || recentItems.length === 0) return;
try {
await Promise.all(
recentItems.map((item) => removeRecentFile(currentHost.id, item.path)),
);
loadQuickAccessData();
toast.success(t("fileManager.clearedAllRecentFiles"));
} catch (error) {
console.error("Failed to clear recent files:", error);
toast.error(t("fileManager.clearFailed"));
}
};
// ─── Quick-access item click ──────────────────────────────────────────────────
const handleQuickAccessClick = (item: SidebarItem) => {
if (item.type === "recent" || item.type === "pinned") {
if (onFileOpen) {
onFileOpen(item);
} else {
const directory =
item.path.substring(0, item.path.lastIndexOf("/")) || "/";
onPathChange(directory);
}
} else if (item.type === "shortcut") {
onPathChange(item.path);
}
};
// ─── FolderTree directory selection (onSelect callback) ──────────────────────
/**
* Called by FolderTree whenever the user selects (clicks) a tree item.
* We navigate to the folder and lazily load children on first visit.
*/
const handleDirectorySelect = useCallback(
async (id: string) => {
// Walk the tree to find the item by id
const findItem = (items: SidebarItem[]): SidebarItem | null => {
for (const item of items) {
if (item.id === id) return item;
if (item.children) {
const found = findItem(item.children);
if (found) return found;
}
}
return null;
};
const item = findItem(directoryTree);
if (!item) return;
// Navigate to path
onPathChange(item.path);
// Lazy-load children the first time this folder is expanded
if (
sshSessionId &&
item.path !== "/" &&
!loadedFoldersRef.current.has(item.path)
) {
await loadSubdirectory(id, item.path);
}
},
[directoryTree, onPathChange, sshSessionId, loadSubdirectory],
);
// ─── Context menu ─────────────────────────────────────────────────────────────
const findTreeItemById = useCallback(
(items: SidebarItem[], id: string): SidebarItem | null => {
for (const item of items) {
if (item.id === id) return item;
if (item.children) {
const found = findTreeItemById(item.children, id);
if (found) return found;
}
}
return null;
},
[],
);
const handleItemContextMenu = (e: React.MouseEvent, item: SidebarItem) => {
e.preventDefault();
e.stopPropagation();
if (onItemContextMenu) {
onItemContextMenu(e, item);
return;
}
setContextMenu({ x: e.clientX, y: e.clientY, isVisible: true, item });
};
const handleTreeContextMenu = (e: React.MouseEvent) => {
const target = e.target as HTMLElement;
const row = target.closest<HTMLElement>("[data-id]");
if (!row) return;
const id = row.getAttribute("data-id");
if (!id) return;
const item = findTreeItemById(directoryTree, id);
if (!item || item.type !== "folder") return;
handleItemContextMenu(e, item);
};
const closeContextMenu = () => {
setContextMenu((prev) => ({ ...prev, isVisible: false, item: null }));
};
useEffect(() => {
if (!contextMenu.isVisible || onItemContextMenu) return;
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Element;
const menuElement = document.querySelector("[data-sidebar-context-menu]");
if (!menuElement?.contains(target)) closeContextMenu();
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") closeContextMenu();
};
const timeoutId = setTimeout(() => {
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleKeyDown);
}, 50);
return () => {
clearTimeout(timeoutId);
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleKeyDown);
};
}, [contextMenu.isVisible, onItemContextMenu]);
// ─── Derive selected tree node + ancestors from currentPath ──────────────────
const { selectedTreeId, ancestorIds } = useMemo(() => {
if (currentPath === "/")
return { selectedTreeId: "root", ancestorIds: new Set<string>() };
const ancestors: string[] = [];
const findByPath = (
items: SidebarItem[],
path: string[],
): string | null => {
for (const item of items) {
if (item.path === currentPath) {
ancestors.push(...path, "root");
return item.id;
}
if (item.children) {
const found = findByPath(item.children, [...path, item.id]);
if (found) return found;
}
}
return null;
};
const id = findByPath(directoryTree, []);
return { selectedTreeId: id, ancestorIds: new Set(ancestors) };
}, [currentPath, directoryTree]);
// ─── Render helpers ───────────────────────────────────────────────────────────
/**
* Recursively renders directory tree items using FolderTree.Item + Content.
*
* FolderTree.Item detects "has children" via React.Children.count > 0.
* By always wrapping children in <FolderTree.Content> (even when the
* children array is empty), every directory shows the expand chevron.
* FolderTree.Content internally shows nothing when its own children are
* absent, so an unloaded folder simply expands to an empty state while
* the async fetch fills it in.
*/
const renderFolderTreeItem = (item: SidebarItem): React.ReactNode => (
<FolderTree.Item key={item.id} id={item.id} label={item.name}>
<FolderTree.Content>
{item.children?.map((child) => renderFolderTreeItem(child))}
</FolderTree.Content>
</FolderTree.Item>
);
const renderQuickAccessItem = (item: SidebarItem, icon: React.ReactNode) => {
const dirPath =
item.type === "shortcut"
? item.path
: item.path.substring(0, item.path.lastIndexOf("/")) || "/";
const isActive = currentPath === dirPath;
return (
<button
key={item.id}
className={cn(
"w-full flex items-center gap-2.5 px-3 py-1.5 text-[11px] font-bold uppercase tracking-wider transition-colors text-left border-l-2",
isActive
? "bg-accent-brand/10 text-accent-brand border-accent-brand"
: "text-muted-foreground hover:text-foreground hover:bg-muted border-transparent",
)}
onClick={() => handleQuickAccessClick(item)}
onContextMenu={(e) => handleItemContextMenu(e, item)}
title={item.path}
>
<div className="shrink-0">{icon}</div>
<span className="flex-1 truncate">{item.name}</span>
</button>
);
};
const renderSection = (
title: string,
items: SidebarItem[],
renderItem: (item: SidebarItem) => React.ReactNode,
) => {
if (items.length === 0) return null;
return (
<div>
<div className="px-3 py-1.5">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{title}
</span>
</div>
{items.map((item) => renderItem(item))}
</div>
);
};
const hasQuickAccessItems =
recentItems.length > 0 || pinnedItems.length > 0 || shortcuts.length > 0;
// ─── Render ───────────────────────────────────────────────────────────────────
const storageFilesystems = diskInfo?.filesystems ?? [];
const activeStorageMount = selectedMount ?? diskInfo?.mount ?? null;
const selectedFs =
selectedMount !== null
? (storageFilesystems.find((fs) => fs.mount === selectedMount) ?? null)
: null;
const storage =
selectedFs && selectedFs.percent !== null
? {
percent: selectedFs.percent,
usedHuman: selectedFs.usedHuman ?? "N/A",
totalHuman: selectedFs.totalHuman ?? "N/A",
}
: diskInfo && diskInfo.percent !== null
? {
percent: diskInfo.percent,
usedHuman: diskInfo.usedHuman,
totalHuman: diskInfo.totalHuman,
}
: null;
return (
<>
<div className="h-full flex flex-col bg-card overflow-hidden">
<div className="flex-1 overflow-y-auto thin-scrollbar">
{onOpenTrash && (
<button
className="w-full flex items-center gap-2.5 px-3 py-2 text-[11px] font-bold uppercase tracking-wider text-muted-foreground hover:text-foreground hover:bg-muted border-l-2 border-transparent"
onClick={onOpenTrash}
>
<Trash2 className="size-3.5 shrink-0" />
<span>{t("fileManager.trash")}</span>
</button>
)}
{/* ── Recent files ──────────────────────────────────────── */}
{renderSection(t("fileManager.recent"), recentItems, (item) =>
renderQuickAccessItem(
item,
<File
className={cn(
"size-3.5 shrink-0",
currentPath ===
(item.path.substring(0, item.path.lastIndexOf("/")) || "/")
? "text-accent-brand"
: "text-muted-foreground/60",
)}
/>,
),
)}
{/* ── Pinned files ───────────────────────────────────────── */}
{renderSection(t("fileManager.pinned"), pinnedItems, (item) =>
renderQuickAccessItem(
item,
<Star
className={cn(
"size-3.5 shrink-0",
currentPath ===
(item.path.substring(0, item.path.lastIndexOf("/")) || "/")
? "text-accent-brand fill-accent-brand"
: "text-muted-foreground/60",
)}
/>,
),
)}
{/* ── Folder shortcuts ───────────────────────────────────── */}
{renderSection(t("fileManager.folderShortcuts"), shortcuts, (item) =>
renderQuickAccessItem(
item,
<Folder
className={cn(
"size-3.5 shrink-0",
currentPath === item.path
? "text-accent-brand"
: "text-muted-foreground/60",
)}
/>,
),
)}
{/* ── Directory tree ─────────────────────────────────────── */}
<div
className={cn(
hasQuickAccessItems && "border-t border-border mt-1 pt-1",
)}
>
<div className="px-3 py-1.5">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("fileManager.directories")}
</span>
</div>
<div className="px-1">
<div onContextMenu={handleTreeContextMenu}>
<FolderTree.Root
id="sidebar-directory-tree"
defaultExpanded={["root"]}
selectedId={selectedTreeId}
expandedIds={ancestorIds}
onSelect={(id) => handleDirectorySelect(id)}
className="bg-transparent border-0 rounded-none shadow-none"
>
{directoryTree.map((item) => renderFolderTreeItem(item))}
</FolderTree.Root>
</div>
</div>
</div>
{/* ── Storage — mobile only (inside scroll) ──────────────── */}
{storage && (
<div className="md:hidden">
<div className="border-t border-border mx-0 my-2" />
<div className="px-3 py-1.5">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("fileManager.storage")}
</span>
</div>
<div className="px-3 pb-3">
<StorageMeter
storage={storage}
filesystems={storageFilesystems}
selectedMount={activeStorageMount}
onSelectMount={setSelectedMount}
/>
</div>
</div>
)}
</div>
{/* ── Storage — desktop only (bottom of sidebar) ──────────── */}
{storage && (
<div className="hidden md:flex flex-col p-3 gap-2 border-t border-border shrink-0">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("fileManager.storage")}
</span>
<StorageMeter
storage={storage}
filesystems={storageFilesystems}
selectedMount={activeStorageMount}
onSelectMount={setSelectedMount}
/>
</div>
)}
</div>
{/* ── Context menu (fallback when parent does not supply onItemContextMenu) */}
{!onItemContextMenu && contextMenu.isVisible && contextMenu.item && (
<>
<div className="fixed inset-0 z-40" />
<div
data-sidebar-context-menu
className="fixed bg-card border border-border rounded-none shadow-xl min-w-[180px] z-50 overflow-hidden"
style={{ left: contextMenu.x, top: contextMenu.y }}
>
{contextMenu.item.type === "recent" && (
<>
<button
className="w-full px-3 h-9 text-left text-[10px] font-bold uppercase tracking-widest flex items-center gap-2.5 hover:bg-accent-brand/10 hover:text-accent-brand text-muted-foreground transition-colors"
onClick={() => {
handleRemoveRecentFile(contextMenu.item!);
closeContextMenu();
}}
>
<Clock className="size-3.5 shrink-0" />
<span className="flex-1">
{t("fileManager.removeFromRecentFiles")}
</span>
</button>
{recentItems.length > 1 && (
<>
<div className="border-t border-border" />
<button
className="w-full px-3 h-9 text-left text-[10px] font-bold uppercase tracking-widest flex items-center gap-2.5 text-destructive hover:bg-destructive/10 transition-colors"
onClick={() => {
handleClearAllRecent();
closeContextMenu();
}}
>
<Clock className="size-3.5 shrink-0" />
<span className="flex-1">
{t("fileManager.clearAllRecentFiles")}
</span>
</button>
</>
)}
</>
)}
{contextMenu.item.type === "pinned" && (
<button
className="w-full px-3 h-9 text-left text-[10px] font-bold uppercase tracking-widest flex items-center gap-2.5 hover:bg-accent-brand/10 hover:text-accent-brand text-muted-foreground transition-colors"
onClick={() => {
handleUnpinFile(contextMenu.item!);
closeContextMenu();
}}
>
<Star className="size-3.5 shrink-0" />
<span className="flex-1">{t("fileManager.unpinFile")}</span>
</button>
)}
{contextMenu.item.type === "shortcut" && (
<button
className="w-full px-3 h-9 text-left text-[10px] font-bold uppercase tracking-widest flex items-center gap-2.5 hover:bg-accent-brand/10 hover:text-accent-brand text-muted-foreground transition-colors"
onClick={() => {
handleRemoveShortcut(contextMenu.item!);
closeContextMenu();
}}
>
<Bookmark className="size-3.5 shrink-0" />
<span className="flex-1">
{t("fileManager.removeShortcut")}
</span>
</button>
)}
</div>
</>
)}
</>
);
}
@@ -1,498 +0,0 @@
import React, { useEffect, useRef, useState } from "react";
import {
ArrowUp,
ChevronLeft,
ChevronRight,
Copy,
FilePlus,
Folder,
FolderPlus,
Grid3X3,
Laptop,
Layout,
List,
Plus,
RefreshCw,
Rows3,
Search,
Trash2,
Upload,
} from "lucide-react";
import { Button } from "@/components/button.tsx";
import { Input } from "@/components/input.tsx";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/dropdown-menu.tsx";
import type { FileItem } from "@/types/index";
type SortBy = "name" | "modified" | "size";
type SortOrder = "asc" | "desc";
type ViewMode = "grid" | "list";
type Density = "comfortable" | "compact";
type FileManagerToolbarProps = {
t: (key: string) => string;
currentPath: string;
navIndex: number;
navHistoryLength: number;
isLoading: boolean;
sshSessionId: string | null;
selectedFiles: FileItem[];
searchQuery: string;
setSearchQuery: (query: string) => void;
viewMode: ViewMode;
setViewMode: (mode: ViewMode) => void;
density: Density;
setDensity: (density: Density) => void;
sortBy: SortBy;
setSortBy: (sortBy: SortBy) => void;
sortOrder: SortOrder;
setSortOrder: (sortOrder: SortOrder) => void;
setMobileSidebarOpen: (updater: (open: boolean) => boolean) => void;
goBack: () => void;
goForward: () => void;
goUp: () => void;
navigateTo: (path: string) => void;
handleRefreshDirectory: () => void;
handleDeleteFiles: (files: FileItem[]) => void;
handleCopyFiles: (files: FileItem[]) => void;
handleFilesDropped: (fileList: FileList) => void;
handleCreateNewFolder: () => void;
handleCreateNewFile: () => void;
/** Desktop app only: show the Local | Remote split-view toggle. */
showLocalPaneToggle?: boolean;
localPaneOpen?: boolean;
onToggleLocalPane?: () => void;
/** Desktop directories sidebar (mobile uses the overlay instead). */
sidebarOpen?: boolean;
onToggleSidebar?: () => void;
};
function Breadcrumb({
currentPath,
navigateTo,
t,
}: Pick<FileManagerToolbarProps, "currentPath" | "navigateTo" | "t">) {
return (
<>
<Folder className="size-3.5 text-accent-brand shrink-0" />
<div className="flex items-center gap-1 overflow-x-auto scrollbar-none text-[10px] font-bold uppercase tracking-widest whitespace-nowrap">
{currentPath.split("/").map((part, i, arr) => (
<React.Fragment key={i}>
{part === "" && i === 0 ? (
<button
onClick={(e) => {
e.stopPropagation();
navigateTo("/");
}}
className="hover:text-accent-brand transition-colors"
>
{t("fileManager.root")}
</button>
) : part !== "" ? (
<button
onClick={(e) => {
e.stopPropagation();
navigateTo(arr.slice(0, i + 1).join("/") || "/");
}}
className="hover:text-accent-brand transition-colors"
>
{part}
</button>
) : null}
{i < arr.length - 1 && part !== "" && (
<ChevronRight className="size-3 text-muted-foreground shrink-0" />
)}
{i === 0 && arr.length > 1 && part === "" && (
<ChevronRight className="size-3 text-muted-foreground shrink-0" />
)}
</React.Fragment>
))}
</div>
</>
);
}
function PathBar({
currentPath,
navigateTo,
t,
className,
}: Pick<FileManagerToolbarProps, "currentPath" | "navigateTo" | "t"> & {
className: string;
}) {
const [isEditing, setIsEditing] = useState(false);
const [value, setValue] = useState(currentPath);
const inputRef = useRef<HTMLInputElement>(null);
const doneRef = useRef(false);
useEffect(() => {
if (!isEditing) return;
doneRef.current = false;
const timer = setTimeout(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, 0);
return () => clearTimeout(timer);
}, [isEditing]);
const commit = (path: string) => {
if (doneRef.current) return;
doneRef.current = true;
setIsEditing(false);
const trimmed = path.trim();
if (trimmed && trimmed !== currentPath) {
navigateTo(trimmed);
}
};
const cancel = () => {
if (doneRef.current) return;
doneRef.current = true;
setIsEditing(false);
};
if (isEditing) {
return (
<div className={className}>
<Folder className="size-3.5 text-accent-brand shrink-0" />
<input
ref={inputRef}
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
commit(value);
} else if (e.key === "Escape") {
e.preventDefault();
cancel();
}
}}
onBlur={() => commit(value)}
className="flex-1 min-w-0 bg-transparent text-xs font-semibold tracking-wide outline-none text-foreground"
/>
</div>
);
}
return (
<div
className={`${className} cursor-text`}
onClick={() => {
setValue(currentPath);
setIsEditing(true);
}}
>
<Breadcrumb currentPath={currentPath} navigateTo={navigateTo} t={t} />
</div>
);
}
export function FileManagerToolbar({
t,
currentPath,
navIndex,
navHistoryLength,
isLoading,
sshSessionId,
selectedFiles,
searchQuery,
setSearchQuery,
viewMode,
setViewMode,
density,
setDensity,
sortBy,
setSortBy,
sortOrder,
setSortOrder,
setMobileSidebarOpen,
goBack,
goForward,
goUp,
navigateTo,
handleRefreshDirectory,
handleDeleteFiles,
handleCopyFiles,
handleFilesDropped,
handleCreateNewFolder,
handleCreateNewFile,
showLocalPaneToggle = false,
localPaneOpen = false,
onToggleLocalPane,
sidebarOpen = true,
onToggleSidebar,
}: FileManagerToolbarProps) {
return (
<div className="flex flex-col shrink-0 mx-3 mt-3 border border-border bg-card">
<div className="flex flex-row items-center justify-between px-3 py-2 gap-2">
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => {
// Below md the sidebar is an overlay; above it, a persisted
// show/hide of the directories panel.
const isDesktop =
typeof window !== "undefined" &&
window.matchMedia("(min-width: 768px)").matches;
if (isDesktop && onToggleSidebar) onToggleSidebar();
else setMobileSidebarOpen((open) => !open);
}}
className={`size-8 rounded-none ${sidebarOpen ? "" : "md:bg-accent-brand/10 md:text-accent-brand"}`}
title={t("fileManager.toggleSidebar")}
aria-pressed={!sidebarOpen}
>
<Layout className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={goBack}
disabled={navIndex <= 0}
className="size-8 rounded-none"
>
<ChevronLeft className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={goForward}
disabled={navIndex >= navHistoryLength - 1}
className="size-8 rounded-none"
>
<ChevronRight className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={goUp}
disabled={currentPath === "/"}
className="size-8 rounded-none"
>
<ArrowUp className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={handleRefreshDirectory}
className="size-8 rounded-none"
>
<RefreshCw
className={`size-4 ${isLoading && !!sshSessionId ? "animate-spin [animation-duration:0.5s]" : ""}`}
/>
</Button>
</div>
<PathBar
currentPath={currentPath}
navigateTo={navigateTo}
t={t}
className="hidden md:flex flex-1 items-center px-3 h-8 bg-muted/50 border border-border rounded-none gap-2 overflow-hidden"
/>
<div className="flex items-center gap-2">
{selectedFiles.length > 0 && (
<div className="flex items-center gap-1 px-2 py-1 bg-accent-brand/10 border border-accent-brand/20 text-accent-brand text-[10px] font-black uppercase tracking-tighter">
<Button
variant="ghost"
size="icon"
className="size-6 text-accent-brand hover:bg-accent-brand/20 rounded-none"
onClick={() => handleDeleteFiles(selectedFiles)}
>
<Trash2 className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-6 text-accent-brand hover:bg-accent-brand/20 rounded-none"
onClick={() => handleCopyFiles(selectedFiles)}
>
<Copy className="size-3.5" />
</Button>
</div>
)}
<div className="relative w-28 md:w-48">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
<Input
placeholder={t("fileManager.searchFiles")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-8 pl-8 text-xs bg-muted/50 border-border rounded-none focus:ring-1 focus:ring-accent-brand/50"
/>
</div>
{showLocalPaneToggle && (
<Button
variant={localPaneOpen ? "secondary" : "ghost"}
size="icon"
onClick={onToggleLocalPane}
title={
localPaneOpen
? t("fileManager.hideLocalFiles")
: t("fileManager.showLocalFiles")
}
aria-pressed={localPaneOpen}
className={`hidden md:inline-flex size-8 rounded-none border border-border ${localPaneOpen ? "bg-accent-brand/10 text-accent-brand border-accent-brand/40" : ""}`}
>
<Laptop className="size-4" />
</Button>
)}
<div className="flex items-center border border-border rounded-none overflow-hidden">
<Button
variant={viewMode === "grid" ? "secondary" : "ghost"}
size="icon"
onClick={() => setViewMode("grid")}
className={`size-8 rounded-none ${viewMode === "grid" ? "bg-accent-brand/10 text-accent-brand" : ""}`}
>
<Grid3X3 className="size-4" />
</Button>
<Button
variant={viewMode === "list" ? "secondary" : "ghost"}
size="icon"
onClick={() => setViewMode("list")}
className={`size-8 rounded-none border-l border-border ${viewMode === "list" ? "bg-accent-brand/10 text-accent-brand" : ""}`}
>
<List className="size-4" />
</Button>
<Button
variant={density === "compact" ? "secondary" : "ghost"}
size="icon"
onClick={() =>
setDensity(density === "compact" ? "comfortable" : "compact")
}
className={`size-8 rounded-none border-l border-border ${density === "compact" ? "bg-accent-brand/10 text-accent-brand" : ""}`}
title={t(
density === "compact"
? "fileManager.comfortableLayout"
: "fileManager.compactLayout",
)}
>
<Rows3 className="size-4" />
</Button>
</div>
<label
className="hidden md:block cursor-pointer"
title={t("fileManager.upload")}
>
<input
type="file"
multiple
className="hidden"
onChange={(e) => {
const files = e.target.files;
if (files) handleFilesDropped(files);
}}
/>
<div className="h-8 px-3 flex items-center gap-1.5 border border-border hover:bg-muted text-muted-foreground hover:text-foreground transition-colors text-[10px] font-bold uppercase tracking-widest">
<Upload className="size-3.5" /> {t("fileManager.upload")}
</div>
</label>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-8 gap-1.5 border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 rounded-none font-bold uppercase tracking-widest text-[10px]"
>
<Plus className="size-3.5" />
{t("fileManager.new")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-44 rounded-none border-border bg-card"
onCloseAutoFocus={(e) => e.preventDefault()}
>
<DropdownMenuItem
onSelect={() => {
setTimeout(() => handleCreateNewFolder(), 0);
}}
className="rounded-none text-xs font-semibold gap-2 focus:bg-accent-brand/10 focus:text-accent-brand"
>
<FolderPlus className="size-4 text-accent-brand" />
{t("fileManager.newFolder")}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
setTimeout(() => handleCreateNewFile(), 0);
}}
className="rounded-none text-xs font-semibold gap-2 focus:bg-accent-brand/10 focus:text-accent-brand"
>
<FilePlus className="size-4 text-muted-foreground" />
{t("fileManager.newFile")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-[10px] uppercase tracking-widest text-muted-foreground py-1">
{t("fileManager.sortBy")}
</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={sortBy}
onValueChange={(value) => setSortBy(value as SortBy)}
>
<DropdownMenuRadioItem
value="name"
className="rounded-none text-xs"
>
{t("fileManager.sortByName")}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem
value="modified"
className="rounded-none text-xs"
>
{t("fileManager.sortByDate")}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem
value="size"
className="rounded-none text-xs"
>
{t("fileManager.sortBySize")}
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
value={sortOrder}
onValueChange={(value) => setSortOrder(value as SortOrder)}
>
<DropdownMenuRadioItem
value="asc"
className="rounded-none text-xs"
>
{t("fileManager.ascending")}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem
value="desc"
className="rounded-none text-xs"
>
{t("fileManager.descending")}
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<div className="md:hidden flex items-center px-3 pb-2 gap-2">
<PathBar
currentPath={currentPath}
navigateTo={navigateTo}
t={t}
className="flex-1 flex items-center px-3 h-8 bg-muted/50 border border-border gap-2 overflow-hidden"
/>
</div>
</div>
);
}
@@ -1,214 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import { RotateCcw, Trash2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Button } from "@/components/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/dialog";
import {
emptySSHTrash,
getSSHTrash,
permanentlyDeleteSSHTrashItem,
restoreSSHTrashItem,
updateSSHTrashRetention,
type TrashItem,
} from "@/api/ssh-file-operations-api";
export function FileManagerTrashDialog({
open,
onOpenChange,
sessionId,
onChanged,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
sessionId: string | null;
onChanged: () => void;
}) {
const { t } = useTranslation();
const [items, setItems] = useState<TrashItem[]>([]);
const [retentionDays, setRetentionDays] = useState(7);
const [canManageRetention, setCanManageRetention] = useState(false);
const [loading, setLoading] = useState(false);
const load = useCallback(async () => {
if (!sessionId) return;
setLoading(true);
try {
const result = await getSSHTrash(sessionId);
setItems(result.items);
setRetentionDays(result.retentionDays);
setCanManageRetention(result.canManageRetention);
} catch {
toast.error(t("fileManager.trashLoadFailed"));
} finally {
setLoading(false);
}
}, [sessionId, t]);
useEffect(() => {
if (open) void load();
}, [load, open]);
async function restore(item: TrashItem) {
if (!sessionId) return;
try {
await restoreSSHTrashItem(sessionId, item.id);
setItems((current) => current.filter((entry) => entry.id !== item.id));
onChanged();
toast.success(t("fileManager.trashRestored", { name: item.name }));
} catch {
toast.error(t("fileManager.trashRestoreFailed"));
}
}
async function remove(item: TrashItem) {
if (!sessionId) return;
if (
!window.confirm(t("fileManager.trashDeleteConfirm", { name: item.name }))
)
return;
try {
await permanentlyDeleteSSHTrashItem(sessionId, item.id);
setItems((current) => current.filter((entry) => entry.id !== item.id));
toast.success(
t("fileManager.trashPermanentlyDeleted", { name: item.name }),
);
} catch {
toast.error(t("fileManager.trashDeleteFailed"));
}
}
async function empty() {
if (!sessionId || items.length === 0) return;
if (!window.confirm(t("fileManager.trashEmptyConfirm"))) return;
try {
await emptySSHTrash(sessionId);
setItems([]);
toast.success(t("fileManager.trashEmptied"));
} catch {
toast.error(t("fileManager.trashDeleteFailed"));
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[85dvh] w-[calc(100vw-2rem)] flex-col sm:max-w-3xl">
<DialogHeader className="shrink-0 pr-8">
<DialogTitle>{t("fileManager.trash")}</DialogTitle>
<DialogDescription>
{t("fileManager.trashDescription", { days: retentionDays })}{" "}
<a
href="https://docs.termix.site/features/files-and-hosts/trash"
target="_blank"
rel="noreferrer"
className="text-accent-brand hover:underline"
>
{t("hosts.docsLink")}
</a>
</DialogDescription>
</DialogHeader>
{canManageRetention && (
<div className="flex shrink-0 items-center gap-2 border border-border p-2 text-xs">
<span className="flex-1">{t("fileManager.trashRetention")}</span>
<input
type="number"
min={1}
max={3650}
value={retentionDays}
onChange={(event) => setRetentionDays(Number(event.target.value))}
className="h-8 w-20 border border-border bg-background px-2"
/>
<Button
size="sm"
variant="outline"
onClick={async () => {
if (!sessionId || retentionDays < 1 || retentionDays > 3650)
return;
try {
await updateSSHTrashRetention(sessionId, retentionDays);
toast.success(t("fileManager.trashRetentionSaved"));
} catch {
toast.error(t("fileManager.trashRetentionFailed"));
}
}}
>
{t("common.save")}
</Button>
</div>
)}
<div className="min-h-0 flex-1 overflow-y-auto border border-border">
{loading ? (
<div className="p-6 text-center text-sm text-muted-foreground">
{t("common.loading")}
</div>
) : items.length === 0 ? (
<div className="p-6 text-center text-sm text-muted-foreground">
{t("fileManager.trashEmpty")}
</div>
) : (
items.map((item) => (
<div
key={item.id}
className="flex flex-col gap-2 border-b border-border p-3 last:border-b-0 sm:flex-row sm:items-center sm:gap-3"
>
<Trash2 className="size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div
className="truncate text-sm font-medium"
title={item.name}
>
{item.name}
</div>
<div
className="truncate text-xs text-muted-foreground"
title={item.originalPath}
>
{item.originalPath}
</div>
<div className="text-xs text-muted-foreground">
{new Date(item.deletedAt).toLocaleString()}
</div>
</div>
<div className="flex shrink-0 gap-2">
<Button
size="sm"
variant="outline"
onClick={() => void restore(item)}
>
<RotateCcw className="mr-1 size-3.5" />
{t("fileManager.trashRestore")}
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => void remove(item)}
>
{t("fileManager.trashDeletePermanently")}
</Button>
</div>
</div>
))
)}
</div>
<div className="flex shrink-0 justify-end">
<Button
variant="destructive"
disabled={items.length === 0}
onClick={() => void empty()}
>
{t("fileManager.trashEmptyAction")}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
@@ -1,322 +0,0 @@
import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils.ts";
import {
Clipboard,
Edit3,
Eye,
EyeOff,
ExternalLink,
FilePlus,
FolderOpen,
FolderPlus,
RefreshCw,
Trash2,
Upload,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { Kbd, KbdKey, KbdSeparator } from "@/components/kbd.tsx";
import type { LocalFileEntry } from "@/types/electron";
const VIEWPORT_PADDING = 16;
export interface LocalFileContextMenuProps {
x: number;
y: number;
/** Entries the menu acts on; empty means the pane background was clicked. */
entries: LocalFileEntry[];
isVisible: boolean;
showHidden: boolean;
canUpload: boolean;
onClose: () => void;
onOpen: (entry: LocalFileEntry) => void;
onUploadToRemote: (entries: LocalFileEntry[]) => void;
onReveal: (entry?: LocalFileEntry) => void;
onRename: (entry: LocalFileEntry) => void;
onCopyPath: (entries: LocalFileEntry[]) => void;
onNewFolder: () => void;
onNewFile: () => void;
onRefresh: () => void;
onToggleHidden: () => void;
onDelete: (entries: LocalFileEntry[]) => void;
}
interface MenuItem {
icon?: React.ReactNode;
label?: string;
action?: () => void;
shortcut?: string;
separator?: boolean;
disabled?: boolean;
danger?: boolean;
}
/**
* Right-click menu for the local pane. Same chrome and dismissal rules as
* FileManagerContextMenu, with the subset of actions that make sense for the
* user's own disk.
*/
export function LocalFileContextMenu({
x,
y,
entries,
isVisible,
showHidden,
canUpload,
onClose,
onOpen,
onUploadToRemote,
onReveal,
onRename,
onCopyPath,
onNewFolder,
onNewFile,
onRefresh,
onToggleHidden,
onDelete,
}: LocalFileContextMenuProps) {
const { t } = useTranslation();
const menuRef = useRef<HTMLDivElement>(null);
const [menuPosition, setMenuPosition] = useState({ x, y });
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
if (!isVisible) {
setIsMounted(false);
return;
}
setIsMounted(true);
let cleanupFn: (() => void) | null = null;
const timeoutId = setTimeout(() => {
const handleClickOutside = (event: MouseEvent) => {
if (!menuRef.current?.contains(event.target as Node)) onClose();
};
const handleRightClick = (event: MouseEvent) => {
event.preventDefault();
onClose();
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
onClose();
}
};
const handleBlur = () => onClose();
const handleScroll = () => onClose();
document.addEventListener("mousedown", handleClickOutside, true);
document.addEventListener("contextmenu", handleRightClick);
document.addEventListener("keydown", handleKeyDown);
window.addEventListener("blur", handleBlur);
window.addEventListener("scroll", handleScroll, true);
cleanupFn = () => {
document.removeEventListener("mousedown", handleClickOutside, true);
document.removeEventListener("contextmenu", handleRightClick);
document.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("blur", handleBlur);
window.removeEventListener("scroll", handleScroll, true);
};
}, 50);
return () => {
clearTimeout(timeoutId);
cleanupFn?.();
};
}, [isVisible, x, y, onClose]);
useLayoutEffect(() => {
if (!isVisible || !menuRef.current) return;
const menuWidth = menuRef.current.offsetWidth;
const menuHeight = menuRef.current.offsetHeight;
let adjustedX = x;
let adjustedY = y;
if (x + menuWidth > window.innerWidth) {
adjustedX = window.innerWidth - menuWidth - 10;
}
if (y + menuHeight > window.innerHeight) {
adjustedY = Math.max(10, window.innerHeight - menuHeight - 10);
}
setMenuPosition({ x: Math.max(8, adjustedX), y: Math.max(8, adjustedY) });
}, [isVisible, x, y, entries.length]);
const isEntryContext = entries.length > 0;
const isSingle = entries.length === 1;
const single = isSingle ? entries[0] : null;
const menuItems: MenuItem[] = [];
if (isEntryContext) {
if (single) {
menuItems.push({
icon: <ExternalLink className="size-3.5" />,
label:
single.type === "directory"
? t("fileManager.localOpenFolder")
: t("fileManager.localOpen"),
action: () => onOpen(single),
shortcut: "Enter",
});
}
menuItems.push({
icon: <Upload className="size-3.5" />,
label: isSingle
? t("fileManager.localUploadToRemote")
: t("fileManager.localUploadToRemoteMany", { count: entries.length }),
action: () => onUploadToRemote(entries),
disabled: !canUpload,
});
menuItems.push({
icon: <FolderOpen className="size-3.5" />,
label: t("fileManager.localRevealInFileManager"),
action: () => onReveal(entries[0]),
});
menuItems.push({ separator: true });
if (single) {
menuItems.push({
icon: <Edit3 className="size-3.5" />,
label: t("fileManager.rename"),
action: () => onRename(single),
shortcut: "F2",
});
}
menuItems.push({
icon: <Clipboard className="size-3.5" />,
label: isSingle ? t("fileManager.copyPath") : t("fileManager.copyPaths"),
action: () => onCopyPath(entries),
});
menuItems.push({ separator: true });
menuItems.push({
icon: <Trash2 className="size-3.5" />,
label: isSingle
? t("fileManager.localMoveToTrash")
: t("fileManager.localMoveToTrashMany", { count: entries.length }),
action: () => onDelete(entries),
shortcut: "Del",
danger: true,
});
} else {
menuItems.push({
icon: <FolderPlus className="size-3.5" />,
label: t("fileManager.newFolder"),
action: onNewFolder,
});
menuItems.push({
icon: <FilePlus className="size-3.5" />,
label: t("fileManager.newFile"),
action: onNewFile,
});
menuItems.push({ separator: true });
menuItems.push({
icon: <FolderOpen className="size-3.5" />,
label: t("fileManager.localRevealInFileManager"),
action: () => onReveal(),
});
menuItems.push({
icon: showHidden ? (
<EyeOff className="size-3.5" />
) : (
<Eye className="size-3.5" />
),
label: showHidden
? t("fileManager.localHideHidden")
: t("fileManager.localShowHidden"),
action: onToggleHidden,
});
menuItems.push({
icon: <RefreshCw className="size-3.5" />,
label: t("fileManager.refresh"),
action: onRefresh,
shortcut: "F5",
});
}
const renderShortcut = (shortcut: string) => {
const keys = shortcut.split("+");
if (keys.length === 1) return <Kbd>{keys[0]}</Kbd>;
return (
<Kbd>
{keys.map((key, index) => (
<React.Fragment key={`${key}-${index}`}>
<KbdKey>{key}</KbdKey>
{index < keys.length - 1 && <KbdSeparator />}
</React.Fragment>
))}
</Kbd>
);
};
if (!isVisible && !isMounted) return null;
return (
<>
<div
className={cn(
"fixed inset-0 z-[99990] transition-opacity duration-150",
!isMounted && "opacity-0",
)}
/>
<div
ref={menuRef}
data-context-menu
data-testid="local-file-context-menu"
className="fixed bg-card border border-border rounded-none shadow-md min-w-[220px] max-w-[300px] z-[99995] overflow-x-hidden overflow-y-auto py-1"
style={{
left: menuPosition.x,
top: menuPosition.y,
maxHeight: `calc(100vh - ${VIEWPORT_PADDING * 2}px)`,
}}
>
{menuItems.map((item, index) => {
if (item.separator) {
return (
<div
key={`separator-${index}`}
className="my-1 border-t border-border"
/>
);
}
return (
<button
key={`${item.label}-${index}`}
type="button"
className={cn(
"w-full px-3 min-h-8 py-1.5 text-left text-xs font-semibold flex items-center justify-between gap-3 rounded-none transition-colors cursor-pointer",
"hover:bg-accent-brand/10 hover:text-accent-brand",
item.disabled &&
"opacity-40 cursor-not-allowed hover:bg-transparent hover:text-current",
item.danger &&
"text-destructive hover:bg-destructive/10 hover:text-destructive",
)}
onClick={() => {
if (item.disabled) return;
item.action?.();
onClose();
}}
disabled={item.disabled}
>
<div className="flex items-center gap-2 flex-1 min-w-0">
<div className="flex-shrink-0 text-muted-foreground">
{item.icon}
</div>
<span className="flex-1 leading-tight">{item.label}</span>
</div>
{item.shortcut && (
<div className="ml-auto flex-shrink-0 opacity-50">
{renderShortcut(item.shortcut)}
</div>
)}
</button>
);
})}
</div>
</>
);
}
File diff suppressed because it is too large. Load diff
@@ -1,98 +0,0 @@
import React, { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/dialog.tsx";
import { Button } from "@/components/button.tsx";
import { PasswordInput } from "@/components/password-input.tsx";
import { Shield } from "lucide-react";
import { useTranslation } from "react-i18next";
interface SudoPasswordDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (password: string) => void;
}
export function SudoPasswordDialog({
open,
onOpenChange,
onSubmit,
}: SudoPasswordDialogProps) {
const { t } = useTranslation();
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!open) {
setPassword("");
setLoading(false);
}
}, [open]);
const handleSubmit = async (e?: React.FormEvent) => {
if (e) {
e.preventDefault();
}
if (!password.trim()) {
return;
}
setLoading(true);
onSubmit(password);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[calc(100vw-2rem)] sm:max-w-md rounded-none border-border bg-card">
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle className="text-xs font-bold uppercase tracking-widest flex items-center gap-2">
<Shield className="size-4 text-accent-brand" />
{t("fileManager.sudoPasswordRequired")}
</DialogTitle>
<DialogDescription className="text-[10px] font-bold uppercase tracking-tight text-muted-foreground">
{t("fileManager.enterSudoPassword")}
</DialogDescription>
</DialogHeader>
<div className="py-4">
<PasswordInput
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t("fileManager.sudoPassword")}
className="rounded-none bg-muted/50 border-border text-xs focus:ring-1 focus:ring-accent-brand/50"
autoFocus
disabled={loading}
/>
</div>
<DialogFooter>
<Button
type="button"
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={loading}
className="rounded-none text-[10px] font-bold uppercase tracking-widest"
>
{t("common.cancel")}
</Button>
<Button
type="submit"
disabled={!password.trim() || loading}
variant="outline"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 rounded-none text-[10px] font-bold uppercase tracking-widest"
>
{loading ? t("common.loading") : t("common.confirm")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
@@ -1,88 +0,0 @@
import { useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { getTransferStatus, listActiveTransfers } from "@/main-axios.ts";
import { createFormatTransferMetrics } from "./transferMetricsFormat.ts";
import {
beginTransferProgressMonitoring,
isTransferBeingMonitored,
showTransferCompletionToast,
} from "./transferProgressMonitor.tsx";
import {
clearStalePendingTransfer,
getPendingTransferIds,
isTransferNotified,
} from "./transferNotificationStore.ts";
import { runAdaptivePolling } from "@/lib/adaptive-polling.ts";
const POLL_INTERVAL_MS = 2000;
export function TransferMonitor() {
const { t } = useTranslation();
const formatTransferMetrics = useMemo(
() => createFormatTransferMetrics(t),
[t],
);
useEffect(() => {
const reconcileTransfers = async () => {
let hasWork = false;
try {
const { transfers } = await listActiveTransfers();
hasWork = transfers.length > 0;
for (const transfer of transfers) {
if (isTransferBeingMonitored(transfer.transferId)) continue;
beginTransferProgressMonitoring(transfer.transferId, t, {
resumed: true,
initialStatus: transfer,
formatTransferMetrics,
});
}
} catch {
// Non-fatal: file-manager service may be unavailable briefly
}
const pendingTransferIds = getPendingTransferIds();
for (const transferId of pendingTransferIds) {
if (
isTransferBeingMonitored(transferId) ||
isTransferNotified(transferId)
) {
continue;
}
hasWork = true;
try {
const status = await getTransferStatus(transferId);
if (status.status === "running") {
if (!isTransferBeingMonitored(transferId)) {
beginTransferProgressMonitoring(transferId, t, {
resumed: true,
initialStatus: status,
formatTransferMetrics,
});
}
continue;
}
showTransferCompletionToast(
status,
t,
undefined,
formatTransferMetrics,
);
} catch {
clearStalePendingTransfer(transferId);
}
}
return hasWork;
};
return runAdaptivePolling(reconcileTransfers, {
minIntervalMs: POLL_INTERVAL_MS,
maxIntervalMs: 30_000,
stablePollsPerStep: 1,
});
}, [t, formatTransferMetrics]);
return null;
}
@@ -1,108 +0,0 @@
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 { useTranslation } from "react-i18next";
interface FileItem {
name: string;
size?: number;
}
interface AudioPreviewProps {
file: FileItem;
content: string;
color: string;
onMediaDimensionsChange?: (dimensions: {
width: number;
height: number;
}) => void;
}
function formatFileSize(bytes?: number, t?: (key: string) => string): string {
if (!bytes) return t ? t("fileManager.unknownSize") : "Unknown size";
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${sizes[i]}`;
}
function getAudioMimeType(filename: string): string {
const ext = filename.split(".").pop()?.toLowerCase() || "";
switch (ext) {
case "mp3":
return "audio/mpeg";
case "wav":
return "audio/wav";
case "flac":
return "audio/flac";
case "ogg":
return "audio/ogg";
case "aac":
return "audio/aac";
case "m4a":
return "audio/mp4";
default:
return "audio/mpeg";
}
}
export function AudioPreview({
file,
content,
color,
onMediaDimensionsChange,
}: AudioPreviewProps) {
const { t } = useTranslation();
const ext = file.name.split(".").pop()?.toLowerCase() || "";
const audioUrl = `data:${getAudioMimeType(file.name)};base64,${content}`;
return (
<div className="p-6 flex items-center justify-center h-full">
<div className="w-full max-w-2xl">
<div className="space-y-4">
<div className="flex justify-center">
<div
className={cn(
"w-32 h-32 rounded-lg bg-gradient-to-br from-pink-100 to-purple-100 flex items-center justify-center shadow-lg",
color,
)}
>
<Music className="w-16 h-16 text-pink-600" />
</div>
</div>
<div className="text-center">
<h3 className="font-semibold text-foreground text-lg mb-1">
{file.name.replace(/\.[^/.]+$/, "")}
</h3>
<p className="text-sm text-muted-foreground">
{ext.toUpperCase()} • {formatFileSize(file.size, t)}
</p>
</div>
<div className="rounded-lg overflow-hidden">
<AudioPlayer
src={audioUrl}
onLoadedMetaData={() => {
onMediaDimensionsChange?.({
width: 600,
height: 400,
});
}}
onError={(e) => {
console.error("Audio playback error:", e);
}}
showJumpControls={false}
showSkipControls={false}
showDownloadProgress={true}
customAdditionalControls={[]}
customVolumeControls={[]}
/>
</div>
</div>
</div>
</div>
);
}
@@ -1,164 +0,0 @@
import React, { forwardRef, useImperativeHandle, useMemo, useRef } from "react";
import CodeMirror from "@uiw/react-codemirror";
import { oneDark } from "@codemirror/theme-one-dark";
import {
loadLanguage,
type LanguageName,
} from "@uiw/codemirror-extensions-langs";
import { EditorView, keymap } from "@codemirror/view";
import { searchKeymap, search, openSearchPanel } from "@codemirror/search";
import {
defaultKeymap,
history,
historyKeymap,
toggleComment,
} from "@codemirror/commands";
import { autocompletion, completionKeymap } from "@codemirror/autocomplete";
export interface CodeEditorHandle {
openSearchPanel: () => void;
}
interface CodeEditorProps {
fileName: string;
value: string;
placeholder: string;
onChange: (value: string) => void;
onFocus: () => void;
onBlur: () => void;
fontSize?: number;
}
function getLanguageExtension(filename: string) {
const ext = filename.split(".").pop()?.toLowerCase() || "";
const baseName = filename.toLowerCase();
if (["dockerfile", "makefile", "rakefile", "gemfile"].includes(baseName)) {
return loadLanguage(baseName as LanguageName);
}
const langMap: Record<string, string> = {
js: "javascript",
jsx: "jsx",
ts: "typescript",
tsx: "tsx",
py: "python",
java: "java",
cpp: "cpp",
c: "c",
cs: "csharp",
php: "php",
rb: "ruby",
go: "go",
rs: "rust",
html: "html",
css: "css",
scss: "sass",
less: "less",
json: "json",
xml: "xml",
yaml: "yaml",
yml: "yaml",
toml: "toml",
sql: "sql",
sh: "shell",
bash: "shell",
zsh: "shell",
vue: "vue",
svelte: "svelte",
md: "markdown",
conf: "shell",
ini: "properties",
};
const language = langMap[ext];
return language ? loadLanguage(language as LanguageName) : null;
}
export const CodeEditor = forwardRef<CodeEditorHandle, CodeEditorProps>(
function CodeEditor(
{ fileName, value, placeholder, onChange, onFocus, onBlur, fontSize = 14 },
ref,
) {
const editorRef = useRef<{ view?: EditorView } | null>(null);
const extensions = useMemo(() => {
const languageExtension = getLanguageExtension(fileName);
return [
...(languageExtension ? [languageExtension] : []),
history(),
search(),
autocompletion(),
keymap.of([
...defaultKeymap,
...searchKeymap,
...historyKeymap,
...completionKeymap,
{
key: "Mod-/",
run: toggleComment,
preventDefault: true,
},
{
key: "Mod-h",
run: () => false,
preventDefault: true,
},
]),
EditorView.theme({
"&": {
height: "100%",
fontSize: `${fontSize}px`,
},
".cm-scroller": {
overflow: "auto",
scrollbarWidth: "thin",
scrollbarColor: "var(--scrollbar-thumb) var(--scrollbar-track)",
},
".cm-editor": {
height: "100%",
},
}),
];
}, [fileName, fontSize]);
useImperativeHandle(
ref,
() => ({
openSearchPanel: () => {
const view = editorRef.current?.view;
if (view) {
openSearchPanel(view);
}
},
}),
[],
);
return (
<CodeMirror
ref={editorRef}
value={value}
onChange={onChange}
onFocus={onFocus}
onBlur={onBlur}
extensions={extensions}
theme={oneDark}
placeholder={placeholder}
className="h-full"
basicSetup={{
lineNumbers: true,
foldGutter: true,
dropCursor: false,
allowMultipleSelections: false,
indentOnInput: true,
bracketMatching: true,
closeBrackets: true,
autocompletion: true,
highlightSelectionMatches: false,
}}
/>
);
},
);
@@ -1,43 +0,0 @@
import type React from "react";
import { cn } from "@/lib/utils.ts";
interface ColumnResizeHandleProps {
onMouseDown: (event: React.MouseEvent) => void;
onDoubleClick: (event: React.MouseEvent) => void;
onClick: (event: React.MouseEvent) => void;
"data-resizing"?: string;
title?: string;
}
/**
* Thin grab area on the left edge of a list-view header cell. The parent cell
* must be `relative`. Shows a hairline on hover and while dragging.
*/
export function ColumnResizeHandle({
title,
"data-resizing": resizing,
...handlers
}: ColumnResizeHandleProps) {
return (
<div
role="separator"
aria-orientation="vertical"
title={title}
data-column-resize-handle
data-resizing={resizing}
className={cn(
"absolute -left-2 top-0 bottom-0 w-3 cursor-col-resize group/handle z-10",
)}
{...handlers}
>
<div
className={cn(
"absolute left-1/2 top-0.5 bottom-0.5 w-px -translate-x-1/2 transition-colors",
resizing
? "bg-accent-brand"
: "bg-transparent group-hover/handle:bg-accent-brand/60",
)}
/>
</div>
);
}
@@ -1,105 +0,0 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { Check } from "lucide-react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils.ts";
import type { ResizableColumns } from "../hooks/useResizableColumns.ts";
interface ColumnVisibilityMenuProps {
x: number;
y: number;
isVisible: boolean;
columns: ResizableColumns;
onClose: () => void;
}
/**
* Right-click menu for a list-view header: tick/untick which optional
* columns are shown. The name column is always on and not listed.
*/
export function ColumnVisibilityMenu({
x,
y,
isVisible,
columns,
onClose,
}: ColumnVisibilityMenuProps) {
const { t } = useTranslation();
const menuRef = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState({ x, y });
useEffect(() => {
if (!isVisible) return;
const handleMouseDown = (event: MouseEvent) => {
if (!menuRef.current?.contains(event.target as Node)) onClose();
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
};
const timeout = setTimeout(() => {
document.addEventListener("mousedown", handleMouseDown, true);
document.addEventListener("keydown", handleKeyDown);
window.addEventListener("blur", onClose);
}, 50);
return () => {
clearTimeout(timeout);
document.removeEventListener("mousedown", handleMouseDown, true);
document.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("blur", onClose);
};
}, [isVisible, onClose]);
useLayoutEffect(() => {
if (!isVisible || !menuRef.current) return;
const { offsetWidth, offsetHeight } = menuRef.current;
setPosition({
x: Math.max(8, Math.min(x, window.innerWidth - offsetWidth - 10)),
y: Math.max(8, Math.min(y, window.innerHeight - offsetHeight - 10)),
});
}, [isVisible, x, y]);
if (!isVisible) return null;
const onlyOneVisible = columns.visibleColumns.length <= 1;
return (
<div
ref={menuRef}
data-context-menu
data-testid="column-visibility-menu"
className="fixed bg-card border border-border rounded-none shadow-md min-w-[200px] z-[99995] py-1"
style={{ left: position.x, top: position.y }}
>
<div className="px-3 pt-1.5 pb-1 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("fileManager.columns")}
</div>
{columns.columns.map((column) => {
const visible = columns.isVisible(column.key);
// Keep at least one optional column so the header stays meaningful.
const disabled = visible && onlyOneVisible;
return (
<button
key={column.key}
type="button"
role="menuitemcheckbox"
aria-checked={visible}
disabled={disabled}
className={cn(
"w-full px-3 min-h-8 py-1.5 text-left text-xs font-semibold flex items-center gap-2 rounded-none transition-colors cursor-pointer",
"hover:bg-accent-brand/10 hover:text-accent-brand",
disabled &&
"opacity-40 cursor-not-allowed hover:bg-transparent hover:text-current",
)}
onClick={() => {
if (!disabled) columns.toggleColumn(column.key);
}}
>
<span className="size-3.5 flex items-center justify-center text-accent-brand">
{visible && <Check className="size-3.5" />}
</span>
<span className="flex-1 leading-tight">{t(column.labelKey)}</span>
</button>
);
})}
</div>
);
}
@@ -1,170 +0,0 @@
import React, { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/dialog.tsx";
import { Button } from "@/components/button.tsx";
import { Input } from "@/components/input.tsx";
import { Label } from "@/components/label.tsx";
import { Package } from "lucide-react";
import { useTranslation } from "react-i18next";
interface CompressDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
fileNames: string[];
onCompress: (archiveName: string, format: string) => void;
}
export function CompressDialog({
open,
onOpenChange,
fileNames,
onCompress,
}: CompressDialogProps) {
const { t } = useTranslation();
const [archiveName, setArchiveName] = useState("");
const [format, setFormat] = useState("zip");
useEffect(() => {
if (open && fileNames.length > 0) {
if (fileNames.length === 1) {
const baseName = fileNames[0].replace(/\.[^/.]+$/, "");
setArchiveName(baseName);
} else {
setArchiveName("archive");
}
}
}, [open, fileNames]);
const handleCompress = () => {
if (!archiveName.trim()) return;
let finalName = archiveName.trim();
const extensions: Record<string, string> = {
zip: ".zip",
"tar.gz": ".tar.gz",
"tar.bz2": ".tar.bz2",
"tar.xz": ".tar.xz",
tar: ".tar",
"7z": ".7z",
};
const expectedExtension = extensions[format];
if (expectedExtension && !finalName.endsWith(expectedExtension)) {
finalName += expectedExtension;
}
onCompress(finalName, format);
onOpenChange(false);
};
const formats = ["zip", "tar.gz", "tar.bz2", "tar.xz", "tar", "7z"] as const;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[calc(100vw-2rem)] sm:max-w-md rounded-none border-border bg-card">
<DialogHeader>
<DialogTitle className="text-xs font-bold uppercase tracking-widest flex items-center gap-2">
<Package className="size-4 text-accent-brand" />
{t("fileManager.compressFiles")}
</DialogTitle>
<DialogDescription className="text-[10px] font-bold uppercase tracking-tight text-muted-foreground">
{t("fileManager.compressFilesDesc", { count: fileNames.length })}
</DialogDescription>
</DialogHeader>
<div className="py-4 flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<Label
className="text-xs font-bold uppercase tracking-widest text-muted-foreground"
htmlFor="archiveName"
>
{t("fileManager.archiveName")}
</Label>
<Input
id="archiveName"
value={archiveName}
onChange={(e) => setArchiveName(e.target.value)}
placeholder={t("fileManager.enterArchiveName")}
className="rounded-none bg-muted/50 border-border text-xs"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleCompress();
}
}}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
{t("fileManager.compressionFormat")}
</Label>
<div className="grid grid-cols-3 gap-1">
{formats.map((f) => (
<button
key={f}
onClick={() => setFormat(f)}
className={`py-2 text-xs font-bold uppercase tracking-widest border transition-colors ${
format === f
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand"
: "border-border text-muted-foreground hover:text-foreground hover:bg-muted"
}`}
>
.{f}
</button>
))}
</div>
</div>
<div className="rounded-none bg-muted/10 border border-border p-3">
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-2">
{t("fileManager.selectedFiles")}:
</p>
<ul className="text-xs space-y-1">
{fileNames.slice(0, 5).map((name, index) => (
<li
key={index}
className="truncate text-foreground font-medium"
>
• {name}
</li>
))}
{fileNames.length > 5 && (
<li className="text-muted-foreground">
{t("fileManager.andMoreFiles", {
count: fileNames.length - 5,
})}
</li>
)}
</ul>
</div>
</div>
<DialogFooter>
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
className="rounded-none text-[10px] font-bold uppercase tracking-widest"
>
{t("common.cancel")}
</Button>
<Button
variant="outline"
onClick={handleCompress}
disabled={!archiveName.trim()}
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 rounded-none text-[10px] font-bold uppercase tracking-widest"
>
<Package className="size-3.5 mr-1" />
{t("fileManager.compress")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -1,348 +0,0 @@
/* eslint-disable react-hooks/exhaustive-deps */
import React, { useState, useEffect } from "react";
import { DiffEditor } from "@monaco-editor/react";
import { Button } from "@/components/button.tsx";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import {
Download,
RefreshCw,
Eye,
EyeOff,
ArrowLeftRight,
FileText,
} from "lucide-react";
import {
readSSHFile,
downloadSSHFile,
getSSHStatus,
connectSSH,
} from "@/main-axios.ts";
import type { FileItem, SSHHost } from "@/types";
interface DiffViewerProps {
file1: FileItem;
file2: FileItem;
sshSessionId: string;
sshHost: SSHHost;
onDownload1?: () => void;
onDownload2?: () => void;
}
export function DiffViewer({
file1,
file2,
sshSessionId,
sshHost,
}: DiffViewerProps) {
const { t } = useTranslation();
const [content1, setContent1] = useState<string>("");
const [content2, setContent2] = useState<string>("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [diffMode, setDiffMode] = useState<"side-by-side" | "inline">(
"side-by-side",
);
const [showLineNumbers, setShowLineNumbers] = useState(true);
const ensureSSHConnection = async () => {
try {
const status = await getSSHStatus(sshSessionId);
if (!status.connected) {
await connectSSH(sshSessionId, {
hostId: sshHost.id,
ip: sshHost.ip,
port: sshHost.port,
username: sshHost.username,
password: sshHost.password,
sshKey: sshHost.key,
keyPassword: sshHost.keyPassword,
authType: sshHost.authType,
credentialId: sshHost.credentialId,
userId: sshHost.userId,
});
}
} catch {
await connectSSH(sshSessionId, {
hostId: sshHost.id,
ip: sshHost.ip,
port: sshHost.port,
username: sshHost.username,
password: sshHost.password,
sshKey: sshHost.key,
keyPassword: sshHost.keyPassword,
authType: sshHost.authType,
credentialId: sshHost.credentialId,
userId: sshHost.userId,
});
}
};
const loadFileContents = async () => {
if (file1.type !== "file" || file2.type !== "file") {
setError(t("fileManager.canOnlyCompareFiles"));
return;
}
try {
setIsLoading(true);
setError(null);
await ensureSSHConnection();
const [response1, response2] = await Promise.all([
readSSHFile(sshSessionId, file1.path),
readSSHFile(sshSessionId, file2.path),
]);
setContent1(response1.content || "");
setContent2(response2.content || "");
} catch (error: unknown) {
console.error("Failed to load files for diff:", error);
const err = error as {
message?: string;
response?: { data?: { tooLarge?: boolean; error?: string } };
};
const errorData = err?.response?.data;
if (errorData?.tooLarge) {
setError(t("fileManager.fileTooLarge", { error: errorData.error }));
} else if (
err.message?.includes("connection") ||
err.message?.includes("established")
) {
setError(
t("fileManager.sshConnectionFailed", {
name: sshHost.name,
ip: sshHost.ip,
port: sshHost.port,
}),
);
} else {
setError(
t("fileManager.loadFileFailed", {
error:
err.message || errorData?.error || t("fileManager.unknownError"),
}),
);
}
} finally {
setIsLoading(false);
}
};
const handleDownloadFile = async (file: FileItem) => {
try {
await ensureSSHConnection();
const response = await downloadSSHFile(sshSessionId, file.path);
if (response?.content) {
const byteCharacters = atob(response.content);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
const blob = new Blob([byteArray], {
type: response.mimeType || "application/octet-stream",
});
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = response.fileName || file.name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
toast.success(
t("fileManager.downloadFileSuccess", { name: file.name }),
);
}
} catch (error: unknown) {
console.error("Failed to download file:", error);
const err = error as { message?: string };
toast.error(
t("fileManager.downloadFileFailed") +
": " +
(err.message || t("fileManager.unknownError")),
);
}
};
const getFileLanguage = (fileName: string): string => {
const ext = fileName.split(".").pop()?.toLowerCase();
const languageMap: Record<string, string> = {
js: "javascript",
jsx: "javascript",
ts: "typescript",
tsx: "typescript",
py: "python",
java: "java",
c: "c",
cpp: "cpp",
cs: "csharp",
php: "php",
rb: "ruby",
go: "go",
rs: "rust",
html: "html",
css: "css",
scss: "scss",
less: "less",
json: "json",
xml: "xml",
yaml: "yaml",
yml: "yaml",
md: "markdown",
sql: "sql",
sh: "shell",
bash: "shell",
ps1: "powershell",
dockerfile: "dockerfile",
};
return languageMap[ext || ""] || "plaintext";
};
useEffect(() => {
loadFileContents();
}, [file1, file2, sshSessionId]);
if (isLoading) {
return (
<div className="h-full flex items-center justify-center bg-card">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-2"></div>
<p className="text-sm text-muted-foreground">
{t("fileManager.loadingFileComparison")}
</p>
</div>
</div>
);
}
if (error) {
return (
<div className="h-full flex items-center justify-center bg-card">
<div className="text-center max-w-md">
<FileText className="w-16 h-16 mx-auto mb-4 text-red-500 opacity-50" />
<p className="text-red-500 mb-4">{error}</p>
<Button onClick={loadFileContents} variant="outline">
<RefreshCw className="w-4 h-4 mr-2" />
{t("fileManager.reload")}
</Button>
</div>
</div>
);
}
return (
<div className="h-full flex flex-col bg-card">
<div className="flex-shrink-0 border-b border-border p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="text-sm">
<span className="text-muted-foreground">
{t("fileManager.compare")}:
</span>
<span className="font-medium text-green-400 mx-2">
{file1.name}
</span>
<ArrowLeftRight className="w-4 h-4 inline mx-1" />
<span className="font-medium text-blue-400">{file2.name}</span>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() =>
setDiffMode(
diffMode === "side-by-side" ? "inline" : "side-by-side",
)
}
>
{diffMode === "side-by-side"
? t("fileManager.sideBySide")
: t("fileManager.inline")}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowLineNumbers(!showLineNumbers)}
>
{showLineNumbers ? (
<Eye className="w-4 h-4" />
) : (
<EyeOff className="w-4 h-4" />
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleDownloadFile(file1)}
title={t("fileManager.downloadFile", { name: file1.name })}
>
<Download className="w-4 h-4 mr-1" />
{file1.name}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleDownloadFile(file2)}
title={t("fileManager.downloadFile", { name: file2.name })}
>
<Download className="w-4 h-4 mr-1" />
{file2.name}
</Button>
<Button variant="outline" size="sm" onClick={loadFileContents}>
<RefreshCw className="w-4 h-4" />
</Button>
</div>
</div>
</div>
<div className="flex-1">
<DiffEditor
original={content1}
modified={content2}
language={getFileLanguage(file1.name)}
theme="vs-dark"
options={{
renderSideBySide: diffMode === "side-by-side",
lineNumbers: showLineNumbers ? "on" : "off",
minimap: { enabled: false },
scrollBeyondLastLine: false,
fontSize: 13,
wordWrap: "off",
automaticLayout: true,
readOnly: true,
originalEditable: false,
scrollbar: {
vertical: "visible",
horizontal: "visible",
},
diffWordWrap: "off",
ignoreTrimWhitespace: false,
}}
loading={
<div className="h-full flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500 mx-auto mb-2"></div>
<p className="text-sm text-muted-foreground">
{t("fileManager.initializingEditor")}
</p>
</div>
</div>
}
/>
</div>
</div>
);
}
@@ -1,75 +0,0 @@
import React from "react";
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";
interface DiffWindowProps {
windowId: string;
file1: FileItem;
file2: FileItem;
sshSessionId: string;
sshHost: SSHHost;
initialX?: number;
initialY?: number;
}
export function DiffWindow({
windowId,
file1,
file2,
sshSessionId,
sshHost,
initialX = 150,
initialY = 100,
}: DiffWindowProps) {
const { t } = useTranslation();
const { closeWindow, maximizeWindow, focusWindow, windows } =
useWindowManager();
const currentWindow = windows.find((w) => w.id === windowId);
const handleClose = () => {
closeWindow(windowId);
};
const handleMaximize = () => {
maximizeWindow(windowId);
};
const handleFocus = () => {
focusWindow(windowId);
};
if (!currentWindow) {
return null;
}
return (
<DraggableWindow
title={t("fileManager.fileComparison", {
file1: file1.name,
file2: file2.name,
})}
initialX={initialX}
initialY={initialY}
initialWidth={1200}
initialHeight={700}
minWidth={800}
minHeight={500}
onClose={handleClose}
onMaximize={handleMaximize}
onFocus={handleFocus}
isMaximized={currentWindow.isMaximized}
zIndex={currentWindow.zIndex}
>
<DiffViewer
file1={file1}
file2={file2}
sshSessionId={sshSessionId}
sshHost={sshHost}
/>
</DraggableWindow>
);
}
@@ -1,71 +0,0 @@
import { formatTransferMbPerSec } from "@/main-axios.ts";
import { useTranslation } from "react-i18next";
import { ArrowDownToLine } from "lucide-react";
import { TransferProgressBar } from "./TransferProgressBar";
interface DownloadProgressToastProps {
fileName: string;
loaded: number;
total?: number;
mbPerSec?: number;
}
function formatBytes(bytes: number): string {
if (bytes <= 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
const formattedSize =
size < 10 && unitIndex > 0 ? size.toFixed(1) : Math.round(size).toString();
return `${formattedSize} ${units[unitIndex]}`;
}
export function DownloadProgressToast({
fileName,
loaded,
total,
mbPerSec,
}: DownloadProgressToastProps) {
const { t } = useTranslation();
const percent =
total !== undefined && total > 0
? Math.min(100, Math.round((loaded / total) * 100))
: undefined;
const speed = formatTransferMbPerSec(mbPerSec);
return (
<div className="flex w-[min(calc(100vw-5rem),288px)] max-w-full flex-col gap-2 pr-2">
<p className="flex items-center gap-2 text-sm font-medium leading-tight">
<ArrowDownToLine className="size-3.5 shrink-0 text-accent-brand" />
<span className="truncate">
{t("fileManager.downloadingFile", { name: fileName })}
</span>
</p>
<TransferProgressBar
value={percent}
label={t("fileManager.downloadingFile", { name: fileName })}
/>
<div className="flex items-center justify-between gap-3 pr-1 text-xs text-muted-foreground">
<span className="min-w-0 truncate">
{total !== undefined
? t("fileManager.downloadProgressBytes", {
transferred: formatBytes(loaded),
total: formatBytes(total),
})
: formatBytes(loaded)}
</span>
<span
className={`shrink-0 tabular-nums ${speed ? "font-medium text-foreground" : "invisible"}`}
aria-hidden={!speed}
>
{speed || "0 MB/s"}
</span>
</div>
</div>
);
}
@@ -1,381 +0,0 @@
import React, { useState, useRef, useCallback, useEffect } from "react";
import { cn } from "@/lib/utils.ts";
import { Minus, X, Maximize2, Minimize2 } from "lucide-react";
import { useTranslation } from "react-i18next";
interface DraggableWindowProps {
title: string;
children: React.ReactNode;
initialX?: number;
initialY?: number;
initialWidth?: number;
initialHeight?: number;
minWidth?: number;
minHeight?: number;
onClose: () => void;
onMinimize?: () => void;
onMaximize?: () => void;
onResize?: () => void;
isMaximized?: boolean;
zIndex?: number;
onFocus?: () => void;
targetSize?: { width: number; height: number };
titleActions?: React.ReactNode;
}
export function DraggableWindow({
title,
children,
initialX = 100,
initialY = 100,
initialWidth = 600,
initialHeight = 400,
minWidth = 300,
minHeight = 200,
onClose,
onMinimize,
onMaximize,
onResize,
isMaximized = false,
zIndex = 1000,
onFocus,
targetSize,
titleActions,
}: DraggableWindowProps) {
const { t } = useTranslation();
const [position, setPosition] = useState({ x: initialX, y: initialY });
const [size, setSize] = useState({
width: initialWidth,
height: initialHeight,
});
const [isDragging, setIsDragging] = useState(false);
const [isResizing, setIsResizing] = useState(false);
const [resizeDirection, setResizeDirection] = useState<string>("");
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
const [windowStart, setWindowStart] = useState({ x: 0, y: 0 });
const [sizeStart, setSizeStart] = useState({ width: 0, height: 0 });
const containerBoundsRef = useRef({ width: 0, height: 0 });
const windowRef = useRef<HTMLDivElement>(null);
const titleBarRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (targetSize && !isMaximized) {
const container = windowRef.current?.offsetParent as HTMLElement | null;
const maxWidth = container
? Math.min(container.clientWidth * 0.9, 1200)
: Math.min(window.innerWidth * 0.9, 1200);
const maxHeight = container
? Math.min(container.clientHeight * 0.8, 800)
: Math.min(window.innerHeight * 0.8, 800);
let newWidth = Math.min(targetSize.width + 50, maxWidth);
let newHeight = Math.min(targetSize.height + 150, maxHeight);
if (newWidth > maxWidth || newHeight > maxHeight) {
const widthRatio = maxWidth / newWidth;
const heightRatio = maxHeight / newHeight;
const scale = Math.min(widthRatio, heightRatio);
newWidth = Math.floor(newWidth * scale);
newHeight = Math.floor(newHeight * scale);
}
newWidth = Math.max(newWidth, minWidth);
newHeight = Math.max(newHeight, minHeight);
setSize({ width: newWidth, height: newHeight });
setPosition({
x: Math.max(
0,
(container ? container.clientWidth : window.innerWidth) / 2 -
newWidth / 2,
),
y: Math.max(
0,
(container ? container.clientHeight : window.innerHeight) / 2 -
newHeight / 2,
),
});
}
}, [targetSize, isMaximized, minWidth, minHeight]);
const handleWindowClick = useCallback(() => {
onFocus?.();
}, [onFocus]);
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
if (isMaximized) return;
e.preventDefault();
setIsDragging(true);
setDragStart({ x: e.clientX, y: e.clientY });
setWindowStart({ x: position.x, y: position.y });
const container = windowRef.current?.offsetParent as HTMLElement | null;
containerBoundsRef.current = {
width: container ? container.clientWidth : window.innerWidth,
height: container ? container.clientHeight : window.innerHeight,
};
onFocus?.();
},
[isMaximized, position, onFocus],
);
const handleMouseMove = useCallback(
(e: MouseEvent) => {
if (isDragging && !isMaximized) {
const deltaX = e.clientX - dragStart.x;
const deltaY = e.clientY - dragStart.y;
const newX = windowStart.x + deltaX;
const newY = windowStart.y + deltaY;
const { width: containerW, height: containerH } =
containerBoundsRef.current;
const maxX = containerW - size.width;
const maxY = containerH - size.height;
setPosition({
x: Math.max(0, Math.min(maxX, newX)),
y: Math.max(49, Math.min(maxY, newY)),
});
}
if (isResizing && !isMaximized) {
const deltaX = e.clientX - dragStart.x;
const deltaY = e.clientY - dragStart.y;
let newWidth = sizeStart.width;
let newHeight = sizeStart.height;
let newX = windowStart.x;
let newY = windowStart.y;
if (resizeDirection.includes("right")) {
newWidth = Math.max(minWidth, sizeStart.width + deltaX);
}
if (resizeDirection.includes("left")) {
const widthChange = -deltaX;
newWidth = Math.max(minWidth, sizeStart.width + widthChange);
if (newWidth > minWidth || widthChange > 0) {
newX = windowStart.x - (newWidth - sizeStart.width);
} else {
newX = windowStart.x - (minWidth - sizeStart.width);
}
}
if (resizeDirection.includes("bottom")) {
newHeight = Math.max(minHeight, sizeStart.height + deltaY);
}
if (resizeDirection.includes("top")) {
const heightChange = -deltaY;
newHeight = Math.max(minHeight, sizeStart.height + heightChange);
if (newHeight > minHeight || heightChange > 0) {
newY = windowStart.y - (newHeight - sizeStart.height);
} else {
newY = windowStart.y - (minHeight - sizeStart.height);
}
}
const { width: containerW, height: containerH } =
containerBoundsRef.current;
newX = Math.max(0, Math.min(containerW - newWidth, newX));
newY = Math.max(49, Math.min(containerH - newHeight, newY));
setSize({ width: newWidth, height: newHeight });
setPosition({ x: newX, y: newY });
if (onResize) {
onResize();
}
}
},
[
isDragging,
isResizing,
isMaximized,
dragStart,
windowStart,
sizeStart,
size,
minWidth,
minHeight,
resizeDirection,
onResize,
],
);
const handleMouseUp = useCallback(() => {
setIsDragging(false);
setIsResizing(false);
setResizeDirection("");
}, []);
const handleResizeStart = useCallback(
(e: React.MouseEvent, direction: string) => {
if (isMaximized) return;
e.preventDefault();
e.stopPropagation();
setIsResizing(true);
setResizeDirection(direction);
setDragStart({ x: e.clientX, y: e.clientY });
setWindowStart({ x: position.x, y: position.y });
setSizeStart({ width: size.width, height: size.height });
const container = windowRef.current?.offsetParent as HTMLElement | null;
containerBoundsRef.current = {
width: container ? container.clientWidth : window.innerWidth,
height: container ? container.clientHeight : window.innerHeight,
};
onFocus?.();
},
[isMaximized, position, size, onFocus],
);
useEffect(() => {
if (isDragging || isResizing) {
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
document.body.style.userSelect = "none";
document.body.style.cursor = isDragging ? "grabbing" : "resizing";
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
document.body.style.userSelect = "";
document.body.style.cursor = "";
};
}
}, [isDragging, isResizing, handleMouseMove, handleMouseUp]);
const handleTitleDoubleClick = useCallback(() => {
onMaximize?.();
}, [onMaximize]);
return (
<div
ref={windowRef}
className={cn(
"absolute bg-[#0d0e0c] border border-border rounded-none shadow-2xl",
"select-none overflow-hidden flex flex-col",
isMaximized ? "inset-0" : "",
)}
style={{
left: isMaximized ? 0 : position.x,
top: isMaximized ? 0 : position.y,
width: isMaximized ? "100%" : size.width,
height: isMaximized ? "100%" : size.height,
zIndex,
}}
onClick={handleWindowClick}
>
<div
ref={titleBarRef}
className="flex items-center justify-between px-4 py-2 bg-muted/30 border-b border-border shrink-0 cursor-grab active:cursor-grabbing"
onMouseDown={handleMouseDown}
onDoubleClick={handleTitleDoubleClick}
>
<div className="flex items-center gap-3 flex-1 overflow-hidden">
<span className="text-[10px] font-bold uppercase tracking-[0.2em] text-accent-brand shrink-0">
{t("fileManager.editor")}
</span>
<div className="h-4 w-px bg-border/50 shrink-0" />
<span className="text-[10px] font-bold tracking-tight text-muted-foreground truncate">
{title}
</span>
</div>
<div className="flex items-center gap-0.5">
{titleActions}
{onMinimize && (
<button
className="size-6 flex items-center justify-center rounded-none hover:bg-accent-brand/10 hover:text-accent-brand text-muted-foreground transition-colors"
onClick={(e) => {
e.stopPropagation();
onMinimize();
}}
title={t("common.minimize")}
>
<Minus className="size-3.5" />
</button>
)}
{onMaximize && (
<button
className="size-6 flex items-center justify-center rounded-none hover:bg-accent-brand/10 hover:text-accent-brand text-muted-foreground transition-colors"
onClick={(e) => {
e.stopPropagation();
onMaximize();
}}
title={isMaximized ? t("common.restore") : t("common.maximize")}
>
{isMaximized ? (
<Minimize2 className="size-3.5" />
) : (
<Maximize2 className="size-3.5" />
)}
</button>
)}
<button
className="size-6 flex items-center justify-center rounded-none hover:bg-accent-brand/10 hover:text-accent-brand text-muted-foreground transition-colors"
onClick={(e) => {
e.stopPropagation();
onClose();
}}
title={t("common.close")}
>
<X className="size-3.5" />
</button>
</div>
</div>
<div className="flex-1 overflow-hidden">{children}</div>
{!isMaximized && (
<>
<div
className="absolute top-0 left-0 right-0 h-1 cursor-n-resize"
onMouseDown={(e) => handleResizeStart(e, "top")}
/>
<div
className="absolute bottom-0 left-0 right-0 h-1 cursor-s-resize"
onMouseDown={(e) => handleResizeStart(e, "bottom")}
/>
<div
className="absolute top-0 bottom-0 left-0 w-1 cursor-w-resize"
onMouseDown={(e) => handleResizeStart(e, "left")}
/>
<div
className="absolute top-0 bottom-0 right-0 w-1 cursor-e-resize"
onMouseDown={(e) => handleResizeStart(e, "right")}
/>
<div
className="absolute top-0 left-0 w-2 h-2 cursor-nw-resize"
onMouseDown={(e) => handleResizeStart(e, "top-left")}
/>
<div
className="absolute top-0 right-0 w-2 h-2 cursor-ne-resize"
onMouseDown={(e) => handleResizeStart(e, "top-right")}
/>
<div
className="absolute bottom-0 left-0 w-2 h-2 cursor-sw-resize"
onMouseDown={(e) => handleResizeStart(e, "bottom-left")}
/>
<div
className="absolute bottom-0 right-0 w-2 h-2 cursor-se-resize"
onMouseDown={(e) => handleResizeStart(e, "bottom-right")}
/>
</>
)}
</div>
);
}
@@ -1,954 +0,0 @@
/* eslint-disable react-hooks/exhaustive-deps */
import React, { Suspense, lazy, useState, useEffect, useRef } from "react";
import { cn } from "@/lib/utils.ts";
import { useTranslation } from "react-i18next";
import {
FileText,
Image as ImageIcon,
Film,
Music,
File as FileIcon,
Code,
AlertCircle,
Download,
Eye,
Edit,
Save,
RotateCcw,
Keyboard,
ExternalLink,
Settings,
Search,
ZoomIn,
ZoomOut,
} from "lucide-react";
import {
SiJavascript,
SiTypescript,
SiPython,
SiCplusplus,
SiC,
SiDotnet,
SiPhp,
SiRuby,
SiGo,
SiRust,
SiHtml5,
SiSass,
SiVuedotjs,
SiSvelte,
SiMarkdown,
SiGnubash,
SiMysql,
SiDocker,
} from "react-icons/si";
import { Button } from "@/components/button.tsx";
import { Kbd, KbdKey } from "@/components/kbd.tsx";
import type { CodeEditorHandle } from "./CodeEditor.tsx";
import {
loadAudioPreview,
loadCodeEditor,
loadImagePreview,
loadMarkdownRenderer,
loadPdfPreview,
resolveFilePreviewKind,
} from "../file-preview-preload";
const CodeEditor = lazy(loadCodeEditor);
const ImagePreview = lazy(loadImagePreview);
const MarkdownRenderer = lazy(loadMarkdownRenderer);
const PdfPreview = lazy(loadPdfPreview);
const AudioPreview = lazy(loadAudioPreview);
interface FileItem {
name: string;
type: "file" | "directory" | "link";
path: string;
size?: number;
modified?: string;
permissions?: string;
owner?: string;
group?: string;
}
interface FileViewerProps {
file: FileItem;
content?: string;
savedContent?: string;
isLoading?: boolean;
isSaving?: boolean;
isEditable?: boolean;
resetKey?: number;
onContentChange?: (content: string) => void;
onSave?: (content: string) => void;
onRevert?: () => void;
onDownload?: () => void;
onOpenExternal?: () => void;
onChooseExternalEditor?: () => void;
onMediaDimensionsChange?: (dimensions: {
width: number;
height: number;
}) => void;
}
function getLanguageIcon(filename: string): React.ReactNode {
const ext = filename.split(".").pop()?.toLowerCase() || "";
const baseName = filename.toLowerCase();
if (["dockerfile"].includes(baseName)) {
return <SiDocker className="w-6 h-6 text-blue-400" />;
}
if (["makefile", "rakefile", "gemfile"].includes(baseName)) {
return <SiRuby className="w-6 h-6 text-red-500" />;
}
const iconMap: Record<string, React.ReactNode> = {
js: <SiJavascript className="w-6 h-6 text-yellow-400" />,
jsx: <SiJavascript className="w-6 h-6 text-yellow-400" />,
ts: <SiTypescript className="w-6 h-6 text-blue-500" />,
tsx: <SiTypescript className="w-6 h-6 text-blue-500" />,
py: <SiPython className="w-6 h-6 text-blue-400" />,
java: <Code className="w-6 h-6 text-red-500" />,
cpp: <SiCplusplus className="w-6 h-6 text-blue-600" />,
c: <SiC className="w-6 h-6 text-blue-700" />,
cs: <SiDotnet className="w-6 h-6 text-purple-600" />,
php: <SiPhp className="w-6 h-6 text-indigo-500" />,
rb: <SiRuby className="w-6 h-6 text-red-500" />,
go: <SiGo className="w-6 h-6 text-cyan-500" />,
rs: <SiRust className="w-6 h-6 text-orange-600" />,
html: <SiHtml5 className="w-6 h-6 text-orange-500" />,
css: <Code className="w-6 h-6 text-blue-500" />,
scss: <SiSass className="w-6 h-6 text-pink-500" />,
sass: <SiSass className="w-6 h-6 text-pink-500" />,
less: <Code className="w-6 h-6 text-blue-600" />,
json: <Code className="w-6 h-6 text-yellow-500" />,
xml: <Code className="w-6 h-6 text-orange-500" />,
yaml: <Code className="w-6 h-6 text-red-400" />,
yml: <Code className="w-6 h-6 text-red-400" />,
toml: <Code className="w-6 h-6 text-orange-400" />,
sql: <SiMysql className="w-6 h-6 text-blue-500" />,
sh: <SiGnubash className="w-6 h-6 text-foreground" />,
bash: <SiGnubash className="w-6 h-6 text-foreground" />,
zsh: <Code className="w-6 h-6 text-foreground" />,
vue: <SiVuedotjs className="w-6 h-6 text-green-500" />,
svelte: <SiSvelte className="w-6 h-6 text-orange-500" />,
md: <SiMarkdown className="w-6 h-6 text-muted-foreground" />,
conf: <Code className="w-6 h-6 text-muted-foreground" />,
ini: <Code className="w-6 h-6 text-muted-foreground" />,
};
return iconMap[ext] || <Code className="w-6 h-6 text-yellow-500" />;
}
function getFileType(filename: string): {
type: string;
icon: React.ReactNode;
color: string;
} {
const kind = resolveFilePreviewKind(filename);
if (kind === "image") {
return {
type: "image",
icon: <ImageIcon className="w-6 h-6" />,
color: "text-green-500",
};
} else if (kind === "video") {
return {
type: "video",
icon: <Film className="w-6 h-6" />,
color: "text-purple-500",
};
} else if (kind === "audio") {
return {
type: "audio",
icon: <Music className="w-6 h-6" />,
color: "text-pink-500",
};
} else if (kind === "markdown") {
return {
type: "markdown",
icon: <FileText className="w-6 h-6" />,
color: "text-blue-600",
};
} else if (kind === "pdf") {
return {
type: "pdf",
icon: <FileText className="w-6 h-6" />,
color: "text-red-600",
};
} else if (kind === "text") {
return {
type: "text",
icon: <FileText className="w-6 h-6" />,
color: "text-blue-500",
};
} else if (kind === "code") {
return {
type: "code",
icon: getLanguageIcon(filename),
color: "text-yellow-500",
};
} else {
return {
type: "unknown",
icon: <FileIcon className="w-6 h-6" />,
color: "text-foreground-subtle",
};
}
}
function formatFileSize(bytes?: number, t?: (key: string) => string): string {
if (!bytes) return t ? t("fileManager.unknownSize") : "Unknown size";
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${sizes[i]}`;
}
function PreviewFallback({ label }: { label: string }) {
return (
<div className="h-full flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-2"></div>
<p className="text-sm text-muted-foreground">{label}</p>
</div>
</div>
);
}
export function FileViewer({
file,
content = "",
savedContent = "",
isLoading = false,
isSaving = false,
isEditable = false,
resetKey,
onContentChange,
onSave,
onRevert,
onDownload,
onOpenExternal,
onChooseExternalEditor,
onMediaDimensionsChange,
}: FileViewerProps) {
const { t } = useTranslation();
const [editedContent, setEditedContent] = useState(content);
const [, setOriginalContent] = useState(savedContent || content);
const [hasChanges, setHasChanges] = useState(false);
const [showLargeFileWarning, setShowLargeFileWarning] = useState(false);
const [forceShowAsText, setForceShowAsText] = useState(false);
const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false);
const [editorFocused, setEditorFocused] = useState(false);
const [markdownEditMode, setMarkdownEditMode] = useState(false);
const [editorFontSize, setEditorFontSize] = useState<number>(() => {
const stored = localStorage.getItem("fileManagerEditorFontSize");
return stored ? parseInt(stored, 10) : 14;
});
const editorRef = useRef<CodeEditorHandle | null>(null);
const MIN_FONT_SIZE = 8;
const MAX_FONT_SIZE = 32;
const decreaseFontSize = () => {
setEditorFontSize((prev) => {
const next = Math.max(MIN_FONT_SIZE, prev - 1);
localStorage.setItem("fileManagerEditorFontSize", String(next));
return next;
});
};
const increaseFontSize = () => {
setEditorFontSize((prev) => {
const next = Math.min(MAX_FONT_SIZE, prev + 1);
localStorage.setItem("fileManagerEditorFontSize", String(next));
return next;
});
};
const fileTypeInfo = getFileType(file.name);
const WARNING_SIZE = 50 * 1024 * 1024;
const MAX_SIZE = Number.MAX_SAFE_INTEGER;
const shouldShowAsText =
fileTypeInfo.type === "text" ||
fileTypeInfo.type === "code" ||
(fileTypeInfo.type === "unknown" &&
(forceShowAsText || !file.size || file.size <= WARNING_SIZE));
const isLargeFile = file.size && file.size > WARNING_SIZE;
const isTooLarge = file.size && file.size > MAX_SIZE;
useEffect(() => {
setEditedContent(content);
if (savedContent) {
setOriginalContent(savedContent);
}
}, [file.name, file.path, resetKey]);
useEffect(() => {
setHasChanges(content !== savedContent);
if (fileTypeInfo.type === "unknown" && isLargeFile && !forceShowAsText) {
setShowLargeFileWarning(true);
} else {
setShowLargeFileWarning(false);
}
}, [content, savedContent, fileTypeInfo.type, isLargeFile, forceShowAsText]);
const handleContentChange = (newContent: string) => {
setEditedContent(newContent);
setHasChanges(newContent !== savedContent);
onContentChange?.(newContent);
};
const handleSave = () => {
if (isSaving) return;
onSave?.(editedContent);
};
const handleRevert = () => {
if (onRevert) {
onRevert();
} else {
setEditedContent(savedContent);
setHasChanges(false);
}
};
useEffect(() => {
if (!editorFocused || !isEditable) return;
const handleKeyDown = (e: KeyboardEvent) => {
const isCtrl = e.ctrlKey || e.metaKey;
if (isCtrl && e.key.toLowerCase() === "s") {
e.preventDefault();
e.stopPropagation();
handleSave();
}
};
document.addEventListener("keydown", handleKeyDown, true);
return () => {
document.removeEventListener("keydown", handleKeyDown, true);
};
}, [editorFocused, isEditable, handleSave]);
const handleConfirmOpenAsText = () => {
setForceShowAsText(true);
setShowLargeFileWarning(false);
};
const handleCancelOpenAsText = () => {
setShowLargeFileWarning(false);
};
if (isLoading) {
return (
<div className="h-full flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-4"></div>
<p className="text-sm text-muted-foreground">Loading file...</p>
</div>
</div>
);
}
return (
<div className="h-full flex flex-col bg-background">
<div className="flex-shrink-0 bg-card border-b border-border p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div className={cn("p-2 rounded-lg bg-muted", fileTypeInfo.color)}>
{fileTypeInfo.icon}
</div>
<div>
<h3 className="font-medium text-foreground">{file.name}</h3>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<span>{formatFileSize(file.size, t)}</span>
{file.modified && (
<span>
{t("fileManager.modified")}: {file.modified}
</span>
)}
<span
className={cn(
"px-2 py-1 rounded-full text-xs",
fileTypeInfo.color,
"bg-muted",
)}
>
{fileTypeInfo.type.toUpperCase()}
</span>
</div>
</div>
</div>
<div className="ml-auto flex min-w-0 max-w-full flex-wrap items-center justify-end gap-2">
{isEditable && (
<Button
variant="ghost"
size="sm"
onClick={() => editorRef.current?.openSearchPanel()}
className="flex items-center gap-2"
title={t("fileManager.searchInFile")}
>
<Search className="w-4 h-4" />
</Button>
)}
{isEditable && (
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={decreaseFontSize}
disabled={editorFontSize <= MIN_FONT_SIZE}
title={t("fileManager.decreaseFontSize")}
className="w-7 h-7 p-0"
>
<ZoomOut className="w-4 h-4" />
</Button>
<span className="text-xs text-muted-foreground w-8 text-center select-none">
{editorFontSize}px
</span>
<Button
variant="ghost"
size="sm"
onClick={increaseFontSize}
disabled={editorFontSize >= MAX_FONT_SIZE}
title={t("fileManager.increaseFontSize")}
className="w-7 h-7 p-0"
>
<ZoomIn className="w-4 h-4" />
</Button>
</div>
)}
{isEditable && (
<Button
variant="ghost"
size="sm"
onClick={() => setShowKeyboardShortcuts(!showKeyboardShortcuts)}
className="flex items-center gap-2"
title={t("fileManager.showKeyboardShortcuts")}
>
<Keyboard className="w-4 h-4" />
</Button>
)}
{hasChanges && (
<>
<Button
variant="outline"
size="sm"
onClick={handleRevert}
disabled={isSaving}
className="flex items-center gap-2"
>
<RotateCcw className="w-4 h-4" />
Revert
</Button>
<Button
variant="default"
size="sm"
onClick={handleSave}
disabled={isSaving}
className="flex items-center gap-2"
>
<Save className="w-4 h-4" />
Save
</Button>
</>
)}
{onDownload && (
<Button
variant="outline"
size="sm"
onClick={onDownload}
className="flex items-center gap-2"
>
<Download className="w-4 h-4" />
{t("fileManager.download")}
</Button>
)}
{isEditable && onOpenExternal && (
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
onClick={onOpenExternal}
className="flex items-center gap-2"
>
<ExternalLink className="w-4 h-4" />
{t("fileManager.openExternalEditor")}
</Button>
{onChooseExternalEditor && (
<Button
variant="ghost"
size="sm"
onClick={onChooseExternalEditor}
className="h-8 w-8 p-0"
title={t("fileManager.chooseExternalEditor")}
>
<Settings className="w-4 h-4" />
</Button>
)}
</div>
)}
</div>
</div>
</div>
{showKeyboardShortcuts && isEditable && (
<div className="flex-shrink-0 bg-muted/30 border-b border-border p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold">
{t("fileManager.keyboardShortcuts")}
</h3>
<Button
variant="ghost"
size="sm"
onClick={() => setShowKeyboardShortcuts(false)}
className="h-6 w-6 p-0"
>
×
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-xs">
<div className="space-y-2">
<h4 className="font-medium text-muted-foreground">
{t("fileManager.searchAndReplace")}
</h4>
<div className="space-y-1">
<div className="flex justify-between">
<span>{t("fileManager.search")}</span>
<Kbd className="px-2 py-1 bg-background rounded text-xs">
<KbdKey className="px-2 py-1 bg-background rounded text-xs">
Ctrl+F
</KbdKey>
</Kbd>
</div>
<div className="flex justify-between">
<span>{t("fileManager.replace")}</span>
<Kbd className="px-2 py-1 bg-background rounded text-xs">
<KbdKey className="px-2 py-1 bg-background rounded text-xs">
Ctrl+H
</KbdKey>
</Kbd>
</div>
<div className="flex justify-between">
<span>{t("fileManager.findNext")}</span>
<Kbd className="px-2 py-1 bg-background rounded text-xs">
<KbdKey className="px-2 py-1 bg-background rounded text-xs">
F3
</KbdKey>
</Kbd>
</div>
<div className="flex justify-between">
<span>{t("fileManager.findPrevious")}</span>
<Kbd className="px-2 py-1 bg-background rounded text-xs">
<KbdKey className="px-2 py-1 bg-background rounded text-xs">
Shift+F3
</KbdKey>
</Kbd>
</div>
</div>
</div>
<div className="space-y-2">
<h4 className="font-medium text-muted-foreground">
{t("fileManager.editing")}
</h4>
<div className="space-y-1">
<div className="flex justify-between">
<span>{t("fileManager.save")}</span>
<Kbd className="px-2 py-1 bg-background rounded text-xs">
<KbdKey className="px-2 py-1 bg-background rounded text-xs">
Ctrl+S
</KbdKey>
</Kbd>
</div>
<div className="flex justify-between">
<span>{t("fileManager.selectAll")}</span>
<Kbd className="px-2 py-1 bg-background rounded text-xs">
<KbdKey className="px-2 py-1 bg-background rounded text-xs">
Ctrl+A
</KbdKey>
</Kbd>
</div>
<div className="flex justify-between">
<span>{t("fileManager.undo")}</span>
<Kbd className="px-2 py-1 bg-background rounded text-xs">
<KbdKey className="px-2 py-1 bg-background rounded text-xs">
Ctrl+Z
</KbdKey>
</Kbd>
</div>
<div className="flex justify-between">
<span>{t("fileManager.redo")}</span>
<Kbd className="px-2 py-1 bg-background rounded text-xs">
<KbdKey className="px-2 py-1 bg-background rounded text-xs">
Ctrl+Y
</KbdKey>
</Kbd>
</div>
<div className="flex justify-between">
<span>{t("fileManager.toggleComment")}</span>
<Kbd className="px-2 py-1 bg-background rounded text-xs">
<KbdKey className="px-2 py-1 bg-background rounded text-xs">
Ctrl+/
</KbdKey>
</Kbd>
</div>
<div className="flex justify-between">
<span>{t("fileManager.autoComplete")}</span>
<Kbd className="px-2 py-1 bg-background rounded text-xs">
<KbdKey className="px-2 py-1 bg-background rounded text-xs">
Ctrl+Space
</KbdKey>
</Kbd>
</div>
<div className="flex justify-between">
<span>{t("fileManager.moveLineUp")}</span>
<Kbd className="px-2 py-1 bg-background rounded text-xs">
<KbdKey className="px-2 py-1 bg-background rounded text-xs">
Alt+↑
</KbdKey>
</Kbd>
</div>
<div className="flex justify-between">
<span>{t("fileManager.moveLineDown")}</span>
<Kbd className="px-2 py-1 bg-background rounded text-xs">
<KbdKey className="px-2 py-1 bg-background rounded text-xs">
Alt+↓
</KbdKey>
</Kbd>
</div>
</div>
</div>
</div>
</div>
)}
<div className="flex-1 overflow-hidden">
{showLargeFileWarning && (
<div className="h-full flex items-center justify-center bg-background">
<div className="bg-card border border-destructive/30 rounded-lg p-6 max-w-md mx-4 shadow-lg">
<div className="flex items-start gap-3 mb-4">
<AlertCircle className="w-6 h-6 text-destructive flex-shrink-0 mt-0.5" />
<div>
<h3 className="font-medium text-foreground mb-2">
{t("fileManager.largeFileWarning")}
</h3>
<p className="text-sm text-muted-foreground mb-3">
{t("fileManager.largeFileWarningDesc", {
size: formatFileSize(file.size, t),
})}
</p>
{isTooLarge ? (
<div className="bg-destructive/10 border border-destructive/30 rounded p-3 mb-4">
<p className="text-sm text-destructive font-medium">
File is too large (&gt; 10MB) and cannot be opened as
text for security reasons.
</p>
</div>
) : (
<p className="text-sm text-muted-foreground mb-4">
Do you want to continue opening this file as text? This
may slow down your browser.
</p>
)}
</div>
</div>
<div className="flex gap-3">
{!isTooLarge && (
<Button
variant="default"
size="sm"
onClick={handleConfirmOpenAsText}
className="flex items-center gap-2"
>
<FileText className="w-4 h-4" />
Open as Text
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={onDownload}
className="flex items-center gap-2"
>
<Download className="w-4 h-4" />
{t("fileManager.downloadInstead")}
</Button>
<Button
variant="outline"
size="sm"
onClick={handleCancelOpenAsText}
>
Cancel
</Button>
</div>
</div>
</div>
)}
{fileTypeInfo.type === "image" && !showLargeFileWarning && (
<Suspense fallback={<PreviewFallback label="Loading image..." />}>
<ImagePreview
content={content}
fileName={file.name}
onDownload={onDownload}
onMediaDimensionsChange={onMediaDimensionsChange}
/>
</Suspense>
)}
{shouldShowAsText && !showLargeFileWarning && (
<div className="h-full flex flex-col">
{isEditable ? (
<Suspense
fallback={<PreviewFallback label="Loading editor..." />}
>
<CodeEditor
ref={editorRef}
fileName={file.name}
value={editedContent}
onChange={handleContentChange}
onFocus={() => setEditorFocused(true)}
onBlur={() => setEditorFocused(false)}
placeholder={t("fileManager.startTyping")}
fontSize={editorFontSize}
/>
</Suspense>
) : (
<div className="h-full p-4 font-mono text-sm whitespace-pre-wrap overflow-auto thin-scrollbar bg-background text-foreground">
{editedContent || content || t("fileManager.fileIsEmpty")}
</div>
)}
</div>
)}
{fileTypeInfo.type === "video" && !showLargeFileWarning && (
<div className="p-6 flex items-center justify-center h-full">
<div className="w-full max-w-4xl">
{(() => {
const ext = file.name.split(".").pop()?.toLowerCase() || "";
const mimeType = (() => {
switch (ext) {
case "mp4":
return "video/mp4";
case "webm":
return "video/webm";
case "mkv":
return "video/x-matroska";
case "avi":
return "video/x-msvideo";
case "mov":
return "video/quicktime";
case "wmv":
return "video/x-ms-wmv";
case "flv":
return "video/x-flv";
default:
return "video/mp4";
}
})();
const videoUrl = `data:${mimeType};base64,${content}`;
return (
<div className="relative">
<video
controls
className="w-full rounded-lg shadow-sm"
style={{
maxHeight: "calc(100vh - 200px)",
backgroundColor: "#000",
}}
preload="metadata"
onError={(e) => {
console.error(
"Video playback error:",
e.currentTarget.error,
);
}}
onLoadedMetadata={(e) => {
const video = e.currentTarget;
if (
onMediaDimensionsChange &&
video.videoWidth &&
video.videoHeight
) {
onMediaDimensionsChange({
width: video.videoWidth,
height: video.videoHeight,
});
}
}}
>
<source src={videoUrl} type={mimeType} />
<div className="text-center text-muted-foreground p-4">
<AlertCircle className="w-8 h-8 mx-auto mb-2" />
<p>
Your browser does not support video playback for this
format.
</p>
{onDownload && (
<Button
variant="outline"
onClick={onDownload}
className="mt-2 flex items-center gap-2 mx-auto"
>
<Download className="w-4 h-4" />
Download to play externally
</Button>
)}
</div>
</video>
</div>
);
})()}
</div>
</div>
)}
{fileTypeInfo.type === "markdown" && !showLargeFileWarning && (
<div className="h-full flex flex-col">
<div className="flex-shrink-0 bg-muted/30 border-b border-border p-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Button
variant={markdownEditMode ? "default" : "outline"}
size="sm"
onClick={() => setMarkdownEditMode(true)}
>
<Edit className="w-4 h-4 mr-1" />
{t("fileManager.edit")}
</Button>
<Button
variant={!markdownEditMode ? "default" : "outline"}
size="sm"
onClick={() => setMarkdownEditMode(false)}
>
<Eye className="w-4 h-4 mr-1" />
{t("fileManager.preview")}
</Button>
</div>
<div className="flex items-center gap-2"></div>
</div>
</div>
<div className="flex-1 flex overflow-hidden">
{markdownEditMode ? (
<>
<div className="flex-1 border-r border-border">
<div className="h-full p-4 bg-background">
<textarea
value={editedContent}
onChange={(e) => {
setEditedContent(e.target.value);
onContentChange?.(e.target.value);
}}
className="w-full h-full resize-none border-0 bg-transparent text-foreground font-mono text-sm leading-relaxed focus:outline-none focus:ring-0"
placeholder={t("fileManager.startWritingMarkdown")}
/>
</div>
</div>
<div className="flex-1 overflow-auto thin-scrollbar bg-muted/10">
<div className="p-4">
<Suspense
fallback={
<PreviewFallback label="Loading preview..." />
}
>
<MarkdownRenderer
compact
content={editedContent || "Nothing to preview yet..."}
/>
</Suspense>
</div>
</div>
</>
) : (
<div className="flex-1 overflow-auto thin-scrollbar p-6">
<div className="max-w-4xl mx-auto">
<Suspense
fallback={<PreviewFallback label="Loading preview..." />}
>
<MarkdownRenderer content={editedContent} />
</Suspense>
</div>
</div>
)}
</div>
</div>
)}
{fileTypeInfo.type === "pdf" && !showLargeFileWarning && (
<Suspense
fallback={<PreviewFallback label="Loading PDF viewer..." />}
>
<PdfPreview
content={content}
onDownload={onDownload}
onMediaDimensionsChange={onMediaDimensionsChange}
/>
</Suspense>
)}
{fileTypeInfo.type === "audio" && !showLargeFileWarning && (
<Suspense
fallback={<PreviewFallback label="Loading audio player..." />}
>
<AudioPreview
file={file}
content={content}
color={fileTypeInfo.color}
onMediaDimensionsChange={onMediaDimensionsChange}
/>
</Suspense>
)}
{fileTypeInfo.type === "unknown" &&
!shouldShowAsText &&
!showLargeFileWarning && (
<div className="h-full flex items-center justify-center">
<div className="text-center text-muted-foreground">
<AlertCircle className="w-16 h-16 mx-auto mb-4 text-muted-foreground/50" />
<h3 className="text-lg font-medium mb-2">
Cannot preview this file type
</h3>
<p className="text-sm mb-4">
This file type is not supported for preview. You can download
it to view in an external application.
</p>
{onDownload && (
<Button
variant="outline"
onClick={onDownload}
className="flex items-center gap-2 mx-auto"
>
<Download className="w-4 h-4" />
{t("fileManager.downloadFile")}
</Button>
)}
</div>
</div>
)}
</div>
<div className="flex-shrink-0 bg-muted/50 border-t border-border px-4 py-2 text-xs text-muted-foreground">
<div className="flex justify-between items-center">
<span>{file.path}</span>
{hasChanges && (
<span className="text-orange-600 font-medium">
● Unsaved changes
</span>
)}
</div>
</div>
</div>
);
}
@@ -1,571 +0,0 @@
/* eslint-disable react-hooks/exhaustive-deps */
import React, { useState, useEffect, useRef } from "react";
import { DraggableWindow } from "./DraggableWindow.tsx";
import { FileViewer } from "./FileViewer.tsx";
import { useWindowManager } from "./WindowManager.tsx";
import {
downloadSSHFile,
readSSHFile,
writeSSHFile,
getSSHStatus,
connectSSH,
} from "@/main-axios.ts";
import { toast } from "sonner";
import type { SSHHost } from "@/types/index";
import { useTranslation } from "react-i18next";
interface FileItem {
name: string;
type: "file" | "directory" | "link";
path: string;
size?: number;
modified?: string;
permissions?: string;
owner?: string;
group?: string;
}
interface FileWindowProps {
windowId: string;
file: FileItem;
sshSessionId: string;
sshHost: SSHHost;
initialX?: number;
initialY?: number;
onFileNotFound?: (file: FileItem) => void;
}
function isDisplayableText(str: string): boolean {
let printable = 0;
for (let i = 0; i < Math.min(str.length, 1000); i++) {
const code = str.charCodeAt(i);
if (
(code >= 32 && code <= 126) ||
code === 9 ||
code === 10 ||
code === 13 ||
code >= 128
) {
printable++;
}
}
return printable / Math.min(str.length, 1000) > 0.85;
}
export function FileWindow({
windowId,
file,
sshSessionId,
sshHost,
initialX = 100,
initialY = 100,
onFileNotFound,
}: FileWindowProps) {
const { closeWindow, maximizeWindow, focusWindow, windows } =
useWindowManager();
const { t } = useTranslation();
const [content, setContent] = useState<string>("");
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [isEditable, setIsEditable] = useState(false);
const [pendingContent, setPendingContent] = useState<string>("");
const [resetKey, setResetKey] = useState(0);
const [mediaDimensions, setMediaDimensions] = useState<
{ width: number; height: number } | undefined
>();
const [externalEditorPath, setExternalEditorPath] = useState<string>("");
const autoSaveTimerRef = useRef<NodeJS.Timeout | null>(null);
const externalEditorRef = useRef<{
editId: string;
unsubscribe?: () => void;
} | null>(null);
const currentWindow = windows.find((w) => w.id === windowId);
useEffect(() => {
if (!window.electronAPI?.isElectron) return;
window.electronAPI
.getSetting?.("fileManager.externalEditorPath")
.then((value) => {
if (typeof value === "string") setExternalEditorPath(value);
})
.catch(() => {});
}, []);
const ensureSSHConnection = async () => {
try {
const status = await getSSHStatus(sshSessionId);
if (!status.connected) {
await connectSSH(sshSessionId, {
hostId: sshHost.id,
ip: sshHost.ip,
port: sshHost.port,
username: sshHost.username,
password: sshHost.password,
sshKey: sshHost.key,
keyPassword: sshHost.keyPassword,
authType: sshHost.authType,
credentialId: sshHost.credentialId,
userId: sshHost.userId,
});
}
} catch (error) {
console.error("SSH connection check/reconnect failed:", error);
throw error;
}
};
useEffect(() => {
const loadFileContent = async () => {
if (file.type !== "file") return;
try {
setIsLoading(true);
await ensureSSHConnection();
const response = await readSSHFile(sshSessionId, file.path);
let fileContent = response.content || "";
if (response.encoding === "base64") {
try {
const bytes = Uint8Array.from(atob(fileContent), (c) =>
c.charCodeAt(0),
);
const decoded = new TextDecoder("utf-8").decode(bytes);
if (isDisplayableText(decoded)) {
fileContent = decoded;
}
} catch (err) {
console.error("Failed to decode base64 content:", err);
}
}
setContent(fileContent);
setPendingContent(fileContent);
setResetKey((k) => k + 1);
if (!file.size) {
const contentSize = new Blob([fileContent]).size;
file.size = contentSize;
}
const mediaExtensions = [
"jpg",
"jpeg",
"png",
"gif",
"bmp",
"svg",
"webp",
"tiff",
"ico",
"mp3",
"wav",
"ogg",
"aac",
"flac",
"m4a",
"wma",
"mp4",
"avi",
"mov",
"wmv",
"flv",
"mkv",
"webm",
"m4v",
"zip",
"rar",
"7z",
"tar",
"gz",
"bz2",
"xz",
"exe",
"dll",
"so",
"dylib",
"bin",
"iso",
];
const extension = file.name.split(".").pop()?.toLowerCase();
setIsEditable(!mediaExtensions.includes(extension || ""));
} catch (error: unknown) {
console.error("Failed to load file:", error);
const err = error as {
message?: string;
isFileNotFound?: boolean;
response?: {
status?: number;
data?: {
tooLarge?: boolean;
error?: string;
fileNotFound?: boolean;
};
};
};
const errorData = err?.response?.data;
if (errorData?.tooLarge) {
toast.error(`File too large: ${errorData.error}`, {
duration: 10000,
});
} else if (
err.message?.includes("connection") ||
err.message?.includes("established")
) {
toast.error(
`SSH connection failed. Please check your connection to ${sshHost.name} (${sshHost.ip}:${sshHost.port})`,
);
} else {
const errorMessage =
errorData?.error || err.message || "Unknown error";
const isFileNotFound =
err.isFileNotFound ||
errorData?.fileNotFound ||
err.response?.status === 404 ||
errorMessage.includes("File not found") ||
errorMessage.includes("No such file or directory") ||
errorMessage.includes("cannot access") ||
errorMessage.includes("not found") ||
errorMessage.includes("Resource not found");
if (isFileNotFound && onFileNotFound) {
onFileNotFound(file);
toast.error(
t("fileManager.fileNotFoundAndRemoved", { name: file.name }),
);
closeWindow(windowId);
return;
} else {
toast.error(
t("fileManager.failedToLoadFile", {
error: errorMessage.includes("Server error occurred")
? t("fileManager.serverErrorOccurred")
: errorMessage,
}),
);
}
}
} finally {
setIsLoading(false);
}
};
loadFileContent();
}, [file, sshSessionId, sshHost]);
const handleRevert = async () => {
const loadFileContent = async () => {
if (file.type !== "file") return;
try {
setIsLoading(true);
await ensureSSHConnection();
const response = await readSSHFile(sshSessionId, file.path, {
force: true,
});
const fileContent = response.content || "";
setContent(fileContent);
setPendingContent("");
setResetKey((k) => k + 1);
if (!file.size) {
const contentSize = new Blob([fileContent]).size;
file.size = contentSize;
}
} catch (error: unknown) {
console.error("Failed to load file content:", error);
const err = error as { message?: string };
toast.error(
`${t("fileManager.failedToLoadFile")}: ${err.message || t("fileManager.unknownError")}`,
);
} finally {
setIsLoading(false);
}
};
loadFileContent();
};
const handleSave = async (newContent: string) => {
try {
setIsSaving(true);
await ensureSSHConnection();
await writeSSHFile(sshSessionId, file.path, newContent);
setContent(newContent);
setPendingContent("");
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current);
autoSaveTimerRef.current = null;
}
toast.success(t("fileManager.fileSavedSuccessfully"));
} catch (error: unknown) {
console.error("Failed to save file:", error);
const err = error as { message?: string };
if (
err.message?.includes("connection") ||
err.message?.includes("established")
) {
toast.error(
`SSH connection failed. Please check your connection to ${sshHost.name} (${sshHost.ip}:${sshHost.port})`,
);
} else {
toast.error(
`${t("fileManager.failedToSaveFile")}: ${err.message || t("fileManager.unknownError")}`,
);
}
} finally {
setIsSaving(false);
}
};
const closeExternalEditor = async () => {
const session = externalEditorRef.current;
if (!session) return;
session.unsubscribe?.();
externalEditorRef.current = null;
try {
await window.electronAPI?.closeExternalEditor?.(session.editId);
} catch (error) {
console.error("Failed to close external editor session:", error);
}
};
const handleOpenExternalEditor = async () => {
if (!window.electronAPI?.isElectron) {
toast.error(t("fileManager.externalEditorDesktopOnly"));
return;
}
try {
await closeExternalEditor();
const result = await window.electronAPI.openExternalEditor({
fileName: file.name,
content: pendingContent || content,
encoding: "utf8",
editorPath: externalEditorPath || null,
});
if (!result.success || !result.editId) {
throw new Error(result.error || "Failed to open external editor");
}
const unsubscribe = window.electronAPI.onExternalEditorSaved?.(
(payload) => {
if (payload.editId !== result.editId) return;
void handleSave(payload.content);
},
);
externalEditorRef.current = {
editId: result.editId,
unsubscribe,
};
toast.success(t("fileManager.externalEditorOpened"));
} catch (error: unknown) {
console.error("Failed to open external editor:", error);
const err = error as { message?: string };
toast.error(
`${t("fileManager.failedToOpenExternalEditor")}: ${
err.message || t("fileManager.unknownError")
}`,
);
}
};
const handleChooseExternalEditor = async () => {
if (!window.electronAPI?.isElectron) {
toast.error(t("fileManager.externalEditorDesktopOnly"));
return;
}
try {
const result = await window.electronAPI.showOpenDialog({
title: t("fileManager.chooseExternalEditor"),
properties: ["openFile"],
});
const editorPath = result.filePaths?.[0];
if (result.canceled || !editorPath) return;
await window.electronAPI.setSetting?.(
"fileManager.externalEditorPath",
editorPath,
);
setExternalEditorPath(editorPath);
toast.success(t("fileManager.externalEditorSelected"));
} catch (error: unknown) {
console.error("Failed to select external editor:", error);
const err = error as { message?: string };
toast.error(
`${t("fileManager.failedToSelectExternalEditor")}: ${
err.message || t("fileManager.unknownError")
}`,
);
}
};
const handleContentChange = (newContent: string) => {
setPendingContent(newContent);
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current);
autoSaveTimerRef.current = null;
}
if (newContent !== content) {
autoSaveTimerRef.current = setTimeout(async () => {
try {
await handleSave(newContent);
toast.success(t("fileManager.fileAutoSaved"));
} catch (error) {
console.error("Auto-save failed:", error);
toast.error(t("fileManager.autoSaveFailed"));
}
}, 60000);
}
};
useEffect(() => {
return () => {
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current);
}
void closeExternalEditor();
};
}, []);
const handleDownload = async () => {
try {
await ensureSSHConnection();
const response = await downloadSSHFile(sshSessionId, file.path);
if (response?.content) {
const byteCharacters = atob(response.content);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
const blob = new Blob([byteArray], {
type: response.mimeType || "application/octet-stream",
});
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = response.fileName || file.name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
toast.success(
t("fileManager.fileDownloadedSuccessfully", {
name: response.fileName || file.name,
}),
);
}
} catch (error: unknown) {
console.error("Failed to download file:", error);
const err = error as { message?: string };
if (
err.message?.includes("connection") ||
err.message?.includes("established")
) {
toast.error(
`SSH connection failed. Please check your connection to ${sshHost.name} (${sshHost.ip}:${sshHost.port})`,
);
} else {
toast.error(
`Failed to download file: ${err.message || "Unknown error"}`,
);
}
}
};
const handleClose = () => {
void closeExternalEditor();
closeWindow(windowId);
};
const handleMaximize = () => {
maximizeWindow(windowId);
};
const handleFocus = () => {
focusWindow(windowId);
};
const handleMediaDimensionsChange = (dimensions: {
width: number;
height: number;
}) => {
setMediaDimensions(dimensions);
};
if (!currentWindow) {
return null;
}
return (
<DraggableWindow
title={file.name}
initialX={initialX}
initialY={initialY}
initialWidth={800}
initialHeight={600}
minWidth={400}
minHeight={300}
onClose={handleClose}
onMaximize={handleMaximize}
onFocus={handleFocus}
isMaximized={currentWindow.isMaximized}
zIndex={currentWindow.zIndex}
targetSize={mediaDimensions}
>
<FileViewer
file={file}
content={pendingContent || content}
savedContent={content}
isLoading={isLoading}
isSaving={isSaving}
resetKey={resetKey}
onRevert={handleRevert}
isEditable={isEditable}
onContentChange={handleContentChange}
onSave={(newContent) => handleSave(newContent)}
onDownload={handleDownload}
onOpenExternal={
window.electronAPI?.isElectron && isEditable
? handleOpenExternalEditor
: undefined
}
onChooseExternalEditor={
window.electronAPI?.isElectron && isEditable
? handleChooseExternalEditor
: undefined
}
onMediaDimensionsChange={handleMediaDimensionsChange}
/>
</DraggableWindow>
);
}
@@ -1,112 +0,0 @@
import React, { useState } from "react";
import { PhotoProvider, PhotoView } from "react-photo-view";
import "react-photo-view/dist/react-photo-view.css";
import { AlertCircle, Download } from "lucide-react";
import { Button } from "@/components/button.tsx";
import { useTranslation } from "react-i18next";
interface ImagePreviewProps {
content: string;
fileName: string;
onDownload?: () => void;
onMediaDimensionsChange?: (dimensions: {
width: number;
height: number;
}) => void;
}
function getImageDataUrl(content: string, fileName: string): string {
const ext = fileName.split(".").pop()?.toLowerCase() || "";
const mimeTypes: Record<string, string> = {
svg: "image/svg+xml",
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
webp: "image/webp",
bmp: "image/bmp",
ico: "image/x-icon",
tiff: "image/tiff",
tif: "image/tiff",
};
const mimeType = mimeTypes[ext] || "image/png";
return `data:${mimeType};base64,${content}`;
}
export function ImagePreview({
content,
fileName,
onDownload,
onMediaDimensionsChange,
}: ImagePreviewProps) {
const { t } = useTranslation();
const [imageLoadError, setImageLoadError] = useState(false);
const [imageLoading, setImageLoading] = useState(true);
const imageUrl = getImageDataUrl(content, fileName);
return (
<div className="p-6 flex items-center justify-center h-full relative">
{imageLoadError ? (
<div className="text-center text-muted-foreground">
<AlertCircle className="w-16 h-16 mx-auto mb-4 text-muted-foreground/50" />
<h3 className="text-lg font-medium mb-2">
{t("fileManager.imageLoadError")}
</h3>
<p className="text-sm mb-4">{fileName}</p>
{onDownload && (
<Button
variant="outline"
onClick={onDownload}
className="flex items-center gap-2 mx-auto"
>
<Download className="w-4 h-4" />
{t("fileManager.download")}
</Button>
)}
</div>
) : (
<PhotoProvider maskOpacity={0.7}>
<PhotoView src={imageUrl}>
<img
src={imageUrl}
alt={fileName}
className="max-w-full max-h-full object-contain rounded-lg shadow-sm cursor-pointer hover:shadow-lg transition-shadow"
style={{ maxHeight: "calc(100vh - 200px)" }}
onLoad={(e) => {
setImageLoading(false);
setImageLoadError(false);
const img = e.currentTarget;
if (
onMediaDimensionsChange &&
img.naturalWidth &&
img.naturalHeight
) {
onMediaDimensionsChange({
width: img.naturalWidth,
height: img.naturalHeight,
});
}
}}
onError={() => {
setImageLoading(false);
setImageLoadError(true);
}}
/>
</PhotoView>
</PhotoProvider>
)}
{imageLoading && !imageLoadError && (
<div className="absolute inset-0 flex items-center justify-center bg-background/80">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-2"></div>
<p className="text-sm text-muted-foreground">Loading image...</p>
</div>
</div>
)}
</div>
);
}
@@ -1,110 +0,0 @@
import { Button } from "@/components/button.tsx";
import { useTranslation } from "react-i18next";
import { formatFileSize } from "../file-manager-utils.ts";
export interface LocalTransferBatchStatus {
direction: "upload" | "download";
totalFiles: number;
completedFiles: number;
currentFileName?: string;
/** Files being transferred right now (parallel transfers). */
activeFiles?: number;
bytesDone: number;
totalBytes: number;
mbPerSec?: number;
cancelling?: boolean;
}
interface LocalTransferProgressToastProps {
status: LocalTransferBatchStatus;
onCancel?: () => void;
}
export function LocalTransferProgressToast({
status,
onCancel,
}: LocalTransferProgressToastProps) {
const { t } = useTranslation();
const percent =
status.totalBytes > 0
? Math.min(100, Math.round((status.bytesDone / status.totalBytes) * 100))
: undefined;
const title =
status.direction === "upload"
? t("fileManager.localUploadingProgress", {
current: Math.min(
status.completedFiles + Math.max(1, status.activeFiles ?? 1),
status.totalFiles,
),
total: status.totalFiles,
})
: t("fileManager.localDownloadingProgress", {
current: Math.min(
status.completedFiles + Math.max(1, status.activeFiles ?? 1),
status.totalFiles,
),
total: status.totalFiles,
});
return (
<div className="flex w-[min(calc(100vw-5rem),288px)] max-w-full flex-col gap-2 pr-2">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="text-sm font-medium leading-tight">{title}</p>
{status.currentFileName && (
<p
className="text-xs text-muted-foreground truncate"
title={status.currentFileName}
>
{(status.activeFiles ?? 0) > 1 && (
<span className="mr-1 text-foreground/70">
{t("fileManager.localTransferInFlight", {
count: status.activeFiles,
})}
{" · "}
</span>
)}
{status.currentFileName}
</p>
)}
</div>
{onCancel && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 shrink-0 px-2 text-xs"
disabled={status.cancelling}
onClick={onCancel}
>
{status.cancelling
? t("transfer.progressCancelling")
: t("transfer.progressCancel")}
</Button>
)}
</div>
<div className="bg-primary/20 relative h-2 w-full overflow-hidden rounded-full">
{percent === undefined ? (
<div className="bg-primary/60 absolute inset-y-0 left-0 w-1/3 animate-pulse rounded-full" />
) : (
<div
className="bg-primary h-full rounded-full transition-[width]"
style={{ width: `${percent}%` }}
/>
)}
</div>
<div className="flex items-center justify-between gap-3 pr-1 text-xs text-muted-foreground">
<span className="min-w-0 truncate">
{formatFileSize(status.bytesDone)} /{" "}
{formatFileSize(status.totalBytes)}
</span>
<span className="shrink-0 tabular-nums font-medium text-foreground">
{status.mbPerSec !== undefined
? `${status.mbPerSec.toFixed(status.mbPerSec >= 10 ? 0 : 1)} MB/s`
: ""}
</span>
</div>
</div>
);
}
@@ -1,122 +0,0 @@
import React from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { oneDark as syntaxTheme } from "react-syntax-highlighter/dist/esm/styles/prism";
interface MarkdownRendererProps {
content: string;
compact?: boolean;
}
export function MarkdownRenderer({
content,
compact = false,
}: MarkdownRendererProps) {
const h1Class = compact
? "text-2xl font-bold mb-4 mt-6 text-foreground border-b border-border pb-2"
: "text-3xl font-bold mb-6 mt-8 text-foreground border-b border-border pb-2";
const h2Class = compact
? "text-xl font-semibold mb-3 mt-5 text-foreground border-b border-border pb-1"
: "text-2xl font-semibold mb-4 mt-6 text-foreground border-b border-border pb-1";
const h3Class = compact
? "text-lg font-semibold mb-2 mt-4 text-foreground"
: "text-xl font-semibold mb-3 mt-4 text-foreground";
const h4Class = compact
? "text-base font-semibold mb-2 mt-3 text-foreground"
: "text-lg font-semibold mb-2 mt-3 text-foreground";
const pClass = compact
? "mb-3 text-foreground leading-relaxed"
: "mb-4 text-foreground leading-relaxed";
const listClass = compact
? "mb-3 ml-4 text-foreground"
: "mb-4 ml-6 text-foreground";
const quoteClass = compact
? "border-l-4 border-blue-500 pl-3 mb-3 italic text-muted-foreground bg-muted/30 py-1"
: "border-l-4 border-blue-500 pl-4 mb-4 italic text-muted-foreground bg-muted/30 py-2";
const tableWrapClass = compact
? "mb-3 overflow-x-auto thin-scrollbar"
: "mb-4 overflow-x-auto thin-scrollbar";
const tableClass = compact
? "min-w-full border border-border rounded-lg text-sm"
: "min-w-full border border-border rounded-lg";
const thClass = compact
? "px-3 py-2 text-left font-semibold text-foreground"
: "px-4 py-2 text-left font-semibold text-foreground";
const tdClass = compact
? "px-3 py-2 text-foreground"
: "px-4 py-2 text-foreground";
return (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
code({ className, children, ...props }) {
const match = /language-(\w+)/.exec(className || "");
// v9 removed the `inline` flag; a fenced block always carries a
// language- class, an inline span never does.
const inline = !className;
return !inline && match ? (
<SyntaxHighlighter
style={syntaxTheme}
language={match[1]}
PreTag="div"
className="rounded-lg"
{...props}
>
{String(children).replace(/\n$/, "")}
</SyntaxHighlighter>
) : (
<code
className="bg-muted px-1 py-0.5 rounded text-sm font-mono"
{...props}
>
{children}
</code>
);
},
h1: ({ children }) => <h1 className={h1Class}>{children}</h1>,
h2: ({ children }) => <h2 className={h2Class}>{children}</h2>,
h3: ({ children }) => <h3 className={h3Class}>{children}</h3>,
h4: ({ children }) => <h4 className={h4Class}>{children}</h4>,
p: ({ children }) => <p className={pClass}>{children}</p>,
ul: ({ children }) => (
<ul className={`${listClass} list-disc`}>{children}</ul>
),
ol: ({ children }) => (
<ol className={`${listClass} list-decimal`}>{children}</ol>
),
li: ({ children }) => (
<li className="mb-1 text-foreground">{children}</li>
),
blockquote: ({ children }) => (
<blockquote className={quoteClass}>{children}</blockquote>
),
table: ({ children }) => (
<div className={tableWrapClass}>
<table className={tableClass}>{children}</table>
</div>
),
thead: ({ children }) => <thead className="bg-muted">{children}</thead>,
tbody: ({ children }) => <tbody>{children}</tbody>,
tr: ({ children }) => (
<tr className="border-b border-border">{children}</tr>
),
th: ({ children }) => <th className={thClass}>{children}</th>,
td: ({ children }) => <td className={tdClass}>{children}</td>,
a: ({ href, children }) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 underline"
>
{children}
</a>
),
}}
>
{content}
</ReactMarkdown>
);
}
@@ -1,43 +0,0 @@
import type React from "react";
import { cn } from "@/lib/utils.ts";
interface PaneResizeHandleProps {
label: string;
onMouseDown: (event: React.MouseEvent) => void;
onDoubleClick?: () => void;
active?: boolean;
}
/**
* Vertical drag handle placed between two panes in a `gap-3` flex row. It
* overlaps the gap (negative margins) so the panes' spacing is unchanged, and
* shows a hairline on hover / while dragging. Double-click resets the width.
*/
export function PaneResizeHandle({
label,
onMouseDown,
onDoubleClick,
active = false,
}: PaneResizeHandleProps) {
return (
<div
role="separator"
aria-orientation="vertical"
aria-label={label}
title={label}
data-pane-resize-handle
onMouseDown={onMouseDown}
onDoubleClick={onDoubleClick}
className="hidden md:block -mx-3 w-3 shrink-0 cursor-col-resize group relative z-10"
>
<div
className={cn(
"absolute inset-y-0 left-1/2 w-px -translate-x-1/2 transition-colors",
active
? "bg-accent-brand"
: "bg-transparent group-hover:bg-accent-brand/60",
)}
/>
</div>
);
}
@@ -1,148 +0,0 @@
import React, { useState } from "react";
import { Document, Page, pdfjs } from "react-pdf";
import { AlertCircle, Download } from "lucide-react";
import { Button } from "@/components/button.tsx";
import { useTranslation } from "react-i18next";
import pdfjsWorkerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url";
pdfjs.GlobalWorkerOptions.workerSrc = pdfjsWorkerUrl;
interface PdfPreviewProps {
content: string;
onDownload?: () => void;
onMediaDimensionsChange?: (dimensions: {
width: number;
height: number;
}) => void;
}
export function PdfPreview({
content,
onDownload,
onMediaDimensionsChange,
}: PdfPreviewProps) {
const { t } = useTranslation();
const [numPages, setNumPages] = useState<number | null>(null);
const [pageNumber, setPageNumber] = useState(1);
const [pdfScale, setPdfScale] = useState(1.2);
const [pdfError, setPdfError] = useState(false);
return (
<div className="h-full flex flex-col bg-background">
<div className="flex-shrink-0 bg-muted/30 border-b border-border p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPageNumber(Math.max(1, pageNumber - 1))}
disabled={pageNumber <= 1}
>
{t("fileManager.previous")}
</Button>
<span className="text-sm text-foreground px-3 py-1 bg-background border border-border">
{t("fileManager.pageXOfY", {
current: pageNumber,
total: numPages || 0,
})}
</span>
<Button
variant="outline"
size="sm"
onClick={() =>
setPageNumber(Math.min(numPages || 1, pageNumber + 1))
}
disabled={!numPages || pageNumber >= numPages}
>
{t("fileManager.next")}
</Button>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPdfScale(Math.max(0.5, pdfScale - 0.2))}
>
{t("fileManager.zoomOut")}
</Button>
<span className="text-sm text-foreground px-3 py-1 bg-background border border-border min-w-[80px] text-center">
{Math.round(pdfScale * 100)}%
</span>
<Button
variant="outline"
size="sm"
onClick={() => setPdfScale(Math.min(3.0, pdfScale + 0.2))}
>
{t("fileManager.zoomIn")}
</Button>
</div>
</div>
</div>
</div>
<div className="flex-1 overflow-auto thin-scrollbar p-6 bg-surface">
<div className="flex justify-center">
{pdfError ? (
<div className="text-center text-muted-foreground p-8">
<AlertCircle className="w-16 h-16 mx-auto mb-4 text-muted-foreground/50" />
<h3 className="text-lg font-medium mb-2">
{t("fileManager.cannotLoadPdf")}
</h3>
<p className="text-sm mb-4">{t("fileManager.pdfLoadError")}</p>
{onDownload && (
<Button
variant="outline"
onClick={onDownload}
className="flex items-center gap-2 mx-auto"
>
<Download className="w-4 h-4" />
{t("fileManager.download")}
</Button>
)}
</div>
) : (
<Document
file={`data:application/pdf;base64,${content}`}
onLoadSuccess={({ numPages }) => {
setNumPages(numPages);
setPdfError(false);
onMediaDimensionsChange?.({
width: 800,
height: 600,
});
}}
onLoadError={(error) => {
console.error("PDF load error:", error);
setPdfError(true);
}}
loading={
<div className="text-center p-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-2"></div>
<p className="text-sm text-muted-foreground">
{t("fileManager.loadingPdf")}
</p>
</div>
}
>
<Page
pageNumber={pageNumber}
scale={pdfScale}
className="shadow-lg"
loading={
<div className="text-center p-4">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500 mx-auto mb-2"></div>
<p className="text-xs text-muted-foreground">
{t("fileManager.loadingPage")}
</p>
</div>
}
/>
</Document>
)}
</div>
</div>
</div>
);
}
@@ -1,264 +0,0 @@
import React, { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/dialog.tsx";
import { Button } from "@/components/button.tsx";
import { Input } from "@/components/input.tsx";
import { useTranslation } from "react-i18next";
import { Lock } from "lucide-react";
interface FileItem {
name: string;
type: "file" | "directory" | "link";
path: string;
permissions?: string;
owner?: string;
group?: string;
}
interface PermissionsDialogProps {
file: FileItem | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onSave: (file: FileItem, permissions: string) => Promise<void>;
}
const parsePermissions = (
perms: string,
): { owner: number; group: number; other: number } => {
if (!perms) {
return { owner: 0, group: 0, other: 0 };
}
if (/^\d{3,4}$/.test(perms)) {
const numStr = perms.slice(-3);
return {
owner: parseInt(numStr[0] || "0", 10),
group: parseInt(numStr[1] || "0", 10),
other: parseInt(numStr[2] || "0", 10),
};
}
const cleanPerms = perms.replace(/^-/, "").substring(0, 9);
const calcBits = (str: string): number => {
let value = 0;
if (str[0] === "r") value += 4;
if (str[1] === "w") value += 2;
if (str[2] === "x") value += 1;
return value;
};
return {
owner: calcBits(cleanPerms.substring(0, 3)),
group: calcBits(cleanPerms.substring(3, 6)),
other: calcBits(cleanPerms.substring(6, 9)),
};
};
const toNumeric = (owner: number, group: number, other: number): string => {
return `${owner}${group}${other}`;
};
export function PermissionsDialog({
file,
open,
onOpenChange,
onSave,
}: PermissionsDialogProps) {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const initialPerms = parsePermissions(file?.permissions || "644");
const [ownerRead, setOwnerRead] = useState((initialPerms.owner & 4) !== 0);
const [ownerWrite, setOwnerWrite] = useState((initialPerms.owner & 2) !== 0);
const [ownerExecute, setOwnerExecute] = useState(
(initialPerms.owner & 1) !== 0,
);
const [groupRead, setGroupRead] = useState((initialPerms.group & 4) !== 0);
const [groupWrite, setGroupWrite] = useState((initialPerms.group & 2) !== 0);
const [groupExecute, setGroupExecute] = useState(
(initialPerms.group & 1) !== 0,
);
const [otherRead, setOtherRead] = useState((initialPerms.other & 4) !== 0);
const [otherWrite, setOtherWrite] = useState((initialPerms.other & 2) !== 0);
const [otherExecute, setOtherExecute] = useState(
(initialPerms.other & 1) !== 0,
);
useEffect(() => {
if (file) {
const perms = parsePermissions(file.permissions || "644");
setOwnerRead((perms.owner & 4) !== 0);
setOwnerWrite((perms.owner & 2) !== 0);
setOwnerExecute((perms.owner & 1) !== 0);
setGroupRead((perms.group & 4) !== 0);
setGroupWrite((perms.group & 2) !== 0);
setGroupExecute((perms.group & 1) !== 0);
setOtherRead((perms.other & 4) !== 0);
setOtherWrite((perms.other & 2) !== 0);
setOtherExecute((perms.other & 1) !== 0);
}
}, [file]);
const calculateOctal = (): string => {
const owner =
(ownerRead ? 4 : 0) + (ownerWrite ? 2 : 0) + (ownerExecute ? 1 : 0);
const group =
(groupRead ? 4 : 0) + (groupWrite ? 2 : 0) + (groupExecute ? 1 : 0);
const other =
(otherRead ? 4 : 0) + (otherWrite ? 2 : 0) + (otherExecute ? 1 : 0);
return toNumeric(owner, group, other);
};
const handleSave = async () => {
if (!file) return;
setLoading(true);
try {
const permissions = calculateOctal();
await onSave(file, permissions);
onOpenChange(false);
} catch (error) {
console.error("Failed to update permissions:", error);
} finally {
setLoading(false);
}
};
if (!file) return null;
const octal = calculateOctal();
const rows = [
{
label: `${t("fileManager.owner")}${file.owner ? ` (${file.owner})` : ""}`,
read: ownerRead,
setRead: setOwnerRead,
write: ownerWrite,
setWrite: setOwnerWrite,
execute: ownerExecute,
setExecute: setOwnerExecute,
},
{
label: `${t("fileManager.group")}${file.group ? ` (${file.group})` : ""}`,
read: groupRead,
setRead: setGroupRead,
write: groupWrite,
setWrite: setGroupWrite,
execute: groupExecute,
setExecute: setGroupExecute,
},
{
label: t("fileManager.others"),
read: otherRead,
setRead: setOtherRead,
write: otherWrite,
setWrite: setOtherWrite,
execute: otherExecute,
setExecute: setOtherExecute,
},
];
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[calc(100vw-2rem)] sm:max-w-lg rounded-none border-border bg-card">
<DialogHeader>
<DialogTitle className="text-xs font-bold uppercase tracking-widest flex items-center gap-2">
<Lock className="size-4 text-accent-brand" />
{t("fileManager.changePermissions")}
</DialogTitle>
<DialogDescription className="text-[10px] font-bold tracking-tight text-muted-foreground font-mono break-all">
{file.path}
</DialogDescription>
</DialogHeader>
<div className="py-3 flex flex-col gap-4">
<div className="border border-border overflow-hidden">
<div className="grid grid-cols-[1fr_64px_64px_64px] bg-muted/50 border-b border-border">
<div className="px-3 py-2 text-[10px] font-bold uppercase tracking-widest text-muted-foreground" />
{[
t("fileManager.read"),
t("fileManager.write"),
t("fileManager.execute"),
].map((h) => (
<div
key={h}
className="py-2 text-[10px] font-bold uppercase tracking-widest text-muted-foreground text-center border-l border-border"
>
{h}
</div>
))}
</div>
{rows.map((row, i) => (
<div
key={i}
className={`grid grid-cols-[1fr_64px_64px_64px] ${i < rows.length - 1 ? "border-b border-border" : ""}`}
>
<div className="px-3 py-3 text-xs font-semibold">
{row.label}
</div>
{[
{ val: row.read, set: row.setRead },
{ val: row.write, set: row.setWrite },
{ val: row.execute, set: row.setExecute },
].map((perm, j) => (
<div
key={j}
className="flex items-center justify-center border-l border-border py-3"
>
<input
type="checkbox"
checked={perm.val}
onChange={(e) => perm.set(e.target.checked)}
className="accent-[var(--accent-brand)] size-4 cursor-pointer"
/>
</div>
))}
</div>
))}
</div>
<div className="flex items-center gap-3">
<span className="text-xs font-bold uppercase tracking-widest text-muted-foreground shrink-0">
{t("fileManager.octal")}
</span>
<Input
value={octal}
readOnly
className="w-20 rounded-none bg-muted/50 border-border text-xs font-mono text-center h-8"
/>
<span className="text-[10px] text-muted-foreground font-mono">
{t("fileManager.currentPermissions")}: {file.permissions || "—"}
</span>
</div>
</div>
<DialogFooter>
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={loading}
className="rounded-none text-[10px] font-bold uppercase tracking-widest"
>
{t("common.cancel")}
</Button>
<Button
variant="outline"
onClick={handleSave}
disabled={loading}
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 rounded-none text-[10px] font-bold uppercase tracking-widest"
>
{loading ? t("common.saving") : t("common.save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -1,140 +0,0 @@
import React from "react";
import { DraggableWindow } from "./DraggableWindow.tsx";
import {
Terminal,
type TerminalHandle,
type TerminalHostConfig,
} from "@/features/terminal/Terminal.tsx";
import { useWindowManager } from "./WindowManager.tsx";
import { useTranslation } from "react-i18next";
import { CommandHistoryProvider } from "@/features/terminal/command-history/CommandHistoryContext.tsx";
import type { SSHHost } from "@/types/index.ts";
import { ExternalLink } from "lucide-react";
interface TerminalWindowProps {
windowId: string;
hostConfig: SSHHost;
initialPath?: string;
initialX?: number;
initialY?: number;
executeCommand?: string;
onPromoteToTab?: (path?: string) => void;
}
export function TerminalWindow({
windowId,
hostConfig,
initialPath,
initialX = 200,
initialY = 150,
executeCommand,
onPromoteToTab,
}: TerminalWindowProps) {
const { t } = useTranslation();
const { closeWindow, maximizeWindow, focusWindow, windows } =
useWindowManager();
const terminalRef = React.useRef<TerminalHandle | null>(null);
const resizeTimeoutRef = React.useRef<NodeJS.Timeout | null>(null);
React.useEffect(() => {
return () => {
if (resizeTimeoutRef.current) {
clearTimeout(resizeTimeoutRef.current);
}
};
}, []);
const currentWindow = windows.find((w) => w.id === windowId);
if (!currentWindow) {
return null;
}
const handleClose = () => {
closeWindow(windowId);
};
const handleMaximize = () => {
maximizeWindow(windowId);
if (resizeTimeoutRef.current) {
clearTimeout(resizeTimeoutRef.current);
}
resizeTimeoutRef.current = setTimeout(() => {
if (terminalRef.current?.fit) {
terminalRef.current.fit();
}
}, 150);
};
const handleFocus = () => {
focusWindow(windowId);
};
const handleResize = () => {
if (resizeTimeoutRef.current) {
clearTimeout(resizeTimeoutRef.current);
}
resizeTimeoutRef.current = setTimeout(() => {
if (terminalRef.current?.fit) {
terminalRef.current.fit();
}
}, 100);
};
const handlePromoteToTab = () => {
onPromoteToTab?.(initialPath);
closeWindow(windowId);
};
const terminalTitle = executeCommand
? t("terminal.runTitle", { host: hostConfig.name, command: executeCommand })
: initialPath
? t("terminal.terminalWithPath", {
host: hostConfig.name,
path: initialPath,
})
: t("terminal.terminalTitle", { host: hostConfig.name });
return (
<CommandHistoryProvider>
<DraggableWindow
title={terminalTitle}
initialX={initialX}
initialY={initialY}
initialWidth={800}
initialHeight={500}
minWidth={600}
minHeight={400}
onClose={handleClose}
onMaximize={handleMaximize}
onFocus={handleFocus}
onResize={handleResize}
isMaximized={currentWindow.isMaximized}
zIndex={currentWindow.zIndex}
titleActions={
onPromoteToTab ? (
<button
className="size-6 flex items-center justify-center rounded-none hover:bg-accent-brand/10 hover:text-accent-brand text-muted-foreground transition-colors"
onClick={(e) => {
e.stopPropagation();
handlePromoteToTab();
}}
title={t("common.openInNewTab")}
>
<ExternalLink className="size-3.5" />
</button>
) : null
}
>
<Terminal
ref={terminalRef}
hostConfig={hostConfig as TerminalHostConfig}
isVisible={!currentWindow.isMinimized}
initialPath={initialPath}
executeCommand={executeCommand}
onClose={handleClose}
/>
</DraggableWindow>
</CommandHistoryProvider>
);
}
@@ -1,42 +0,0 @@
import type { CSSProperties } from "react";
import { cn } from "@/lib/utils";
export function TransferProgressBar({
value,
label,
stalled = false,
}: {
value?: number;
label: string;
stalled?: boolean;
}) {
const clamped = Math.min(100, Math.max(0, value ?? 0));
const indeterminate = value === undefined;
return (
<div
role="progressbar"
aria-label={label}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={indeterminate ? undefined : clamped}
aria-busy={indeterminate}
data-stalled={stalled || undefined}
className="transfer-progress-track"
>
<div
className={cn(
"transfer-progress-fill",
indeterminate && "transfer-progress-indeterminate",
stalled && "transfer-progress-stalled",
)}
style={
indeterminate
? undefined
: ({ "--transfer-progress": clamped / 100 } as CSSProperties)
}
/>
</div>
);
}
@@ -1,149 +0,0 @@
import { Button } from "@/components/button.tsx";
import {
formatTransferMbPerSec,
getTransferProgressPercent,
type TransferProgressResponse,
} from "@/main-axios.ts";
import { useTranslation } from "react-i18next";
import {
Archive,
ArchiveRestore,
ArrowRight,
Gauge,
RefreshCw,
ShieldCheck,
} from "lucide-react";
import { TransferProgressBar } from "./TransferProgressBar";
interface TransferProgressToastProps {
status: TransferProgressResponse;
liveMbPerSec?: number;
stalled?: boolean;
formatSize: (bytes?: number) => string;
onCancel?: () => void;
cancelling?: boolean;
}
export function TransferProgressToast({
status,
liveMbPerSec,
stalled = false,
formatSize,
onCancel,
cancelling = false,
}: TransferProgressToastProps) {
const { t } = useTranslation();
const percent = getTransferProgressPercent(status);
let title = t("transfer.progressTransferring");
if (status.phase === "reconnecting") {
title = t("transfer.progressReconnecting");
} else if (status.phase === "benchmarking") {
title = t("transfer.progressBenchmarking");
} else if (status.phase === "verifying") {
title = t("transfer.progressVerifying");
} else if (status.phase === "compressing") {
title = t("transfer.progressCompressing");
} else if (status.phase === "extracting") {
title = t("transfer.progressExtracting");
} else if (
status.method === "item_sftp" &&
status.totalItems !== undefined &&
status.itemsCompleted !== undefined
) {
title = t("transfer.progressTransferringItems", {
current: status.itemsCompleted,
total: status.totalItems,
});
}
const hasByteProgress =
status.bytesTransferred !== undefined &&
status.totalBytes !== undefined &&
status.totalBytes > 0;
const detailLeft = hasByteProgress
? t("transfer.progressBytes", {
transferred: formatSize(status.bytesTransferred),
total: formatSize(status.totalBytes),
})
: status.method === "item_sftp" &&
status.totalItems !== undefined &&
status.itemsCompleted !== undefined
? t("transfer.progressItems", {
current: status.itemsCompleted,
total: status.totalItems,
})
: null;
const liveRate =
status.phase === "reconnecting"
? undefined
: stalled
? t("transfer.progressStalled")
: liveMbPerSec !== undefined
? status.parallelSegmentCount && status.parallelSegmentCount > 1
? t("transfer.progressTotalSpeed", {
speed: formatTransferMbPerSec(liveMbPerSec),
lanes: status.parallelSegmentCount,
})
: formatTransferMbPerSec(liveMbPerSec)
: undefined;
const showIndeterminate =
status.phase === "reconnecting" || percent === undefined;
const PhaseIcon =
status.phase === "compressing"
? Archive
: status.phase === "benchmarking"
? Gauge
: status.phase === "verifying"
? ShieldCheck
: status.phase === "extracting"
? ArchiveRestore
: status.phase === "reconnecting"
? RefreshCw
: ArrowRight;
return (
<div className="flex w-[min(calc(100vw-5rem),288px)] max-w-full flex-col gap-2 pr-2">
<div className="flex items-start justify-between gap-2">
<p
key={status.phase}
className="motion-transfer-phase flex min-w-0 items-center gap-2 text-sm font-medium leading-tight"
>
<PhaseIcon className="size-3.5 shrink-0 text-accent-brand" />
<span className="truncate">{title}</span>
</p>
{onCancel && status.status === "running" && status.transferId && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 shrink-0 px-2 text-xs"
disabled={cancelling}
onClick={onCancel}
>
{cancelling
? t("transfer.progressCancelling")
: t("transfer.progressCancel")}
</Button>
)}
</div>
<TransferProgressBar
value={showIndeterminate ? undefined : percent}
label={title}
stalled={stalled}
/>
<div className="flex items-center justify-between gap-3 pr-1 text-xs text-muted-foreground">
<span className="min-w-0 truncate">{detailLeft ?? ""}</span>
<span
className={`shrink-0 tabular-nums ${stalled ? "text-amber-500" : liveRate ? "font-medium text-foreground" : "invisible"}`}
aria-hidden={!liveRate}
>
{liveRate ?? "0 MB/s"}
</span>
</div>
</div>
);
}
File diff suppressed because it is too large. Load diff
@@ -1,144 +0,0 @@
/* eslint-disable react-refresh/only-export-components */
import React, { useState, useCallback, useRef } from "react";
export interface WindowInstance {
id: string;
title: string;
component: React.ReactNode | ((windowId: string) => React.ReactNode);
x: number;
y: number;
width: number;
height: number;
isMaximized: boolean;
isMinimized: boolean;
zIndex: number;
}
interface WindowManagerProps {
children?: React.ReactNode;
}
interface WindowManagerContextType {
windows: WindowInstance[];
openWindow: (window: Omit<WindowInstance, "id" | "zIndex">) => string;
closeWindow: (id: string) => void;
minimizeWindow: (id: string) => void;
maximizeWindow: (id: string) => void;
focusWindow: (id: string) => void;
updateWindow: (id: string, updates: Partial<WindowInstance>) => void;
}
const WindowManagerContext =
React.createContext<WindowManagerContextType | null>(null);
export function WindowManager({ children }: WindowManagerProps) {
const [windows, setWindows] = useState<WindowInstance[]>([]);
const nextZIndex = useRef(1000);
const windowCounter = useRef(0);
const openWindow = useCallback(
(windowData: Omit<WindowInstance, "id" | "zIndex">) => {
const id = `window-${++windowCounter.current}`;
const zIndex = ++nextZIndex.current;
const offset = (windows.length % 5) * 20;
let adjustedX = windowData.x + offset;
let adjustedY = windowData.y + offset;
const maxX = Math.max(0, window.innerWidth - windowData.width - 20);
const maxY = Math.max(0, window.innerHeight - windowData.height - 20);
adjustedX = Math.max(20, Math.min(adjustedX, maxX));
adjustedY = Math.max(20, Math.min(adjustedY, maxY));
const newWindow: WindowInstance = {
...windowData,
id,
zIndex,
x: adjustedX,
y: adjustedY,
};
setWindows((prev) => [...prev, newWindow]);
return id;
},
[windows.length],
);
const closeWindow = useCallback((id: string) => {
setWindows((prev) => prev.filter((w) => w.id !== id));
}, []);
const minimizeWindow = useCallback((id: string) => {
setWindows((prev) =>
prev.map((w) =>
w.id === id ? { ...w, isMinimized: !w.isMinimized } : w,
),
);
}, []);
const maximizeWindow = useCallback((id: string) => {
setWindows((prev) =>
prev.map((w) =>
w.id === id ? { ...w, isMaximized: !w.isMaximized } : w,
),
);
}, []);
const focusWindow = useCallback((id: string) => {
setWindows((prev) => {
const targetWindow = prev.find((w) => w.id === id);
if (!targetWindow) return prev;
const newZIndex = ++nextZIndex.current;
return prev.map((w) => (w.id === id ? { ...w, zIndex: newZIndex } : w));
});
}, []);
const updateWindow = useCallback(
(id: string, updates: Partial<WindowInstance>) => {
setWindows((prev) =>
prev.map((w) => (w.id === id ? { ...w, ...updates } : w)),
);
},
[],
);
const contextValue: WindowManagerContextType = {
windows,
openWindow,
closeWindow,
minimizeWindow,
maximizeWindow,
focusWindow,
updateWindow,
};
return (
<WindowManagerContext.Provider value={contextValue}>
{children}
<div
className="window-container absolute inset-0 pointer-events-none overflow-hidden"
style={{ zIndex: 1000 }}
>
<div className="relative w-full h-full pointer-events-none">
{windows.map((window) => (
<div key={window.id} className="pointer-events-auto">
{typeof window.component === "function"
? window.component(window.id)
: window.component}
</div>
))}
</div>
</div>
</WindowManagerContext.Provider>
);
}
export function useWindowManager() {
const context = React.useContext(WindowManagerContext);
if (!context) {
throw new Error("useWindowManager must be used within a WindowManager");
}
return context;
}
@@ -1,36 +0,0 @@
import type { FileItem, SSHHost } from "@/types/index";
import type { LogEntry } from "@/types/connection-log.ts";
export interface FileManagerProps {
initialHost?: SSHHost | null;
initialFilePath?: string;
initialPath?: string;
onClose?: () => void;
onOpenTerminalTab?: (path?: string) => void;
/** When false, pause keepalive while the tab stays mounted in the background. */
isVisible?: boolean;
}
export type ConnectionLogPayload = Omit<LogEntry, "id" | "timestamp">;
export type SSHConnectionError = Error & {
connectionLogs?: ConnectionLogPayload[];
requires_totp?: boolean;
requires_warpgate?: boolean;
sessionId?: string;
prompt?: string;
url?: string;
securityKey?: string;
status?: string;
reason?: "no_keyboard" | "auth_failed" | "timeout";
};
export interface CreateIntent {
id: string;
type: "file" | "directory";
defaultName: string;
currentName: string;
}
export type PendingSudoOperation =
{ type: "delete"; files: FileItem[] } | { type: "navigate"; path: string };
@@ -1,35 +0,0 @@
export function formatFileSize(bytes?: number): string {
if (bytes === undefined || bytes === null) return "-";
if (bytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
const formattedSize =
size < 10 && unitIndex > 0 ? size.toFixed(1) : Math.round(size).toString();
return `${formattedSize} ${units[unitIndex]}`;
}
export async function runWithConcurrency<T>(
items: T[],
limit: number,
task: (item: T) => Promise<void>,
): Promise<void> {
let nextIndex = 0;
const workerCount = Math.min(Math.max(1, limit), items.length);
await Promise.all(
Array.from({ length: workerCount }, async () => {
while (nextIndex < items.length) {
const item = items[nextIndex++];
await task(item);
}
}),
);
}
@@ -1,118 +0,0 @@
import {
markAdaptiveResourceUsed,
runAdaptiveBackgroundTask,
} from "@/lib/adaptive-resource-budget";
export type FilePreviewKind =
| "image"
| "video"
| "audio"
| "markdown"
| "pdf"
| "text"
| "code"
| "unknown";
const extensions = {
image: new Set(["png", "jpg", "jpeg", "gif", "bmp", "svg", "webp"]),
video: new Set(["mp4", "avi", "mkv", "mov", "wmv", "flv", "webm"]),
audio: new Set(["mp3", "wav", "flac", "ogg", "aac", "m4a"]),
markdown: new Set(["md", "markdown", "mdown", "mkdn", "mdx"]),
pdf: new Set(["pdf"]),
text: new Set(["txt", "readme"]),
code: new Set([
"js",
"ts",
"jsx",
"tsx",
"py",
"java",
"cpp",
"c",
"cs",
"php",
"rb",
"go",
"rs",
"html",
"css",
"scss",
"less",
"json",
"xml",
"yaml",
"yml",
"toml",
"ini",
"conf",
"sh",
"bash",
"zsh",
"sql",
"vue",
"svelte",
]),
} satisfies Record<Exclude<FilePreviewKind, "unknown">, Set<string>>;
const extensionlessCodeFiles = new Set([
"dockerfile",
"makefile",
"rakefile",
"gemfile",
]);
export function resolveFilePreviewKind(filename: string): FilePreviewKind {
const lowerName = filename.toLowerCase();
if (extensionlessCodeFiles.has(lowerName)) return "code";
const extension = lowerName.split(".").pop() || "";
for (const [kind, knownExtensions] of Object.entries(extensions)) {
if (knownExtensions.has(extension)) return kind as FilePreviewKind;
}
return "unknown";
}
export const loadCodeEditor = () =>
import("./components/CodeEditor.tsx").then((module) => ({
default: module.CodeEditor,
}));
export const loadImagePreview = () =>
import("./components/ImagePreview.tsx").then((module) => ({
default: module.ImagePreview,
}));
export const loadMarkdownRenderer = () =>
import("./components/MarkdownRenderer.tsx").then((module) => ({
default: module.MarkdownRenderer,
}));
export const loadPdfPreview = () =>
import("./components/PdfPreview.tsx").then((module) => ({
default: module.PdfPreview,
}));
export const loadAudioPreview = () =>
import("./components/AudioPreview.tsx").then((module) => ({
default: module.AudioPreview,
}));
const previewLoaders: Partial<Record<FilePreviewKind, () => Promise<unknown>>> =
{
image: loadImagePreview,
audio: loadAudioPreview,
markdown: loadMarkdownRenderer,
pdf: loadPdfPreview,
text: loadCodeEditor,
code: loadCodeEditor,
unknown: loadCodeEditor,
};
export function preloadFilePreview(filename: string): void {
const kind = resolveFilePreviewKind(filename);
const loader = previewLoaders[kind];
if (loader) runAdaptiveBackgroundTask("module", `preview:${kind}`, loader);
}
export function markFilePreviewUsed(filename: string): void {
markAdaptiveResourceUsed(
"module",
`preview:${resolveFilePreviewKind(filename)}`,
);
}
@@ -1,178 +0,0 @@
import { useState, useCallback } from "react";
interface DragAndDropState {
isDragging: boolean;
dragCounter: number;
draggedFiles: File[];
}
interface UseDragAndDropProps {
onFilesDropped: (files: FileList) => void;
onItemsDropped?: (entries: FileSystemEntry[]) => void;
onError?: (error: string) => void;
maxFileSize?: number;
allowedTypes?: string[];
}
export function useDragAndDrop({
onFilesDropped,
onItemsDropped,
onError,
maxFileSize = 5120,
allowedTypes = [],
}: UseDragAndDropProps) {
const [state, setState] = useState<DragAndDropState>({
isDragging: false,
dragCounter: 0,
draggedFiles: [],
});
const validateFiles = useCallback(
(files: FileList): string | null => {
const maxSizeBytes = maxFileSize * 1024 * 1024;
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (file.size > maxSizeBytes) {
return `File "${file.name}" is too large. Maximum size is ${maxFileSize}MB.`;
}
if (allowedTypes.length > 0) {
const fileExt = file.name.split(".").pop()?.toLowerCase();
const mimeType = file.type.toLowerCase();
const isAllowed = allowedTypes.some((type) => {
if (type.startsWith(".")) {
return fileExt === type.slice(1);
}
if (type.includes("/")) {
return (
mimeType === type || mimeType.startsWith(type.replace("*", ""))
);
}
switch (type) {
case "image":
return mimeType.startsWith("image/");
case "video":
return mimeType.startsWith("video/");
case "audio":
return mimeType.startsWith("audio/");
case "text":
return mimeType.startsWith("text/");
default:
return false;
}
});
if (!isAllowed) {
return `File type "${file.type || "unknown"}" is not allowed.`;
}
}
}
return null;
},
[maxFileSize, allowedTypes],
);
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setState((prev) => ({
...prev,
dragCounter: prev.dragCounter + 1,
}));
if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
setState((prev) => ({
...prev,
isDragging: true,
}));
}
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setState((prev) => {
const newCounter = prev.dragCounter - 1;
return {
...prev,
dragCounter: newCounter,
isDragging: newCounter > 0,
};
});
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = "copy";
}, []);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
// Read the entries before touching state. Updating state flushes a render
// and the browser clears dataTransfer once the drop handler unwinds, so
// anything read later comes back empty (Firefox is strictest here).
const entries: FileSystemEntry[] = [];
if (onItemsDropped && e.dataTransfer.items?.length > 0) {
for (const item of Array.from(e.dataTransfer.items)) {
const entry = item.webkitGetAsEntry?.();
if (entry) entries.push(entry);
}
}
const files = e.dataTransfer.files;
setState({
isDragging: false,
dragCounter: 0,
draggedFiles: [],
});
if (onItemsDropped && entries.some((entry) => entry.isDirectory)) {
onItemsDropped(entries);
return;
}
if (files.length === 0) {
return;
}
const validationError = validateFiles(files);
if (validationError) {
onError?.(validationError);
return;
}
onFilesDropped(files);
},
[validateFiles, onFilesDropped, onItemsDropped, onError],
);
const resetDragState = useCallback(() => {
setState({
isDragging: false,
dragCounter: 0,
draggedFiles: [],
});
}, []);
return {
isDragging: state.isDragging,
dragHandlers: {
onDragEnter: handleDragEnter,
onDragLeave: handleDragLeave,
onDragOver: handleDragOver,
onDrop: handleDrop,
},
resetDragState,
};
}
@@ -1,286 +0,0 @@
import { useState, useCallback } from "react";
import { toast } from "sonner";
import { downloadSSHFile } from "@/main-axios";
import type { FileItem, SSHHost } from "@/types/index.js";
interface DragToDesktopState {
isDragging: boolean;
isDownloading: boolean;
progress: number;
error: string | null;
}
interface UseDragToDesktopProps {
sshSessionId: string;
sshHost: SSHHost;
}
interface DragToDesktopOptions {
enableToast?: boolean;
onSuccess?: () => void;
onError?: (error: string) => void;
}
export function useDragToDesktop({ sshSessionId }: UseDragToDesktopProps) {
const [state, setState] = useState<DragToDesktopState>({
isDragging: false,
isDownloading: false,
progress: 0,
error: null,
});
const isElectron = () => {
return (
typeof window !== "undefined" &&
window.electronAPI &&
window.electronAPI.isElectron
);
};
const dragFileToDesktop = useCallback(
async (file: FileItem, options: DragToDesktopOptions = {}) => {
const { enableToast = true, onSuccess, onError } = options;
if (!isElectron()) {
const error =
"Drag to desktop feature is only available in desktop application";
if (enableToast) toast.error(error);
onError?.(error);
return false;
}
if (file.type !== "file") {
const error = "Only files can be dragged to desktop";
if (enableToast) toast.error(error);
onError?.(error);
return false;
}
try {
setState((prev) => ({
...prev,
isDownloading: true,
progress: 0,
error: null,
}));
const response = await downloadSSHFile(sshSessionId, file.path);
if (!response?.content) {
throw new Error("Unable to get file content");
}
setState((prev) => ({ ...prev, progress: 50 }));
const tempResult = await window.electronAPI.createTempFile({
fileName: file.name,
content: response.content,
encoding: "base64",
});
if (!tempResult.success) {
throw new Error(
tempResult.error || "Failed to create temporary file",
);
}
setState((prev) => ({ ...prev, progress: 80, isDragging: true }));
const dragResult = await window.electronAPI.startDragToDesktop({
tempId: tempResult.tempId,
fileName: file.name,
});
if (!dragResult.success) {
throw new Error(dragResult.error || "Failed to start dragging");
}
setState((prev) => ({ ...prev, progress: 100 }));
if (enableToast) {
toast.success(`Dragging ${file.name} to desktop`);
}
onSuccess?.();
setTimeout(async () => {
await window.electronAPI.cleanupTempFile(tempResult.tempId);
setState((prev) => ({
...prev,
isDragging: false,
isDownloading: false,
progress: 0,
}));
}, 10000);
return true;
} catch (error: unknown) {
console.error("Failed to drag to desktop:", error);
const err = error as { message?: string };
const errorMessage = err.message || "Drag failed";
setState((prev) => ({
...prev,
isDownloading: false,
isDragging: false,
progress: 0,
error: errorMessage,
}));
if (enableToast) {
toast.error(`Drag failed: ${errorMessage}`);
}
onError?.(errorMessage);
return false;
}
},
[sshSessionId],
);
const dragFilesToDesktop = useCallback(
async (files: FileItem[], options: DragToDesktopOptions = {}) => {
const { enableToast = true, onSuccess, onError } = options;
if (!isElectron()) {
const error =
"Drag to desktop feature is only available in desktop application";
if (enableToast) toast.error(error);
onError?.(error);
return false;
}
const fileList = files.filter((f) => f.type === "file");
if (fileList.length === 0) {
const error = "No files available for dragging";
if (enableToast) toast.error(error);
onError?.(error);
return false;
}
if (fileList.length === 1) {
return dragFileToDesktop(fileList[0], options);
}
try {
setState((prev) => ({
...prev,
isDownloading: true,
progress: 0,
error: null,
}));
const downloadPromises = fileList.map((file) =>
downloadSSHFile(sshSessionId, file.path),
);
const responses = await Promise.all(downloadPromises);
setState((prev) => ({ ...prev, progress: 40 }));
const folderName = `Files_${Date.now()}`;
const filesData = fileList.map((file, index) => ({
relativePath: file.name,
content: responses[index]?.content || "",
encoding: "base64" as const,
}));
const tempResult = await window.electronAPI.createTempFolder({
folderName,
files: filesData,
});
if (!tempResult.success) {
throw new Error(
tempResult.error || "Failed to create temporary folder",
);
}
setState((prev) => ({ ...prev, progress: 80, isDragging: true }));
const dragResult = await window.electronAPI.startDragToDesktop({
tempId: tempResult.tempId,
fileName: folderName,
});
if (!dragResult.success) {
throw new Error(dragResult.error || "Failed to start dragging");
}
setState((prev) => ({ ...prev, progress: 100 }));
if (enableToast) {
toast.success(`Dragging ${fileList.length} files to desktop`);
}
onSuccess?.();
setTimeout(async () => {
await window.electronAPI.cleanupTempFile(tempResult.tempId);
setState((prev) => ({
...prev,
isDragging: false,
isDownloading: false,
progress: 0,
}));
}, 15000);
return true;
} catch (error: unknown) {
console.error("Failed to batch drag to desktop:", error);
const err = error as { message?: string };
const errorMessage = err.message || "Batch drag failed";
setState((prev) => ({
...prev,
isDownloading: false,
isDragging: false,
progress: 0,
error: errorMessage,
}));
if (enableToast) {
toast.error(`Batch drag failed: ${errorMessage}`);
}
onError?.(errorMessage);
return false;
}
},
[sshSessionId, dragFileToDesktop],
);
const dragFolderToDesktop = useCallback(
async (folder: FileItem, options: DragToDesktopOptions = {}) => {
const { enableToast = true, onError } = options;
if (!isElectron()) {
const error =
"Drag to desktop feature is only available in desktop application";
if (enableToast) toast.error(error);
onError?.(error);
return false;
}
if (folder.type !== "directory") {
const error = "Only folder types can be dragged";
if (enableToast) toast.error(error);
onError?.(error);
return false;
}
if (enableToast) {
toast.info("Folder drag functionality is under development...");
}
return false;
},
[],
);
return {
...state,
isElectron: isElectron(),
dragFileToDesktop,
dragFilesToDesktop,
dragFolderToDesktop,
};
}
@@ -1,300 +0,0 @@
/* eslint-disable react-hooks/exhaustive-deps */
import { useState, useCallback, useRef } from "react";
import { toast } from "sonner";
import { downloadSSHFile } from "@/main-axios";
import type { FileItem, SSHHost } from "@/types/index.js";
interface DragToSystemState {
isDragging: boolean;
isDownloading: boolean;
progress: number;
error: string | null;
}
interface UseDragToSystemProps {
sshSessionId: string;
sshHost: SSHHost;
}
interface DragToSystemOptions {
enableToast?: boolean;
onSuccess?: () => void;
onError?: (error: string) => void;
}
export function useDragToSystemDesktop({ sshSessionId }: UseDragToSystemProps) {
const [state, setState] = useState<DragToSystemState>({
isDragging: false,
isDownloading: false,
progress: 0,
error: null,
});
const dragDataRef = useRef<{
files: FileItem[];
options: DragToSystemOptions;
} | null>(null);
const saveLastDirectory = async (fileHandle: {
getParent?: () => Promise<unknown>;
}) => {
try {
if ("indexedDB" in window && fileHandle.getParent) {
const dirHandle = await fileHandle.getParent();
const request = indexedDB.open("termix-dirs", 1);
request.onsuccess = () => {
const db = request.result;
const transaction = db.transaction(["directories"], "readwrite");
const store = transaction.objectStore("directories");
store.put({ handle: dirHandle }, "lastSaveDir");
};
}
} catch (error) {
console.error("Drag operation failed:", error);
}
};
const isFileSystemAPISupported = () => {
return "showSaveFilePicker" in window;
};
const isDraggedOutsideWindow = (e: DragEvent) => {
const margin = 50;
return (
e.clientX < margin ||
e.clientX > window.innerWidth - margin ||
e.clientY < margin ||
e.clientY > window.innerHeight - margin
);
};
const createFileBlob = async (file: FileItem): Promise<Blob> => {
const response = await downloadSSHFile(sshSessionId, file.path);
if (!response?.content) {
throw new Error(`Unable to get content for file ${file.name}`);
}
const binaryString = atob(response.content);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return new Blob([bytes]);
};
const createZipBlob = async (files: FileItem[]): Promise<Blob> => {
const JSZip = (await import("jszip")).default;
const zip = new JSZip();
for (const file of files) {
const blob = await createFileBlob(file);
zip.file(file.name, blob);
}
return await zip.generateAsync({ type: "blob" });
};
const fallbackDownload = (blob: Blob, fileName: string) => {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
const handleDragToSystem = useCallback(
async (files: FileItem[], options: DragToSystemOptions = {}) => {
const { enableToast = true, onSuccess, onError } = options;
if (files.length === 0) {
const error = "No files available for dragging";
if (enableToast) toast.error(error);
onError?.(error);
return false;
}
const fileList = files.filter((f) => f.type === "file");
if (fileList.length === 0) {
const error = "Only files can be dragged to desktop";
if (enableToast) toast.error(error);
onError?.(error);
return false;
}
try {
setState((prev) => ({
...prev,
isDownloading: true,
progress: 0,
error: null,
}));
const fileName =
fileList.length === 1 ? fileList[0].name : `files_${Date.now()}.zip`;
let fileHandle: {
createWritable?: () => Promise<{
write: (data: Blob) => Promise<void>;
close: () => Promise<void>;
}>;
getParent?: () => Promise<unknown>;
} | null = null;
if (isFileSystemAPISupported()) {
try {
fileHandle = await (
window as Window & {
showSaveFilePicker?: (options: {
suggestedName: string;
startIn: string;
types: Array<{
description: string;
accept: Record<string, string[]>;
}>;
}) => Promise<{
createWritable?: () => Promise<{
write: (data: Blob) => Promise<void>;
close: () => Promise<void>;
}>;
getParent?: () => Promise<unknown>;
}>;
}
).showSaveFilePicker!({
suggestedName: fileName,
startIn: "desktop",
types: [
{
description: "Files",
accept: {
"*/*": [
".txt",
".jpg",
".png",
".pdf",
".zip",
".tar",
".gz",
],
},
},
],
});
} catch (error: unknown) {
const err = error as { name?: string };
if (err.name === "AbortError") {
setState((prev) => ({
...prev,
isDownloading: false,
progress: 0,
}));
return false;
}
throw error;
}
}
let blob: Blob;
if (fileList.length === 1) {
blob = await createFileBlob(fileList[0]);
setState((prev) => ({ ...prev, progress: 70 }));
} else {
blob = await createZipBlob(fileList);
setState((prev) => ({ ...prev, progress: 70 }));
}
setState((prev) => ({ ...prev, progress: 90 }));
if (fileHandle) {
await saveLastDirectory(fileHandle);
const writable = await fileHandle.createWritable();
await writable.write(blob);
await writable.close();
} else {
fallbackDownload(blob, fileName);
if (enableToast) {
toast.info(
"Due to browser limitations, file will be downloaded to default download directory",
);
}
}
setState((prev) => ({ ...prev, progress: 100 }));
if (enableToast) {
toast.success(
fileList.length === 1
? `${fileName} saved to specified location`
: `${fileList.length} files packaged and saved`,
);
}
onSuccess?.();
setTimeout(() => {
setState((prev) => ({ ...prev, isDownloading: false, progress: 0 }));
}, 1000);
return true;
} catch (error: unknown) {
const err = error as { message?: string };
const errorMessage = err.message || "Save failed";
setState((prev) => ({
...prev,
isDownloading: false,
progress: 0,
error: errorMessage,
}));
if (enableToast) {
toast.error(`Save failed: ${errorMessage}`);
}
onError?.(errorMessage);
return false;
}
},
[sshSessionId],
);
const startDragToSystem = useCallback(
(files: FileItem[], options: DragToSystemOptions = {}) => {
dragDataRef.current = { files, options };
setState((prev) => ({ ...prev, isDragging: true, error: null }));
},
[],
);
const handleDragEnd = useCallback(
(e: DragEvent) => {
if (!dragDataRef.current) return;
const { files, options } = dragDataRef.current;
if (isDraggedOutsideWindow(e)) {
handleDragToSystem(files, options);
}
dragDataRef.current = null;
setState((prev) => ({ ...prev, isDragging: false }));
},
[handleDragToSystem],
);
const cancelDragToSystem = useCallback(() => {
dragDataRef.current = null;
setState((prev) => ({ ...prev, isDragging: false, error: null }));
}, []);
return {
...state,
isFileSystemAPISupported: isFileSystemAPISupported(),
startDragToSystem,
handleDragEnd,
cancelDragToSystem,
handleDragToSystem,
};
}
@@ -1,92 +0,0 @@
import { useState, useCallback } from "react";
interface FileItem {
name: string;
type: "file" | "directory" | "link";
path: string;
size?: number;
modified?: string;
permissions?: string;
owner?: string;
group?: string;
}
export function useFileSelection() {
const [selectedFiles, setSelectedFiles] = useState<FileItem[]>([]);
const selectFile = useCallback((file: FileItem, multiSelect = false) => {
if (multiSelect) {
setSelectedFiles((prev) => {
const isSelected = prev.some((f) => f.path === file.path);
if (isSelected) {
return prev.filter((f) => f.path !== file.path);
} else {
return [...prev, file];
}
});
} else {
setSelectedFiles([file]);
}
}, []);
const selectRange = useCallback(
(files: FileItem[], startFile: FileItem, endFile: FileItem) => {
const startIndex = files.findIndex((f) => f.path === startFile.path);
const endIndex = files.findIndex((f) => f.path === endFile.path);
if (startIndex !== -1 && endIndex !== -1) {
const start = Math.min(startIndex, endIndex);
const end = Math.max(startIndex, endIndex);
const rangeFiles = files.slice(start, end + 1);
setSelectedFiles(rangeFiles);
}
},
[],
);
const selectAll = useCallback((files: FileItem[]) => {
setSelectedFiles([...files]);
}, []);
const clearSelection = useCallback(() => {
setSelectedFiles([]);
}, []);
const toggleSelection = useCallback((file: FileItem) => {
setSelectedFiles((prev) => {
const isSelected = prev.some((f) => f.path === file.path);
if (isSelected) {
return prev.filter((f) => f.path !== file.path);
} else {
return [...prev, file];
}
});
}, []);
const isSelected = useCallback(
(file: FileItem) => {
return selectedFiles.some((f) => f.path === file.path);
},
[selectedFiles],
);
const getSelectedCount = useCallback(() => {
return selectedFiles.length;
}, [selectedFiles]);
const setSelection = useCallback((files: FileItem[]) => {
setSelectedFiles(files);
}, []);
return {
selectedFiles,
selectFile,
selectRange,
selectAll,
clearSelection,
toggleSelection,
isSelected,
getSelectedCount,
setSelection,
};
}
@@ -1,566 +0,0 @@
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 {
cancelLocalTransfer,
createLocalTransferId,
createSSHFolder,
downloadSessionFileToLocal,
listSSHFiles,
uploadLocalFileToSession,
} from "@/main-axios.ts";
import {
ensureLocalDirectory,
getLocalHome,
localPathsExist,
walkLocalPaths,
} from "@/lib/local-files.ts";
import {
UnsafeLocalNameError,
buildLocalDestination,
getTransferConcurrency,
runWithConcurrency,
joinRemotePath,
planRemoteDirectories,
remoteBaseName,
remoteDirForRelativePath,
} from "../local-transfer-utils.ts";
import {
LocalTransferProgressToast,
type LocalTransferBatchStatus,
} from "../components/LocalTransferProgressToast.tsx";
interface UseLocalTransfersOptions {
sshSessionId: string | null;
hostId?: number;
ensureSSHConnection: () => Promise<unknown>;
/** Called after uploads so the remote listing can refresh. */
onRemoteChanged: (remoteDir: string) => void;
/** Called after downloads so the local pane can refresh. */
onLocalChanged: (localDir: string) => void;
}
interface RemoteDownloadPlanEntry {
remotePath: string;
/** "/"-separated, includes the dragged root's own name. */
relativePath: string;
size?: number;
}
/**
* Asks whether existing local files may be replaced. Resolves `true` only on
* an explicit "Replace"; dismissing, auto-close and "Skip" all mean skip, so
* the batch can never overwrite silently.
*/
function askReplaceExisting(
t: ReturnType<typeof useTranslation>["t"],
count: number,
): Promise<boolean> {
return new Promise((resolve) => {
let settled = false;
const settle = (value: boolean) => {
if (settled) return;
settled = true;
resolve(value);
};
toast.warning(t("fileManager.localReplaceExistingPrompt", { count }), {
duration: 15000,
action: {
label: t("fileManager.localReplace"),
onClick: () => settle(true),
},
cancel: {
label: t("fileManager.localSkipExisting"),
onClick: () => settle(false),
},
onDismiss: () => settle(false),
onAutoClose: () => settle(false),
});
});
}
/** One line a user can act on, taken from whatever the bridge or backend said. */
function describeTransferError(error: unknown): string | undefined {
const message =
error instanceof Error ? error.message : String(error ?? "").trim();
return message ? message.slice(0, 200) : undefined;
}
class TransferCancelledError extends Error {
constructor() {
super("Transfer cancelled");
this.name = "TransferCancelledError";
}
}
function createSpeedometer() {
let lastBytes = 0;
let lastTime = Date.now();
let mbPerSec: number | undefined;
return (bytesDone: number) => {
const now = Date.now();
const deltaMs = now - lastTime;
if (deltaMs >= 300) {
const deltaBytes = bytesDone - lastBytes;
if (deltaBytes >= 0) {
mbPerSec = (deltaBytes / deltaMs / 1024 / 1024) * 1000;
}
lastBytes = bytesDone;
lastTime = now;
}
return mbPerSec;
};
}
/**
* Orchestrates batched local<->remote transfers for the dual-pane file
* manager: expands folders, creates directory skeletons, streams files one by
* one through the Electron main process, and reports aggregate progress in a
* single toast with a cancel button.
*/
export function useLocalTransfers({
sshSessionId,
hostId,
ensureSSHConnection,
onRemoteChanged,
onLocalChanged,
}: UseLocalTransfersOptions) {
const { t } = useTranslation();
const batchCounter = useRef(0);
const runBatch = useCallback(
async (
direction: "upload" | "download",
totalFiles: number,
totalBytes: number,
work: (ctx: {
isCancelled: () => boolean;
/** Marks a transfer as in flight so Cancel can reach it. */
trackTransfer: (id: string, fileName: string) => void;
/** Progress of one in-flight transfer; bytes are summed across all. */
progress: (id: string, transferred: number) => void;
/** Transfer finished (success or failure); its size is now settled. */
settleTransfer: (id: string, size: number) => void;
}) => Promise<{ failed: string[]; reason?: string }>,
) => {
batchCounter.current += 1;
const toastId = `local-transfer-${batchCounter.current}`;
let cancelled = false;
let cancelling = false;
const inFlight = new Map<
string,
{ fileName: string; transferred: number }
>();
let settledBytes = 0;
let completedFiles = 0;
let lastFileName: string | undefined;
const speed = createSpeedometer();
const status: LocalTransferBatchStatus = {
direction,
totalFiles,
completedFiles: 0,
bytesDone: 0,
totalBytes,
};
const render = () => {
toast.loading(
createElement(LocalTransferProgressToast, {
status: { ...status, cancelling },
onCancel: () => {
cancelled = true;
cancelling = true;
render();
for (const id of inFlight.keys()) void cancelLocalTransfer(id);
},
}),
{ id: toastId, duration: Infinity },
);
};
render();
try {
const refresh = () => {
let bytesDone = settledBytes;
for (const entry of inFlight.values()) bytesDone += entry.transferred;
status.completedFiles = completedFiles;
status.bytesDone = bytesDone;
status.activeFiles = inFlight.size;
status.currentFileName =
lastFileName ?? inFlight.values().next().value?.fileName;
status.mbPerSec = speed(bytesDone);
render();
};
const { failed, reason } = await work({
isCancelled: () => cancelled,
trackTransfer: (id, fileName) => {
inFlight.set(id, { fileName, transferred: 0 });
lastFileName = fileName;
refresh();
},
progress: (id, transferred) => {
const entry = inFlight.get(id);
if (!entry) return;
entry.transferred = transferred;
lastFileName = entry.fileName;
refresh();
},
settleTransfer: (id, size) => {
inFlight.delete(id);
settledBytes += size;
completedFiles += 1;
refresh();
},
});
toast.dismiss(toastId);
if (cancelled) {
toast.info(t("fileManager.localTransferCancelled"));
return;
}
const key = direction === "upload" ? "Upload" : "Download";
if (failed.length === 0) {
toast.success(
t(`fileManager.local${key}Complete`, { count: totalFiles }),
);
} else if (failed.length === totalFiles) {
// Every file failed the same way more often than not (auth,
// permissions, a dead session); say why instead of just "failed".
toast.error(
t(`fileManager.local${key}Failed`),
reason ? { description: reason } : undefined,
);
} else {
toast.warning(
t(`fileManager.local${key}Partial`, {
done: totalFiles - failed.length,
failed: failed.length,
}),
reason ? { description: reason } : undefined,
);
}
} catch (error) {
toast.dismiss(toastId);
if (error instanceof TransferCancelledError || cancelled) {
toast.info(t("fileManager.localTransferCancelled"));
return;
}
const message =
error instanceof Error ? error.message : String(error ?? "");
toast.error(
direction === "upload"
? t("fileManager.localUploadFailed")
: t("fileManager.localDownloadFailed"),
message ? { description: message } : undefined,
);
console.error(`Local ${direction} batch failed:`, error);
}
},
[t],
);
/** Uploads local files/folders (by absolute path) into a remote directory. */
const uploadLocalPaths = useCallback(
async (localPaths: string[], remoteDir: string) => {
if (!sshSessionId) {
toast.error(t("fileManager.noSSHConnection"));
return;
}
if (localPaths.length === 0) return;
let plan: LocalWalkResult;
try {
plan = await walkLocalPaths(localPaths);
} catch (error) {
toast.error(
t("fileManager.localUploadFailed"),
error instanceof Error ? { description: error.message } : undefined,
);
return;
}
if (plan.files.length === 0 && plan.emptyDirs.length === 0) {
toast.info(t("fileManager.localNothingToTransfer"));
return;
}
const sessionId = sshSessionId;
await runBatch(
"upload",
plan.files.length,
plan.totalBytes,
async ({ isCancelled, trackTransfer, progress, settleTransfer }) => {
await ensureSSHConnection();
const dirs = planRemoteDirectories(
plan.files.map((f) => f.relativePath),
plan.emptyDirs,
);
for (const dir of dirs) {
if (isCancelled()) throw new TransferCancelledError();
const parent = dir.includes("/")
? joinRemotePath(remoteDir, dir.slice(0, dir.lastIndexOf("/")))
: remoteDir;
const name = dir.split("/").pop()!;
// The backend tolerates existing directories (#1442), so a
// failure here is real and should stop the batch up front.
await createSSHFolder(sessionId, parent, name, hostId);
}
// Files go up several at a time (see "Simultaneous File Transfers"
// in the profile settings); directories were created above, so
// order between files no longer matters.
const failed: string[] = [];
let reason: string | undefined;
await runWithConcurrency(
plan.files,
getTransferConcurrency(),
async (file) => {
const fileName = file.relativePath.split("/").pop()!;
const targetDir = remoteDirForRelativePath(
remoteDir,
file.relativePath,
);
const transferId = createLocalTransferId("local-upload");
trackTransfer(transferId, fileName);
try {
await uploadLocalFileToSession({
sessionId,
remoteDir: targetDir,
localPath: file.localPath,
fileName,
hostId,
transferId,
onProgress: ({ transferred }) =>
progress(transferId, transferred),
});
} catch (error) {
if (!isCancelled()) {
failed.push(file.relativePath);
reason ??= describeTransferError(error);
console.error(`Failed to upload ${file.localPath}:`, error);
}
} finally {
settleTransfer(transferId, file.size);
}
},
isCancelled,
);
return { failed, reason };
},
);
onRemoteChanged(remoteDir);
},
[sshSessionId, hostId, ensureSSHConnection, onRemoteChanged, runBatch, t],
);
/** Downloads remote files/folders into a local directory. */
const downloadRemoteItems = useCallback(
async (items: FileItem[], localDir: string) => {
if (!sshSessionId) {
toast.error(t("fileManager.noSSHConnection"));
return;
}
if (items.length === 0) return;
const sessionId = sshSessionId;
const { separator } = await getLocalHome();
// Every remote name is validated for the local platform and the result
// must stay inside `localDir`; anything else is reported and skipped.
const toLocalPath = (relativePath: string) =>
buildLocalDestination(localDir, relativePath, separator);
// Expand directories into a flat file plan first so the toast can show
// a real total. Listing is the only part that goes through the
// renderer's normal API path.
const plan: RemoteDownloadPlanEntry[] = [];
const emptyDirs: string[] = [];
const expandTaskId = `local-download-expand-${Date.now()}`;
toast.loading(t("fileManager.localPreparingDownload"), {
id: expandTaskId,
duration: Infinity,
});
try {
await ensureSSHConnection();
const walkRemote = async (remotePath: string, relDir: string) => {
const { files } = await listSSHFiles(sessionId, remotePath, {
force: true,
});
if (files.length === 0) {
emptyDirs.push(relDir);
return;
}
for (const child of files) {
const rel = `${relDir}/${child.name}`;
if (child.type === "directory") {
await walkRemote(child.path, rel);
} else if (child.type === "file" || child.type === "link") {
plan.push({
remotePath: child.path,
relativePath: rel,
size: child.size,
});
}
}
};
for (const item of items) {
const name = item.name || remoteBaseName(item.path);
if (item.type === "directory") {
await walkRemote(item.path, name);
} else {
plan.push({
remotePath: item.path,
relativePath: name,
size: item.size,
});
}
}
} catch (error) {
toast.dismiss(expandTaskId);
toast.error(
t("fileManager.localDownloadFailed"),
error instanceof Error ? { description: error.message } : undefined,
);
return;
}
toast.dismiss(expandTaskId);
if (plan.length === 0 && emptyDirs.length === 0) {
toast.info(t("fileManager.localNothingToTransfer"));
return;
}
// Drop anything whose remote name cannot become a safe local path
// (separators, traversal, Windows-invalid characters). These never
// reach the filesystem; the user is told how many were skipped.
const unsafe: string[] = [];
const safeDest = (relativePath: string): string | null => {
try {
return toLocalPath(relativePath);
} catch (error) {
if (error instanceof UnsafeLocalNameError) {
unsafe.push(relativePath);
return null;
}
throw error;
}
};
const plannedDirs = emptyDirs
.map((dir) => ({ dir, dest: safeDest(dir) }))
.filter((d): d is { dir: string; dest: string } => d.dest !== null);
const plannedFiles = plan
.map((entry) => ({ entry, dest: safeDest(entry.relativePath) }))
.filter(
(p): p is { entry: RemoteDownloadPlanEntry; dest: string } =>
p.dest !== null,
);
if (unsafe.length > 0) {
toast.error(
t("fileManager.localUnsafeNamesSkipped", { count: unsafe.length }),
{ description: unsafe.slice(0, 3).join(", ") },
);
console.warn("Skipped remote items with unsafe local names:", unsafe);
}
if (plannedFiles.length === 0 && plannedDirs.length === 0) {
return;
}
// Collision policy: never replace silently. Find destinations that
// already exist and let the user choose Replace or Skip for the batch.
const destinations = plannedFiles.map((p) => p.dest);
let existing: Set<string>;
try {
existing = new Set(await localPathsExist(destinations));
} catch (error) {
toast.error(
t("fileManager.localDownloadFailed"),
error instanceof Error ? { description: error.message } : undefined,
);
return;
}
let overwriteExisting = false;
if (existing.size > 0) {
overwriteExisting = await askReplaceExisting(t, existing.size);
}
const skipped = overwriteExisting
? []
: plannedFiles.filter(({ dest }) => existing.has(dest));
const work = overwriteExisting
? plannedFiles
: plannedFiles.filter(({ dest }) => !existing.has(dest));
if (skipped.length > 0) {
toast.info(
t("fileManager.localSkippedExisting", { count: skipped.length }),
);
}
if (work.length === 0 && plannedDirs.length === 0) {
onLocalChanged(localDir);
return;
}
const workBytes = work.reduce((sum, w) => sum + (w.entry.size ?? 0), 0);
await runBatch(
"download",
work.length,
workBytes,
async ({ isCancelled, trackTransfer, progress, settleTransfer }) => {
for (const { dest } of plannedDirs) {
if (isCancelled()) throw new TransferCancelledError();
await ensureLocalDirectory(dest, localDir);
}
const failed: string[] = [];
let reason: string | undefined;
await runWithConcurrency(
work,
getTransferConcurrency(),
async ({ entry, dest }) => {
const fileName = entry.relativePath.split("/").pop()!;
const transferId = createLocalTransferId("local-download");
trackTransfer(transferId, fileName);
try {
await downloadSessionFileToLocal({
sessionId,
remotePath: entry.remotePath,
destPath: dest,
rootPath: localDir,
expectedSize: entry.size,
overwrite: overwriteExisting && existing.has(dest),
transferId,
onProgress: ({ transferred }) =>
progress(transferId, transferred),
});
} catch (error) {
if (!isCancelled()) {
failed.push(entry.relativePath);
reason ??= describeTransferError(error);
console.error(
`Failed to download ${entry.remotePath}:`,
error,
);
}
} finally {
settleTransfer(transferId, entry.size ?? 0);
}
},
isCancelled,
);
return { failed, reason };
},
);
onLocalChanged(localDir);
},
[sshSessionId, ensureSSHConnection, onLocalChanged, runBatch, t],
);
return { uploadLocalPaths, downloadRemoteItems };
}
@@ -1,106 +0,0 @@
import { useCallback, useRef, useState } from "react";
import type React from "react";
interface UsePaneWidthOptions {
/** localStorage key the width is remembered under. */
storageKey: string;
defaultWidth: number;
minWidth: number;
/**
* Upper bound as a fraction of the containing row's width (measured at
* drag start from `containerRef`), so a pane can never swallow the layout.
*/
maxFraction?: number;
containerRef?: React.RefObject<HTMLElement | null>;
}
function readStoredWidth(
storageKey: string,
defaultWidth: number,
minWidth: number,
): number {
try {
const saved = Number(localStorage.getItem(storageKey));
return Number.isFinite(saved) && saved >= minWidth ? saved : defaultWidth;
} catch {
return defaultWidth;
}
}
/**
* A persisted, drag-resizable pane width. `startResize` goes on the
* mousedown of a vertical drag handle sitting on the pane's right edge;
* `resetWidth` (e.g. on double-click) restores the default.
*/
export function usePaneWidth({
storageKey,
defaultWidth,
minWidth,
maxFraction = 0.65,
containerRef,
}: UsePaneWidthOptions) {
const [width, setWidth] = useState<number>(() =>
readStoredWidth(storageKey, defaultWidth, minWidth),
);
const [isResizing, setIsResizing] = useState(false);
const widthRef = useRef(width);
widthRef.current = width;
const persist = useCallback(
(value: number) => {
try {
localStorage.setItem(storageKey, String(value));
} catch {
// storage unavailable
}
},
[storageKey],
);
const startResize = useCallback(
(event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
const startX = event.clientX;
const startWidth = widthRef.current;
const rowWidth =
containerRef?.current?.getBoundingClientRect().width ?? 0;
const maxWidth = Math.max(
minWidth,
rowWidth ? rowWidth * maxFraction : Number.POSITIVE_INFINITY,
);
let latest = startWidth;
setIsResizing(true);
const onMove = (e: MouseEvent) => {
latest = Math.round(
Math.min(
maxWidth,
Math.max(minWidth, startWidth + (e.clientX - startX)),
),
);
if (latest !== widthRef.current) setWidth(latest);
};
const onUp = () => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
document.body.style.cursor = "";
document.body.style.userSelect = "";
setIsResizing(false);
persist(latest);
};
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
},
[containerRef, maxFraction, minWidth, persist],
);
const resetWidth = useCallback(() => {
setWidth(defaultWidth);
persist(defaultWidth);
}, [defaultWidth, persist]);
return { width, isResizing, startResize, resetWidth };
}
@@ -1,239 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type React from "react";
export interface ResizableColumnSpec {
/** Stable key, also used for persistence. */
key: string;
/** i18n key for the column's label (used by the show/hide menu). */
labelKey: string;
defaultWidth: number;
minWidth?: number;
maxWidth?: number;
}
interface UseResizableColumnsOptions {
/** localStorage key; widths are remembered per pane. */
storageKey: string;
/**
* Fixed-width columns, in visual order, that follow a leading flexible
* column (the file name). The flexible column absorbs whatever is left.
*/
columns: ResizableColumnSpec[];
}
const DEFAULT_MIN = 48;
const DEFAULT_MAX = 600;
function readStoredWidths(
storageKey: string,
columns: ResizableColumnSpec[],
): Record<string, number> {
const widths: Record<string, number> = {};
for (const column of columns) widths[column.key] = column.defaultWidth;
try {
const raw = localStorage.getItem(storageKey);
if (!raw) return widths;
const parsed = JSON.parse(raw) as Record<string, unknown>;
for (const column of columns) {
const value = parsed?.[column.key];
if (typeof value === "number" && Number.isFinite(value)) {
widths[column.key] = clamp(value, column);
}
}
} catch {
// storage unavailable or corrupt: fall back to defaults
}
return widths;
}
function readHiddenKeys(
storageKey: string,
columns: ResizableColumnSpec[],
): Set<string> {
try {
const raw = localStorage.getItem(`${storageKey}:hidden`);
if (!raw) return new Set();
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return new Set();
const known = new Set(columns.map((c) => c.key));
return new Set(
parsed.filter((k): k is string => typeof k === "string" && known.has(k)),
);
} catch {
return new Set();
}
}
function clamp(value: number, column: ResizableColumnSpec): number {
const min = column.minWidth ?? DEFAULT_MIN;
const max = column.maxWidth ?? DEFAULT_MAX;
return Math.round(Math.min(max, Math.max(min, value)));
}
/**
* Column widths for a list view whose first column is flexible and whose
* remaining columns are fixed and user-resizable by dragging the boundary at
* the left edge of each header cell. Double-clicking a handle resets that
* column to its default. Widths persist in localStorage.
*/
export function useResizableColumns({
storageKey,
columns,
}: UseResizableColumnsOptions) {
const [widths, setWidths] = useState<Record<string, number>>(() =>
readStoredWidths(storageKey, columns),
);
const [hiddenKeys, setHiddenKeys] = useState<Set<string>>(() =>
readHiddenKeys(storageKey, columns),
);
const [resizingKey, setResizingKey] = useState<string | null>(null);
const widthsRef = useRef(widths);
widthsRef.current = widths;
// Re-read if the pane is re-keyed (e.g. a different storage key).
useEffect(() => {
setWidths(readStoredWidths(storageKey, columns));
setHiddenKeys(readHiddenKeys(storageKey, columns));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [storageKey]);
const isVisible = useCallback(
(key: string) => !hiddenKeys.has(key),
[hiddenKeys],
);
const visibleColumns = useMemo(
() => columns.filter((c) => !hiddenKeys.has(c.key)),
[columns, hiddenKeys],
);
const toggleColumn = useCallback(
(key: string) => {
setHiddenKeys((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
try {
localStorage.setItem(
`${storageKey}:hidden`,
JSON.stringify(Array.from(next)),
);
} catch {
// storage unavailable
}
return next;
});
},
[storageKey],
);
const persist = useCallback(
(next: Record<string, number>) => {
try {
localStorage.setItem(storageKey, JSON.stringify(next));
} catch {
// storage unavailable
}
},
[storageKey],
);
const gridTemplateColumns = useMemo(
() =>
[
"minmax(140px, 1fr)",
...visibleColumns.map((c) => `${widths[c.key]}px`),
].join(" "),
[visibleColumns, widths],
);
const startResize = useCallback(
(key: string, event: React.MouseEvent) => {
const column = columns.find((c) => c.key === key);
if (!column) return;
event.preventDefault();
event.stopPropagation();
const startX = event.clientX;
const startWidth = widthsRef.current[key];
setResizingKey(key);
const onMove = (e: MouseEvent) => {
// The handle sits on the column's left edge, so dragging right
// narrows the column and dragging left widens it.
const next = clamp(startWidth - (e.clientX - startX), column);
if (next !== widthsRef.current[key]) {
setWidths((prev) => ({ ...prev, [key]: next }));
}
};
const onUp = () => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
document.body.style.cursor = "";
document.body.style.userSelect = "";
setResizingKey(null);
persist(widthsRef.current);
// The browser dispatches a click to the common ancestor of the
// mousedown/mouseup targets, which here is the sortable header cell.
// Swallow that one click so finishing a drag never toggles the sort.
const swallow = (e: MouseEvent) => {
e.stopPropagation();
e.preventDefault();
};
document.addEventListener("click", swallow, { capture: true });
setTimeout(
() =>
document.removeEventListener("click", swallow, { capture: true }),
0,
);
};
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
},
[columns, persist],
);
const resetColumn = useCallback(
(key: string) => {
const column = columns.find((c) => c.key === key);
if (!column) return;
setWidths((prev) => {
const next = { ...prev, [key]: column.defaultWidth };
persist(next);
return next;
});
},
[columns, persist],
);
const getHandleProps = useCallback(
(key: string) => ({
onMouseDown: (event: React.MouseEvent) => startResize(key, event),
onDoubleClick: (event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
resetColumn(key);
},
onClick: (event: React.MouseEvent) => event.stopPropagation(),
"data-resizing": resizingKey === key ? "true" : undefined,
}),
[startResize, resetColumn, resizingKey],
);
return {
widths,
gridTemplateColumns,
getHandleProps,
resizingKey,
columns,
visibleColumns,
isVisible,
toggleColumn,
};
}
export type ResizableColumns = ReturnType<typeof useResizableColumns>;
@@ -1,413 +0,0 @@
// 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";
/** Custom MIME type carried by drags that originate in the local pane. */
export const LOCAL_FILES_DRAG_MIME = "application/x-termix-local-files";
/**
* Custom MIME type the remote grid adds to its internal drags so other panes
* can recognise them during dragenter/dragover (when payloads are unreadable).
*/
export const REMOTE_FILES_DRAG_MIME = "application/x-termix-remote-files";
export interface LocalFilesDragPayload {
type: "local_files";
paths: string[];
}
export interface InternalFilesDragPayload {
type: "internal_files";
files: string[];
}
export function serializeLocalFilesDragPayload(paths: string[]): string {
const payload: LocalFilesDragPayload = { type: "local_files", paths };
return JSON.stringify(payload);
}
export function parseLocalFilesDragPayload(
raw: string | null | undefined,
): string[] | null {
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as Partial<LocalFilesDragPayload>;
if (parsed?.type !== "local_files" || !Array.isArray(parsed.paths)) {
return null;
}
const paths = parsed.paths.filter(
(p): p is string => typeof p === "string" && p.length > 0,
);
return paths.length > 0 ? paths : null;
} catch {
return null;
}
}
/** Parses the payload the remote grid puts on `text/plain` for internal drags. */
export function parseInternalFilesDragPayload(
raw: string | null | undefined,
): string[] | null {
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as Partial<InternalFilesDragPayload>;
if (parsed?.type !== "internal_files" || !Array.isArray(parsed.files)) {
return null;
}
const files = parsed.files.filter(
(p): p is string => typeof p === "string" && p.length > 0,
);
return files.length > 0 ? files : null;
} catch {
return null;
}
}
/** True while a drag that started in the local pane is over the element. */
export function isLocalFilesDrag(
dataTransfer: Pick<DataTransfer, "types"> | null | undefined,
): boolean {
if (!dataTransfer) return false;
return Array.from(dataTransfer.types ?? []).includes(LOCAL_FILES_DRAG_MIME);
}
/**
* Marks a drag that starts on remote rows. Within the remote grid a drop is a
* move; onto the local pane it is a copy (a download). The source has to
* allow both, because Chromium refuses a drop whose dropEffect is not in
* effectAllowed without firing any event at all -- the drop just silently
* does nothing.
*/
export function beginRemoteFilesDrag(
dataTransfer: Pick<DataTransfer, "setData" | "effectAllowed">,
remotePaths: string[],
): void {
const payload: InternalFilesDragPayload = {
type: "internal_files",
files: remotePaths,
};
dataTransfer.setData("text/plain", JSON.stringify(payload));
// Lets sibling panes recognise this drag before the payload is readable.
dataTransfer.setData(REMOTE_FILES_DRAG_MIME, "1");
dataTransfer.effectAllowed = "copyMove";
}
/** True while a drag that started in the remote grid is over the element. */
export function isRemoteFilesDrag(
dataTransfer: Pick<DataTransfer, "types"> | null | undefined,
): boolean {
if (!dataTransfer) return false;
return Array.from(dataTransfer.types ?? []).includes(REMOTE_FILES_DRAG_MIME);
}
/** Joins a POSIX remote directory with one or more path segments. */
export function joinRemotePath(base: string, ...segments: string[]): string {
let out = base || "/";
for (const segment of segments) {
const clean = segment.replace(/^\/+|\/+$/g, "");
if (!clean) continue;
out = out.endsWith("/") ? `${out}${clean}` : `${out}/${clean}`;
}
return out;
}
/** Joins a local directory and a file name using the platform separator. */
export function joinLocalPath(
base: string,
name: string,
separator: string,
): string {
const sep = separator || "/";
const trimmedBase = base.endsWith(sep) ? base.slice(0, -sep.length) : base;
// Root on POSIX is "/" which trims to ""; keep the separator in that case.
return `${trimmedBase}${sep}${name}`;
}
/** Thrown when a remote name cannot be used as a local path component. */
export class UnsafeLocalNameError extends Error {
readonly code = "EINVAL";
constructor(
readonly name: string,
readonly reason: string,
) {
super(`Unsafe file name "${name}": ${reason}`);
}
}
// Windows refuses these device names in any directory, with any extension.
const WINDOWS_RESERVED_NAMES =
/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\.[^.]*)?$/i;
/**
* Validates one remote path component for use as a single local path
* segment on the destination platform (`separator` "\\" means Windows).
*
* A POSIX file name may legally contain "\\", ":" or end in a dot, all of
* which Windows treats as separators, drive prefixes or strips — so a remote
* name like "..\\outside.txt" would otherwise be normalised out of the
* selected download folder. Anything that is not a plain name is rejected;
* traversal ("..") and separators are rejected on every platform.
*/
export function assertSafeLocalComponent(
name: string,
separator: string,
): string {
if (name === "" || name === "." || name === "..") {
throw new UnsafeLocalNameError(name, "empty or traversal component");
}
if (name.includes("/") || name.includes("\0")) {
throw new UnsafeLocalNameError(name, "contains a path separator or NUL");
}
if (separator === "\\") {
if (/[\\:*?"<>|]/.test(name)) {
throw new UnsafeLocalNameError(
name,
"contains a character Windows does not allow in file names",
);
}
if (/[\u0001-\u001f]/.test(name)) {
throw new UnsafeLocalNameError(name, "contains control characters");
}
if (/[. ]$/.test(name)) {
throw new UnsafeLocalNameError(
name,
"Windows strips trailing dots and spaces",
);
}
if (WINDOWS_RESERVED_NAMES.test(name)) {
throw new UnsafeLocalNameError(name, "reserved device name on Windows");
}
}
return name;
}
function samePathPrefix(a: string, b: string, separator: string): boolean {
return separator === "\\" ? a.toLowerCase() === b.toLowerCase() : a === b;
}
/**
* Builds the local destination for a "/"-separated relative remote path
* under `localDir`, validating every component for the destination platform
* and asserting the result stays strictly inside `localDir`.
*/
export function buildLocalDestination(
localDir: string,
relativePath: string,
separator: string,
): string {
const sep = separator || "/";
const parts = relativePath.split("/").filter((p) => p !== "");
if (parts.length === 0) {
throw new UnsafeLocalNameError(relativePath, "empty path");
}
const dest = parts.reduce(
(acc, part) => joinLocalPath(acc, assertSafeLocalComponent(part, sep), sep),
localDir,
);
const root = localDir.endsWith(sep) ? localDir : `${localDir}${sep}`;
if (
!samePathPrefix(dest.slice(0, root.length), root, sep) ||
dest.length <= root.length
) {
throw new UnsafeLocalNameError(
relativePath,
"destination escapes the selected folder",
);
}
return dest;
}
export function remoteBaseName(remotePath: string): string {
const trimmed = remotePath.replace(/\/+$/, "");
return trimmed.split("/").pop() || trimmed || "/";
}
/**
* Given the "/"-separated relative paths of files being uploaded (each
* including its top-level root name) plus any empty directories, returns the
* set of directories that must exist on the remote, shallowest first, so each
* parent is created before its children.
*/
export function planRemoteDirectories(
fileRelativePaths: string[],
emptyDirs: string[] = [],
): string[] {
const dirs = new Set<string>();
for (const relativePath of fileRelativePaths) {
const parts = relativePath.split("/").filter(Boolean);
for (let i = 1; i < parts.length; i++) {
dirs.add(parts.slice(0, i).join("/"));
}
}
for (const dir of emptyDirs) {
const parts = dir.split("/").filter(Boolean);
for (let i = 1; i <= parts.length; i++) {
dirs.add(parts.slice(0, i).join("/"));
}
}
return Array.from(dirs).sort(
(a, b) => a.split("/").length - b.split("/").length || a.localeCompare(b),
);
}
/** Remote directory a file with the given relative path should land in. */
export function remoteDirForRelativePath(
base: string,
relativePath: string,
): string {
const idx = relativePath.lastIndexOf("/");
if (idx <= 0) return base;
return joinRemotePath(base, relativePath.slice(0, idx));
}
/** Short "Kind" column label, in the spirit of Finder / Termius. */
export function describeLocalKind(
entry: Pick<LocalFileEntry, "name" | "type">,
): string {
if (entry.type === "directory") return "folder";
if (entry.type === "link") return "link";
const dot = entry.name.lastIndexOf(".");
if (dot > 0 && dot < entry.name.length - 1) {
return entry.name.slice(dot + 1).toLowerCase();
}
return "file";
}
export type LocalSortField = "name" | "modified" | "size" | "kind";
export function sortLocalEntries(
entries: LocalFileEntry[],
field: LocalSortField,
order: "asc" | "desc",
): LocalFileEntry[] {
const dir = order === "asc" ? 1 : -1;
const collator = new Intl.Collator(undefined, {
numeric: true,
sensitivity: "base",
});
return [...entries].sort((a, b) => {
// Folders always group first, matching the remote grid.
const aDir = a.type === "directory" ? 0 : 1;
const bDir = b.type === "directory" ? 0 : 1;
if (aDir !== bDir) return aDir - bDir;
let cmp = 0;
switch (field) {
case "modified":
cmp = (a.modifiedTimestamp ?? 0) - (b.modifiedTimestamp ?? 0);
break;
case "size":
cmp = a.size - b.size;
break;
case "kind":
cmp = collator.compare(describeLocalKind(a), describeLocalKind(b));
break;
default:
cmp = 0;
}
if (cmp === 0) cmp = collator.compare(a.name, b.name);
return cmp * dir;
});
}
const MONTH_ABBREVIATIONS = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
];
/**
* Formats a local mtime the same way the backend formats remote entries
* (`formatMtime` in src/backend/hosts/file-manager/utils.ts, i.e. `ls -l`
* style): `Sep 11 16:25` for the last six months, `Sep 11 2025` before
* that — so both panes read alike.
*/
export function formatLocalModified(
timestamp?: number,
now: Date = new Date(),
): string {
if (!timestamp) return "--";
const date = new Date(timestamp);
if (Number.isNaN(date.getTime())) return "--";
const month = MONTH_ABBREVIATIONS[date.getMonth()];
const day = date.getDate().toString().padStart(2, " ");
const sixMonthsAgo = new Date(now.getTime() - 180 * 24 * 60 * 60 * 1000);
if (date > sixMonthsAgo) {
const hours = date.getHours().toString().padStart(2, "0");
const minutes = date.getMinutes().toString().padStart(2, "0");
return `${month} ${day} ${hours}:${minutes}`;
}
return `${month} ${day} ${date.getFullYear()}`;
}
// ---------------------------------------------------------------------------
// Parallel transfers
export const TRANSFER_CONCURRENCY_STORAGE_KEY =
"termix:file-manager:transfer-concurrency";
export const DEFAULT_TRANSFER_CONCURRENCY = 4;
export const MAX_TRANSFER_CONCURRENCY = 8;
export function clampTransferConcurrency(value: unknown): number {
const n = Math.floor(Number(value));
if (!Number.isFinite(n)) return DEFAULT_TRANSFER_CONCURRENCY;
return Math.min(MAX_TRANSFER_CONCURRENCY, Math.max(1, n));
}
/** How many files are transferred at the same time (1 = one after another). */
export function getTransferConcurrency(): number {
try {
const raw = localStorage.getItem(TRANSFER_CONCURRENCY_STORAGE_KEY);
return raw === null
? DEFAULT_TRANSFER_CONCURRENCY
: clampTransferConcurrency(raw);
} catch {
return DEFAULT_TRANSFER_CONCURRENCY;
}
}
export function setTransferConcurrency(value: number): number {
const clamped = clampTransferConcurrency(value);
try {
localStorage.setItem(TRANSFER_CONCURRENCY_STORAGE_KEY, String(clamped));
} catch {
// storage unavailable
}
return clamped;
}
/**
* Runs `worker` over `items` with at most `limit` in flight, preserving the
* original order of dispatch. Stops dispatching new items once `shouldStop()`
* returns true; items already in flight run to completion (the caller
* cancels those through their own transfer ids). Never rejects because of a
* single item: worker errors are the worker's business.
*/
export async function runWithConcurrency<T>(
items: readonly T[],
limit: number,
worker: (item: T, index: number) => Promise<void>,
shouldStop: () => boolean = () => false,
): Promise<void> {
const size = Math.max(1, Math.min(limit, items.length));
let next = 0;
const lanes = Array.from({ length: size }, async () => {
while (next < items.length && !shouldStop()) {
const index = next++;
await worker(items[index], index);
}
});
await Promise.all(lanes);
}
@@ -1,53 +0,0 @@
import type { FileItem } from "@/types/index";
export function childPath(parent: string, name: string): string {
return parent === "/" ? `/${name}` : `${parent.replace(/\/$/, "")}/${name}`;
}
export function addOptimisticItem(
files: FileItem[],
parent: string,
name: string,
type: "file" | "directory",
now = Date.now(),
): FileItem[] {
const path = childPath(parent, name);
if (files.some((file) => file.path === path)) return files;
return [
...files,
{
name,
path,
type,
size: type === "file" ? 0 : undefined,
modified: new Date(now).toISOString(),
modifiedTimestamp: now,
},
];
}
export function removePaths(files: FileItem[], paths: Set<string>): FileItem[] {
return files.filter((file) => !paths.has(file.path));
}
export function restoreItems(
files: FileItem[],
restored: FileItem[],
): FileItem[] {
const existing = new Set(files.map((file) => file.path));
return [...files, ...restored.filter((file) => !existing.has(file.path))];
}
export function renameOptimisticItem(
files: FileItem[],
originalPath: string,
newName: string,
): FileItem[] {
const slash = originalPath.lastIndexOf("/");
const parent = slash <= 0 ? "/" : originalPath.slice(0, slash);
return files.map((file) =>
file.path === originalPath
? { ...file, name: newName, path: childPath(parent, newName) }
: file,
);
}
@@ -1,92 +0,0 @@
import type { TFunction } from "i18next";
import {
formatDurationMs,
formatTransferMbPerSec,
type TransferTimings,
} from "@/main-axios.ts";
export function createFormatTransferMetrics(t: TFunction) {
return (timings?: TransferTimings): string => {
if (!timings) return "";
const parts: string[] = [];
if (
timings.directBenchmarkMs !== undefined &&
timings.relayBenchmarkMs !== undefined
) {
parts.push(
t("transfer.metricsRouteBenchmark", {
direct: formatDurationMs(timings.directBenchmarkMs),
relay: formatDurationMs(timings.relayBenchmarkMs),
}),
);
}
if (timings.prepareDestMs !== undefined) {
parts.push(
t("transfer.metricsPrepare", {
duration: formatDurationMs(timings.prepareDestMs),
}),
);
}
if (timings.compressMs !== undefined) {
parts.push(
t("transfer.metricsCompress", {
duration: formatDurationMs(timings.compressMs),
}),
);
}
for (const hop of timings.hops ?? []) {
const hopKey =
hop.id === "source_read"
? "transfer.metricsHopSourceRead"
: hop.id === "dest_local_write"
? "transfer.metricsHopDestLocalWrite"
: "transfer.metricsHopDestSftpWrite";
parts.push(
t(hopKey, {
throughput: formatTransferMbPerSec(hop.mbPerSec),
}),
);
}
if (timings.transferMs !== undefined) {
parts.push(
t("transfer.metricsTransfer", {
duration: formatDurationMs(timings.transferMs),
throughput: formatTransferMbPerSec(
timings.endToEndMbPerSec,
timings.transferBytes,
timings.transferMs,
),
}),
);
}
if (timings.extractMs !== undefined) {
parts.push(
t("transfer.metricsExtract", {
duration: formatDurationMs(timings.extractMs),
}),
);
}
if (timings.verifyMs !== undefined) {
parts.push(
t("transfer.metricsVerify", {
duration: formatDurationMs(timings.verifyMs),
}),
);
}
if (timings.sourceDeleteMs !== undefined) {
parts.push(
t("transfer.metricsSourceDelete", {
duration: formatDurationMs(timings.sourceDeleteMs),
}),
);
}
if (timings.totalMs !== undefined) {
parts.push(
t("transfer.metricsTotal", {
duration: formatDurationMs(timings.totalMs),
}),
);
}
return parts.join(" · ");
};
}
@@ -1,52 +0,0 @@
const PENDING_KEY = "termix_pending_transfers";
const NOTIFIED_KEY = "termix_notified_transfers";
function readJsonArray(key: string): string[] {
try {
const raw = localStorage.getItem(key);
if (!raw) return [];
const parsed = JSON.parse(raw) as unknown;
return Array.isArray(parsed)
? parsed.filter((id): id is string => typeof id === "string")
: [];
} catch {
return [];
}
}
function writeJsonArray(key: string, values: string[]): void {
localStorage.setItem(key, JSON.stringify(values));
}
export function registerPendingTransfer(transferId: string): void {
const pending = readJsonArray(PENDING_KEY);
if (!pending.includes(transferId)) {
writeJsonArray(PENDING_KEY, [...pending, transferId]);
}
}
export function markTransferNotified(transferId: string): void {
const notified = readJsonArray(NOTIFIED_KEY);
if (!notified.includes(transferId)) {
writeJsonArray(NOTIFIED_KEY, [...notified, transferId].slice(-200));
}
writeJsonArray(
PENDING_KEY,
readJsonArray(PENDING_KEY).filter((id) => id !== transferId),
);
}
export function isTransferNotified(transferId: string): boolean {
return readJsonArray(NOTIFIED_KEY).includes(transferId);
}
export function getPendingTransferIds(): string[] {
return readJsonArray(PENDING_KEY);
}
export function clearStalePendingTransfer(transferId: string): void {
writeJsonArray(
PENDING_KEY,
readJsonArray(PENDING_KEY).filter((id) => id !== transferId),
);
}
@@ -1,344 +0,0 @@
import { getErrorMessage } from "../../lib/error-message.js";
import { toast } from "sonner";
import type { TFunction } from "i18next";
import {
pollTransferUntilComplete,
cancelTransferToHost,
cleanupCancelledTransfer,
retryTransferToHost,
createTransferProgressTracker,
type TransferProgressResponse,
type TransferTimings,
} from "@/main-axios.ts";
import { TransferProgressToast } from "./components/TransferProgressToast.tsx";
import {
markTransferNotified,
registerPendingTransfer,
} from "./transferNotificationStore.ts";
const monitoredTransferIds = new Set<string>();
const TOAST_CLASS = "!pr-10 !pl-4 transfer-progress-toast";
function formatFileSize(bytes?: number): string {
if (bytes === undefined || bytes === null) return "-";
if (bytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
const formattedSize =
size < 10 && unitIndex > 0 ? size.toFixed(1) : Math.round(size).toString();
return `${formattedSize} ${units[unitIndex]}`;
}
function renderTransferProgressToast(
toastId: string | number,
status: TransferProgressResponse,
liveMbPerSec?: number,
onCancel?: () => void,
cancelling?: boolean,
stalled?: boolean,
): void {
toast.loading(
<TransferProgressToast
status={status}
liveMbPerSec={liveMbPerSec}
stalled={stalled}
formatSize={formatFileSize}
onCancel={onCancel}
cancelling={cancelling}
/>,
{ id: toastId, duration: Infinity, className: TOAST_CLASS },
);
}
export function showTransferCompletionToast(
finalStatus: TransferProgressResponse,
t: TFunction,
toastId?: string | number,
formatTransferMetrics?: (timings?: TransferTimings) => string,
): void {
showDefaultCompletionToast(
finalStatus,
toastId ?? `transfer-done-${finalStatus.transferId}`,
t,
formatTransferMetrics,
);
markTransferNotified(finalStatus.transferId);
}
export function isTransferBeingMonitored(transferId: string): boolean {
return monitoredTransferIds.has(transferId);
}
export interface TransferMonitorHandle {
toastId: string | number;
waitForCompletion: Promise<TransferProgressResponse>;
}
export interface BeginTransferMonitoringOptions {
resumed?: boolean;
initialStatus?: Partial<TransferProgressResponse>;
onComplete?: (
finalStatus: TransferProgressResponse,
toastId: string | number,
) => void;
onProgress?: (status: TransferProgressResponse) => void;
formatTransferMetrics?: (timings?: TransferTimings) => string;
}
function showCancelledTransferToast(
finalStatus: TransferProgressResponse,
toastId: string | number,
t: TFunction,
): void {
const hasPartialDest =
(finalStatus.partialDestRemaining ??
(finalStatus.bytesTransferred ?? 0) > 0) ||
(finalStatus.itemsCompleted ?? 0) > 0;
let description: string | undefined;
if (finalStatus.moveRequested && hasPartialDest) {
description = t("transfer.transferCancelledMoveHint");
} else if (hasPartialDest) {
description = t("transfer.transferCancelledCopyHint");
}
const showCleanupAction = hasPartialDest && !finalStatus.cleanupCompleted;
toast.info(t("transfer.transferCancelled"), {
id: toastId,
description,
className: TOAST_CLASS,
duration: showCleanupAction ? Infinity : undefined,
action: showCleanupAction
? {
label: t("transfer.cleanupDestFiles"),
onClick: () => {
void cleanupCancelledTransfer(finalStatus.transferId)
.then((result) => {
toast.dismiss(toastId);
if (result.failedPaths.length > 0) {
toast.warning(t("transfer.cleanupDestFilesPartial"));
} else if (result.removedPaths.length > 0) {
toast.success(t("transfer.cleanupDestFilesSuccess"));
} else {
toast.info(t("transfer.cleanupDestFilesNothing"));
}
})
.catch((error: unknown) => {
const message =
error instanceof Error
? error.message
: t("fileManager.unknownError");
toast.error(
`${t("transfer.cleanupDestFilesError")}: ${message}`,
);
});
},
}
: undefined,
});
}
function showFailedTransferToast(
finalStatus: TransferProgressResponse,
toastId: string | number,
t: TFunction,
formatTransferMetrics?: (timings?: TransferTimings) => string,
): void {
const hasPartial =
(finalStatus.partialDestRemaining ??
(finalStatus.bytesTransferred ?? 0) > 0) ||
(finalStatus.itemsCompleted ?? 0) > 0;
const descriptionParts: string[] = [];
if (finalStatus.message) {
descriptionParts.push(finalStatus.message);
}
if (finalStatus.retryable && hasPartial) {
descriptionParts.push(t("transfer.transferFailedRetryHint"));
}
const showRetry = finalStatus.retryable === true;
toast.error(t("transfer.transferError"), {
id: toastId,
description:
descriptionParts.length > 0 ? descriptionParts.join(" ") : undefined,
className: TOAST_CLASS,
duration: showRetry ? Infinity : undefined,
action: showRetry
? {
label: t("transfer.retryTransfer"),
onClick: () => {
void retryTransferToHost(finalStatus.transferId)
.then(() => {
toast.dismiss(toastId);
beginTransferProgressMonitoring(finalStatus.transferId, t, {
resumed: true,
formatTransferMetrics,
});
})
.catch((error: unknown) => {
const message =
error instanceof Error
? error.message
: t("fileManager.unknownError");
toast.error(`${t("transfer.retryTransferError")}: ${message}`);
});
},
}
: undefined,
});
}
function showDefaultCompletionToast(
finalStatus: TransferProgressResponse,
toastId: string | number,
t: TFunction,
formatTransferMetrics?: (timings?: TransferTimings) => string,
): void {
if (finalStatus.status === "cancelled") {
showCancelledTransferToast(finalStatus, toastId, t);
return;
}
if (finalStatus.status === "error") {
showFailedTransferToast(finalStatus, toastId, t, formatTransferMetrics);
return;
}
if (finalStatus.status === "partial") {
const failed = finalStatus.failedPaths?.join(", ") || "";
const metrics = formatTransferMetrics?.(finalStatus.timings);
toast.warning(
t("transfer.transferPartialHint", {
paths: failed,
count: finalStatus.failedPaths?.length || 0,
}),
{
id: toastId,
description: metrics || undefined,
className: TOAST_CLASS,
duration: undefined,
},
);
return;
}
const metrics = formatTransferMetrics?.(finalStatus.timings);
toast.success(t("transfer.transferSuccess"), {
id: toastId,
description: metrics || undefined,
className: TOAST_CLASS,
duration: undefined,
});
}
export function beginTransferProgressMonitoring(
transferId: string,
t: TFunction,
options: BeginTransferMonitoringOptions = {},
): TransferMonitorHandle | null {
if (monitoredTransferIds.has(transferId)) {
return null;
}
monitoredTransferIds.add(transferId);
registerPendingTransfer(transferId);
const progressTracker = createTransferProgressTracker();
let cancelling = false;
const initialStatus: TransferProgressResponse = {
transferId,
status: "running",
phase: "transferring",
...options.initialStatus,
};
const progressToast = toast.loading(
<TransferProgressToast
status={initialStatus}
formatSize={formatFileSize}
/>,
{
duration: Infinity,
description: options.resumed ? t("transfer.resumedHint") : undefined,
className: TOAST_CLASS,
},
);
const handleCancelTransfer = () => {
if (cancelling) return;
cancelling = true;
renderTransferProgressToast(
progressToast,
{ ...initialStatus, transferId },
undefined,
handleCancelTransfer,
true,
);
void cancelTransferToHost(transferId);
};
const waitForCompletion = pollTransferUntilComplete(
transferId,
(status) => {
options.onProgress?.(status);
const { rate, stalled } = progressTracker.update(status);
renderTransferProgressToast(
progressToast,
status,
rate,
handleCancelTransfer,
cancelling,
stalled,
);
},
250,
)
.then((finalStatus) => {
markTransferNotified(transferId);
if (options.onComplete) {
options.onComplete(finalStatus, progressToast);
} else {
showDefaultCompletionToast(
finalStatus,
progressToast,
t,
options.formatTransferMetrics,
);
}
return finalStatus;
})
.catch((error: unknown) => {
const message = getErrorMessage(error, t("fileManager.unknownError"));
toast.error(`${t("transfer.transferError")}: ${message}`, {
id: progressToast,
className: TOAST_CLASS,
duration: undefined,
});
markTransferNotified(transferId);
throw error;
})
.finally(() => {
monitoredTransferIds.delete(transferId);
});
renderTransferProgressToast(
progressToast,
initialStatus,
undefined,
handleCancelTransfer,
cancelling,
);
return { toastId: progressToast, waitForCompletion };
}
@@ -44,7 +44,6 @@ import "./widgets/TermixUptimeWidget";
import "./widgets/SystemOverviewWidget";
import "./widgets/SshTerminalWidget";
import "./widgets/QuickConnectWidget";
import "./widgets/FileManagerWidget";
import "./widgets/TunnelWidget";
import "./widgets/CalendarWidget";
import "./widgets/CountdownWidget";
@@ -1,18 +0,0 @@
import type {
FileManagerWidgetConfig,
WidgetEditFormProps,
} from "@/types/homepage-types";
import { SingleHostEditForm } from "./SingleHostEditForm";
export function FileManagerWidgetEditForm({
config,
onChange,
}: WidgetEditFormProps<FileManagerWidgetConfig>) {
return (
<SingleHostEditForm
hostId={config.hostId}
onChange={(hostId) => onChange({ ...config, hostId })}
filter={(h) => !!(h.enableSsh && h.enableFileManager)}
/>
);
}
@@ -28,7 +28,6 @@ import type {
SystemOverviewConfig,
SshTerminalConfig,
QuickConnectConfig,
FileManagerWidgetConfig,
TunnelWidgetConfig,
CalendarConfig,
CountdownConfig,
@@ -58,7 +57,6 @@ import { TermixUptimeEditForm } from "./TermixUptimeEditForm";
import { SystemOverviewEditForm } from "./SystemOverviewEditForm";
import { SshTerminalEditForm } from "./SshTerminalEditForm";
import { QuickConnectEditForm } from "./QuickConnectEditForm";
import { FileManagerWidgetEditForm } from "./FileManagerWidgetEditForm";
import { TunnelWidgetEditForm } from "./TunnelWidgetEditForm";
import { CalendarEditForm } from "./CalendarEditForm";
import { CountdownEditForm } from "./CountdownEditForm";
@@ -227,13 +225,6 @@ export function WidgetEditDialog({
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
/>
);
case "file_manager_widget":
return (
<FileManagerWidgetEditForm
config={config as unknown as FileManagerWidgetConfig}
onChange={(c) => setConfig(c as unknown as Record<string, unknown>)}
/>
);
case "tunnel_widget":
return (
<TunnelWidgetEditForm
@@ -1,5 +1,5 @@
import type { ComponentType } from "react";
import { FolderSearch, Network } from "lucide-react";
import { Network } from "lucide-react";
import type { SSHHostWithStatus } from "@/main-axios";
import type { Host } from "@/types/ui-types";
import { sshHostToHost } from "@/sidebar/HostManagerData";
@@ -20,12 +20,6 @@ export function quickConnectTargets(
actions: HostActionDef[],
): QuickConnectTarget[] {
const core: QuickConnectTarget[] = [
{
type: "files",
icon: FolderSearch,
labelKey: "homepage.connType_files",
enabled: (h) => !!(h.enableSsh && h.enableFileManager),
},
{
type: "tunnel",
icon: Network,
@@ -1,86 +0,0 @@
import { useState, useEffect } from "react";
import { FolderSearch } from "lucide-react";
import { useTranslation } from "react-i18next";
import { registerWidget } from "./WidgetRegistry";
import type {
FileManagerWidgetConfig,
WidgetComponentProps,
} from "@/types/homepage-types";
import { GRID_SIZE } from "@/types/homepage-types";
import { getSSHHosts } from "@/api/ssh-host-management-api";
import type { SSHHostWithStatus } from "@/main-axios";
import { FileManager } from "@/features/file-manager/FileManager";
import type { SSHHost } from "@/types/index";
import { WidgetTitle } from "./WidgetTitle";
function FileManagerWidget({
widget,
config,
}: WidgetComponentProps<FileManagerWidgetConfig>) {
const { t } = useTranslation();
const [host, setHost] = useState<SSHHostWithStatus | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!config.hostId) {
setLoading(false);
return;
}
getSSHHosts()
.then((hosts) => {
const found = hosts.find((h) => h.id === config.hostId);
setHost(found ?? null);
})
.catch(() => {})
.finally(() => setLoading(false));
}, [config.hostId]);
if (!config.hostId) {
return (
<div className="flex flex-col items-center justify-center w-full h-full gap-2 text-muted-foreground/60">
<FolderSearch size={20} />
<span className="text-xs">{t("homepage.widgetNoHostSelected")}</span>
</div>
);
}
if (loading) {
return (
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground/60">
{t("homepage.loading")}
</div>
);
}
if (!host) {
return (
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground/60">
{t("homepage.widgetNoHostSelected")}
</div>
);
}
return (
<div className="flex flex-col w-full h-full overflow-hidden">
<WidgetTitle title={widget.title} icon={<FolderSearch size={11} />} />
<div
className="flex-1 overflow-hidden"
onMouseDown={(e) => e.stopPropagation()}
>
<FileManager initialHost={host as unknown as SSHHost} />
</div>
</div>
);
}
registerWidget<FileManagerWidgetConfig>({
id: "file_manager_widget",
name: "File Manager",
description: "Embedded SFTP file manager for a configured host",
category: "system",
icon: <FolderSearch size={14} />,
defaultConfig: { hostId: 0 },
defaultSize: { w: GRID_SIZE * 20, h: GRID_SIZE * 14 },
minSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 8 },
component: FileManagerWidget,
});
File diff suppressed because it is too large. Load diff
@@ -1,26 +0,0 @@
export function normalizeRemoteDir(remotePath: string): string {
const trimmed = remotePath.trim().replace(/\\/g, "/");
if (!trimmed) return "/";
const collapsed = trimmed.replace(/\/+/g, "/");
const absolute = collapsed.startsWith("/") ? collapsed : `/${collapsed}`;
if (absolute === "/") return "/";
return absolute.replace(/\/+$/, "");
}
export function joinRemotePath(basePath: string, childPath: string): string {
const base = normalizeRemoteDir(basePath);
const child = childPath.replace(/\\/g, "/").replace(/^\/+/, "");
if (!child) return base;
return base === "/" ? `/${child}` : `${base}/${child}`;
}
export function hasSameHostTransferConflict(
sourcePaths: string[],
destinationDir: string,
): boolean {
const dest = normalizeRemoteDir(destinationDir);
return sourcePaths.some((sourcePath) => {
const source = normalizeRemoteDir(sourcePath);
return dest === source || dest.startsWith(`${source}/`);
});
}
@@ -5,6 +5,7 @@ import type { TerminalHandle, TerminalHostConfig } from "./Terminal";
import { useIsMobile } from "@/hooks/use-mobile";
import { useTabsSafe } from "@/shell/TabContext";
import { hostToSSHHost } from "@/lib/host-to-ssh-host";
import { invokeAction } from "@/shell/action-registry";
import type { TabRenderProps } from "@/shell/tab-registry";
const CommandHistoryProvider = lazy(() =>
@@ -88,9 +89,11 @@ export function TerminalTabContent({
}
previewTheme={previewTerminalTheme}
onOpenFileInEditor={(filePath) =>
shell.openFileInEditor(host, filePath)
void invokeAction("files.openEditor", host, filePath)
}
onOpenFileManager={(path) =>
void invokeAction("files.openHost", host, path)
}
onOpenFileManager={(path) => shell.openFileManager(host, path)}
isQuickConnect={host.id.startsWith("quick-connect-")}
onSaveQuickConnect={
shell.saveQuickConnect
+1 -6
View File
@@ -1,5 +1,5 @@
import type { ComponentType } from "react";
import { FolderOpen, Network } from "lucide-react";
import { Network } from "lucide-react";
import { tabTypeForActivity } from "@/shell/tab-registry";
export interface ActivityTarget {
@@ -11,11 +11,6 @@ export interface ActivityTarget {
/** Recent-activity types core itself records. */
const CORE_ACTIVITY: Record<string, ActivityTarget> = {
file_manager: {
icon: FolderOpen,
tab: "files",
labelKey: "dashboard.activityFileManager",
},
tunnel: {
icon: Network,
tab: "tunnel",
-38
View File
@@ -1,38 +0,0 @@
import type { FileItem } from "@/types/index";
import { createKeyedRequestCache } from "./keyed-request-cache";
export interface FileContentResult {
content: string;
path: string;
encoding?: "base64" | "utf8";
}
const cache = createKeyedRequestCache<FileContentResult>(30_000, 24);
const keyFor = (sessionId: string, path: string) => `${sessionId}\0${path}`;
export function getCachedFileContent(
sessionId: string,
path: string,
loader: () => Promise<FileContentResult>,
force = false,
): Promise<FileContentResult> {
return cache.get(keyFor(sessionId, path), loader, { force });
}
export function invalidateCachedFileContent(
sessionId: string,
path: string,
): void {
cache.invalidate(keyFor(sessionId, path));
}
export function shouldPrefetchFileContent(
file: FileItem,
maxPrefetchBytes: number,
): boolean {
return (
file.type === "file" &&
typeof file.size === "number" &&
file.size <= maxPrefetchBytes
);
}
-43
View File
@@ -1,43 +0,0 @@
import type { FileItem } from "@/types/index";
import { createKeyedRequestCache } from "./keyed-request-cache";
export interface FileListResult {
files: FileItem[];
path: string;
}
const cache = createKeyedRequestCache<FileListResult>(5_000, 150);
const keyFor = (sessionId: string, path: string) => `${sessionId}\0${path}`;
export function getCachedFileList(
sessionId: string,
path: string,
loader: () => Promise<FileListResult>,
force = false,
): Promise<FileListResult> {
return cache.get(keyFor(sessionId, path), loader, { force });
}
export function peekCachedFileList(
sessionId: string,
path: string,
maxAgeMs = 30_000,
): FileListResult | null {
return cache.peek(keyFor(sessionId, path), maxAgeMs);
}
export function invalidateCachedFileList(
sessionId: string,
path: string,
): void {
cache.invalidate(keyFor(sessionId, path));
}
export function updateCachedFileList(
sessionId: string,
path: string,
files: FileItem[],
): void {
cache.set(keyFor(sessionId, path), { files, path });
}
-191
View File
@@ -1,191 +0,0 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { ConnectionStatus } from "@/components/connection/connection-status.ts";
interface UseConnectionRetryOptions {
connect: () => void | Promise<void>;
maxAttempts?: number;
baseDelayMs?: number;
maxDelayMs?: number;
enabled?: boolean;
autoStart?: boolean;
}
interface UseConnectionRetryResult {
status: ConnectionStatus;
attempt: number;
maxAttempts: number;
nextRetryInMs: number | null;
markConnected: () => void;
markFailed: () => void;
retryNow: () => void;
reset: () => void;
}
const COUNTDOWN_TICK_MS = 250;
const RETRY_JITTER_RATIO = 0.1;
export function computeReconnectDelay(
attempt: number,
baseDelayMs: number,
maxDelayMs: number,
random = Math.random,
): number {
const base = Math.min(
baseDelayMs * Math.pow(2, Math.max(0, attempt - 1)),
maxDelayMs,
);
const jitter = base * RETRY_JITTER_RATIO * (random() * 2 - 1);
return Math.max(baseDelayMs, Math.min(maxDelayMs, Math.round(base + jitter)));
}
// Backoff curve mirrors Terminal.tsx's proven attemptReconnection(): 2s, 4s,
// 8s, capped at 8s, up to 8 attempts before falling back to a manual retry.
export function useConnectionRetry({
connect,
maxAttempts = 8,
baseDelayMs = 2000,
maxDelayMs = 8000,
enabled = true,
autoStart = true,
}: UseConnectionRetryOptions): UseConnectionRetryResult {
const [status, setStatus] = useState<ConnectionStatus>("connecting");
const [attempt, setAttempt] = useState(0);
const [nextRetryInMs, setNextRetryInMs] = useState<number | null>(null);
const connectRef = useRef(connect);
connectRef.current = connect;
const enabledRef = useRef(enabled);
enabledRef.current = enabled;
const attemptRef = useRef(0);
const retryTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const countdownIntervalRef = useRef<ReturnType<typeof setInterval> | null>(
null,
);
const isMountedRef = useRef(true);
const markFailedRef = useRef<() => void>(() => {});
const clearTimers = useCallback(() => {
if (retryTimeoutRef.current) {
clearTimeout(retryTimeoutRef.current);
retryTimeoutRef.current = null;
}
if (countdownIntervalRef.current) {
clearInterval(countdownIntervalRef.current);
countdownIntervalRef.current = null;
}
}, []);
const runConnect = useCallback(() => {
if (!isMountedRef.current) return;
setStatus("connecting");
try {
void Promise.resolve(connectRef.current()).catch(() => {
markFailedRef.current();
});
} catch {
markFailedRef.current();
}
}, []);
const scheduleRetry = useCallback(() => {
if (attemptRef.current >= maxAttempts) {
setStatus("disconnected");
setNextRetryInMs(null);
return;
}
attemptRef.current += 1;
setAttempt(attemptRef.current);
const delay = computeReconnectDelay(
attemptRef.current,
baseDelayMs,
maxDelayMs,
);
let remaining = delay;
setNextRetryInMs(remaining);
countdownIntervalRef.current = setInterval(() => {
remaining = Math.max(0, remaining - COUNTDOWN_TICK_MS);
setNextRetryInMs(remaining);
}, COUNTDOWN_TICK_MS);
retryTimeoutRef.current = setTimeout(() => {
clearTimers();
if (!isMountedRef.current || !enabledRef.current) return;
runConnect();
}, delay);
}, [baseDelayMs, maxDelayMs, maxAttempts, clearTimers, runConnect]);
const markConnected = useCallback(() => {
clearTimers();
attemptRef.current = 0;
setAttempt(0);
setNextRetryInMs(null);
if (isMountedRef.current) setStatus("connected");
}, [clearTimers]);
const markFailed = useCallback(() => {
clearTimers();
if (!isMountedRef.current) return;
setStatus("error");
if (enabledRef.current) {
scheduleRetry();
} else {
setNextRetryInMs(null);
}
}, [clearTimers, scheduleRetry]);
markFailedRef.current = markFailed;
const retryNow = useCallback(() => {
clearTimers();
attemptRef.current = 0;
setAttempt(0);
setNextRetryInMs(null);
runConnect();
}, [clearTimers, runConnect]);
const reset = useCallback(() => {
clearTimers();
attemptRef.current = 0;
setAttempt(0);
setNextRetryInMs(null);
if (isMountedRef.current) setStatus("connecting");
}, [clearTimers]);
useEffect(() => {
isMountedRef.current = true;
if (autoStart) {
runConnect();
}
return () => {
isMountedRef.current = false;
clearTimers();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (enabled && status === "error" && !retryTimeoutRef.current) {
scheduleRetry();
}
if (!enabled) {
clearTimers();
setNextRetryInMs(null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enabled]);
return {
status,
attempt,
maxAttempts,
nextRetryInMs,
markConnected,
markFailed,
retryNow,
reset,
};
}
-385
View File
@@ -712,7 +712,6 @@
"hostManager": "Host Manager",
"credentials": "Credentials",
"portForwarding": "Forwarding",
"sftp": "SFTP",
"connections": "Connections",
"serial": "Serial",
"keepSideMenuOpen": "Keep Menu Open",
@@ -764,14 +763,12 @@
"tabRdp": "RDP",
"tabVnc": "VNC",
"tabTunnels": "Tunnels",
"tabFiles": "Files",
"tabStats": "Host Metrics",
"tabTelnet": "Telnet",
"tabSharing": "Sharing",
"tabAuthentication": "Authentication",
"terminal": "Terminal",
"tunnel": "Tunnel",
"fileManager": "File Manager",
"serverStats": "Host Metrics",
"status": {
"available": "Available",
@@ -1115,12 +1112,6 @@
"proxmoxResultSkippedNoIp": "{{count}} skipped (no IP found)",
"proxmoxStatsUrlCopied": "Proxmox Stats URL copied to clipboard",
"copyProxmoxStatsUrlAction": "Copy Proxmox Stats URL",
"enableFileManagerMonitor": "Enable File Manager",
"enableFileManagerMonitorDesc": "Browse and manage files on this host over SFTP",
"scpLegacyLabel": "SCP Legacy Mode",
"scpLegacyDesc": "Use legacy file transfer for servers that do not support the SFTP subsystem (e.g. embedded or minimal SSH servers).",
"defaultPathLabel": "Default Path",
"fileManagerPathHint": "The directory to open when the file manager launches for this host.",
"statusChecksLabel": "Status Checks",
"enableStatusChecks": "Enable Status Checks",
"enableStatusChecksDesc": "Periodically ping this host to verify availability",
@@ -1280,7 +1271,6 @@
"deletedHost": "Deleted {{name}}",
"copiedToClipboard": "Copied to clipboard",
"terminalUrlCopied": "Terminal URL copied",
"fileManagerUrlCopied": "File Manager URL copied",
"tunnelUrlCopied": "Tunnel URL copied",
"dockerUrlCopied": "Docker URL copied",
"hostMetricsUrlCopied": "Host Metrics URL copied",
@@ -1298,7 +1288,6 @@
"copyAddress": "Copy Address",
"copyLink": "Copy Link",
"copyTerminalUrlAction": "Copy Terminal URL",
"copyFileManagerUrlAction": "Copy File Manager URL",
"copyTunnelUrlAction": "Copy Tunnel URL",
"copyDockerUrlAction": "Copy Docker URL",
"copyHostMetricsUrlAction": "Copy Host Metrics URL",
@@ -1868,336 +1857,6 @@
"removeFromSplit": "Remove from Split"
}
},
"fileManager": {
"noHostSelected": "No host selected",
"initializingEditor": "Initializing editor...",
"file": "File",
"folder": "Folder",
"uploadFile": "Upload File",
"downloadFile": "Download",
"extractArchive": "Extract Archive",
"extractingArchive": "Extracting {{name}}...",
"archiveExtractedSuccessfully": "{{name}} extracted successfully",
"extractFailed": "Extract failed",
"compressFile": "Compress File",
"compressFiles": "Compress Files",
"compressFilesDesc": "Compress {{count}} items into an archive",
"archiveName": "Archive Name",
"enterArchiveName": "Enter archive name...",
"compressionFormat": "Compression Format",
"selectedFiles": "Selected files",
"andMoreFiles": "and {{count}} more...",
"compress": "Compress",
"compressingFiles": "Compressing {{count}} items into {{name}}...",
"filesCompressedSuccessfully": "{{name}} created successfully",
"compressFailed": "Compression failed",
"edit": "Edit",
"preview": "Preview",
"previous": "Previous",
"next": "Next",
"pageXOfY": "Page {{current}} of {{total}}",
"zoomOut": "Zoom Out",
"zoomIn": "Zoom In",
"newFile": "New File",
"newFolder": "New Folder",
"rename": "Rename",
"uploading": "Uploading...",
"uploadingFile": "Uploading {{name}}...",
"fileName": "File Name",
"folderName": "Folder Name",
"fileUploadedSuccessfully": "File \"{{name}}\" uploaded successfully",
"failedToUploadFile": "Failed to upload file",
"uploadingFolderFiles": "Uploading {{count}} file(s)...",
"uploadedFolderFiles": "Uploaded {{count}} file(s) successfully",
"uploadedFolderPartial": "Uploaded {{uploaded}} file(s), {{failed}} failed",
"fileDownloadedSuccessfully": "File \"{{name}}\" downloaded successfully",
"failedToDownloadFile": "Failed to download file",
"fileCreatedSuccessfully": "File \"{{name}}\" created successfully",
"folderCreatedSuccessfully": "Folder \"{{name}}\" created successfully",
"failedToCreateItem": "Failed to create item",
"operationFailed": "{{operation}} operation failed for {{name}}: {{error}}",
"failedToResolveSymlink": "Failed to resolve symlink",
"itemsDeletedSuccessfully": "{{count}} items deleted successfully",
"failedToDeleteItems": "Failed to delete items",
"sudoPasswordRequired": "Administrator Password Required",
"enterSudoPassword": "Enter sudo password to continue this operation",
"sudoPassword": "Sudo password",
"sudoOperationFailed": "Sudo operation failed",
"sudoAuthFailed": "Sudo authentication failed",
"dragFilesToUpload": "Drop files here to upload",
"dropToUploadHere": "Drop to upload here",
"dropToDownloadHere": "Drop to download here",
"localFiles": "Local",
"showLocalFiles": "Show local files (split view)",
"hideLocalFiles": "Hide local files",
"localHome": "Home folder",
"localPathPlaceholder": "Local folder path",
"localShowHidden": "Show hidden files",
"localHideHidden": "Hide hidden files",
"localRevealInFileManager": "Reveal in system file manager",
"localCannotOpenFolder": "Cannot open folder",
"localItemCount": "{{count}} items",
"localSelectedCount": "{{count}} selected",
"back": "Back",
"forward": "Forward",
"up": "Up",
"kind": "Kind",
"localUploadingProgress": "Uploading {{current}} of {{total}}",
"localDownloadingProgress": "Downloading {{current}} of {{total}}",
"localTransferInFlight": "{{count}} at once",
"localPreparingDownload": "Preparing download...",
"localTransferCancelled": "Transfer cancelled",
"localNothingToTransfer": "Nothing to transfer",
"localUploadComplete": "Uploaded {{count}} file(s)",
"localUploadFailed": "Upload failed",
"localUploadPartial": "Uploaded {{done}} file(s), {{failed}} failed",
"localDownloadComplete": "Downloaded {{count}} file(s)",
"localDownloadFailed": "Download failed",
"localDownloadPartial": "Downloaded {{done}} file(s), {{failed}} failed",
"localReplaceExistingPrompt": "{{count}} item(s) already exist in this folder. Replace them?",
"localReplace": "Replace",
"localSkipExisting": "Skip",
"localSkippedExisting": "Skipped {{count}} existing item(s)",
"localUnsafeNamesSkipped": "Skipped {{count}} item(s) whose names cannot be used on this computer",
"localOpen": "Open",
"localOpenFolder": "Open folder",
"localUploadToRemote": "Upload to server",
"localUploadToRemoteMany": "Upload {{count}} items to server",
"localMoveToTrash": "Move to Trash",
"localMoveToTrashMany": "Move {{count}} items to Trash",
"localTrashConfirmSingle": "Move \"{{name}}\" to the Trash?",
"localTrashConfirmFolder": "Move the folder \"{{name}}\" and all its contents to the Trash?",
"localTrashConfirmMany": "Move {{count}} items to the Trash?",
"localTrashed": "Moved {{count}} item(s) to the Trash",
"localTrashFailed": "Could not move to the Trash",
"localRenameFailed": "Rename failed",
"localCreateFailed": "Could not create item",
"goToParentFolder": "Go to parent folder",
"resizeSidebar": "Drag to resize the sidebar (double-click to reset)",
"resizeLocalPane": "Drag to resize the local pane (double-click to reset)",
"columns": "Columns",
"columnsHint": "Right-click to choose columns",
"emptyFolder": "This folder is empty",
"searchFiles": "Search files...",
"upload": "Upload",
"selectHostToStart": "Select a host to start file management",
"sshRequiredForFileManager": "File manager requires SSH. This host does not have SSH enabled.",
"failedToConnect": "Failed to connect to SSH",
"failedToLoadDirectory": "Failed to load directory",
"noSSHConnection": "No SSH connection available",
"copy": "Copy",
"cut": "Cut",
"paste": "Paste",
"copyPath": "Copy Path",
"copyPaths": "Copy Paths",
"delete": "Delete",
"properties": "Properties",
"refresh": "Refresh",
"downloadFiles": "Download {{count}} files to Browser",
"copyFiles": "Copy {{count}} items",
"cutFiles": "Cut {{count}} items",
"deleteFiles": "Delete {{count}} items",
"filesCopiedToClipboard": "{{count}} items copied to clipboard",
"filesCutToClipboard": "{{count}} items cut to clipboard",
"pathCopiedToClipboard": "Path copied to clipboard",
"pathsCopiedToClipboard": "{{count}} paths copied to clipboard",
"failedToCopyPath": "Failed to copy path to clipboard",
"copyFolderLink": "Copy Link to Folder",
"copyCurrentFolderLink": "Copy Link to Current Folder",
"folderLinkCopied": "Folder link copied to clipboard",
"failedToCopyFolderLink": "Failed to copy folder link",
"movedItems": "Moved {{count}} items",
"failedToDeleteItem": "Failed to delete item",
"itemRenamedSuccessfully": "{{type}} renamed successfully",
"failedToRenameItem": "Failed to rename item",
"download": "Download",
"openExternalEditor": "Open externally",
"chooseExternalEditor": "Choose external editor",
"externalEditorSelected": "External editor selected",
"externalEditorDesktopOnly": "External editor is only available in the desktop app",
"externalEditorOpened": "Opened in external editor. Saving there will upload changes back to the server.",
"failedToOpenExternalEditor": "Failed to open external editor",
"failedToSelectExternalEditor": "Failed to select external editor",
"permissions": "Permissions",
"size": "Size",
"modified": "Modified",
"path": "Path",
"confirmDelete": "Are you sure you want to delete {{name}}?",
"permissionDenied": "Permission denied",
"serverError": "Server Error",
"fileSavedSuccessfully": "File saved successfully",
"failedToSaveFile": "Failed to save file",
"confirmDeleteSingleItem": "Are you sure you want to permanently delete \"{{name}}\"?",
"confirmDeleteMultipleItems": "Are you sure you want to permanently delete {{count}} items?",
"confirmDeleteMultipleItemsWithFolders": "Are you sure you want to permanently delete {{count}} items? This includes folders and their contents.",
"confirmDeleteFolder": "Are you sure you want to permanently delete the folder \"{{name}}\" and all its contents?",
"permanentDeleteWarning": "This action cannot be undone. The item(s) will be permanently deleted from the server.",
"moveToTrashWarning": "The item(s) will be moved to the Termix Trash and can be restored before the retention period expires.",
"itemsMovedToTrash": "{{count}} item(s) moved to Trash",
"trash": "Trash",
"trashDescription": "Deleted items are retained for {{days}} day(s) on this host.",
"trashLoadFailed": "Failed to load Trash",
"trashEmpty": "Trash is empty",
"trashRestore": "Restore",
"trashRestored": "Restored {{name}}",
"trashRestoreFailed": "Failed to restore item. The original path may already exist.",
"trashDeletePermanently": "Delete permanently",
"trashDeleteConfirm": "Permanently delete {{name}}? This cannot be undone.",
"trashPermanentlyDeleted": "Permanently deleted {{name}}",
"trashDeleteFailed": "Failed to permanently delete item",
"trashEmptyAction": "Empty Trash",
"trashEmptyConfirm": "Permanently delete every item in Trash? This cannot be undone.",
"trashEmptied": "Trash emptied",
"trashRetention": "Automatic cleanup after (days)",
"trashRetentionSaved": "Trash retention updated",
"trashRetentionFailed": "Failed to update Trash retention",
"trashUnavailableConfirm": "This item cannot be moved to Trash, usually because it is on another filesystem or requires elevated permissions. Delete it permanently instead?",
"recent": "Recent",
"pinned": "Pinned",
"folderShortcuts": "Folder Shortcuts",
"failedToReconnectSSH": "Failed to reconnect SSH session",
"openTerminalHere": "Open Terminal Here",
"run": "Run",
"openTerminalInFolder": "Open Terminal in This Folder",
"openTerminalInFileLocation": "Open Terminal at File Location",
"runningFile": "Running - {{file}}",
"onlyRunExecutableFiles": "Can only run executable files",
"directories": "Directories",
"removedFromRecentFiles": "Removed \"{{name}}\" from recent files",
"removeFailed": "Remove failed",
"unpinnedSuccessfully": "Unpinned \"{{name}}\" successfully",
"unpinFailed": "Unpin failed",
"removedShortcut": "Removed shortcut \"{{name}}\"",
"removeShortcutFailed": "Remove shortcut failed",
"clearedAllRecentFiles": "Cleared all recent files",
"clearFailed": "Clear failed",
"removeFromRecentFiles": "Remove from recent files",
"clearAllRecentFiles": "Clear all recent files",
"unpinFile": "Unpin file",
"removeShortcut": "Remove shortcut",
"pinFile": "Pin file",
"addToShortcuts": "Add to shortcuts",
"pasteFailed": "Paste failed",
"noUndoableActions": "No undoable actions",
"undoCopySuccess": "Undid copy operation: Deleted {{count}} copied files",
"undoCopyFailedDelete": "Undo failed: Could not delete any copied files",
"undoCopyFailedNoInfo": "Undo failed: Could not find copied file information",
"undoMoveSuccess": "Undid move operation: Moved {{count}} files back to original location",
"undoMoveFailedMove": "Undo failed: Could not move any files back",
"undoMoveFailedNoInfo": "Undo failed: Could not find moved file information",
"undoDeleteNotSupported": "Delete operation cannot be undone: Files have been permanently deleted from server",
"undoTypeNotSupported": "Unsupported undo operation type",
"undoOperationFailed": "Undo operation failed",
"unknownError": "Unknown error",
"confirm": "Confirm",
"find": "Find...",
"replace": "Replace",
"downloadInstead": "Download Instead",
"keyboardShortcuts": "Keyboard Shortcuts",
"searchAndReplace": "Search & Replace",
"editing": "Editing",
"search": "Search",
"findNext": "Find Next",
"findPrevious": "Find Previous",
"save": "Save",
"selectAll": "Select All",
"undo": "Undo",
"redo": "Redo",
"moveLineUp": "Move Line Up",
"moveLineDown": "Move Line Down",
"toggleComment": "Toggle Comment",
"autoComplete": "Auto Complete",
"imageLoadError": "Failed to load image",
"startTyping": "Start typing...",
"unknownSize": "Unknown size",
"fileIsEmpty": "File is empty",
"largeFileWarning": "Large File Warning",
"largeFileWarningDesc": "This file is {{size}} in size, which may cause performance issues when opened as text.",
"fileNotFoundAndRemoved": "File \"{{name}}\" not found and has been removed from recent/pinned files",
"failedToLoadFile": "Failed to load file: {{error}}",
"serverErrorOccurred": "Server error occurred. Please try again later.",
"autoSaveFailed": "Auto-save failed",
"fileAutoSaved": "File auto-saved",
"moveFileFailed": "Failed to move {{name}}",
"moveOperationFailed": "Move operation failed",
"canOnlyCompareFiles": "Can only compare two files",
"comparingFiles": "Comparing files: {{file1}} and {{file2}}",
"dragFailed": "Drag operation failed",
"filePinnedSuccessfully": "File \"{{name}}\" pinned successfully",
"pinFileFailed": "Failed to pin file",
"fileUnpinnedSuccessfully": "File \"{{name}}\" unpinned successfully",
"unpinFileFailed": "Failed to unpin file",
"shortcutAddedSuccessfully": "Folder shortcut \"{{name}}\" added successfully",
"addShortcutFailed": "Failed to add shortcut",
"operationCompletedSuccessfully": "{{operation}} {{count}} items successfully",
"operationCompleted": "{{operation}} {{count}} items",
"downloadFileSuccess": "File {{name}} downloaded successfully",
"downloadFileFailed": "Download failed",
"downloadingFile": "Downloading {{name}}…",
"downloadProgressBytes": "{{transferred}} / {{total}}",
"moveTo": "Move to {{name}}",
"diffCompareWith": "Diff compare with {{name}}",
"dragOutsideToDownload": "Drag outside window to download ({{count}} files)",
"newFolderDefault": "NewFolder",
"newFileDefault": "NewFile.txt",
"successfullyMovedItems": "Successfully moved {{count}} items to {{target}}",
"move": "Move",
"searchInFile": "Search in file (Ctrl+F)",
"showKeyboardShortcuts": "Show keyboard shortcuts",
"decreaseFontSize": "Decrease font size",
"increaseFontSize": "Increase font size",
"startWritingMarkdown": "Start writing your markdown content...",
"loadingFileComparison": "Loading file comparison...",
"reload": "Reload",
"compare": "Compare",
"sideBySide": "Side by Side",
"inline": "Inline",
"fileComparison": "File Comparison: {{file1}} vs {{file2}}",
"fileTooLarge": "File too large: {{error}}",
"sshConnectionFailed": "SSH connection failed. Please check your connection to {{name}} ({{ip}}:{{port}})",
"loadFileFailed": "Failed to load file: {{error}}",
"connecting": "Connecting...",
"connectedSuccessfully": "Connected successfully",
"totpVerificationFailed": "TOTP verification failed",
"warpgateVerificationFailed": "Warpgate authentication failed",
"authenticationFailed": "Authentication failed",
"incorrectPassphrase": "Incorrect passphrase. Please try again.",
"verificationCodePrompt": "Verification code:",
"changePermissions": "Change Permissions",
"currentPermissions": "Current Permissions",
"owner": "Owner",
"group": "Group",
"others": "Others",
"read": "Read",
"write": "Write",
"execute": "Execute",
"permissionsChangedSuccessfully": "Permissions changed successfully",
"failedToChangePermissions": "Failed to change permissions",
"name": "Name",
"sortByName": "Name",
"sortByDate": "Date Modified",
"sortBySize": "Size",
"ascending": "Ascending",
"descending": "Descending",
"root": "Root",
"new": "New",
"sortBy": "Sort By",
"items": "Items",
"selected": "Selected",
"editor": "Editor",
"octal": "Octal",
"storage": "Storage",
"disk": "Disk",
"used": "Used",
"of": "of",
"toggleSidebar": "Toggle Sidebar",
"compactLayout": "Use compact layout",
"comfortableLayout": "Use comfortable layout",
"cannotLoadPdf": "Cannot load PDF",
"pdfLoadError": "There was an error loading this PDF file.",
"loadingPdf": "Loading PDF...",
"loadingPage": "Loading page..."
},
"transfer": {
"copyToHost": "Copy to host…",
"moveToHost": "Move to host…",
@@ -2303,50 +1962,6 @@
"retryTransferError": "Retry failed",
"transferFailedRetryHint": "Partial data was kept on the destination. Retry will resume when the connection is back."
},
"sftpTransfer": {
"title": "SFTP",
"description": "Transfer files between two SSH hosts.",
"sourceServer": "Source Server",
"destinationServer": "Destination Server",
"selectHost": "Select host",
"noHostSelected": "No host selected",
"move": "Move",
"copy": "Copy",
"upload": "Upload",
"itemsSelected": "{{count}} item selected",
"itemsSelected_other": "{{count}} items selected",
"ready": "Ready",
"loadingHosts": "Loading hosts...",
"name": "Name",
"modified": "Modified",
"size": "Size",
"noFiles": "No files",
"open": "Open",
"up": "Up",
"refresh": "Refresh",
"copyToTarget": "Copy to Target Directory",
"rename": "Rename",
"delete": "Delete",
"editPermissions": "Edit Permissions",
"createNewFolder": "Create New Folder",
"deleteSelectedItems": "Delete Selected Items",
"deleteConfirm": "Delete {{count}} selected item?",
"deleteConfirm_other": "Delete {{count}} selected items?",
"cancel": "Cancel",
"save": "Save",
"folderCreated": "Folder created",
"itemRenamed": "Item renamed",
"actionFailed": "Action failed",
"itemsDeleted": "{{count}} item deleted",
"itemsDeleted_other": "{{count}} items deleted",
"deleteFailed": "Delete failed",
"permissionsUpdated": "Permissions updated",
"noRemoteSessionConnected": "No remote session connected",
"failedToListFiles": "Failed to list files",
"failedToRefresh": "Failed to refresh",
"destinationInsideSource": "Destination cannot be inside the selected source path.",
"sameHostNoOp": "Source and destination are the same folder."
},
"tunnels": {
"noSshTunnels": "No SSH Tunnels",
"createFirstTunnelMessage": "You haven't created any SSH tunnels yet. Configure tunnel connections in the Host Manager to get started.",
-385
View File
@@ -715,7 +715,6 @@
"hostManager": "Gasheerbestuurder",
"credentials": "Geloofsbriewe",
"portForwarding": "Aanstuur",
"sftp": "SFTP",
"connections": "Verbindings",
"serial": "Reeks",
"keepSideMenuOpen": "Hou Kieslys Oop",
@@ -767,14 +766,12 @@
"tabRdp": "RDP",
"tabVnc": "VNC",
"tabTunnels": "Tonnels",
"tabFiles": "Lêers",
"tabStats": "Gasheermetrieke",
"tabTelnet": "Telnet",
"tabSharing": "Deling",
"tabAuthentication": "Verifikasie",
"terminal": "Terminaal",
"tunnel": "Tonnel",
"fileManager": "Lêerbestuurder",
"serverStats": "Gasheermetrieke",
"status": {
"available": "Beskikbaar",
@@ -1118,12 +1115,6 @@
"proxmoxResultSkippedNoIp": "{{count}} oorgeslaan (geen IP gevind nie)",
"proxmoxStatsUrlCopied": "Proxmox Statistiek URL na knipbord gekopieer",
"copyProxmoxStatsUrlAction": "Kopieer Proxmox Statistiek URL",
"enableFileManagerMonitor": "Aktiveer Lêerbestuurder",
"enableFileManagerMonitorDesc": "Blaai deur en bestuur lêers op hierdie gasheer via SFTP",
"scpLegacyLabel": "SCP Legacy-modus",
"scpLegacyDesc": "Gebruik ouer lêeroordrag vir bedieners wat nie die SFTP-substelsel ondersteun nie (bv. ingebedde of minimale SSH-bedieners).",
"defaultPathLabel": "Standaardpad",
"fileManagerPathHint": "Die gids om oop te maak wanneer die lêerbestuurder vir hierdie gasheer begin.",
"statusChecksLabel": "Statuskontroles",
"enableStatusChecks": "Aktiveer statuskontroles",
"enableStatusChecksDesc": "Ping hierdie gasheer gereeld om beskikbaarheid te verifieer",
@@ -1278,7 +1269,6 @@
"deletedHost": "Verwyder {{name}}",
"copiedToClipboard": "Na knipbord gekopieer",
"terminalUrlCopied": "Terminaal-URL gekopieer",
"fileManagerUrlCopied": "Lêerbestuurder-URL gekopieer",
"tunnelUrlCopied": "Tonnel-URL gekopieer",
"dockerUrlCopied": "Docker-URL gekopieer",
"hostMetricsUrlCopied": "Gasheermetrieke-URL gekopieer",
@@ -1296,7 +1286,6 @@
"copyAddress": "Kopieer Adres",
"copyLink": "Kopieer skakel",
"copyTerminalUrlAction": "Kopieer terminaal-URL",
"copyFileManagerUrlAction": "Kopieer Lêerbestuurder URL",
"copyTunnelUrlAction": "Kopieer Tonnel URL",
"copyDockerUrlAction": "Kopieer Docker URL",
"copyHostMetricsUrlAction": "Kopieer gasheermetrieke-URL",
@@ -1857,336 +1846,6 @@
"removeFromSplit": "Verwyder van Split"
}
},
"fileManager": {
"noHostSelected": "Geen gasheer gekies nie",
"initializingEditor": "Inisialiseer redigeerder...",
"file": "Lêer",
"folder": "Vouer",
"uploadFile": "Laai lêer op",
"downloadFile": "Laai af",
"extractArchive": "Uittrekselargief",
"extractingArchive": "Onttrek tans {{name}}...",
"archiveExtractedSuccessfully": "{{name}} suksesvol onttrek",
"extractFailed": "Uittreksel het misluk",
"compressFile": "Komprimeer lêer",
"compressFiles": "Kompresseer lêers",
"compressFilesDesc": "Kompresseer {{count}} items in 'n argief",
"archiveName": "Argiefnaam",
"enterArchiveName": "Voer argiefnaam in...",
"compressionFormat": "Kompressieformaat",
"selectedFiles": "Geselekteerde lêers",
"andMoreFiles": "en {{count}} meer...",
"compress": "Kompressie",
"compressingFiles": "Komprimering van {{count}} items in {{name}}...",
"filesCompressedSuccessfully": "{{name}} suksesvol geskep",
"compressFailed": "Kompressie het misluk",
"edit": "Wysig",
"preview": "Voorskou",
"previous": "Vorige",
"next": "Volgende",
"pageXOfY": "Bladsy {{current}} van {{total}}",
"zoomOut": "Uitzoom",
"zoomIn": "Inzoem",
"newFile": "Nuwe Lêer",
"newFolder": "Nuwe Vouer",
"rename": "Hernoem",
"uploading": "Laai tans op...",
"uploadingFile": "Laai tans {{name}} op ...",
"fileName": "Lêernaam",
"folderName": "Vouernaam",
"fileUploadedSuccessfully": "Lêer \"{{name}}\" is suksesvol opgelaai",
"failedToUploadFile": "Kon nie lêer oplaai nie",
"uploadingFolderFiles": "Laai tans {{count}} lêer(s) op...",
"uploadedFolderFiles": "{{count}} lêer(s) suksesvol opgelaai",
"uploadedFolderPartial": "Opgelaaide {{uploaded}} lêer(s), {{failed}} het misluk",
"fileDownloadedSuccessfully": "Lêer \"{{name}}\" is suksesvol afgelaai",
"failedToDownloadFile": "Kon nie lêer aflaai nie",
"fileCreatedSuccessfully": "Lêer \"{{name}}\" suksesvol geskep",
"folderCreatedSuccessfully": "Lêer \"{{name}}\" suksesvol geskep",
"failedToCreateItem": "Kon nie item skep nie",
"operationFailed": "{{operation}} -bewerking het misluk vir {{name}}: {{error}}",
"failedToResolveSymlink": "Kon nie simskakel oplos nie",
"itemsDeletedSuccessfully": "{{count}} items is suksesvol verwyder",
"failedToDeleteItems": "Kon nie items uitvee nie",
"sudoPasswordRequired": "Administrateurwagwoord benodig",
"enterSudoPassword": "Voer die sudo-wagwoord in om hierdie operasie voort te sit",
"sudoPassword": "Sudo-wagwoord",
"sudoOperationFailed": "Sudo-bewerking het misluk",
"sudoAuthFailed": "Sudo-verifikasie het misluk",
"dragFilesToUpload": "Plaas lêers hier om op te laai",
"dropToUploadHere": "Laat val om hier op te laai",
"dropToDownloadHere": "Los om hier af te laai",
"localFiles": "Plaaslik",
"showLocalFiles": "Wys plaaslike lêers (gesplete aansig)",
"hideLocalFiles": "Versteek plaaslike lêers",
"localHome": "Tuisbladmap",
"localPathPlaceholder": "Plaaslike vouerpad",
"localShowHidden": "Wys versteekte lêers",
"localHideHidden": "Versteek versteekte lêers",
"localRevealInFileManager": "Wys in stelsellêerbestuurder",
"localCannotOpenFolder": "Kan nie lêer oopmaak nie",
"localItemCount": "{{count}} items",
"localSelectedCount": "{{count}} gekies",
"back": "Terug",
"forward": "Vorentoe",
"up": "Op",
"kind": "Soortgelyk",
"localUploadingProgress": "Laai tans {{current}} van {{total}} op",
"localDownloadingProgress": "Laai tans {{current}} van {{total}} af",
"localTransferInFlight": "{{count}} gelyktydig",
"localPreparingDownload": "Berei aflaai voor...",
"localTransferCancelled": "Oordrag gekanselleer",
"localNothingToTransfer": "Niks om oor te dra nie",
"localUploadComplete": "Opgelaaide {{count}} lêer(s)",
"localUploadFailed": "Oplaai het misluk",
"localUploadPartial": "Opgelaaide {{done}} lêer(s), {{failed}} het misluk",
"localDownloadComplete": "Afgelaaide {{count}} lêer(s)",
"localDownloadFailed": "Aflaai het misluk",
"localDownloadPartial": "Aflaai van {{done}} lêer(s), {{failed}} het misluk",
"localReplaceExistingPrompt": "{{count}} item(s) bestaan reeds in hierdie vouer. Vervang hulle?",
"localReplace": "Vervang",
"localSkipExisting": "Slaan oor",
"localSkippedExisting": "Oorgeslaan {{count}} bestaande item(s)",
"localUnsafeNamesSkipped": "Oorgeslaan {{count}} item(s) waarvan die name nie op hierdie rekenaar gebruik kan word nie",
"localOpen": "Oop",
"localOpenFolder": "Maak vouer oop",
"localUploadToRemote": "Laai op na bediener",
"localUploadToRemoteMany": "Laai {{count}} items na bediener op",
"localMoveToTrash": "Skuif na asblik",
"localMoveToTrashMany": "Skuif {{count}} items na die asblik",
"localTrashConfirmSingle": "Skuif \"{{name}}\" na die asblik?",
"localTrashConfirmFolder": "Skuif die vouer \"{{name}}\" en al sy inhoud na die asblik?",
"localTrashConfirmMany": "Skuif {{count}} items na die asblik?",
"localTrashed": "Het {{count}} item(s) na die asblik geskuif",
"localTrashFailed": "Kon nie na die asblik skuif nie",
"localRenameFailed": "Hernoem het misluk",
"localCreateFailed": "Kon nie item skep nie",
"goToParentFolder": "Gaan na ouermap",
"resizeSidebar": "Sleep om die sybalk se grootte te verander (dubbelklik om terug te stel)",
"resizeLocalPane": "Sleep om die plaaslike paneel se grootte te verander (dubbelklik om terug te stel)",
"columns": "Kolomme",
"columnsHint": "Regskliek om kolomme te kies",
"emptyFolder": "Hierdie vouer is leeg",
"searchFiles": "Soek lêers...",
"upload": "Oplaai",
"selectHostToStart": "Kies 'n gasheer om lêerbestuur te begin",
"sshRequiredForFileManager": "Lêerbestuurder benodig SSH. Hierdie gasheer het nie SSH geaktiveer nie.",
"failedToConnect": "Kon nie aan SSH koppel nie",
"failedToLoadDirectory": "Kon nie gids laai nie",
"noSSHConnection": "Geen SSH-verbinding beskikbaar nie",
"copy": "Kopieer",
"cut": "Sny",
"paste": "Plak",
"copyPath": "Kopieer Pad",
"copyPaths": "Kopieer paaie",
"delete": "Vee uit",
"properties": "Eienskappe",
"refresh": "Verfris",
"downloadFiles": "Laai {{count}} lêers na die blaaier af",
"copyFiles": "Kopieer {{count}} items",
"cutFiles": "Sny {{count}} items",
"deleteFiles": "Vee {{count}} items uit",
"filesCopiedToClipboard": "{{count}} items gekopieer na knipbord",
"filesCutToClipboard": "{{count}} items is na knipbord gesny",
"pathCopiedToClipboard": "Pad gekopieer na knipbord",
"pathsCopiedToClipboard": "{{count}} paaie gekopieer na knipbord",
"failedToCopyPath": "Kon nie pad na knipbord kopieer nie",
"copyFolderLink": "Kopieer skakel na vouer",
"copyCurrentFolderLink": "Kopieer skakel na huidige vouer",
"folderLinkCopied": "Vouerskakel gekopieer na knipbord",
"failedToCopyFolderLink": "Kon nie vouerskakel kopieer nie",
"movedItems": "Het {{count}} items geskuif",
"failedToDeleteItem": "Kon nie item verwyder nie",
"itemRenamedSuccessfully": "{{type}} suksesvol hernoem",
"failedToRenameItem": "Kon nie item hernoem nie",
"download": "Laai af",
"openExternalEditor": "Maak ekstern oop",
"chooseExternalEditor": "Kies eksterne redigeerder",
"externalEditorSelected": "Eksterne redigeerder gekies",
"externalEditorDesktopOnly": "Eksterne redigeerder is slegs beskikbaar in die rekenaarprogram",
"externalEditorOpened": "Oopgemaak in eksterne redigeerder. As jy dit daar stoor, sal dit veranderinge terug na die bediener oplaai.",
"failedToOpenExternalEditor": "Kon nie eksterne redigeerder oopmaak nie",
"failedToSelectExternalEditor": "Kon nie eksterne redigeerder kies nie",
"permissions": "Toestemmings",
"size": "Grootte",
"modified": "Gewysig",
"path": "Pad",
"confirmDelete": "Is jy seker jy wil {{name}} verwyder?",
"permissionDenied": "Toestemming geweier",
"serverError": "Bedienerfout",
"fileSavedSuccessfully": "Lêer suksesvol gestoor",
"failedToSaveFile": "Kon nie lêer stoor nie",
"confirmDeleteSingleItem": "Is jy seker jy wil \"{{name}} \" permanent verwyder?",
"confirmDeleteMultipleItems": "Is jy seker jy wil {{count}} items permanent verwyder?",
"confirmDeleteMultipleItemsWithFolders": "Is jy seker jy wil {{count}} items permanent verwyder? Dit sluit gidse en hul inhoud in.",
"confirmDeleteFolder": "Is jy seker jy wil die vouer \"{{name}}\" en al sy inhoud permanent verwyder?",
"permanentDeleteWarning": "Hierdie aksie kan nie ongedaan gemaak word nie. Die item(s) sal permanent van die bediener verwyder word.",
"moveToTrashWarning": "Die item(s) sal na die Termix-asblik geskuif word en kan herstel word voordat die bewaringstydperk verstryk.",
"itemsMovedToTrash": "{{count}} item(s) na die asblik geskuif",
"trash": "Asblik",
"trashDescription": "Geskrapte items word vir {{days}} dag(e) op hierdie gasheer behou.",
"trashLoadFailed": "Kon nie die asblik laai nie",
"trashEmpty": "Asblik is leeg",
"trashRestore": "Herstel",
"trashRestored": "Herstel {{name}}",
"trashRestoreFailed": "Kon nie item herstel nie. Die oorspronklike pad bestaan dalk reeds.",
"trashDeletePermanently": "Vee permanent uit",
"trashDeleteConfirm": "Wil jy {{name}}permanent verwyder? Dit kan nie ongedaan gemaak word nie.",
"trashPermanentlyDeleted": "Permanent verwyder {{name}}",
"trashDeleteFailed": "Kon nie item permanent verwyder nie",
"trashEmptyAction": "Maak asblik leeg",
"trashEmptyConfirm": "Wil jy elke item in die asblik permanent verwyder? Dit kan nie ongedaan gemaak word nie.",
"trashEmptied": "Asblik leeggemaak",
"trashRetention": "Outomatiese skoonmaak na (dae)",
"trashRetentionSaved": "Asblikbewaring opgedateer",
"trashRetentionFailed": "Kon nie asblikbehoud opdateer nie",
"trashUnavailableConfirm": "Hierdie item kan nie na die asblik geskuif word nie, gewoonlik omdat dit op 'n ander lêerstelsel is of verhoogde toestemmings benodig. Verwyder dit eerder permanent?",
"recent": "Onlangs",
"pinned": "Vasgepen",
"folderShortcuts": "Vouerkortpaaie",
"failedToReconnectSSH": "Kon nie SSH-sessie herkoppel nie",
"openTerminalHere": "Maak Terminaal Hier Oop",
"run": "Hardloop",
"openTerminalInFolder": "Maak Terminaal in Hierdie Vouer Oop",
"openTerminalInFileLocation": "Maak Terminaal oop by Lêerligging",
"runningFile": "Hardloop - {{file}}",
"onlyRunExecutableFiles": "Kan slegs uitvoerbare lêers uitvoer",
"directories": "Gidse",
"removedFromRecentFiles": "\"{{name}}\" is uit onlangse lêers verwyder",
"removeFailed": "Verwydering het misluk",
"unpinnedSuccessfully": "\"{{name}}\" suksesvol ontspeld",
"unpinFailed": "Ontspeld het misluk",
"removedShortcut": "Kortpad \"{{name}} \" verwyder",
"removeShortcutFailed": "Verwydering van kortpad het misluk",
"clearedAllRecentFiles": "Alle onlangse lêers is uitgevee",
"clearFailed": "Vee uit misluk",
"removeFromRecentFiles": "Verwyder uit onlangse lêers",
"clearAllRecentFiles": "Vee alle onlangse lêers uit",
"unpinFile": "Ontspeld lêer",
"removeShortcut": "Verwyder kortpad",
"pinFile": "Speld lêer vas",
"addToShortcuts": "Voeg by kortpaaie",
"pasteFailed": "Plak het misluk",
"noUndoableActions": "Geen ongedaanbare aksies nie",
"undoCopySuccess": "Kopieerbewerking ongedaan gemaak: Gekopieerde lêers is verwyder {{count}}",
"undoCopyFailedDelete": "Ongedaan maak het misluk: Kon geen gekopieerde lêers verwyder nie",
"undoCopyFailedNoInfo": "Ongedaan maak het misluk: Kon nie gekopieerde lêerinligting vind nie",
"undoMoveSuccess": "Ongedaan gemaak met skuifbewerking: {{count}} lêers teruggeskuif na oorspronklike ligging",
"undoMoveFailedMove": "Ongedaan maak het misluk: Kon geen lêers terugskuif nie",
"undoMoveFailedNoInfo": "Ongedaan maak het misluk: Kon nie inligting oor die verskuifde lêer vind nie",
"undoDeleteNotSupported": "Verwyderingsbewerking kan nie ongedaan gemaak word nie: Lêers is permanent van die bediener verwyder",
"undoTypeNotSupported": "Nie-ondersteunde ongedaanmaak-bewerkingtipe",
"undoOperationFailed": "Ongedaanmaak-bewerking het misluk",
"unknownError": "Onbekende fout",
"confirm": "Bevestig",
"find": "Vind...",
"replace": "Vervang",
"downloadInstead": "Laai eerder af",
"keyboardShortcuts": "Sleutelbordkortpaaie",
"searchAndReplace": "Soek en vervang",
"editing": "Redigering",
"search": "Soek",
"findNext": "Vind Volgende",
"findPrevious": "Vind Vorige",
"save": "Stoor",
"selectAll": "Kies Alles",
"undo": "Ongedaan maak",
"redo": "Herdoen",
"moveLineUp": "Skuif lyn op",
"moveLineDown": "Skuif lyn af",
"toggleComment": "Wissel kommentaar",
"autoComplete": "Outomatiese Voltooiing",
"imageLoadError": "Kon nie beeld laai nie",
"startTyping": "Begin tik...",
"unknownSize": "Onbekende grootte",
"fileIsEmpty": "Lêer is leeg",
"largeFileWarning": "Waarskuwing oor groot lêers",
"largeFileWarningDesc": "Hierdie lêer is {{size}} groot, wat werkverrigtingsprobleme kan veroorsaak wanneer dit as teks oopgemaak word.",
"fileNotFoundAndRemoved": "Lêer \"{{name}}\" nie gevind nie en is verwyder uit onlangse/vasgespelde lêers",
"failedToLoadFile": "Kon nie lêer laai nie: {{error}}",
"serverErrorOccurred": "Bedienerfout het voorgekom. Probeer asseblief later weer.",
"autoSaveFailed": "Outomatiese stoor het misluk",
"fileAutoSaved": "Lêer outomaties gestoor",
"moveFileFailed": "Kon nie {{name}} skuif nie",
"moveOperationFailed": "Skuifbewerking het misluk",
"canOnlyCompareFiles": "Kan slegs twee lêers vergelyk",
"comparingFiles": "Vergelyk lêers: {{file1}} en {{file2}}",
"dragFailed": "Sleepbewerking het misluk",
"filePinnedSuccessfully": "Lêer \"{{name}}\" suksesvol vasgepen",
"pinFileFailed": "Kon nie lêer vaspen nie",
"fileUnpinnedSuccessfully": "Lêer \"{{name}}\" suksesvol ontspeld",
"unpinFileFailed": "Kon nie lêer ontspeld nie",
"shortcutAddedSuccessfully": "Vouerkortpad \"{{name}}\" suksesvol bygevoeg",
"addShortcutFailed": "Kon nie kortpad byvoeg nie",
"operationCompletedSuccessfully": "{{operation}} {{count}} items suksesvol",
"operationCompleted": "{{operation}} {{count}} items",
"downloadFileSuccess": "Lêer {{name}} suksesvol afgelaai",
"downloadFileFailed": "Aflaai het misluk",
"downloadingFile": "Laai tans af {{name}}…",
"downloadProgressBytes": "{{transferred}} / {{total}}",
"moveTo": "Skuif na {{name}}",
"diffCompareWith": "Verskil vergelyking met {{name}}",
"dragOutsideToDownload": "Sleep buite die venster om af te laai ({{count}} lêers)",
"newFolderDefault": "NuweVouer",
"newFileDefault": "NuweLêer.txt",
"successfullyMovedItems": "Suksesvol {{count}} items na {{target}} geskuif",
"move": "Beweeg",
"searchInFile": "Soek in lêer (Ctrl+F)",
"showKeyboardShortcuts": "Wys sleutelbordkortpaaie",
"decreaseFontSize": "Verklein lettergrootte",
"increaseFontSize": "Vergroot lettergrootte",
"startWritingMarkdown": "Begin om jou afslaginhoud te skryf...",
"loadingFileComparison": "Laai lêervergelyking...",
"reload": "Herlaai",
"compare": "Vergelyk",
"sideBySide": "Sy aan Sy",
"inline": "Inlyn",
"fileComparison": "Lêervergelyking: {{file1}} vs {{file2}}",
"fileTooLarge": "Lêer te groot: {{error}}",
"sshConnectionFailed": "SSH-verbinding het misluk. Kontroleer asseblief jou verbinding met {{name}} ({{ip}}:{{port}})",
"loadFileFailed": "Kon nie lêer laai nie: {{error}}",
"connecting": "Verbind...",
"connectedSuccessfully": "Suksesvol gekoppel",
"totpVerificationFailed": "TOTP-verifikasie het misluk",
"warpgateVerificationFailed": "Warpgate-verifikasie het misluk",
"authenticationFailed": "Verifikasie het misluk",
"incorrectPassphrase": "Verkeerde wagwoordfrase. Probeer asseblief weer.",
"verificationCodePrompt": "Verifikasiekode:",
"changePermissions": "Verander Toestemmings",
"currentPermissions": "Huidige Toestemmings",
"owner": "Eienaar",
"group": "Groep",
"others": "Ander",
"read": "Lees",
"write": "Skryf",
"execute": "Voer uit",
"permissionsChangedSuccessfully": "Toestemmings suksesvol verander",
"failedToChangePermissions": "Kon nie toestemmings verander nie",
"name": "Naam",
"sortByName": "Naam",
"sortByDate": "Datum Gewysig",
"sortBySize": "Grootte",
"ascending": "Stygend",
"descending": "Dalend",
"root": "Wortel",
"new": "Nuut",
"sortBy": "Sorteer volgens",
"items": "Items",
"selected": "Gekies",
"editor": "Redakteur",
"octal": "Oktaal",
"storage": "Berging",
"disk": "Skyf",
"used": "Gebruik",
"of": "van",
"toggleSidebar": "Wissel sybalk",
"compactLayout": "Gebruik kompakte uitleg",
"comfortableLayout": "Gebruik gemaklike uitleg",
"cannotLoadPdf": "Kan nie PDF laai nie",
"pdfLoadError": "Daar was 'n fout tydens die laai van hierdie PDF-lêer.",
"loadingPdf": "Laai PDF...",
"loadingPage": "Laai bladsy..."
},
"transfer": {
"copyToHost": "Kopieer na gasheer…",
"moveToHost": "Skuif na gasheer…",
@@ -2292,50 +1951,6 @@
"retryTransferError": "Herprobeer het misluk",
"transferFailedRetryHint": "Gedeeltelike data is op die bestemming gehou. Herprobeer sal hervat word wanneer die verbinding terug is."
},
"sftpTransfer": {
"title": "SFTP",
"description": "Dra lêers oor tussen twee SSH-gashere.",
"sourceServer": "Bronbediener",
"destinationServer": "Bestemmingsbediener",
"selectHost": "Kies gasheer",
"noHostSelected": "Geen gasheer gekies nie",
"move": "Beweeg",
"copy": "Kopieer",
"upload": "Oplaai",
"itemsSelected": "{{count}} item gekies",
"itemsSelected_other": "{{count}} items gekies",
"ready": "Gereed",
"loadingHosts": "Laai gashere...",
"name": "Naam",
"modified": "Gewysig",
"size": "Grootte",
"noFiles": "Geen lêers nie",
"open": "Oop",
"up": "Op",
"refresh": "Verfris",
"copyToTarget": "Kopieer na teikengids",
"rename": "Hernoem",
"delete": "Vee uit",
"editPermissions": "Wysig Toestemmings",
"createNewFolder": "Skep Nuwe Vouer",
"deleteSelectedItems": "Vee geselekteerde items uit",
"deleteConfirm": "Vee {{count}} gekose item uit?",
"deleteConfirm_other": "Vee {{count}} gekose items uit?",
"cancel": "Kanselleer",
"save": "Stoor",
"folderCreated": "Vouer geskep",
"itemRenamed": "Item hernoem",
"actionFailed": "Aksie het misluk",
"itemsDeleted": "{{count}} item is verwyder",
"itemsDeleted_other": "{{count}} items is uitgevee",
"deleteFailed": "Verwydering het misluk",
"permissionsUpdated": "Toestemmings opgedateer",
"noRemoteSessionConnected": "Geen afstandsessie gekoppel nie",
"failedToListFiles": "Kon nie lêers lys nie",
"failedToRefresh": "Kon nie herlaai nie",
"destinationInsideSource": "Bestemming kan nie binne die gekose bronpad wees nie.",
"sameHostNoOp": "Bron en bestemming is dieselfde lêergids."
},
"tunnels": {
"noSshTunnels": "Geen SSH-tonnels nie",
"createFirstTunnelMessage": "Jy het nog geen SSH-tonnels geskep nie. Konfigureer tonnelverbindings in die Gasheerbestuurder om te begin.",
-385
View File
@@ -715,7 +715,6 @@
"hostManager": "مدير المضيفين",
"credentials": "بيانات الاعتماد",
"portForwarding": "إعادة التوجيه",
"sftp": "SFTP",
"connections": "الاتصالات",
"serial": "تسلسلي",
"keepSideMenuOpen": "أبقِ قائمة الطعام مفتوحة",
@@ -767,14 +766,12 @@
"tabRdp": "RDP",
"tabVnc": "VNC",
"tabTunnels": "الأنفاق",
"tabFiles": "الملفات",
"tabStats": "مقاييس المضيف",
"tabTelnet": "Telnet",
"tabSharing": "المشاركة",
"tabAuthentication": "المصادقة",
"terminal": "الطرفية",
"tunnel": "النفق",
"fileManager": "مدير الملفات",
"serverStats": "مقاييس المضيف",
"status": {
"available": "متاح",
@@ -1118,12 +1115,6 @@
"proxmoxResultSkippedNoIp": "تم تخطي {{count}} (لم يتم العثور على عنوان IP)",
"proxmoxStatsUrlCopied": "تم نسخ رابط إحصائيات Proxmox إلى الحافظة",
"copyProxmoxStatsUrlAction": "انسخ رابط إحصائيات Proxmox",
"enableFileManagerMonitor": "تمكين مدير الملفات",
"enableFileManagerMonitorDesc": "استعراض وإدارة الملفات على هذا المضيف عبر SFTP",
"scpLegacyLabel": "وضع SCP القديم",
"scpLegacyDesc": "استخدم وضع النقل القديم للملفات للخوادم التي لا تدعم النظام الفرعي SFTP (مثل خوادم SSH المدمجة أو ذات الإمكانيات المحدودة).",
"defaultPathLabel": "المسار الافتراضي",
"fileManagerPathHint": "الدليل الذي سيتم فتحه عند تشغيل مدير الملفات لهذا المضيف.",
"statusChecksLabel": "فحوصات الحالة",
"enableStatusChecks": "تمكين فحوصات الحالة",
"enableStatusChecksDesc": "إرسال ping دوريًا لهذا المضيف للتحقق من توفره",
@@ -1278,7 +1269,6 @@
"deletedHost": "تم حذف {{name}}",
"copiedToClipboard": "تم النسخ إلى الحافظة",
"terminalUrlCopied": "تم نسخ رابط الطرفية",
"fileManagerUrlCopied": "تم نسخ رابط مدير الملفات",
"tunnelUrlCopied": "تم نسخ رابط النفق",
"dockerUrlCopied": "تم نسخ رابط Docker",
"hostMetricsUrlCopied": "تم نسخ رابط مقاييس المضيف",
@@ -1296,7 +1286,6 @@
"copyAddress": "نسخ العنوان",
"copyLink": "نسخ الرابط",
"copyTerminalUrlAction": "نسخ رابط الطرفية",
"copyFileManagerUrlAction": "نسخ رابط مدير الملفات",
"copyTunnelUrlAction": "نسخ رابط النفق",
"copyDockerUrlAction": "نسخ رابط Docker",
"copyHostMetricsUrlAction": "نسخ رابط مقاييس المضيف",
@@ -1857,336 +1846,6 @@
"removeFromSplit": "إزالة من التقسيم"
}
},
"fileManager": {
"noHostSelected": "لم يتم اختيار مضيف",
"initializingEditor": "جارٍ تهيئة المحرر...",
"file": "ملف",
"folder": "مجلد",
"uploadFile": "رفع ملف",
"downloadFile": "تنزيل",
"extractArchive": "استخراج الأرشيف",
"extractingArchive": "جارٍ استخراج {{name}}...",
"archiveExtractedSuccessfully": "تم استخراج {{name}} بنجاح",
"extractFailed": "فشل الاستخراج",
"compressFile": "ضغط ملف",
"compressFiles": "ضغط ملفات",
"compressFilesDesc": "ضغط {{count}} عنصر في أرشيف",
"archiveName": "اسم الأرشيف",
"enterArchiveName": "أدخل اسم الأرشيف...",
"compressionFormat": "صيغة الضغط",
"selectedFiles": "الملفات المحددة",
"andMoreFiles": "و {{count}} المزيد...",
"compress": "ضغط",
"compressingFiles": "جارٍ ضغط {{count}} عنصر في {{name}}...",
"filesCompressedSuccessfully": "تم إنشاء {{name}} بنجاح",
"compressFailed": "فشل الضغط",
"edit": "تحرير",
"preview": "معاينة",
"previous": "السابق",
"next": "التالي",
"pageXOfY": "صفحة {{current}} من {{total}}",
"zoomOut": "تصغير",
"zoomIn": "تكبير",
"newFile": "ملف جديد",
"newFolder": "مجلد جديد",
"rename": "إعادة تسمية",
"uploading": "جارٍ الرفع...",
"uploadingFile": "جارٍ رفع {{name}}...",
"fileName": "اسم الملف",
"folderName": "اسم المجلد",
"fileUploadedSuccessfully": "تم رفع الملف \"{{name}}\" بنجاح",
"failedToUploadFile": "فشل رفع الملف",
"uploadingFolderFiles": "جارٍ تحميل {{count}} ملف(ملفات)...",
"uploadedFolderFiles": "تم تحميل {{count}} ملف بنجاح",
"uploadedFolderPartial": "تم تحميل {{uploaded}} ملفًا، {{failed}} فشل",
"fileDownloadedSuccessfully": "تم تنزيل الملف \"{{name}}\" بنجاح",
"failedToDownloadFile": "فشل تنزيل الملف",
"fileCreatedSuccessfully": "تم إنشاء الملف \"{{name}}\" بنجاح",
"folderCreatedSuccessfully": "تم إنشاء المجلد \"{{name}}\" بنجاح",
"failedToCreateItem": "فشل إنشاء العنصر",
"operationFailed": "فشلت عملية {{operation}} لـ {{name}}: {{error}}",
"failedToResolveSymlink": "فشل حل الرابط الرمزي",
"itemsDeletedSuccessfully": "تم حذف {{count}} عناصر بنجاح",
"failedToDeleteItems": "فشل حذف العناصر",
"sudoPasswordRequired": "كلمة مرور المسؤول مطلوبة",
"enterSudoPassword": "أدخل كلمة مرور sudo لمتابعة هذه العملية",
"sudoPassword": "كلمة مرور sudo",
"sudoOperationFailed": "فشلت عملية sudo",
"sudoAuthFailed": "فشلت مصادقة sudo",
"dragFilesToUpload": "أفلت الملفات هنا لرفعها",
"dropToUploadHere": "قم بإسقاط الملف هنا للتحميل",
"dropToDownloadHere": "قم بالتنزيل من هنا",
"localFiles": "محلي",
"showLocalFiles": "عرض الملفات المحلية (عرض مقسم)",
"hideLocalFiles": "إخفاء الملفات المحلية",
"localHome": "المجلد الرئيسي",
"localPathPlaceholder": "مسار المجلد المحلي",
"localShowHidden": "إظهار الملفات المخفية",
"localHideHidden": "إخفاء الملفات المخفية",
"localRevealInFileManager": "إظهار في مدير ملفات النظام",
"localCannotOpenFolder": "لا يمكن فتح المجلد",
"localItemCount": "{{count}} عناصر",
"localSelectedCount": "{{count}} تم الاختيار",
"back": "خلف",
"forward": "إلى الأمام",
"up": "أعلى",
"kind": "عطوف",
"localUploadingProgress": "جارٍ تحميل {{current}} من {{total}}",
"localDownloadingProgress": "جارٍ تنزيل {{current}} من {{total}}",
"localTransferInFlight": "{{count}} دفعة واحدة",
"localPreparingDownload": "جارٍ تحضير التنزيل...",
"localTransferCancelled": "تم إلغاء التحويل",
"localNothingToTransfer": "لا يوجد شيء لنقله",
"localUploadComplete": "تم تحميل {{count}} ملف(ملفات)",
"localUploadFailed": "فشل التحميل",
"localUploadPartial": "تم تحميل {{done}} ملفًا، {{failed}} فشل",
"localDownloadComplete": "تم تنزيل {{count}} ملفًا(ملفات)",
"localDownloadFailed": "فشل التنزيل",
"localDownloadPartial": "تم تنزيل {{done}} ملفًا، {{failed}} فشل",
"localReplaceExistingPrompt": "{{count}} العنصر (العناصر) موجودة بالفعل في هذا المجلد. هل تريد استبدالها؟",
"localReplace": "يستبدل",
"localSkipExisting": "يتخطى",
"localSkippedExisting": "تم تخطي {{count}} عنصر (عناصر) موجودة",
"localUnsafeNamesSkipped": "تم تخطي {{count}} عنصرًا لا يمكن استخدام أسمائها على هذا الكمبيوتر",
"localOpen": "يفتح",
"localOpenFolder": "افتح المجلد",
"localUploadToRemote": "التحميل إلى الخادم",
"localUploadToRemoteMany": "قم بتحميل {{count}} عنصرًا إلى الخادم",
"localMoveToTrash": "انقل إلى سلة المهملات",
"localMoveToTrashMany": "انقل {{count}} عنصرًا إلى سلة المهملات",
"localTrashConfirmSingle": "هل تريد نقل \"{{name}}\" إلى سلة المهملات؟",
"localTrashConfirmFolder": "هل تريد نقل المجلد \"{{name}}\" وجميع محتوياته إلى سلة المهملات؟",
"localTrashConfirmMany": "هل تريد نقل العناصر {{count}} إلى سلة المهملات؟",
"localTrashed": "تم نقل {{count}} عنصرًا إلى سلة المهملات",
"localTrashFailed": "لم يتمكن من الانتقال إلى سلة المهملات",
"localRenameFailed": "فشلت عملية إعادة التسمية",
"localCreateFailed": "تعذر إنشاء العنصر",
"goToParentFolder": "انتقل إلى المجلد الرئيسي",
"resizeSidebar": "اسحب لتغيير حجم الشريط الجانبي (انقر نقراً مزدوجاً لإعادة التعيين)",
"resizeLocalPane": "اسحب لتغيير حجم اللوحة المحلية (انقر نقراً مزدوجاً لإعادة التعيين)",
"columns": "الأعمدة",
"columnsHint": "انقر بزر الماوس الأيمن لاختيار الأعمدة",
"emptyFolder": "هذا المجلد فارغ",
"searchFiles": "بحث في الملفات...",
"upload": "رفع",
"selectHostToStart": "حدد مضيفًا لبدء إدارة الملفات",
"sshRequiredForFileManager": "يتطلب مدير الملفات SSH. هذا المضيف لم يتم تمكين SSH عليه.",
"failedToConnect": "فشل الاتصال بـ SSH",
"failedToLoadDirectory": "فشل تحميل المجلد",
"noSSHConnection": "لا يوجد اتصال SSH متاح",
"copy": "نسخ",
"cut": "قص",
"paste": "لصق",
"copyPath": "نسخ المسار",
"copyPaths": "نسخ المسارات",
"delete": "حذف",
"properties": "خصائص",
"refresh": "تحديث",
"downloadFiles": "تنزيل {{count}} ملفات إلى المتصفح",
"copyFiles": "نسخ {{count}} عناصر",
"cutFiles": "قص {{count}} عناصر",
"deleteFiles": "حذف {{count}} عناصر",
"filesCopiedToClipboard": "تم نسخ {{count}} عناصر إلى الحافظة",
"filesCutToClipboard": "تم قص {{count}} عناصر إلى الحافظة",
"pathCopiedToClipboard": "تم نسخ المسار إلى الحافظة",
"pathsCopiedToClipboard": "تم نسخ {{count}} مسارات إلى الحافظة",
"failedToCopyPath": "فشل نسخ المسار إلى الحافظة",
"copyFolderLink": "نسخ رابط المجلد",
"copyCurrentFolderLink": "نسخ رابط المجلد الحالي",
"folderLinkCopied": "تم نسخ رابط المجلد إلى الحافظة",
"failedToCopyFolderLink": "فشل نسخ رابط المجلد",
"movedItems": "تم نقل {{count}} عناصر",
"failedToDeleteItem": "فشل حذف العنصر",
"itemRenamedSuccessfully": "تمت إعادة تسمية {{type}} بنجاح",
"failedToRenameItem": "فشلت إعادة تسمية العنصر",
"download": "تنزيل",
"openExternalEditor": "فتح خارجيًا",
"chooseExternalEditor": "اختيار محرر خارجي",
"externalEditorSelected": "تم اختيار المحرر الخارجي",
"externalEditorDesktopOnly": "المحرر الخارجي متاح فقط في تطبيق سطح المكتب",
"externalEditorOpened": "تم الفتح في المحرر الخارجي. سيؤدي الحفظ هناك إلى رفع التغييرات إلى الخادم.",
"failedToOpenExternalEditor": "فشل فتح المحرر الخارجي",
"failedToSelectExternalEditor": "فشل اختيار المحرر الخارجي",
"permissions": "الصلاحيات",
"size": "الحجم",
"modified": "تاريخ التعديل",
"path": "المسار",
"confirmDelete": "هل أنت متأكد أنك تريد حذف {{name}}؟",
"permissionDenied": "تم رفض الإذن",
"serverError": "خطأ في الخادم",
"fileSavedSuccessfully": "تم حفظ الملف بنجاح",
"failedToSaveFile": "فشل حفظ الملف",
"confirmDeleteSingleItem": "هل أنت متأكد أنك تريد حذف \"{{name}}\" نهائيًا؟",
"confirmDeleteMultipleItems": "هل أنت متأكد أنك تريد حذف {{count}} عناصر نهائيًا؟",
"confirmDeleteMultipleItemsWithFolders": "هل أنت متأكد أنك تريد حذف {{count}} عناصر نهائيًا؟ يتضمن ذلك المجلدات ومحتوياتها.",
"confirmDeleteFolder": "هل أنت متأكد أنك تريد حذف المجلد \"{{name}}\" وجميع محتوياته نهائيًا؟",
"permanentDeleteWarning": "لا يمكن التراجع عن هذا الإجراء. سيتم حذف العنصر (العناصر) نهائيًا من الخادم.",
"moveToTrashWarning": "سيتم نقل العنصر (العناصر) إلى سلة مهملات Termix ويمكن استعادته قبل انتهاء فترة الاحتفاظ.",
"itemsMovedToTrash": "تم نقل العنصر (العناصر) إلى سلة المهملات {{count}}",
"trash": "نفاية",
"trashDescription": "يتم الاحتفاظ بالعناصر المحذوفة لمدة {{days}} يوم (أيام) على هذا المضيف.",
"trashLoadFailed": "فشل تحميل سلة المهملات",
"trashEmpty": "سلة المهملات فارغة",
"trashRestore": "يعيد",
"trashRestored": "تمت الاستعادة {{name}}",
"trashRestoreFailed": "فشلت عملية استعادة العنصر. قد يكون المسار الأصلي موجودًا بالفعل.",
"trashDeletePermanently": "حذف نهائياً",
"trashDeleteConfirm": "حذف {{name}}نهائياً؟ لا يمكن التراجع عن هذا الإجراء.",
"trashPermanentlyDeleted": "تم الحذف نهائياً {{name}}",
"trashDeleteFailed": "فشل حذف العنصر نهائياً",
"trashEmptyAction": "قم بإفراغ سلة المهملات",
"trashEmptyConfirm": "هل تريد حذف جميع العناصر الموجودة في سلة المهملات نهائياً؟ لا يمكن التراجع عن هذا الإجراء.",
"trashEmptied": "تم إفراغ سلة المهملات",
"trashRetention": "التنظيف التلقائي بعد (أيام)",
"trashRetentionSaved": "تحديث نظام الاحتفاظ بالنفايات",
"trashRetentionFailed": "فشل تحديث الاحتفاظ بالنفايات",
"trashUnavailableConfirm": "لا يمكن نقل هذا العنصر إلى سلة المهملات، عادةً لأنه موجود على نظام ملفات آخر أو يتطلب صلاحيات إدارية. هل تريد حذفه نهائياً بدلاً من ذلك؟",
"recent": "الأخيرة",
"pinned": "المثبتة",
"folderShortcuts": "اختصارات المجلدات",
"failedToReconnectSSH": "فشل إعادة الاتصال بجلسة SSH",
"openTerminalHere": "افتح الطرفية هنا",
"run": "تشغيل",
"openTerminalInFolder": "افتح الطرفية في هذا المجلد",
"openTerminalInFileLocation": "افتح الطرفية في موقع الملف",
"runningFile": "قيد التشغيل - {{file}}",
"onlyRunExecutableFiles": "يمكن تشغيل الملفات التنفيذية فقط",
"directories": "المجلدات",
"removedFromRecentFiles": "تمت إزالة \"{{name}}\" من الملفات الحديثة",
"removeFailed": "فشلت الإزالة",
"unpinnedSuccessfully": "تم إلغاء تثبيت \"{{name}}\" بنجاح",
"unpinFailed": "فشل إلغاء التثبيت",
"removedShortcut": "تمت إزالة الاختصار \"{{name}}\"",
"removeShortcutFailed": "فشلت إزالة الاختصار",
"clearedAllRecentFiles": "تم مسح جميع الملفات الحديثة",
"clearFailed": "فشل المسح",
"removeFromRecentFiles": "إزالة من الملفات الحديثة",
"clearAllRecentFiles": "مسح جميع الملفات الحديثة",
"unpinFile": "إلغاء تثبيت الملف",
"removeShortcut": "إزالة الاختصار",
"pinFile": "تثبيت الملف",
"addToShortcuts": "إضافة إلى الاختصارات",
"pasteFailed": "فشل اللصق",
"noUndoableActions": "لا توجد إجراءات يمكن التراجع عنها",
"undoCopySuccess": "تم التراجع عن عملية النسخ: حُذف {{count}} ملفًا منسوخًا",
"undoCopyFailedDelete": "فشل التراجع: تعذر حذف أي ملفات منسوخة",
"undoCopyFailedNoInfo": "فشل التراجع: تعذر العثور على معلومات الملفات المنسوخة",
"undoMoveSuccess": "تم التراجع عن عملية النقل: تم نقل {{count}} ملفًا إلى الموقع الأصلي",
"undoMoveFailedMove": "فشل التراجع: تعذر نقل أي ملفات للخلف",
"undoMoveFailedNoInfo": "فشل التراجع: تعذر العثور على معلومات الملفات المنقولة",
"undoDeleteNotSupported": "لا يمكن التراجع عن عملية الحذف: تم حذف الملفات نهائيًا من الخادم",
"undoTypeNotSupported": "نوع عملية التراجع غير مدعوم",
"undoOperationFailed": "فشلت عملية التراجع",
"unknownError": "خطأ غير معروف",
"confirm": "تأكيد",
"find": "بحث...",
"replace": "استبدال",
"downloadInstead": "تنزيل بدلاً من ذلك",
"keyboardShortcuts": "اختصارات لوحة المفاتيح",
"searchAndReplace": "بحث واستبدال",
"editing": "تعديل",
"search": "بحث",
"findNext": "بحث عن التالي",
"findPrevious": "بحث عن السابق",
"save": "حفظ",
"selectAll": "تحديد الكل",
"undo": "تراجع",
"redo": "إعادة",
"moveLineUp": "نقل السطر لأعلى",
"moveLineDown": "نقل السطر لأسفل",
"toggleComment": "تبديل التعليق",
"autoComplete": "إكمال تلقائي",
"imageLoadError": "فشل تحميل الصورة",
"startTyping": "ابدأ الكتابة...",
"unknownSize": "حجم غير معروف",
"fileIsEmpty": "الملف فارغ",
"largeFileWarning": "تحذير ملف كبير",
"largeFileWarningDesc": "هذا الملف بحجم {{size}}، مما قد يسبب مشكلات في الأداء عند فتحه كنص.",
"fileNotFoundAndRemoved": "الملف \"{{name}}\" غير موجود وتمت إزالته من الملفات الأخيرة/المثبتة",
"failedToLoadFile": "فشل تحميل الملف: {{error}}",
"serverErrorOccurred": "حدث خطأ في الخادم. يرجى المحاولة لاحقًا.",
"autoSaveFailed": "فشل الحفظ التلقائي",
"fileAutoSaved": "تم حفظ الملف تلقائيًا",
"moveFileFailed": "فشل نقل {{name}}",
"moveOperationFailed": "فشلت عملية النقل",
"canOnlyCompareFiles": "يمكن مقارنة ملفين فقط",
"comparingFiles": "مقارنة الملفات: {{file1}} و {{file2}}",
"dragFailed": "فشلت عملية السحب",
"filePinnedSuccessfully": "تم تثبيت الملف \"{{name}}\" بنجاح",
"pinFileFailed": "فشل تثبيت الملف",
"fileUnpinnedSuccessfully": "تم إلغاء تثبيت الملف \"{{name}}\" بنجاح",
"unpinFileFailed": "فشل إلغاء تثبيت الملف",
"shortcutAddedSuccessfully": "تمت إضافة اختصار المجلد \"{{name}}\" بنجاح",
"addShortcutFailed": "فشلت إضافة الاختصار",
"operationCompletedSuccessfully": "تمت {{operation}} {{count}} عناصر بنجاح",
"operationCompleted": "{{operation}} {{count}} عناصر",
"downloadFileSuccess": "تم تنزيل الملف {{name}} بنجاح",
"downloadFileFailed": "فشل التنزيل",
"downloadingFile": "جارٍ التنزيل {{name}}…",
"downloadProgressBytes": "{{transferred}} / {{total}}",
"moveTo": "نقل إلى {{name}}",
"diffCompareWith": "مقارنة الاختلافات مع {{name}}",
"dragOutsideToDownload": "اسحب خارج النافذة للتنزيل ({{count}} ملفات)",
"newFolderDefault": "مجلد جديد",
"newFileDefault": "ملف جديد.txt",
"successfullyMovedItems": "تم نقل {{count}} عناصر بنجاح إلى {{target}}",
"move": "نقل",
"searchInFile": "بحث في الملف (Ctrl+F)",
"showKeyboardShortcuts": "إظهار اختصارات لوحة المفاتيح",
"decreaseFontSize": "تصغير حجم الخط",
"increaseFontSize": "تكبير حجم الخط",
"startWritingMarkdown": "ابدأ كتابة محتوى Markdown...",
"loadingFileComparison": "جارٍ تحميل مقارنة الملفات...",
"reload": "إعادة تحميل",
"compare": "مقارنة",
"sideBySide": "جنباً إلى جنب",
"inline": "مضمن",
"fileComparison": "مقارنة الملفات: {{file1}} مقابل {{file2}}",
"fileTooLarge": "الملف كبير جداً: {{error}}",
"sshConnectionFailed": "فشل اتصال SSH. يرجى التحقق من الاتصال بـ {{name}} ({{ip}}:{{port}})",
"loadFileFailed": "فشل تحميل الملف: {{error}}",
"connecting": "جارٍ الاتصال...",
"connectedSuccessfully": "تم الاتصال بنجاح",
"totpVerificationFailed": "فشل التحقق من TOTP",
"warpgateVerificationFailed": "فشلت مصادقة Warpgate",
"authenticationFailed": "فشلت المصادقة",
"incorrectPassphrase": "عبارة المرور غير صحيحة. يرجى المحاولة مرة أخرى.",
"verificationCodePrompt": "رمز التحقق:",
"changePermissions": "تغيير الصلاحيات",
"currentPermissions": "الصلاحيات الحالية",
"owner": "المالك",
"group": "المجموعة",
"others": "الآخرون",
"read": "قراءة",
"write": "كتابة",
"execute": "تنفيذ",
"permissionsChangedSuccessfully": "تم تغيير الصلاحيات بنجاح",
"failedToChangePermissions": "فشل تغيير الصلاحيات",
"name": "الاسم",
"sortByName": "الاسم",
"sortByDate": "تاريخ التعديل",
"sortBySize": "الحجم",
"ascending": "تصاعدي",
"descending": "تنازلي",
"root": "الجذر",
"new": "جديد",
"sortBy": "ترتيب حسب",
"items": "عناصر",
"selected": "محدد",
"editor": "محرر",
"octal": "ثماني",
"storage": "التخزين",
"disk": "القرص",
"used": "مستخدم",
"of": "من",
"toggleSidebar": "إظهار/إخفاء الشريط الجانبي",
"compactLayout": "استخدم تصميمًا مضغوطًا",
"comfortableLayout": "استخدم تصميمًا مريحًا",
"cannotLoadPdf": "لا يمكن تحميل PDF",
"pdfLoadError": "حدث خطأ أثناء تحميل ملف PDF هذا.",
"loadingPdf": "جارٍ تحميل PDF...",
"loadingPage": "جارٍ تحميل الصفحة..."
},
"transfer": {
"copyToHost": "نسخ إلى المضيف…",
"moveToHost": "نقل إلى المضيف…",
@@ -2292,50 +1951,6 @@
"retryTransferError": "فشلت إعادة المحاولة",
"transferFailedRetryHint": "تم الاحتفاظ بالبيانات الجزئية على الوجهة. ستُستأنف إعادة المحاولة عند عودة الاتصال."
},
"sftpTransfer": {
"title": "SFTP",
"description": "نقل الملفات بين مضيفين عبر بروتوكول SSH.",
"sourceServer": "خادم المصدر",
"destinationServer": "خادم الوجهة",
"selectHost": "حدد المضيف",
"noHostSelected": "لم يتم تحديد أي مضيف",
"move": "يتحرك",
"copy": "ينسخ",
"upload": "رفع",
"itemsSelected": "تم تحديد العنصر {{count}}",
"itemsSelected_other": "تم تحديد {{count}} عنصر",
"ready": "مستعد",
"loadingHosts": "جارٍ تحميل المضيفين...",
"name": "اسم",
"modified": "معدل",
"size": "مقاس",
"noFiles": "لا توجد ملفات",
"open": "يفتح",
"up": "أعلى",
"refresh": "ينعش",
"copyToTarget": "انسخ إلى الدليل الهدف",
"rename": "إعادة تسمية",
"delete": "يمسح",
"editPermissions": "تعديل الأذونات",
"createNewFolder": "إنشاء مجلد جديد",
"deleteSelectedItems": "حذف العناصر المحددة",
"deleteConfirm": "حذف العنصر المحدد {{count}}؟",
"deleteConfirm_other": "حذف {{count}} من العناصر المحددة؟",
"cancel": "يلغي",
"save": "يحفظ",
"folderCreated": "تم إنشاء المجلد",
"itemRenamed": "تمت إعادة تسمية العنصر",
"actionFailed": "فشلت العملية",
"itemsDeleted": "تم حذف العنصر {{count}}",
"itemsDeleted_other": "تم حذف {{count}} عنصر",
"deleteFailed": "فشلت عملية الحذف",
"permissionsUpdated": "تم تحديث الأذونات",
"noRemoteSessionConnected": "لم يتم الاتصال بأي جلسة عن بعد",
"failedToListFiles": "فشل في عرض قائمة الملفات",
"failedToRefresh": "فشل التحديث",
"destinationInsideSource": "لا يمكن أن تكون الوجهة داخل مسار المصدر المحدد.",
"sameHostNoOp": "المصدر والوجهة هما نفس المجلد."
},
"tunnels": {
"noSshTunnels": "لا توجد أنفاق SSH",
"createFirstTunnelMessage": "لم تُنشئ أي أنفاق SSH بعد. قم بتكوين اتصالات الأنفاق في مدير المضيف للبدء.",
-385
View File
@@ -715,7 +715,6 @@
"hostManager": "Мениджър на домакини",
"credentials": "Пълномощия",
"portForwarding": "Препращане",
"sftp": "SFTP",
"connections": "Връзки",
"serial": "Сериен",
"keepSideMenuOpen": "Запази менюто отворено",
@@ -767,14 +766,12 @@
"tabRdp": "ПРСР",
"tabVnc": "VNC",
"tabTunnels": "Тунели",
"tabFiles": "Файлове",
"tabStats": "Метрики на хоста",
"tabTelnet": "Телнет",
"tabSharing": "Споделяне",
"tabAuthentication": "Удостоверяване",
"terminal": "Терминал",
"tunnel": "Тунел",
"fileManager": "Файлов мениджър",
"serverStats": "Метрики на хоста",
"status": {
"available": "Налично",
@@ -1118,12 +1115,6 @@
"proxmoxResultSkippedNoIp": "{{count}} пропуснато (не е намерен IP адрес)",
"proxmoxStatsUrlCopied": "URL адресът на статистиката на Proxmox е копиран в клипборда",
"copyProxmoxStatsUrlAction": "Копиране на URL адреса на статистиката на Proxmox",
"enableFileManagerMonitor": "Активиране на файловия мениджър",
"enableFileManagerMonitorDesc": "Разглеждайте и управлявайте файлове на този хост през SFTP",
"scpLegacyLabel": "SCP Legacy режим",
"scpLegacyDesc": "Използвайте наследствен трансфер на файлове за сървъри, които не поддържат SFTP подсистемата (напр. вградени или минимални SSH сървъри).",
"defaultPathLabel": "Път по подразбиране",
"fileManagerPathHint": "Директорията, която да се отвори при стартиране на файловия мениджър за този хост.",
"statusChecksLabel": "Проверки на състоянието",
"enableStatusChecks": "Активиране на проверки на състоянието",
"enableStatusChecksDesc": "Периодично проверявайте наличността на този хост чрез ping.",
@@ -1278,7 +1269,6 @@
"deletedHost": "Изтрито {{name}}",
"copiedToClipboard": "Копирано в буферната памет",
"terminalUrlCopied": "URL адресът на терминала е копиран",
"fileManagerUrlCopied": "URL адресът на файловия мениджър е копиран",
"tunnelUrlCopied": "URL адресът на тунела е копиран",
"dockerUrlCopied": "URL адресът на Docker е копиран",
"hostMetricsUrlCopied": "URL адресът на показателите за хоста е копиран",
@@ -1296,7 +1286,6 @@
"copyAddress": "Копиране на адрес",
"copyLink": "Копиране на връзката",
"copyTerminalUrlAction": "Копиране на URL адреса на терминала",
"copyFileManagerUrlAction": "Копиране на URL адреса на файловия мениджър",
"copyTunnelUrlAction": "Копиране на URL адреса на тунела",
"copyDockerUrlAction": "Копиране на URL адреса на Docker",
"copyHostMetricsUrlAction": "Копиране на URL адреса на показателите за хоста",
@@ -1857,336 +1846,6 @@
"removeFromSplit": "Премахване от Сплит"
}
},
"fileManager": {
"noHostSelected": "Няма избран хост",
"initializingEditor": "Инициализиране на редактора...",
"file": "Файл",
"folder": "Папка",
"uploadFile": "Качване на файл",
"downloadFile": "Изтегляне",
"extractArchive": "Извличане на архив",
"extractingArchive": "Извличане на {{name}}...",
"archiveExtractedSuccessfully": "{{name}} е извлечено успешно",
"extractFailed": "Извличането не бе успешно",
"compressFile": "Компресиране на файл",
"compressFiles": "Компресиране на файлове",
"compressFilesDesc": "Компресиране на {{count}} елементи в архив",
"archiveName": "Име на архива",
"enterArchiveName": "Въведете име на архива...",
"compressionFormat": "Формат на компресия",
"selectedFiles": "Избрани файлове",
"andMoreFiles": "и още {{count}}...",
"compress": "Компресиране",
"compressingFiles": "Компресиране на {{count}} елементи в {{name}}...",
"filesCompressedSuccessfully": "{{name}} е създадено успешно",
"compressFailed": "Компресията не беше успешна",
"edit": "Редактиране",
"preview": "Преглед",
"previous": "Предишен",
"next": "Следващо",
"pageXOfY": "Страница {{current}} от {{total}}",
"zoomOut": "Намаляване на мащаба",
"zoomIn": "Увеличаване на мащаба",
"newFile": "Нов файл",
"newFolder": "Нова папка",
"rename": "Преименуване",
"uploading": "Качване...",
"uploadingFile": "Качване {{name}}...",
"fileName": "Име на файл",
"folderName": "Име на папката",
"fileUploadedSuccessfully": "Файлът „{{name}}“ е качен успешно",
"failedToUploadFile": "Качването на файла не бе успешно",
"uploadingFolderFiles": "Качване на {{count}} файл(а)...",
"uploadedFolderFiles": "Качени {{count}} файла(а) успешно",
"uploadedFolderPartial": "Качени {{uploaded}} файла(а), {{failed}} неуспешно",
"fileDownloadedSuccessfully": "Файлът „{{name}}“ е изтеглен успешно",
"failedToDownloadFile": "Изтеглянето на файла не бе успешно",
"fileCreatedSuccessfully": "Файлът „{{name}}“ е създаден успешно",
"folderCreatedSuccessfully": "Папка „{{name}}“ е създадена успешно",
"failedToCreateItem": "Създаването на елемент не бе успешно",
"operationFailed": "Операцията {{operation}} е неуспешна за {{name}}: {{error}}",
"failedToResolveSymlink": "Неуспешно разрешаване на символната връзка",
"itemsDeletedSuccessfully": "{{count}} елементи са изтрити успешно",
"failedToDeleteItems": "Изтриването на елементи не бе успешно",
"sudoPasswordRequired": "Изисква се администраторска парола",
"enterSudoPassword": "Въведете паролата за sudo, за да продължите тази операция",
"sudoPassword": "Парола за Sudo",
"sudoOperationFailed": "Sudo операцията не беше успешна",
"sudoAuthFailed": "Sudo удостоверяването не беше успешно",
"dragFilesToUpload": "Пуснете файлове тук, за да ги качите",
"dropToUploadHere": "Пуснете, за да качите тук",
"dropToDownloadHere": "Пуснете, за да изтеглите тук",
"localFiles": "Местно",
"showLocalFiles": "Показване на локални файлове (разделен изглед)",
"hideLocalFiles": "Скриване на локални файлове",
"localHome": "Начална папка",
"localPathPlaceholder": "Път към локалната папка",
"localShowHidden": "Показване на скритите файлове",
"localHideHidden": "Скриване на скрити файлове",
"localRevealInFileManager": "Разкриване в системния файлов мениджър",
"localCannotOpenFolder": "Не може да се отвори папка",
"localItemCount": "{{count}} артикули",
"localSelectedCount": "{{count}} избрано",
"back": "Обратно",
"forward": "Напред",
"up": "Нагоре",
"kind": "Вид",
"localUploadingProgress": "Качване на {{current}} от {{total}}",
"localDownloadingProgress": "Изтегляне на {{current}} от {{total}}",
"localTransferInFlight": "{{count}} наведнъж",
"localPreparingDownload": "Подготовка за изтегляне...",
"localTransferCancelled": "Прехвърлянето е анулирано",
"localNothingToTransfer": "Няма нищо за прехвърляне",
"localUploadComplete": "Качени {{count}} файла(а)",
"localUploadFailed": "Качването не бе успешно",
"localUploadPartial": "Качени {{done}} файла(а), {{failed}} неуспешно",
"localDownloadComplete": "Изтеглени {{count}} файлове",
"localDownloadFailed": "Изтеглянето не бе успешно",
"localDownloadPartial": "Изтеглени {{done}} файлове, {{failed}} неуспешно",
"localReplaceExistingPrompt": "{{count}} елемент(а) вече съществуват в тази папка. Да се заменят ли?",
"localReplace": "Замяна",
"localSkipExisting": "Пропускане",
"localSkippedExisting": "Пропуснати {{count}} съществуващи елементи",
"localUnsafeNamesSkipped": "Пропуснати {{count}} елементи, чиито имена не могат да се използват на този компютър",
"localOpen": "Отворено",
"localOpenFolder": "Отвори папка",
"localUploadToRemote": "Качване на сървъра",
"localUploadToRemoteMany": "Качване на {{count}} елементи на сървъра",
"localMoveToTrash": "Премести в кошчето",
"localMoveToTrashMany": "Преместване на {{count}} елемента в кошчето",
"localTrashConfirmSingle": "Да се премести ли „{{name}}“ в кошчето?",
"localTrashConfirmFolder": "Да се премести ли папката „{{name}}“ и цялото ѝ съдържание в кошчето?",
"localTrashConfirmMany": "Да се преместят ли {{count}} елементи в кошчето?",
"localTrashed": "Преместени {{count}} елемент(а) в кошчето",
"localTrashFailed": "Не можа да се премести в кошчето",
"localRenameFailed": "Преименуването не бе успешно",
"localCreateFailed": "Не можа да се създаде елемент",
"goToParentFolder": "Отиди до родителската папка",
"resizeSidebar": "Плъзнете, за да промените размера на страничната лента (щракнете двукратно за нулиране)",
"resizeLocalPane": "Плъзнете, за да промените размера на локалния панел (щракнете двукратно за нулиране)",
"columns": "Колони",
"columnsHint": "Щракнете с десния бутон, за да изберете колони",
"emptyFolder": "Тази папка е празна",
"searchFiles": "Търсене на файлове...",
"upload": "Качване",
"selectHostToStart": "Изберете хост, за да започнете управлението на файлове",
"sshRequiredForFileManager": "Файловият мениджър изисква SSH. Този хост не е активиран за SSH.",
"failedToConnect": "Неуспешно свързване към SSH",
"failedToLoadDirectory": "Неуспешно зареждане на директорията",
"noSSHConnection": "Няма налична SSH връзка",
"copy": "Копиране",
"cut": "Изрязване",
"paste": "Паста",
"copyPath": "Копиране на пътя",
"copyPaths": "Копиране на пътища",
"delete": "Изтриване",
"properties": "Имоти",
"refresh": "Обновяване",
"downloadFiles": "Изтегляне на {{count}} файлове в браузъра",
"copyFiles": "Копиране на {{count}} елементи",
"cutFiles": "Изрязване на {{count}} елементи",
"deleteFiles": "Изтриване на {{count}} елементи",
"filesCopiedToClipboard": "{{count}} елементи са копирани в клипборда",
"filesCutToClipboard": "{{count}} елементи, изрязани в клипборда",
"pathCopiedToClipboard": "Пътят е копиран в клипборда",
"pathsCopiedToClipboard": "{{count}} пътища, копирани в клипборда",
"failedToCopyPath": "Копирането на пътя в клипборда не бе успешно",
"copyFolderLink": "Копиране на връзката към папката",
"copyCurrentFolderLink": "Копиране на връзката към текущата папка",
"folderLinkCopied": "Връзката към папката е копирана в клипборда",
"failedToCopyFolderLink": "Копирането на връзката към папката не бе успешно",
"movedItems": "Преместени {{count}} елементи",
"failedToDeleteItem": "Изтриването на елемента не бе успешно",
"itemRenamedSuccessfully": "{{type}} преименуван успешно",
"failedToRenameItem": "Преименуването на елемента не бе успешно",
"download": "Изтегляне",
"openExternalEditor": "Отворено външно",
"chooseExternalEditor": "Изберете външен редактор",
"externalEditorSelected": "Избран външен редактор",
"externalEditorDesktopOnly": "Външният редактор е наличен само в настолното приложение",
"externalEditorOpened": "Отворено във външен редактор. Запазването там ще качи промените обратно на сървъра.",
"failedToOpenExternalEditor": "Неуспешно отваряне на външен редактор",
"failedToSelectExternalEditor": "Избирането на външен редактор не бе успешно",
"permissions": "Разрешения",
"size": "Размер",
"modified": "Модифицирано",
"path": "Път",
"confirmDelete": "Сигурни ли сте, че искате да изтриете {{name}}?",
"permissionDenied": "Разрешението е отказано",
"serverError": "Грешка на сървъра",
"fileSavedSuccessfully": "Файлът е запазен успешно",
"failedToSaveFile": "Запазването на файла не бе успешно",
"confirmDeleteSingleItem": "Сигурни ли сте, че искате да изтриете за постоянно „{{name}}“?",
"confirmDeleteMultipleItems": "Сигурни ли сте, че искате да изтриете за постоянно {{count}} елементи?",
"confirmDeleteMultipleItemsWithFolders": "Наистина ли искате да изтриете за постоянно {{count}} елементи? Това включва папки и тяхното съдържание.",
"confirmDeleteFolder": "Сигурни ли сте, че искате да изтриете окончателно папката „{{name}}“ и цялото ѝ съдържание?",
"permanentDeleteWarning": "Това действие не може да бъде отменено. Елементът(ите) ще бъдат изтрити за постоянно от сървъра.",
"moveToTrashWarning": "Елементът(ите) ще бъдат преместени в кошчето на Termix и могат да бъдат възстановени преди изтичане на срока на съхранение.",
"itemsMovedToTrash": "{{count}} елемент(а) са преместени в кошчето",
"trash": "Кошче",
"trashDescription": "Изтритите елементи се запазват за {{days}} дни на този хост.",
"trashLoadFailed": "Зареждането на кошчето не бе успешно",
"trashEmpty": "Кошчето е празно",
"trashRestore": "Възстановяване",
"trashRestored": "Възстановено {{name}}",
"trashRestoreFailed": "Възстановяването на елемента не бе успешно. Възможно е оригиналният път вече да съществува.",
"trashDeletePermanently": "Изтрий за постоянно",
"trashDeleteConfirm": "Да се изтрие ли за постоянно {{name}}? Това не може да се отмени.",
"trashPermanentlyDeleted": "За постоянно изтрито {{name}}",
"trashDeleteFailed": "Неуспешно трайно изтриване на елемента",
"trashEmptyAction": "Изпразване на кошчето",
"trashEmptyConfirm": "Да се изтрие ли за постоянно всеки елемент в кошчето? Това не може да се отмени.",
"trashEmptied": "Кошчето е изпразнено",
"trashRetention": "Автоматично почистване след (дни)",
"trashRetentionSaved": "Запазването на кошчето е актуализирано",
"trashRetentionFailed": "Актуализирането на задържането на кошчето не бе успешно",
"trashUnavailableConfirm": "Този елемент не може да бъде преместен в кошчето, обикновено защото е на друга файлова система или изисква повишени права. Искате ли да го изтриете за постоянно вместо това?",
"recent": "Скорошни",
"pinned": "Закачено",
"folderShortcuts": "Преки пътища към папки",
"failedToReconnectSSH": "Неуспешно повторно свързване на SSH сесията",
"openTerminalHere": "Отворете терминала тук",
"run": "Бягане",
"openTerminalInFolder": "Отвори терминала в тази папка",
"openTerminalInFileLocation": "Отваряне на терминала на местоположението на файла",
"runningFile": "Бягане - {{file}}",
"onlyRunExecutableFiles": "Може да изпълнява само изпълними файлове",
"directories": "Директории",
"removedFromRecentFiles": "Премахнато е „{{name}}“ от последните файлове",
"removeFailed": "Премахването не бе успешно",
"unpinnedSuccessfully": "Откачено е успешно „{{name}}“",
"unpinFailed": "Откачването не бе успешно",
"removedShortcut": "Премахнат е пряк път „{{name}}“",
"removeShortcutFailed": "Премахването на пряк път не бе успешно",
"clearedAllRecentFiles": "Изчистени са всички скорошни файлове",
"clearFailed": "Изчистването не бе успешно",
"removeFromRecentFiles": "Премахване от скорошни файлове",
"clearAllRecentFiles": "Изчистване на всички скорошни файлове",
"unpinFile": "Откачи файл",
"removeShortcut": "Премахване на пряк път",
"pinFile": "Закачи файл",
"addToShortcuts": "Добавяне към преките пътища",
"pasteFailed": "Поставянето не бе успешно",
"noUndoableActions": "Няма отменими действия",
"undoCopySuccess": "Отменено копиране: Изтрити {{count}} копирани файлове",
"undoCopyFailedDelete": "Отмяната не бе успешна: Не можаха да бъдат изтрити копирани файлове",
"undoCopyFailedNoInfo": "Отмяната не бе успешна: Не можа да бъде намерена информация за копирания файл",
"undoMoveSuccess": "Отменена операция по преместване: {{count}} файлове са преместени обратно в оригиналното местоположение",
"undoMoveFailedMove": "Отмяната не бе успешна: Не можах да преместя файловете обратно",
"undoMoveFailedNoInfo": "Отмяната не бе успешна: Не можа да бъде намерена информация за преместения файл",
"undoDeleteNotSupported": "Операцията по изтриване не може да бъде отменена: Файловете са изтрити за постоянно от сървъра",
"undoTypeNotSupported": "Неподдържан тип операция за отмяна",
"undoOperationFailed": "Отмяната не бе успешна",
"unknownError": "Неизвестна грешка",
"confirm": "Потвърди",
"find": "Намерете...",
"replace": "Замяна",
"downloadInstead": "Изтеглете вместо това",
"keyboardShortcuts": "Клавишни комбинации",
"searchAndReplace": "Търсене и замяна",
"editing": "Редактиране",
"search": "Търсене",
"findNext": "Намери следващото",
"findPrevious": "Намери предишното",
"save": "Запазване",
"selectAll": "Избери всички",
"undo": "Отмяна",
"redo": "Преработи",
"moveLineUp": "Премести линията нагоре",
"moveLineDown": "Премести реда надолу",
"toggleComment": "Превключване на коментара",
"autoComplete": "Автоматично завършване",
"imageLoadError": "Зареждането на изображението не бе успешно",
"startTyping": "Започнете да пишете...",
"unknownSize": "Неизвестен размер",
"fileIsEmpty": "Файлът е празен",
"largeFileWarning": "Предупреждение за голям файл",
"largeFileWarningDesc": "Размерът на този файл е {{size}} , което може да причини проблеми с производителността, когато се отвори като текст.",
"fileNotFoundAndRemoved": "Файлът „{{name}}“ не е намерен и е премахнат от скорошни/закачени файлове",
"failedToLoadFile": "Неуспешно зареждане на файл: {{error}}",
"serverErrorOccurred": "Възникна грешка в сървъра. Моля, опитайте отново по-късно.",
"autoSaveFailed": "Автоматичното запазване не бе успешно",
"fileAutoSaved": "Файлът е автоматично запазен",
"moveFileFailed": "Преместването не бе успешно {{name}}",
"moveOperationFailed": "Операцията по преместване не бе успешна",
"canOnlyCompareFiles": "Може да се сравняват само два файла",
"comparingFiles": "Сравняване на файлове: {{file1}} и {{file2}}",
"dragFailed": "Плъзгането не беше успешно",
"filePinnedSuccessfully": "Файлът „{{name}}“ е успешно закачен",
"pinFileFailed": "Закрепването на файла не бе успешно",
"fileUnpinnedSuccessfully": "Файлът „{{name}}“ е откачен успешно",
"unpinFileFailed": "Откачването на файла не бе успешно",
"shortcutAddedSuccessfully": "Прякият път към папка „{{name}}“ е добавен успешно",
"addShortcutFailed": "Добавянето на пряк път не бе успешно",
"operationCompletedSuccessfully": "{{operation}} {{count}} елементи успешно",
"operationCompleted": "{{operation}} {{count}} артикули",
"downloadFileSuccess": "Файлът {{name}} е изтеглен успешно",
"downloadFileFailed": "Изтеглянето не бе успешно",
"downloadingFile": "Изтегляне {{name}}…",
"downloadProgressBytes": "{{transferred}} / {{total}}",
"moveTo": "Премести се в {{name}}",
"diffCompareWith": "Разлика в сравнение с {{name}}",
"dragOutsideToDownload": "Плъзнете извън прозореца, за да изтеглите ({{count}} файлове)",
"newFolderDefault": "НоваПапка",
"newFileDefault": "NewFile.txt",
"successfullyMovedItems": "Успешно преместени {{count}} елементи в {{target}}",
"move": "Преместване",
"searchInFile": "Търсене във файл (Ctrl+F)",
"showKeyboardShortcuts": "Показване на клавишни комбинации",
"decreaseFontSize": "Намаляване на размера на шрифта",
"increaseFontSize": "Увеличаване на размера на шрифта",
"startWritingMarkdown": "Започнете да пишете съдържанието си за markdown...",
"loadingFileComparison": "Зареждане на сравнението на файлове...",
"reload": "Презареждане",
"compare": "Сравни",
"sideBySide": "Рамо до рамо",
"inline": "Вграден",
"fileComparison": "Сравнение на файлове: {{file1}} срещу {{file2}}",
"fileTooLarge": "Файлът е твърде голям: {{error}}",
"sshConnectionFailed": "SSH връзката не бе успешна. Моля, проверете връзката си с {{name}} ({{ip}}:{{port}})",
"loadFileFailed": "Неуспешно зареждане на файл: {{error}}",
"connecting": "Свързване...",
"connectedSuccessfully": "Свързаването е осъществено успешно",
"totpVerificationFailed": "Проверката на TOTP не бе успешна",
"warpgateVerificationFailed": "Удостоверяването на Warpgate не беше успешно",
"authenticationFailed": "Удостоверяването не бе успешно",
"incorrectPassphrase": "Неправилна парола. Моля, опитайте отново.",
"verificationCodePrompt": "Код за потвърждение:",
"changePermissions": "Промяна на разрешенията",
"currentPermissions": "Текущи разрешения",
"owner": "Собственик",
"group": "Група",
"others": "Други",
"read": "Прочетете",
"write": "Пишете",
"execute": "Изпълнение",
"permissionsChangedSuccessfully": "Разрешенията са променени успешно",
"failedToChangePermissions": "Промяната на разрешенията не бе успешна",
"name": "Име",
"sortByName": "Име",
"sortByDate": "Дата на промяна",
"sortBySize": "Размер",
"ascending": "Възходящ",
"descending": "Низходящо",
"root": "Корен",
"new": "Ново",
"sortBy": "Сортиране по",
"items": "Елементи",
"selected": "Избрано",
"editor": "Редактор",
"octal": "Осмичен",
"storage": "Съхранение",
"disk": "Диск",
"used": "Използван",
"of": "от",
"toggleSidebar": "Превключване на страничната лента",
"compactLayout": "Използвайте компактно оформление",
"comfortableLayout": "Използвайте удобно оформление",
"cannotLoadPdf": "Не може да се зареди PDF файл",
"pdfLoadError": "Възникна грешка при зареждането на този PDF файл.",
"loadingPdf": "Зареждане на PDF файл...",
"loadingPage": "Зареждане на страницата..."
},
"transfer": {
"copyToHost": "Копиране към хост…",
"moveToHost": "Преместване към хост…",
@@ -2292,50 +1951,6 @@
"retryTransferError": "Повторният опит не бе успешен",
"transferFailedRetryHint": "В местоназначението са запазени частични данни. Повторният опит ще се възобнови, когато връзката се възстанови."
},
"sftpTransfer": {
"title": "SFTP",
"description": "Прехвърляне на файлове между два SSH хоста.",
"sourceServer": "Източник на сървъра",
"destinationServer": "Целеви сървър",
"selectHost": "Изберете хост",
"noHostSelected": "Няма избран хост",
"move": "Преместване",
"copy": "Копиране",
"upload": "Качване",
"itemsSelected": "{{count}} избран елемент",
"itemsSelected_other": "{{count}} избрани елементи",
"ready": "Готов",
"loadingHosts": "Зареждане на хостове...",
"name": "Име",
"modified": "Модифицирано",
"size": "Размер",
"noFiles": "Няма файлове",
"open": "Отворено",
"up": "Нагоре",
"refresh": "Опресняване",
"copyToTarget": "Копиране в целевата директория",
"rename": "Преименуване",
"delete": "Изтриване",
"editPermissions": "Редактиране на разрешения",
"createNewFolder": "Създаване на нова папка",
"deleteSelectedItems": "Изтриване на избраните елементи",
"deleteConfirm": "Да се изтрие ли {{count}} избран елемент?",
"deleteConfirm_other": "Да се изтрият ли {{count}} избрани елементи?",
"cancel": "Отказ",
"save": "Запазване",
"folderCreated": "Папката е създадена",
"itemRenamed": "Елементът е преименуван",
"actionFailed": "Действието е неуспешно",
"itemsDeleted": "{{count}} елементът е изтрит",
"itemsDeleted_other": "{{count}} елементи са изтрити",
"deleteFailed": "Изтриването не бе успешно",
"permissionsUpdated": "Разрешенията са актуализирани",
"noRemoteSessionConnected": "Няма свързана отдалечена сесия",
"failedToListFiles": "Неуспешно изброяване на файловете",
"failedToRefresh": "Опресняването не бе успешно",
"destinationInsideSource": "Дестинацията не може да бъде в избрания път на източника.",
"sameHostNoOp": "Източникът и местоназначението са една и съща папка."
},
"tunnels": {
"noSshTunnels": "Няма SSH тунели",
"createFirstTunnelMessage": "Все още не сте създали SSH тунели. Конфигурирайте тунелни връзки в Host Manager, за да започнете.",
-385
View File
@@ -715,7 +715,6 @@
"hostManager": "হোস্ট ম্যানেজার",
"credentials": "যোগ্যতা",
"portForwarding": "ফরোয়ার্ডিং",
"sftp": "এসএফটিপি",
"connections": "সংযোগ",
"serial": "সিরিয়াল",
"keepSideMenuOpen": "মেনু খোলা রাখুন",
@@ -767,14 +766,12 @@
"tabRdp": "আরডিপি",
"tabVnc": "ভিএনসি",
"tabTunnels": "টানেল",
"tabFiles": "ফাইল",
"tabStats": "হোস্ট মেট্রিক্স",
"tabTelnet": "টেলনেট",
"tabSharing": "শেয়ারিং",
"tabAuthentication": "প্রমাণীকরণ",
"terminal": "টার্মিনাল",
"tunnel": "টানেল",
"fileManager": "ফাইল ম্যানেজার",
"serverStats": "হোস্ট মেট্রিক্স",
"status": {
"available": "উপলব্ধ",
@@ -1118,12 +1115,6 @@
"proxmoxResultSkippedNoIp": "{{count}} বাদ দেওয়া হয়েছে (কোন আইপি পাওয়া যায়নি)",
"proxmoxStatsUrlCopied": "Proxmox Stats URL ক্লিপবোর্ডে কপি করা হয়েছে",
"copyProxmoxStatsUrlAction": "Proxmox Stats URL কপি করুন",
"enableFileManagerMonitor": "ফাইল ম্যানেজার সক্রিয় করুন",
"enableFileManagerMonitorDesc": "SFTP-এর মাধ্যমে এই হোস্টের ফাইলগুলি ব্রাউজ ও পরিচালনা করুন।",
"scpLegacyLabel": "SCP লিগ্যাসি মোড",
"scpLegacyDesc": "যেসব সার্ভার SFTP সাবসিস্টেম সমর্থন করে না (যেমন এমবেডেড বা মিনিমাল SSH সার্ভার), সেগুলোর জন্য লিগ্যাসি ফাইল ট্রান্সফার ব্যবহার করুন।",
"defaultPathLabel": "ডিফল্ট পথ",
"fileManagerPathHint": "এই হোস্টের জন্য ফাইল ম্যানেজার চালু হলে যে ডিরেক্টরিটি খুলবে।",
"statusChecksLabel": "অবস্থা যাচাই",
"enableStatusChecks": "স্ট্যাটাস চেক সক্ষম করুন",
"enableStatusChecksDesc": "প্রাপ্যতা যাচাই করতে পর্যায়ক্রমে এই হোস্টটিকে পিং করুন।",
@@ -1278,7 +1269,6 @@
"deletedHost": "মুছে ফেলা হয়েছে {{name}}",
"copiedToClipboard": "ক্লিপবোর্ডে কপি করা হয়েছে",
"terminalUrlCopied": "টার্মিনাল ইউআরএল কপি করা হয়েছে",
"fileManagerUrlCopied": "ফাইল ম্যানেজার ইউআরএল কপি করা হয়েছে",
"tunnelUrlCopied": "টানেল ইউআরএল কপি করা হয়েছে",
"dockerUrlCopied": "ডকার ইউআরএল কপি করা হয়েছে",
"hostMetricsUrlCopied": "হোস্ট মেট্রিক্স ইউআরএল কপি করা হয়েছে",
@@ -1296,7 +1286,6 @@
"copyAddress": "ঠিকানা কপি করুন",
"copyLink": "লিঙ্ক কপি করুন",
"copyTerminalUrlAction": "টার্মিনাল ইউআরএল কপি করুন",
"copyFileManagerUrlAction": "ফাইল ম্যানেজার ইউআরএল কপি করুন",
"copyTunnelUrlAction": "টানেল ইউআরএল কপি করুন",
"copyDockerUrlAction": "ডকার ইউআরএল কপি করুন",
"copyHostMetricsUrlAction": "হোস্ট মেট্রিক্স ইউআরএল কপি করুন",
@@ -1857,336 +1846,6 @@
"removeFromSplit": "স্প্লিট থেকে সরান"
}
},
"fileManager": {
"noHostSelected": "কোন হোস্ট নির্বাচন করা হয়নি",
"initializingEditor": "এডিটর চালু করা হচ্ছে...",
"file": "ফাইল",
"folder": "ফোল্ডার",
"uploadFile": "ফাইল আপলোড করুন",
"downloadFile": "ডাউনলোড করুন",
"extractArchive": "আর্কাইভ নিষ্কাশন করুন",
"extractingArchive": "{{name}} নিষ্কাশন করা হচ্ছে ...",
"archiveExtractedSuccessfully": "{{name}} সফলভাবে নিষ্কাশন করা হয়েছে",
"extractFailed": "নিষ্কাশন ব্যর্থ হয়েছে",
"compressFile": "ফাইল সংকুচিত করুন",
"compressFiles": "ফাইলগুলি সংকুচিত করুন",
"compressFilesDesc": "{{count}} টি আইটেমকে আর্কাইভে সংকুচিত করুন",
"archiveName": "আর্কাইভের নাম",
"enterArchiveName": "আর্কাইভের নাম লিখুন...",
"compressionFormat": "কম্প্রেশন ফরম্যাট",
"selectedFiles": "নির্বাচিত ফাইল",
"andMoreFiles": "এবং {{count}} আরও...",
"compress": "সংকুচিত করুন",
"compressingFiles": "{{count}} টি আইটেমকে {{name}} টি আইটেমে সংকুচিত করা হচ্ছে...",
"filesCompressedSuccessfully": "{{name}} সফলভাবে তৈরি হয়েছে",
"compressFailed": "সংকোচন ব্যর্থ হয়েছে",
"edit": "সম্পাদনা",
"preview": "প্রিভিউ",
"previous": "পূর্ববর্তী",
"next": "পরবর্তী",
"pageXOfY": "{{current}} এর {{total}} পৃষ্ঠা",
"zoomOut": "জুম আউট",
"zoomIn": "জুম ইন",
"newFile": "নতুন ফাইল",
"newFolder": "নতুন ফোল্ডার",
"rename": "পুনঃনামকরণ",
"uploading": "আপলোড হচ্ছে...",
"uploadingFile": "{{name}} আপলোড হচ্ছে ...",
"fileName": "ফাইলের নাম",
"folderName": "ফোল্ডারের নাম",
"fileUploadedSuccessfully": "ফাইল \"{{name}}\" সফলভাবে আপলোড হয়েছে",
"failedToUploadFile": "ফাইল আপলোড করতে ব্যর্থ হয়েছে",
"uploadingFolderFiles": "{{count}} টি ফাইল আপলোড করা হচ্ছে...",
"uploadedFolderFiles": "{{count}} টি ফাইল সফলভাবে আপলোড করা হয়েছে",
"uploadedFolderPartial": "{{uploaded}} টি ফাইল আপলোড করা হয়েছে, {{failed}} টি ফাইল আপলোড ব্যর্থ হয়েছে",
"fileDownloadedSuccessfully": "ফাইল \"{{name}}\" সফলভাবে ডাউনলোড হয়েছে",
"failedToDownloadFile": "ফাইল ডাউনলোড করতে ব্যর্থ হয়েছে",
"fileCreatedSuccessfully": "ফাইল \"{{name}}\" সফলভাবে তৈরি হয়েছে",
"folderCreatedSuccessfully": "\"{{name}}\" ফোল্ডারটি সফলভাবে তৈরি করা হয়েছে",
"failedToCreateItem": "আইটেম তৈরি করতে ব্যর্থ হয়েছে",
"operationFailed": "{{operation}} অপারেশনটি {{name}}এর জন্য ব্যর্থ হয়েছে: {{error}}",
"failedToResolveSymlink": "সিমলিঙ্ক সমাধান করতে ব্যর্থ হয়েছে",
"itemsDeletedSuccessfully": "{{count}} আইটেম সফলভাবে মুছে ফেলা হয়েছে",
"failedToDeleteItems": "আইটেমগুলি মুছে ফেলতে ব্যর্থ হয়েছে",
"sudoPasswordRequired": "প্রশাসক পাসওয়ার্ড আবশ্যক",
"enterSudoPassword": "এই অপারেশনটি চালিয়ে যেতে sudo পাসওয়ার্ড দিন।",
"sudoPassword": "সুডো পাসওয়ার্ড",
"sudoOperationFailed": "Sudo অপারেশন ব্যর্থ হয়েছে",
"sudoAuthFailed": "Sudo প্রমাণীকরণ ব্যর্থ হয়েছে",
"dragFilesToUpload": "আপলোড করার জন্য ফাইলগুলো এখানে রাখুন।",
"dropToUploadHere": "এখানে আপলোড করতে ড্রপ করুন",
"dropToDownloadHere": "ডাউনলোড করতে এখানে ক্লিক করুন",
"localFiles": "স্থানীয়",
"showLocalFiles": "স্থানীয় ফাইলগুলি দেখান (বিভক্ত দৃশ্য)",
"hideLocalFiles": "স্থানীয় ফাইলগুলি লুকান",
"localHome": "হোম ফোল্ডার",
"localPathPlaceholder": "স্থানীয় ফোল্ডার পাথ",
"localShowHidden": "লুকানো ফাইলগুলি দেখান",
"localHideHidden": "লুকানো ফাইলগুলি লুকান",
"localRevealInFileManager": "সিস্টেম ফাইল ম্যানেজারে প্রকাশ করুন",
"localCannotOpenFolder": "ফোল্ডার খোলা যাচ্ছে না",
"localItemCount": "{{count}} আইটেম",
"localSelectedCount": "{{count}} নির্বাচিত",
"back": "ফিরে যান",
"forward": "ফরোয়ার্ড",
"up": "উপরে",
"kind": "ধরনের",
"localUploadingProgress": "{{total}} এর {{current}} আপলোড করা হচ্ছে",
"localDownloadingProgress": "{{total}} এর {{current}} ডাউনলোড হচ্ছে",
"localTransferInFlight": "{{count}} একবারে",
"localPreparingDownload": "ডাউনলোড প্রস্তুত হচ্ছে...",
"localTransferCancelled": "স্থানান্তর বাতিল করা হয়েছে",
"localNothingToTransfer": "স্থানান্তর করার কিছু নেই",
"localUploadComplete": "{{count}} টি ফাইল আপলোড করা হয়েছে",
"localUploadFailed": "আপলোড ব্যর্থ হয়েছে",
"localUploadPartial": "{{done}} টি ফাইল আপলোড করা হয়েছে, {{failed}} টি ফাইল আপলোড ব্যর্থ হয়েছে",
"localDownloadComplete": "{{count}} টি ফাইল ডাউনলোড করা হয়েছে",
"localDownloadFailed": "ডাউনলোড ব্যর্থ হয়েছে",
"localDownloadPartial": "{{done}} টি ফাইল ডাউনলোড করা হয়েছে, {{failed}} টি ফাইল ডাউনলোড ব্যর্থ হয়েছে",
"localReplaceExistingPrompt": "এই ফোল্ডারে {{count}} টি আইটেম ইতিমধ্যেই বিদ্যমান। এগুলো প্রতিস্থাপন করবেন?",
"localReplace": "প্রতিস্থাপন করুন",
"localSkipExisting": "এড়িয়ে যান",
"localSkippedExisting": "{{count}} টি বিদ্যমান আইটেম বাদ দেওয়া হয়েছে",
"localUnsafeNamesSkipped": "{{count}} টি আইটেম বাদ দেওয়া হয়েছে, যেগুলোর নাম এই কম্পিউটারে ব্যবহার করা যাবে না।",
"localOpen": "খোলা",
"localOpenFolder": "ফোল্ডার খুলুন",
"localUploadToRemote": "সার্ভারে আপলোড করুন",
"localUploadToRemoteMany": "সার্ভারে {{count}} টি আইটেম আপলোড করুন",
"localMoveToTrash": "ট্র্যাশে সরান",
"localMoveToTrashMany": "{{count}} টি আইটেম ট্র্যাশে সরান",
"localTrashConfirmSingle": "\"{{name}}\" ট্র্যাশে সরাবেন?",
"localTrashConfirmFolder": "\"{{name}}\" ফোল্ডারটি এবং এর সমস্ত বিষয়বস্তু ট্র্যাশে সরান?",
"localTrashConfirmMany": "{{count}} টি আইটেম ট্র্যাশে সরাবেন?",
"localTrashed": "ট্র্যাশে {{count}} টি আইটেম সরানো হয়েছে",
"localTrashFailed": "ট্র্যাশে সরানো যায়নি",
"localRenameFailed": "নাম পরিবর্তন ব্যর্থ হয়েছে",
"localCreateFailed": "আইটেমটি তৈরি করা যায়নি",
"goToParentFolder": "প্যারেন্ট ফোল্ডারে যান",
"resizeSidebar": "সাইডবারের আকার পরিবর্তন করতে ড্র্যাগ করুন (রিসেট করতে ডাবল-ক্লিক করুন)",
"resizeLocalPane": "স্থানীয় প্যানেলের আকার পরিবর্তন করতে ড্র্যাগ করুন (রিসেট করতে ডাবল-ক্লিক করুন)",
"columns": "কলাম",
"columnsHint": "কলাম বাছাই করতে ডান-ক্লিক করুন",
"emptyFolder": "এই ফোল্ডারটি খালি",
"searchFiles": "ফাইলগুলি অনুসন্ধান করুন...",
"upload": "আপলোড",
"selectHostToStart": "ফাইল ব্যবস্থাপনা শুরু করতে একটি হোস্ট নির্বাচন করুন",
"sshRequiredForFileManager": "ফাইল ম্যানেজারের জন্য SSH প্রয়োজন। এই হোস্টে SSH সক্রিয় করা নেই।",
"failedToConnect": "SSH-এর সাথে সংযোগ করতে ব্যর্থ হয়েছে",
"failedToLoadDirectory": "ডিরেক্টরি লোড করতে ব্যর্থ হয়েছে",
"noSSHConnection": "কোন SSH সংযোগ উপলব্ধ নেই",
"copy": "অনুলিপি",
"cut": "কাটা",
"paste": "পেস্ট",
"copyPath": "কপি পাথ",
"copyPaths": "কপি পাথ",
"delete": "মুছে ফেলুন",
"properties": "বৈশিষ্ট্য",
"refresh": "রিফ্রেশ",
"downloadFiles": "ব্রাউজারে {{count}} ফাইলগুলো ডাউনলোড করুন",
"copyFiles": "{{count}} আইটেম কপি করুন",
"cutFiles": "{{count}} টি আইটেম কাটুন",
"deleteFiles": "{{count}} টি আইটেম মুছুন",
"filesCopiedToClipboard": "{{count}} আইটেম ক্লিপবোর্ডে কপি করা হয়েছে",
"filesCutToClipboard": "{{count}} আইটেমগুলো ক্লিপবোর্ডে কাটা হয়েছে",
"pathCopiedToClipboard": "পাথটি ক্লিপবোর্ডে কপি করা হয়েছে",
"pathsCopiedToClipboard": "{{count}} পাথগুলো ক্লিপবোর্ডে কপি করা হয়েছে",
"failedToCopyPath": "ক্লিপবোর্ডে পাথ কপি করতে ব্যর্থ হয়েছে",
"copyFolderLink": "ফোল্ডারে লিঙ্ক কপি করুন",
"copyCurrentFolderLink": "বর্তমান ফোল্ডারে লিঙ্ক কপি করুন",
"folderLinkCopied": "ফোল্ডারের লিঙ্ক ক্লিপবোর্ডে কপি করা হয়েছে",
"failedToCopyFolderLink": "ফোল্ডার লিঙ্ক কপি করতে ব্যর্থ হয়েছে",
"movedItems": "{{count}} টি আইটেম সরানো হয়েছে",
"failedToDeleteItem": "আইটেমটি মুছে ফেলতে ব্যর্থ হয়েছে",
"itemRenamedSuccessfully": "{{type}} সফলভাবে নাম পরিবর্তন করা হয়েছে",
"failedToRenameItem": "আইটেমের নাম পরিবর্তন করতে ব্যর্থ হয়েছে",
"download": "ডাউনলোড করুন",
"openExternalEditor": "বাইরে খুলুন",
"chooseExternalEditor": "বাহ্যিক সম্পাদক নির্বাচন করুন",
"externalEditorSelected": "বাহ্যিক সম্পাদক নির্বাচিত",
"externalEditorDesktopOnly": "এক্সটার্নাল এডিটর শুধুমাত্র ডেস্কটপ অ্যাপে উপলব্ধ।",
"externalEditorOpened": "বাহ্যিক এডিটরে খোলা হয়েছে। সেখানে সেভ করলে পরিবর্তনগুলো সার্ভারে আপলোড হয়ে যাবে।",
"failedToOpenExternalEditor": "বাহ্যিক সম্পাদক খুলতে ব্যর্থ হয়েছে",
"failedToSelectExternalEditor": "বাহ্যিক সম্পাদক নির্বাচন করতে ব্যর্থ হয়েছে",
"permissions": "অনুমতি",
"size": "আকার",
"modified": "পরিবর্তিত",
"path": "পথ",
"confirmDelete": "আপনি কি {{name}} মুছে ফেলতে নিশ্চিত?",
"permissionDenied": "অনুমতি প্রত্যাখ্যান করা হয়েছে",
"serverError": "সার্ভার ত্রুটি",
"fileSavedSuccessfully": "ফাইল সফলভাবে সংরক্ষিত হয়েছে।",
"failedToSaveFile": "ফাইল সংরক্ষণ করতে ব্যর্থ হয়েছে",
"confirmDeleteSingleItem": "আপনি কি \"{{name}} \" স্থায়ীভাবে মুছে ফেলতে চান?",
"confirmDeleteMultipleItems": "আপনি কি {{count}} টি আইটেম স্থায়ীভাবে মুছে ফেলতে চান?",
"confirmDeleteMultipleItemsWithFolders": "আপনি কি {{count}} টি আইটেম স্থায়ীভাবে মুছে ফেলতে নিশ্চিত? এর মধ্যে ফোল্ডার এবং তার ভেতরের সবকিছু অন্তর্ভুক্ত।",
"confirmDeleteFolder": "আপনি কি \"{{name}}\" ফোল্ডারটি এবং এর ভেতরের সবকিছু স্থায়ীভাবে মুছে ফেলতে চান?",
"permanentDeleteWarning": "এই কাজটি পূর্বাবস্থায় ফেরানো যাবে না। আইটেম(গুলি) সার্ভার থেকে স্থায়ীভাবে মুছে ফেলা হবে।",
"moveToTrashWarning": "আইটেমটি টারমিক্স ট্র্যাশে স্থানান্তরিত হবে এবং সংরক্ষণের সময়সীমা শেষ হওয়ার আগে তা পুনরুদ্ধার করা যাবে।",
"itemsMovedToTrash": "{{count}} আইটেম(গুলি) ট্র্যাশে সরানো হয়েছে",
"trash": "আবর্জনা",
"trashDescription": "এই হোস্টে মুছে ফেলা আইটেমগুলি {{days}} দিন পর্যন্ত সংরক্ষিত থাকে।",
"trashLoadFailed": "ট্র্যাশ লোড করতে ব্যর্থ হয়েছে",
"trashEmpty": "ময়লার ঝুড়ি খালি",
"trashRestore": "পুনরুদ্ধার করুন",
"trashRestored": "পুনরুদ্ধার করা হয়েছে {{name}}",
"trashRestoreFailed": "আইটেমটি পুনরুদ্ধার করা সম্ভব হয়নি। মূল পাথটি হয়তো ইতিমধ্যেই বিদ্যমান আছে।",
"trashDeletePermanently": "স্থায়ীভাবে মুছে ফেলুন",
"trashDeleteConfirm": "{{name}}স্থায়ীভাবে মুছে ফেলবেন? এটি আর পূর্বাবস্থায় ফেরানো যাবে না।",
"trashPermanentlyDeleted": "স্থায়ীভাবে মুছে ফেলা হয়েছে {{name}}",
"trashDeleteFailed": "আইটেমটি স্থায়ীভাবে মুছে ফেলতে ব্যর্থ হয়েছে",
"trashEmptyAction": "ময়লা খালি করুন",
"trashEmptyConfirm": "ট্র্যাশের প্রতিটি আইটেম স্থায়ীভাবে মুছে ফেলবেন? এটি আর পূর্বাবস্থায় ফেরানো যাবে না।",
"trashEmptied": "ময়লা খালি করা হয়েছে",
"trashRetention": "(দিন) পর স্বয়ংক্রিয়ভাবে পরিষ্কার করা হবে",
"trashRetentionSaved": "আবর্জনা সংরক্ষণের সময় আপডেট করা হয়েছে",
"trashRetentionFailed": "ট্র্যাশ রিটেনশন আপডেট করতে ব্যর্থ হয়েছে",
"trashUnavailableConfirm": "এই আইটেমটি ট্র্যাশে সরানো যাচ্ছে না, কারণ এটি সাধারণত অন্য ফাইলসিস্টেমে রয়েছে অথবা এর জন্য বিশেষ অনুমতির প্রয়োজন। এর পরিবর্তে এটি স্থায়ীভাবে মুছে ফেলবেন?",
"recent": "সাম্প্রতিক",
"pinned": "পিন করা",
"folderShortcuts": "ফোল্ডার শর্টকাট",
"failedToReconnectSSH": "SSH সেশন পুনরায় সংযোগ করতে ব্যর্থ হয়েছে",
"openTerminalHere": "এখানে টার্মিনাল খুলুন",
"run": "দৌড়",
"openTerminalInFolder": "এই ফোল্ডারে টার্মিনাল খুলুন",
"openTerminalInFileLocation": "ফাইল লোকেশনে টার্মিনাল খুলুন",
"runningFile": "দৌড়ানো - {{file}}",
"onlyRunExecutableFiles": "শুধুমাত্র এক্সিকিউটেবল ফাইল চালানো যাবে",
"directories": "ডিরেক্টরি",
"removedFromRecentFiles": "সাম্প্রতিক ফাইলগুলি থেকে \"{{name}}\" সরানো হয়েছে",
"removeFailed": "ব্যর্থ অপসারণ",
"unpinnedSuccessfully": "\"{{name}}\" সফলভাবে আনপিন করা হয়েছে",
"unpinFailed": "আনপিন ব্যর্থ হয়েছে",
"removedShortcut": "\"{{name}} \" শর্টকাটটি সরানো হয়েছে",
"removeShortcutFailed": "শর্টকাট অপসারণ ব্যর্থ হয়েছে",
"clearedAllRecentFiles": "সাম্প্রতিক সমস্ত ফাইল মুছে ফেলা হয়েছে",
"clearFailed": "পরিষ্কার করতে ব্যর্থ হয়েছে",
"removeFromRecentFiles": "সাম্প্রতিক ফাইলগুলি থেকে সরান",
"clearAllRecentFiles": "সাম্প্রতিক সমস্ত ফাইল মুছে ফেলুন",
"unpinFile": "ফাইলটি আনপিন করুন",
"removeShortcut": "শর্টকাট সরান",
"pinFile": "পিন ফাইল",
"addToShortcuts": "শর্টকাটে যোগ করুন",
"pasteFailed": "পেস্ট ব্যর্থ হয়েছে",
"noUndoableActions": "কোনো পূর্বাবস্থায় ফেরানো যায় না এমন কাজ নেই।",
"undoCopySuccess": "কপি অপারেশন বাতিল করা হয়েছে: {{count}} টি কপি করা ফাইল মুছে ফেলা হয়েছে",
"undoCopyFailedDelete": "পূর্বাবস্থায় ফেরানো ব্যর্থ হয়েছে: কপি করা কোনো ফাইল মুছে ফেলা সম্ভব হয়নি",
"undoCopyFailedNoInfo": "পূর্বাবস্থায় ফেরানো ব্যর্থ হয়েছে: কপি করা ফাইলের তথ্য খুঁজে পাওয়া যায়নি",
"undoMoveSuccess": "স্থানান্তর প্রক্রিয়া বাতিল করা হয়েছে: {{count}} টি ফাইল মূল অবস্থানে ফিরিয়ে আনা হয়েছে",
"undoMoveFailedMove": "পূর্বাবস্থায় ফেরানো ব্যর্থ হয়েছে: কোনো ফাইল আগের জায়গায় সরানো সম্ভব হয়নি",
"undoMoveFailedNoInfo": "পূর্বাবস্থায় ফেরানো ব্যর্থ হয়েছে: স্থানান্তরিত ফাইলের তথ্য খুঁজে পাওয়া যায়নি",
"undoDeleteNotSupported": "মুছে ফেলার প্রক্রিয়াটি পূর্বাবস্থায় ফেরানো যাবে না: ফাইলগুলো সার্ভার থেকে স্থায়ীভাবে মুছে ফেলা হয়েছে।",
"undoTypeNotSupported": "অসমর্থিত পূর্বাবস্থায় ফেরানোর অপারেশনের ধরণ",
"undoOperationFailed": "পূর্বাবস্থায় ফেরানো ব্যর্থ হয়েছে",
"unknownError": "অজানা ত্রুটি",
"confirm": "নিশ্চিত করুন",
"find": "খুঁজুন...",
"replace": "প্রতিস্থাপন করুন",
"downloadInstead": "পরিবর্তে ডাউনলোড করুন",
"keyboardShortcuts": "কীবোর্ড শর্টকাট",
"searchAndReplace": "অনুসন্ধান ও প্রতিস্থাপন",
"editing": "সম্পাদনা",
"search": "অনুসন্ধান",
"findNext": "পরবর্তী খুঁজুন",
"findPrevious": "পূর্ববর্তী খুঁজুন",
"save": "সংরক্ষণ করুন",
"selectAll": "সব নির্বাচন করুন",
"undo": "পূর্বাবস্থায় ফেরান",
"redo": "পুনরায় করুন",
"moveLineUp": "লাইন আপ সরান",
"moveLineDown": "লাইন নিচে সরান",
"toggleComment": "মন্তব্য টগল করুন",
"autoComplete": "স্বয়ংক্রিয়ভাবে সম্পূর্ণ",
"imageLoadError": "ছবি লোড করতে ব্যর্থ হয়েছে",
"startTyping": "টাইপ করা শুরু করুন...",
"unknownSize": "অজানা আকার",
"fileIsEmpty": "ফাইলটি খালি",
"largeFileWarning": "বড় ফাইলের সতর্কতা",
"largeFileWarningDesc": "এই ফাইলটির আকার {{size}} , যা টেক্সট হিসেবে খুললে পারফরম্যান্সের সমস্যা সৃষ্টি করতে পারে।",
"fileNotFoundAndRemoved": "\"{{name}}\" ফাইলটি খুঁজে পাওয়া যায়নি এবং সাম্প্রতিক/পিন করা ফাইলগুলো থেকে সরিয়ে ফেলা হয়েছে।",
"failedToLoadFile": "ফাইল লোড করতে ব্যর্থ: {{error}}",
"serverErrorOccurred": "সার্ভার ত্রুটি ঘটেছে। অনুগ্রহ করে পরে আবার চেষ্টা করুন।",
"autoSaveFailed": "স্বয়ংক্রিয় সংরক্ষণ ব্যর্থ হয়েছে",
"fileAutoSaved": "ফাইল স্বয়ংক্রিয়ভাবে সংরক্ষিত হয়েছে",
"moveFileFailed": "{{name}} সরাতে ব্যর্থ",
"moveOperationFailed": "স্থানান্তর প্রক্রিয়া ব্যর্থ হয়েছে",
"canOnlyCompareFiles": "শুধুমাত্র দুটি ফাইল তুলনা করা যাবে।",
"comparingFiles": "ফাইলগুলোর তুলনা: {{file1}} এবং {{file2}}",
"dragFailed": "ড্র্যাগ অপারেশন ব্যর্থ হয়েছে",
"filePinnedSuccessfully": "ফাইল \"{{name}}\" সফলভাবে পিন করা হয়েছে",
"pinFileFailed": "ফাইল পিন করতে ব্যর্থ হয়েছে",
"fileUnpinnedSuccessfully": "ফাইল \"{{name}}\" সফলভাবে আনপিন করা হয়েছে",
"unpinFileFailed": "ফাইল আনপিন করতে ব্যর্থ হয়েছে",
"shortcutAddedSuccessfully": "ফোল্ডার শর্টকাট \"{{name}}\" সফলভাবে যোগ করা হয়েছে",
"addShortcutFailed": "শর্টকাট যোগ করতে ব্যর্থ হয়েছে",
"operationCompletedSuccessfully": "{{operation}} {{count}} আইটেম সফলভাবে",
"operationCompleted": "{{operation}} {{count}} আইটেম",
"downloadFileSuccess": "ফাইল {{name}} সফলভাবে ডাউনলোড হয়েছে",
"downloadFileFailed": "ডাউনলোড ব্যর্থ হয়েছে",
"downloadingFile": "ডাউনলোড হচ্ছে {{name}}…",
"downloadProgressBytes": "{{transferred}} / {{total}}",
"moveTo": "{{name}} এ যান",
"diffCompareWith": "{{name}} এর সাথে পার্থক্য তুলনা করুন",
"dragOutsideToDownload": "ডাউনলোড করতে উইন্ডোর বাইরে টেনে আনুন ({{count}} ফাইলগুলো)",
"newFolderDefault": "নতুন ফোল্ডার",
"newFileDefault": "NewFile.txt",
"successfullyMovedItems": "{{count}} টি আইটেম সফলভাবে {{target}} টি আইটেমে সরানো হয়েছে",
"move": "স্থানান্তর",
"searchInFile": "ফাইলে অনুসন্ধান করুন (Ctrl+F)",
"showKeyboardShortcuts": "কিবোর্ড শর্টকাট দেখান",
"decreaseFontSize": "ফন্টের আকার কমান",
"increaseFontSize": "ফন্টের আকার বাড়ান",
"startWritingMarkdown": "আপনার মার্কডাউন কন্টেন্ট লেখা শুরু করুন...",
"loadingFileComparison": "ফাইল তুলনা লোড হচ্ছে...",
"reload": "পুনরায় লোড করুন",
"compare": "তুলনা করুন",
"sideBySide": "পাশাপাশি",
"inline": "ইনলাইন",
"fileComparison": "ফাইল তুলনা: {{file1}} বনাম {{file2}}",
"fileTooLarge": "ফাইলের আকার অনেক বড়: {{error}}",
"sshConnectionFailed": "SSH সংযোগ ব্যর্থ হয়েছে। অনুগ্রহ করে {{name}} ({{ip}}:{{port}} ) -এ আপনার সংযোগ পরীক্ষা করুন।",
"loadFileFailed": "ফাইল লোড করতে ব্যর্থ: {{error}}",
"connecting": "সংযোগ স্থাপন...",
"connectedSuccessfully": "সফলভাবে সংযুক্ত হয়েছে",
"totpVerificationFailed": "TOTP যাচাইকরণ ব্যর্থ হয়েছে",
"warpgateVerificationFailed": "ওয়ার্পগেট প্রমাণীকরণ ব্যর্থ হয়েছে",
"authenticationFailed": "প্রমাণীকরণ ব্যর্থ হয়েছে",
"incorrectPassphrase": "ভুল পাসফ্রেজ। অনুগ্রহ করে আবার চেষ্টা করুন।",
"verificationCodePrompt": "যাচাইকরণ কোড:",
"changePermissions": "অনুমতি পরিবর্তন করুন",
"currentPermissions": "বর্তমান অনুমতি",
"owner": "মালিক",
"group": "গ্রুপ",
"others": "অন্যান্য",
"read": "পড়ুন",
"write": "লিখুন",
"execute": "কার্যকর করুন",
"permissionsChangedSuccessfully": "অনুমতি সফলভাবে পরিবর্তন করা হয়েছে",
"failedToChangePermissions": "অনুমতি পরিবর্তন করতে ব্যর্থ হয়েছে",
"name": "নাম",
"sortByName": "নাম",
"sortByDate": "পরিবর্তিত তারিখ",
"sortBySize": "আকার",
"ascending": "আরোহী",
"descending": "অবতরণ",
"root": "মূল",
"new": "নতুন",
"sortBy": "অনুসারে সাজান",
"items": "আইটেম",
"selected": "নির্বাচিত",
"editor": "সম্পাদক",
"octal": "অক্টাল",
"storage": "স্টোরেজ",
"disk": "ডিস্ক",
"used": "ব্যবহৃত",
"of": "এর",
"toggleSidebar": "সাইডবার টগল করুন",
"compactLayout": "সংক্ষিপ্ত বিন্যাস ব্যবহার করুন",
"comfortableLayout": "আরামদায়ক বিন্যাস ব্যবহার করুন",
"cannotLoadPdf": "পিডিএফ লোড করা যাচ্ছে না",
"pdfLoadError": "এই পিডিএফ ফাইলটি লোড করতে ত্রুটি হয়েছে।",
"loadingPdf": "পিডিএফ লোড হচ্ছে...",
"loadingPage": "পৃষ্ঠা লোড হচ্ছে..."
},
"transfer": {
"copyToHost": "হোস্টে কপি করুন…",
"moveToHost": "হোস্টে যান…",
@@ -2292,50 +1951,6 @@
"retryTransferError": "পুনরায় চেষ্টা ব্যর্থ হয়েছে",
"transferFailedRetryHint": "গন্তব্যে আংশিক ডেটা সংরক্ষিত হয়েছে। সংযোগ ফিরে এলে পুনরায় চেষ্টা শুরু হবে।"
},
"sftpTransfer": {
"title": "এসএফটিপি",
"description": "দুটি SSH হোস্টের মধ্যে ফাইল স্থানান্তর করুন।",
"sourceServer": "উৎস সার্ভার",
"destinationServer": "গন্তব্য সার্ভার",
"selectHost": "হোস্ট নির্বাচন করুন",
"noHostSelected": "কোন হোস্ট নির্বাচন করা হয়নি",
"move": "স্থানান্তর",
"copy": "অনুলিপি",
"upload": "আপলোড",
"itemsSelected": "{{count}} আইটেম নির্বাচিত",
"itemsSelected_other": "{{count}} নির্বাচিত আইটেম",
"ready": "প্রস্তুত",
"loadingHosts": "হোস্ট লোড হচ্ছে...",
"name": "নাম",
"modified": "পরিবর্তিত",
"size": "আকার",
"noFiles": "কোন ফাইল নেই",
"open": "খোলা",
"up": "উপরে",
"refresh": "রিফ্রেশ",
"copyToTarget": "লক্ষ্য ডিরেক্টরিতে অনুলিপি করুন",
"rename": "নাম পরিবর্তন",
"delete": "মুছে ফেলুন",
"editPermissions": "সম্পাদনার অনুমতি",
"createNewFolder": "নতুন ফোল্ডার তৈরি করুন",
"deleteSelectedItems": "নির্বাচিত আইটেমগুলি মুছুন",
"deleteConfirm": "{{count}} নির্বাচিত আইটেমটি মুছবেন?",
"deleteConfirm_other": "{{count}} নির্বাচিত আইটেমগুলি মুছবেন?",
"cancel": "বাতিল করুন",
"save": "সংরক্ষণ করুন",
"folderCreated": "ফোল্ডার তৈরি করা হয়েছে",
"itemRenamed": "আইটেমের নাম পরিবর্তন করা হয়েছে",
"actionFailed": "পদক্ষেপ ব্যর্থ হয়েছে",
"itemsDeleted": "{{count}} আইটেমটি মুছে ফেলা হয়েছে",
"itemsDeleted_other": "{{count}} আইটেম মুছে ফেলা হয়েছে",
"deleteFailed": "মুছে ফেলা ব্যর্থ হয়েছে",
"permissionsUpdated": "অনুমতি আপডেট করা হয়েছে",
"noRemoteSessionConnected": "কোন রিমোট সেশন সংযুক্ত নেই",
"failedToListFiles": "ফাইলগুলি তালিকাভুক্ত করতে ব্যর্থ হয়েছে",
"failedToRefresh": "রিফ্রেশ করতে ব্যর্থ হয়েছে",
"destinationInsideSource": "গন্তব্য নির্বাচিত উৎস পথের মধ্যে থাকতে পারবে না।",
"sameHostNoOp": "উৎস এবং গন্তব্য একই ফোল্ডার।"
},
"tunnels": {
"noSshTunnels": "কোন SSH টানেল নেই",
"createFirstTunnelMessage": "আপনি এখনও কোনো SSH টানেল তৈরি করেননি। শুরু করার জন্য হোস্ট ম্যানেজারে টানেল সংযোগগুলি কনফিগার করুন।",
-385
View File
@@ -715,7 +715,6 @@
"hostManager": "Gestor d'amfitrions",
"credentials": "Credencials",
"portForwarding": "Reenviament",
"sftp": "SFTP",
"connections": "Connexions",
"serial": "Sèrie",
"keepSideMenuOpen": "Mantenir el menú obert",
@@ -767,14 +766,12 @@
"tabRdp": "RDP",
"tabVnc": "VNC",
"tabTunnels": "Túnels",
"tabFiles": "Fitxers",
"tabStats": "Mètriques de l'amfitrió",
"tabTelnet": "Telnet",
"tabSharing": "Compartir",
"tabAuthentication": "Autenticació",
"terminal": "Terminal",
"tunnel": "Túnel",
"fileManager": "Gestor de fitxers",
"serverStats": "Mètriques de l'amfitrió",
"status": {
"available": "Disponible",
@@ -1118,12 +1115,6 @@
"proxmoxResultSkippedNoIp": "{{count}} omès (no s'ha trobat cap IP)",
"proxmoxStatsUrlCopied": "URL de les estadístiques de Proxmox copiada al porta-retalls",
"copyProxmoxStatsUrlAction": "Copia l'URL de les estadístiques de Proxmox",
"enableFileManagerMonitor": "Habilita el gestor de fitxers",
"enableFileManagerMonitorDesc": "Navega i gestiona fitxers en aquest amfitrió mitjançant SFTP",
"scpLegacyLabel": "Mode Llegat de SCP",
"scpLegacyDesc": "Feu servir la transferència de fitxers antiga per a servidors que no admeten el subsistema SFTP (per exemple, servidors SSH integrats o mínims).",
"defaultPathLabel": "Camí predeterminat",
"fileManagerPathHint": "El directori que s'obrirà quan s'iniciï el gestor de fitxers per a aquest amfitrió.",
"statusChecksLabel": "Comprovacions d'estat",
"enableStatusChecks": "Activa les comprovacions d'estat",
"enableStatusChecksDesc": "Fes ping periòdicament a aquest host per verificar la disponibilitat",
@@ -1278,7 +1269,6 @@
"deletedHost": "Suprimit {{name}}",
"copiedToClipboard": "Copiat al porta-retalls",
"terminalUrlCopied": "S'ha copiat l'URL del terminal",
"fileManagerUrlCopied": "S'ha copiat l'URL del gestor de fitxers",
"tunnelUrlCopied": "S'ha copiat l'URL del túnel",
"dockerUrlCopied": "S'ha copiat l'URL de Docker",
"hostMetricsUrlCopied": "URL de mètriques de l'amfitrió copiada",
@@ -1296,7 +1286,6 @@
"copyAddress": "Copiar adreça",
"copyLink": "Copia l'enllaç",
"copyTerminalUrlAction": "Copia l'URL del terminal",
"copyFileManagerUrlAction": "Copia l'URL del gestor de fitxers",
"copyTunnelUrlAction": "Copia l'URL del túnel",
"copyDockerUrlAction": "Copia l'URL de Docker",
"copyHostMetricsUrlAction": "Copia l'URL de les mètriques de l'amfitrió",
@@ -1857,336 +1846,6 @@
"removeFromSplit": "Elimina de Split"
}
},
"fileManager": {
"noHostSelected": "No s'ha seleccionat cap amfitrió",
"initializingEditor": "Inicialitzant l'editor...",
"file": "Fitxer",
"folder": "Carpeta",
"uploadFile": "Puja el fitxer",
"downloadFile": "Descarrega",
"extractArchive": "Extreure l'arxiu",
"extractingArchive": "Extraient {{name}}...",
"archiveExtractedSuccessfully": "{{name}} s'ha extret correctament",
"extractFailed": "L'extracció ha fallat",
"compressFile": "Comprimir fitxer",
"compressFiles": "Comprimir fitxers",
"compressFilesDesc": "Comprimir elements {{count}} en un arxiu",
"archiveName": "Nom de l'arxiu",
"enterArchiveName": "Introduïu el nom de l'arxiu...",
"compressionFormat": "Format de compressió",
"selectedFiles": "Fitxers seleccionats",
"andMoreFiles": "i {{count}} més...",
"compress": "Comprimir",
"compressingFiles": "Comprimint elements {{count}} en {{name}}...",
"filesCompressedSuccessfully": "{{name}} creat correctament",
"compressFailed": "La compressió ha fallat",
"edit": "Edita",
"preview": "Vista prèvia",
"previous": "Anterior",
"next": "Següent",
"pageXOfY": "Pàgina {{current}} de {{total}}",
"zoomOut": "Allunyar",
"zoomIn": "Ampliar zoom",
"newFile": "Fitxer nou",
"newFolder": "Nova carpeta",
"rename": "Canvia el nom",
"uploading": "S'està carregant...",
"uploadingFile": "S'està carregant {{name}}...",
"fileName": "Nom del fitxer",
"folderName": "Nom de la carpeta",
"fileUploadedSuccessfully": "El fitxer \"{{name}}\" s'ha carregat correctament",
"failedToUploadFile": "No s'ha pogut carregar el fitxer",
"uploadingFolderFiles": "S'estan pujant {{count}} fitxer(s)...",
"uploadedFolderFiles": "S'han carregat correctament els fitxers {{count}}",
"uploadedFolderPartial": "No s'ha pogut pujar el(s) fitxer(s) {{uploaded}} , {{failed}}",
"fileDownloadedSuccessfully": "Fitxer \"{{name}}\" descarregat correctament",
"failedToDownloadFile": "No s'ha pogut descarregar el fitxer",
"fileCreatedSuccessfully": "Fitxer \"{{name}}\" creat correctament",
"folderCreatedSuccessfully": "La carpeta \"{{name}}\" s'ha creat correctament",
"failedToCreateItem": "No s'ha pogut crear l'element",
"operationFailed": "L'operació {{operation}} ha fallat per a {{name}}: {{error}}",
"failedToResolveSymlink": "No s'ha pogut resoldre l'enllaç simbòlic",
"itemsDeletedSuccessfully": "{{count}} elements suprimits correctament",
"failedToDeleteItems": "No s'han pogut suprimir els elements",
"sudoPasswordRequired": "Contrasenya d'administrador requerida",
"enterSudoPassword": "Introduïu la contrasenya sudo per continuar amb aquesta operació",
"sudoPassword": "Contrasenya de Sudo",
"sudoOperationFailed": "L'operació Sudo ha fallat.",
"sudoAuthFailed": "L'autenticació de Sudo ha fallat.",
"dragFilesToUpload": "Deixa anar els fitxers aquí per pujar-los",
"dropToUploadHere": "Deixa anar per pujar aquí",
"dropToDownloadHere": "Deixa anar per descarregar aquí",
"localFiles": "Local",
"showLocalFiles": "Mostra els fitxers locals (vista dividida)",
"hideLocalFiles": "Amaga els fitxers locals",
"localHome": "Carpeta d'inici",
"localPathPlaceholder": "Ruta de la carpeta local",
"localShowHidden": "Mostra els fitxers ocults",
"localHideHidden": "Amaga els fitxers ocults",
"localRevealInFileManager": "Mostra al gestor de fitxers del sistema",
"localCannotOpenFolder": "No es pot obrir la carpeta",
"localItemCount": "{{count}} elements",
"localSelectedCount": "{{count}} seleccionat",
"back": "Enrere",
"forward": "Endavant",
"up": "Amunt",
"kind": "Amable",
"localUploadingProgress": "S'està carregant {{current}} de {{total}}",
"localDownloadingProgress": "Descarregant {{current}} de {{total}}",
"localTransferInFlight": "{{count}} alhora",
"localPreparingDownload": "Preparant la descàrrega...",
"localTransferCancelled": "Transferència cancel·lada",
"localNothingToTransfer": "Res a transferir",
"localUploadComplete": "Fitxer(s) {{count}} penjat(s)",
"localUploadFailed": "La pujada ha fallat",
"localUploadPartial": "No s'ha pogut pujar el(s) fitxer(s) {{done}} , {{failed}}",
"localDownloadComplete": "Fitxer(s) descarregat(s) {{count}}",
"localDownloadFailed": "La descàrrega ha fallat",
"localDownloadPartial": "No s'ha pogut descarregar el(s) fitxer(s) {{done}} , {{failed}}.",
"localReplaceExistingPrompt": "Ja existeixen elements {{count}} en aquesta carpeta. Voleu substituir-los?",
"localReplace": "Substitueix",
"localSkipExisting": "Omet",
"localSkippedExisting": "S'han omès {{count}} elements existents",
"localUnsafeNamesSkipped": "S'han omès {{count}} elements els noms dels quals no es poden utilitzar en aquest ordinador",
"localOpen": "Obre",
"localOpenFolder": "Obre la carpeta",
"localUploadToRemote": "Puja al servidor",
"localUploadToRemoteMany": "Puja {{count}} elements al servidor",
"localMoveToTrash": "Mou a la paperera",
"localMoveToTrashMany": "Mou {{count}} elements a la paperera",
"localTrashConfirmSingle": "Voleu moure \"{{name}}\" a la paperera?",
"localTrashConfirmFolder": "Voleu moure la carpeta \"{{name}}\" i tot el seu contingut a la paperera?",
"localTrashConfirmMany": "Voleu moure {{count}} elements a la paperera?",
"localTrashed": "S'han mogut {{count}} elements a la paperera.",
"localTrashFailed": "No s'ha pogut moure a la paperera",
"localRenameFailed": "No s'ha pogut canviar el nom",
"localCreateFailed": "No s'ha pogut crear l'element",
"goToParentFolder": "Anar a la carpeta principal",
"resizeSidebar": "Arrossegueu per canviar la mida de la barra lateral (feu doble clic per restablir-la)",
"resizeLocalPane": "Arrossegueu per canviar la mida del panell local (feu doble clic per restablir-lo)",
"columns": "Columnes",
"columnsHint": "Feu clic amb el botó dret per triar columnes",
"emptyFolder": "Aquesta carpeta està buida",
"searchFiles": "Cerca fitxers...",
"upload": "Pujada",
"selectHostToStart": "Seleccioneu un amfitrió per iniciar la gestió de fitxers",
"sshRequiredForFileManager": "El gestor de fitxers requereix SSH. Aquest amfitrió no té SSH habilitat.",
"failedToConnect": "No s'ha pogut connectar a SSH",
"failedToLoadDirectory": "No s'ha pogut carregar el directori",
"noSSHConnection": "No hi ha connexió SSH disponible",
"copy": "Còpia",
"cut": "Tallar",
"paste": "Enganxa",
"copyPath": "Copia la ruta",
"copyPaths": "Copiar camins",
"delete": "Suprimeix",
"properties": "Propietats",
"refresh": "Actualitza",
"downloadFiles": "Baixeu els fitxers {{count}} al navegador",
"copyFiles": "Copia elements {{count}}",
"cutFiles": "Retalla elements {{count}}",
"deleteFiles": "Suprimeix elements {{count}}",
"filesCopiedToClipboard": "{{count}} elements copiats al porta-retalls",
"filesCutToClipboard": "{{count}} elements retallats al porta-retalls",
"pathCopiedToClipboard": "La ruta s'ha copiat al porta-retalls",
"pathsCopiedToClipboard": "{{count}} camins copiats al porta-retalls",
"failedToCopyPath": "No s'ha pogut copiar la ruta al porta-retalls",
"copyFolderLink": "Copia l'enllaç a la carpeta",
"copyCurrentFolderLink": "Copia l'enllaç a la carpeta actual",
"folderLinkCopied": "Enllaç de carpeta copiat al porta-retalls",
"failedToCopyFolderLink": "No s'ha pogut copiar l'enllaç de la carpeta",
"movedItems": "Elements moguts {{count}}",
"failedToDeleteItem": "No s'ha pogut suprimir l'element",
"itemRenamedSuccessfully": "{{type}} s'ha canviat el nom correctament",
"failedToRenameItem": "No s'ha pogut canviar el nom de l'element",
"download": "Descarrega",
"openExternalEditor": "Obre externament",
"chooseExternalEditor": "Trieu un editor extern",
"externalEditorSelected": "Editor extern seleccionat",
"externalEditorDesktopOnly": "L'editor extern només està disponible a l'aplicació d'escriptori",
"externalEditorOpened": "Obert en un editor extern. Si deseu-ho, els canvis es tornaran a carregar al servidor.",
"failedToOpenExternalEditor": "No s'ha pogut obrir l'editor extern",
"failedToSelectExternalEditor": "No s'ha pogut seleccionar l'editor extern",
"permissions": "Permisos",
"size": "Mida",
"modified": "Modificat",
"path": "Camí",
"confirmDelete": "Esteu segur que voleu suprimir {{name}}?",
"permissionDenied": "Permís denegat",
"serverError": "Error del servidor",
"fileSavedSuccessfully": "Fitxer desat correctament",
"failedToSaveFile": "No s'ha pogut desar el fitxer",
"confirmDeleteSingleItem": "Esteu segur que voleu suprimir permanentment \"{{name}}\"?",
"confirmDeleteMultipleItems": "Esteu segur que voleu suprimir permanentment els elements {{count}}?",
"confirmDeleteMultipleItemsWithFolders": "Esteu segur que voleu suprimir permanentment els elements {{count}} ? Això inclou les carpetes i el seu contingut.",
"confirmDeleteFolder": "Esteu segur que voleu suprimir permanentment la carpeta \"{{name}}\" i tot el seu contingut?",
"permanentDeleteWarning": "Aquesta acció no es pot desfer. Els elements s'eliminaran permanentment del servidor.",
"moveToTrashWarning": "Els elements es mouran a la paperera de Termix i es podran restaurar abans que expiri el període de retenció.",
"itemsMovedToTrash": "{{count}} element(s) mogut(s) a la paperera",
"trash": "Escombraries",
"trashDescription": "Els elements suprimits es conserven durant {{days}} dies en aquest amfitrió.",
"trashLoadFailed": "No s'ha pogut carregar la paperera",
"trashEmpty": "La paperera és buida",
"trashRestore": "Restaurar",
"trashRestored": "Restaurat {{name}}",
"trashRestoreFailed": "No s'ha pogut restaurar l'element. És possible que la ruta original ja existeixi.",
"trashDeletePermanently": "Suprimeix permanentment",
"trashDeleteConfirm": "Voleu suprimir permanentment {{name}}? Això no es pot desfer.",
"trashPermanentlyDeleted": "Suprimit permanentment {{name}}",
"trashDeleteFailed": "No s'ha pogut suprimir l'element permanentment",
"trashEmptyAction": "Buida la paperera",
"trashEmptyConfirm": "Voleu suprimir permanentment tots els elements de la paperera? Això no es pot desfer.",
"trashEmptied": "Paperera buida",
"trashRetention": "Neteja automàtica després de (dies)",
"trashRetentionSaved": "Retenció de brossa actualitzada",
"trashRetentionFailed": "No s'ha pogut actualitzar la retenció de la paperera",
"trashUnavailableConfirm": "Aquest element no es pot moure a la paperera, normalment perquè es troba en un altre sistema de fitxers o requereix permisos elevats. Voleu suprimir-lo permanentment?",
"recent": "Recent",
"pinned": "Fixat",
"folderShortcuts": "Dreceres de carpeta",
"failedToReconnectSSH": "No s'ha pogut reconnectar la sessió SSH",
"openTerminalHere": "Obre el terminal aquí",
"run": "Corre",
"openTerminalInFolder": "Obre el terminal en aquesta carpeta",
"openTerminalInFileLocation": "Obre el terminal a la ubicació del fitxer",
"runningFile": "Córrer - {{file}}",
"onlyRunExecutableFiles": "Només pot executar fitxers executables",
"directories": "Directoris",
"removedFromRecentFiles": "S'ha eliminat \"{{name}}\" dels fitxers recents",
"removeFailed": "No s'ha pogut eliminar l'opció.",
"unpinnedSuccessfully": "S'ha desfixat \"{{name}}\" correctament",
"unpinFailed": "No s'ha pogut desancorar la fixació.",
"removedShortcut": "S'ha eliminat la drecera \"{{name}}\"",
"removeShortcutFailed": "No s'ha pogut eliminar la drecera.",
"clearedAllRecentFiles": "S'han esborrat tots els fitxers recents",
"clearFailed": "Error en esborrar",
"removeFromRecentFiles": "Elimina dels fitxers recents",
"clearAllRecentFiles": "Esborra tots els fitxers recents",
"unpinFile": "Desfixar fitxer",
"removeShortcut": "Elimina la drecera",
"pinFile": "Fitxer fixat",
"addToShortcuts": "Afegeix a les dreceres",
"pasteFailed": "No s'ha pogut enganxar",
"noUndoableActions": "Sense accions desfaibles",
"undoCopySuccess": "Operació de còpia desfeta: S'han suprimit {{count}} fitxers copiats",
"undoCopyFailedDelete": "Error en desfer: no s'han pogut suprimir els fitxers copiats.",
"undoCopyFailedNoInfo": "Error en desfer: no s'ha pogut trobar la informació del fitxer copiat",
"undoMoveSuccess": "Operació de moviment desfeta: S'han tornat a moure els fitxers {{count}} a la ubicació original.",
"undoMoveFailedMove": "Desfer ha fallat: no s'han pogut moure cap fitxer enrere",
"undoMoveFailedNoInfo": "Desfer ha fallat: no s'ha pogut trobar la informació del fitxer mogut",
"undoDeleteNotSupported": "L'operació de supressió no es pot desfer: els fitxers s'han suprimit permanentment del servidor.",
"undoTypeNotSupported": "Tipus d'operació de desfer no compatible",
"undoOperationFailed": "L'operació de desfer ha fallat",
"unknownError": "Error desconegut",
"confirm": "Confirma",
"find": "Troba...",
"replace": "Substitueix",
"downloadInstead": "Descarrega en comptes d'això",
"keyboardShortcuts": "Dreceres de teclat",
"searchAndReplace": "Cerca i substitueix",
"editing": "Edició",
"search": "Cerca",
"findNext": "Cerca el següent",
"findPrevious": "Cerca anterior",
"save": "Desa",
"selectAll": "Selecciona-ho tot",
"undo": "Desfer",
"redo": "Refer",
"moveLineUp": "Mou la línia cap amunt",
"moveLineDown": "Mou la línia cap avall",
"toggleComment": "Activa/desactiva el comentari",
"autoComplete": "Completar automàticament",
"imageLoadError": "No s'ha pogut carregar la imatge",
"startTyping": "Comença a escriure...",
"unknownSize": "Mida desconeguda",
"fileIsEmpty": "El fitxer està buit",
"largeFileWarning": "Avís de fitxer gran",
"largeFileWarningDesc": "Aquest fitxer té una mida de {{size}} , cosa que pot causar problemes de rendiment quan s'obre com a text.",
"fileNotFoundAndRemoved": "No s'ha trobat el fitxer \"{{name}}\" i s'ha eliminat dels fitxers recents/fixats.",
"failedToLoadFile": "No s'ha pogut carregar el fitxer: {{error}}",
"serverErrorOccurred": "S'ha produït un error del servidor. Torna-ho a provar més tard.",
"autoSaveFailed": "El desament automàtic ha fallat",
"fileAutoSaved": "Fitxer desat automàticament",
"moveFileFailed": "No s'ha pogut moure {{name}}",
"moveOperationFailed": "L'operació de moviment ha fallat",
"canOnlyCompareFiles": "Només es poden comparar dos fitxers",
"comparingFiles": "Comparació de fitxers: {{file1}} i {{file2}}",
"dragFailed": "L'operació d'arrossegament ha fallat",
"filePinnedSuccessfully": "Fitxer \"{{name}}\" fixat correctament",
"pinFileFailed": "No s'ha pogut fixar el fitxer",
"fileUnpinnedSuccessfully": "El fitxer \"{{name}}\" s'ha desanclat correctament",
"unpinFileFailed": "No s'ha pogut desancorar el fitxer",
"shortcutAddedSuccessfully": "Drecera de carpeta \"{{name}}\" afegida correctament",
"addShortcutFailed": "No s'ha pogut afegir la drecera",
"operationCompletedSuccessfully": "{{operation}} {{count}} elements correctament",
"operationCompleted": "{{operation}} {{count}} elements",
"downloadFileSuccess": "Fitxer {{name}} descarregat correctament",
"downloadFileFailed": "La descàrrega ha fallat",
"downloadingFile": "Descarregant {{name}}…",
"downloadProgressBytes": "{{transferred}} / {{total}}",
"moveTo": "Mou a {{name}}",
"diffCompareWith": "Comparació de diferències amb {{name}}",
"dragOutsideToDownload": "Arrossega fora de la finestra per descarregar (fitxers{{count}})",
"newFolderDefault": "NovaCarpeta",
"newFileDefault": "NouFitxer.txt",
"successfullyMovedItems": "S'han mogut correctament {{count}} elements a {{target}}",
"move": "Moure",
"searchInFile": "Cerca en un fitxer (Ctrl+F)",
"showKeyboardShortcuts": "Mostra les dreceres de teclat",
"decreaseFontSize": "Reduir la mida de la lletra",
"increaseFontSize": "Augmenta la mida de la lletra",
"startWritingMarkdown": "Comença a escriure el teu contingut de rebaixes...",
"loadingFileComparison": "S'està carregant la comparació de fitxers...",
"reload": "Torna a carregar",
"compare": "Comparar",
"sideBySide": "Costat al costat",
"inline": "En línia",
"fileComparison": "Comparació de fitxers: {{file1}} vs {{file2}}",
"fileTooLarge": "Fitxer massa gran: {{error}}",
"sshConnectionFailed": "La connexió SSH ha fallat. Si us plau, comproveu la connexió a {{name}} ({{ip}}:{{port}})",
"loadFileFailed": "No s'ha pogut carregar el fitxer: {{error}}",
"connecting": "Connectant...",
"connectedSuccessfully": "Connexió correcta",
"totpVerificationFailed": "La verificació TOTP ha fallat",
"warpgateVerificationFailed": "L'autenticació de Warpgate ha fallat",
"authenticationFailed": "L'autenticació ha fallat",
"incorrectPassphrase": "Contrasenya incorrecta. Si us plau, torneu-ho a intentar.",
"verificationCodePrompt": "Codi de verificació:",
"changePermissions": "Canvia els permisos",
"currentPermissions": "Permisos actuals",
"owner": "propietari",
"group": "Grup",
"others": "Altres",
"read": "Llegir",
"write": "Escriure",
"execute": "Executar",
"permissionsChangedSuccessfully": "Els permisos s'han canviat correctament",
"failedToChangePermissions": "No s'han pogut canviar els permisos",
"name": "Nom",
"sortByName": "Nom",
"sortByDate": "Data de modificació",
"sortBySize": "Mida",
"ascending": "Ascendent",
"descending": "Descendent",
"root": "Arrel",
"new": "Nou",
"sortBy": "Ordena per",
"items": "Articles",
"selected": "Seleccionat",
"editor": "Editor",
"octal": "Octal",
"storage": "Emmagatzematge",
"disk": "Disc",
"used": "Usat",
"of": "de",
"toggleSidebar": "Obre/tanca la barra lateral",
"compactLayout": "Utilitza un disseny compacte",
"comfortableLayout": "Utilitza un disseny còmode",
"cannotLoadPdf": "No es pot carregar el PDF",
"pdfLoadError": "S'ha produït un error en carregar aquest fitxer PDF.",
"loadingPdf": "S'està carregant el PDF...",
"loadingPage": "S'està carregant la pàgina..."
},
"transfer": {
"copyToHost": "Copia a l'amfitrió…",
"moveToHost": "Moure a l'amfitrió…",
@@ -2292,50 +1951,6 @@
"retryTransferError": "No s'ha pogut reintentar.",
"transferFailedRetryHint": "S'han conservat dades parcials a la destinació. El reintent es reprendrà quan es restableixi la connexió."
},
"sftpTransfer": {
"title": "SFTP",
"description": "Transferir fitxers entre dos hosts SSH.",
"sourceServer": "Servidor d'origen",
"destinationServer": "Servidor de destinació",
"selectHost": "Selecciona l'amfitrió",
"noHostSelected": "No s'ha seleccionat cap amfitrió",
"move": "Moure",
"copy": "Còpia",
"upload": "Pujada",
"itemsSelected": "{{count}} element seleccionat",
"itemsSelected_other": "{{count}} elements seleccionats",
"ready": "Llest",
"loadingHosts": "S'estan carregant els amfitrions...",
"name": "Nom",
"modified": "Modificat",
"size": "Mida",
"noFiles": "Sense fitxers",
"open": "Obre",
"up": "Amunt",
"refresh": "Actualitza",
"copyToTarget": "Copia al directori de destinació",
"rename": "Canvia el nom",
"delete": "Suprimeix",
"editPermissions": "Edita els permisos",
"createNewFolder": "Crea una carpeta nova",
"deleteSelectedItems": "Suprimeix els elements seleccionats",
"deleteConfirm": "Voleu suprimir l'element seleccionat {{count}}?",
"deleteConfirm_other": "Voleu suprimir els elements seleccionats {{count}}?",
"cancel": "Cancel·la",
"save": "Desa",
"folderCreated": "Carpeta creada",
"itemRenamed": "L'element s'ha rebatejat",
"actionFailed": "L'acció ha fallat",
"itemsDeleted": "{{count}} element suprimit",
"itemsDeleted_other": "{{count}} elements suprimits",
"deleteFailed": "No s'ha pogut eliminar l'opció.",
"permissionsUpdated": "Permisos actualitzats",
"noRemoteSessionConnected": "No hi ha cap sessió remota connectada",
"failedToListFiles": "No s'han pogut llistar els fitxers",
"failedToRefresh": "No s'ha pogut actualitzar",
"destinationInsideSource": "La destinació no pot ser dins de la ruta d'origen seleccionada.",
"sameHostNoOp": "L'origen i la destinació són la mateixa carpeta."
},
"tunnels": {
"noSshTunnels": "Sense túnels SSH",
"createFirstTunnelMessage": "Encara no has creat cap túnel SSH. Configureu les connexions del túnel al Gestor d'amfitrions per començar.",
-385
View File
@@ -715,7 +715,6 @@
"hostManager": "Hostitelský manažer",
"credentials": "Pověření",
"portForwarding": "Přeposílání",
"sftp": "SFTP",
"connections": "Připojení",
"serial": "Seriál",
"keepSideMenuOpen": "Ponechat nabídku otevřenou",
@@ -767,14 +766,12 @@
"tabRdp": "PRV",
"tabVnc": "VNC",
"tabTunnels": "Tunely",
"tabFiles": "Soubory",
"tabStats": "Metriky hostitele",
"tabTelnet": "Telnet",
"tabSharing": "Sdílení",
"tabAuthentication": "Ověřování",
"terminal": "Terminál",
"tunnel": "Tunel",
"fileManager": "Správce souborů",
"serverStats": "Metriky hostitele",
"status": {
"available": "K dispozici",
@@ -1118,12 +1115,6 @@
"proxmoxResultSkippedNoIp": "{{count}} přeskočeno (nenalezena žádná IP adresa)",
"proxmoxStatsUrlCopied": "URL statistik Proxmoxu zkopírována do schránky",
"copyProxmoxStatsUrlAction": "Kopírovat URL statistik Proxmoxu",
"enableFileManagerMonitor": "Povolit Správce souborů",
"enableFileManagerMonitorDesc": "Procházení a správa souborů na tomto hostiteli přes SFTP",
"scpLegacyLabel": "Starší režim SCP",
"scpLegacyDesc": "Pro servery, které nepodporují subsystém SFTP (např. vestavěné nebo minimální SSH servery), použijte starší přenos souborů.",
"defaultPathLabel": "Výchozí cesta",
"fileManagerPathHint": "Adresář, který se má otevřít při spuštění správce souborů pro tento hostitel.",
"statusChecksLabel": "Kontroly stavu",
"enableStatusChecks": "Povolit kontroly stavu",
"enableStatusChecksDesc": "Pravidelně pingovat tento hostitel pro ověření dostupnosti",
@@ -1278,7 +1269,6 @@
"deletedHost": "Smazáno {{name}}",
"copiedToClipboard": "Zkopírováno do schránky",
"terminalUrlCopied": "URL terminálu zkopírována",
"fileManagerUrlCopied": "URL adresa Správce souborů zkopírována",
"tunnelUrlCopied": "URL tunelu zkopírována",
"dockerUrlCopied": "URL adresa Dockeru zkopírována",
"hostMetricsUrlCopied": "URL adresa metrik hostitele zkopírována",
@@ -1296,7 +1286,6 @@
"copyAddress": "Kopírovat adresu",
"copyLink": "Kopírovat odkaz",
"copyTerminalUrlAction": "Kopírovat URL terminálu",
"copyFileManagerUrlAction": "Kopírovat URL Správce souborů",
"copyTunnelUrlAction": "Kopírovat URL tunelu",
"copyDockerUrlAction": "Kopírovat URL Dockeru",
"copyHostMetricsUrlAction": "Kopírovat URL metrik hostitele",
@@ -1857,336 +1846,6 @@
"removeFromSplit": "Odebrat ze Splitu"
}
},
"fileManager": {
"noHostSelected": "Nebyl vybrán žádný hostitel",
"initializingEditor": "Inicializace editoru...",
"file": "Soubor",
"folder": "Složka",
"uploadFile": "Nahrát soubor",
"downloadFile": "Stáhnout",
"extractArchive": "Extrahovat archiv",
"extractingArchive": "Extrahování {{name}}...",
"archiveExtractedSuccessfully": "{{name}} byl úspěšně extrahován",
"extractFailed": "Extrakce se nezdařila",
"compressFile": "Komprimovat soubor",
"compressFiles": "Komprimovat soubory",
"compressFilesDesc": "Komprimovat {{count}} položek do archivu",
"archiveName": "Název archivu",
"enterArchiveName": "Zadejte název archivu...",
"compressionFormat": "Kompresní formát",
"selectedFiles": "Vybrané soubory",
"andMoreFiles": "a {{count}} dalších...",
"compress": "Komprimovat",
"compressingFiles": "Komprimace {{count}} položek do {{name}}...",
"filesCompressedSuccessfully": "{{name}} úspěšně vytvořeno",
"compressFailed": "Komprese selhala",
"edit": "Upravit",
"preview": "Náhled",
"previous": "Předchozí",
"next": "Další",
"pageXOfY": "Strana {{current}} z {{total}}",
"zoomOut": "Oddálit",
"zoomIn": "Přiblížit",
"newFile": "Nový soubor",
"newFolder": "Nová složka",
"rename": "Přejmenovat",
"uploading": "Nahrávání...",
"uploadingFile": "Nahrávání {{name}}...",
"fileName": "Název souboru",
"folderName": "Název složky",
"fileUploadedSuccessfully": "Soubor „{{name}}“ byl úspěšně nahrán",
"failedToUploadFile": "Nahrávání souboru se nezdařilo",
"uploadingFolderFiles": "Nahrávání {{count}} souborů...",
"uploadedFolderFiles": "Úspěšně nahráno {{count}} souborů",
"uploadedFolderPartial": "Nahrání souboru (souborů) {{uploaded}} se nezdařilo. {{failed}}",
"fileDownloadedSuccessfully": "Soubor „{{name}}“ byl úspěšně stažen",
"failedToDownloadFile": "Stažení souboru se nezdařilo",
"fileCreatedSuccessfully": "Soubor „{{name}}“ byl úspěšně vytvořen",
"folderCreatedSuccessfully": "Složka „{{name}}“ byla úspěšně vytvořena",
"failedToCreateItem": "Vytvoření položky se nezdařilo",
"operationFailed": "Operace {{operation}} se nezdařila pro {{name}}: {{error}}",
"failedToResolveSymlink": "Nepodařilo se vyřešit symbolický odkaz",
"itemsDeletedSuccessfully": "{{count}} položek bylo úspěšně smazáno",
"failedToDeleteItems": "Položky se nepodařilo smazat",
"sudoPasswordRequired": "Vyžadováno heslo správce",
"enterSudoPassword": "Pro pokračování v této operaci zadejte heslo sudo.",
"sudoPassword": "Heslo Sudo",
"sudoOperationFailed": "Operace Sudo selhala",
"sudoAuthFailed": "Ověření Sudo selhalo",
"dragFilesToUpload": "Přetáhněte sem soubory k nahrání",
"dropToUploadHere": "Přetáhněte sem pro nahrání",
"dropToDownloadHere": "Přetáhněte pro stažení sem",
"localFiles": "Místní",
"showLocalFiles": "Zobrazit lokální soubory (rozdělené zobrazení)",
"hideLocalFiles": "Skrýt lokální soubory",
"localHome": "Domovská složka",
"localPathPlaceholder": "Cesta k místní složce",
"localShowHidden": "Zobrazit skryté soubory",
"localHideHidden": "Skrýt skryté soubory",
"localRevealInFileManager": "Zobrazit ve správci systémových souborů",
"localCannotOpenFolder": "Nelze otevřít složku",
"localItemCount": "{{count}} položek",
"localSelectedCount": "{{count}} vybráno",
"back": "Zadní",
"forward": "Vpřed",
"up": "Nahoru",
"kind": "Druh",
"localUploadingProgress": "Nahrávání {{current}} z {{total}}",
"localDownloadingProgress": "Stahování {{current}} z {{total}}",
"localTransferInFlight": "{{count}} najednou",
"localPreparingDownload": "Příprava stahování...",
"localTransferCancelled": "Převod zrušen",
"localNothingToTransfer": "Nic k převodu",
"localUploadComplete": "Nahráno {{count}} souborů",
"localUploadFailed": "Nahrávání se nezdařilo",
"localUploadPartial": "Nahrání souboru (souborů) {{done}} se nezdařilo. {{failed}}",
"localDownloadComplete": "Staženo {{count}} souborů",
"localDownloadFailed": "Stahování se nezdařilo",
"localDownloadPartial": "Stažení souboru (souborů) {{done}} se nezdařilo. {{failed}}",
"localReplaceExistingPrompt": "V této složce již existuje {{count}} položek. Nahradit je?",
"localReplace": "Nahradit",
"localSkipExisting": "Přeskočit",
"localSkippedExisting": "Přeskočeno {{count}} existujících položek",
"localUnsafeNamesSkipped": "Přeskočeno {{count}} položek, jejichž názvy nelze na tomto počítači použít",
"localOpen": "OTEVŘENO",
"localOpenFolder": "Otevřít složku",
"localUploadToRemote": "Nahrát na server",
"localUploadToRemoteMany": "Nahrát {{count}} položek na server",
"localMoveToTrash": "Přesunout do koše",
"localMoveToTrashMany": "Přesunout {{count}} položek do koše",
"localTrashConfirmSingle": "Přesunout „{{name}}“ do koše?",
"localTrashConfirmFolder": "Přesunout složku „{{name}}“ a veškerý její obsah do koše?",
"localTrashConfirmMany": "Přesunout {{count}} položek do koše?",
"localTrashed": "Přesunuto {{count}} položek do koše",
"localTrashFailed": "Nepodařilo se přesunout do koše",
"localRenameFailed": "Přejmenování se nezdařilo",
"localCreateFailed": "Nepodařilo se vytvořit položku",
"goToParentFolder": "Přejít do nadřazené složky",
"resizeSidebar": "Přetažením změníte velikost postranního panelu (dvojitým kliknutím resetujete)",
"resizeLocalPane": "Přetažením změníte velikost lokálního panelu (dvojitým kliknutím resetujete).",
"columns": "Sloupce",
"columnsHint": "Kliknutím pravým tlačítkem myši vyberete sloupce",
"emptyFolder": "Tato složka je prázdná",
"searchFiles": "Hledat soubory...",
"upload": "Nahrát",
"selectHostToStart": "Vyberte hostitele pro spuštění správy souborů",
"sshRequiredForFileManager": "Správce souborů vyžaduje SSH. Tento hostitel nemá SSH povoleno.",
"failedToConnect": "Nepodařilo se připojit k SSH",
"failedToLoadDirectory": "Nepodařilo se načíst adresář",
"noSSHConnection": "Není k dispozici žádné SSH připojení",
"copy": "Kopie",
"cut": "Střih",
"paste": "Pasta",
"copyPath": "Kopírovat cestu",
"copyPaths": "Kopírovat cesty",
"delete": "Vymazat",
"properties": "Nemovitosti",
"refresh": "Obnovit",
"downloadFiles": "Stáhnout {{count}} souborů do prohlížeče",
"copyFiles": "Kopírovat {{count}} položek",
"cutFiles": "Vyjmout {{count}} položek",
"deleteFiles": "Smazat {{count}} položek",
"filesCopiedToClipboard": "{{count}} položek zkopírováno do schránky",
"filesCutToClipboard": "{{count}} položek vyjmutých do schránky",
"pathCopiedToClipboard": "Cesta zkopírována do schránky",
"pathsCopiedToClipboard": "{{count}} cest zkopírováno do schránky",
"failedToCopyPath": "Nepodařilo se zkopírovat cestu do schránky",
"copyFolderLink": "Kopírovat odkaz do složky",
"copyCurrentFolderLink": "Kopírovat odkaz do aktuální složky",
"folderLinkCopied": "Odkaz na složku byl zkopírován do schránky",
"failedToCopyFolderLink": "Nepodařilo se zkopírovat odkaz na složku",
"movedItems": "Přesunuto {{count}} položek",
"failedToDeleteItem": "Položku se nepodařilo smazat",
"itemRenamedSuccessfully": "{{type}} byl úspěšně přejmenován",
"failedToRenameItem": "Přejmenování položky se nezdařilo",
"download": "Stáhnout",
"openExternalEditor": "Otevřít externě",
"chooseExternalEditor": "Vyberte externího editora",
"externalEditorSelected": "Vybrán externí editor",
"externalEditorDesktopOnly": "Externí editor je k dispozici pouze v desktopové aplikaci",
"externalEditorOpened": "Otevřeno v externím editoru. Uložením tam se změny nahrají zpět na server.",
"failedToOpenExternalEditor": "Nepodařilo se otevřít externí editor",
"failedToSelectExternalEditor": "Nepodařilo se vybrat externí editor",
"permissions": "Oprávnění",
"size": "Velikost",
"modified": "Upraveno",
"path": "Cesta",
"confirmDelete": "Opravdu chcete smazat {{name}}?",
"permissionDenied": "Oprávnění zamítnuto",
"serverError": "Chyba serveru",
"fileSavedSuccessfully": "Soubor byl úspěšně uložen",
"failedToSaveFile": "Uložení souboru se nezdařilo",
"confirmDeleteSingleItem": "Opravdu chcete trvale smazat „{{name}}“?",
"confirmDeleteMultipleItems": "Opravdu chcete trvale smazat {{count}} položek?",
"confirmDeleteMultipleItemsWithFolders": "Opravdu chcete trvale smazat {{count}} položek? To zahrnuje i složky a jejich obsah.",
"confirmDeleteFolder": "Opravdu chcete trvale smazat složku „{{name}}“ a veškerý její obsah?",
"permanentDeleteWarning": "Tuto akci nelze vrátit zpět. Položka(y) bude(budou) trvale smazána(y) ze serveru.",
"moveToTrashWarning": "Položka(y) bude(budou) přesunuty(y) do koše Termix a bude(budou) možné je obnovit před uplynutím doby uchování.",
"itemsMovedToTrash": "{{count}} položek přesunutých do koše",
"trash": "Odpadky",
"trashDescription": "Smazané položky jsou na tomto hostiteli uchovávány po dobu {{days}} dní.",
"trashLoadFailed": "Načtení koše se nezdařilo",
"trashEmpty": "Koš je prázdný",
"trashRestore": "Obnovit",
"trashRestored": "Obnoveno {{name}}",
"trashRestoreFailed": "Obnovení položky se nezdařilo. Původní cesta již pravděpodobně existuje.",
"trashDeletePermanently": "Trvale smazat",
"trashDeleteConfirm": "Trvale smazat {{name}}? Tuto akci nelze vrátit zpět.",
"trashPermanentlyDeleted": "Trvale smazáno {{name}}",
"trashDeleteFailed": "Trvalé smazání položky se nezdařilo",
"trashEmptyAction": "Vysypat koš",
"trashEmptyConfirm": "Trvale smazat všechny položky v koši? Tuto akci nelze vrátit zpět.",
"trashEmptied": "Koš vyprázdněn",
"trashRetention": "Automatické čištění po (dnech)",
"trashRetentionSaved": "Uchovávání koše aktualizováno",
"trashRetentionFailed": "Aktualizace uchovávání v koši se nepodařilo",
"trashUnavailableConfirm": "Tuto položku nelze přesunout do koše, obvykle proto, že se nachází v jiném souborovém systému nebo vyžaduje zvýšená oprávnění. Chcete ji místo toho trvale smazat?",
"recent": "Nedávné",
"pinned": "Připnuto",
"folderShortcuts": "Zkratky složek",
"failedToReconnectSSH": "Nepodařilo se znovu připojit relaci SSH",
"openTerminalHere": "Otevřít terminál zde",
"run": "Běh",
"openTerminalInFolder": "Otevřít terminál v této složce",
"openTerminalInFileLocation": "Otevřít terminál v umístění souboru",
"runningFile": "Běh - {{file}}",
"onlyRunExecutableFiles": "Lze spustit pouze spustitelné soubory",
"directories": "Adresáře",
"removedFromRecentFiles": "Z posledních souborů odstraněno „{{name}}“",
"removeFailed": "Odebrání se nezdařilo",
"unpinnedSuccessfully": "Úspěšně odepnuto „{{name}}“",
"unpinFailed": "Odepnout se nepodařilo",
"removedShortcut": "Odstraněna zkratka „{{name}}“",
"removeShortcutFailed": "Odebrání zástupce se nezdařilo",
"clearedAllRecentFiles": "Vymazány všechny nedávné soubory",
"clearFailed": "Vymazání se nezdařilo",
"removeFromRecentFiles": "Odebrat z posledních souborů",
"clearAllRecentFiles": "Vymazat všechny nedávné soubory",
"unpinFile": "Odepnout soubor",
"removeShortcut": "Odebrat zkratku",
"pinFile": "Připnout soubor",
"addToShortcuts": "Přidat do zkratek",
"pasteFailed": "Vložení se nezdařilo",
"noUndoableActions": "Žádné akce, které lze vrátit zpět",
"undoCopySuccess": "Zrušená operace kopírování: Smazáno {{count}} zkopírovaných souborů",
"undoCopyFailedDelete": "Vrácení se nezdařilo: Nepodařilo se smazat žádné zkopírované soubory.",
"undoCopyFailedNoInfo": "Vrácení se nezdařilo: Nelze najít informace o zkopírovaném souboru.",
"undoMoveSuccess": "Zrušená operace přesunutí: {{count}} souborů bylo přesunuto zpět do původního umístění.",
"undoMoveFailedMove": "Vrácení se nezdařilo: Nepodařilo se přesunout žádné soubory zpět.",
"undoMoveFailedNoInfo": "Vrácení se nezdařilo: Nelze najít informace o přesunutém souboru.",
"undoDeleteNotSupported": "Operaci smazání nelze vrátit zpět: Soubory byly trvale smazány ze serveru.",
"undoTypeNotSupported": "Nepodporovaný typ operace vrácení zpět",
"undoOperationFailed": "Operace vrácení se nezdařila",
"unknownError": "Neznámá chyba",
"confirm": "Potvrdit",
"find": "Nalézt...",
"replace": "Nahradit",
"downloadInstead": "Stáhnout místo toho",
"keyboardShortcuts": "Klávesové zkratky",
"searchAndReplace": "Hledat a nahradit",
"editing": "Editace",
"search": "Vyhledávání",
"findNext": "Najít další",
"findPrevious": "Najít předchozí",
"save": "Uložit",
"selectAll": "Vybrat vše",
"undo": "Zpět",
"redo": "Předělat",
"moveLineUp": "Posunout seřadit nahoru",
"moveLineDown": "Posunout řádek dolů",
"toggleComment": "Přepnout komentář",
"autoComplete": "Automatické dokončení",
"imageLoadError": "Nepodařilo se načíst obrázek",
"startTyping": "Začněte psát...",
"unknownSize": "Neznámá velikost",
"fileIsEmpty": "Soubor je prázdný",
"largeFileWarning": "Varování před velkým souborem",
"largeFileWarningDesc": "Velikost tohoto souboru je {{size}} , což může při otevření jako text způsobit problémy s výkonem.",
"fileNotFoundAndRemoved": "Soubor „{{name}}“ nebyl nalezen a byl odstraněn z nedávných/připnutých souborů.",
"failedToLoadFile": "Nepodařilo se načíst soubor: {{error}}",
"serverErrorOccurred": "Došlo k chybě serveru. Zkuste to prosím znovu později.",
"autoSaveFailed": "Automatické ukládání se nezdařilo",
"fileAutoSaved": "Soubor se automaticky ukládá",
"moveFileFailed": "Přesun se nezdařilo {{name}}",
"moveOperationFailed": "Operace přesunu se nezdařila",
"canOnlyCompareFiles": "Lze porovnat pouze dva soubory",
"comparingFiles": "Porovnání souborů: {{file1}} a {{file2}}",
"dragFailed": "Operace přetažení se nezdařila",
"filePinnedSuccessfully": "Soubor „{{name}}“ byl úspěšně připnut",
"pinFileFailed": "Připnutí souboru se nezdařilo",
"fileUnpinnedSuccessfully": "Soubor „{{name}}“ byl úspěšně odepnut",
"unpinFileFailed": "Nepodařilo se odepnout soubor",
"shortcutAddedSuccessfully": "Zástupce složky „{{name}}“ byl úspěšně přidán",
"addShortcutFailed": "Přidání zástupce se nezdařilo",
"operationCompletedSuccessfully": "{{operation}} {{count}} položek úspěšně",
"operationCompleted": "{{operation}} {{count}} položek",
"downloadFileSuccess": "Soubor {{name}} byl úspěšně stažen",
"downloadFileFailed": "Stahování se nezdařilo",
"downloadingFile": "Stahování {{name}}…",
"downloadProgressBytes": "{{transferred}} / {{total}}",
"moveTo": "Přesunout na {{name}}",
"diffCompareWith": "Rozdíl ve srovnání s {{name}}",
"dragOutsideToDownload": "Přetáhněte mimo okno pro stažení ({{count}} souborů)",
"newFolderDefault": "Nová složka",
"newFileDefault": "NovýSoubor.txt",
"successfullyMovedItems": "Položky {{count}} byly úspěšně přesunuty do {{target}}",
"move": "Pohyb",
"searchInFile": "Hledat v souboru (Ctrl+F)",
"showKeyboardShortcuts": "Zobrazit klávesové zkratky",
"decreaseFontSize": "Zmenšit velikost písma",
"increaseFontSize": "Zvětšit velikost písma",
"startWritingMarkdown": "Začněte psát svůj obsah v Markdownu...",
"loadingFileComparison": "Načítání porovnání souborů...",
"reload": "Znovu načíst",
"compare": "Porovnat",
"sideBySide": "Bok po boku",
"inline": "Vložený",
"fileComparison": "Porovnání souborů: {{file1}} vs. {{file2}}",
"fileTooLarge": "Soubor je příliš velký: {{error}}",
"sshConnectionFailed": "SSH připojení selhalo. Zkontrolujte prosím připojení k {{name}} ({{ip}}:{{port}})",
"loadFileFailed": "Nepodařilo se načíst soubor: {{error}}",
"connecting": "Spojovací...",
"connectedSuccessfully": "Úspěšné připojení",
"totpVerificationFailed": "Ověření TOTP se nezdařilo",
"warpgateVerificationFailed": "Ověření Warpgate selhalo",
"authenticationFailed": "Ověření se nezdařilo",
"incorrectPassphrase": "Nesprávné heslo. Zkuste to prosím znovu.",
"verificationCodePrompt": "Ověřovací kód:",
"changePermissions": "Změnit oprávnění",
"currentPermissions": "Aktuální oprávnění",
"owner": "Majitel",
"group": "Skupina",
"others": "Ostatní",
"read": "Číst",
"write": "Napsat",
"execute": "Vykonat",
"permissionsChangedSuccessfully": "Oprávnění byla úspěšně změněna",
"failedToChangePermissions": "Nepodařilo se změnit oprávnění",
"name": "Jméno",
"sortByName": "Jméno",
"sortByDate": "Datum úpravy",
"sortBySize": "Velikost",
"ascending": "Vzestupně",
"descending": "Klesající",
"root": "Vykořenit",
"new": "Nový",
"sortBy": "Seřadit podle",
"items": "Položky",
"selected": "Vybraný",
"editor": "Editor",
"octal": "Osmičkové",
"storage": "Skladování",
"disk": "Disk",
"used": "Použitý",
"of": "z",
"toggleSidebar": "Přepnout postranní panel",
"compactLayout": "Použít kompaktní rozvržení",
"comfortableLayout": "Použijte pohodlné rozvržení",
"cannotLoadPdf": "Nelze načíst PDF",
"pdfLoadError": "Při načítání tohoto PDF souboru došlo k chybě.",
"loadingPdf": "Načítání PDF...",
"loadingPage": "Načítání stránky..."
},
"transfer": {
"copyToHost": "Kopírovat na hostitele…",
"moveToHost": "Přesunout na hostitele…",
@@ -2292,50 +1951,6 @@
"retryTransferError": "Opakovaný pokus se nezdařil",
"transferFailedRetryHint": "V cíli byla uložena částečná data. Po obnovení připojení bude pokus pokračovat."
},
"sftpTransfer": {
"title": "SFTP",
"description": "Přenos souborů mezi dvěma SSH hostiteli.",
"sourceServer": "Zdrojový server",
"destinationServer": "Cílový server",
"selectHost": "Vyberte hostitele",
"noHostSelected": "Nebyl vybrán žádný hostitel",
"move": "Pohyb",
"copy": "Kopie",
"upload": "Nahrát",
"itemsSelected": "Vybrána položka {{count}}",
"itemsSelected_other": "Vybráno {{count}} položek",
"ready": "Připraveno",
"loadingHosts": "Načítání hostitelů...",
"name": "Jméno",
"modified": "Upraveno",
"size": "Velikost",
"noFiles": "Žádné soubory",
"open": "OTEVŘENO",
"up": "Nahoru",
"refresh": "Obnovit",
"copyToTarget": "Kopírovat do cílového adresáře",
"rename": "Přejmenovat",
"delete": "Vymazat",
"editPermissions": "Upravit oprávnění",
"createNewFolder": "Vytvořit novou složku",
"deleteSelectedItems": "Smazat vybrané položky",
"deleteConfirm": "Smazat {{count}} vybranou položku?",
"deleteConfirm_other": "Smazat {{count}} vybraných položek?",
"cancel": "Zrušit",
"save": "Uložit",
"folderCreated": "Složka vytvořena",
"itemRenamed": "Položka přejmenována",
"actionFailed": "Akce se nezdařila",
"itemsDeleted": "{{count}} položka smazána",
"itemsDeleted_other": "{{count}} položek smazáno",
"deleteFailed": "Smazání se nezdařilo",
"permissionsUpdated": "Oprávnění aktualizována",
"noRemoteSessionConnected": "Není připojena žádná vzdálená relace",
"failedToListFiles": "Seznam souborů se nepodařilo zobrazit",
"failedToRefresh": "Obnovení se nezdařilo",
"destinationInsideSource": "Cíl nemůže být uvnitř vybrané zdrojové cesty.",
"sameHostNoOp": "Zdroj a cíl jsou stejná složka."
},
"tunnels": {
"noSshTunnels": "Žádné SSH tunely",
"createFirstTunnelMessage": "Zatím jste nevytvořili žádné SSH tunely. Chcete-li začít, nakonfigurujte tunelová připojení ve Správci hostitele.",
-385
View File
@@ -715,7 +715,6 @@
"hostManager": "Værtsadministrator",
"credentials": "Akkreditiver",
"portForwarding": "Videresendelse",
"sftp": "SFTP",
"connections": "Forbindelser",
"serial": "Seriel",
"keepSideMenuOpen": "Hold menuen åben",
@@ -767,14 +766,12 @@
"tabRdp": "Landdistriktsudviklingsplan",
"tabVnc": "VNC",
"tabTunnels": "Tunneler",
"tabFiles": "Filer",
"tabStats": "Værtsmålinger",
"tabTelnet": "Telnet",
"tabSharing": "Deling",
"tabAuthentication": "Godkendelse",
"terminal": "Terminal",
"tunnel": "Tunnel",
"fileManager": "Filhåndtering",
"serverStats": "Værtsmålinger",
"status": {
"available": "Tilgængelig",
@@ -1118,12 +1115,6 @@
"proxmoxResultSkippedNoIp": "{{count}} sprunget over (ingen IP fundet)",
"proxmoxStatsUrlCopied": "Proxmox Stats URL kopieret til udklipsholder",
"copyProxmoxStatsUrlAction": "Kopiér Proxmox Stats URL",
"enableFileManagerMonitor": "Aktivér Filhåndtering",
"enableFileManagerMonitorDesc": "Gennemse og administrer filer på denne vært via SFTP",
"scpLegacyLabel": "SCP Legacy-tilstand",
"scpLegacyDesc": "Brug ældre filoverførsel til servere, der ikke understøtter SFTP-undersystemet (f.eks. integrerede eller minimale SSH-servere).",
"defaultPathLabel": "Standardsti",
"fileManagerPathHint": "Den mappe, der skal åbnes, når filhåndteringen starter for denne vært.",
"statusChecksLabel": "Statuskontroller",
"enableStatusChecks": "Aktivér statuskontroller",
"enableStatusChecksDesc": "Ping denne vært regelmæssigt for at bekræfte tilgængelighed",
@@ -1278,7 +1269,6 @@
"deletedHost": "Slettet {{name}}",
"copiedToClipboard": "Kopieret til udklipsholderen",
"terminalUrlCopied": "Terminal-URL kopieret",
"fileManagerUrlCopied": "URL'en til filhåndtering er kopieret",
"tunnelUrlCopied": "Tunnel-URL kopieret",
"dockerUrlCopied": "Docker-URL kopieret",
"hostMetricsUrlCopied": "URL til værtsmålinger kopieret",
@@ -1296,7 +1286,6 @@
"copyAddress": "Kopiér adresse",
"copyLink": "Kopiér link",
"copyTerminalUrlAction": "Kopiér terminal-URL",
"copyFileManagerUrlAction": "Kopiér URL til filhåndtering",
"copyTunnelUrlAction": "Kopiér tunnel-URL",
"copyDockerUrlAction": "Kopiér Docker-URL",
"copyHostMetricsUrlAction": "Kopiér URL for værtsmålinger",
@@ -1857,336 +1846,6 @@
"removeFromSplit": "Fjern fra Split"
}
},
"fileManager": {
"noHostSelected": "Ingen vært valgt",
"initializingEditor": "Initialiserer editor...",
"file": "Fil",
"folder": "Folder",
"uploadFile": "Upload fil",
"downloadFile": "Download",
"extractArchive": "Uddrag af arkivet",
"extractingArchive": "Udtrækker {{name}}...",
"archiveExtractedSuccessfully": "{{name}} blev udpakket",
"extractFailed": "Udpakning mislykkedes",
"compressFile": "Komprimer fil",
"compressFiles": "Komprimer filer",
"compressFilesDesc": "Komprimer {{count}} elementer til et arkiv",
"archiveName": "Arkivnavn",
"enterArchiveName": "Indtast arkivnavn...",
"compressionFormat": "Kompressionsformat",
"selectedFiles": "Valgte filer",
"andMoreFiles": "og {{count}} mere...",
"compress": "Sammenpresse",
"compressingFiles": "Komprimerer {{count}} elementer til {{name}}...",
"filesCompressedSuccessfully": "{{name}} er oprettet",
"compressFailed": "Komprimering mislykkedes",
"edit": "Redigere",
"preview": "Forhåndsvisning",
"previous": "Tidligere",
"next": "Næste",
"pageXOfY": "Side {{current}} af {{total}}",
"zoomOut": "Zoom ud",
"zoomIn": "Zoom ind",
"newFile": "Ny fil",
"newFolder": "Ny mappe",
"rename": "Omdøb",
"uploading": "Uploader...",
"uploadingFile": "Uploader {{name}}...",
"fileName": "Filnavn",
"folderName": "Mappenavn",
"fileUploadedSuccessfully": "Filen \"{{name}}\" er blevet uploadet",
"failedToUploadFile": "Kunne ikke uploade filen",
"uploadingFolderFiles": "Uploader {{count}} fil(er)...",
"uploadedFolderFiles": "Uploadede {{count}} fil(er)",
"uploadedFolderPartial": "Upload af {{uploaded}} fil(er), {{failed}} mislykkedes",
"fileDownloadedSuccessfully": "Filen \"{{name}}\" er blevet downloadet",
"failedToDownloadFile": "Kunne ikke downloade filen",
"fileCreatedSuccessfully": "Filen \"{{name}}\" er blevet oprettet",
"folderCreatedSuccessfully": "Mappen \"{{name}}\" er blevet oprettet",
"failedToCreateItem": "Kunne ikke oprette elementet",
"operationFailed": "{{operation}} handling mislykkedes for {{name}}: {{error}}",
"failedToResolveSymlink": "Kunne ikke løse symbolsk link",
"itemsDeletedSuccessfully": "{{count}} elementer er blevet slettet",
"failedToDeleteItems": "Kunne ikke slette elementer",
"sudoPasswordRequired": "Administratoradgangskode påkrævet",
"enterSudoPassword": "Indtast sudo-adgangskoden for at fortsætte denne handling",
"sudoPassword": "Sudo-adgangskode",
"sudoOperationFailed": "Sudo-handlingen mislykkedes",
"sudoAuthFailed": "Sudo-godkendelse mislykkedes",
"dragFilesToUpload": "Slip filer her for at uploade",
"dropToUploadHere": "Slip for at uploade her",
"dropToDownloadHere": "Slip for at downloade her",
"localFiles": "Lokal",
"showLocalFiles": "Vis lokale filer (delt visning)",
"hideLocalFiles": "Skjul lokale filer",
"localHome": "Hjemmemappe",
"localPathPlaceholder": "Lokal mappesti",
"localShowHidden": "Vis skjulte filer",
"localHideHidden": "Skjul skjulte filer",
"localRevealInFileManager": "Vis i systemfilhåndtering",
"localCannotOpenFolder": "Kan ikke åbne mappen",
"localItemCount": "{{count}} elementer",
"localSelectedCount": "{{count}} valgt",
"back": "Tilbage",
"forward": "Forward",
"up": "Op",
"kind": "Slags",
"localUploadingProgress": "Uploader {{current}} af {{total}}",
"localDownloadingProgress": "Downloader {{current}} af {{total}}",
"localTransferInFlight": "{{count}} på én gang",
"localPreparingDownload": "Forbereder download...",
"localTransferCancelled": "Overførsel annulleret",
"localNothingToTransfer": "Intet at overføre",
"localUploadComplete": "Uploadede {{count}} fil(er)",
"localUploadFailed": "Upload mislykkedes",
"localUploadPartial": "Upload af {{done}} fil(er), {{failed}} mislykkedes",
"localDownloadComplete": "Downloadede {{count}} fil(er)",
"localDownloadFailed": "Download mislykkedes",
"localDownloadPartial": "Download af {{done}} fil(er), {{failed}} mislykkedes",
"localReplaceExistingPrompt": "{{count}} elementer findes allerede i denne mappe. Skal de erstattes?",
"localReplace": "Erstatte",
"localSkipExisting": "Springe",
"localSkippedExisting": "Springede {{count}} eksisterende element(er) over",
"localUnsafeNamesSkipped": "Springede {{count}} elementer over, hvis navne ikke kan bruges på denne computer",
"localOpen": "Åben",
"localOpenFolder": "Åbn mappe",
"localUploadToRemote": "Upload til server",
"localUploadToRemoteMany": "Upload {{count}} elementer til serveren",
"localMoveToTrash": "Flyt til papirkurven",
"localMoveToTrashMany": "Flyt {{count}} elementer til papirkurven",
"localTrashConfirmSingle": "Flytte \"{{name}}\" til papirkurven?",
"localTrashConfirmFolder": "Flytte mappen \"{{name}}\" og alt dens indhold til papirkurven?",
"localTrashConfirmMany": "Flytte {{count}} elementer til papirkurven?",
"localTrashed": "Flyttede {{count}} element(er) til papirkurven",
"localTrashFailed": "Kunne ikke flytte til papirkurven",
"localRenameFailed": "Omdøbning mislykkedes",
"localCreateFailed": "Kunne ikke oprette elementet",
"goToParentFolder": "Gå til overordnet mappe",
"resizeSidebar": "Træk for at ændre størrelsen på sidebjælken (dobbeltklik for at nulstille)",
"resizeLocalPane": "Træk for at ændre størrelsen på den lokale rude (dobbeltklik for at nulstille)",
"columns": "Kolonner",
"columnsHint": "Højreklik for at vælge kolonner",
"emptyFolder": "Denne mappe er tom",
"searchFiles": "Søg i filer...",
"upload": "Upload",
"selectHostToStart": "Vælg en vært for at starte filhåndtering",
"sshRequiredForFileManager": "Filhåndtering kræver SSH. Denne vært har ikke SSH aktiveret.",
"failedToConnect": "Kunne ikke oprette forbindelse til SSH",
"failedToLoadDirectory": "Kunne ikke indlæse mappen",
"noSSHConnection": "Ingen SSH-forbindelse tilgængelig",
"copy": "Kopi",
"cut": "Skære",
"paste": "Indsæt",
"copyPath": "Kopiér sti",
"copyPaths": "Kopier stier",
"delete": "Slet",
"properties": "Ejendomme",
"refresh": "Opfriske",
"downloadFiles": "Download {{count}} filer til browseren",
"copyFiles": "Kopiér {{count}} elementer",
"cutFiles": "Klip {{count}} elementer",
"deleteFiles": "Slet {{count}} elementer",
"filesCopiedToClipboard": "{{count}} elementer kopieret til udklipsholderen",
"filesCutToClipboard": "{{count}} elementer klippet til udklipsholder",
"pathCopiedToClipboard": "Sti kopieret til udklipsholder",
"pathsCopiedToClipboard": "{{count}} stier kopieret til udklipsholder",
"failedToCopyPath": "Kunne ikke kopiere stien til udklipsholderen",
"copyFolderLink": "Kopiér link til mappe",
"copyCurrentFolderLink": "Kopiér link til aktuel mappe",
"folderLinkCopied": "Mappelink kopieret til udklipsholder",
"failedToCopyFolderLink": "Kunne ikke kopiere mappelinket",
"movedItems": "Flyttede {{count}} elementer",
"failedToDeleteItem": "Kunne ikke slette elementet",
"itemRenamedSuccessfully": "{{type}} er omdøbt",
"failedToRenameItem": "Kunne ikke omdøbe elementet",
"download": "Download",
"openExternalEditor": "Åbn eksternt",
"chooseExternalEditor": "Vælg ekstern editor",
"externalEditorSelected": "Ekstern editor valgt",
"externalEditorDesktopOnly": "Ekstern editor er kun tilgængelig i desktop-appen",
"externalEditorOpened": "Åbnet i ekstern editor. Hvis du gemmer der, uploades ændringerne tilbage til serveren.",
"failedToOpenExternalEditor": "Kunne ikke åbne den eksterne editor",
"failedToSelectExternalEditor": "Kunne ikke vælge ekstern editor",
"permissions": "Tilladelser",
"size": "Størrelse",
"modified": "Ændret",
"path": "Sti",
"confirmDelete": "Er du sikker på, at du vil slette {{name}}?",
"permissionDenied": "Tilladelse nægtet",
"serverError": "Serverfejl",
"fileSavedSuccessfully": "Filen er gemt",
"failedToSaveFile": "Kunne ikke gemme filen",
"confirmDeleteSingleItem": "Er du sikker på, at du vil slette \"{{name}} \" permanent?",
"confirmDeleteMultipleItems": "Er du sikker på, at du vil slette {{count}} elementer permanent?",
"confirmDeleteMultipleItemsWithFolders": "Er du sikker på, at du vil slette {{count}} elementer permanent? Dette inkluderer mapper og deres indhold.",
"confirmDeleteFolder": "Er du sikker på, at du vil slette mappen \"{{name}}\" og alt dens indhold permanent?",
"permanentDeleteWarning": "Denne handling kan ikke fortrydes. Elementet/elementerne vil blive permanent slettet fra serveren.",
"moveToTrashWarning": "Elementet/elementerne vil blive flyttet til Termix-papirkurven og kan gendannes, inden opbevaringsperioden udløber.",
"itemsMovedToTrash": "{{count}} element(er) flyttet til papirkurven",
"trash": "Skrald",
"trashDescription": "Slettede elementer gemmes i {{days}} dag(e) på denne vært.",
"trashLoadFailed": "Kunne ikke indlæse papirkurven",
"trashEmpty": "Papirkurven er tom",
"trashRestore": "Gendan",
"trashRestored": "Gendannet {{name}}",
"trashRestoreFailed": "Elementet kunne ikke gendannes. Den oprindelige sti findes muligvis allerede.",
"trashDeletePermanently": "Slet permanent",
"trashDeleteConfirm": "Vil du slette {{name}}permanent? Dette kan ikke fortrydes.",
"trashPermanentlyDeleted": "Permanent slettet {{name}}",
"trashDeleteFailed": "Kunne ikke slette elementet permanent",
"trashEmptyAction": "Tøm papirkurven",
"trashEmptyConfirm": "Vil du slette alle elementer i papirkurven permanent? Dette kan ikke fortrydes.",
"trashEmptied": "Skraldespanden tømt",
"trashRetention": "Automatisk oprydning efter (dage)",
"trashRetentionSaved": "Papirkurvsopbevaring opdateret",
"trashRetentionFailed": "Kunne ikke opdatere opbevaring af papirkurven",
"trashUnavailableConfirm": "Dette element kan ikke flyttes til papirkurven, normalt fordi det er på et andet filsystem eller kræver forhøjede tilladelser. Slet det permanent i stedet?",
"recent": "Nylig",
"pinned": "Fastgjort",
"folderShortcuts": "Mappegenveje",
"failedToReconnectSSH": "Kunne ikke genoprette forbindelsen til SSH-sessionen",
"openTerminalHere": "Åbn terminalen her",
"run": "Løbe",
"openTerminalInFolder": "Åbn Terminal i denne mappe",
"openTerminalInFileLocation": "Åbn terminalen på filplaceringen",
"runningFile": "Løb - {{file}}",
"onlyRunExecutableFiles": "Kan kun køre eksekverbare filer",
"directories": "Kataloger",
"removedFromRecentFiles": "Fjernede \"{{name}}\" fra seneste filer",
"removeFailed": "Fjernelse mislykkedes",
"unpinnedSuccessfully": "\"{{name}}\" blev frigjort",
"unpinFailed": "Frigørelse mislykkedes",
"removedShortcut": "Fjernede genvejen \"{{name}}\"",
"removeShortcutFailed": "Fjernelse af genvej mislykkedes",
"clearedAllRecentFiles": "Rydde alle seneste filer",
"clearFailed": "Rydning mislykkedes",
"removeFromRecentFiles": "Fjern fra seneste filer",
"clearAllRecentFiles": "Ryd alle seneste filer",
"unpinFile": "Frigør fil",
"removeShortcut": "Fjern genvej",
"pinFile": "Fastgør fil",
"addToShortcuts": "Føj til genveje",
"pasteFailed": "Indsætning mislykkedes",
"noUndoableActions": "Ingen handlinger, der kan fortrydes",
"undoCopySuccess": "Fortrydt kopiering: Slettede {{count}} kopierede filer",
"undoCopyFailedDelete": "Fortryd mislykkedes: Kunne ikke slette nogen kopierede filer",
"undoCopyFailedNoInfo": "Fortryd mislykkedes: Kunne ikke finde kopierede filoplysninger",
"undoMoveSuccess": "Fortrydt flyttehandling: Flyttede {{count}} filer tilbage til den oprindelige placering",
"undoMoveFailedMove": "Fortryd mislykkedes: Kunne ikke flytte nogen filer tilbage",
"undoMoveFailedNoInfo": "Fortryd mislykkedes: Kunne ikke finde oplysninger om den flyttede fil",
"undoDeleteNotSupported": "Sletningen kan ikke fortrydes: Filerne er blevet permanent slettet fra serveren",
"undoTypeNotSupported": "Ikke-understøttet fortrydelseshandlingstype",
"undoOperationFailed": "Fortrydelseshandling mislykkedes",
"unknownError": "Ukendt fejl",
"confirm": "Bekræfte",
"find": "Finde...",
"replace": "Erstatte",
"downloadInstead": "Download i stedet",
"keyboardShortcuts": "Tastaturgenveje",
"searchAndReplace": "Søg og erstat",
"editing": "Redigering",
"search": "Søge",
"findNext": "Find næste",
"findPrevious": "Find forrige",
"save": "Spare",
"selectAll": "Vælg alle",
"undo": "Fortryd",
"redo": "Gentag",
"moveLineUp": "Flyt linje op",
"moveLineDown": "Flyt linje ned",
"toggleComment": "Slå kommentar til/fra",
"autoComplete": "Autofuldførelse",
"imageLoadError": "Billedet kunne ikke indlæses",
"startTyping": "Begynd at skrive...",
"unknownSize": "Ukendt størrelse",
"fileIsEmpty": "Filen er tom",
"largeFileWarning": "Advarsel om stor fil",
"largeFileWarningDesc": "Denne fil er {{size}} stor, hvilket kan forårsage problemer med ydeevnen, når den åbnes som tekst.",
"fileNotFoundAndRemoved": "Filen \"{{name}}\" blev ikke fundet og er blevet fjernet fra seneste/fastgjorte filer",
"failedToLoadFile": "Kunne ikke indlæse filen: {{error}}",
"serverErrorOccurred": "Der opstod en serverfejl. Prøv igen senere.",
"autoSaveFailed": "Automatisk lagring mislykkedes",
"fileAutoSaved": "Fil gemt automatisk",
"moveFileFailed": "Kunne ikke flytte {{name}}",
"moveOperationFailed": "Flytteoperationen mislykkedes",
"canOnlyCompareFiles": "Kan kun sammenligne to filer",
"comparingFiles": "Sammenligning af filer: {{file1}} og {{file2}}",
"dragFailed": "Trækhandlingen mislykkedes",
"filePinnedSuccessfully": "Filen \"{{name}}\" er blevet fastgjort",
"pinFileFailed": "Kunne ikke fastlåse filen",
"fileUnpinnedSuccessfully": "Filen \"{{name}}\" blev frigjort",
"unpinFileFailed": "Kunne ikke frigøre filen",
"shortcutAddedSuccessfully": "Mappens genvej \"{{name}}\" blev tilføjet",
"addShortcutFailed": "Kunne ikke tilføje genvej",
"operationCompletedSuccessfully": "{{operation}} {{count}} elementer er blevet genanvendt",
"operationCompleted": "{{operation}} {{count}} elementer",
"downloadFileSuccess": "Fil {{name}} er blevet downloadet",
"downloadFileFailed": "Download mislykkedes",
"downloadingFile": "Downloader {{name}}…",
"downloadProgressBytes": "{{transferred}} / {{total}}",
"moveTo": "Flyt til {{name}}",
"diffCompareWith": "Differens sammenligning med {{name}}",
"dragOutsideToDownload": "Træk uden for vinduet for at downloade ({{count}} filer)",
"newFolderDefault": "NyMappe",
"newFileDefault": "Nyfil.txt",
"successfullyMovedItems": "{{count}} elementer er flyttet til {{target}}",
"move": "Flytte",
"searchInFile": "Søg i filen (Ctrl+F)",
"showKeyboardShortcuts": "Vis tastaturgenveje",
"decreaseFontSize": "Formindsk skriftstørrelsen",
"increaseFontSize": "Øg skriftstørrelsen",
"startWritingMarkdown": "Begynd at skrive dit indhold til nedskrivning...",
"loadingFileComparison": "Indlæser filsammenligning...",
"reload": "Genindlæs",
"compare": "Sammenligne",
"sideBySide": "Side om side",
"inline": "Indlejret",
"fileComparison": "Filsammenligning: {{file1}} vs. {{file2}}",
"fileTooLarge": "Filen er for stor: {{error}}",
"sshConnectionFailed": "SSH-forbindelse mislykkedes. Kontroller venligst din forbindelse til {{name}} ({{ip}}:{{port}})",
"loadFileFailed": "Kunne ikke indlæse filen: {{error}}",
"connecting": "Forbinder...",
"connectedSuccessfully": "Forbindelsen er oprettet",
"totpVerificationFailed": "TOTP-bekræftelse mislykkedes",
"warpgateVerificationFailed": "Warpgate-godkendelse mislykkedes",
"authenticationFailed": "Godkendelse mislykkedes",
"incorrectPassphrase": "Forkert adgangskode. Prøv igen.",
"verificationCodePrompt": "Bekræftelseskode:",
"changePermissions": "Ændre tilladelser",
"currentPermissions": "Nuværende tilladelser",
"owner": "Ejer",
"group": "Gruppe",
"others": "Andre",
"read": "Læse",
"write": "Skrive",
"execute": "Udfør",
"permissionsChangedSuccessfully": "Tilladelserne er ændret",
"failedToChangePermissions": "Kunne ikke ændre tilladelser",
"name": "Navn",
"sortByName": "Navn",
"sortByDate": "Dato ændret",
"sortBySize": "Størrelse",
"ascending": "Stigende",
"descending": "Faldende",
"root": "Rod",
"new": "Ny",
"sortBy": "Sortér efter",
"items": "Varer",
"selected": "Valgt",
"editor": "Redaktør",
"octal": "Oktal",
"storage": "Opbevaring",
"disk": "Disk",
"used": "Brugt",
"of": "af",
"toggleSidebar": "Slå sidebjælken til/fra",
"compactLayout": "Brug kompakt layout",
"comfortableLayout": "Brug et komfortabelt layout",
"cannotLoadPdf": "Kan ikke indlæse PDF'en",
"pdfLoadError": "Der opstod en fejl under indlæsning af denne PDF-fil.",
"loadingPdf": "Indlæser PDF...",
"loadingPage": "Indlæser side..."
},
"transfer": {
"copyToHost": "Kopiér til vært…",
"moveToHost": "Flyt til vært…",
@@ -2292,50 +1951,6 @@
"retryTransferError": "Genforsøg mislykkedes",
"transferFailedRetryHint": "Delvise data blev gemt på destinationen. Forsøget genoptages, når forbindelsen er tilbage."
},
"sftpTransfer": {
"title": "SFTP",
"description": "Overfør filer mellem to SSH-værter.",
"sourceServer": "Kildeserver",
"destinationServer": "Destinationsserver",
"selectHost": "Vælg vært",
"noHostSelected": "Ingen vært valgt",
"move": "Flytte",
"copy": "Kopi",
"upload": "Upload",
"itemsSelected": "{{count}} element valgt",
"itemsSelected_other": "{{count}} elementer valgt",
"ready": "Parat",
"loadingHosts": "Indlæser værter...",
"name": "Navn",
"modified": "Ændret",
"size": "Størrelse",
"noFiles": "Ingen filer",
"open": "Åben",
"up": "Op",
"refresh": "Opfriske",
"copyToTarget": "Kopiér til målmappe",
"rename": "Omdøb",
"delete": "Slet",
"editPermissions": "Rediger tilladelser",
"createNewFolder": "Opret ny mappe",
"deleteSelectedItems": "Slet valgte elementer",
"deleteConfirm": "Slet {{count}} det valgte element?",
"deleteConfirm_other": "Slet {{count}} valgte elementer?",
"cancel": "Ophæve",
"save": "Spare",
"folderCreated": "Mappe oprettet",
"itemRenamed": "Varen er omdøbt",
"actionFailed": "Handlingen mislykkedes",
"itemsDeleted": "{{count}} element slettet",
"itemsDeleted_other": "{{count}} elementer slettet",
"deleteFailed": "Sletning mislykkedes",
"permissionsUpdated": "Tilladelser opdateret",
"noRemoteSessionConnected": "Ingen fjernsession tilsluttet",
"failedToListFiles": "Kunne ikke liste filer",
"failedToRefresh": "Kunne ikke opdatere",
"destinationInsideSource": "Destinationen må ikke være inden for den valgte kildesti.",
"sameHostNoOp": "Kilde og destination er den samme mappe."
},
"tunnels": {
"noSshTunnels": "Ingen SSH-tunneler",
"createFirstTunnelMessage": "Du har endnu ikke oprettet nogen SSH-tunneler. Konfigurer tunnelforbindelser i Host Manager for at komme i gang.",
-385
View File
@@ -715,7 +715,6 @@
"hostManager": "Host-Manager",
"credentials": "Zugangsdaten",
"portForwarding": "Weiterleitung",
"sftp": "SFTP",
"connections": "Verbindungen",
"serial": "Seriell",
"keepSideMenuOpen": "Menü geöffnet lassen",
@@ -767,14 +766,12 @@
"tabRdp": "RDP",
"tabVnc": "VNC",
"tabTunnels": "Tunnel",
"tabFiles": "Dateien",
"tabStats": "Host-Metriken",
"tabTelnet": "Telnet",
"tabSharing": "Freigabe",
"tabAuthentication": "Authentifizierung",
"terminal": "Terminal",
"tunnel": "Tunnel",
"fileManager": "Dateimanager",
"serverStats": "Host-Metriken",
"status": {
"available": "Verfügbar",
@@ -1118,12 +1115,6 @@
"proxmoxResultSkippedNoIp": "{{count}} übersprungen (keine IP-Adresse gefunden)",
"proxmoxStatsUrlCopied": "Proxmox-Statistik-URL in die Zwischenablage kopiert",
"copyProxmoxStatsUrlAction": "Proxmox-Statistik-URL kopieren",
"enableFileManagerMonitor": "Dateimanager aktivieren",
"enableFileManagerMonitorDesc": "Dateien auf diesem Host über SFTP durchsuchen und verwalten",
"scpLegacyLabel": "SCP-Legacy-Modus",
"scpLegacyDesc": "Verwenden Sie die Legacy-Dateiübertragung für Server, die das SFTP-Subsystem nicht unterstützen (z. B. eingebettete oder minimale SSH-Server).",
"defaultPathLabel": "Standardpfad",
"fileManagerPathHint": "Das Verzeichnis, das geöffnet wird, wenn der Dateimanager für diesen Host gestartet wird.",
"statusChecksLabel": "Statusprüfungen",
"enableStatusChecks": "Statusprüfungen aktivieren",
"enableStatusChecksDesc": "Diesen Host regelmäßig anpingen, um die Verfügbarkeit zu überprüfen",
@@ -1278,7 +1269,6 @@
"deletedHost": "{{name}} gelöscht",
"copiedToClipboard": "In die Zwischenablage kopiert",
"terminalUrlCopied": "Terminal-URL kopiert",
"fileManagerUrlCopied": "Dateimanager-URL kopiert",
"tunnelUrlCopied": "Tunnel-URL kopiert",
"dockerUrlCopied": "Docker-URL kopiert",
"hostMetricsUrlCopied": "Host-Metriken-URL kopiert",
@@ -1296,7 +1286,6 @@
"copyAddress": "Adresse kopieren",
"copyLink": "Link kopieren",
"copyTerminalUrlAction": "Terminal-URL kopieren",
"copyFileManagerUrlAction": "Dateimanager-URL kopieren",
"copyTunnelUrlAction": "Tunnel-URL kopieren",
"copyDockerUrlAction": "Docker-URL kopieren",
"copyHostMetricsUrlAction": "Host-Metriken-URL kopieren",
@@ -1857,336 +1846,6 @@
"removeFromSplit": "Aus Teilung entfernen"
}
},
"fileManager": {
"noHostSelected": "Kein Host ausgewählt",
"initializingEditor": "Editor wird initialisiert...",
"file": "Datei",
"folder": "Ordner",
"uploadFile": "Datei hochladen",
"downloadFile": "Herunterladen",
"extractArchive": "Archiv entpacken",
"extractingArchive": "Extrahiere {{name}}...",
"archiveExtractedSuccessfully": "{{name}} erfolgreich extrahiert",
"extractFailed": "Extrahieren fehlgeschlagen",
"compressFile": "Datei komprimieren",
"compressFiles": "Dateien komprimieren",
"compressFilesDesc": "{{count}} Elemente in ein Archiv komprimieren",
"archiveName": "Archivname",
"enterArchiveName": "Archivname eingeben...",
"compressionFormat": "Komprimierungsformat",
"selectedFiles": "Ausgewählte Dateien",
"andMoreFiles": "und {{count}} weitere...",
"compress": "Komprimieren",
"compressingFiles": "Komprimiere {{count}} Elemente in {{name}}...",
"filesCompressedSuccessfully": "{{name}} erfolgreich erstellt",
"compressFailed": "Komprimierung fehlgeschlagen",
"edit": "Bearbeiten",
"preview": "Vorschau",
"previous": "Zurück",
"next": "Weiter",
"pageXOfY": "Seite {{current}} von {{total}}",
"zoomOut": "Verkleinern",
"zoomIn": "Vergrößern",
"newFile": "Neue Datei",
"newFolder": "Neuer Ordner",
"rename": "Umbenennen",
"uploading": "Lade hoch...",
"uploadingFile": "Lade {{name}} hoch...",
"fileName": "Dateiname",
"folderName": "Ordnername",
"fileUploadedSuccessfully": "Datei \"{{name}}\" erfolgreich hochgeladen",
"failedToUploadFile": "Hochladen der Datei fehlgeschlagen",
"uploadingFolderFiles": "Uploading {{count}} file(s)...",
"uploadedFolderFiles": "{{count}} Datei(en) erfolgreich hochgeladen",
"uploadedFolderPartial": "Hochladen von {{uploaded}} Datei(en), {{failed}} fehlgeschlagen",
"fileDownloadedSuccessfully": "Datei \"{{name}}\" erfolgreich heruntergeladen",
"failedToDownloadFile": "Herunterladen der Datei fehlgeschlagen",
"fileCreatedSuccessfully": "Datei \"{{name}}\" erfolgreich erstellt",
"folderCreatedSuccessfully": "Ordner \"{{name}}\" erfolgreich erstellt",
"failedToCreateItem": "Fehler beim Erstellen des Elements",
"operationFailed": "{{operation}}-Operation für {{name}} fehlgeschlagen: {{error}}",
"failedToResolveSymlink": "Fehler beim Auflösen des Symlinks",
"itemsDeletedSuccessfully": "{{count}} Elemente erfolgreich gelöscht",
"failedToDeleteItems": "Fehler beim Löschen der Elemente",
"sudoPasswordRequired": "Sudo-Passwort erforderlich",
"enterSudoPassword": "Sudo-Passwort eingeben, um diesen Vorgang fortzusetzen",
"sudoPassword": "Sudo-Passwort",
"sudoOperationFailed": "Sudo-Vorgang fehlgeschlagen",
"sudoAuthFailed": "Sudo-Authentifizierung fehlgeschlagen",
"dragFilesToUpload": "Dateien hier ablegen, um sie hochzuladen",
"dropToUploadHere": "Hier zum Hochladen ablegen",
"dropToDownloadHere": "Hier klicken zum Herunterladen",
"localFiles": "Lokal",
"showLocalFiles": "Lokale Dateien anzeigen (geteilte Ansicht)",
"hideLocalFiles": "Lokale Dateien ausblenden",
"localHome": "Startseitenordner",
"localPathPlaceholder": "Lokaler Ordnerpfad",
"localShowHidden": "Versteckte Dateien anzeigen",
"localHideHidden": "Versteckte Dateien ausblenden",
"localRevealInFileManager": "Im Systemdateimanager anzeigen",
"localCannotOpenFolder": "Ordner kann nicht geöffnet werden",
"localItemCount": "{{count}} Artikel",
"localSelectedCount": "{{count}} ausgewählt",
"back": "Zurück",
"forward": "Nach vorne",
"up": "Hoch",
"kind": "Art",
"localUploadingProgress": "Hochladen von {{current}} von {{total}}",
"localDownloadingProgress": "Herunterladen von {{current}} von {{total}}",
"localTransferInFlight": "{{count}} gleichzeitig",
"localPreparingDownload": "Download wird vorbereitet...",
"localTransferCancelled": "Transfer storniert",
"localNothingToTransfer": "Nichts zu übertragen",
"localUploadComplete": "Es wurden {{count}} Dateien hochgeladen",
"localUploadFailed": "Hochladen fehlgeschlagen",
"localUploadPartial": "Hochladen von {{done}} Datei(en), {{failed}} fehlgeschlagen",
"localDownloadComplete": "Heruntergeladene Datei(en) {{count}}",
"localDownloadFailed": "Download fehlgeschlagen",
"localDownloadPartial": "Download der Datei(en) {{done}} , {{failed}} fehlgeschlagen",
"localReplaceExistingPrompt": "{{count}} In diesem Ordner sind bereits Elemente vorhanden. Sollen sie ersetzt werden?",
"localReplace": "Ersetzen",
"localSkipExisting": "Überspringen",
"localSkippedExisting": "Übersprungene {{count}} vorhandene Elemente",
"localUnsafeNamesSkipped": "{{count}} Elemente übersprungen, deren Namen auf diesem Computer nicht verwendet werden können",
"localOpen": "Offen",
"localOpenFolder": "Ordner öffnen",
"localUploadToRemote": "Auf Server hochladen",
"localUploadToRemoteMany": "{{count}} Elemente auf den Server hochladen",
"localMoveToTrash": "In den Papierkorb verschieben",
"localMoveToTrashMany": "Verschiebe {{count}} Objekte in den Papierkorb.",
"localTrashConfirmSingle": "\"{{name}}\" in den Papierkorb verschieben?",
"localTrashConfirmFolder": "Den Ordner \"{{name}}\" und seinen gesamten Inhalt in den Papierkorb verschieben?",
"localTrashConfirmMany": "{{count}} Elemente in den Papierkorb verschieben?",
"localTrashed": "{{count}} Elemente wurden in den Papierkorb verschoben.",
"localTrashFailed": "Konnte nicht in den Mülleimer bewegt werden.",
"localRenameFailed": "Umbenennen fehlgeschlagen",
"localCreateFailed": "Element konnte nicht erstellt werden",
"goToParentFolder": "Gehe zum übergeordneten Ordner",
"resizeSidebar": "Ziehen Sie, um die Seitenleiste in der Größe anzupassen (Doppelklick zum Zurücksetzen).",
"resizeLocalPane": "Ziehen Sie, um die Größe des lokalen Fensters zu ändern (Doppelklick zum Zurücksetzen).",
"columns": "Spalten",
"columnsHint": "Klicken Sie mit der rechten Maustaste, um Spalten auszuwählen.",
"emptyFolder": "Dieser Ordner ist leer",
"searchFiles": "Dateien durchsuchen...",
"upload": "Hochladen",
"selectHostToStart": "Wählen Sie einen Host aus, um die Dateiverwaltung zu starten",
"sshRequiredForFileManager": "Dateimanager erfordert SSH. SSH ist auf diesem Host nicht aktiviert.",
"failedToConnect": "Verbindung zu SSH fehlgeschlagen",
"failedToLoadDirectory": "Fehler beim Laden des Verzeichnisses",
"noSSHConnection": "Keine SSH-Verbindung verfügbar",
"copy": "Kopieren",
"cut": "Ausschneiden",
"paste": "Einfügen",
"copyPath": "Pfad kopieren",
"copyPaths": "Pfade kopieren",
"delete": "Löschen",
"properties": "Eigenschaften",
"refresh": "Aktualisieren",
"downloadFiles": "{{count}} Dateien über den Browser herunterladen",
"copyFiles": "{{count}} Elemente kopieren",
"cutFiles": "{{count}} Elemente ausschneiden",
"deleteFiles": "{{count}} Elemente löschen",
"filesCopiedToClipboard": "{{count}} Elemente in die Zwischenablage kopiert",
"filesCutToClipboard": "{{count}} Elemente in die Zwischenablage ausgeschnitten",
"pathCopiedToClipboard": "Pfad in die Zwischenablage kopiert",
"pathsCopiedToClipboard": "{{count}} Pfade in die Zwischenablage kopiert",
"failedToCopyPath": "Fehler beim Kopieren des Pfads in die Zwischenablage",
"copyFolderLink": "Link zum Ordner kopieren",
"copyCurrentFolderLink": "Link zum aktuellen Ordner kopieren",
"folderLinkCopied": "Ordner-Link in die Zwischenablage kopiert",
"failedToCopyFolderLink": "Fehler beim Kopieren des Ordner-Links",
"movedItems": "{{count}} Elemente verschoben",
"failedToDeleteItem": "Fehler beim Löschen des Elements",
"itemRenamedSuccessfully": "{{type}} erfolgreich umbenannt",
"failedToRenameItem": "Fehler beim Umbenennen des Elements",
"download": "Herunterladen",
"openExternalEditor": "Extern öffnen",
"chooseExternalEditor": "Externen Editor auswählen",
"externalEditorSelected": "Externer Editor ausgewählt",
"externalEditorDesktopOnly": "Externer Editor ist nur in der Desktop-App verfügbar",
"externalEditorOpened": "Im externen Editor geöffnet. Beim Speichern werden die Änderungen auf den Server hochgeladen.",
"failedToOpenExternalEditor": "Fehler beim Öffnen des externen Editors",
"failedToSelectExternalEditor": "Fehler bei der Auswahl des externen Editors",
"permissions": "Berechtigungen",
"size": "Größe",
"modified": "Geändert",
"path": "Pfad",
"confirmDelete": "Sind Sie sicher, dass Sie {{name}} löschen möchten?",
"permissionDenied": "Zugriff verweigert",
"serverError": "Serverfehler",
"fileSavedSuccessfully": "Datei erfolgreich gespeichert",
"failedToSaveFile": "Fehler beim Speichern der Datei",
"confirmDeleteSingleItem": "Sind Sie sicher, dass Sie \"{{name}}\" endgültig löschen möchten?",
"confirmDeleteMultipleItems": "Sind Sie sicher, dass Sie {{count}} Elemente endgültig löschen möchten?",
"confirmDeleteMultipleItemsWithFolders": "Sind Sie sicher, dass Sie {{count}} Elemente endgültig löschen möchten? Dies beinhaltet auch Ordner und deren Inhalte.",
"confirmDeleteFolder": "Sind Sie sicher, dass Sie den Ordner \"{{name}}\" und seinen gesamten Inhalt endgültig löschen möchten?",
"permanentDeleteWarning": "Diese Aktion kann nicht rückgängig gemacht werden. Die Elemente werden endgültig vom Server gelöscht.",
"moveToTrashWarning": "Die Elemente werden in den Termix-Papierkorb verschoben und können vor Ablauf der Aufbewahrungsfrist wiederhergestellt werden.",
"itemsMovedToTrash": "{{count}} Elemente in den Papierkorb verschoben",
"trash": "Müll",
"trashDescription": "Gelöschte Elemente werden auf diesem Host für {{days}} Tage aufbewahrt.",
"trashLoadFailed": "Der Papierkorb konnte nicht geladen werden.",
"trashEmpty": "Der Mülleimer ist leer",
"trashRestore": "Wiederherstellen",
"trashRestored": "Wiederhergestellt {{name}}",
"trashRestoreFailed": "Das Element konnte nicht wiederhergestellt werden. Der ursprüngliche Pfad existiert möglicherweise bereits.",
"trashDeletePermanently": "Endgültig löschen",
"trashDeleteConfirm": "{{name}}endgültig löschen? Dies kann nicht rückgängig gemacht werden.",
"trashPermanentlyDeleted": "Endgültig gelöscht {{name}}",
"trashDeleteFailed": "Fehler beim endgültigen Löschen des Eintrags",
"trashEmptyAction": "Mülleimer leeren",
"trashEmptyConfirm": "Alle Elemente im Papierkorb endgültig löschen? Dies kann nicht rückgängig gemacht werden.",
"trashEmptied": "Müll geleert",
"trashRetention": "Automatische Bereinigung nach (Tagen)",
"trashRetentionSaved": "Müllspeicherung aktualisiert",
"trashRetentionFailed": "Aktualisierung der Papierkorb-Aufbewahrung fehlgeschlagen",
"trashUnavailableConfirm": "Dieses Element kann nicht in den Papierkorb verschoben werden, üblicherweise weil es sich auf einem anderen Dateisystem befindet oder erhöhte Berechtigungen erfordert. Soll es stattdessen endgültig gelöscht werden?",
"recent": "Zuletzt verwendet",
"pinned": "Angepinnt",
"folderShortcuts": "Ordner-Verknüpfungen",
"failedToReconnectSSH": "Fehler beim Wiederherstellen der SSH-Sitzung",
"openTerminalHere": "Terminal hier öffnen",
"run": "Ausführen",
"openTerminalInFolder": "Terminal in diesem Ordner öffnen",
"openTerminalInFileLocation": "Terminal am Dateispeicherort öffnen",
"runningFile": "Läuft - {{file}}",
"onlyRunExecutableFiles": "Nur ausführbare Dateien können ausgeführt werden",
"directories": "Verzeichnisse",
"removedFromRecentFiles": "\"{{name}}\" aus den zuletzt verwendeten Dateien entfernt",
"removeFailed": "Entfernen fehlgeschlagen",
"unpinnedSuccessfully": "\"{{name}}\" erfolgreich gelöst",
"unpinFailed": "Lösen fehlgeschlagen",
"removedShortcut": "Verknüpfung \"{{name}}\" entfernt",
"removeShortcutFailed": "Entfernen der Verknüpfung fehlgeschlagen",
"clearedAllRecentFiles": "Alle zuletzt verwendeten Dateien gelöscht",
"clearFailed": "Löschen fehlgeschlagen",
"removeFromRecentFiles": "Aus zuletzt verwendeten Dateien entfernen",
"clearAllRecentFiles": "Alle zuletzt verwendeten Dateien löschen",
"unpinFile": "Datei lösen",
"removeShortcut": "Verknüpfung entfernen",
"pinFile": "Datei anheften",
"addToShortcuts": "Zu Verknüpfungen hinzufügen",
"pasteFailed": "Einfügen fehlgeschlagen",
"noUndoableActions": "Keine rückgängig zu machenden Aktionen",
"undoCopySuccess": "Kopiervorgang rückgängig gemacht: {{count}} kopierte Dateien gelöscht",
"undoCopyFailedDelete": "Rückgängig fehlgeschlagen: Konnte keine kopierten Dateien löschen",
"undoCopyFailedNoInfo": "Rückgängig fehlgeschlagen: Informationen zu kopierten Dateien nicht gefunden",
"undoMoveSuccess": "Verschiebevorgang rückgängig gemacht: {{count}} Dateien an den ursprünglichen Ort zurückverschoben",
"undoMoveFailedMove": "Rückgängig fehlgeschlagen: Konnte keine Dateien zurückverschieben",
"undoMoveFailedNoInfo": "Rückgängig fehlgeschlagen: Informationen zu verschobenen Dateien nicht gefunden",
"undoDeleteNotSupported": "Löschvorgang kann nicht rückgängig gemacht werden: Dateien wurden dauerhaft vom Server gelöscht",
"undoTypeNotSupported": "Nicht unterstützter Typ für Rückgängig-Vorgang",
"undoOperationFailed": "Rückgängig-Vorgang fehlgeschlagen",
"unknownError": "Unbekannter Fehler",
"confirm": "Bestätigen",
"find": "Suchen...",
"replace": "Ersetzen",
"downloadInstead": "Stattdessen herunterladen",
"keyboardShortcuts": "Tastaturkürzel",
"searchAndReplace": "Suchen & Ersetzen",
"editing": "Bearbeiten",
"search": "Suchen",
"findNext": "Weitersuchen",
"findPrevious": "Vorheriges suchen",
"save": "Speichern",
"selectAll": "Alles auswählen",
"undo": "Rückgängig",
"redo": "Wiederholen",
"moveLineUp": "Zeile nach oben verschieben",
"moveLineDown": "Zeile nach unten verschieben",
"toggleComment": "Kommentar umschalten",
"autoComplete": "Autovervollständigung",
"imageLoadError": "Bild konnte nicht geladen werden",
"startTyping": "Mit der Eingabe beginnen...",
"unknownSize": "Unbekannte Größe",
"fileIsEmpty": "Datei ist leer",
"largeFileWarning": "Warnung bei großen Dateien",
"largeFileWarningDesc": "Diese Datei ist {{size}} groß, was beim Öffnen als Text zu Leistungsproblemen führen kann.",
"fileNotFoundAndRemoved": "Datei \"{{name}}\" nicht gefunden und wurde aus den zuletzt verwendeten/angehefteten Dateien entfernt.",
"failedToLoadFile": "Datei konnte nicht geladen werden: {{error}}",
"serverErrorOccurred": "Ein Serverfehler ist aufgetreten. Bitte versuchen Sie es später erneut.",
"autoSaveFailed": "Automatisches Speichern fehlgeschlagen",
"fileAutoSaved": "Datei automatisch gespeichert",
"moveFileFailed": "Verschieben von {{name}} fehlgeschlagen",
"moveOperationFailed": "Verschiebevorgang fehlgeschlagen",
"canOnlyCompareFiles": "Es können nur zwei Dateien verglichen werden",
"comparingFiles": "Vergleiche Dateien: {{file1}} und {{file2}}",
"dragFailed": "Ziehvorgang fehlgeschlagen",
"filePinnedSuccessfully": "Datei \"{{name}}\" wurde erfolgreich angeheftet",
"pinFileFailed": "Anheften der Datei fehlgeschlagen",
"fileUnpinnedSuccessfully": "Datei \"{{name}}\" wurde erfolgreich abgeheftet",
"unpinFileFailed": "Loslösen der Datei fehlgeschlagen",
"shortcutAddedSuccessfully": "Ordnerverknüpfung \"{{name}}\" erfolgreich hinzugefügt",
"addShortcutFailed": "Hinzufügen der Verknüpfung fehlgeschlagen",
"operationCompletedSuccessfully": "{{operation}} {{count}} Elemente erfolgreich abgeschlossen",
"operationCompleted": "{{operation}} {{count}} Elemente",
"downloadFileSuccess": "Datei {{name}} erfolgreich heruntergeladen",
"downloadFileFailed": "Download fehlgeschlagen",
"downloadingFile": "Herunterladen {{name}}…",
"downloadProgressBytes": "{{transferred}} / {{total}}",
"moveTo": "Verschieben nach {{name}}",
"diffCompareWith": "Diff-Vergleich mit {{name}}",
"dragOutsideToDownload": "Zum Herunterladen aus dem Fenster ziehen ({{count}} Dateien)",
"newFolderDefault": "Neuer Ordner",
"newFileDefault": "Neue Datei.txt",
"successfullyMovedItems": "{{count}} Elemente erfolgreich nach {{target}} verschoben",
"move": "Verschieben",
"searchInFile": "In Datei suchen (Strg+F)",
"showKeyboardShortcuts": "Tastaturkürzel anzeigen",
"decreaseFontSize": "Schriftgröße verkleinern",
"increaseFontSize": "Schriftgröße vergrößern",
"startWritingMarkdown": "Beginnen Sie mit der Eingabe Ihres Markdown-Inhalts...",
"loadingFileComparison": "Dateivergleich wird geladen...",
"reload": "Neu laden",
"compare": "Vergleichen",
"sideBySide": "Nebeneinander",
"inline": "Inline",
"fileComparison": "Dateivergleich: {{file1}} vs. {{file2}}",
"fileTooLarge": "Datei zu groß: {{error}}",
"sshConnectionFailed": "SSH-Verbindung fehlgeschlagen. Bitte überprüfen Sie Ihre Verbindung zu {{name}} ({{ip}}:{{port}})",
"loadFileFailed": "Fehler beim Laden der Datei: {{error}}",
"connecting": "Verbindung wird hergestellt...",
"connectedSuccessfully": "Erfolgreich verbunden",
"totpVerificationFailed": "TOTP-Überprüfung fehlgeschlagen",
"warpgateVerificationFailed": "Warpgate-Authentifizierung fehlgeschlagen",
"authenticationFailed": "Authentifizierung fehlgeschlagen",
"incorrectPassphrase": "Falsche Passphrase. Bitte versuchen Sie es erneut.",
"verificationCodePrompt": "Verifizierungscode:",
"changePermissions": "Berechtigungen ändern",
"currentPermissions": "Aktuelle Berechtigungen",
"owner": "Besitzer",
"group": "Gruppe",
"others": "Andere",
"read": "Lesen",
"write": "Schreiben",
"execute": "Ausführen",
"permissionsChangedSuccessfully": "Berechtigungen erfolgreich geändert",
"failedToChangePermissions": "Berechtigungen konnten nicht geändert werden",
"name": "Name",
"sortByName": "Name",
"sortByDate": "Änderungsdatum",
"sortBySize": "Größe",
"ascending": "Aufsteigend",
"descending": "Absteigend",
"root": "Stammverzeichnis",
"new": "Neu",
"sortBy": "Sortieren nach",
"items": "Elemente",
"selected": "Ausgewählt",
"editor": "Editor",
"octal": "Oktal",
"storage": "Speicher",
"disk": "Datenträger",
"used": "Belegt",
"of": "von",
"toggleSidebar": "Seitenleiste umschalten",
"compactLayout": "Kompaktes Layout verwenden",
"comfortableLayout": "Nutzen Sie ein komfortables Layout",
"cannotLoadPdf": "PDF kann nicht geladen werden",
"pdfLoadError": "Beim Laden dieser PDF-Datei ist ein Fehler aufgetreten.",
"loadingPdf": "PDF wird geladen...",
"loadingPage": "Seite wird geladen..."
},
"transfer": {
"copyToHost": "Auf Host kopieren…",
"moveToHost": "Auf Host verschieben…",
@@ -2292,50 +1951,6 @@
"retryTransferError": "Wiederholung fehlgeschlagen.",
"transferFailedRetryHint": "Unvollständige Daten wurden am Ziel beibehalten. Die Wiederholung wird fortgesetzt, sobald die Verbindung wiederhergestellt ist."
},
"sftpTransfer": {
"title": "SFTP",
"description": "Dateien zwischen zwei SSH-Hosts übertragen.",
"sourceServer": "Quellserver",
"destinationServer": "Zielserver",
"selectHost": "Host auswählen",
"noHostSelected": "Kein Host ausgewählt",
"move": "Bewegen",
"copy": "Kopie",
"upload": "Hochladen",
"itemsSelected": "{{count}} Element ausgewählt",
"itemsSelected_other": "{{count}} ausgewählte Elemente",
"ready": "Bereit",
"loadingHosts": "Hosts werden geladen...",
"name": "Name",
"modified": "Modifiziert",
"size": "Größe",
"noFiles": "Keine Dateien",
"open": "Offen",
"up": "Hoch",
"refresh": "Aktualisieren",
"copyToTarget": "In Zielverzeichnis kopieren",
"rename": "Umbenennen",
"delete": "Löschen",
"editPermissions": "Berechtigungen bearbeiten",
"createNewFolder": "Neuen Ordner erstellen",
"deleteSelectedItems": "Ausgewählte Elemente löschen",
"deleteConfirm": "Delete {{count}} selected item?",
"deleteConfirm_other": "{{count}} ausgewählte Elemente löschen?",
"cancel": "Stornieren",
"save": "Speichern",
"folderCreated": "Ordner erstellt",
"itemRenamed": "Artikel umbenannt",
"actionFailed": "Aktion fehlgeschlagen",
"itemsDeleted": "{{count}} Element gelöscht",
"itemsDeleted_other": "{{count}} gelöschte Elemente",
"deleteFailed": "Löschen fehlgeschlagen",
"permissionsUpdated": "Berechtigungen aktualisiert",
"noRemoteSessionConnected": "Keine Remote-Sitzung verbunden",
"failedToListFiles": "Fehler beim Auflisten der Dateien",
"failedToRefresh": "Aktualisierung fehlgeschlagen",
"destinationInsideSource": "Das Ziel darf nicht innerhalb des ausgewählten Quellpfads liegen.",
"sameHostNoOp": "Quell- und Zielordner sind derselbe Ordner."
},
"tunnels": {
"noSshTunnels": "Keine SSH-Tunnel",
"createFirstTunnelMessage": "Sie haben noch keine SSH-Tunnel erstellt. Konfigurieren Sie Tunnelverbindungen im Host-Manager, um zu beginnen.",
-385
View File
@@ -715,7 +715,6 @@
"hostManager": "Διευθυντής Υποδοχής",
"credentials": "Διαπιστευτήρια",
"portForwarding": "Προώθηση",
"sftp": "SFTP",
"connections": "Συνδέσεις",
"serial": "Σειράς",
"keepSideMenuOpen": "Διατήρηση μενού ανοιχτού",
@@ -767,14 +766,12 @@
"tabRdp": "ΠΑΑ",
"tabVnc": "VNC",
"tabTunnels": "Σήραγγες",
"tabFiles": "Αρχεία",
"tabStats": "Μετρήσεις κεντρικού υπολογιστή",
"tabTelnet": "Τέλνετ",
"tabSharing": "Μοιρασιά",
"tabAuthentication": "Πιστοποίηση",
"terminal": "Τερματικό",
"tunnel": "Σήραγγα",
"fileManager": "Διαχειριστής αρχείων",
"serverStats": "Μετρήσεις κεντρικού υπολογιστή",
"status": {
"available": "Διαθέσιμος",
@@ -1118,12 +1115,6 @@
"proxmoxResultSkippedNoIp": "{{count}} παραλείφθηκε (δεν βρέθηκε IP)",
"proxmoxStatsUrlCopied": "Η διεύθυνση URL των στατιστικών του Proxmox αντιγράφηκε στο πρόχειρο",
"copyProxmoxStatsUrlAction": "Αντιγραφή URL Στατιστικών Proxmox",
"enableFileManagerMonitor": "Ενεργοποίηση Διαχείρισης Αρχείων",
"enableFileManagerMonitorDesc": "Περιηγηθείτε και διαχειριστείτε αρχεία σε αυτόν τον κεντρικό υπολογιστή μέσω SFTP",
"scpLegacyLabel": "Λειτουργία παλαιού τύπου SCP",
"scpLegacyDesc": "Χρησιμοποιήστε μεταφορά αρχείων παλαιού τύπου για διακομιστές που δεν υποστηρίζουν το υποσύστημα SFTP (π.χ. ενσωματωμένοι ή ελάχιστοι διακομιστές SSH).",
"defaultPathLabel": "Προεπιλεγμένη διαδρομή",
"fileManagerPathHint": "Ο κατάλογος που θα ανοίξει κατά την εκκίνηση του διαχειριστή αρχείων για αυτόν τον κεντρικό υπολογιστή.",
"statusChecksLabel": "Έλεγχοι κατάστασης",
"enableStatusChecks": "Ενεργοποίηση ελέγχων κατάστασης",
"enableStatusChecksDesc": "Περιοδική ping σε αυτόν τον κεντρικό υπολογιστή για επαλήθευση διαθεσιμότητας",
@@ -1278,7 +1269,6 @@
"deletedHost": "Διαγράφηκε {{name}}",
"copiedToClipboard": "Αντιγράφηκε στο πρόχειρο",
"terminalUrlCopied": "Η διεύθυνση URL του τερματικού αντιγράφηκε",
"fileManagerUrlCopied": "Η διεύθυνση URL του διαχειριστή αρχείων αντιγράφηκε",
"tunnelUrlCopied": "Η διεύθυνση URL της σήραγγας αντιγράφηκε",
"dockerUrlCopied": "Η διεύθυνση URL του Docker αντιγράφηκε",
"hostMetricsUrlCopied": "Η διεύθυνση URL μετρήσεων κεντρικού υπολογιστή αντιγράφηκε",
@@ -1296,7 +1286,6 @@
"copyAddress": "Αντιγραφή διεύθυνσης",
"copyLink": "Αντιγραφή συνδέσμου",
"copyTerminalUrlAction": "Αντιγραφή URL τερματικού",
"copyFileManagerUrlAction": "Αντιγραφή URL Διαχείρισης Αρχείων",
"copyTunnelUrlAction": "Αντιγραφή URL σήραγγας",
"copyDockerUrlAction": "Αντιγραφή διεύθυνσης URL του Docker",
"copyHostMetricsUrlAction": "Αντιγραφή URL μετρήσεων κεντρικού υπολογιστή",
@@ -1857,336 +1846,6 @@
"removeFromSplit": "Αφαίρεση από το Σπλιτ"
}
},
"fileManager": {
"noHostSelected": "Δεν έχει επιλεγεί κεντρικός υπολογιστής",
"initializingEditor": "Αρχικοποίηση προγράμματος επεξεργασίας...",
"file": "Αρχείο",
"folder": "Ντοσιέ",
"uploadFile": "Μεταφόρτωση αρχείου",
"downloadFile": "Λήψη",
"extractArchive": "Εξαγωγή αρχείου",
"extractingArchive": "Εξαγωγή {{name}}...",
"archiveExtractedSuccessfully": "{{name}} εξήχθη με επιτυχία",
"extractFailed": "Η εξαγωγή απέτυχε.",
"compressFile": "Συμπίεση αρχείου",
"compressFiles": "Συμπίεση αρχείων",
"compressFilesDesc": "Συμπίεση στοιχείων {{count}} σε ένα αρχείο",
"archiveName": "Όνομα αρχείου",
"enterArchiveName": "Εισαγάγετε όνομα αρχείου...",
"compressionFormat": "Μορφή συμπίεσης",
"selectedFiles": "Επιλεγμένα αρχεία",
"andMoreFiles": "και {{count}} περισσότερα...",
"compress": "Κομπρέσα",
"compressingFiles": "Συμπίεση στοιχείων {{count}} σε {{name}}...",
"filesCompressedSuccessfully": "{{name}} δημιουργήθηκε με επιτυχία",
"compressFailed": "Η συμπίεση απέτυχε",
"edit": "Εκδίδω",
"preview": "Πρεμιέρα",
"previous": "Προηγούμενος",
"next": "Επόμενος",
"pageXOfY": "Σελίδα {{current}} από {{total}}",
"zoomOut": "Σμίκρυνση",
"zoomIn": "Μεγέθυνση",
"newFile": "Νέο αρχείο",
"newFolder": "Νέος φάκελος",
"rename": "Μετονομάζω",
"uploading": "Μεταφόρτωση...",
"uploadingFile": "Μεταφόρτωση {{name}}...",
"fileName": "Όνομα αρχείου",
"folderName": "Όνομα φακέλου",
"fileUploadedSuccessfully": "Το αρχείο \"{{name}}\" μεταφορτώθηκε με επιτυχία",
"failedToUploadFile": "Η μεταφόρτωση του αρχείου απέτυχε",
"uploadingFolderFiles": "Μεταφόρτωση {{count}} αρχείου(ων)...",
"uploadedFolderFiles": "Μεταφορτώθηκαν με επιτυχία τα {{count}} αρχεία",
"uploadedFolderPartial": "Η μεταφόρτωση {{uploaded}} αρχείου(ων), {{failed}} απέτυχε",
"fileDownloadedSuccessfully": "Το αρχείο \"{{name}}\" λήφθηκε με επιτυχία.",
"failedToDownloadFile": "Αποτυχία λήψης αρχείου",
"fileCreatedSuccessfully": "Το αρχείο \"{{name}}\" δημιουργήθηκε με επιτυχία",
"folderCreatedSuccessfully": "Ο φάκελος \"{{name}}\" δημιουργήθηκε με επιτυχία",
"failedToCreateItem": "Η δημιουργία στοιχείου απέτυχε",
"operationFailed": "Η λειτουργία {{operation}} απέτυχε για {{name}}: {{error}}",
"failedToResolveSymlink": "Αποτυχία επίλυσης συμβολικού συνδέσμου",
"itemsDeletedSuccessfully": "{{count}} στοιχεία διαγράφηκαν με επιτυχία",
"failedToDeleteItems": "Η διαγραφή στοιχείων απέτυχε",
"sudoPasswordRequired": "Απαιτείται κωδικός πρόσβασης διαχειριστή",
"enterSudoPassword": "Εισαγάγετε τον κωδικό πρόσβασης sudo για να συνεχίσετε αυτήν τη λειτουργία",
"sudoPassword": "Κωδικός πρόσβασης Sudo",
"sudoOperationFailed": "Η λειτουργία Sudo απέτυχε",
"sudoAuthFailed": "Ο έλεγχος ταυτότητας Sudo απέτυχε.",
"dragFilesToUpload": "Αποθέστε αρχεία εδώ για μεταφόρτωση",
"dropToUploadHere": "Αποθέστε εδώ για μεταφόρτωση",
"dropToDownloadHere": "Αποθέστε για λήψη εδώ",
"localFiles": "Τοπικός",
"showLocalFiles": "Εμφάνιση τοπικών αρχείων (διαιρεμένη προβολή)",
"hideLocalFiles": "Απόκρυψη τοπικών αρχείων",
"localHome": "Αρχική σελίδα",
"localPathPlaceholder": "Διαδρομή τοπικού φακέλου",
"localShowHidden": "Εμφάνιση κρυφών αρχείων",
"localHideHidden": "Απόκρυψη κρυφών αρχείων",
"localRevealInFileManager": "Εμφάνιση στον διαχειριστή αρχείων συστήματος",
"localCannotOpenFolder": "Δεν είναι δυνατό το άνοιγμα του φακέλου",
"localItemCount": "{{count}} στοιχεία",
"localSelectedCount": "επιλεγμένο {{count}}",
"back": "Πίσω",
"forward": "Προς τα εμπρός",
"up": "Επάνω",
"kind": "Είδος",
"localUploadingProgress": "Μεταφόρτωση {{current}} από {{total}}",
"localDownloadingProgress": "Λήψη {{current}} από {{total}}",
"localTransferInFlight": "{{count}} ταυτόχρονα",
"localPreparingDownload": "Προετοιμασία λήψης...",
"localTransferCancelled": "Η μεταφορά ακυρώθηκε.",
"localNothingToTransfer": "Δεν υπάρχει τίποτα προς μεταφορά",
"localUploadComplete": "Μεταφορτώθηκε {{count}} αρχείο(α)",
"localUploadFailed": "Η μεταφόρτωση απέτυχε.",
"localUploadPartial": "Η μεταφόρτωση {{done}} αρχείου(ων), {{failed}} απέτυχε",
"localDownloadComplete": "Λήψη {{count}} αρχείου(ων)",
"localDownloadFailed": "Η λήψη απέτυχε.",
"localDownloadPartial": "Η λήψη του/των {{done}} αρχείου/αρχείων απέτυχε, το {{failed}}",
"localReplaceExistingPrompt": "{{count}} στοιχεία υπάρχουν ήδη σε αυτόν τον φάκελο. Να αντικατασταθούν;",
"localReplace": "Αντικαθιστώ",
"localSkipExisting": "Παραλείπω",
"localSkippedExisting": "Παραλείφθηκαν {{count}} υπάρχοντα στοιχεία",
"localUnsafeNamesSkipped": "Παραλείφθηκαν {{count}} στοιχεία των οποίων τα ονόματα δεν μπορούν να χρησιμοποιηθούν σε αυτόν τον υπολογιστή",
"localOpen": "Ανοιχτό",
"localOpenFolder": "Άνοιγμα φακέλου",
"localUploadToRemote": "Μεταφόρτωση στον διακομιστή",
"localUploadToRemoteMany": "Μεταφόρτωση {{count}} στοιχείων στον διακομιστή",
"localMoveToTrash": "Μετακίνηση στον Κάδο",
"localMoveToTrashMany": "Μετακίνηση {{count}} στοιχείων στον Κάδο Απορριμμάτων",
"localTrashConfirmSingle": "Μετακίνηση του \"{{name}}\" στον Κάδο;",
"localTrashConfirmFolder": "Μετακίνηση του φακέλου \"{{name}}\" και όλου του περιεχομένου του στον Κάδο Απορριμμάτων;",
"localTrashConfirmMany": "Μετακίνηση {{count}} στοιχείων στον Κάδο;",
"localTrashed": "Μετακινήθηκαν {{count}} στοιχεία στον Κάδο Απορριμμάτων",
"localTrashFailed": "Δεν ήταν δυνατή η μετακίνηση στον Κάδο",
"localRenameFailed": "Η μετονομασία απέτυχε.",
"localCreateFailed": "Δεν ήταν δυνατή η δημιουργία στοιχείου",
"goToParentFolder": "Μετάβαση στον γονικό φάκελο",
"resizeSidebar": "Σύρετε για να αλλάξετε το μέγεθος της πλαϊνής γραμμής (κάντε διπλό κλικ για επαναφορά)",
"resizeLocalPane": "Σύρετε για να αλλάξετε το μέγεθος του τοπικού παραθύρου (κάντε διπλό κλικ για επαναφορά)",
"columns": "Στήλες",
"columnsHint": "Κάντε δεξί κλικ για να επιλέξετε στήλες",
"emptyFolder": "Αυτός ο φάκελος είναι κενός",
"searchFiles": "Αναζήτηση αρχείων...",
"upload": "Μεταφόρτωση",
"selectHostToStart": "Επιλέξτε έναν κεντρικό υπολογιστή για να ξεκινήσετε τη διαχείριση αρχείων",
"sshRequiredForFileManager": "Ο διαχειριστής αρχείων απαιτεί SSH. Αυτός ο κεντρικός υπολογιστής δεν έχει ενεργοποιημένο SSH.",
"failedToConnect": "Αποτυχία σύνδεσης με SSH",
"failedToLoadDirectory": "Αποτυχία φόρτωσης καταλόγου",
"noSSHConnection": "Δεν υπάρχει διαθέσιμη σύνδεση SSH",
"copy": "Αντίγραφο",
"cut": "Τομή",
"paste": "Πάστα",
"copyPath": "Αντιγραφή διαδρομής",
"copyPaths": "Διαδρομές αντιγραφής",
"delete": "Διαγράφω",
"properties": "Σκηνικά θέατρου",
"refresh": "Φρεσκάρω",
"downloadFiles": "Λήψη αρχείων {{count}} στο πρόγραμμα περιήγησης",
"copyFiles": "Αντιγραφή {{count}} στοιχείων",
"cutFiles": "Αποκοπή {{count}} στοιχείων",
"deleteFiles": "Διαγραφή {{count}} στοιχείων",
"filesCopiedToClipboard": "{{count}} στοιχεία αντιγράφηκαν στο πρόχειρο",
"filesCutToClipboard": "{{count}} στοιχεία αποκόπηκαν στο πρόχειρο",
"pathCopiedToClipboard": "Η διαδρομή αντιγράφηκε στο πρόχειρο",
"pathsCopiedToClipboard": "{{count}} διαδρομές αντιγράφηκαν στο πρόχειρο",
"failedToCopyPath": "Αποτυχία αντιγραφής διαδρομής στο πρόχειρο",
"copyFolderLink": "Αντιγραφή συνδέσμου σε φάκελο",
"copyCurrentFolderLink": "Αντιγραφή συνδέσμου στον τρέχοντα φάκελο",
"folderLinkCopied": "Ο σύνδεσμος φακέλου αντιγράφηκε στο πρόχειρο",
"failedToCopyFolderLink": "Αποτυχία αντιγραφής συνδέσμου φακέλου",
"movedItems": "Μετακινήθηκαν {{count}} στοιχεία",
"failedToDeleteItem": "Η διαγραφή του στοιχείου απέτυχε",
"itemRenamedSuccessfully": "Το {{type}} μετονομάστηκε με επιτυχία",
"failedToRenameItem": "Η μετονομασία του στοιχείου απέτυχε",
"download": "Λήψη",
"openExternalEditor": "Άνοιγμα εξωτερικά",
"chooseExternalEditor": "Επιλογή εξωτερικού επεξεργαστή",
"externalEditorSelected": "Επιλέχθηκε εξωτερικός επεξεργαστής",
"externalEditorDesktopOnly": "Ο εξωτερικός επεξεργαστής είναι διαθέσιμος μόνο στην εφαρμογή για υπολογιστές",
"externalEditorOpened": "Άνοιξε σε εξωτερικό πρόγραμμα επεξεργασίας. Η αποθήκευση εκεί θα μεταφορτώσει τις αλλαγές πίσω στον διακομιστή.",
"failedToOpenExternalEditor": "Αποτυχία ανοίγματος εξωτερικού προγράμματος επεξεργασίας",
"failedToSelectExternalEditor": "Αποτυχία επιλογής εξωτερικού προγράμματος επεξεργασίας",
"permissions": "Δικαιώματα",
"size": "Μέγεθος",
"modified": "Τροποποιημένο",
"path": "Μονοπάτι",
"confirmDelete": "Είστε σίγουροι ότι θέλετε να διαγράψετε το {{name}};",
"permissionDenied": "Η άδεια απορρίφθηκε",
"serverError": "Σφάλμα διακομιστή",
"fileSavedSuccessfully": "Το αρχείο αποθηκεύτηκε με επιτυχία",
"failedToSaveFile": "Η αποθήκευση του αρχείου απέτυχε",
"confirmDeleteSingleItem": "Είστε σίγουροι ότι θέλετε να διαγράψετε οριστικά το \"{{name}}\";",
"confirmDeleteMultipleItems": "Είστε βέβαιοι ότι θέλετε να διαγράψετε οριστικά τα στοιχεία {{count}};",
"confirmDeleteMultipleItemsWithFolders": "Είστε βέβαιοι ότι θέλετε να διαγράψετε οριστικά τα στοιχεία {{count}} ; Αυτό περιλαμβάνει τους φακέλους και το περιεχόμενό τους.",
"confirmDeleteFolder": "Είστε βέβαιοι ότι θέλετε να διαγράψετε οριστικά τον φάκελο \"{{name}}\" και όλα τα περιεχόμενά του;",
"permanentDeleteWarning": "Αυτή η ενέργεια δεν μπορεί να αναιρεθεί. Τα στοιχεία θα διαγραφούν οριστικά από τον διακομιστή.",
"moveToTrashWarning": "Τα στοιχεία θα μετακινηθούν στον Κάδο Απορριμμάτων του Termix και μπορούν να αποκατασταθούν πριν λήξει η περίοδος διατήρησης.",
"itemsMovedToTrash": "{{count}} στοιχείο(α) μετακινήθηκε(αν) στον Κάδο",
"trash": "Σκουπίδια",
"trashDescription": "Τα διαγραμμένα στοιχεία διατηρούνται για {{days}} ημέρα(ες) σε αυτόν τον κεντρικό υπολογιστή.",
"trashLoadFailed": "Αποτυχία φόρτωσης κάδου απορριμμάτων",
"trashEmpty": "Ο κάδος απορριμμάτων είναι άδειος",
"trashRestore": "Επαναφέρω",
"trashRestored": "Αποκαταστάθηκε {{name}}",
"trashRestoreFailed": "Αποτυχία επαναφοράς στοιχείου. Η αρχική διαδρομή ενδέχεται να υπάρχει ήδη.",
"trashDeletePermanently": "Οριστική διαγραφή",
"trashDeleteConfirm": "Να διαγραφεί οριστικά το {{name}}; Αυτή η ενέργεια δεν μπορεί να αναιρεθεί.",
"trashPermanentlyDeleted": "Διαγράφηκε οριστικά {{name}}",
"trashDeleteFailed": "Η οριστική διαγραφή του στοιχείου απέτυχε",
"trashEmptyAction": "Άδειασμα Κάδου",
"trashEmptyConfirm": "Να διαγραφούν οριστικά όλα τα στοιχεία στον Κάδο; Δεν είναι δυνατή η αναίρεση αυτής της ενέργειας.",
"trashEmptied": "Ο κάδος αδειάστηκε",
"trashRetention": "Αυτόματος καθαρισμός μετά από (ημέρες)",
"trashRetentionSaved": "Η διατήρηση απορριμμάτων ενημερώθηκε",
"trashRetentionFailed": "Αποτυχία ενημέρωσης διατήρησης κάδου απορριμμάτων",
"trashUnavailableConfirm": "Αυτό το στοιχείο δεν μπορεί να μετακινηθεί στον Κάδο, συνήθως επειδή βρίσκεται σε άλλο σύστημα αρχείων ή απαιτεί αυξημένα δικαιώματα. Να διαγραφεί οριστικά;",
"recent": "Πρόσφατος",
"pinned": "Καρφιτσωμένο",
"folderShortcuts": "Συντομεύσεις φακέλων",
"failedToReconnectSSH": "Αποτυχία επανασύνδεσης συνεδρίας SSH",
"openTerminalHere": "Άνοιγμα τερματικού εδώ",
"run": "Τρέξιμο",
"openTerminalInFolder": "Άνοιγμα τερματικού σε αυτόν τον φάκελο",
"openTerminalInFileLocation": "Άνοιγμα τερματικού στη θέση αρχείου",
"runningFile": "Τρέξιμο - {{file}}",
"onlyRunExecutableFiles": "Μπορεί να εκτελέσει μόνο εκτελέσιμα αρχεία",
"directories": "Κατάλογοι",
"removedFromRecentFiles": "Αφαιρέθηκε το \"{{name}}\" από πρόσφατα αρχεία",
"removeFailed": "Η κατάργηση απέτυχε.",
"unpinnedSuccessfully": "Το \"{{name}}\" ξεκαρφιτσώθηκε με επιτυχία",
"unpinFailed": "Το ξεκαρφίτσωμα απέτυχε.",
"removedShortcut": "Αφαιρέθηκε η συντόμευση \"{{name}}\"",
"removeShortcutFailed": "Η κατάργηση της συντόμευσης απέτυχε.",
"clearedAllRecentFiles": "Διαγράφηκαν όλα τα πρόσφατα αρχεία",
"clearFailed": "Η διαγραφή απέτυχε.",
"removeFromRecentFiles": "Κατάργηση από πρόσφατα αρχεία",
"clearAllRecentFiles": "Διαγραφή όλων των πρόσφατων αρχείων",
"unpinFile": "Ξεκαρφίτσωμα αρχείου",
"removeShortcut": "Κατάργηση συντόμευσης",
"pinFile": "Καρφίτσωμα αρχείου",
"addToShortcuts": "Προσθήκη στις συντομεύσεις",
"pasteFailed": "Η επικόλληση απέτυχε.",
"noUndoableActions": "Δεν υπάρχουν ενέργειες που μπορούν να αναιρεθούν",
"undoCopySuccess": "Αναίρεση λειτουργίας αντιγραφής: Διαγράφηκαν {{count}} αντιγραμμένα αρχεία",
"undoCopyFailedDelete": "Η αναίρεση απέτυχε: Δεν ήταν δυνατή η διαγραφή των αντιγραμμένων αρχείων",
"undoCopyFailedNoInfo": "Η αναίρεση απέτυχε: Δεν ήταν δυνατή η εύρεση των πληροφοριών του αντιγραμμένου αρχείου",
"undoMoveSuccess": "Αναίρεση λειτουργίας μετακίνησης: Μετακινήθηκαν τα αρχεία {{count}} πίσω στην αρχική τους θέση",
"undoMoveFailedMove": "Η αναίρεση απέτυχε: Δεν ήταν δυνατή η επαναφορά κανενός αρχείου",
"undoMoveFailedNoInfo": "Η αναίρεση απέτυχε: Δεν ήταν δυνατή η εύρεση των πληροφοριών του μετακινημένου αρχείου",
"undoDeleteNotSupported": "Η λειτουργία διαγραφής δεν μπορεί να αναιρεθεί: Τα αρχεία έχουν διαγραφεί οριστικά από τον διακομιστή",
"undoTypeNotSupported": "Μη υποστηριζόμενος τύπος λειτουργίας αναίρεσης",
"undoOperationFailed": "Η λειτουργία αναίρεσης απέτυχε",
"unknownError": "Άγνωστο σφάλμα",
"confirm": "Επιβεβαιώνω",
"find": "Εύρημα...",
"replace": "Αντικαθιστώ",
"downloadInstead": "Λήψη εναλλακτικά",
"keyboardShortcuts": "Συντομεύσεις πληκτρολογίου",
"searchAndReplace": "Αναζήτηση & Αντικατάσταση",
"editing": "Επεξεργασία",
"search": "Ερευνα",
"findNext": "Εύρεση επόμενου",
"findPrevious": "Εύρεση προηγούμενου",
"save": "Εκτός",
"selectAll": "Επιλογή όλων",
"undo": "Ξεκάνω",
"redo": "Ξανακάνω",
"moveLineUp": "Μετακίνηση γραμμής προς τα πάνω",
"moveLineDown": "Μετακίνηση γραμμής προς τα κάτω",
"toggleComment": "Εναλλαγή σχολίου",
"autoComplete": "Αυτόματη συμπλήρωση",
"imageLoadError": "Αποτυχία φόρτωσης εικόνας",
"startTyping": "Ξεκινήστε να πληκτρολογείτε...",
"unknownSize": "Άγνωστο μέγεθος",
"fileIsEmpty": "Το αρχείο είναι κενό",
"largeFileWarning": "Προειδοποίηση για μεγάλο αρχείο",
"largeFileWarningDesc": "Αυτό το αρχείο έχει μέγεθος {{size}} , το οποίο ενδέχεται να προκαλέσει προβλήματα απόδοσης όταν ανοιχτεί ως κείμενο.",
"fileNotFoundAndRemoved": "Το αρχείο \"{{name}}\" δεν βρέθηκε και έχει αφαιρεθεί από τα πρόσφατα/καρφιτσωμένα αρχεία",
"failedToLoadFile": "Αποτυχία φόρτωσης αρχείου: {{error}}",
"serverErrorOccurred": "Παρουσιάστηκε σφάλμα διακομιστή. Δοκιμάστε ξανά αργότερα.",
"autoSaveFailed": "Η αυτόματη αποθήκευση απέτυχε",
"fileAutoSaved": "Το αρχείο αποθηκεύτηκε αυτόματα",
"moveFileFailed": "Αποτυχία μετακίνησης {{name}}",
"moveOperationFailed": "Η λειτουργία μετακίνησης απέτυχε",
"canOnlyCompareFiles": "Μπορεί να συγκρίνει μόνο δύο αρχεία",
"comparingFiles": "Σύγκριση αρχείων: {{file1}} και {{file2}}",
"dragFailed": "Η λειτουργία μεταφοράς απέτυχε",
"filePinnedSuccessfully": "Το αρχείο \"{{name}}\" καρφιτσώθηκε με επιτυχία",
"pinFileFailed": "Αποτυχία καρφιτσώματος αρχείου",
"fileUnpinnedSuccessfully": "Το αρχείο \"{{name}}\" ξεκαρφιτσώθηκε με επιτυχία",
"unpinFileFailed": "Αποτυχία ξεκαρφιτσώματος αρχείου",
"shortcutAddedSuccessfully": "Η συντόμευση φακέλου \"{{name}}\" προστέθηκε με επιτυχία",
"addShortcutFailed": "Η προσθήκη συντόμευσης απέτυχε",
"operationCompletedSuccessfully": "{{operation}} {{count}} στοιχεία με επιτυχία",
"operationCompleted": "{{operation}} {{count}} στοιχεία",
"downloadFileSuccess": "Το αρχείο {{name}} λήφθηκε με επιτυχία.",
"downloadFileFailed": "Η λήψη απέτυχε.",
"downloadingFile": "Λήψη {{name}}…",
"downloadProgressBytes": "{{transferred}} / {{total}}",
"moveTo": "Μετακίνηση σε {{name}}",
"diffCompareWith": "Διαφορά σε σύγκριση με {{name}}",
"dragOutsideToDownload": "Σύρετε έξω από το παράθυρο για λήψη ({{count}} αρχείων)",
"newFolderDefault": "ΝέοςΦάκελος",
"newFileDefault": "ΝέοΑρχείο.txt",
"successfullyMovedItems": "Επιτυχής μετακίνηση {{count}} στοιχείων στο {{target}}",
"move": "Κίνηση",
"searchInFile": "Αναζήτηση σε αρχείο (Ctrl+F)",
"showKeyboardShortcuts": "Εμφάνιση συντομεύσεων πληκτρολογίου",
"decreaseFontSize": "Μείωση μεγέθους γραμματοσειράς",
"increaseFontSize": "Αύξηση μεγέθους γραμματοσειράς",
"startWritingMarkdown": "Ξεκινήστε να γράφετε το περιεχόμενό σας με έκπτωση...",
"loadingFileComparison": "Φόρτωση σύγκρισης αρχείων...",
"reload": "Γεμίζω πάλι",
"compare": "Συγκρίνω",
"sideBySide": "Πλάι πλάι",
"inline": "Ενσωματωμένο",
"fileComparison": "Σύγκριση αρχείων: {{file1}} έναντι {{file2}}",
"fileTooLarge": "Το αρχείο είναι πολύ μεγάλο: {{error}}",
"sshConnectionFailed": "Η σύνδεση SSH απέτυχε. Ελέγξτε τη σύνδεσή σας με {{name}} ({{ip}}:{{port}})",
"loadFileFailed": "Αποτυχία φόρτωσης αρχείου: {{error}}",
"connecting": "Συνδετικός...",
"connectedSuccessfully": "Η σύνδεση ολοκληρώθηκε με επιτυχία",
"totpVerificationFailed": "Η επαλήθευση TOTP απέτυχε.",
"warpgateVerificationFailed": "Η πιστοποίηση Warpgate απέτυχε.",
"authenticationFailed": "Η επαλήθευση ταυτότητας απέτυχε",
"incorrectPassphrase": "Λανθασμένη φράση πρόσβασης. Δοκιμάστε ξανά.",
"verificationCodePrompt": "Κωδικός επαλήθευσης:",
"changePermissions": "Αλλαγή Δικαιωμάτων",
"currentPermissions": "Τρέχοντα Δικαιώματα",
"owner": "Ιδιοκτήτης",
"group": "Ομάδα",
"others": "Άλλοι",
"read": "Ανάγνωση",
"write": "Γράφω",
"execute": "Εκτελώ",
"permissionsChangedSuccessfully": "Τα δικαιώματα άλλαξαν με επιτυχία",
"failedToChangePermissions": "Αποτυχία αλλαγής δικαιωμάτων",
"name": "Ονομα",
"sortByName": "Ονομα",
"sortByDate": "Ημερομηνία τροποποίησης",
"sortBySize": "Μέγεθος",
"ascending": "Αύξουσα",
"descending": "Φθίνων",
"root": "Ρίζα",
"new": "Νέος",
"sortBy": "Ταξινόμηση κατά",
"items": "Αντικείμενα",
"selected": "Επιλεγμένο",
"editor": "Συντάκτης",
"octal": "Οκτάεδρος",
"storage": "Αποθήκευση",
"disk": "Δίσκος",
"used": "Μεταχειρισμένος",
"of": "του",
"toggleSidebar": "Εναλλαγή πλευρικής γραμμής",
"compactLayout": "Χρήση συμπαγούς διάταξης",
"comfortableLayout": "Χρησιμοποιήστε άνετη διάταξη",
"cannotLoadPdf": "Δεν είναι δυνατή η φόρτωση του PDF",
"pdfLoadError": "Παρουσιάστηκε σφάλμα κατά τη φόρτωση αυτού του αρχείου PDF.",
"loadingPdf": "Φόρτωση PDF...",
"loadingPage": "Φόρτωση σελίδας..."
},
"transfer": {
"copyToHost": "Αντιγραφή στον κεντρικό υπολογιστή…",
"moveToHost": "Μετακίνηση στον κεντρικό υπολογιστή…",
@@ -2292,50 +1951,6 @@
"retryTransferError": "Η επανάληψη απέτυχε",
"transferFailedRetryHint": "Μερικά δεδομένα διατηρήθηκαν στον προορισμό. Η επανάληψη θα συνεχιστεί όταν αποκατασταθεί η σύνδεση."
},
"sftpTransfer": {
"title": "SFTP",
"description": "Μεταφορά αρχείων μεταξύ δύο κεντρικών υπολογιστών SSH.",
"sourceServer": "Διακομιστής πηγής",
"destinationServer": "Διακομιστής προορισμού",
"selectHost": "Επιλογή κεντρικού υπολογιστή",
"noHostSelected": "Δεν έχει επιλεγεί κεντρικός υπολογιστής",
"move": "Κίνηση",
"copy": "Αντίγραφο",
"upload": "Μεταφόρτωση",
"itemsSelected": "Επιλέχθηκε {{count}} στοιχείο",
"itemsSelected_other": "Επιλέχθηκαν {{count}} στοιχεία",
"ready": "Ετοιμος",
"loadingHosts": "Φόρτωση κεντρικών υπολογιστών...",
"name": "Ονομα",
"modified": "Τροποποιημένο",
"size": "Μέγεθος",
"noFiles": "Δεν υπάρχουν αρχεία",
"open": "Ανοιχτό",
"up": "Επάνω",
"refresh": "Φρεσκάρω",
"copyToTarget": "Αντιγραφή στον κατάλογο προορισμού",
"rename": "Μετονομάζω",
"delete": "Διαγράφω",
"editPermissions": "Επεξεργασία δικαιωμάτων",
"createNewFolder": "Δημιουργία νέου φακέλου",
"deleteSelectedItems": "Διαγραφή επιλεγμένων στοιχείων",
"deleteConfirm": "Διαγραφή του επιλεγμένου {{count}} στοιχείου;",
"deleteConfirm_other": "Διαγραφή {{count}} επιλεγμένων στοιχείων;",
"cancel": "Ματαίωση",
"save": "Εκτός",
"folderCreated": "Ο φάκελος δημιουργήθηκε",
"itemRenamed": "Το στοιχείο μετονομάστηκε",
"actionFailed": "Η ενέργεια απέτυχε",
"itemsDeleted": "{{count}} στοιχείο διαγράφηκε",
"itemsDeleted_other": "{{count}} στοιχεία διαγράφηκαν",
"deleteFailed": "Η διαγραφή απέτυχε.",
"permissionsUpdated": "Τα δικαιώματα ενημερώθηκαν",
"noRemoteSessionConnected": "Δεν έχει συνδεθεί απομακρυσμένη συνεδρία",
"failedToListFiles": "Αποτυχία καταχώρισης αρχείων",
"failedToRefresh": "Η ανανέωση απέτυχε",
"destinationInsideSource": "Ο προορισμός δεν μπορεί να βρίσκεται εντός της επιλεγμένης διαδρομής προέλευσης.",
"sameHostNoOp": "Η προέλευση και ο προορισμός είναι ο ίδιος φάκελος."
},
"tunnels": {
"noSshTunnels": "Δεν υπάρχουν σήραγγες SSH",
"createFirstTunnelMessage": "Δεν έχετε δημιουργήσει ακόμη σήραγγες SSH. Διαμορφώστε τις συνδέσεις σήραγγας στο Host Manager για να ξεκινήσετε.",
-385
View File
@@ -715,7 +715,6 @@
"hostManager": "Gestor de hosts",
"credentials": "Credenciales",
"portForwarding": "Reenvío",
"sftp": "SFTP",
"connections": "Conexiones",
"serial": "Serial",
"keepSideMenuOpen": "Mantener el menú abierto",
@@ -767,14 +766,12 @@
"tabRdp": "RDP",
"tabVnc": "VNC",
"tabTunnels": "Túneles",
"tabFiles": "Archivos",
"tabStats": "Métricas del host",
"tabTelnet": "Telnet",
"tabSharing": "Compartir",
"tabAuthentication": "Autenticación",
"terminal": "Terminal",
"tunnel": "Túnel",
"fileManager": "Gestor de archivos",
"serverStats": "Métricas del host",
"status": {
"available": "Disponible",
@@ -1118,12 +1115,6 @@
"proxmoxResultSkippedNoIp": "{{count}} omitido (no se encontró ninguna IP)",
"proxmoxStatsUrlCopied": "Se copió al portapapeles la URL de estadísticas de Proxmox.",
"copyProxmoxStatsUrlAction": "Copiar la URL de estadísticas de Proxmox",
"enableFileManagerMonitor": "Habilitar Gestor de archivos",
"enableFileManagerMonitorDesc": "Explorar y gestionar archivos en este host mediante SFTP",
"scpLegacyLabel": "Modo SCP heredado",
"scpLegacyDesc": "Usar transferencia de archivos heredada para servidores que no admiten el subsistema SFTP (p. ej., servidores SSH integrados o mínimos).",
"defaultPathLabel": "Ruta predeterminada",
"fileManagerPathHint": "Directorio que se abre al iniciar el gestor de archivos para este host.",
"statusChecksLabel": "Comprobaciones de estado",
"enableStatusChecks": "Habilitar comprobaciones de estado",
"enableStatusChecksDesc": "Hacer ping periódicamente a este host para verificar su disponibilidad.",
@@ -1278,7 +1269,6 @@
"deletedHost": "Eliminado {{name}}",
"copiedToClipboard": "Copiado al portapapeles",
"terminalUrlCopied": "URL de terminal copiada",
"fileManagerUrlCopied": "URL del gestor de archivos copiada",
"tunnelUrlCopied": "URL del túnel copiada",
"dockerUrlCopied": "URL de Docker copiada",
"hostMetricsUrlCopied": "URL de métricas del host copiada",
@@ -1296,7 +1286,6 @@
"copyAddress": "Copiar dirección",
"copyLink": "Copiar enlace",
"copyTerminalUrlAction": "Copiar URL del terminal",
"copyFileManagerUrlAction": "Copiar URL del gestor de archivos",
"copyTunnelUrlAction": "Copiar URL del túnel",
"copyDockerUrlAction": "Copiar URL de Docker",
"copyHostMetricsUrlAction": "Copiar URL de métricas del host",
@@ -1857,336 +1846,6 @@
"removeFromSplit": "Quitar de la división"
}
},
"fileManager": {
"noHostSelected": "Ningún host seleccionado",
"initializingEditor": "Inicializando editor...",
"file": "Archivo",
"folder": "Carpeta",
"uploadFile": "Subir archivo",
"downloadFile": "Descargar",
"extractArchive": "Extraer archivo",
"extractingArchive": "Extrayendo {{name}}...",
"archiveExtractedSuccessfully": "{{name}} extraído correctamente",
"extractFailed": "Extracción fallida",
"compressFile": "Comprimir archivo",
"compressFiles": "Comprimir archivos",
"compressFilesDesc": "Comprimir {{count}} elementos en un archivo",
"archiveName": "Nombre del archivo",
"enterArchiveName": "Introduzca el nombre del archivo...",
"compressionFormat": "Formato de compresión",
"selectedFiles": "Archivos seleccionados",
"andMoreFiles": "y {{count}} más...",
"compress": "Comprimir",
"compressingFiles": "Comprimiendo {{count}} elementos en {{name}}...",
"filesCompressedSuccessfully": "{{name}} creado correctamente",
"compressFailed": "La compresión falló",
"edit": "Editar",
"preview": "Vista previa",
"previous": "Anterior",
"next": "Siguiente",
"pageXOfY": "Página {{current}} de {{total}}",
"zoomOut": "Alejar",
"zoomIn": "Acercar",
"newFile": "Nuevo archivo",
"newFolder": "Nueva carpeta",
"rename": "Renombrar",
"uploading": "Subiendo...",
"uploadingFile": "Subiendo {{name}}...",
"fileName": "Nombre del archivo",
"folderName": "Nombre de la carpeta",
"fileUploadedSuccessfully": "Archivo \"{{name}}\" subido correctamente",
"failedToUploadFile": "Error al subir el archivo",
"uploadingFolderFiles": "Subiendo {{count}} archivo(s)...",
"uploadedFolderFiles": "Se subieron correctamente los archivos {{count}}",
"uploadedFolderPartial": "Se subieron {{uploaded}} archivos, {{failed}} falló.",
"fileDownloadedSuccessfully": "Archivo \"{{name}}\" descargado correctamente",
"failedToDownloadFile": "Error al descargar el archivo",
"fileCreatedSuccessfully": "Archivo \"{{name}}\" creado correctamente",
"folderCreatedSuccessfully": "Carpeta \"{{name}}\" creada correctamente",
"failedToCreateItem": "No se pudo crear el elemento",
"operationFailed": "La operación {{operation}} falló para {{name}}: {{error}}",
"failedToResolveSymlink": "No se pudo resolver el enlace simbólico",
"itemsDeletedSuccessfully": "{{count}} elementos eliminados correctamente",
"failedToDeleteItems": "No se pudieron eliminar los elementos",
"sudoPasswordRequired": "Se requiere contraseña de administrador",
"enterSudoPassword": "Introduzca la contraseña sudo para continuar esta operación",
"sudoPassword": "Contraseña sudo",
"sudoOperationFailed": "La operación sudo falló",
"sudoAuthFailed": "La autenticación sudo falló",
"dragFilesToUpload": "Suelte los archivos aquí para subirlos",
"dropToUploadHere": "Arrastra para subir aquí",
"dropToDownloadHere": "Haz clic aquí para descargar",
"localFiles": "Local",
"showLocalFiles": "Mostrar archivos locales (vista dividida)",
"hideLocalFiles": "Ocultar archivos locales",
"localHome": "Carpeta de inicio",
"localPathPlaceholder": "Ruta de la carpeta local",
"localShowHidden": "Mostrar archivos ocultos",
"localHideHidden": "Ocultar archivos ocultos",
"localRevealInFileManager": "Mostrar en el administrador de archivos del sistema",
"localCannotOpenFolder": "No se puede abrir la carpeta",
"localItemCount": "{{count}} elementos",
"localSelectedCount": "{{count}} seleccionado",
"back": "Atrás",
"forward": "Adelante",
"up": "Arriba",
"kind": "Amable",
"localUploadingProgress": "Subiendo {{current}} de {{total}}",
"localDownloadingProgress": "Descargando {{current}} de {{total}}",
"localTransferInFlight": "{{count}} al instante",
"localPreparingDownload": "Preparando la descarga...",
"localTransferCancelled": "Transferencia cancelada",
"localNothingToTransfer": "Nada que transferir",
"localUploadComplete": "Archivo(s) subido(s) {{count}}",
"localUploadFailed": "Error al cargar la página",
"localUploadPartial": "Se subieron {{done}} archivos, {{failed}} falló.",
"localDownloadComplete": "Archivo(s) descargado(s) {{count}}",
"localDownloadFailed": "Falló la descarga.",
"localDownloadPartial": "Se descargaron {{done}} archivos, {{failed}} falló.",
"localReplaceExistingPrompt": "Ya existen {{count}} elementos en esta carpeta. ¿Desea reemplazarlos?",
"localReplace": "Reemplazar",
"localSkipExisting": "Saltar",
"localSkippedExisting": "Se omitieron {{count}} elementos existentes",
"localUnsafeNamesSkipped": "Se omitieron {{count}} elementos cuyos nombres no se pueden usar en este ordenador.",
"localOpen": "Abierto",
"localOpenFolder": "Abrir carpeta",
"localUploadToRemote": "Subir al servidor",
"localUploadToRemoteMany": "Sube {{count}} elementos al servidor",
"localMoveToTrash": "Mover a la basura",
"localMoveToTrashMany": "Mover {{count}} elementos a la Papelera",
"localTrashConfirmSingle": "¿Mover \"{{name}}\" a la papelera?",
"localTrashConfirmFolder": "¿Mover la carpeta \"{{name}}\" y todo su contenido a la Papelera?",
"localTrashConfirmMany": "¿Mover {{count}} elementos a la Papelera?",
"localTrashed": "Se movieron {{count}} elementos a la Papelera",
"localTrashFailed": "No se pudo mover a la papelera",
"localRenameFailed": "Error al cambiar el nombre",
"localCreateFailed": "No se pudo crear el artículo.",
"goToParentFolder": "Ir a la carpeta principal",
"resizeSidebar": "Arrastra para cambiar el tamaño de la barra lateral (haz doble clic para restablecer).",
"resizeLocalPane": "Arrastra para cambiar el tamaño del panel local (haz doble clic para restablecer).",
"columns": "Columnas",
"columnsHint": "Haz clic con el botón derecho para elegir las columnas.",
"emptyFolder": "Esta carpeta está vacía",
"searchFiles": "Buscar archivos...",
"upload": "Subir",
"selectHostToStart": "Seleccione un host para iniciar la gestión de archivos",
"sshRequiredForFileManager": "El gestor de archivos requiere SSH. Este host no tiene SSH habilitado.",
"failedToConnect": "No se pudo conectar a SSH",
"failedToLoadDirectory": "No se pudo cargar el directorio",
"noSSHConnection": "No hay conexión SSH disponible",
"copy": "Copiar",
"cut": "Cortar",
"paste": "Pegar",
"copyPath": "Copiar ruta",
"copyPaths": "Copiar rutas",
"delete": "Eliminar",
"properties": "Propiedades",
"refresh": "Actualizar",
"downloadFiles": "Descargar {{count}} archivos al navegador",
"copyFiles": "Copiar {{count}} elementos",
"cutFiles": "Cortar {{count}} elementos",
"deleteFiles": "Eliminar {{count}} elementos",
"filesCopiedToClipboard": "{{count}} elementos copiados al portapapeles",
"filesCutToClipboard": "{{count}} elementos cortados al portapapeles",
"pathCopiedToClipboard": "Ruta copiada al portapapeles",
"pathsCopiedToClipboard": "{{count}} rutas copiadas al portapapeles",
"failedToCopyPath": "Error al copiar la ruta al portapapeles",
"copyFolderLink": "Copiar enlace a la carpeta",
"copyCurrentFolderLink": "Copiar enlace a la carpeta actual",
"folderLinkCopied": "Enlace a la carpeta copiado al portapapeles",
"failedToCopyFolderLink": "Error al copiar el enlace a la carpeta",
"movedItems": "{{count}} elementos movidos",
"failedToDeleteItem": "Error al eliminar el elemento",
"itemRenamedSuccessfully": "{{type}} renombrado con éxito",
"failedToRenameItem": "Error al renombrar el elemento",
"download": "Descargar",
"openExternalEditor": "Abrir externamente",
"chooseExternalEditor": "Elegir editor externo",
"externalEditorSelected": "Editor externo seleccionado",
"externalEditorDesktopOnly": "El editor externo solo está disponible en la aplicación de escritorio",
"externalEditorOpened": "Abierto en el editor externo. Al guardar allí se subirán los cambios al servidor.",
"failedToOpenExternalEditor": "Error al abrir el editor externo",
"failedToSelectExternalEditor": "Error al seleccionar el editor externo",
"permissions": "Permisos",
"size": "Tamaño",
"modified": "Modificado",
"path": "Ruta",
"confirmDelete": "¿Está seguro de que desea eliminar {{name}}?",
"permissionDenied": "Permiso denegado",
"serverError": "Error del servidor",
"fileSavedSuccessfully": "Archivo guardado con éxito",
"failedToSaveFile": "Error al guardar el archivo",
"confirmDeleteSingleItem": "¿Está seguro de que desea eliminar permanentemente \"{{name}}\"?",
"confirmDeleteMultipleItems": "¿Está seguro de que desea eliminar permanentemente {{count}} elementos?",
"confirmDeleteMultipleItemsWithFolders": "¿Está seguro de que desea eliminar permanentemente {{count}} elementos? Esto incluye carpetas y su contenido.",
"confirmDeleteFolder": "¿Está seguro de que desea eliminar permanentemente la carpeta \"{{name}}\" y todo su contenido?",
"permanentDeleteWarning": "Esta acción no se puede deshacer. Los elementos se eliminarán permanentemente del servidor.",
"moveToTrashWarning": "El/los elemento(s) se moverán a la Papelera de Termix y se podrán restaurar antes de que expire el período de retención.",
"itemsMovedToTrash": "{{count}} elemento(s) movido(s) a la Papelera",
"trash": "Basura",
"trashDescription": "Los elementos eliminados se conservan durante {{days}} día(s) en este host.",
"trashLoadFailed": "No se pudo cargar la papelera.",
"trashEmpty": "La papelera está vacía",
"trashRestore": "Restaurar",
"trashRestored": "Restaurado {{name}}",
"trashRestoreFailed": "No se pudo restaurar el elemento. Es posible que la ruta original ya exista.",
"trashDeletePermanently": "Eliminar permanentemente",
"trashDeleteConfirm": "¿Eliminar permanentemente {{name}}? Esto no se puede deshacer.",
"trashPermanentlyDeleted": "Eliminado permanentemente {{name}}",
"trashDeleteFailed": "No se pudo eliminar el elemento de forma permanente.",
"trashEmptyAction": "Vaciar la basura",
"trashEmptyConfirm": "¿Eliminar permanentemente todos los elementos de la Papelera? Esta acción es irreversible.",
"trashEmptied": "Basura vaciada",
"trashRetention": "Limpieza automática después de (días)",
"trashRetentionSaved": "Se ha actualizado la retención de basura.",
"trashRetentionFailed": "Error al actualizar la retención de la papelera",
"trashUnavailableConfirm": "Este elemento no se puede mover a la Papelera, normalmente porque se encuentra en otro sistema de archivos o requiere permisos de administrador. ¿Desea eliminarlo permanentemente?",
"recent": "Recientes",
"pinned": "Fijados",
"folderShortcuts": "Accesos directos a carpetas",
"failedToReconnectSSH": "Error al reconectar la sesión SSH",
"openTerminalHere": "Abrir terminal aquí",
"run": "Ejecutar",
"openTerminalInFolder": "Abrir terminal en esta carpeta",
"openTerminalInFileLocation": "Abrir terminal en la ubicación del archivo",
"runningFile": "Ejecutando - {{file}}",
"onlyRunExecutableFiles": "Solo se pueden ejecutar archivos ejecutables",
"directories": "Directorios",
"removedFromRecentFiles": "Se eliminó \"{{name}}\" de los archivos recientes",
"removeFailed": "Error al eliminar",
"unpinnedSuccessfully": "Se desancló \"{{name}}\" correctamente",
"unpinFailed": "Error al desanclar",
"removedShortcut": "Se eliminó el acceso directo \"{{name}}\"",
"removeShortcutFailed": "Error al eliminar el acceso directo",
"clearedAllRecentFiles": "Se borraron todos los archivos recientes",
"clearFailed": "Error al borrar",
"removeFromRecentFiles": "Eliminar de archivos recientes",
"clearAllRecentFiles": "Borrar todos los archivos recientes",
"unpinFile": "Desanclar archivo",
"removeShortcut": "Eliminar acceso directo",
"pinFile": "Anclar archivo",
"addToShortcuts": "Añadir a accesos directos",
"pasteFailed": "Error al pegar",
"noUndoableActions": "No hay acciones para deshacer",
"undoCopySuccess": "Se deshizo la copia: se eliminaron {{count}} archivos copiados",
"undoCopyFailedDelete": "Error al deshacer: no se pudo eliminar ningún archivo copiado",
"undoCopyFailedNoInfo": "Error al deshacer: no se encontró información de los archivos copiados",
"undoMoveSuccess": "Se deshizo el movimiento: se movieron {{count}} archivos a su ubicación original",
"undoMoveFailedMove": "Error al deshacer: no se pudo mover ningún archivo de vuelta",
"undoMoveFailedNoInfo": "Error al deshacer: no se encontró información de los archivos movidos",
"undoDeleteNotSupported": "La operación de eliminación no se puede deshacer: los archivos se han eliminado permanentemente del servidor",
"undoTypeNotSupported": "Tipo de operación de deshacer no compatible",
"undoOperationFailed": "Error en la operación de deshacer",
"unknownError": "Error desconocido",
"confirm": "Confirmar",
"find": "Buscar...",
"replace": "Reemplazar",
"downloadInstead": "Descargar en su lugar",
"keyboardShortcuts": "Atajos de teclado",
"searchAndReplace": "Buscar y reemplazar",
"editing": "Edición",
"search": "Buscar",
"findNext": "Buscar siguiente",
"findPrevious": "Buscar anterior",
"save": "Guardar",
"selectAll": "Seleccionar todo",
"undo": "Deshacer",
"redo": "Rehacer",
"moveLineUp": "Mover línea hacia arriba",
"moveLineDown": "Mover línea hacia abajo",
"toggleComment": "Alternar comentario",
"autoComplete": "Autocompletar",
"imageLoadError": "Error al cargar la imagen",
"startTyping": "Empiece a escribir...",
"unknownSize": "Tamaño desconocido",
"fileIsEmpty": "El archivo está vacío",
"largeFileWarning": "Advertencia de archivo grande",
"largeFileWarningDesc": "Este archivo tiene un tamaño de {{size}}, lo que puede causar problemas de rendimiento al abrirlo como texto.",
"fileNotFoundAndRemoved": "El archivo \"{{name}}\" no se encontró y se ha eliminado de los archivos recientes/fijados.",
"failedToLoadFile": "Error al cargar el archivo: {{error}}",
"serverErrorOccurred": "Se ha producido un error en el servidor. Vuelva a intentarlo más tarde.",
"autoSaveFailed": "Error en el autoguardado",
"fileAutoSaved": "Archivo autoguardado",
"moveFileFailed": "Error al mover {{name}}",
"moveOperationFailed": "Error en la operación de mover",
"canOnlyCompareFiles": "Solo se pueden comparar dos archivos",
"comparingFiles": "Comparando archivos: {{file1}} y {{file2}}",
"dragFailed": "Error en la operación de arrastre",
"filePinnedSuccessfully": "Archivo \"{{name}}\" fijado correctamente",
"pinFileFailed": "Error al fijar el archivo",
"fileUnpinnedSuccessfully": "Archivo \"{{name}}\" desfijado correctamente",
"unpinFileFailed": "No se pudo desanclar el archivo",
"shortcutAddedSuccessfully": "Acceso directo a la carpeta \"{{name}}\" añadido correctamente",
"addShortcutFailed": "No se pudo añadir el acceso directo",
"operationCompletedSuccessfully": "{{operation}} {{count}} elementos correctamente",
"operationCompleted": "{{operation}} {{count}} elementos",
"downloadFileSuccess": "Archivo {{name}} descargado correctamente",
"downloadFileFailed": "Error en la descarga",
"downloadingFile": "Descargando {{name}}…",
"downloadProgressBytes": "{{transferred}} / {{total}}",
"moveTo": "Mover a {{name}}",
"diffCompareWith": "Comparar diferencias con {{name}}",
"dragOutsideToDownload": "Arrastrar fuera de la ventana para descargar ({{count}} archivos)",
"newFolderDefault": "NuevaCarpeta",
"newFileDefault": "NuevoArchivo.txt",
"successfullyMovedItems": "{{count}} elementos movidos correctamente a {{target}}",
"move": "Mover",
"searchInFile": "Buscar en archivo (Ctrl+F)",
"showKeyboardShortcuts": "Mostrar atajos de teclado",
"decreaseFontSize": "Reducir tamaño de fuente",
"increaseFontSize": "Aumentar tamaño de fuente",
"startWritingMarkdown": "Empiece a escribir su contenido Markdown...",
"loadingFileComparison": "Cargando comparación de archivos...",
"reload": "Recargar",
"compare": "Comparar",
"sideBySide": "Lado a lado",
"inline": "En línea",
"fileComparison": "Comparación de archivos: {{file1}} vs {{file2}}",
"fileTooLarge": "Archivo demasiado grande: {{error}}",
"sshConnectionFailed": "Error de conexión SSH. Verifique su conexión a {{name}} ({{ip}}:{{port}})",
"loadFileFailed": "Error al cargar el archivo: {{error}}",
"connecting": "Conectando...",
"connectedSuccessfully": "Conectado correctamente",
"totpVerificationFailed": "Error en la verificación TOTP",
"warpgateVerificationFailed": "Error de autenticación de Warpgate",
"authenticationFailed": "Error de autenticación",
"incorrectPassphrase": "Frase de contraseña incorrecta. Inténtelo de nuevo.",
"verificationCodePrompt": "Código de verificación:",
"changePermissions": "Cambiar permisos",
"currentPermissions": "Permisos actuales",
"owner": "Propietario",
"group": "Grupo",
"others": "Otros",
"read": "Lectura",
"write": "Escritura",
"execute": "Ejecución",
"permissionsChangedSuccessfully": "Permisos cambiados correctamente",
"failedToChangePermissions": "Error al cambiar los permisos",
"name": "Nombre",
"sortByName": "Nombre",
"sortByDate": "Fecha de modificación",
"sortBySize": "Tamaño",
"ascending": "Ascendente",
"descending": "Descendente",
"root": "Raíz",
"new": "Nuevo",
"sortBy": "Ordenar por",
"items": "Elementos",
"selected": "Seleccionados",
"editor": "Editor",
"octal": "Octal",
"storage": "Almacenamiento",
"disk": "Disco",
"used": "Usado",
"of": "de",
"toggleSidebar": "Alternar barra lateral",
"compactLayout": "Utilice un diseño compacto.",
"comfortableLayout": "Utilice una disposición cómoda",
"cannotLoadPdf": "No se puede cargar el PDF",
"pdfLoadError": "Se produjo un error al cargar este archivo PDF.",
"loadingPdf": "Cargando PDF...",
"loadingPage": "Cargando página..."
},
"transfer": {
"copyToHost": "Copiar al host…",
"moveToHost": "Mover al host…",
@@ -2292,50 +1951,6 @@
"retryTransferError": "Error al reintentar",
"transferFailedRetryHint": "Se conservaron datos parciales en el destino. El reintento se reanudará cuando se restablezca la conexión."
},
"sftpTransfer": {
"title": "SFTP",
"description": "Transferir archivos entre dos hosts SSH.",
"sourceServer": "Servidor de origen",
"destinationServer": "Servidor de destino",
"selectHost": "Seleccione el host",
"noHostSelected": "No se ha seleccionado ningún host.",
"move": "Mover",
"copy": "Copiar",
"upload": "Subir",
"itemsSelected": "{{count}} elemento seleccionado",
"itemsSelected_other": "{{count}} elementos seleccionados",
"ready": "Listo",
"loadingHosts": "Cargando hosts...",
"name": "Nombre",
"modified": "Modificado",
"size": "Tamaño",
"noFiles": "No hay archivos",
"open": "Abierto",
"up": "Arriba",
"refresh": "Refrescar",
"copyToTarget": "Copiar al directorio de destino",
"rename": "Rebautizar",
"delete": "Borrar",
"editPermissions": "Editar permisos",
"createNewFolder": "Crear nueva carpeta",
"deleteSelectedItems": "Eliminar elementos seleccionados",
"deleteConfirm": "¿Eliminar {{count}} elemento seleccionado?",
"deleteConfirm_other": "¿Eliminar {{count}} elementos seleccionados?",
"cancel": "Cancelar",
"save": "Ahorrar",
"folderCreated": "Carpeta creada",
"itemRenamed": "Artículo renombrado",
"actionFailed": "La acción fracasó",
"itemsDeleted": "{{count}} elemento eliminado",
"itemsDeleted_other": "{{count}} elementos eliminados",
"deleteFailed": "Error al eliminar",
"permissionsUpdated": "Permisos actualizados",
"noRemoteSessionConnected": "No hay ninguna sesión remota conectada.",
"failedToListFiles": "No se pudieron listar los archivos.",
"failedToRefresh": "No se pudo actualizar",
"destinationInsideSource": "El destino no puede estar dentro de la ruta de origen seleccionada.",
"sameHostNoOp": "La carpeta de origen y la de destino son la misma."
},
"tunnels": {
"noSshTunnels": "Sin túneles SSH",
"createFirstTunnelMessage": "Aún no has creado ningún túnel SSH. Configura las conexiones de túnel en el Administrador de hosts para empezar.",
Loaded 100 of 259 files, more files were not shown because too many files have changed in this diff. Show more