mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
feat: convert file manager into a plugin
This commit is contained in:
1 parent
b76da9ab52
commit
c85c1fdb86
259 files changed
+54155
-16791
No files matched your search
@@ -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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -53,6 +53,8 @@ const PLUGIN_PURPOSES = new Set<SshConnectPurpose>([
|
||||
"fleet",
|
||||
"remote-desktop",
|
||||
"tunnel",
|
||||
"file-manager",
|
||||
"file-transfer",
|
||||
]);
|
||||
|
||||
function purposeOf(options?: PluginSshConnectOptions): SshConnectPurpose {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -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 (> 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,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",
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 بعد. قم بتكوين اتصالات الأنفاق في مدير المضيف للبدء.",
|
||||
|
||||
@@ -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, за да започнете.",
|
||||
|
||||
@@ -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 টানেল তৈরি করেননি। শুরু করার জন্য হোস্ট ম্যানেজারে টানেল সংযোগগুলি কনফিগার করুন।",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 για να ξεκινήσετε.",
|
||||
|
||||
@@ -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
Reference in new issue
Block a user